Compare commits

...

36 Commits

Author SHA1 Message Date
aewens b7d668cedf Now works for ircv3 server 2019-05-06 18:15:44 -04:00
aewens 579f7df5a5 Fixed passive action bug 2019-04-10 14:12:14 -04:00
aewens 64d0208837 Updating to use tildeverse links and fixes bugs for using bot with lite features 2019-03-07 12:37:55 -05:00
aewens 286c144bdf Merge branch 'master' of ben/babili-bot into master 2019-03-01 11:28:34 -05:00
Ben Harris a20a675e25 fix bbj weburl 2019-03-01 09:32:37 -05:00
aewens 1296a65b39 Fixed bug where non-UTF8 chars break bot 2019-01-18 11:40:21 -05:00
aewens 7a58cd789d Merge branch 'rename-title-task' of aewens/babili-bot into master 2019-01-11 19:21:17 -05:00
Robert Miles 2e870c3796 Change RSS post feed to be "links" instead of "title"
It looks better this way.
2019-01-11 15:56:56 -05:00
aewens 7acbceca8e Fixed RSS formatting, fixed bug in oofscore, fixed bug in core for leaving channels 2019-01-11 11:28:52 -05:00
aewens 675a8099b5 Fixed bug in word score detection, added cosmic voyage to RSS feed 2018-12-10 13:27:09 -05:00
aewens 7d44bfad19 Bug fixes for word scoring 2018-12-04 11:45:18 -05:00
aewens 5246008861 Bug fixes for ping timeout and missing commands in busy channels 2018-11-27 15:23:23 -05:00
aewens f32dce7faf Merge branch 'master' of khuxkm/babili-bot into master 2018-11-26 11:29:48 -05:00
aewens 957160b686 Fixed bug that broke IRC integration with external services 2018-11-26 11:27:24 -05:00
Robert Miles 0d367b0a42 Clean up coroutines/__init__.py
Make a function that returns a lambda to make it clearer what it does.
2018-11-23 22:10:10 -05:00
aewens a28241c718 Fixed bug where ':Closing link:' crashes bot when spoken in chat and bug in thread stopping in tasks 2018-11-15 14:18:41 -05:00
aewens dc5d1ca756 Fixed bug left during debugging, oof 2018-11-12 15:00:25 -05:00
aewens f45531d022 Cleaning up log files 2018-11-12 14:58:06 -05:00
aewens 73eb573f83 Merge branch 'develop' 2018-11-02 22:25:35 -04:00
aewens 721215b013 Various bug fixes 2018-11-02 22:25:23 -04:00
aewens 57419aa78a Refactored to include auto-join channels in config file, now using \r\n message splitting 2018-10-10 12:19:57 -04:00
aewens b56de77a3f Fixed user name bug, made saving memories threading, added oofscoring, added logging 2018-10-01 12:10:25 -04:00
aewens 4e0978b534 Bug fixes to actions/coroutines, added better debug mode 2018-09-26 13:58:30 -04:00
aewens 3749f1d121 Merge branch 'develop' 2018-09-16 00:44:32 -04:00
aewens 219baa47f6 Fixed death-by-trailing-whitespace bug 2018-09-16 00:35:30 -04:00
aewens 2453aa1601 Merge branch 'develop' 2018-09-16 00:28:01 -04:00
aewens d90d1a9567 Bug fixes to handle spam / capital letters 2018-09-16 00:27:38 -04:00
aewens 97cdb98542 Master release candidate 1, added alias to bbj, added RSS support for tilde.news links/comments 2018-09-15 21:27:56 -04:00
aewens 746ed85333 Added pushbullet to summon, added in tasks/coroutines system, added in bbj integration 2018-09-15 20:11:02 -04:00
aewens e7424f690b Added whois action 2018-09-13 21:45:00 -04:00
aewens 626c0c4883 Added year and name into LICENSE, added in most of babili's actions 2018-09-13 15:37:32 -04:00
aewens 27ac672d6d Added responses system, added actions system to react to response triggers 2018-09-11 21:08:45 -04:00
aewens 31e4d6da67 Setup basic framework layout, added more IRC sensor functions 2018-09-10 18:32:21 -04:00
aewens 39ca8287ea Added usermode +B to conform to tildeverse bot RFC 2018-09-10 11:18:59 -04:00
aewens cff5edd86b Merge branch 'enlightenment' into develop 2018-09-10 11:14:57 -04:00
aewens 0cc70c0ee7 Added usermode +B for tildeverse RFC conformity 2018-09-10 11:12:27 -04:00
20 changed files with 1137 additions and 89 deletions

6
.gitignore vendored
View File

