From aab637dfe944239a78986b3ac137a7651e248b47 Mon Sep 17 00:00:00 2001 From: Thorsten S Date: Wed, 3 Oct 2018 17:23:26 +0200 Subject: [PATCH 1/1] init (playing squash like a pro) --- .gitignore | 4 + distbot/__init__.py | 1 + distbot/bot/__init__.py | 1 + distbot/bot/action_worker.py | 151 +++++ distbot/bot/bot.py | 229 +++++++ distbot/bot/worker.py | 135 +++++ distbot/common/__init__.py | 1 + distbot/common/action.py | 65 ++ distbot/common/config.py | 80 +++ distbot/common/config/local_config.ini.spec | 17 + .../common/config/persistent_config.ini.spec | 42 ++ distbot/common/message.py | 67 +++ distbot/common/utils.py | 149 +++++ distbot/doc/README.md | 23 + distbot/plugins/__init__.py | 1 + distbot/plugins/basic.py | 295 ++++++++++ distbot/plugins/bofh.py | 510 ++++++++++++++++ distbot/plugins/bots.py | 9 + distbot/plugins/bugtracker.py | 102 ++++ distbot/plugins/extended.py | 10 + distbot/plugins/feeds.py | 137 +++++ distbot/plugins/fun.py | 175 ++++++ distbot/plugins/lookup.py | 194 ++++++ distbot/plugins/meta.py | 57 ++ distbot/plugins/morse.py | 175 ++++++ distbot/plugins/muc.py | 93 +++ distbot/plugins/plugin_help.py | 88 +++ distbot/plugins/queue_management.py | 5 + distbot/plugins/searx.py | 114 ++++ distbot/plugins/translation.py | 557 ++++++++++++++++++ distbot/plugins/url.py | 77 +++ setup.py | 18 + 32 files changed, 3582 insertions(+) create mode 100755 .gitignore create mode 100755 distbot/__init__.py create mode 100755 distbot/bot/__init__.py create mode 100755 distbot/bot/action_worker.py create mode 100755 distbot/bot/bot.py create mode 100755 distbot/bot/worker.py create mode 100755 distbot/common/__init__.py create mode 100755 distbot/common/action.py create mode 100755 distbot/common/config.py create mode 100755 distbot/common/config/local_config.ini.spec create mode 100755 distbot/common/config/persistent_config.ini.spec create mode 100755 distbot/common/message.py create mode 100755 distbot/common/utils.py create mode 100755 distbot/doc/README.md create mode 100755 distbot/plugins/__init__.py create mode 100755 distbot/plugins/basic.py create mode 100755 distbot/plugins/bofh.py create mode 100755 distbot/plugins/bots.py create mode 100755 distbot/plugins/bugtracker.py create mode 100755 distbot/plugins/extended.py create mode 100755 distbot/plugins/feeds.py create mode 100755 distbot/plugins/fun.py create mode 100755 distbot/plugins/lookup.py create mode 100755 distbot/plugins/meta.py create mode 100755 distbot/plugins/morse.py create mode 100755 distbot/plugins/muc.py create mode 100755 distbot/plugins/plugin_help.py create mode 100755 distbot/plugins/queue_management.py create mode 100755 distbot/plugins/searx.py create mode 100755 distbot/plugins/translation.py create mode 100755 distbot/plugins/url.py create mode 100755 setup.py diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..1991284 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +local_config.ini +persistent_config.ini +.idea +distbot.egg-info diff --git a/distbot/__init__.py b/distbot/__init__.py new file mode 100755 index 0000000..40a96af --- /dev/null +++ b/distbot/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/distbot/bot/__init__.py b/distbot/bot/__init__.py new file mode 100755 index 0000000..40a96af --- /dev/null +++ b/distbot/bot/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/distbot/bot/action_worker.py b/distbot/bot/action_worker.py new file mode 100755 index 0000000..429b07f --- /dev/null +++ b/distbot/bot/action_worker.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +import json +import logging +import sched +import threading +import time + +import pika + +from distbot.common.action import Action, send_action +from distbot.common.config import conf_set, conf_get, log_format +from distbot.common.message import process_message + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +EVENTLOOP_DELAY = 0.1 + + +class EventLoop(threading.Thread): + event_list = None + + def __init__(self, event_list): + super().__init__() + self.event_list = event_list + + def run(self): + while 1: + self.event_list.run(False) + time.sleep(EVENTLOOP_DELAY) + + +class ActionThread(threading.Thread): + event_list = sched.scheduler(time.time, time.sleep) + + def __init__(self, bot): + super().__init__() + self.event_thread = EventLoop(self.event_list) + self.bot = bot + # TODO: same in worker, check if that is waste + self.busy = False + + connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost')) + self.channel = connection.channel() + + def callback(self, ch, method, properties, body): + body = json.loads(body.decode("utf-8")) + self.busy = True + + action = self.parse_body(body) + self.run_action(action) + + self.busy = False + logger.debug("Done") + ch.basic_ack(delivery_tag=method.delivery_tag) + + @staticmethod + def parse_body(body): + action = Action.deserialize(body) + return action + + def run(self): + logger.debug("Processing actions in queue %s", self.bot.actionqueue) + self.event_thread.start() + self.channel.basic_consume(self.callback, queue=self.bot.actionqueue) + self.channel.start_consuming() + + def die(self): + self.channel.stop_consuming() + + def find_scheduled_action_by_mutex(self, mutex): + action_item = None + for item in self.event_list.queue: + if item.kwargs["action"].mutex == mutex: + action_item = item + break + return action_item + + def schedule_action(self, event): + """ + :type event: Action + """ + # TODO: mutex handling + logger.info("scheduling event: %s", event.serialize()) + if event.mutex and self.find_scheduled_action_by_mutex(event.mutex): + logger.info("not scheduling that event (prevented by mutex)") + raise RuntimeError("not scheduling that event (prevented by mutex)") + + self.event_list.enterabs( + event.time, 0, send_action, + kwargs={'actionqueue': self.bot.actionqueue, 'action': event} + ) + + def unschedule_action(self, event): + """ + Remove a scheduled action + :type event: Action + """ + item = self.find_scheduled_action_by_mutex(event.mutex) + if item: + self.event_list.cancel(item) + + def run_action(self, action): + logger.debug("Executing action %s", action.serialize()) + + # to prevent really stupid clients not scrolling at incoming messages very quickly. + delay = 0.5 + try: + if action.event: + if action.event.stop_event: + self.unschedule_action(action.event) + else: + self.schedule_action(action.event) + except RuntimeError as e: + action.msg = "Warning: {}\nOriginal message: {}".format(e, action.msg) + + # indirect execution + if action.command: + replay_body = json.dumps({ + "from": None, + "to": None, + "body": ' '.join(action.command.split('.')[1:]), + }) + process_message(routing_key=action.command, body=replay_body) + + if action.msg: # and rate_limit(RATE_CHAT | plugin.ratelimit_class): + time.sleep(delay) + self.bot.echo(action.msg) + + # TODO test that + if action.priv_msg: # and rate_limit(RATE_CHAT | plugin.ratelimit_class): + self.bot.echo(action['priv_msg'], action.sender) + + if action.presence: + conf_set('presence', action.presence) + # TODO implement + + # self.status = action.presence.get('msg') + # self.show = action.presence.get('status') + # + # self.send_presence(pstatus=self.status, pshow=self.show) + # self.reconnect(wait=True) + + request_counter = int(conf_get('request_counter')) + conf_set('request_counter', request_counter + 1) + diff --git a/distbot/bot/bot.py b/distbot/bot/bot.py new file mode 100755 index 0000000..b640c03 --- /dev/null +++ b/distbot/bot/bot.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +import json +import logging +import shlex + +import pika +import sleekxmpp + +from distbot.bot import action_worker, worker as worker_mod +from distbot.common.config import conf_get, log_format +from distbot.common.message import process_message, get_nick_from_message +from distbot.plugins import basic, fun, lookup, url, feeds, muc, translation, searx, queue_management, plugin_help, \ + morse, meta, \ + extended, bugtracker, bots, bofh + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + +logging.getLogger("pika").setLevel(logging.WARN) +logging.getLogger('sleekxmpp').setLevel(logging.INFO) + +WORKER_QUEUE = "work" + +PLUGIN_MODULES = { + basic: basic.ALL, + bofh: bofh.ALL, + bots: bots.ALL, + bugtracker: bugtracker.ALL, + extended: extended.ALL, + feeds: feeds.ALL, + fun: fun.ALL, + lookup: lookup.ALL, + meta: meta.ALL, + morse: morse.ALL, + muc: muc.ALL, + plugin_help: plugin_help.ALL, + queue_management: queue_management.ALL, + searx: searx.ALL, + translation: translation.ALL, + url: url.ALL, +} + + +class Bot(sleekxmpp.ClientXMPP): + def __init__(self, jid, password, rooms, nick): + super(Bot, self).__init__(jid, password) + + self.actionqueue = 'action_processing' + self.actionthread = None + self.job_workers = [] + self.workers = [] + + self.rooms = rooms + self.nick = nick + + self.add_event_handler('session_start', self.session_start) + self.add_event_handler('groupchat_message', self.muc_message) + for room in self.rooms: + self.add_event_handler('muc::%s::got_online' % room, self.muc_online) + + self._initialize_plugins() + import ssl + self.ssl_version = ssl.PROTOCOL_TLSv1_2 + + def _initialize_plugins(self): + self.register_plugin('xep_0045') + self.register_plugin('xep_0199', {'keepalive': True}) + self.register_plugin('xep_0308') + + def kill_workers(self): + [t.die() for t in self.workers + self.job_workers] + [t.join() for t in self.workers + self.job_workers] + if self.actionthread: + self.actionthread.die() + self.actionthread.join() + + def initialize_workers(self): + connection = pika.BlockingConnection( + pika.ConnectionParameters('localhost') + ) + channel = connection.channel() + channel.exchange_declare(exchange='topic_command', exchange_type='topic') + channel.exchange_declare(exchange='topic_parse', exchange_type='topic') + channel.queue_declare(queue=WORKER_QUEUE, durable=True) + + for classes in PLUGIN_MODULES.values(): + for cls in classes: + try: + worker = cls(actionqueue=self.actionqueue) + self.job_workers.append(worker) + worker.start() + except Exception as e: + # Whoopsie. Thatz a broken thing. + raise SystemExit() + + def initialize_actionthreads(self): + + self.actionthread = action_worker.ActionThread(bot=self) + self.actionthread.start() + + # TODO: doesnt work... + def reset_all_workerthreads(self): + from importlib import reload + + [t.die() for t in self.job_workers] + [t.join() for t in self.job_workers] + + self.actionthread.die() + self.actionthread.join() + + reload(worker_mod) + reload(action_worker) + + for module in PLUGIN_MODULES.keys(): + reload(module) + + self.initialize_workers() + self.initialize_actionthreads() + self.echo("code blue") + + # TODO: doesn't stop + def disconnect(self, reconnect=False, wait=None, send_close=True): + logger.info("Stopping all workers...") + self.kill_workers() + logger.info("Stopping self...") + super(Bot, self)._disconnect(reconnect, wait=False, send_close=True) + logger.info("Gudbai...") + raise SystemExit() + + def session_start(self, _): + self.get_roster() + self.send_presence(ppriority=0, pstatus=None, pshow=None) + for room in self.rooms: + logger.info('%s: joining' % room) + ret = self.plugin['xep_0045'].joinMUC( + room, + self.nick, + wait=True + ) + logger.info('%s: joined with code %s' % (room, ret)) + self.initialize_workers() + self.initialize_actionthreads() + + def muc_message(self, msg): + if msg['mucnick'] == self.nick or 'groupchat' != msg['type']: + return False + return self.message(msg) + + def muc_online(self, msg): + sender = get_nick_from_message(msg) + if sender == self.nick: + return + process_message( + 'userjoin.{}'.format(msg["from"]), + body=json.dumps({ + "from": msg["from"].jid, + "to": msg["to"].jid, + "body": msg["body"].strip() + }) + ) + + def message(self, msg): + logger.debug("msg is " + str(msg)) + logger.debug("from: " + str(msg['from'])) + if msg['type'] in ('normal', 'chat', 'groupchat'): + message = msg['body'] + + if "hangup" in message: + self.disconnect() + + if "reset-workers" in message: + self.echo("No, please... !") + [t.die() for t in self.job_workers] + [t.join() for t in self.job_workers] + self.initialize_workers() + self.echo("argh...") + + if "reset-worker-one" in message: + self.echo("But I was your fav..ARGH") + self.actionthread.die() + self.actionthread.join() + self.initialize_actionthreads() + self.echo("Worker One available for master.") + + key = shlex.split(msg["body"]) + + # cut the nick from the message + offset = 0 + if self.nick in key[0]: + key.pop(0) + key.insert(0, "nick") + offset = len(self.nick) + 1 + + process_message( + routing_key='.'.join(key), + body=json.dumps({ + "from": msg["from"].jid, + "to": msg["to"].jid, + "body": msg["body"][offset:].strip() + }) + ) + + def echo(self, body, room=None): + if not room: + rooms = self.rooms + else: + rooms = [room] + + for r in rooms: + self.send_message( + mto=r, + mbody=body, + mtype='groupchat' + ) + + +def run(): + bot = Bot(jid=conf_get("jid"), password=conf_get("password"), rooms=conf_get("rooms"), + nick=conf_get("bot_nickname")) + bot.connect() + bot.process() + + +if __name__ == '__main__': + run() diff --git a/distbot/bot/worker.py b/distbot/bot/worker.py new file mode 100755 index 0000000..647028e --- /dev/null +++ b/distbot/bot/worker.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +import json +import logging +import threading +from collections import deque, defaultdict + +import pika +from functools import partial + +from distbot.common.action import Action, send_action +from distbot.common.config import conf_get, log_format + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +class Worker(threading.Thread): + binding_keys = [] + description = "" + usage = "" + + # all room's history + total_history = None + # set to current room + history = None + uses_history = False + + # if using history, a plugin needs to receive the previous messages + CATCH_ALL = ["#"] + + def __init__(self, actionqueue, queue="work"): + super().__init__() + self.queue = queue + self.actionqueue = actionqueue + if self.uses_history: + deque5 = partial(deque, maxlen=5) + self.total_history = defaultdict(deque5) + + if not self.usage: + cmds = [] + for key in self.binding_keys: + keys = key.split(".") + if keys and keys[0] == "nick": + cmds.append(keys[1]) + + if cmds: + self.usage = "{}: {}".format(conf_get("bot_nickname"), "|".join(set(cmds))) + else: + self.usage = "(reaction only)" + self.used_channel = None + + try: + self.register_plugin() + except: + logger.exception("Oops. Registration failed") + + connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost')) + self.channel = connection.channel() + self.channel.exchange_declare(exchange='classifier', exchange_type='topic') + + def callback(self, ch, method, properties, body): + logger.debug("Reacting on %s in %s", str(method.routing_key), self.get_subclass_name()) + body = json.loads(body.decode("utf-8")) + self.used_channel = method.routing_key.split(".") + + if self.uses_history: + self.history = self.total_history[body["to"]] + action = None + try: + action = self.parse_body(body) + if self.uses_history: + self.total_history[body["to"]].append(body) + ack = True + assert (action is None) or isinstance(action, Action) + except Exception as e: + # Don't crash the thread. No Exceptions. + logger.exception("Ouch. {} crashed. Don't ack.".format(self.get_subclass_name()), exc_info=True) + ack = False + if not action: + logger.debug("no action taken") + return + action.sender = body["from"] + send_action(self.actionqueue, action) + + if ack: + logger.debug("Done") + ch.basic_ack(delivery_tag=method.delivery_tag) + + def get_subclass_name(self): + return self.__class__.__name__ + + def die(self): + logger.info("Bye from %s", self.get_subclass_name()) + self.channel.stop_consuming() + + def run(self): + result = self.channel.queue_declare(exclusive=True) + self.queue = result.method.queue + + for binding_key in self.binding_keys: + logger.info("Registering plugin %s for %s", self.get_subclass_name(), binding_key) + self.channel.queue_bind( + exchange='classifier', + queue=self.queue, + routing_key=binding_key + ) + + self.channel.basic_consume(self.callback, queue=self.queue) + self.channel.start_consuming() + + def parse_body(self, msg): + raise NotImplementedError() + + def register_plugin(self): + + connection = pika.BlockingConnection(pika.ConnectionParameters('localhost')) + channel = connection.channel() + channel.queue_declare(queue='plugin_registry') + channel.basic_publish( + exchange='', + routing_key='plugin_registry', + body=json.dumps(self.get_declaration()) + ) + + def get_declaration(self): + return { + "name": self.get_subclass_name(), + "bindings": self.binding_keys, + "description": self.description, + "usage": self.usage, + } diff --git a/distbot/common/__init__.py b/distbot/common/__init__.py new file mode 100755 index 0000000..40a96af --- /dev/null +++ b/distbot/common/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/distbot/common/action.py b/distbot/common/action.py new file mode 100755 index 0000000..e0e2a52 --- /dev/null +++ b/distbot/common/action.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +import json +from copy import deepcopy + +import pika + + +class Action: + event = None + msg = None + priv_msg = None + presence = None + sender = None + + # TODO: Event(Action)? + stop_event = None + time = None + command = None + mutex = None + + def __init__(self, **kwargs): + self.msg = kwargs.get('msg') + self.priv_msg = kwargs.get('priv_msg') + self.presence = kwargs.get('presence') + self.sender = kwargs.get('sender') + + self.event = kwargs.get('event') + self.stop_event = kwargs.get('stop_event') + self.time = kwargs.get('time') + self.mutex = kwargs.get('mutex') + + self.command = kwargs.get('command') + + @property + def serializable(self): + return self.__dict__ + + def serialize(self): + serialized = deepcopy(self.serializable) + if self.event: + serialized["event"] = self.event.serializable + return json.dumps(serialized) + + @staticmethod + def deserialize(action): + if isinstance(action, str): + action = json.loads(action) + action = Action(**action) + if action.event: + action.event = Action.deserialize(action.event) + return action + + +def send_action(actionqueue, action): + connection = pika.BlockingConnection( + pika.ConnectionParameters('localhost') + ) + channel = connection.channel() + channel.queue_declare(queue=actionqueue, durable=True) + + channel.basic_publish( + exchange='', + routing_key=actionqueue, + body=action.serialize() + ) diff --git a/distbot/common/config.py b/distbot/common/config.py new file mode 100755 index 0000000..9f96795 --- /dev/null +++ b/distbot/common/config.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import logging + +import os +from configobj import ConfigObj +from validate import Validator + +log_format = ' %(asctime)s %(process)d %(thread)d %(levelname).1s %(funcName)-15s %(message)s' +logging.basicConfig( + level=logging.INFO, + format=log_format +) + +logger = logging.getLogger(__name__) + +logging.getLogger('sleekxmpp').setLevel(logging.INFO) + + +def conf_get(key): + return Config().get(key) + + +# TODO fix possible race conditions (apply lock) +def conf_set(key, value): + Config().set(key, value) + + +CONFIG_SUFFIX = os.environ.get('BOTSUFFIX', '') +RESOURCE_PATH = "distbot/common/config" + + +class Config: + def __init__(self): + self.config_store = ConfigObj( + 'local_config{}.ini'.format(CONFIG_SUFFIX), + configspec='{}/local_config.ini.spec'.format(RESOURCE_PATH), + encoding='utf-8' + ) + self.runtime_config_store = ConfigObj( + 'persistent_config{}.ini'.format(CONFIG_SUFFIX), + configspec='{}/persistent_config.ini.spec'.format(RESOURCE_PATH), + encoding='utf-8' + ) + if not all(c.validate(Validator(), True) for c in (self.config_store, self.runtime_config_store)): + logger.error("Config Validation failed") + raise SystemError() + else: + # TODO: can't remember the reason for this one + self.config_store.write() + self.runtime_config_store.write() + + def _set(self, cfg, path, value): + if len(path) == 1: + cfg[path[0]] = value + elif not path: + raise KeyError() + else: + self._set(cfg=cfg[path[0]], path=path[1:], value=value) + + def set(self, key, value): + if '.' in key: + path = key.split('.') + self._set(self.runtime_config_store, path, value) + else: + self.runtime_config_store[key] = value + self.runtime_config_store.write() + + def get(self, key): + if '.' in key: + path = key.split('.') + value = self.get(path.pop(0)) + for p in path: + value = value.get(p) + # die early, if run into a dead end + if value is None: + break + else: + self.runtime_config_store.merge(self.config_store) + value = self.runtime_config_store.get(key) + return value diff --git a/distbot/common/config/local_config.ini.spec b/distbot/common/config/local_config.ini.spec new file mode 100755 index 0000000..ccc6767 --- /dev/null +++ b/distbot/common/config/local_config.ini.spec @@ -0,0 +1,17 @@ +jid = string +password = string +rooms = string_list(default=list('spielwiese@chat.debianforum.de',)) + +src-url = string + +bot_nickname = string +bot_owner = string +bot_owner_email = string +detectlanguage_api_key = string + +loglevel = option('ERROR', WARN', 'INFO', 'DEBUG', default='INFO') +debug_mode = boolean(default=false) + +# rate limiting, TODO +hist_max_count = integer(default=5) +hist_max_time = integer(default=10*60) diff --git a/distbot/common/config/persistent_config.ini.spec b/distbot/common/config/persistent_config.ini.spec new file mode 100755 index 0000000..ce4e903 --- /dev/null +++ b/distbot/common/config/persistent_config.ini.spec @@ -0,0 +1,42 @@ +other_bots = string_list(default=list()) +spammers = string_list(default=list()) +request_counter = integer(default=0) +start_time = integer(default=0) + +[plugins] + [[info]] + enabled = boolean(default=true) + [[dsa-watcher]] + interval = integer(default=900) + active = boolean(default=False) + last_dsa = int() + last_dsa_date = float() + [[dice]] + # the "dice" feature will use more efficient random data (0) for given users + enhanced-random-user = string_list(default=list()) + [[moin]] + # the "moin" feature will be "disabled" for given users + moin-modified-user = string_list(default=list()) + moin-disabled-user = string_list(default=list()) + [[teatimer]] + steep_time = integer(default=220) + + +[user_pref] + +[user_records] + +[user_joins] + +[url_blacklist] +heise = string(default='^.*heise\.de/.*-[0-9]+\.html$') +wikipedia = string(default='^.*wikipedia\.org/wiki/.*$') +blog = string(default=string(default='^.*blog\.fefe\.de/\?ts=[0-9a-f]+$')) +ibash = string(default='^.*ibash\.de/zitat.*$') +golem = string(default='^.*golem\.de/news/.*$') +paste_debian = string(default='^.*paste\.debian\.net/((hidden|plainh?)/)?[0-9a-f]+/?$') +example = string(default='^.*example\.(org|net|com).*$') +sprunge = string(default='^.*sprunge\.us/.*$') +ftp_debian = string(default='^.*ftp\...\.debian\.org.*$') +fefe = string(default='^.*blog\.fefe\.de.*$') +lan = string(default='^.*(192\.168\.).*$') diff --git a/distbot/common/message.py b/distbot/common/message.py new file mode 100755 index 0000000..a0e7b4c --- /dev/null +++ b/distbot/common/message.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +import logging +# Serializing +import shlex + +import pika +from sleekxmpp import Message, Presence +from sleekxmpp.jid import JID + +from distbot.common.config import conf_get, log_format + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +def get_words(msg): + return shlex.split(msg["body"]) + + +def get_nick_from_message(message_obj): + """ + Extract the actual nick + :type message_obj: Message + """ + if isinstance(message_obj, Message): + msg_type = message_obj.get_type() + + if msg_type == "groupchat": + return message_obj.get_mucnick() + elif msg_type == "chat": + jid = message_obj.get_from() + return jid.resource + else: + raise Exception("Message, but not groupchat/chat") + + elif isinstance(message_obj, Presence): + jid = message_obj.get_from() + return jid.resource + elif isinstance(message_obj, dict): + jid = JID(message_obj["from"]) + return jid.resource + else: + raise Exception("Message type is: " + str(type(message_obj))) + + +def process_message(routing_key, body): + connection = pika.BlockingConnection(pika.ConnectionParameters('localhost')) + channel = connection.channel() + channel.exchange_declare(exchange='classifier', exchange_type='topic') + + connection = pika.BlockingConnection( + pika.ConnectionParameters('localhost') + ) + channel = connection.channel() + + logger.debug("Processing message body with routing key {}".format(routing_key)) + channel.basic_publish( + exchange='classifier', + routing_key=routing_key, + body=body + ) + connection.close() diff --git a/distbot/common/utils.py b/distbot/common/utils.py new file mode 100755 index 0000000..2f8b607 --- /dev/null +++ b/distbot/common/utils.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +import html.parser +import logging +import time +from urllib.error import URLError + +import re +import requests +from functools import wraps + +from distbot.common.config import log_format, conf_get + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) +logger = logging.getLogger(__name__) + +BUFSIZ = 8192 +USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36' + + +def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None): + """Retry calling the decorated function using an exponential backoff. + + http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/ + original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry + + :param ExceptionToCheck: the exception to check. may be a tuple of + exceptions to check + :type ExceptionToCheck: Exception or tuple + :param tries: number of times to try (not retry) before giving up + :type tries: int + :param delay: initial delay between retries in seconds + :type delay: int + :param backoff: backoff multiplier e.g. value of 2 will double the delay + each retry + :type backoff: int + :param logger: logger to use. If None, print + :type logger: logging.Logger instance + """ + + def deco_retry(f): + + @wraps(f) + def f_retry(*args, **kwargs): + mtries, mdelay = tries, delay + while mtries > 1: + try: + return f(*args, **kwargs) + except ExceptionToCheck as e: + msg = "%s, Retrying in %d seconds..." % (str(e), mdelay) + if logger: + logger.warning(msg) + else: + print(msg) + time.sleep(mdelay) + mtries -= 1 + mdelay *= backoff + return f(*args, **kwargs) + + return f_retry # true decorator + + return deco_retry + + +def giphy(subject, api_key): + url = 'http://api.giphy.com/v1/gifs/random?tag={}&api_key={}&limit=1&offset=0'.format(subject, api_key) + response = requests.get(url) + giphy_url = None + try: + data = response.json() + giphy_url = data['data']['image_url'] + except: + pass + return giphy_url + + +def fetch_page(url, user_agent=USER_AGENT): + log = logging.getLogger(__name__) + log.info('fetching page ' + url) + response = requests.get(url, headers={'User-Agent': user_agent}, stream=True, timeout=15) + content = response.raw.read(BUFSIZ, decode_content=True) + return content.decode(response.encoding or 'utf-8'), response.headers + + +def extract_title(url): + global parser + + if 'repo/urlbot-native.git' in url: + logger.info('repo URL found: ' + url) + return 'wee, that looks like my home repo!', [] + + logger.info('extracting title from ' + url) + + try: + user_agent = None + # sick bastards, writing title with JS + if "youtube.com" in url or "youtu.be" in url: + user_agent = "curl" + (html_text, headers) = fetch_page(url, user_agent) + + except URLError as e: + return None + except UnicodeDecodeError: + return None + except Exception as e: + return 'failed: %s for %s' % (str(e), url) + + if 'content-type' in headers: + logger.debug('content-type: ' + headers['content-type']) + + if 'text/' != headers['content-type'][:len('text/')]: + return 1, headers['content-type'] + + result = re.match(r'.*?([^<]*?).*?', html_text, re.S | re.M | re.IGNORECASE) + if result: + match = result.groups()[0] + + parser = html.parser.HTMLParser() + try: + expanded_html = html.unescape(match) + except UnicodeDecodeError as e: # idk why this can happen, but it does + logger.warning('parser.unescape() expoded here: ' + str(e)) + expanded_html = match + return expanded_html + else: + return None + + +def get_version_git(): + import subprocess + + cmd = ['git', 'log', '--oneline', '--abbrev-commit'] + + try: + p = subprocess.Popen(cmd, bufsize=1, stdout=subprocess.PIPE) + first_line = p.stdout.readline() + line_count = len(p.stdout.readlines()) + 1 + + if 0 == p.wait(): + # skip this 1st, 2nd, 3rd stuff and use always [0-9]th + return "version (Git, %dth rev) '%s'" % ( + line_count, str(first_line.strip(), encoding='utf8') + ) + else: + return "(unknown version)" + except: + return "cannot determine version" diff --git a/distbot/doc/README.md b/distbot/doc/README.md new file mode 100755 index 0000000..9168c2b --- /dev/null +++ b/distbot/doc/README.md @@ -0,0 +1,23 @@ + +Urlbot +====== + +This is a rewrite of the `urlbot-native`, an xmpp bot based on sleekxmpp to serve and entertain one or more jabber chat rooms. Its primary function is to resolve urls posted in the MUC and print the title, hence the name. Other than that, it has several useful functions like an answerphone (`record username message`) and several less useful, but funny commands (`cake`). + + +Target architecture +------------------- + +Instead of a single-threaded python process primarily controlled by the xmpp loop, this version uses two components: + + * an httpd acting as command & control server, probably to be extended with an admin interface (still to come) + * a rather simple jabberbot, piping all (valid) input from the MUC to the httpd and returning messages + * inbetween is a rabbitmq instance, which allows for events to be handled independently. + +Currently, all the plugins are started from within the bot's thread, but that's subject to change. + + +Why rewriting? +-------------- + +The previous mechanism was lacking proper interfaces and couldn't handle more complex scenarios. diff --git a/distbot/plugins/__init__.py b/distbot/plugins/__init__.py new file mode 100755 index 0000000..40a96af --- /dev/null +++ b/distbot/plugins/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/distbot/plugins/basic.py b/distbot/plugins/basic.py new file mode 100755 index 0000000..a6667fd --- /dev/null +++ b/distbot/plugins/basic.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +import logging +import time + +import random + +from distbot.bot.worker import Worker +from distbot.common import config +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_nick_from_message, get_words + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +class Dice(Worker): + binding_keys = ['nick.dice.#', 'nick.würfle.#'] + description = "rolls a dice, optionally up to 5 times" + usage = "bot: dice " + + DICE_CHARS = ['◇', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'] + + def __init__(self, actionqueue): + super(Dice, self).__init__(actionqueue=actionqueue) + + def parse_body(self, msg): + words = get_words(msg) + nick = get_nick_from_message(msg) + num_dice = 1 + if len(words) > 1 and int(words[1]): + num_dice = int(words[1]) + if not (0 < num_dice < 6): + return Action(msg="invalid argument (only up to 5 dices at once)") + + dices = [] + for dice in range(num_dice): + if nick in config.conf_get('plugins.dice.enhanced-random-user'): + rnd = 0 + else: + rnd = random.randint(1, 6) + dices.append(' %s (\u200b%d\u200b)' % (Dice.DICE_CHARS[rnd], rnd)) + + answer = 'rolling %s for %s:' % ( + 'a dice' if 1 == num_dice else '%d dices' % num_dice, nick + ) + ' '.join(dices) + return Action(msg=answer) + + +class Ping(Worker): + binding_keys = ["nick.ping"] + description = "pong" + + def parse_body(self, msg): + return Action(msg="pong") + + +# Helper class for XChoose +class ChooseTree: + def __init__(self, item=None): + self.item = item + self.tree = None + self.closed = False + + # opening our root node + if self.item is None: + self.open() + + def open(self): + if self.tree is None: + self.tree = [] + elif self.closed: + raise Exception("cannot re-open group for item '%s'" % (self.item)) + + def close(self): + if self.tree is None: + raise Exception("close on unopened bracket") + elif len(self.tree) == 0: + raise Exception("item '%s' has a group without sub options" % (self.item)) + else: + self.closed = True + + def last(self): + return self.tree[-1] + + def choose(self): + if self.item: + yield self.item + + if self.tree: + sel = random.choice(self.tree) + for sub in sel.choose(): + yield sub + + def add(self, item): + self.tree.append(ChooseTree(item)) + + +class XChoose(Worker): + binding_keys = ["nick.xchoose.#"] + description = 'chooses randomly between nested option groups' + usage = "bot: xchoose taco (vegan meaty) kebab (spicy 'more spicy')" + + # because of error handling we're nesting this function here + @staticmethod + def xchoose(line): + item = '' + quote = None + choose_tree = ChooseTree() + choose_stack = [choose_tree] + bracket_stack = [] + + for pos, c in enumerate(line, 1): + try: + if quote: + if c == quote: + quote = None + else: + item += c + + elif c == ' ': + if item: + choose_stack[-1].add(item) + item = '' + + elif c in ('(', '[', '{', '<'): + if item: + choose_stack[-1].add(item) + item = '' + + try: + last = choose_stack[-1].last() + last.open() + choose_stack.append(last) + bracket_stack.append(c) + except IndexError: + raise Exception("cannot open group without preceding option") + + elif c in (')', ']', '}', '>'): + if not bracket_stack: + raise Exception("missing leading bracket for '%s'" % (c)) + + opening_bracket = bracket_stack.pop(-1) + wanted_closing_bracket = {'(': ')', '[': ']', '{': '}', '<': '>'}[opening_bracket] + if c != wanted_closing_bracket: + raise Exception("bracket mismatch, wanted bracket '%s' but got '%s'" % ( + wanted_closing_bracket, c)) + + if item: + choose_stack[-1].add(item) + item = '' + + choose_stack[-1].close() + choose_stack.pop(-1) + + elif c in ('"', "'"): + quote = c + + else: + item += c + + except Exception as e: + raise Exception("%s (at pos %d)" % (e, pos)) + + if bracket_stack: + raise Exception("missing closing bracket for '%s'" % (bracket_stack[-1])) + + if quote: + raise Exception("missing closing quote (%s)" % (quote)) + + if item: + choose_stack[-1].add(item) + + return ' '.join(choose_tree.choose()) + + def parse_body(self, msg): + import re + line = re.sub('.*xchoose *', '', msg["body"]) + sender = get_nick_from_message(msg) + try: + return Action(msg='%s: %s' % (sender, self.xchoose(line))) + except Exception as e: + return Action(msg='%s: %s' % (sender, str(e))) + + +class Choose(Worker): + binding_keys = [ + "nick.choose.#", "nick.choose", + "nick.sudo.choose.#", "nick.sudo.choose" + ] + description = 'chooses randomly between arguments' + usage = "bot: [sudo] choose " + + def parse_body(self, msg): + words = get_words(msg) + sender = get_nick_from_message(msg) + sudo = 'sudo' in self.used_channel + if sudo: + words.pop(0) # remove sudo from args + sudoers = conf_get('sudoers') or [] + if sender not in sudoers: + return Action(msg="{} is not in the sudoers file. This incident will be reported.") + + alternatives = words[1:] + logger.debug("Alternatives: %s", str(alternatives)) + binary = ( + (('Yes.', 'Yeah!', 'Ok!', 'Aye!', 'Great!'), 4), + (('No.', 'Naah..', 'Meh.', 'Nay.', 'You stupid?'), 4), + (('Maybe.', 'Dunno.', 'I don\'t care.'), 2) + ) + + def weighted_choice(choices): + total = sum(w for c, w in choices) + r = random.uniform(0, total) + upto = 0 + for c, w in choices: + if upto + w >= r: + return c + upto += w + + def binary_choice(sudo=False): + if sudo: + return 'Yes, Master.' + else: + return random.choice(weighted_choice(binary)) + + # single or no choice + if len(alternatives) < 2: + return Action(msg='{}: {}'.format(sender, binary_choice(sudo=sudo))) + + elif 'choose' not in alternatives: + choice = random.choice(alternatives) + return Action(msg='%s: I prefer %s!' % (sender, choice)) + + def choose_between(options): + responses = [] + current_choices = [] + + for item in options: + if item == 'choose': + if len(current_choices) < 2: + responses.append(binary_choice(sudo=sudo)) + else: + responses.append(random.choice(current_choices)) + current_choices = [] + else: + current_choices.append(item) + if len(current_choices) < 2: + responses.append(binary_choice(sudo=sudo)) + else: + responses.append(random.choice(current_choices)) + return responses + + logger.debug('sent multiple random choices') + return Action(msg='%s: My choices are: %s!' % (sender, ', '.join(choose_between(alternatives)))) + + +class TeaTimer(Worker): + binding_keys = ["nick.teatimer.*", "nick.teatimer"] + description = 'sets a tea timer to $1 or currently %d seconds' % conf_get('plugins.teatimer.steep_time') + usage = "bot: teatimer " + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + words = get_words(msg) + steep = config.conf_get('plugins.teatimer.steep_time') + + if words[1:]: + try: + steep = int(words[1]) + except ValueError as e: + return { + 'msg': sender + ': error when parsing int(%s): %s' % ( + words[1], str(e) + ) + } + + ready = time.time() + steep + + try: + logger.info('tea timer set to %s' % time.strftime('%Y-%m-%d %H:%M', time.localtime(ready))) + except (ValueError, OverflowError) as e: + return Action(msg=': time format error: ' + str(e)) + + return Action( + msg=sender + ': Tea timer set to %s' % time.strftime('%Y-%m-%d %H:%M', time.localtime(ready)), + event=Action(time=ready, msg=(sender + ': Your tea is ready!'), mutex='teatimer_{}'.format(sender)) + ) + + +ALL = [Dice, Ping, XChoose, Choose, TeaTimer] diff --git a/distbot/plugins/bofh.py b/distbot/plugins/bofh.py new file mode 100755 index 0000000..ec63322 --- /dev/null +++ b/distbot/plugins/bofh.py @@ -0,0 +1,510 @@ +# -*- coding: utf-8 -*- +import logging + +import random + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_nick_from_message + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +class BOFH(Worker): + binding_keys = ["nick.excuse", "nick.excuse.#"] + description = "prints BOFH style excuses" + + # retrieved from http://pages.cs.wisc.edu/~ballard/bofh/excuses + excuses = [ + "a part of these answers would unsettle the population", + "rapid unscheduled disassembly of engine core", + "clock speed", + "solar flares", + "electromagnetic radiation from satellite debris", + "static from nylon underwear", + "static from plastic slide rules", + "global warming", + "poor power conditioning", + "static buildup", + "doppler effect", + "hardware stress fractures", + "magnetic interference from money/credit cards", + "dry joints on cable plug", + "we're waiting for [the phone company] to fix that line", + "sounds like a Windows problem, try calling Microsoft support", + "temporary routing anomaly", + "somebody was calculating pi on the server", + "fat electrons in the lines", + "excess surge protection", + "floating point processor overflow", + "divide-by-zero error", + "POSIX compliance problem", + "monitor resolution too high", + "improperly oriented keyboard", + "network packets travelling uphill (use a carrier pigeon)", + "Decreasing electron flux", + "first Saturday after first full moon in Winter", + "radiosity depletion", + "CPU radiator broken", + "It works the way the Wang did, what's the problem", + "positron router malfunction", + "cellular telephone interference", + "techtonic stress", + "piezo-electric interference", + "(l)user error", + "working as designed", + "dynamic software linking table corrupted", + "heavy gravity fluctuation, move computer to floor rapidly", + "secretary plugged hairdryer into UPS", + "terrorist activities", + "not enough memory, go get system upgrade", + "interrupt configuration error", + "spaghetti cable cause packet failure", + "boss forgot system password", + "bank holiday - system operating credits not recharged", + "virus attack, luser responsible", + "waste water tank overflowed onto computer", + "Complete Transient Lockout", + "bad ether in the cables", + "Bogon emissions", + "Change in Earth's rotational speed", + "Cosmic ray particles crashed through the hard disk platter", + "Smell from unhygienic janitorial staff wrecked the tape heads", + "Little hamster in running wheel had coronary; waiting for replacement to be Fedexed from Wyoming", + "Evil dogs hypnotised the night shift", + "Plumber mistook routing panel for decorative wall fixture", + "Electricians made popcorn in the power supply", + "Groundskeepers stole the root password", + "high pressure system failure", + "failed trials, system needs redesigned", + "system has been recalled", + "not approved by the FCC", + "need to wrap system in aluminum foil to fix problem", + "not properly grounded, please bury computer", + "CPU needs recalibration", + "system needs to be rebooted", + "bit bucket overflow", + "descramble code needed from software company", + "only available on a need to know basis", + "knot in cables caused data stream to become twisted and kinked", + "nesting roaches shorted out the ether cable", + "The file system is full of it", + "Satan did it", + "Daemons did it", + "You're out of memory", + "There isn't any problem", + "Unoptimized hard drive", + "Typo in the code", + "Yes, yes, its called a design limitation", + "Look, buddy: Windows 3.1 IS A General Protection Fault.", + "That's a great computer you have there; have you considered how it would work as a BSD machine?", + "Please excuse me, I have to circuit an AC line through my head to get this database working.", + "Yeah, yo mama dresses you funny and you need a mouse to delete files.", + "Support staff hung over, send aspirin and come back LATER.", + "Someone is standing on the ethernet cable, causing a kink in the cable", + "Windows 95 undocumented \"feature\"", + "Runt packets", + "Password is too complex to decrypt", + "Boss' kid fucked up the machine", + "Electromagnetic energy loss", + "Budget cuts", + "Mouse chewed through power cable", + "Stale file handle (next time use Tupperware(tm)!)", + "Feature not yet implemented", + "Internet outage", + "Pentium FDIV bug", + "Vendor no longer supports the product", + "Small animal kamikaze attack on power supplies", + "The vendor put the bug there.", + "SIMM crosstalk.", + "IRQ dropout", + "Collapsed Backbone", + "Power company testing new voltage spike (creation) equipment", + "operators on strike due to broken coffee machine", + "backup tape overwritten with copy of system manager's favourite CD", + "UPS interrupted the server's power", + "The electrician didn't know what the yellow cable was so he yanked the ethernet out.", + "The keyboard isn't plugged in", + "The air conditioning water supply pipe ruptured over the machine room", + "The electricity substation in the car park blew up.", + "The rolling stones concert down the road caused a brown out", + "The salesman drove over the CPU board.", + "The monitor is plugged into the serial port", + "Root nameservers are out of sync", + "electro-magnetic pulses from French above ground nuke testing.", + "your keyboard's space bar is generating spurious keycodes.", + "the real ttys became pseudo ttys and vice-versa.", + "the printer thinks its a router.", + "the router thinks its a printer.", + "evil hackers from Serbia.", + "we just switched to FDDI.", + "halon system went off and killed the operators.", + "because Bill Gates is a Jehovah's witness and so nothing can work on St. Swithin's day.", + "user to computer ratio too high.", + "user to computer ration too low.", + "we just switched to Sprint.", + "it has Intel Inside", + "Sticky bits on disk.", + "Power Company having EMP problems with their reactor", + "The ring needs another token", + "new management", + "telnet: Unable to connect to remote host: Connection refused", + "SCSI Chain overterminated", + "It's not plugged in.", + "because of network lag due to too many people playing deathmatch", + "You put the disk in upside down.", + "Daemons loose in system.", + "User was distributing pornography on server; system seized by FBI.", + "BNC (brain not connected)", + "UBNC (user brain not connected)", + "LBNC (luser brain not connected)", + "disks spinning backwards - toggle the hemisphere jumper.", + "new guy cross-connected phone lines with ac power bus.", + "had to use hammer to free stuck disk drive heads.", + "Too few computrons available.", + "Flat tire on station wagon with tapes. (\"Never underestimate the bandwidth of" + " a station wagon full of tapes hurling down the highway\" Andrew S. Tannenbaum) ", + "Communications satellite used by the military for star wars.", + "Party-bug in the Aloha protocol.", + "Insert coin for new game", + "Dew on the telephone lines.", + "Arcserve crashed the server again.", + "Some one needed the powerstrip, so they pulled the switch plug.", + "My pony-tail hit the on/off switch on the power strip.", + "Big to little endian conversion error", + "You can tune a file system, but you can't tune a fish (from most tunefs man pages)", + "Dumb terminal", + "Zombie processes haunting the computer", + "Incorrect time synchronization", + "Defunct processes", + "Stubborn processes", + "non-redundant fan failure ", + "monitor VLF leakage", + "bugs in the RAID", + "no \"any\" key on keyboard", + "root rot", + "Backbone Scoliosis", + "/pub/lunch", + "excessive collisions & not enough packet ambulances", + "le0: no carrier: transceiver cable problem?", + "broadcast packets on wrong frequency", + "popper unable to process jumbo kernel", + "NOTICE: alloc: /dev/null: filesystem full", + "pseudo-user on a pseudo-terminal", + "Recursive traversal of loopback mount points", + "Backbone adjustment", + "OS swapped to disk", + "vapors from evaporating sticky-note adhesives", + "sticktion", + "short leg on process table", + "multicasts on broken packets", + "ether leak", + "Atilla the Hub", + "endothermal recalibration", + "filesystem not big enough for Jumbo Kernel Patch", + "loop found in loop in redundant loopback", + "system consumed all the paper for paging", + "permission denied", + "Reformatting Page. Wait...", + "..disk or the processor is on fire.", + "SCSI's too wide.", + "Proprietary Information.", + "Just type 'mv * /dev/null'.", + "runaway cat on system.", + "Did you pay the new Support Fee?", + "We only support a 1200 bps connection.", + "We only support a 28000 bps connection.", + "Me no internet, only janitor, me just wax floors.", + "I'm sorry a pentium won't do, you need an SGI to connect with us.", + "Post-it Note Sludge leaked into the monitor.", + "the curls in your keyboard cord are losing electricity.", + "The monitor needs another box of pixels.", + "RPC_PMAP_FAILURE", + "kernel panic: write-only-memory (/dev/wom0) capacity exceeded.", + "Write-only-memory subsystem too slow for this machine. Contact your local dealer.", + "Just pick up the phone and give modem connect sounds. \"Well you said we " + "should get more lines so we don't have voice lines.\"", + "Quantum dynamics are affecting the transistors", + "Police are examining all internet packets in the search for a narco-net-trafficker", + "We are currently trying a new concept of using a live mouse. " + "Unfortunately, one has yet to survive being hooked up to the computer.....please bear with us.", + "Your mail is being routed through Germany ... and they're censoring us.", + "Only people with names beginning with 'A' are getting mail this week (a la Microsoft)", + "We didn't pay the Internet bill and it's been cut off.", + "Lightning strikes.", + "Of course it doesn't work. We've performed a software upgrade.", + "Change your language to Finnish.", + "Fluorescent lights are generating negative ions. If turning them off doesn't work," + " take them out and put tin foil on the ends.", + "High nuclear activity in your area.", + "What office are you in? Oh, that one. Did you know that your building was built over the universities " + "first nuclear research site? And wow, aren't you the lucky one, " + "your office is right over where the core is buried!", + "The MGs ran out of gas.", + "The UPS doesn't have a battery backup.", + "Recursivity. Call back if it happens again.", + "Someone thought The Big Red Button was a light switch.", + "The mainframe needs to rest. It's getting old, you know.", + "I'm not sure. Try calling the Internet's head office -- it's in the book.", + "The lines are all busy (busied out, that is -- why let them in to begin with?).", + "Jan 9 16:41:27 huber su: 'su root' succeeded for .... on /dev/pts/1", + "It's those computer people in X {city of world}. They keep stuffing things up.", + "A star wars satellite accidently blew up the WAN.", + "Fatal error right in front of screen", + "That function is not currently supported, but Bill Gates assures us it will be featured in the next upgrade.", + "wrong polarity of neutron flow", + "Lusers learning curve appears to be fractal", + "We had to turn off that service to comply with the CDA Bill.", + "Ionization from the air-conditioning", + "TCP/IP UDP alarm threshold is set too low.", + "Someone is broadcasting pygmy packets and the router doesn't know how to deal with them.", + "The new frame relay network hasn't bedded down the software loop transmitter yet. ", + "Fanout dropping voltage too much, try cutting some of those little traces", + "Plate voltage too low on demodulator tube", + "You did wha... oh _dear_....", + "CPU needs bearings repacked", + "Too many little pins on CPU confusing it, bend back and forth until 10-20% are neatly removed." + " Do _not_ leave metal bits visible!", + "_Rosin_ core solder? But...", + "Software uses US measurements, but the OS is in metric...", + "The computer fleetly, mouse and all.", + "Your cat tried to eat the mouse.", + "The Borg tried to assimilate your system. Resistance is futile.", + "It must have been the lightning storm we had (yesterday) (last week) (last month)", + "Due to Federal Budget problems we have been forced to cut back on the number of users" + " able to access the system at one time. (namely none allowed....)", + "Too much radiation coming from the soil.", + "Unfortunately we have run out of bits/bytes/whatever. Don't worry, the next supply will be coming next week.", + "Program load too heavy for processor to lift.", + "Processes running slowly due to weak power supply", + "Our ISP is having {switching,routing,SMDS,frame relay} problems", + "We've run out of licenses", + "Interference from lunar radiation", + "Standing room only on the bus.", + "You need to install an RTFM interface.", + "That would be because the software doesn't work.", + "That's easy to fix, but I can't be bothered.", + "Someone's tie is caught in the printer, and if anything else gets printed, he'll be in it too.", + "We're upgrading /dev/null", + "The Usenet news is out of date", + "Our POP server was kidnapped by a weasel.", + "It's stuck in the Web.", + "Your modem doesn't speak English.", + "The mouse escaped.", + "All of the packets are empty.", + "The UPS is on strike.", + "Neutrino overload on the nameserver", + "Melting hard drives", + "Someone has messed up the kernel pointers", + "The kernel license has expired", + "Netscape has crashed", + "The cord jumped over and hit the power switch.", + "It was OK before you touched it.", + "Bit rot", + "U.S. Postal Service", + "Your Flux Capacitor has gone bad.", + "The Dilithium Crystals need to be rotated.", + "The static electricity routing is acting up...", + "Traceroute says that there is a routing problem in the backbone. It's not our problem.", + "The co-locator cannot verify the frame-relay gateway to the ISDN server.", + "High altitude condensation from U.S.A.F prototype aircraft has contaminated the primary subnet mask." + " Turn off your computer for 9 days to avoid damaging it.", + "Lawn mower blade in your fan need sharpening", + "Electrons on a bender", + "Telecommunications is upgrading. ", + "Telecommunications is downgrading.", + "Telecommunications is downshifting.", + "Hard drive sleeping. Let it wake up on it's own...", + "Interference between the keyboard and the chair.", + "The CPU has shifted, and become decentralized.", + "Due to the CDA, we no longer have a root account.", + "We ran out of dial tone and we're and waiting for the phone company to deliver another bottle.", + "You must've hit the wrong any key.", + "PCMCIA slave driver", + "The Token fell out of the ring. Call us when you find it.", + "The hardware bus needs a new token.", + "Too many interrupts", + "Not enough interrupts", + "The data on your hard drive is out of balance.", + "Digital Manipulator exceeding velocity parameters", + "appears to be a Slow/Narrow SCSI-0 Interface problem", + "microelectronic Riemannian curved-space fault in write-only file system", + "fractal radiation jamming the backbone", + "routing problems on the neural net", + "IRQ-problems with the Un-Interruptible-Power-Supply", + "CPU-angle has to be adjusted because of vibrations coming from the nearby road", + "emissions from GSM-phones", + "CD-ROM server needs recalibration", + "firewall needs cooling", + "asynchronous inode failure", + "transient bus protocol violation", + "incompatible bit-registration operators", + "your process is not ISO 9000 compliant", + "You need to upgrade your VESA local bus to a MasterCard local bus.", + "The recent proliferation of Nuclear Testing", + "Elves on strike. (Why do they call EMAG Elf Magic)", + "Internet exceeded Luser level, please wait until a luser logs off before attempting to log back on.", + "Your EMAIL is now being delivered by the USPS.", + "Your computer hasn't been returning all the bits it gets from the Internet.", + "You've been infected by the Telescoping Hubble virus.", + "Scheduled global CPU outage", + "Your Pentium has a heating problem - try cooling it with ice cold water.(Do not turn off your computer," + " you do not want to cool down the Pentium Chip while he isn't working, do you?)", + "Your processor has processed too many instructions. Turn it off immediately, do not type any commands!!", + "Your packets were eaten by the terminator", + "Your processor does not develop enough heat.", + "We need a licensed electrician to replace the light bulbs in the computer room.", + "The POP server is out of Coke", + "Fiber optics caused gas main leak", + "Server depressed, needs Prozac", + "quantum decoherence", + "those damn raccoons!", + "suboptimal routing experience", + "A plumber is needed, the network drain is clogged", + "50% of the manual is in .pdf readme files", + "the AA battery in the wallclock sends magnetic interference", + "the xy axis in the trackball is coordinated with the summer solstice", + "the butane lighter causes the pincushioning", + "old inkjet cartridges emanate barium-based fumes", + "manager in the cable duct", + "We'll fix that in the next (upgrade, update, patch release, service pack).", + "HTTPD Error 666 : BOFH was here", + "HTTPD Error 4004 : very old Intel cpu - insufficient processing power", + "The ATM board has run out of 10 pound notes. We are having a whip round to refill it, care to contribute ?", + "Network failure - call NBC", + "Having to manually track the satellite.", + "Your/our computer(s) had suffered a memory leak, and we are waiting for them to be topped up.", + "The rubber band broke", + "We're on Token Ring, and it looks like the token got loose.", + "Stray Alpha Particles from memory packaging caused Hard Memory Error on Server.", + "paradigm shift...without a clutch", + "PEBKAC (Problem Exists Between Keyboard And Chair)", + "The cables are not the same length.", + "Second-system effect.", + "Chewing gum on /dev/sd3c", + "Boredom in the Kernel.", + "the daemons! the daemons! the terrible daemons!", + "I'd love to help you -- it's just that the Boss won't let me near the computer. ", + "struck by the Good Times virus", + "YOU HAVE AN I/O ERROR -> Incompetent Operator error", + "Your parity check is overdrawn and you're out of cache.", + "Communist revolutionaries taking over the server room and demanding all the computers in the building or" + " they shoot the sysadmin. Poor misguided fools.", + "Plasma conduit breach", + "Out of cards on drive D:", + "Sand fleas eating the Internet cables", + "parallel processors running perpendicular today", + "ATM cell has no roaming feature turned on, notebooks can't connect", + "Webmasters kidnapped by evil cult.", + "Failure to adjust for daylight savings time.", + "Virus transmitted from computer to sysadmins.", + "Virus due to computers having unsafe sex.", + "Incorrectly configured static routes on the corerouters.", + "Forced to support NT servers; sysadmins quit.", + "Suspicious pointer corrupted virtual machine", + "It's the InterNIC's fault.", + "Root name servers corrupted.", + "Budget cuts forced us to sell all the power cords for the servers.", + "Someone hooked the twisted pair wires into the answering machine.", + "Operators killed by year 2000 bug bite.", + "We've picked COBOL as the language of choice.", + "Operators killed when huge stack of backup tapes fell over.", + "Robotic tape changer mistook operator's tie for a backup tape.", + "Someone was smoking in the computer room and set off the halon systems.", + "Your processor has taken a ride to Heaven's Gate on the UFO behind Hale-Bopp's comet.", + "it's an ID-10-T error", + "Dyslexics retyping hosts file on servers", + "The Internet is being scanned for viruses.", + "Your computer's union contract is set to expire at midnight.", + "Bad user karma.", + "/dev/clue was linked to /dev/null", + "Increased sunspot activity.", + "We already sent around a notice about that.", + "It's union rules. There's nothing we can do about it. Sorry.", + "Interference from the Van Allen Belt.", + "Jupiter is aligned with Mars.", + "Redundant ACLs. ", + "Mail server hit by UniSpammer.", + "T-1's congested due to porn traffic to the news server.", + "Data for intranet got routed through the extranet and landed on the internet.", + "We are a 100% Microsoft Shop.", + "We are Microsoft. What you are experiencing is not a problem; it is an undocumented feature.", + "Sales staff sold a product we don't offer.", + "Secretary sent chain letter to all 5000 employees.", + "Sysadmin didn't hear pager go off due to loud music from bar-room speakers.", + "Sysadmin accidentally destroyed pager with a large hammer.", + "Sysadmins unavailable because they are in a meeting talking about why they are unavailable so much.", + "Bad cafeteria food landed all the sysadmins in the hospital.", + "Route flapping at the NAP.", + "Computers under water due to SYN flooding.", + "The vulcan-death-grip ping has been applied.", + "Electrical conduits in machine room are melting.", + "Traffic jam on the Information Superhighway.", + "Radial Telemetry Infiltration", + "Cow-tippers tipped a cow onto the server.", + "tachyon emissions overloading the system", + "Maintenance window broken", + "We're out of slots on the server", + "Computer room being moved. Our systems are down for the weekend.", + "Sysadmins busy fighting SPAM.", + "Repeated reboots of the system failed to solve problem", + "Feature was not beta tested", + "Domain controller not responding", + "Someone else stole your IP address, call the Internet detectives!", + "It's not RFC-822 compliant.", + "operation failed because: there is no message for this error (#1014)", + "stop bit received", + "internet is needed to catch the etherbunny", + "network down, IP packets delivered via UPS", + "Firmware update in the coffee machine", + "Temporal anomaly", + "Mouse has out-of-cheese-error", + "Borg implants are failing", + "Borg nanites have infested the server", + "error: one bad user found in front of screen", + "Please state the nature of the technical emergency", + "Internet shut down due to maintenance", + "Daemon escaped from pentagram", + "crop circles in the corn shell", + "sticky bit has come loose", + "Hot Java has gone cold", + "Cache miss - please take better aim next time", + "Hash table has woodworm", + "Trojan horse ran out of hay", + "Zombie processes detected, machine is haunted.", + "overflow error in /dev/null", + "Browser's cookie is corrupted -- someone's been nibbling on it.", + "Mailer-daemon is busy burning your message in hell.", + "According to Microsoft, it's by design", + "vi needs to be upgraded to vii", + "greenpeace free'd the mallocs", + "Terrorists crashed an airplane into the server room, have to remove /bin/laden. (rm -rf /bin/laden)", + "astropneumatic oscillations in the water-cooling", + "Somebody ran the operating system through a spelling checker.", + "Rhythmic variations in the voltage reaching the power supply.", + "Keyboard Actuator Failure. Order and Replace.", + "Packet held up at customs.", + "Propagation delay.", + "High line impedance.", + "Someone set us up the bomb.", + "Power surges on the Underground.", + "Don't worry; it's been deprecated. The new one is worse.", + "Excess condensation in cloud network", + "It is a layer 8 problem", + "The math co-processor had an overflow error that leaked out and shorted the RAM", + "Leap second overloaded RHEL6 servers", + "DNS server drank too much and had a hiccup" + ] + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + return Action(msg="{}: {}".format(sender, random.choice(self.excuses))) + + +ALL = [BOFH] diff --git a/distbot/plugins/bots.py b/distbot/plugins/bots.py new file mode 100755 index 0000000..b635f73 --- /dev/null +++ b/distbot/plugins/bots.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" +Interaction with other bots +""" + +# TODO: provoke-bots, botlist and CRUD +# possible won't need this + +ALL = [] diff --git a/distbot/plugins/bugtracker.py b/distbot/plugins/bugtracker.py new file mode 100755 index 0000000..08927b9 --- /dev/null +++ b/distbot/plugins/bugtracker.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- + +import logging + +import re + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.utils import extract_title + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +# TODO other trackers? how to integrate/disable conflicting ones with #? + +class DebianTracker(Worker): + binding_keys = Worker.CATCH_ALL + description = "Parse debian bugs" + usage = "reacts to #12345" + + def parse_body(self, msg): + bugs = re.findall(r'#(\d{4,})', msg['body']) + if not bugs: + return None + + out = [] + for b in bugs: + logger.info('detected Debian bug #%s' % b) + + url = 'https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=%s' % b + + title = extract_title(url) + + if title: + out.append('Debian Bug: %s: %s' % (title, url)) + + return Action(msg="\n".join(out)) + + +class SecurityTracker(Worker): + binding_keys = Worker.CATCH_ALL + description = "Parse CVE and DSA handles" + usage = "reacts on CVE-1234 and DSA-1234" + + def parse_body(self, msg): + tokens = re.findall(r'((DSA|CVE)-\d\d\d\d-\d+)', msg['body'].upper()) + if not tokens: + return None + + logger.info('detected DSA/CVE handle') + out = ['https://security-tracker.debian.org/tracker/%s' % d for d in tokens] + + return Action(msg="\n".join(out)) + + +class URLResolver(Worker): + binding_keys = Worker.CATCH_ALL + description = "resolves titles of posted URLs" + + def parse_body(self, msg): + user_pref_nospoiler = conf_get('user_pref.spoiler') + if user_pref_nospoiler: + logger.info('nospoiler in userconf') + return + + result = re.findall(r'(https?://[^\s>]+)', msg["body"]) + if not result: + return + + url_blacklist = conf_get('plugins.urlresolve.blacklist').values() + + out = [] + for url in result[:10]: + if any([re.match(b, url) for b in url_blacklist]): + logger.info('url blacklist match for ' + url) + break + # brackets aren't allowed anyways... and repeatedly used. + if url.endswith(')'): + url = url.rstrip(')') + try: + title = extract_title(url) + except UnicodeError as e: + message = 'Bug triggered (%s), invalid URL/domain part: %s' % (str(e), url) + logger.warning(message) + return {'msg': message} + + if title: + title = title.strip() + message = 'Title: %s' % title + message = message.replace('\n', '\\n') + out.append(message) + + return Action(msg="\n".join(out)) + + +ALL = [DebianTracker, SecurityTracker, URLResolver] diff --git a/distbot/plugins/extended.py b/distbot/plugins/extended.py new file mode 100755 index 0000000..3d0cd5c --- /dev/null +++ b/distbot/plugins/extended.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +Functions which make use of httpd + +Actually, just collecting things. Naming is TBD +""" + +# TODO: poll, games like CAH, quiz, ... + +ALL = [] diff --git a/distbot/plugins/feeds.py b/distbot/plugins/feeds.py new file mode 100755 index 0000000..a839ca2 --- /dev/null +++ b/distbot/plugins/feeds.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +import logging +import time +from datetime import datetime, timedelta + +import requests +from lxml import etree + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, conf_set, log_format +from distbot.common.message import get_words + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +def format_time(unixtime): + if not unixtime: + return "" + return datetime.fromtimestamp(unixtime).isoformat() + + +class DSAWatcher(Worker): + binding_keys = ["nick.dsa-watcher", "nick.dsa-watcher.status", + "nick.dsa-watcher.start", "nick.dsa-watcher.stop", "nick.dsa-watcher.run", + "nick.dsa-watcher.crawl"] + description = 'automatically crawls for newly published Debian Security Announces' + usage = "call with start, stop for background control or \"crawl\" for an intermediate status report" + + @staticmethod + def get_id_from_about_string(about): + return int(about.split('/')[-1].split('-')[1]) + + def get_dsa_list(self, after_dsa_id, since): + """ + Get a list of dsa items in form of id and package, retrieved from the RSS feed + :param after_dsa_id: optional integer to filter on (only DSA's after that will be returned) + :param since: earliest date + :returns list of id, package (with DSA prefix) + """ + nsmap = { + "purl": "http://purl.org/rss/1.0/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "dc": "http://purl.org/dc/elements/1.1/", + } + dsa_response = requests.get("https://www.debian.org/security/dsa-long") + xmldoc = etree.fromstring(dsa_response.content) + dsa_about_list = xmldoc.xpath('//purl:item/@rdf:about', namespaces=nsmap) + for dsa_about in reversed(dsa_about_list): + dsa_id = self.get_id_from_about_string(dsa_about) + title = xmldoc.xpath( + '//purl:item[@rdf:about="{}"]/purl:title/text()'.format(dsa_about), + namespaces=nsmap + )[0] + date = xmldoc.xpath('//purl:item[@rdf:about="{}"]/dc:date/text()'.format(dsa_about), namespaces=nsmap)[0] + if after_dsa_id and dsa_id <= after_dsa_id: + continue + else: + if datetime.strptime(date, "%Y-%m-%d") < since: + continue + yield dsa_id, date, str(title).replace(' - security update', '') + + def crawl(self): + out = [] + last_dsa = conf_get('plugins.dsa-watcher.last_dsa') + if last_dsa: + last_dsa = int(last_dsa) + last_dsa_date = conf_get('plugins.dsa-watcher.last_dsa_date') + + if last_dsa_date: + since = datetime.fromtimestamp(last_dsa_date) + else: + since = datetime.today() - timedelta(days=3) + + logger.debug('Searching for DSA after {}'.format(since)) + for dsa, date, package in self.get_dsa_list(after_dsa_id=last_dsa, since=since): + url = 'https://security-tracker.debian.org/tracker/DSA-%d-1' % dsa + + msg = '[{}] new Debian Security Announce found ({}): {}'.format(date, package, url) + out.append(msg) + last_dsa = dsa + + conf_set("plugins.dsa-watcher.last_dsa_date", time.time()) + conf_set("plugins.dsa-watcher.last_dsa", last_dsa) + + return "\n".join(out) + + @staticmethod + def get_next_schedule(): + crawl_at = time.time() + conf_get('plugins.dsa-watcher.interval') + msg = 'next crawl set to %s' % time.strftime('%Y-%m-%d %H:%M', time.localtime(crawl_at)) + logger.info(msg) + + return Action(time=crawl_at, command="nick.dsa-watcher.run", mutex="dsa-watcher") + + def parse_body(self, msg): + words = get_words(msg)[1:] + cmd = None + if words: + cmd = words[0] + active = (conf_get("plugins.dsa-watcher.active") is True) or False + state = "active" if active else "inactive" + last_dsa_date = conf_get('plugins.dsa-watcher.last_dsa_date') + interval = conf_get('plugins.dsa-watcher.interval') + + logger.debug("dsa-crawler: active {}, cmd {}, last_crawl at {}".format(active, str(cmd), format(last_dsa_date))) + + if cmd == "start": + msg = self.crawl() + event = self.get_next_schedule() + conf_set("plugins.dsa-watcher.active", True) + return Action(event=event, msg=msg) + if cmd == "run": + msg = self.crawl() + event = self.get_next_schedule() + return Action(event=event, msg=msg) + elif cmd == "crawl": + msg = self.crawl() + return Action(msg=msg) + elif cmd == "stop": + conf_set("plugins.dsa-watcher.active", False) + return Action(event=Action(stop_event=True, mutex="dsa-watcher")) + elif cmd in (None, "status"): + msg = "Currently {}, last crawled at {}.".format(state, format_time(last_dsa_date)) + if active: + last_dsa_date = last_dsa_date or time.time() + msg += " Next crawl at {}".format(format_time(last_dsa_date + interval)) + + return Action(msg=msg) + + +ALL = [DSAWatcher] diff --git a/distbot/plugins/fun.py b/distbot/plugins/fun.py new file mode 100755 index 0000000..474763f --- /dev/null +++ b/distbot/plugins/fun.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +import logging + +import random + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_nick_from_message, get_words +from distbot.common.utils import giphy + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +class Klammer(Worker): + binding_keys = ["nick.klammer"] + description = "prints an anoying paper clip aka. Karl Klammer" + + def parse_body(self, msg): + klammer_ascii = "\n".join([ + get_nick_from_message(msg) + ',', + r" _, Was moechten", + r"( _\_ Sie tun?", + r" \0 O\ ", + r" \\ \\ [ ] ja ", + r" \`' ) [ ] noe", + r" `'' " + ]) + return Action(msg=klammer_ascii) + + +class Terminate(Worker): + binding_keys = ["nick.terminate"] + description = "hidden prototype" + + def parse_body(self, msg): + return Action(msg="insufficient power supply, please connect fission module") + + +class Unicode(Worker): + binding_keys = ["nick.unikot", "nick.unicode"] + description = "unikots into your chatroom" + + def parse_body(self, msg): + klammer_ascii = "\n".join([ + get_nick_from_message(msg) + ''', here's some''', + '''┌────────┐''', + '''│Unicode!│''', + '''└────────┘''' + ]) + return Action(msg=klammer_ascii) + + +class Slap(Worker): + binding_keys = ["nick.slap.#"] + description = "slap people" + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + if len(self.used_channel) > 2: + slapped = " ".join(self.used_channel[2:]) + else: + slapped = sender + return Action(msg='/me slaps {}'.format(slapped)) + + +class Consumables(Worker): + description = "Om nom nom" + usage = "bot: cake/coffee/kaffee/keks/schnitzel [please]" + binding_keys = [ + "nick.cake.*", "nick.cake", + "nick.schnitzel.*", "nick.schnitzel", + "nick.keks.*", "nick.keks", + "nick.cookie.*", "nick.cookie", + "nick.kaffee.*", "nick.kaffee", + ] + + excuses = [ + "No {} for you!", + ("The Enrichment Center is required to remind you " + "that you will be baked, and then there will be {}."), + "The {} is a lie!", + ("This is your fault. I'm going to kill you. " + "And all the {} is gone. You don't even care, do you?"), + "Quit now and {} will be served immediately.", + ("Enrichment Center regulations require both hands to be " + "empty before any {}..."), + ("Uh oh. Somebody cut the {}. I told them to wait for " + "you, but they did it anyway. There is still some left, " + "though, if you hurry back."), + "I'm going to kill you, and all the {} is gone.", + "Who's gonna make the {} when I'm gone? You?" + ] + + def excuse(self, sender, thing): + return Action(msg='{}: {}'.format(sender, random.choice(self.excuses).format(thing))) + + @staticmethod + def give(sender, thing): + thing_giphy_translation = { + "kaffee": "coffee", + "keks": "cookie", + } + gif = giphy(thing_giphy_translation.get(thing, thing), 'dc6zaTOxFJmzC') + return Action(msg='{} for {}: {}'.format(thing, sender, gif)) + + def parse_body(self, msg): + words = get_words(msg) + thing = words[0] + sender = get_nick_from_message(msg) + if len(words) == 2 and words[1] in ('please', 'bitte'): + return self.give(sender, thing) + else: + return self.excuse(sender, thing) + + +class MentalDeficits(Worker): + binding_keys = Worker.CATCH_ALL + description = "reacts on excessive use of exclamation marks" + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + min_ill = 3 + c = 0 + flag = False + + # return True for min_ill '!' in a row + for d in msg['body']: + if '!' == d or '?' == d: + c += 1 + else: + c = 0 + if min_ill <= c: + flag = True + break + + if flag: + return Action(msg='Multiple exclamation/question marks are a sure sign of mental disease, with ' + '{} as a living example.'.format(sender)) + + +class Selfreaction(Worker): + binding_keys = ["/me.#.{}.#".format(conf_get("bot_nickname"))] + description = "reacts to being talked about" + + me_replys = [ + 'are you that rude to everybody?', + 'oh, thank you...', + 'do you really think that was nice?', + 'that sounds very interesting...', + "excuse me, but I'm already late for an appointment" + ] + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + return Action(msg='{}: {}'.format(sender, random.choice(self.me_replys))) + + +class Doctor(Worker): + binding_keys = [ + "#.doctor.#" + "#.doktor.#" + ] + description = "machines don't like the doctor." + + def parse_body(self, msg): + return Action(msg="ELIMINIEREN! ELIMINIEREN!") + + +ALL = [Klammer, Terminate, Unicode, Slap, Consumables, MentalDeficits, Selfreaction, Doctor] diff --git a/distbot/plugins/lookup.py b/distbot/plugins/lookup.py new file mode 100755 index 0000000..c49080c --- /dev/null +++ b/distbot/plugins/lookup.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +""" +Plugins related to looking things up +""" + +import logging +import traceback +import unicodedata + +import requests + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_nick_from_message, get_words + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +# TODO: not implemented yet - the lookup table is crap. looking for other ways. +class UnicodeLookup(Worker): + binding_keys = ["nick.unicode-lookup.#"] + description = "search unicode character" + + def parse_body(self, msg): + search_words = get_words(msg)[1:] + + # TODO + import unicode + + characters = { + k: v for k, v in unicode.characters.items() if + all([word.lower() in v.lower().split() for word in search_words]) + } + lines = [] + + for code, name in characters.items(): + char = chr(int(code, 16)) + lines.append("Character \"{}\" with code {} is named \"{}\"".format(char, code, name)) + if len(lines) > 29: + lines.append("warning: limit (30) reached.") + break + + if not lines: + return { + 'msg': 'No match.' + } + elif len(lines) > 3: + channel = 'priv_msg' + else: + channel = 'msg' + return { + channel: lines + } + + +class UnicodeDecode(Worker): + binding_keys = ["nick.decode.*"] + Worker.CATCH_ALL + description = 'prints the long description of an unicode character' + usage = 'decode {single character} or "decode that" for the last message' + + uses_history = True + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + words = get_words(msg)[1:] + if not words or "decode" not in self.used_channel: + return + logger.debug('decode called for %s' % words[0]) + out = [] + if words[0] == 'that': + if not self.history: + return + else: + to_decode = self.history[-1]['body'] + else: + to_decode = words[0] + + for i, char in enumerate(to_decode): + if i > 9: + out.append('... limit reached.') + break + + char_esc = str(char.encode('unicode_escape'))[3:-1] + + if 0 == len(char_esc): + char_esc = '' + else: + char_esc = ' (%s)' % char_esc + + try: + uni_name = unicodedata.name(char) + except Exception as e: + logger.info('decode(%s) failed: %s' % (char, e)) + out.append("can't decode %s%s: %s" % (char, char_esc, e)) + continue + + out.append('%s%s is called "%s"' % (char, char_esc, uni_name)) + + if 1 == len(out): + return Action(msg='%s: %s' % (sender, out[0])) + else: + return Action(msg='\n'.join(['%s: decoding %s:' % (sender, words[0])] + out)) + + +class Wikipedia(Worker): + binding_keys = ["nick.wp.#", "nick.wp-en.#"] + description = "search the Wikipedia" + usage = "wp {term} or wp-en {term}" + + def parse_body(self, msg): + words = get_words(msg) + sender = get_nick_from_message(msg) + + lang = "de" if words[0] == "wp" else "en" + query = ' '.join(words[1:]) + + if query == '': + return Action(msg=sender + ': no query given') + + apiparams = { + 'action': 'query', + 'prop': 'extracts|info', + 'explaintext': '', + 'redirects': '', + 'exsentences': 2, + 'continue': '', + 'format': 'json', + 'titles': query, + 'inprop': 'url' + } + apiurl = 'https://%s.wikipedia.org/w/api.php' % (lang) + + logger.info('fetching %s' % apiurl) + + try: + response = requests.get(apiurl, params=apiparams).json() + + page = next(iter(response['query']['pages'].values())) + short = page.get('extract') + link = page.get('canonicalurl') + except Exception as e: + logger.error('wp(%s) failed: %s, %s' % (query, e, traceback.format_exc())) + return Action(msg='{}: something failed: {}'.format(sender, e)) + + if short: + return Action(msg=sender + ': %s (<%s>)' % ( + short if short.strip() else '(nix)', link + )) + elif 'missing' in page: + return Action(msg='Article "%s" not found' % page.get('title', query)) + else: + return Action(msg='json data seem to be broken') + + +class DuckDuckGo(Worker): + binding_keys = ["nick.ducksearch.#"] + description = "search the web (using duckduckgo)" + usage = "bot: ducksearch " + + def parse_body(self, msg): + + url = 'http://api.duckduckgo.com/' + params = dict( + q=' '.join(get_words(msg)[1:]), + format='json', + pretty=0, + no_redirect=1, + t='jabberbot' + ) + response = requests.get(url, params=params).json() + link = response.get('AbstractURL') + abstract = response.get('Abstract') + redirect = response.get('Redirect') + + if len(abstract) > 150: + suffix = '…' + else: + suffix = '' + + if link: + return Action(msg='{}{} ({})'.format(abstract[:150], suffix, link)) + elif redirect: + return Action(msg='No direct result found, use {}'.format(redirect)) + else: + return Action(msg='Sorry, no results.') + + +ALL = [UnicodeDecode, Wikipedia, DuckDuckGo] diff --git a/distbot/plugins/meta.py b/distbot/plugins/meta.py new file mode 100755 index 0000000..5f90d64 --- /dev/null +++ b/distbot/plugins/meta.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +import time + +# TODO bot-presence, mute, show-runtimeconfig +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get +from distbot.common.message import get_nick_from_message +from distbot.common.utils import get_version_git + +start_time = time.time() + + +class Version(Worker): + binding_keys = ["nick.version"] + description = "prints version" + + def parse_body(self, msg): + return Action(msg="I'm running {}".format(get_version_git())) + + +class Uptime(Worker): + binding_keys = ["nick.uptime"] + description = "prints uptime" + + def parse_body(self, msg): + uptime = time.time() - start_time + sender = get_nick_from_message(msg) + request_count = conf_get("request_counter") + + return Action(msg="{}: happily serving for {} second{}, {} request{} so far".format( + sender, + uptime, + 's' if uptime == 1 else '', + request_count, + 's' if request_count == 1 else '', + )) + + +class Info(Worker): + binding_keys = ["nick.info", "nick.source"] + description = "prints info message" + + def parse_body(self, msg): + # TODO not totally true regarding task and rate limiting + if "info" in self.used_channel: + return Action(msg=''': I'm a bot, my job is to extract tags from posted URLs. In case I'm annoying or for further + questions, please talk to my master %s. I'm rate limited. + To make me exit immediately, highlight me with 'hangup' in the message + (emergency only, please). For other commands, highlight me with 'help'.''' % ( + conf_get('bot_owner'))) + else: + return Action(msg='My source code can be found at %s' % conf_get('src-url')) + + +ALL = [Version, Uptime, Info] diff --git a/distbot/plugins/morse.py b/distbot/plugins/morse.py new file mode 100755 index 0000000..b193ab2 --- /dev/null +++ b/distbot/plugins/morse.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +import logging +import re + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_nick_from_message, get_words + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +# copy from https://de.wikipedia.org/wiki/Morsezeichen +raw_wiki_copy = """ +A· − +B− · · · +C− · − · +D− · · +E· +F· · − · +G− − · +H· · · · +I· · +J· − − − +K− · − +L· − · · +M− − +N− · +O− − − +P· − − · +Q− − · − +R· − · +S· · · +T− +U· · − +V· · · − +W· − − +X− · · − +Y− · − − +Z− − · · +1· − − − − +2· · − − − +3· · · − − +4· · · · − +5· · · · · +6− · · · · +7− − · · · +8− − − · · +9− − − − · +0− − − − − +""" + + +# machen dictionary aus wikipaste +def wiki_paste_to_morse_dict(wikicopy): + wikicopy = wikicopy.replace(' ', '') + morse_dict = {l[0]: l[1:] for l in wikicopy.splitlines() if l} + return morse_dict + + +ascii_morse = wiki_paste_to_morse_dict(raw_wiki_copy) +morse_ascii = {v: k for k, v in ascii_morse.items()} + + +# return a dictionary of possible morse-chars as key +# and their count as value +def possible_morse_chars(string): + """ + returns dit,dah or None + """ + stats = {} + + for c in re.sub("[\w\d ]", '', string): + try: + stats[c] += 1 + except KeyError: + stats[c] = 1 + + return stats + + +# return morse-encoded string +def morse_encode(string, dot='·', dash='−', sep=' ', ignore_unknown=False): + morse_codes = [] + + for char in string.upper(): + try: + morse_codes.append(ascii_morse[char].replace('·', dot).replace('−', dash)) + except KeyError: + if not ignore_unknown: + morse_codes.append(char) + + return sep.join(morse_codes) + + +# return morse-decoded string with number of errors as tuple +# -> (decoded string, num errors) +def morse_decode(string, dot=None, dash=None): + """ + decode a "morse string" to ascii text + uses \s{2,} as word separator + """ + # dot and dash given, just decode + if dot and dash: + errors = 0 + + words = [] + # drawback: does not allow single characters. + for match in re.finditer('([{dit}{dah}]+((?:\\s)[{dit}{dah}]+)+|\w+)'.format(dit=dot, dah=dash), string): + word = match.group() + logger.debug("morse word: ", word) + if any([dot in word, dash in word]): + w = [] + for morse_character in word.split(): + try: + character = morse_ascii[morse_character.replace(dot, '·').replace(dash, '−')] + print("Converted \t{} \tto {}".format(morse_character, character)) + except KeyError: + character = morse_character + errors += 1 + w.append(character) + words.append(''.join(w)) + # words.append(''.join([morse_ascii[x.replace(dot, '·').replace(dash, '−')] for x in word.split()])) + else: + words.append(word) + return ' '.join(words), errors + + # dot/dash given, search for dash/dot + else: + if not dash: + dash_stats = {x: string.count(x) for x in '-−_'} + dash = max(dash_stats, key=dash_stats.get) + if not dot: + dot_stats = {x: string.count(x) for x in '.·*'} + dot = max(dot_stats, key=dot_stats.get) + + return morse_decode(string, dot=dot, dash=dash) + + +class Morse(Worker): + binding_keys = [ + "nick.morse-decode.*.#", + "nick.morse-decode.that", + "nick.morse-encode.*.#", + "nick.morse-encode.that", + ] + Worker.CATCH_ALL + description = "translates between morse code and string" + usage = "bot: morse-decode/morse-encode <that|message>" + + uses_history = True + + def parse_body(self, msg): + words = get_words(msg) + sender = get_nick_from_message(msg) + + if not ("morse-decode" in self.used_channel or "morse-encode" in self.used_channel): + return + + if self.used_channel[2] == "that" and self.history: + message = self.history[-1]["body"] + else: + message = " ".join(words[1:]) + if self.used_channel[1] == "morse-decode": + return Action(msg="{}: {}".format(sender, morse_decode(message))) + else: + return Action(msg="{}: {}".format(sender, morse_encode(message))) + + +ALL = [Morse] diff --git a/distbot/plugins/muc.py b/distbot/plugins/muc.py new file mode 100755 index 0000000..b44d59d --- /dev/null +++ b/distbot/plugins/muc.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +""" +MUC related plugins +""" + +import time + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, conf_set +from distbot.common.message import get_nick_from_message, get_words + + +# TODO: drehtuer, moin + +class Recorder(Worker): + binding_keys = [ + "nick.show.records.*", + "nick.record.*.#", + "nick.record.*.that", + "nick.record.*.previous", + "userjoin.*", + ] + Worker.CATCH_ALL + + description = "Please talk after the beep. BEEEP." + usage = "use with \"bot: record thatguy I got your money\" " \ + "or \"bot: record thisguy that\" for the previous message." \ + " Use with show records [user} to show currently stored record counters [filtered on that user]" + + channel = None + uses_history = True + + def callback(self, ch, method, properties, body): + self.used_channel = method.routing_key.split(".") + super(Recorder, self).callback(ch, method, properties, body) + + @staticmethod + def record(sender, recipient, recording): + message = '{} ({}): {}'.format(sender, time.strftime('%Y-%m-%d %H:%M'), recording) + message_list = conf_get("user_records.{}".format(recipient)) or [] + message_list.append(message) + conf_set("user_records.{}".format(recipient), message_list) + + def parse_body(self, msg): + words = get_words(msg) + cmd = self.used_channel + sender = get_nick_from_message(msg) + + if cmd[0] == "userjoin": + user = sender + records = conf_get("user_records.{}".format(user)) + if not records: + return + else: + msg = '%s, there %s %d message%s for you:\n%s' % ( + user, + 'is' if len(records) == 1 else 'are', + len(records), + '' if len(records) == 1 else 's', + '\n'.join(records) + ) + conf_set("user_records.{}".format(user), []) + return Action(msg=msg) + # feels a bit clunky + elif cmd[0] == "nick" and len(cmd) == 3: + if cmd[1] == "show" and cmd[2] == "records": + if len(words) == 4: + user = words(3) + else: + user = None + + msg = '%s: offline records%s: %s' % ( + sender, + '' if not user else ' (limited to %s)' % user, + ', '.join( + [ + '%s (%d)' % (key, len(val)) for key, val in conf_get('user_records').items() + if not user or user.lower() in key.lower() + ] + )) + return Action(msg=msg) + elif cmd[1] == "record" and cmd[2] != "show": + recipient = words[1] + if words[2] in ('that', 'previous'): + recording = self.history[-1]["body"] if self.history else "*whistle*" + else: + recording = " ".join(words[2:]) + + self.record(sender, recipient, recording) + return Action(msg="Message saved for {}".format(recipient)) + + +ALL = [Recorder] diff --git a/distbot/plugins/plugin_help.py b/distbot/plugins/plugin_help.py new file mode 100755 index 0000000..7c5401a --- /dev/null +++ b/distbot/plugins/plugin_help.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +import json +import logging + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) +logger = logging.getLogger(__name__) + + +class Plugins(Worker): + """ + Plugin registry + + Plugins send their own definition to the plugin_registry queue, where this thread picks it up and provides + help - even during runtime. + TODO: "unregister plugin" hook, so we can stop plugins and not show their help again + """ + binding_keys = [ + "nick.help", + "nick.help.*", + "nick.plugin.activate.*", + "nick.plugin.deactivate.*", + ] + description = "provide information (and control) about plugins" + usage = "bot: help [plugin name], bot: plugin activate/deactivate <plugin name>" + + plugin_storage = {} + + def __init__(self, actionqueue, queue="work"): + super(Plugins, self).__init__(actionqueue, queue) + self.channel.exchange_declare(exchange='plugin_exc', exchange_type='fanout') + + def run(self): + result = self.channel.queue_declare(exclusive=True) + plugin_queue = result.method.queue + self.channel.queue_bind(exchange='plugin_exc', queue=plugin_queue) + self.channel.basic_consume(self.callback_plugins, queue='plugin_registry') + super(Plugins, self).run() + + def callback_plugins(self, ch, method, properties, body): + body = json.loads(body.decode("utf-8")) + logger.debug("received plugin in registry") + self.plugin_storage[body["name"]] = body + ch.basic_ack(delivery_tag=method.delivery_tag) + + def send_help(self, plugin_name=None): + + if not plugin_name: + msg = "Known commands/reactions:\n" # + ", ".join(self.plugin_storage.keys()) + for plugin, definition in self.plugin_storage.items(): + msg += ("{}: \n" + "Description:\t{}\n" + "Usage:\t{}\n" + "Bindings:\t{}\n\n").format( + plugin, + definition["description"], + definition["usage"], + definition["bindings"] + ) + else: + definition = self.plugin_storage[plugin_name] + msg = "Usage: {}\n".format(definition["usage"]) + + return Action(msg=msg) + + def parse_body(self, msg): + bind = self.used_channel + if bind[1] == "help": + plugin_name = None + if len(bind) > 2: + plugin_name = bind[2] + return self.send_help(plugin_name) + elif bind[1] == "plugin": + + # TODO find a good way to disable plugins (completely OR just suspended) + if bind[2] == "activate": + pass + elif bind[2] == "deactivate": + pass + + +ALL = [Plugins] diff --git a/distbot/plugins/queue_management.py b/distbot/plugins/queue_management.py new file mode 100755 index 0000000..c300dbe --- /dev/null +++ b/distbot/plugins/queue_management.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +# TODO: reset jobs + +ALL = [] diff --git a/distbot/plugins/searx.py b/distbot/plugins/searx.py new file mode 100755 index 0000000..4f20265 --- /dev/null +++ b/distbot/plugins/searx.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +import json +import logging + +import requests +from lxml import html, etree +from requests import HTTPError, ConnectionError +from requests.exceptions import SSLError + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_words +from distbot.common.utils import retry + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +if not hasattr(json, 'JSONDecodeError'): + json.JSONDecodeError = ValueError + + +class BrokenSearxError(RuntimeError): + def __init__(self, url, response): + pass + + +class RateLimitingError(HTTPError): + pass + + +class Searx(Worker): + binding_keys = ["nick.search.#"] + description = "search the web (using searx)" + usage = "bot: search <terms>" + + search_list = [] + + def __init__(self, actionqueue): + super().__init__(actionqueue) + self.search_list = self.fetch_all_searx_engines() + + @staticmethod + def fetch_all_searx_engines(): + # response = requests.get("http://stats.searx.oe5tpo.com") + response = requests.get("https://stats.searx.xyz") + response.raise_for_status() + tree = etree.XML( + response.content, + parser=html.HTMLParser() + ) + searxes = [str(x) for x in tree.xpath('//span[text()[contains(.,"200 - OK")]]/../..//a/text()') if + str(x).startswith("http")] + logger.info("Registered {} searxes".format(len(searxes))) + if not searxes: + raise RuntimeError("not a single searx discovered... " + str(response.content)) + return searxes + + @retry(ExceptionToCheck=(ConnectionError, BrokenSearxError, RateLimitingError, json.JSONDecodeError, SSLError), + tries=10, delay=1, backoff=1.1) + def searx(self, text): + if not self.search_list: + self.search_list = self.fetch_all_searx_engines() + + url = self.search_list[0] + logger.info('Currently feeding from {} (of {} in stock)'.format(url, len(self.search_list))) + try: + response = requests.get(url, params={ + 'q': text, + 'format': 'json', + 'lang': 'en' + }) + except SSLError: + self.search_list.pop(0) + raise + + if response.status_code == 429: + self.search_list.pop(0) + raise RateLimitingError(response=response, request=response.request) + elif response.status_code >= 400: + self.search_list.pop(0) + raise BrokenSearxError(url, response) + try: + response = response.json() + except json.JSONDecodeError: + # "maintenance" they say... + self.search_list.pop(0) + raise + + if 'results' not in response or not response['results']: + return + return [(r.get('content', ''), r['url'], url) for r in response['results']][0] + + def parse_body(self, msg): + + result = self.searx(' '.join(get_words(msg)[1:])) + if not result: + return {'msg': 'Sorry, no results.'} + else: + abstract, url, provider = result + + if len(abstract) > 150: + suffix = '…' + else: + suffix = '' + return Action(msg='{}{} ({}) - powered by {}'.format(abstract[:150], suffix, url, provider)) + + +ALL = [Searx] diff --git a/distbot/plugins/translation.py b/distbot/plugins/translation.py new file mode 100755 index 0000000..c4430bd --- /dev/null +++ b/distbot/plugins/translation.py @@ -0,0 +1,557 @@ +# -*- coding: utf-8 -*- + +import logging + +import re +import requests + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, log_format +from distbot.common.message import get_nick_from_message, get_words + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +class Translator(Worker): + binding_keys = ["nick.translate.show", + "nick.translate.that", + "nick.translate.*.#", + ] + Worker.CATCH_ALL + description = 'translate text fragments, use "translate show" to get a list of languages.' + + usage = "bot: translate that to get the last message translated (to german)" \ + " or bot: translate en|de my favorite bot" + + languages = [ + ('aa', 'Afar'), + ('ab', 'Abkhazian'), + ('af', 'Afrikaans'), + ('ak', 'Akan'), + ('sq', 'Albanian'), + ('am', 'Amharic'), + ('ar', 'Arabic'), + ('an', 'Aragonese'), + ('hy', 'Armenian'), + ('as', 'Assamese'), + ('av', 'Avaric'), + ('ae', 'Avestan'), + ('ay', 'Aymara'), + ('az', 'Azerbaijani'), + ('ba', 'Bashkir'), + ('bm', 'Bambara'), + ('eu', 'Basque'), + ('be', 'Belarusian'), + ('bn', 'Bengali'), + ('bh', 'Bihari languages'), + ('bi', 'Bislama'), + ('bo', 'Tibetan'), + ('bs', 'Bosnian'), + ('br', 'Breton'), + ('bg', 'Bulgarian'), + ('my', 'Burmese'), + ('ca', 'Catalan; Valencian'), + ('cs', 'Czech'), + ('ch', 'Chamorro'), + ('ce', 'Chechen'), + ('zh', 'Chinese'), + ('cu', 'Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic'), + ('cv', 'Chuvash'), + ('kw', 'Cornish'), + ('co', 'Corsican'), + ('cr', 'Cree'), + ('cy', 'Welsh'), + ('cs', 'Czech'), + ('da', 'Danish'), + ('de', 'German'), + ('dv', 'Divehi; Dhivehi; Maldivian'), + ('nl', 'Dutch; Flemish'), + ('dz', 'Dzongkha'), + ('el', 'Greek, Modern (1453-)'), + ('en', 'English'), + ('eo', 'Esperanto'), + ('et', 'Estonian'), + ('eu', 'Basque'), + ('ee', 'Ewe'), + ('fo', 'Faroese'), + ('fa', 'Persian'), + ('fj', 'Fijian'), + ('fi', 'Finnish'), + ('fr', 'French'), + ('fr', 'French'), + ('fy', 'Western Frisian'), + ('ff', 'Fulah'), + ('Ga', 'Georgian'), + ('de', 'German'), + ('gd', 'Gaelic; Scottish Gaelic'), + ('ga', 'Irish'), + ('gl', 'Galician'), + ('gv', 'Manx'), + ('el', 'Greek, Modern (1453-)'), + ('gn', 'Guarani'), + ('gu', 'Gujarati'), + ('ht', 'Haitian; Haitian Creole'), + ('ha', 'Hausa'), + ('he', 'Hebrew'), + ('hz', 'Herero'), + ('hi', 'Hindi'), + ('ho', 'Hiri Motu'), + ('hr', 'Croatian'), + ('hu', 'Hungarian'), + ('hy', 'Armenian'), + ('ig', 'Igbo'), + ('is', 'Icelandic'), + ('io', 'Ido'), + ('ii', 'Sichuan Yi; Nuosu'), + ('iu', 'Inuktitut'), + ('ie', 'Interlingue; Occidental'), + ('ia', 'Interlingua (International Auxiliary Language Association)'), + ('id', 'Indonesian'), + ('ik', 'Inupiaq'), + ('is', 'Icelandic'), + ('it', 'Italian'), + ('jv', 'Javanese'), + ('ja', 'Japanese'), + ('kl', 'Kalaallisut; Greenlandic'), + ('kn', 'Kannada'), + ('ks', 'Kashmiri'), + ('ka', 'Georgian'), + ('kr', 'Kanuri'), + ('kk', 'Kazakh'), + ('km', 'Central Khmer'), + ('ki', 'Kikuyu; Gikuyu'), + ('rw', 'Kinyarwanda'), + ('ky', 'Kirghiz; Kyrgyz'), + ('kv', 'Komi'), + ('kg', 'Kongo'), + ('ko', 'Korean'), + ('kj', 'Kuanyama; Kwanyama'), + ('ku', 'Kurdish'), + ('lo', 'Lao'), + ('la', 'Latin'), + ('lv', 'Latvian'), + ('li', 'Limburgan; Limburger; Limburgish'), + ('ln', 'Lingala'), + ('lt', 'Lithuanian'), + ('lb', 'Luxembourgish; Letzeburgesch'), + ('lu', 'Luba-Katanga'), + ('lg', 'Ganda'), + ('mk', 'Macedonian'), + ('mh', 'Marshallese'), + ('ml', 'Malayalam'), + ('mi', 'Maori'), + ('mr', 'Marathi'), + ('ms', 'Malay'), + ('Mi', 'Micmac'), + ('mk', 'Macedonian'), + ('mg', 'Malagasy'), + ('mt', 'Maltese'), + ('mn', 'Mongolian'), + ('mi', 'Maori'), + ('ms', 'Malay'), + ('my', 'Burmese'), + ('na', 'Nauru'), + ('nv', 'Navajo; Navaho'), + ('nr', 'Ndebele, South; South Ndebele'), + ('nd', 'Ndebele, North; North Ndebele'), + ('ng', 'Ndonga'), + ('ne', 'Nepali'), + ('nl', 'Dutch; Flemish'), + ('nn', 'Norwegian Nynorsk; Nynorsk, Norwegian'), + ('nb', 'Bokmål, Norwegian; Norwegian Bokmål'), + ('no', 'Norwegian'), + ('oc', 'Occitan (post 1500)'), + ('oj', 'Ojibwa'), + ('or', 'Oriya'), + ('om', 'Oromo'), + ('os', 'Ossetian; Ossetic'), + ('pa', 'Panjabi; Punjabi'), + ('fa', 'Persian'), + ('pi', 'Pali'), + ('pl', 'Polish'), + ('pt', 'Portuguese'), + ('ps', 'Pushto; Pashto'), + ('qu', 'Quechua'), + ('rm', 'Romansh'), + ('ro', 'Romanian; Moldavian; Moldovan'), + ('ro', 'Romanian; Moldavian; Moldovan'), + ('rn', 'Rundi'), + ('ru', 'Russian'), + ('sg', 'Sango'), + ('sa', 'Sanskrit'), + ('si', 'Sinhala; Sinhalese'), + ('sk', 'Slovak'), + ('sk', 'Slovak'), + ('sl', 'Slovenian'), + ('se', 'Northern Sami'), + ('sm', 'Samoan'), + ('sn', 'Shona'), + ('sd', 'Sindhi'), + ('so', 'Somali'), + ('st', 'Sotho, Southern'), + ('es', 'Spanish; Castilian'), + ('sq', 'Albanian'), + ('sc', 'Sardinian'), + ('sr', 'Serbian'), + ('ss', 'Swati'), + ('su', 'Sundanese'), + ('sw', 'Swahili'), + ('sv', 'Swedish'), + ('ty', 'Tahitian'), + ('ta', 'Tamil'), + ('tt', 'Tatar'), + ('te', 'Telugu'), + ('tg', 'Tajik'), + ('tl', 'Tagalog'), + ('th', 'Thai'), + ('bo', 'Tibetan'), + ('ti', 'Tigrinya'), + ('to', 'Tonga (Tonga Islands)'), + ('tn', 'Tswana'), + ('ts', 'Tsonga'), + ('tk', 'Turkmen'), + ('tr', 'Turkish'), + ('tw', 'Twi'), + ('ug', 'Uighur; Uyghur'), + ('uk', 'Ukrainian'), + ('ur', 'Urdu'), + ('uz', 'Uzbek'), + ('ve', 'Venda'), + ('vi', 'Vietnamese'), + ('vo', 'Volapük'), + ('cy', 'Welsh'), + ('wa', 'Walloon'), + ('wo', 'Wolof'), + ('xh', 'Xhosa'), + ('yi', 'Yiddish'), + ('yo', 'Yoruba'), + ('za', 'Zhuang; Chuang'), + ('zh', 'Chinese'), + ('zu', 'Zulu') + ] + countries = [ + ('AF', u'Afghanistan'), + ('AX', u'\xc5land Islands'), + ('AL', u'Albania'), + ('DZ', u'Algeria'), + ('AS', u'American Samoa'), + ('AD', u'Andorra'), + ('AO', u'Angola'), + ('AI', u'Anguilla'), + ('AQ', u'Antarctica'), + ('AG', u'Antigua and Barbuda'), + ('AR', u'Argentina'), + ('AM', u'Armenia'), + ('AW', u'Aruba'), + ('AU', u'Australia'), + ('AT', u'Austria'), + ('AZ', u'Azerbaijan'), + ('BS', u'Bahamas'), + ('BH', u'Bahrain'), + ('BD', u'Bangladesh'), + ('BB', u'Barbados'), + ('BY', u'Belarus'), + ('BE', u'Belgium'), + ('BZ', u'Belize'), + ('BJ', u'Benin'), + ('BM', u'Bermuda'), + ('BT', u'Bhutan'), + ('BO', u'Bolivia, Plurinational State of'), + ('BQ', u'Bonaire, Sint Eustatius and Saba'), + ('BA', u'Bosnia and Herzegovina'), + ('BW', u'Botswana'), + ('BV', u'Bouvet Island'), + ('BR', u'Brazil'), + ('IO', u'British Indian Ocean Territory'), + ('BN', u'Brunei Darussalam'), + ('BG', u'Bulgaria'), + ('BF', u'Burkina Faso'), + ('BI', u'Burundi'), + ('KH', u'Cambodia'), + ('CM', u'Cameroon'), + ('CA', u'Canada'), + ('CV', u'Cape Verde'), + ('KY', u'Cayman Islands'), + ('CF', u'Central African Republic'), + ('TD', u'Chad'), + ('CL', u'Chile'), + ('CN', u'China'), + ('CX', u'Christmas Island'), + ('CC', u'Cocos (Keeling Islands)'), + ('CO', u'Colombia'), + ('KM', u'Comoros'), + ('CG', u'Congo'), + ('CD', u'Congo, The Democratic Republic of the'), + ('CK', u'Cook Islands'), + ('CR', u'Costa Rica'), + ('CI', u"C\xf4te D'ivoire"), + ('HR', u'Croatia'), + ('CU', u'Cuba'), + ('CW', u'Cura\xe7ao'), + ('CY', u'Cyprus'), + ('CZ', u'Czech Republic'), + ('DK', u'Denmark'), + ('DJ', u'Djibouti'), + ('DM', u'Dominica'), + ('DO', u'Dominican Republic'), + ('EC', u'Ecuador'), + ('EG', u'Egypt'), + ('SV', u'El Salvador'), + ('GQ', u'Equatorial Guinea'), + ('ER', u'Eritrea'), + ('EE', u'Estonia'), + ('ET', u'Ethiopia'), + ('FK', u'Falkland Islands (Malvinas)'), + ('FO', u'Faroe Islands'), + ('FJ', u'Fiji'), + ('FI', u'Finland'), + ('FR', u'France'), + ('GF', u'French Guiana'), + ('PF', u'French Polynesia'), + ('TF', u'French Southern Territories'), + ('GA', u'Gabon'), + ('GM', u'Gambia'), + ('GE', u'Georgia'), + ('DE', u'Germany'), + ('GH', u'Ghana'), + ('GI', u'Gibraltar'), + ('GR', u'Greece'), + ('GL', u'Greenland'), + ('GD', u'Grenada'), + ('GP', u'Guadeloupe'), + ('GU', u'Guam'), + ('GT', u'Guatemala'), + ('GG', u'Guernsey'), + ('GN', u'Guinea'), + ('GW', u'Guinea-bissau'), + ('GY', u'Guyana'), + ('HT', u'Haiti'), + ('HM', u'Heard Island and McDonald Islands'), + ('VA', u'Holy See (Vatican City State)'), + ('HN', u'Honduras'), + ('HK', u'Hong Kong'), + ('HU', u'Hungary'), + ('IS', u'Iceland'), + ('IN', u'India'), + ('ID', u'Indonesia'), + ('IR', u'Iran, Islamic Republic of'), + ('IQ', u'Iraq'), + ('IE', u'Ireland'), + ('IM', u'Isle of Man'), + ('IL', u'Israel'), + ('IT', u'Italy'), + ('JM', u'Jamaica'), + ('JP', u'Japan'), + ('JE', u'Jersey'), + ('JO', u'Jordan'), + ('KZ', u'Kazakhstan'), + ('KE', u'Kenya'), + ('KI', u'Kiribati'), + ('KP', u"Korea, Democratic People's Republic of"), + ('KR', u'Korea, Republic of'), + ('KW', u'Kuwait'), + ('KG', u'Kyrgyzstan'), + ('LA', u"Lao People's Democratic Republic"), + ('LV', u'Latvia'), + ('LB', u'Lebanon'), + ('LS', u'Lesotho'), + ('LR', u'Liberia'), + ('LY', u'Libya'), + ('LI', u'Liechtenstein'), + ('LT', u'Lithuania'), + ('LU', u'Luxembourg'), + ('MO', u'Macao'), + ('MK', u'Macedonia, The Former Yugoslav Republic of'), + ('MG', u'Madagascar'), + ('MW', u'Malawi'), + ('MY', u'Malaysia'), + ('MV', u'Maldives'), + ('ML', u'Mali'), + ('MT', u'Malta'), + ('MH', u'Marshall Islands'), + ('MQ', u'Martinique'), + ('MR', u'Mauritania'), + ('MU', u'Mauritius'), + ('YT', u'Mayotte'), + ('MX', u'Mexico'), + ('FM', u'Micronesia, Federated States of'), + ('MD', u'Moldova, Republic of'), + ('MC', u'Monaco'), + ('MN', u'Mongolia'), + ('ME', u'Montenegro'), + ('MS', u'Montserrat'), + ('MA', u'Morocco'), + ('MZ', u'Mozambique'), + ('MM', u'Myanmar'), + ('NA', u'Namibia'), + ('NR', u'Nauru'), + ('NP', u'Nepal'), + ('NL', u'Netherlands'), + ('NC', u'New Caledonia'), + ('NZ', u'New Zealand'), + ('NI', u'Nicaragua'), + ('NE', u'Niger'), + ('NG', u'Nigeria'), + ('NU', u'Niue'), + ('NF', u'Norfolk Island'), + ('MP', u'Northern Mariana Islands'), + ('NO', u'Norway'), + ('OM', u'Oman'), + ('PK', u'Pakistan'), + ('PW', u'Palau'), + ('PS', u'Palestinian Territory, Occupied'), + ('PA', u'Panama'), + ('PG', u'Papua New Guinea'), + ('PY', u'Paraguay'), + ('PE', u'Peru'), + ('PH', u'Philippines'), + ('PN', u'Pitcairn'), + ('PL', u'Poland'), + ('PT', u'Portugal'), + ('PR', u'Puerto Rico'), + ('QA', u'Qatar'), + ('RE', u'R\xe9union'), + ('RO', u'Romania'), + ('RU', u'Russian Federation'), + ('RW', u'Rwanda'), + ('BL', u'Saint Barth\xe9lemy'), + ('SH', u'Saint Helena, Ascension and Tristan Da Cunha'), + ('KN', u'Saint Kitts and Nevis'), + ('LC', u'Saint Lucia'), + ('MF', u'Saint Martin (French Part)'), + ('PM', u'Saint Pierre and Miquelon'), + ('VC', u'Saint Vincent and the Grenadines'), + ('WS', u'Samoa'), + ('SM', u'San Marino'), + ('ST', u'Sao Tome and Principe'), + ('SA', u'Saudi Arabia'), + ('SN', u'Senegal'), + ('RS', u'Serbia'), + ('SC', u'Seychelles'), + ('SL', u'Sierra Leone'), + ('SG', u'Singapore'), + ('SX', u'Sint Maarten (Dutch Part)'), + ('SK', u'Slovakia'), + ('SI', u'Slovenia'), + ('SB', u'Solomon Islands'), + ('SO', u'Somalia'), + ('ZA', u'South Africa'), + ('GS', u'South Georgia and the South Sandwich Islands'), + ('SS', u'South Sudan'), + ('ES', u'Spain'), + ('LK', u'Sri Lanka'), + ('SD', u'Sudan'), + ('SR', u'Suriname'), + ('SJ', u'Svalbard and Jan Mayen'), + ('SZ', u'Swaziland'), + ('SE', u'Sweden'), + ('CH', u'Switzerland'), + ('SY', u'Syrian Arab Republic'), + ('TW', u'Taiwan, Province of China'), + ('TJ', u'Tajikistan'), + ('TZ', u'Tanzania, United Republic of'), + ('TH', u'Thailand'), + ('TL', u'Timor-leste'), + ('TG', u'Togo'), + ('TK', u'Tokelau'), + ('TO', u'Tonga'), + ('TT', u'Trinidad and Tobago'), + ('TN', u'Tunisia'), + ('TR', u'Turkey'), + ('TM', u'Turkmenistan'), + ('TC', u'Turks and Caicos Islands'), + ('TV', u'Tuvalu'), + ('UG', u'Uganda'), + ('UA', u'Ukraine'), + ('AE', u'United Arab Emirates'), + ('GB', u'United Kingdom'), + ('US', u'United States'), + ('UM', u'United States Minor Outlying Islands'), + ('UY', u'Uruguay'), + ('UZ', u'Uzbekistan'), + ('VU', u'Vanuatu'), + ('VE', u'Venezuela, Bolivarian Republic of'), + ('VN', u'Viet Nam'), + ('VG', u'Virgin Islands, British'), + ('VI', u'Virgin Islands, U.S.'), + ('WF', u'Wallis and Futuna'), + ('EH', u'Western Sahara'), + ('YE', u'Yemen'), + ('ZM', u'Zambia'), + ('ZW', u'Zimbabwe') + ] + + uses_history = True + + def translate(self, text, language, dest): + url = 'http://api.mymemory.translated.net/get' + params = { + 'q': text, + 'langpair': "{}|{}".format(language, dest), + 'de': conf_get('bot_owner_email') + } + response = requests.get(url, params=params).json() + translated_text = response['responseData']['translatedText'] + return translated_text + + def parse_body(self, msg): + available_languages = [code[0] for code in self.languages] + words = get_words(msg) + sender = get_nick_from_message(msg) + + if not words: + return + + if words[0] == "translate": + if words[1] == "show": + return Action(priv_msg='All language codes: {}'.format(', '.join(available_languages)), + sender=msg["from"]) + + elif words[1] == "that": + api_key = conf_get('plugins.translate.detectlanguage_api_key') + if not api_key: + return + if not self.history: + return + last_message = self.history[-1]['body'] + data = { + 'q': last_message, + 'key': api_key + } + result = requests.post('http://ws.detectlanguage.com/0.2/detect', data=data).json() + educated_guess = result['data']['detections'][0] + if not educated_guess['isReliable']: + return Action(msg='not sure about the language.') + else: + return Action(msg=self.translate(text=last_message, language=educated_guess['language'], dest="de")) + else: + pattern = '^(?P<from_lang>[a-z-]{2})(-(?P<from_ct>[a-z-]{2}))?\|' \ + '(?P<to_lang>[a-z-]{2})(-(?P<to_ct>[a-z-]{2}))?$' + pair = re.match(pattern, words[1]) + if not pair: + return Action(msg=self.usage) + else: + pair = pair.groupdict() + from_lang = pair.get('from_lang') + to_lang = pair.get('to_lang') + + # TODO: check country code as well + if not all([lang in available_languages for lang in [from_lang, to_lang]]): + return Action(msg='{}: not a valid language code. Please use ISO 639-1 or RFC3066 codes. ' + 'Use "translate show" to get a full list of all known language ' + 'codes (not necessarily supported) as privmsg.'.format(sender)) + + # TODO: it would be more precise to send the country as well (again). + return Action( + msg='translation: {}'.format(self.translate(text=words[2:], language=from_lang, dest=to_lang)) + ) + else: + logger.debug("eating %s", msg["body"]) + + +ALL = [Translator] diff --git a/distbot/plugins/url.py b/distbot/plugins/url.py new file mode 100755 index 0000000..f3aece5 --- /dev/null +++ b/distbot/plugins/url.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" +Plugins for user specific functions +""" +import logging +from urllib.parse import urlparse + +import requests + +from distbot.bot.worker import Worker +from distbot.common.action import Action +from distbot.common.config import conf_get, conf_set, log_format +from distbot.common.message import get_nick_from_message, get_words + +logging.basicConfig( + level=conf_get("loglevel"), + format=log_format +) + +logger = logging.getLogger(__name__) + + +class SpoilerSetting(Worker): + binding_keys = ["nick.set.spoiler.*", "nick.set.spoiler"] + description = "enable/disable title lookups for own links" + usage = "bot: set spoiler on/off" + + def parse_body(self, msg): + sender = get_nick_from_message(msg) + words = get_words(msg)[1:] + + setting = "on" if conf_get('user_pref.{}.spoiler'.format(sender)) else "off" + if len(words) == 2: + value = words[1] + else: + value = None + + if not value: + return Action(msg="{}: spoiler is {}".format(sender, setting)) + elif value not in ('on', 'off'): + return Action(msg='{}: possible values for spoiler: on, off'.format(sender)) + else: + conf_set("user_pref.{}.spoiler", value == "on") + return Action(msg='okay, {].'.format(sender)) + + +class URLBlacklist(Worker): + description = "show the current URL blacklist" + binding_keys = ["nick.blacklist"] + + def parse_body(self, msg): + blacklist = conf_get('plugins.urlresolve.blacklist') + return Action(msg="URLs blacklisted: %s" % '\n'.join(blacklist)) + + +class IsDown(Worker): + description = "check if a website is reachable" + binding_keys = ["nick.isdown.#"] + usage = "bot: isdown debianforum.de" + + def parse_body(self, msg): + words = get_words(msg) + sender = get_nick_from_message(msg) + + url = words[0] + if 'http' not in url: + url = 'http://{}'.format(url) + response = requests.get('http://www.isup.me/{}'.format(urlparse(url).hostname)).text + if "looks down" in response: + return Action(msg='{}: {} looks down'.format(sender, url)) + elif "is up" in response: + return Action(msg='{}: {} looks up'.format(sender, url)) + elif "site on the interwho" in response: + return Action(msg='{}: {} does not exist, you\'re trying to fool me?'.format(sender, url)) + + +ALL = [SpoilerSetting, URLBlacklist, IsDown] diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..59c6916 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, find_packages + +setup( + name="distbot", + version=0.1, + description="the distributed bot", + install_requires=["sleekxmpp", "pika", "flask", 'configobj', 'requests', 'lxml'], + test_requires=["pytest"], + extras_require={ + 'test': ["pytest"], + }, + entry_points={ + 'console_scripts': [ + ] + }, + packages=find_packages(exclude=['Readme.md', 'tests', 'doc']), + +) -- 2.39.2