@ -1,5 +1,11 @@
# App-specific
settings.json
settings.demo.json
settings.test.json
*.local
data/*.json
logs/*.log*
logs/*.log.*
irc/
# ---> Python

View File

@ -1,4 +1,4 @@
Copyright (c) <year> <owner> . All rights reserved.
Copyright (c) 2018 Austin Ewens. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

88
actions/__init__.py Normal file
View File

@ -0,0 +1,88 @@
from actions.botlist import botlist
from actions.access import banish, pardon
from actions.control import puppet, inject, nomad
from actions.web import summon, whois, how_dare_you
from actions.stupid import score_word, wordscore, wordscoreboard
actions = [
{
"type": "response",
"pattern": "!botlist",
"callback": botlist
},
{
"type": "response",
"pattern": "!rollcall",
"callback": botlist
},
{
"type": "response",
"pattern": "/!summon \S+ .+/",
"callback": summon
},
{
"type": "response",
"pattern": "/!summon \S+$/",
"callback": how_dare_you
},
{
"type": "response",
"pattern": "/!banish \S+ .+/",
"callback": banish
},
{
"type": "response",
"pattern": "/!pardon \S+/",
"callback": pardon
},
{
"type": "response",
"pattern": "/!puppet \S+ .+/",
"callback": puppet
},
{
"type": "response",
"pattern": "/!inject \S+/",
"callback": inject
},
{
"type": "response",
"pattern": "/!nomad \S+ \S+/",
"callback": nomad
},
{
"type": "passive",
"pattern": "/^[^!]*hm+/",
"callback": score_word("hmm", "hm+")
},
{
"type": "response",
"pattern": "/!hmmscore(\s|$)/",
"callback": wordscore("hmm")
},
{
"type": "response",
"pattern": "!hmmscoreboard",
"callback": wordscoreboard("hmm")
},
{
"type": "passive",
"pattern": "/^[^!]*oo+f/",
"callback": score_word("oof", "oo+f")
},
{
"type": "response",
"pattern": "/!oofscore(\s|$)/",
"callback": wordscore("oof")
},
{
"type": "response",
"pattern": "!oofscoreboard",
"callback": wordscoreboard("oof")
},
{
"type": "response",
"pattern": "/!whois \S+/",
"callback": whois
}
]

39
actions/access.py Normal file
View File

@ -0,0 +1,39 @@
from datetime import datetime
def banish(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
user, reason = response.split("!banish ")[1].split(" ", 1)
if name != author:
return
if user not in self.bot.memories["users"]:
self.bot.memories["users"][user] = dict()
self.bot.memories["users"][user]["blacklist"] = {
"reason": reason.strip(),
"when": datetime.now().timestamp()
}
self.bot.thread(self.bot.save_memories)
confirmation = "{} has been banished for reason: {}".format(user, reason)
self.bot.send_message(source, confirmation)
def pardon(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
user = response.split("!pardon ")[1].strip()
if name != author:
return
user_memories = self.bot.memories["users"].get(user, dict())
if user_memories.get("blacklist", None) is not None:
del user_memories["blacklist"]
self.bot.thread(self.bot.save_memories)
confirmation = "{} has been pardoned".format(user)
self.bot.send_message(source, confirmation)

18
actions/botlist.py Normal file
View File

@ -0,0 +1,18 @@
def botlist(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
email = self.bot.settings["email"]
about = "the meta chat bot"
commands = ", ".join([
"!botlist",
"!rollcall",
"!summon",
"!banish",
"!pardon",
"!hmmscore",
"!hmmscoreboard",
"!whois"
])
args = (botnick, author, email, about, commands)
message = "{} | {} <{}> | {} | {}".format(*args)
self.bot.send_message(source, message)

41
actions/control.py Normal file
View File

@ -0,0 +1,41 @@
def puppet(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
command = response.split("!puppet ")[1]
mode, place, message = command.split(" ", 2)
if name != author:
return
modes = {
"say": self.bot.send_message,
"act": self.bot.send_action
}
default = lambda _, msg: self.bot.send_message(source, "Invalid action!")
modes.get(mode, default)(place, message)
def inject(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
command = response.split("!inject ")[1]
if name != author:
return
self.bot.send(command)
def nomad(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
command = response.split("!nomad ")[1]
action, place = command.split(" ", 1)
if name != author:
return
actions = {
"join": self.bot.join,
"leave": self.bot.leave
}
default = lambda p: self.bot.send_message(source, "Invalid action!")
actions.get(action, default)(place)

81
actions/stupid.py Normal file
View File

@ -0,0 +1,81 @@
import re
import operator
def capitalize(word):
return word[0].upper() + word[1:]
def score_word(word, regex):
def wording(self, name, source, response):
check = response.lower().strip()
botnick = self.bot.botnick
pattern = re.compile(regex)
matches = re.findall(pattern, check)
maximum = 10
score = len(matches) if len(matches) <= maximum else maximum
if len(matches) > 1 and len("".join(re.split(pattern, check))) == 0:
return
if name not in self.bot.memories["users"]:
self.bot.memories["users"][name] = dict()
keyword = "{}score".format(word)
if keyword not in self.bot.memories["users"][name]:
self.bot.memories["users"][name][keyword] = 0
current_score = self.bot.memories["users"][name][keyword]
self.bot.memories["users"][name][keyword] = current_score + score
self.bot.thread(self.bot.save_memories)
return wording
def wordscore(word):
def scoring(self, name, source, response):
botnick = self.bot.botnick
score = 0
score_format = "%s score for '{}': {}" % (capitalize(word))
if " " in response:
name = response.split(" ", 1)[1].strip()
if name not in self.bot.memories["users"]:
self.bot.send_message(source, score_format.format(name, score))
return
keyword = "{}score".format(word)
if keyword not in self.bot.memories["users"][name]:
self.bot.send_message(source, score_format.format(name, score))
return
score = self.bot.memories["users"][name][keyword]
self.bot.send_message(source, score_format.format(name, score))
return scoring
def wordscoreboard(word):
def scoreboard(self, name, source, response):
botnick = self.bot.botnick
scores = list()
for user, values in self.bot.memories["users"].items():
scores.append({
"name": user,
"score": values.get("{}score".format(word), 0)
})
size = 3
start = -size
sort_scores = sorted(scores, key=lambda k: k["score"])
top_scores = sort_scores[start:][::-1]
leaders = " | ".join([
"{} {}".format(ts["name"], ts["score"]) for ts in top_scores
])
response = "{} Score Leaderboard: {}".format(capitalize(word), leaders)
self.bot.send_message(source, response)
return scoreboard

106
actions/web.py Normal file
View File

@ -0,0 +1,106 @@
from subprocess import Popen, PIPE
from email.mime.text import MIMEText
from urllib.request import Request, urlopen
from urllib.parse import urlencode
from urllib.error import HTTPError
from json import loads
def get_iden(devices, device_name):
for device in devices:
if device.get("nickname", "") == device_name:
return device.get("iden", "")
def push_note(bot, title, body):
api_url = "https://api.pushbullet.com/v2"
extra_settings = bot.settings.get("extras", dict())
pb_settings = extra_settings.get("pushbullet", dict())
api_key = pb_settings.get("api", "")
device_name = pb_settings.get("device", "")
list_devices = Request("{}/devices".format(api_url))
list_devices.add_header("Access-Token", api_key)
try:
data = loads(urlopen(list_devices).read())
except HTTPError:
return
devices = data.get("devices", list())
iden = get_iden(devices, device_name)
params = {
"device_iden": iden,
"type": "note",
"title": title,
"body": body
}
post_params = urlencode(params).encode()
pushes = Request("{}/pushes".format(api_url), post_params)
pushes.add_header("Access-Token", api_key)
try:
response = loads(urlopen(pushes).read())
except HTTPError as e:
return
def summon(self, name, source, response):
botnick = self.bot.botnick
author = self.bot.author
user, reason = response.split("!summon ")[1].split(" ", 1)
email = "{}@tilde.team"
subject = "You have been summoned!"
text = " ".join([
"My bot, {}, received a summoning request for you".format(botnick),
"from {} in channel {} for reason: {}".format(name, source, reason)
])
message = MIMEText(text)
message["From"] = email.format(botnick)
message["To"] = email.format(user)
message["Subject"] = subject
command = "/usr/sbin/sendmail -t -oi".split(" ")
p = Popen(command, stdin=PIPE, universal_newlines=True)
p.communicate(message.as_string())
if user == author:
push_note(self.bot, subject, text)
confirmation = "{}: You have summoned {}".format(name, user)
self.bot.send_message(source, confirmation)
def how_dare_you(self, name, source, response):
rude = "{}: You think you can just summon someone without a reason? Rude."
self.bot.send_message(source, rude.format(name))
def whois(self, name, source, response):
botnick = self.bot.botnick
domain = response.split("!whois ")[1]
api_url = "https://api.jsonwhoisapi.com/v1/whois"
api_key = self.bot.settings.get("extras", dict()).get("jsonwhoisapi", "")
req = Request("{}?identifier={}".format(api_url, domain))
req.add_header("Authorization", api_key)
try:
data = loads(urlopen(req).read())
except HTTPError:
self.bot.send_message(source, "{} cannot exist".format(domain))
return
registered = data.get("registered", False)
nameservers = len(data.get("nameservers", list())) > 0
self.bot.logger.debug("WHOIS: {}".format(data))
if registered and nameservers:
self.bot.send_message(source, "{} is '{}'".format(domain, "registered"))
elif not (registered or nameservers):
self.bot.send_message(source, "{} is '{}'".format(domain, "available"))
else:
self.bot.send_message(source, "{} might be available".format(domain))

115
app.py
View File

@ -1,12 +1,115 @@
#!/usr/bin/env python3
from bot import Bot
from argparse import ArgumentParser
from os.path import dirname, realpath
bot = Bot("127.0.0.1", 6667, "BabiliBot|py", ["#bots"])
from bot import Bot, Tasks, Responses
from actions import actions
from coroutines import coroutines
def processor(name, source, response):
#bot.send_message(source, "Got response")
print(name, source, response)
parser = ArgumentParser(description="A meta bot for ~team")
parser.add_argument(
"-c",
"--config",
dest="config",
default="settings.json",
help="Load config file"
)
arguments = parser.parse_args()
bot = Bot("localhost", 6667)
responses = Responses(bot)
tasks = Tasks(bot)
tasks.coroutines = coroutines
for action in actions:
if "type" in action and "pattern" in action and "callback" in action:
responses.add_trigger(
action["type"],
action["pattern"],
action["callback"]
)
def try_to_king_me(channel):
bot.send_message("ChanServ", "REGISTER {}", channel)
bot.send_message("ChanServ", "SET Successor {} {}", channel, bot.botnick)
bot.send_message("ChanServ", "SET Founder {} {}", channel, bot.author)
def handle_pm(name, response):
print("PM: {} - {}".format(name, response))
def handle_mode(channel, mode):
if mode == "-r":
try_to_king_me(channel)
def handle_invite(channel, name):
changed = False
kingme = bot.settings.get("extras", dict()).get("kingme", [])
if channel in kingme:
try_to_king_me(channel)
users = bot.memories["users"]
if name not in users:
bot.memories["users"][name] = dict()
changed = True
if "invites" not in users[name]:
bot.memories["users"][name]["invites"] = list()
changed = True
if channel not in bot.memories["users"][name]["invites"]:
bot.memories["users"][name]["invites"].append(channel)
changed = True
#if changed:
# bot.thread(bot.save_memories)
def handle_kick(name, source):
if source in bot.settings.get("extras", dict()).get("rejoin", list()):
bot.join(source)
users = bot.memories["users"]
if name not in users:
bot.memories["users"][name] = dict()
bot.memories["users"][name]["kicker"] = True
#bot.thread(bot.save_memories)
def handle_message(name, source, response):
responses.parse(name, source, response)
if response == "!debug":
bot.logger.debug(":: {}".format(bot.memories))
def handle_raw(message):
if "KICK #chaos {} :".format(bot.author) in message:
bot.send("INVITE {} :#chaos".format(bot.author))
def handle_crashed():
bot.logger.debug("Rebooting")
bot.crashed = True
bot.tasks.stop()
tasks = Tasks(bot)
tasks.coroutines = coroutines
bot.tasks = tasks
bot.start(arguments.config, dirname(realpath(__file__)), {
"pm": handle_pm,
"mode": handle_mode,
"invite": handle_invite,
"kick": handle_kick,
"crashed": handle_crashed,
"message": handle_message
})
if __name__ == "__main__":
bot.start(processor, "settings.json")
bot.tasks = tasks
bot.start(arguments.config, dirname(realpath(__file__)), {
"pm": handle_pm,
"mode": handle_mode,
"invite": handle_invite,
"kick": handle_kick,
"crashed": handle_crashed,
"message": handle_message,
"raw": handle_raw
})

22
bot.scm
View File

@ -1,22 +0,0 @@
(use tcp irregex ports srfi-13 srfi-14 srfi-69)
(define bot-nick "BabiliBot|scm")
(define (send out message)
(format out (string-append message "\r\n")))
(let-values
([(in out)
(tcp-connect "127.0.0.1" 6667)])
(send out
(string-append
"USER "
(string-join (vector->list (make-vector 4 bot-nick)) " ")))
(send out (string-append "NICK " bot-nick))
(let loop
([line (read-line in)])
(if (eof-object? line)
line
(begin
(print line)
(loop (read-line in))))))

View File

@ -1 +1,3 @@
from bot.core import Bot
from bot.core import Bot
from bot.tasks import Tasks
from bot.responses import Responses

View File

@ -1,113 +1,290 @@
import re
import sys
import ssl
import time
import json
import socket
import os.path
import logging
from threading import Thread
from logging.handlers import TimedRotatingFileHandler
logging.basicConfig(
level=logging.DEBUG,
format="[%(levelname)s] [%(asctime)s] >> \n%(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
class Bot:
def __init__(self, server, port, botnick, channels):
def __init__(self, server, port, secure=False):
self.ircsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.logger = logging.getLogger("")
self.server = server
self.port = port
self.botnick = botnick
self.channels = channels
self.secure = secure
self.channels = []
self.running = True
self.crashed = False
self.users = dict()
self.kickers = list()
self.settings = dict()
self.places = list()
self.tasks = None
self.author = ""
self.botnick = ""
self.recv_size = 2048
self.splitter = "\r\n"
if self.secure:
self.ircsock = ssl.wrap_socket(self.ircsock)
def send(self, message, *args):
response = message.format(*args) + "\n"
password = self.settings.get("password", None)
if password is not None:
self.logger.info(response.replace(password, "*" * len(password)))
else:
self.logger.info(response)
print("DEBUG: ", response)
self.ircsock.send(response.encode())
def send_message(self, target, message, *args):
msg = message.format(*args)
response = "PRIVMSG {0} :{1}".format(target, msg) + "\n"
password = self.settings.get("password", None)
if password is not None:
self.logger.info(response.replace(password, "*" * len(password)))
else:
self.logger.info(response)
print("DEBUG: ", response)
self.ircsock.send(response.encode())
try:
self.ircsock.send(response.encode())
except BrokenPipeError:
self.stop()
def send_action(self, target, message, *args):
self.send_message(target, "\001ACTION {}\001".format(message), *args)
def join(self, chan):
self.send("JOIN {}", chan)
self.places.append(chan)
message = ""
magic_string = "End of /NAMES list."
banned = "Cannot join channel (You're banned)"
start = time.time()
while magic_string not in message:
message = self.ircsock.recv(self.recv_size).decode()
message = message.strip("\n\r")
print(message)
# Taking too long, escaping JOIN request
if time.time() - start == 2000:
return
try:
message = self.ircsock.recv(self.recv_size).decode()
if banned in message:
self.places.remove(chan)
return
# message = message.strip(self.splitter)
#self.logger.debug(message)
except UnicodeDecodeError:
continue
user_list = "= {} :".format(chan)
raw_users = message.split(user_list)[1].split(" \r\n")[0].split(" ")
prefix_filter = lambda u: u[1:] if "~" in u or "@" in u else u
users = list(filter(prefix_filter, raw_users))
list_pattern = re.compile("[@=] {} :".format(chan))
user_listing = re.split(list_pattern, message)
if len(user_listing) < 2:
print("DEBUG: Skipping adding users from {}".format(chan))
return
splitter = " {}".format(self.splitter)
raw_users = user_listing[1].split(splitter)[0].split(" ")
users = list(filter(self.parse_name, raw_users))
remember = self.memories["users"]
for user in users:
if user not in self.users:
self.users[user] = dict()
user = self.parse_name(user)
if user not in remember:
self.memories["users"][user] = dict()
def leave(self, chan):
message = "PART {} :Bye-bye!"
self.send(message, chan)
if chan in self.places:
self.places.remove(chan)
def ping(self, message):
response = message.split("PING :")[1]
self.send("PONG :{0}", response)
def get_name(self, text):
return text.split("!", 1)[0][1:]
def parse_name(self, name):
if len(name) == 0 or name is None:
return name
if name[0] in ["~", "@", "+", "%"]:
return name[1:]
else:
return name
def parse(self, message):
before, after = message.split("PRIVMSG ", 1)
name = before.split("!", 1)[0][1:]
name = self.parse_name(self.get_name(before))
source, response = after.split(" :", 1)
return name, source, response
def track_rename(self, message):
before, new_name = message.split("NICK ", 1)
name = before.split("!", 1)[0][1:]
user = self.users[name]
del self.users[name]
self.users[new_name] = user
def handle_mode(self, message):
before, after = message.split("MODE ", 1)
name = self.parse_name(self.get_name(before))
channel, mode = after.split(" ")[:2]
return channel, mode
def answer_invite(self, message):
def handle_rename(self, message):
before, new_name = message.split("NICK ", 1)
name = self.get_name(before)
new_name = self.parse_name(new_name)
name = self.parse_name(name)
user = self.memories["users"][name]
self.memories["users"][new_name] = user
del self.memories["users"][name]
return user, new_name
def handle_invite(self, message):
before, after = message.split("INVITE ", 1)
name = self.parse_name(self.get_name(before))
channel = after.split(":", 1)[1]
self.join(channel)
return channel, name
def log_kick(self, message):
# :aewens!aewens@rightful.heir.to.chaos KICK #insane BabiliBot|py :aewens
regex = "KICK #\S+ {} :".format(self.botnick)
before, kicker = re.split(regex, message)
self.kickers.append(kicker)
def handle_kick(self, message):
before, after = message.split("KICK ", 1)
source = after.split(" ", 1)[0]
name = self.parse_name(self.get_name(before))
return name, source
def handle_join(self, message):
before, after = message.split("JOIN ", 1)
user = self.parse_name(self.get_name(before))
if user not in self.memories["users"]:
self.memories["users"][user] = dict()
return user
def handle_part(self, message):
before, after = message.split("PART ", 1)
user = self.parse_name(self.get_name(before))
return user
def load_memories(self, location):
path = "{}/{}".format(self.location, location)
self.memories_path = path
if not os.path.isfile(path):
self.memories = {
"users": dict()
}
else:
with open(path, "r") as f:
self.memories = json.loads(f.read())
def thread(self, fn, *args):
t = Thread(target=fn, args=args)
t.start()
def save_memories(self):
with open(self.memories_path, "w") as f:
try:
f.write(json.dumps(self.memories))
except ValueError as e:
f.write("")
def load_settings(self, location):
with open(location, "r") as f:
set_vars = [
"author",
"botnick",
"channels"
]
path = "{}/{}".format(self.location, location)
with open(path, "r") as f:
self.settings = json.loads(f.read())
def stop(self):
self.running = False
self.send("QUIT")
for name, attr in self.settings.items():
if name in set_vars:
setattr(self, name, attr)
def start(self, callback, settings):
def stop(self):
self.send("QUIT :Overheating, powering down")
self.ircsock.close()
self.running = False
def start(self, config, location, callback):
message = ""
registered = False
confirmed = True
self.ircsock.connect((self.server, self.port))
self.location = location
self.load_settings(config)
self.load_memories("data/memories.json")
logfile = "{}/logs/{}.log".format(self.location, self.botnick)
logfmt = "[%(levelname)s] [%(asctime)s] >> \n%(message)s"
datefmt = "%Y-%m-%d %H:%M:%S"
logger = TimedRotatingFileHandler(logfile, "midnight", 1)
logger.setLevel(logging.DEBUG)
logger.setFormatter(logging.Formatter(logfmt, datefmt))
self.logger.addHandler(logger)
try:
self.ircsock.connect((self.server, self.port))
except ConnectionRefusedError:
sys.exit(1)
self.send("USER {0} {0} {0} {0}", self.botnick)
self.send("NICK {0}", self.botnick)
self.load_settings(settings)
password = self.settings["password"] or ""
confirm = self.settings["confirm"] or ""
email = self.settings["email"] or ""
magic_string = "MODE {} +r".format(self.botnick)
while magic_string not in message:
message = self.ircsock.recv(self.recv_size).decode()
message = message.strip("\n\r")
print(message)
if not registered and "Password accepted" in message:
magic_phrase = {
"has_registered": "Password accepted",
"needs_to_register": "choose a different nick",
"needs_to_confirm": "Your account will expire",
"not_registered": "Your nickname is not registered"
#"ready_to_id": "is now your displayed host",
#"nickserv_missing": "No such nick/channel"
}
authenticate = len(password) > 0 and len(confirm) > 0
magic_string = "MODE {} :+r".format(self.botnick)
while magic_string not in message and authenticate:
try:
message = self.ircsock.recv(self.recv_size).decode()
except UnicodeDecodeError:
continue
message = message.strip(self.splitter)
#self.logger.debug(message)
if not registered and magic_phrase["has_registered"] in message:
registered = True
if not registered and "choose a different nick" in message:
if not registered and magic_phrase["needs_to_register"] in message:
self.send_message("NickServ", "IDENTIFY {}", password)
if not confirmed and "Your account will expire" in message:
if not confirmed and magic_phrase["needs_to_confirm"] in message:
self.send_message("NickServ", "CONFIRM {}", self.confirm)
confirmed = True
if not registered and magic_phrase["not_registered"] in message:
break
#if not registered and magic_phrase["ready_to_id"] in message:
# self.send_message("NickServ", "IDENTIFY {}", password)
#if not registered and magic_phrase["nickserv_missing"] in message:
# break
if not authenticate:
time.sleep(3)
self.send("MODE {} +B".format(self.botnick))
print("DEBUG: Joining")
@ -116,17 +293,72 @@ class Bot:
print("DEBUG: Joined")
while self.running:
message = self.ircsock.recv(self.recv_size).decode()
message = message.strip("\n\r")
print(message)
if self.tasks is not None:
if getattr(self.tasks, "run", None) is not None:
self.tasks.run()
if "PING :" in message:
self.ping(message)
elif "NICK " in message:
self.track_rename(message)
elif "INVITE " in message:
self.answer_invite(message)
elif "PRIVMSG" in message:
name, source, response = self.parse(message)
callback(name, source, response)
while self.running:
_message = ""
while self.splitter not in _message:
try:
_message = self.ircsock.recv(self.recv_size).decode()
except UnicodeDecodeError:
continue
if "raw" in callback:
callback["raw"](_message)
_message = _message.strip(self.splitter)
messages = [msg for msg in _message.split(self.splitter) if msg]
for message in messages:
if message[:4] == "PING":
self.ping(message)
if "ping" in callback:
callback["ping"]()
elif "PRIVMSG " in message:
name, source, response = self.parse(message)
self.logger.debug(message)
if source == self.botnick and "pm" in callback:
callback["pm"](name, response)
elif "message" in callback:
callback["message"](name, source, response)
elif "MODE " in message:
channel, mode = self.handle_mode(message)
self.logger.debug(message)
if "mode" in callback:
callback["mode"](channel, mode)
elif "NICK " in message:
old_name, new_name = self.handle_rename(message)
self.logger.debug(message)
if "nick" in callback:
callback["nick"](old_name, new_name)
#elif "KICK " in message:
# kicker, source = self.handle_kick(message)
# self.logger.debug(message)
# if "kick" in callback:
# callback["kick"](kicker, source)
elif "JOIN " in message:
user = self.handle_join(message)
self.logger.debug(message)
if "join" in callback:
callback["join"](user)
elif "PART " in message:
user = self.handle_part(message)
self.logger.debug(message)
if "part" in callback:
callback["part"](user)
elif "INVITE " in message:
channel, name = self.handle_invite(message)
self.logger.debug(message)
if "invite" in callback:
callback["invite"](channel, name)
elif "unhandled" in callback:
if "unhandled" in callback:
callback["unhandled"](message)
elif ":Closing link:" in message:
self.logger.warning(message)
self.logger.error("Activing crash mode")
if "crashed" in callback:
callback["crashed"]()
break

92
bot/responses.py Normal file
View File

@ -0,0 +1,92 @@
import re
from datetime import datetime
class Responses:
def __init__(self, bot):
self.bot = bot
self.triggers = {
"name": dict(),
"source": dict(),
"response": dict(),
"passive": dict()
}
def add_trigger(self, trigger_type, pattern, callback):
if trigger_type in self.triggers:
self.triggers[trigger_type][pattern] = callback
def allowed(self, name, source):
memories = self.bot.memories
users = memories["users"]
if name not in users:
return False
last_response = 0
if "last_response" in self.bot.memories["users"][name]:
last_response = self.bot.memories["users"][name]["last_response"]
now = datetime.now().timestamp()
author = self.bot.author
wait = 1
if name != author and last_response > 0 and now - last_response < wait:
reason = "Auto-banished"
self.bot.memories["users"][name]["blacklist"] = {
"reason": reason,
"when": now
}
message = "is ignoring {} for reason '{}'".format(name, reason)
self.bot.send_action(source, message)
return False
return True
def log(self, name, trigger):
if trigger != "response":
return
now = datetime.now().timestamp()
self.bot.memories["users"][name]["last_response"] = now
def parse(self, name, source, response):
users = self.bot.memories["users"]
if name not in users:
return False
check = response.lower().strip()
trig = {
"name": name,
"source": source,
"response": response.lower().strip(),
"passive": response.lower().strip()
}
for trigger in list(self.triggers.keys()):
for pattern, callback in self.triggers[trigger].items():
if pattern[0] != "/" and pattern[-1] != "/":
if pattern == check:
if self.allowed(name, source):
self.log(name, trigger)
callback(self, name, source, response)
elif "blacklist" in users[name]:
reason = users[name]["blacklist"]["reason"]
message = "You were banished for reason '{}'"
message = message.format(reason)
#self.bot.send_message(name, message)
return False
elif trigger != "passive":
regex = re.compile(pattern[1:-1])
if regex.match(trig[trigger]) is not None:
if self.allowed(name, source):
self.log(name, trigger)
callback(self, name, source, response)
elif "blacklist" in users[name]:
reason = users[name]["blacklist"]["reason"]
message = "You were banished for reason '{}'"
message = message.format(reason)
#self.bot.send_message(name, message)
return False
else:
regex = re.compile(pattern[1:-1])
if regex.match(trig[trigger]) is not None:
callback(self, name, source, response)

48
bot/tasks.py Normal file
View File

@ -0,0 +1,48 @@
import time
import sched
from threading import Thread
class Tasks:
def __init__(self, bot):
self.bot = bot
self.scheduler = sched.scheduler(time.time, time.sleep)
self.thread = Thread(target=self.worker, args=(self,))
self.coroutines = list()
self.states = dict()
self.halt = False
def periodic(self, scheduler, interval, action, index, state=dict()):
if self.halt:
return
self.states[index] = action(state)
scheduler.enter(interval, 1, self.periodic, (
scheduler, interval, action, index, self.states[index]
))
def worker(self, tasks):
for c, coro in enumerate(tasks.coroutines):
interval = coro["interval"]
worker = coro["worker"]
state = coro.get("state", dict())
state["bot"] = tasks.bot
tasks.periodic(tasks.scheduler, interval, worker, c, state)
tasks.scheduler.run()
def add_coroutine(self, worker, interval, state=dict()):
self.coroutines.append({
"worker": worker,
"interval": interval,
"state": state
})
def stop(self):
self.halt = True
list(map(self.scheduler.cancel, self.scheduler.queue))
for key, value in self.states.items():
self.states[key] = False
self.thread.join()
def run(self):
self.thread.daemon = True
self.thread.start()

44
coroutines/__init__.py Normal file
View File

@ -0,0 +1,44 @@
from coroutines.bbj import BBJ
from coroutines.rss import RSS
# {
# "worker": test,
# "interval": 3
# }
# def test(bot):
# print("Testing {}".format(bot.botnick))
def use(cls):
return lambda state: cls(state).start()
coroutines = [
{
"worker": use(BBJ),
"interval": 5,
"state": {
"alias": "bbj",
"source": "http://localhost:7099/api",
"channels": ["#team", "#tildeverse"]
}
},
{
"worker": use(RSS),
"interval": 6,
"state": {
"alias": "links",
"source": "https://tilde.news/newest.rss",
"use": "title",
"channels": ["#meta", "#tildeverse"]
}
},
{
"worker": use(RSS),
"interval": 8,
"state": {
"alias": "links-comments",
"source": "https://tilde.news/comments.rss",
"use": "description",
"channels": ["#tildeverse"]
}
}
]

89
coroutines/bbj.py Normal file
View File

@ -0,0 +1,89 @@
from urllib.request import Request, urlopen
from urllib.parse import urlencode
from urllib.error import HTTPError
from datetime import datetime
from json import loads, dumps
from re import sub
class BBJ:
def __init__(self, state):
self.name = "BBJ"
self.bot = state["bot"]
self.alias = state["alias"]
self.source = state["source"]
self.channels = state["channels"]
self.memory = state.get("memory", {
"initialized": False,
"known": dict()
})
def start(self):
if not self.memory["initialized"]:
self.memory["initialized"] = True
self.fetch(self.cache)
return self.run()
def run(self):
self.fetch(self.mirror)
return {
"bot": self.bot,
"alias": self.alias,
"source": self.source,
"channels": self.channels,
"memory": self.memory
}
def cache(self, item):
self.memory["known"][item["thread_id"]] = item["last_mod"]
def process_thread(self, thread_id, thread):
data = thread.get("data", dict())
title = data.get("title", "")
replies = data.get("reply_count", "")
messages = data.get("messages", "")
usermap = thread.get("usermap", dict())
reply = messages[replies]
author = reply.get("author", "")
username = usermap[author].get("user_name", "")
body = reply.get("body", "")
body = sub(r">>\d\n\n", r"", body)
body = sub(r"\n", r" ", body)
php = "https://bbj.tildeverse.org/"
link = "{}?thread_id={}".format(php, thread_id)
for channel in self.channels:
response = "'{}' ({}) : {} <{}>".format(title, username, body, link)
message = "[{}] {}".format(self.alias, response)
self.bot.send_message(channel, message)
def get_thread(self, thread_id, callback):
params = {
"thread_id": thread_id
}
post_params = str(dumps(params)).encode()
thread_load = Request("{}/thread_load".format(self.source), post_params)
thread_load.add_header("Content-Type", "application/json")
try:
response = callback(thread_id, loads(urlopen(thread_load).read()))
except HTTPError:
return
def mirror(self, item):
thread_id = item["thread_id"]
last_mod = self.memory["known"][thread_id]
if last_mod == item["last_mod"]:
return
self.memory["known"][thread_id] = item["last_mod"]
self.get_thread(thread_id, self.process_thread)
def fetch(self, callback):
thread_index = Request("{}/thread_index".format(self.source))
try:
response = loads(urlopen(thread_index).read())
threads = response.get("data", dict())
for thread in threads:
callback(thread)
except HTTPError:
return

77
coroutines/rss.py Normal file
View File

@ -0,0 +1,77 @@
from xml.etree import ElementTree as etree
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from json import loads, dumps
from re import sub
class RSS:
def __init__(self, state):
self.name = "RSS"
self.bot = state["bot"]
self.alias = state["alias"]
self.use = state["use"]
self.source = state["source"]
self.channels = state["channels"]
self.memory = state.get("memory", {
"initialized": False,
"known": list()
})
def start(self):
if not self.memory["initialized"]:
self.memory["initialized"] = True
self.fetch(self.cache)
return self.run()
def run(self):
self.fetch(self.mirror)
return {
"bot": self.bot,
"alias": self.alias,
"use": self.use,
"source": self.source,
"channels": self.channels,
"memory": self.memory
}
def cache(self, item):
guid = item.findtext("guid", None)
if guid is not None:
self.memory["known"].append(guid)
def mirror(self, item):
guid = item.findtext("guid", None)
if guid is None:
return
if guid in self.memory["known"]:
return
self.memory["known"].append(guid)
use = sub(r"(<\/?[^>]+>)|\n", "", item.findtext(self.use, ""))
user = item.findtext("author", "").split("@")[0]
metadata = "(posted by {}) <{}>".format(user, guid)
header = "[{}] {}".format(self.alias, use)
splitter = " "
max_size = 450 - len(splitter)
if len(header) + len(metadata) >= max_size:
header_size = max_size - len(metadata)
header = header[:header_size]
response = "{}{}{}".format(header, splitter, metadata)
for channel in self.channels:
self.bot.send_message(channel, response)
def fetch(self, callback):
req = Request(self.source)
try:
response = urlopen(req).read()
except HTTPError:
return
except URLError:
return
feed = etree.fromstring(response)
items = feed.findall("channel/item")
for item in items:
callback(item)

0
data/.gitkeep Normal file
View File

0
logs/.gitkeep Normal file
View File

8
settings.example.json Normal file → Executable file
View File

@ -1,5 +1,9 @@
{
"botnick": "",
"password": "",
"email": "",
"confirm": ""
}
"confirm": "",
"author": "",
"channels": [],
"extras": {}
}