diff options
| author | 2018-09-24 15:13:27 +0100 | |
|---|---|---|
| committer | 2018-09-24 15:13:27 +0100 | |
| commit | ecb9d7cb3f4435457560e03201bbed57a469d548 (patch) | |
| tree | 5a010f97c209558cdd2d40327d41e6806aedde94 /src | |
| parent | Remove empty spaces in coins.py (diff) | |
| signature | ||
Move most code in root directory to src/
Diffstat (limited to 'src')
| -rw-r--r-- | src/Config.py | 16 | ||||
| -rw-r--r-- | src/Database.py | 369 | ||||
| -rw-r--r-- | src/EventManager.py | 226 | ||||
| -rw-r--r-- | src/Exports.py | 44 | ||||
| -rw-r--r-- | src/IRCBot.py | 197 | ||||
| -rw-r--r-- | src/IRCBuffer.py | 48 | ||||
| -rw-r--r-- | src/IRCChannel.py | 132 | ||||
| -rw-r--r-- | src/IRCLineHandler.py | 593 | ||||
| -rw-r--r-- | src/IRCServer.py | 402 | ||||
| -rw-r--r-- | src/IRCUser.py | 62 | ||||
| -rw-r--r-- | src/Logging.py | 48 | ||||
| -rw-r--r-- | src/ModuleManager.py | 147 | ||||
| -rw-r--r-- | src/Timer.py | 39 | ||||
| -rw-r--r-- | src/Utils.py | 287 |
14 files changed, 2610 insertions, 0 deletions
diff --git a/src/Config.py b/src/Config.py new file mode 100644 index 00000000..71e01871 --- /dev/null +++ b/src/Config.py @@ -0,0 +1,16 @@ +import configparser, os + +class Config(object): + def __init__(self, bot, directory, filename="bot.conf"): + self.bot = bot + self.filename = filename + self.full_location = os.path.join(directory, filename) + self.bot.config = {} + self.load_config() + + def load_config(self): + if os.path.isfile(self.full_location): + with open(self.full_location) as config_file: + parser = configparser.ConfigParser() + parser.read_string(config_file.read()) + return dict(parser["bot"].items()) diff --git a/src/Database.py b/src/Database.py new file mode 100644 index 00000000..dc87b004 --- /dev/null +++ b/src/Database.py @@ -0,0 +1,369 @@ +import json, os, sqlite3, threading, time + +class Table(object): + def __init__(self, database): + self.database = database + +class Servers(Table): + def add(self, alias, hostname, port, password, ipv4, tls, nickname, + username=None, realname=None): + username = username or nickname + realname = realname or nickname + self.database.execute( + """INSERT INTO servers (alias, hostname, port, password, ipv4, + tls, nickname, username, realname) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?)""", + [hostname, port, password, ipv4, tls, nickname, username, realname]) + def get_all(self): + return self.database.execute_fetchall( + "SELECT server_id, alias FROM servers") + def get(self, id): + return self.database.execute_fetchone( + """SELECT server_id, alias, hostname, port, password, ipv4, + tls, nickname, username, realname FROM servers WHERE + server_id=?""", + [id]) + +class Channels(Table): + def add(self, server_id, name): + self.database.execute("""INSERT OR IGNORE INTO channels + (server_id, name) VALUES (?, ?)""", + [server_id, name.lower()]) + def delete(self, channel_id): + self.database.execute("DELETE FROM channels WHERE channel_id=?", + [channel_id]) + def get_id(self, server_id, name): + value = self.database.execute_fetchone("""SELECT channel_id FROM + channels WHERE server_id=? AND name=?""", + [server_id, name.lower()]) + return value if value == None else value[0] + +class Users(Table): + def add(self, server_id, nickname): + self.database.execute("""INSERT OR IGNORE INTO users + (server_id, nickname) VALUES (?, ?)""", + [server_id, nickname.lower()]) + def delete(self, user_id): + self.database.execute("DELETE FROM users WHERE user_id=?", + [user_id]) + def get_id(self, server_id, nickname): + value = self.database.execute_fetchone("""SELECT user_id FROM + users WHERE server_id=? and nickname=?""", + [server_id, nickname.lower()]) + return value if value == None else value[0] + +class BotSettings(Table): + def set(self, setting, value): + self.database.execute( + "INSERT OR REPLACE INTO bot_settings VALUES (?, ?)", + [setting.lower(), json.dumps(value)]) + def get(self, setting, default=None): + value = self.database.execute_fetchone( + "SELECT value FROM bot_settings WHERE setting=?", + [setting.lower()]) + if value: + return json.loads(value[0]) + return default + def find(self, pattern, default=[]): + values = self.database.execute_fetchall( + "SELECT setting, value FROM bot_settings WHERE setting LIKE ?", + [pattern.lower()]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find_prefix(self, prefix, default=[]): + return self.find_bot_settings("%s%" % prefix, default) + def delete(self, setting): + self.database.execute( + "DELETE FROM bot_settings WHERE setting=?", + [setting.lower()]) + +class ServerSettings(Table): + def set(self, server_id, setting, value): + self.database.execute( + "INSERT OR REPLACE INTO server_settings VALUES (?, ?, ?)", + [server_id, setting.lower(), json.dumps(value)]) + def get(self, server_id, setting, default=None): + value = self.database.execute_fetchone( + """SELECT value FROM server_settings WHERE + server_id=? AND setting=?""", + [server_id,setting.lower()]) + if value: + return json.loads(value[0]) + return default + def find(self, server_id, pattern, default=[]): + values = self.database.execute_fetchall( + """SELECT setting, value FROM server_settings WHERE + server_id=? AND setting LIKE ?""", + [server_id, pattern.lower()]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find_prefix(self, server_id, prefix, default=[]): + return self.find_server_settings(server_id, "%s%" % prefix, default) + def delete(self, server_id, setting): + self.database.execute( + "DELETE FROM server_settings WHERE server_id=? AND setting=?", + [server_id, setting.lower()]) + +class ChannelSettings(Table): + def set(self, channel_id, setting, value): + self.database.execute( + "INSERT OR REPLACE INTO channel_settings VALUES (?, ?, ?)", + [channel_id, setting.lower(), json.dumps(value)]) + def get(self, channel_id, setting, default=None): + value = self.database.execute_fetchone( + """SELECT value FROM channel_settings WHERE + channel_id=? AND setting=?""", [channel_id, setting.lower()]) + if value: + return json.loads(value[0]) + return default + def find(self, channel_id, pattern, default=[]): + values = self.database.execute_fetchall( + """SELECT setting, value FROM channel_settings WHERE + channel_id=? setting LIKE '?'""", [channel_id, pattern.lower()]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find_prefix(self, channel_id, prefix, default=[]): + return self.find_channel_settings(channel_id, "%s%" % prefix, + default) + def delete(self, channel_id, setting): + self.database.execute( + """DELETE FROM channel_settings WHERE channel_id=? + AND setting=?""", [channel_id, setting.lower()]) + +class UserSettings(Table): + def set(self, user_id, setting, value): + self.database.execute( + "INSERT OR REPLACE INTO user_settings VALUES (?, ?, ?)", + [user_id, setting.lower(), json.dumps(value)]) + def get(self, user_id, setting, default=None): + value = self.database.execute_fetchone( + """SELECT value FROM user_settings WHERE + user_id=? and setting=?""", [user_id, setting.lower()]) + if value: + return json.loads(value[0]) + return default + def find_all_by_setting(self, server_id, setting, default=[]): + values = self.database.execute_fetchall( + """SELECT users.nickname, user_settings.value FROM + user_settings INNER JOIN users ON + user_settings.user_id=users.user_id WHERE + users.server_id=? AND user_settings.setting=?""", + [server_id, setting]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find(self, user_id, pattern, default=[]): + values = self.database.execute( + """SELECT setting, value FROM user_settings WHERE + user_id=? AND setting LIKE '?'""", [user_id, pattern.lower()]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find_prefix(self, user_id, prefix, default=[]): + return self.find_user_settings(user_id, "%s%" % prefix, default) + def delete(self, user_id, setting): + self.database.execute( + """DELETE FROM user_settings WHERE + user_id=? AND setting=?""", [user_id, setting.lower()]) + +class UserChannelSettings(Table): + def set(self, user_id, channel_id, setting, value): + self.database.execute( + """INSERT OR REPLACE INTO user_channel_settings VALUES + (?, ?, ?, ?)""", + [user_id, channel_id, setting.lower(), json.dumps(value)]) + def get(self, user_id, channel_id, setting, default=None): + value = self.database.execute_fetchone( + """SELECT value FROM user_channel_settings WHERE + user_id=? AND channel_id=? AND setting=?""", + [user_id, channel_id, setting.lower()]) + if value: + return json.loads(value[0]) + return default + def find(self, user_id, channel_id, pattern, default=[]): + values = self.database.execute_fetchall( + """SELECT setting, value FROM user_channel_settings WHERE + user_id=? AND channel_id=? AND setting LIKE '?'""", + [user_id, channel_id, pattern.lower()]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find_prefix(self, user_id, channel_id, prefix, default=[]): + return self.find_user_settings(user_id, channel_id, "%s%" % prefix, + default) + def find_by_setting(self, user_id, setting, default=[]): + values = self.database.execute_fetchall( + """SELECT channels.name, user_channel_settings.value FROM + user_channel_settings INNER JOIN channels ON + user_channel_settings.channel_id=channels.channel_id + WHERE user_channel_settings.setting=? + AND user_channel_settings.user_id=?""", [setting, user_id]) + if values: + for i, value in enumerate(values): + values[i] = value[0], json.loads(value[1]) + return values + return default + def find_all_by_setting(self, server_id, setting, default=[]): + values = self.database.execute_fetchall( + """SELECT channels.name, users.nickname, + user_channel_settings.value FROM + user_channel_settings INNER JOIN channels ON + user_channel_settings.channel_id=channels.channel_id + INNER JOIN users on user_channel_settings.user_id=users.user_id + WHERE user_channel_settings.setting=? AND + users.server_id=?""", [setting, server_id]) + if values: + for i, value in enumerate(values): + values[i] = value[0], value[1], json.loads(value[2]) + return values + return default + def delete(self, user_id, channel_id, setting): + self.database.execute( + """DELETE FROM user_channel_settings WHERE + user_id=? AND channel_id=? AND setting=?""", + [user_id, channel_id, setting.lower()]) + +class Database(object): + def __init__(self, bot, directory, filename="bot.db"): + self.bot = bot + self.filename = filename + self.full_location = os.path.join(directory, filename) + self.database = sqlite3.connect(self.full_location, + check_same_thread=False, isolation_level=None) + self.database.execute("PRAGMA foreign_keys = ON") + self._cursor = None + + self.make_servers_table() + self.make_channels_table() + self.make_users_table() + self.make_bot_settings_table() + self.make_server_settings_table() + self.make_channel_settings_table() + self.make_user_settings_table() + self.make_user_channel_settings_table() + + self.servers = Servers(self) + self.channels = Channels(self) + self.users = Users(self) + self.bot_settings = BotSettings(self) + self.server_settings = ServerSettings(self) + self.channel_settings = ChannelSettings(self) + self.user_settings = UserSettings(self) + self.user_channel_settings = UserChannelSettings(self) + + def cursor(self): + if self._cursor == None: + self._cursor = self.database.cursor() + return self._cursor + + def _execute_fetch(self, query, fetch_func, params=[]): + printable_query = " ".join(query.split()) + self.bot.log.debug("executing query: \"%s\" (params: %s)", + [printable_query, params]) + start = time.monotonic() + + cursor = self.cursor() + cursor.execute(query, params) + value = fetch_func(cursor) + + end = time.monotonic() + total_milliseconds = (end - start) * 1000 + self.bot.log.debug("executed in %fms", [total_milliseconds]) + + return value + def execute_fetchall(self, query, params=[]): + return self._execute_fetch(query, + lambda cursor: cursor.fetchall(), params) + def execute_fetchone(self, query, params=[]): + return self._execute_fetch(query, + lambda cursor: cursor.fetchone(), params) + def execute(self, query, params=[]): + return self._execute_fetch(query, lambda cursor: None, params) + + def has_table(self, table_name): + result = self.execute_fetchone("""SELECT COUNT(*) FROM + sqlite_master WHERE type='table' AND name=?""", + [table_name]) + return result[0] == 1 + + def make_servers_table(self): + if not self.has_table("servers"): + self.execute("""CREATE TABLE servers + (server_id INTEGER PRIMARY KEY, alias TEXT, hostname TEXT, + port INTEGER,password TEXT,ipv4 BOOLEAN, tls BOOLEAN, + nickname TEXT, username TEXT, realname TEXT)""") + def make_channels_table(self): + if not self.has_table("channels"): + self.execute("""CREATE TABLE channels + (channel_id INTEGER PRIMARY KEY, server_id INTEGER, + name TEXT, FOREIGN KEY (server_id) REFERENCES + servers (server_id) ON DELETE CASCADE, + UNIQUE (server_id, name))""") + self.execute("""CREATE INDEX channels_index + on channels (server_id, name)""") + def make_users_table(self): + if not self.has_table("users"): + self.execute("""CREATE TABLE users + (user_id INTEGER PRIMARY KEY, server_id INTEGER, + nickname TEXT, FOREIGN KEY (server_id) REFERENCES + servers (server_id) ON DELETE CASCADE, + UNIQUE (server_id, nickname))""") + self.execute("""CREATE INDEX users_index + on users (server_id, nickname)""") + def make_bot_settings_table(self): + if not self.has_table("bot_settings"): + self.execute("""CREATE TABLE bot_settings + (setting TEXT PRIMARY KEY, value TEXT)""") + self.execute("""CREATE INDEX bot_settings_index + ON bot_settings (setting)""") + def make_server_settings_table(self): + if not self.has_table("server_settings"): + self.execute("""CREATE TABLE server_settings + (server_id INTEGER, setting TEXT, value TEXT, + FOREIGN KEY(server_id) REFERENCES + servers(server_id) ON DELETE CASCADE, + PRIMARY KEY (server_id, setting))""") + self.execute("""CREATE INDEX server_settings_index + ON server_settings (server_id, setting)""") + def make_channel_settings_table(self): + if not self.has_table("channel_settings"): + self.execute("""CREATE TABLE channel_settings + (channel_id INTEGER, setting TEXT, value TEXT, + FOREIGN KEY (channel_id) REFERENCES channels(channel_id) + ON DELETE CASCADE, PRIMARY KEY (channel_id, setting))""") + self.execute("""CREATE INDEX channel_settings_index + ON channel_settings (channel_id, setting)""") + def make_user_settings_table(self): + if not self.has_table("user_settings"): + self.execute("""CREATE TABLE user_settings + (user_id INTEGER, setting TEXT, value TEXT, + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE CASCADE, PRIMARY KEY (user_id, setting))""") + self.execute("""CREATE INDEX user_settings_index ON + user_settings (user_id, setting)""") + def make_user_channel_settings_table(self): + if not self.has_table("user_channel_settings"): + self.execute("""CREATE TABLE user_channel_settings + (user_id INTEGER, channel_id INTEGER, setting TEXT, + value TEXT, FOREIGN KEY (user_id) REFERENCES + users(user_id) ON DELETE CASCADE, FOREIGN KEY + (channel_id) REFERENCES channels(channel_id) ON + DELETE CASCADE, PRIMARY KEY (user_id, channel_id, + setting))""") + self.execute("""CREATE INDEX user_channel_settings_index + ON user_channel_settings (user_id, channel_id, setting)""") diff --git a/src/EventManager.py b/src/EventManager.py new file mode 100644 index 00000000..604ae337 --- /dev/null +++ b/src/EventManager.py @@ -0,0 +1,226 @@ +import itertools, time, traceback + +PRIORITY_URGENT = 0 +PRIORITY_HIGH = 1 +PRIORITY_MEDIUM = 2 +PRIORITY_LOW = 3 +PRIORITY_MONITOR = 4 + +DEFAULT_PRIORITY = PRIORITY_MEDIUM +DEFAULT_DELIMITER = "." + +class Event(object): + def __init__(self, bot, name, **kwargs): + self.bot = bot + self.name = name + self.kwargs = kwargs + self.eaten = False + def __getitem__(self, key): + return self.kwargs[key] + def get(self, key, default=None): + return self.kwargs.get(key, default) + def __contains__(self, key): + return key in self.kwargs + def eat(self): + self.eaten = True + +class EventCallback(object): + def __init__(self, function, bot, priority, kwargs): + self.function = function + self.bot = bot + self.priority = priority + self.kwargs = kwargs + def call(self, event): + return self.function(event) + +class MultipleEventHook(object): + def __init__(self): + self._event_hooks = set([]) + def _add(self, event_hook): + self._event_hooks.add(event_hook) + + def hook(self, function, **kwargs): + for event_hook in self._event_hooks: + event_hook.hook(function, **kwargs) + + def call_limited(self, maximum, **kwargs): + returns = [] + for event_hook in self._event_hooks: + returns.append(event_hook.call_limited(maximum, **kwargs)) + return returns + def call(self, **kwargs): + returns = [] + for event_hook in self._event_hooks: + returns.append(event_hook.call(**kwargs)) + return returns + +class EventHookContext(object): + def __init__(self, parent, context): + self._parent = parent + self.context = context + def hook(self, function, priority=DEFAULT_PRIORITY, replay=False, + **kwargs): + self._parent._context_hook(self.context, function, priority, replay, + kwargs) + def on(self, subevent, *extra_subevents, delimiter=DEFAULT_DELIMITER): + return self._parent._context_on(self.context, subevent, + extra_subevents, delimiter) + def call_for_result(self, default=None, **kwargs): + return self._parent.call_for_result(default, **kwargs) + def assure_call(self, **kwargs): + self._parent.assure_call(**kwargs) + def call(self, **kwargs): + return self._parent.call(**kwargs) + def call_limited(self, maximum, **kwargs): + return self._parent.call_limited(maximum, **kwargs) + def get_hooks(self): + return self._parent.get_hooks() + def get_children(self): + return self._parent.get_children() + +class EventHook(object): + def __init__(self, bot, name=None, parent=None): + self.bot = bot + self.name = name + self.parent = parent + self._children = {} + self._hooks = [] + self._stored_events = [] + self._context_hooks = {} + + def _make_event(self, kwargs): + return Event(self.bot, self.name, **kwargs) + + def _get_path(self): + path = [] + parent = self + while not parent == None and not parent.name == None: + path.append(parent.name) + parent = parent.parent + return DEFAULT_DELIMITER.join(path[::-1]) + + def new_context(self, context): + return EventHookContext(self, context) + + def hook(self, function, priority=DEFAULT_PRIORITY, replay=False, + **kwargs): + self._hook(function, None, priority, replay, kwargs) + def _context_hook(self, context, function, priority, replay, kwargs): + self._hook(function, context, priority, replay, kwargs) + def _hook(self, function, context, priority, replay, kwargs): + callback = EventCallback(function, self.bot, priority, kwargs) + + if context == None: + self._hooks.append(callback) + else: + if not context in self._context_hooks: + self._context_hooks[context] = [] + self._context_hooks[context].append(callback) + + if replay and not self._stored_events == None: + for kwargs in self._stored_events: + self._call(kwargs) + self._stored_events = None + + def on(self, subevent, *extra_subevents, delimiter=DEFAULT_DELIMITER): + return self._on(subevent, extra_subevents, None, delimiter) + def _context_on(self, context, subevent, extra_subevents, + delimiter=DEFAULT_DELIMITER): + return self._on(subevent, extra_subevents, context, delimiter) + def _on(self, subevent, extra_subevents, context, delimiter): + if delimiter in subevent: + event_chain = subevent.split(delimiter) + event_obj = self + for event_name in event_chain: + event_obj = event_obj.get_child(event_name) + if not context == None: + return event_obj.new_context(context) + return event_obj + + if extra_subevents: + multiple_event_hook = MultipleEventHook() + for extra_subevent in (subevent,)+extra_subevents: + child = self.get_child(extra_subevent) + if not context == None: + child = child.new_context(context) + multiple_event_hook._add(child) + return multiple_event_hook + + child = self.get_child(subevent) + if not context == None: + child = child.new_context(context) + return child + + def call_for_result(self, default=None, **kwargs): + results = self.call_limited(1, **kwargs) + return default if not len(results) else results[0] + def assure_call(self, **kwargs): + if not self._stored_events == None: + self._stored_events.append(kwargs) + else: + self._call(kwargs) + def call(self, **kwargs): + return self._call(kwargs) + def call_limited(self, maximum, **kwargs): + return self._call(kwargs, maximum=maximum) + def _call(self, kwargs, maximum=None): + event_path = self._get_path() + self.bot.log.debug("calling event: \"%s\" (params: %s)", + [event_path, kwargs]) + start = time.monotonic() + + event = self._make_event(kwargs) + returns = [] + for hook in self.get_hooks()[:maximum]: + if event.eaten: + break + try: + returns.append(hook.call(event)) + except Exception as e: + traceback.print_exc() + self.bot.log.error("failed to call event \"%s\"", [ + event_path], exc_info=True) + + total_milliseconds = (time.monotonic() - start) * 1000 + self.bot.log.debug("event \"%s\" called in %fms", [ + event_path, total_milliseconds]) + + self.check_purge() + + return returns + + def get_child(self, child_name): + child_name_lower = child_name.lower() + if not child_name_lower in self._children: + self._children[child_name_lower] = EventHook(self.bot, + child_name_lower, self) + return self._children[child_name_lower] + def remove_child(self, child_name): + child_name_lower = child_name.lower() + if child_name_lower in self._children: + del self._children[child_name_lower] + + def check_purge(self): + if self.is_empty() and not self.parent == None: + self.parent.remove_child(self.name) + self.parent.check_purge() + + def remove_context(self, context): + del self._context_hooks[context] + def has_context(self, context): + return context in self._context_hooks + def purge_context(self, context): + if self.has_context(context): + self.remove_context(context) + + for child_name in self.get_children()[:]: + child = self.get_child(child_name) + child.purge_context(context) + + def get_hooks(self): + return sorted(self._hooks + sum(self._context_hooks.values(), []), + key=lambda e: e.priority) + def get_children(self): + return list(self._children.keys()) + def is_empty(self): + return len(self.get_hooks() + self.get_children()) == 0 diff --git a/src/Exports.py b/src/Exports.py new file mode 100644 index 00000000..8baca50d --- /dev/null +++ b/src/Exports.py @@ -0,0 +1,44 @@ + + +class ExportsContext(object): + def __init__(self, parent, context): + self._parent = parent + self.context = context + + def add(self, setting, value): + self._parent._context_add(self.context, setting, value) + def get_all(self, setting): + return self._parent.get_all(setting) + +class Exports(object): + def __init__(self): + self._exports = {} + self._context_exports = {} + + def new_context(self, context): + return ExportsContext(self, context) + + def add(self, setting, value): + self._add(None, setting, value) + def _context_add(self, context, setting, value): + self._add(context, setting, value) + def _add(self, context, setting, value): + if context == None: + if not setting in self_exports: + self._exports[setting] = [] + self._exports[setting].append(value) + else: + if not context in self._context_exports: + self._context_exports[context] = {} + if not setting in self._context_exports[context]: + self._context_exports[context][setting] = [] + self._context_exports[context][setting].append(value) + + def get_all(self, setting): + return self._exports.get(setting, []) + sum([ + exports.get(setting, []) for exports in + self._context_exports.values()], []) + + def purge_context(self, context): + if context in self._context_exports: + del self._context_exports[context] diff --git a/src/IRCBot.py b/src/IRCBot.py new file mode 100644 index 00000000..b94d31d5 --- /dev/null +++ b/src/IRCBot.py @@ -0,0 +1,197 @@ +import os, select, sys, threading, time, traceback, uuid +from . import EventManager, Exports, IRCLineHandler, IRCServer, Logging +from . import ModuleManager, Timer + +class Bot(object): + def __init__(self): + self.start_time = time.time() + self.lock = threading.Lock() + self.args = None + self.database = None + self.config = None + self.bot_directory = os.path.dirname(os.path.realpath(__file__)) + self.servers = {} + self.running = True + self.poll = select.epoll() + self.timers = [] + + self._events = None + self._exports = None + self.modules = None + self.log = None + self.line_handler = None + + def add_server(self, server_id, connect=True): + (_, alias, hostname, port, password, ipv4, tls, nickname, + username, realname) = self.database.servers.get(server_id) + + new_server = IRCServer.Server(self, self._events, server_id, alias, + hostname, port, password, ipv4, tls, nickname, username, + realname) + if not new_server.get_setting("connect", True): + return + self._events.on("new.server").call(server=new_server) + if connect and new_server.get_setting("connect", True): + self.connect(new_server) + return new_server + def connect(self, server): + try: + server.connect() + except: + sys.stderr.write("Failed to connect to %s\n" % str(server)) + traceback.print_exc() + return False + self.servers[server.fileno()] = server + self.poll.register(server.fileno(), select.EPOLLOUT) + return True + def setup_timers(self, event): + for setting, value in self.find_settings("timer-%"): + id = setting.split("timer-", 1)[1] + self.add_timer(value["event-name"], value["delay"], value[ + "next-due"], id, **value["kwargs"]) + def timer_setting(self, timer): + self.set_setting("timer-%s" % timer.id, { + "event-name": timer.event_name, "delay": timer.delay, + "next-due": timer.next_due, "kwargs": timer.kwargs}) + def timer_setting_remove(self, timer): + self.timers.remove(timer) + self.del_setting("timer-%s" % timer.id) + def add_timer(self, event_name, delay, next_due=None, id=None, persist=True, + **kwargs): + id = id or uuid.uuid4().hex + timer = Timer.Timer(id, self, self._events, event_name, delay, + next_due, **kwargs) + if id: + timer.id = id + elif persist: + self.timer_setting(timer) + self.timers.append(timer) + def next_timer(self): + next = None + for timer in self.timers: + time_left = timer.time_left() + if next == None or time_left < next: + next = time_left + + if next == None: + return None + if next < 0: + return 0 + return next + def call_timers(self): + for timer in self.timers[:]: + if timer.due(): + timer.call() + if timer.done(): + self.timer_setting_remove(timer) + def next_send(self): + next = None + for server in self.servers.values(): + timeout = server.send_throttle_timeout() + if server.waiting_send() and (next == None or timeout < next): + next = timeout + return next + + def next_ping(self): + timeouts = [] + for server in self.servers.values(): + timeout = server.until_next_ping() + if not timeout == None: + timeouts.append(timeout) + if not timeouts: + return None + return min(timeouts) + def next_read_timeout(self): + timeouts = [] + for server in self.servers.values(): + timeouts.append(server.until_read_timeout()) + if not timeouts: + return None + return min(timeouts) + + def get_poll_timeout(self): + timeouts = [] + timeouts.append(self.next_timer()) + timeouts.append(self.next_send()) + timeouts.append(self.next_ping()) + timeouts.append(self.next_read_timeout()) + return min([timeout for timeout in timeouts if not timeout == None]) + + def register_read(self, server): + self.poll.modify(server.fileno(), select.EPOLLIN) + def register_write(self, server): + self.poll.modify(server.fileno(), select.EPOLLOUT) + def register_both(self, server): + self.poll.modify(server.fileno(), + select.EPOLLIN|select.EPOLLOUT) + + def disconnect(self, server): + try: + self.poll.unregister(server.fileno()) + except FileNotFoundError: + pass + del self.servers[server.fileno()] + + def reconnect(self, event): + server_details = self.database.servers.get(event["server_id"]) + server = self.add_server(*(server_details + (False,))) + if self.connect(server): + self.servers[server.fileno()] = server + else: + event["timer"].redo() + + def set_setting(self, setting, value): + self.database.bot_settings.set(setting, value) + def get_setting(self, setting, default=None): + return self.database.bot_settings.get(setting, default) + def find_settings(self, pattern, default=[]): + return self.database.bot_settings.find(pattern, default) + def find_settings_prefix(self, prefix, default=[]): + return self.database.bot_settings.find_prefix( + prefix, default) + def del_setting(self, setting): + self.database.bot_settings.delete(setting) + + def run(self): + while self.running: + self.lock.acquire() + events = self.poll.poll(self.get_poll_timeout()) + self.call_timers() + for fd, event in events: + if fd in self.servers: + server = self.servers[fd] + if event & select.EPOLLIN: + lines = server.read() + for line in lines: + if self.args.verbose: + self.log.info("<%s | %s", [str(server), line]) + else: + self.log.debug("%s (raw) | %s", [str(server), + line]) + server.parse_line(line) + elif event & select.EPOLLOUT: + server._send() + self.register_read(server) + elif event & select.EPULLHUP: + print("hangup") + server.disconnect() + + for server in list(self.servers.values()): + if server.read_timed_out(): + print("pingout from %s" % str(server)) + server.disconnect() + elif server.ping_due() and not server.ping_sent: + server.send_ping() + server.ping_sent = True + if not server.connected: + self.disconnect(server) + + reconnect_delay = self.config.get("reconnect-delay", 10) + self.add_timer("reconnect", reconnect_delay, None, None, False, + server_id=server.id) + + print("disconnected from %s, reconnecting in %d seconds" % ( + str(server), reconnect_delay)) + elif server.waiting_send() and server.throttle_done(): + self.register_both(server) + self.lock.release() diff --git a/src/IRCBuffer.py b/src/IRCBuffer.py new file mode 100644 index 00000000..12a82ada --- /dev/null +++ b/src/IRCBuffer.py @@ -0,0 +1,48 @@ +import re +from . import Utils + +class BufferLine(object): + def __init__(self, sender, message, action, from_self): + self.sender = sender + self.message = message + self.action = action + self.from_self = from_self + +class Buffer(object): + def __init__(self, bot, server): + self.bot = bot + self.server = server + self.lines = [] + self.max_lines = 64 + self._skip_next = False + def add_line(self, sender, message, action, from_self=False): + if not self._skip_next: + line = BufferLine(sender, message, action, from_self) + self.lines.insert(0, line) + if len(self.lines) > self.max_lines: + self.lines.pop() + self._skip_next = False + def get(self, index=0, **kwargs): + from_self = kwargs.get("from_self", True) + for line in self.lines: + if line.from_self and not from_self: + continue + return line + def find(self, pattern, **kwargs): + from_self = kwargs.get("from_self", True) + for_user = kwargs.get("for_user", "") + for_user = Utils.irc_lower(self.server, for_user + ) if for_user else None + not_pattern = kwargs.get("not_pattern", None) + for line in self.lines: + if line.from_self and not from_self: + continue + elif re.search(pattern, line.message): + if not_pattern and re.search(not_pattern, line.message): + continue + if for_user and not Utils.irc_lower(self.server, line.sender + ) == for_user: + continue + return line + def skip_next(self): + self._skip_next = True diff --git a/src/IRCChannel.py b/src/IRCChannel.py new file mode 100644 index 00000000..31c3ee19 --- /dev/null +++ b/src/IRCChannel.py @@ -0,0 +1,132 @@ +import uuid +from . import IRCBuffer, Utils + +class Channel(object): + def __init__(self, name, id, server, bot): + self.name = Utils.irc_lower(server, name) + self.id = id + self.server = server + self.bot = bot + self.topic = "" + self.topic_setter_nickname = None + self.topic_setter_username = None + self.topic_setter_hostname = None + self.topic_time = 0 + self.users = set([]) + self.modes = {} + self.created_timestamp = None + self.buffer = IRCBuffer.Buffer(bot, server) + + def __repr__(self): + return "IRCChannel.Channel(%s|%s)" % (self.server.name, self.name) + + def set_topic(self, topic): + self.topic = topic + def set_topic_setter(self, nickname, username=None, hostname=None): + self.topic_setter_nickname = nickname + self.topic_setter_username = username + self.topic_setter_hostname = hostname + def set_topic_time(self, unix_timestamp): + self.topic_time = unix_timestamp + + def add_user(self, user): + self.users.add(user) + def remove_user(self, user): + self.users.remove(user) + for mode in list(self.modes.keys()): + if mode in self.server.mode_prefixes.values( + ) and user in self.modes[mode]: + self.modes[mode].discard(user) + if not len(self.modes[mode]): + del self.modes[mode] + def has_user(self, user): + return user in self.users + + def add_mode(self, mode, arg=None): + if not mode in self.modes: + self.modes[mode] = set([]) + if arg: + if mode in self.server.mode_prefixes.values(): + user = self.server.get_user(arg) + if user: + self.modes[mode].add(user) + else: + self.modes[mode].add(arg.lower()) + def remove_mode(self, mode, arg=None): + if not arg: + del self.modes[mode] + else: + if mode in self.server.mode_prefixes.values(): + user = self.server.get_user(arg) + if user: + self.modes[mode].discard(user) + else: + self.modes[mode].discard(arg.lower()) + if not len(self.modes[mode]): + del self.modes[mode] + def change_mode(self, remove, mode, arg=None): + if remove: + self.remove_mode(mode, arg) + else: + self.add_mode(mode, arg) + + def set_setting(self, setting, value): + self.bot.database.channel_settings.set(self.id, setting, value) + def get_setting(self, setting, default=None): + return self.bot.database.channel_settings.get(self.id, setting, + default) + def find_settings(self, pattern, default=[]): + return self.bot.database.channel_settings.find(self.id, pattern, + default) + def find_settings_prefix(self, prefix, default=[]): + return self.bot.database.channel_settings.find_prefix(self.id, + prefix, default) + def del_setting(self, setting): + self.bot.database.channel_settings.delete(self.id, setting) + + def set_user_setting(self, user_id, setting, value): + self.bot.database.user_channel_settings.set(user_id, self.id, + setting, value) + def get_user_setting(self, user_id, setting, default=None): + return self.bot.database.user_channel_settings.get(user_id, + self.id, setting, default) + def find_user_settings(self, user_i, pattern, default=[]): + return self.bot.database.user_channel_settings.find(user_id, + self.id, pattern, default) + def find_user_settings_prefix(self, user_id, prefix, default=[]): + return self.bot.database.user_channel_settings.find_prefix( + user_id, self.id, prefix, default) + def del_user_setting(self, user_id, setting): + self.bot.database.user_channel_settings.delete(user_id, self.id, + setting) + def find_all_by_setting(self, setting, default=[]): + return self.bot.database.user_channel_settings.find_all_by_setting( + self.id, setting, default) + + def send_message(self, text, prefix=None): + self.server.send_message(self.name, text, prefix=prefix) + def send_mode(self, mode=None, target=None): + self.server.send_mode(self.name, mode, target) + def send_kick(self, target, reason=None): + self.server.send_kick(self.name, target, reason) + def send_ban(self, hostmask): + self.server.send_mode(self.name, "+b", hostmask) + def send_unban(self, hostmask): + self.server.send_mode(self.name, "-b", hostmask) + def send_topic(self, topic): + self.server.send_topic(self.name, topic) + + def mode_or_above(self, user, mode): + mode_orders = list(self.server.mode_prefixes.values()) + mode_index = mode_orders.index(mode) + for mode in mode_orders[:mode_index+1]: + if user in self.modes.get(mode, []): + return True + return False + + def get_user_status(self, user): + modes = "" + for mode in self.server.mode_prefixes.values(): + if user in self.modes.get(mode, []): + modes += mode + return modes diff --git a/src/IRCLineHandler.py b/src/IRCLineHandler.py new file mode 100644 index 00000000..7d2336dc --- /dev/null +++ b/src/IRCLineHandler.py @@ -0,0 +1,593 @@ +import re, threading +from . import Utils + +RE_PREFIXES = re.compile(r"\bPREFIX=\((\w+)\)(\W+)(?:\b|$)") +RE_CHANMODES = re.compile( + r"\bCHANMODES=(\w*),(\w*),(\w*),(\w*)(?:\b|$)") +RE_CHANTYPES = re.compile(r"\bCHANTYPES=(\W+)(?:\b|$)") +RE_CASEMAPPING = re.compile(r"\bCASEMAPPING=(\S+)") +RE_MODES = re.compile(r"[-+]\w+") + +CAPABILITIES = {"multi-prefix", "chghost", "invite-notify", "account-tag", + "account-notify", "extended-join", "away-notify", "userhost-in-names", + "draft/message-tags-0.2", "server-time", "cap-notify", + "batch", "draft/labeled-response"} + +class LineHandler(object): + def __init__(self, bot, events): + self.bot = bot + self.events = events + events.on("raw.PING").hook(self.ping) + + events.on("raw.001").hook(self.handle_001, default_event=True) + events.on("raw.005").hook(self.handle_005) + events.on("raw.311").hook(self.handle_311, default_event=True) + events.on("raw.332").hook(self.handle_332) + events.on("raw.333").hook(self.handle_333) + events.on("raw.353").hook(self.handle_353, default_event=True) + events.on("raw.366").hook(self.handle_366, default_event=True) + events.on("raw.421").hook(self.handle_421, default_event=True) + events.on("raw.352").hook(self.handle_352, default_event=True) + events.on("raw.354").hook(self.handle_354, default_event=True) + events.on("raw.324").hook(self.handle_324, default_event=True) + events.on("raw.329").hook(self.handle_329, default_event=True) + events.on("raw.433").hook(self.handle_433, default_event=True) + events.on("raw.477").hook(self.handle_477, default_event=True) + + events.on("raw.JOIN").hook(self.join) + events.on("raw.PART").hook(self.part) + events.on("raw.QUIT").hook(self.quit) + events.on("raw.NICK").hook(self.nick) + events.on("raw.MODE").hook(self.mode) + events.on("raw.KICK").hook(self.kick) + events.on("raw.INVITE").hook(self.invite) + events.on("raw.TOPIC").hook(self.topic) + events.on("raw.PRIVMSG").hook(self.privmsg) + events.on("raw.NOTICE").hook(self.notice) + + events.on("raw.CAP").hook(self.cap) + events.on("raw.AUTHENTICATE").hook(self.authenticate) + events.on("raw.CHGHOST").hook(self.chghost) + events.on("raw.ACCOUNT").hook(self.account) + events.on("raw.TAGMSG").hook(self.tagmsg) + events.on("raw.AWAY").hook(self.away) + events.on("raw.BATCH").hook(self.batch) + + def handle(self, server, line): + original_line = line + tags = {} + prefix = None + command = None + + if line[0] == "@": + tags_prefix, line = line[1:].split(" ", 1) + for tag in tags_prefix.split(";"): + if tag: + tag_split = tag.split("=", 1) + tags[tag_split[0]] = "".join(tag_split[1:]) + if "batch" in tags and tags["batch"] in server.batches: + server.batches[tag["batch"]].append(line) + return + + arbitrary = None + if " :" in line: + line, arbitrary = line.split(" :", 1) + if line.endswith(" "): + line = line[:-1] + if line[0] == ":": + prefix, command = line[1:].split(" ", 1) + prefix = Utils.seperate_hostmask(prefix) + if " " in command: + command, line = command.split(" ", 1) + else: + line = "" + else: + command = line + if " " in line: + command, line = line.split(" ", 1) + args = line.split(" ") + + hooks = self.events.on("raw").on(command).get_hooks() + default_event = False + for hook in hooks: + if hook.kwargs.get("default_event", False): + default_event = True + break + last = arbitrary or args[-1] + + #server, prefix, command, args, arbitrary + self.events.on("raw").on(command).call(server=server, last=last, + prefix=prefix, args=args, arbitrary=arbitrary, tags=tags) + if default_event or not hooks: + if command.isdigit(): + self.events.on("received.numeric").on(command).call( + line=original_line, server=server, tags=tags, last=last, + line_split=original_line.split(" "), number=command) + else: + self.events.on("received").on(command).call( + line=original_line, line_split=original_line.split(" "), + command=command, server=server, tags=tags, last=last) + + # ping from the server + def ping(self, event): + event["server"].send_pong(event["last"]) + + # first numeric line the server sends + def handle_001(self, event): + event["server"].name = event["prefix"].nickname + event["server"].set_own_nickname(event["args"][0]) + event["server"].send_whois(event["server"].nickname) + + # server telling us what it supports + def handle_005(self, event): + isupport_line = " ".join(event["args"][1:]) + + if "NAMESX" in isupport_line: + event["server"].send("PROTOCTL NAMESX") + + match = re.search(RE_PREFIXES, isupport_line) + if match: + event["server"].mode_prefixes.clear() + modes = match.group(1) + prefixes = match.group(2) + for i, prefix in enumerate(prefixes): + if i < len(modes): + event["server"].mode_prefixes[prefix] = modes[i] + match = re.search(RE_CHANMODES, isupport_line) + if match: + event["server"].channel_modes = list(match.group(4)) + match = re.search(RE_CHANTYPES, isupport_line) + if match: + event["server"].channel_types = list(match.group(1)) + + match = re.search(RE_CASEMAPPING, isupport_line) + if match: + event["server"].case_mapping = match.group(1) + + self.events.on("received.numeric.005").call( + isupport=isupport_line, server=event["server"]) + + # whois respose (nickname, username, realname, hostname) + def handle_311(self, event): + nickname = event["args"][1] + if event["server"].is_own_nickname(nickname): + target = event["server"] + else: + target = event["server"].get_user(nickname) + target.username = event["args"][2] + target.hostname = event["args"][3] + target.realname = event["arbitrary"] + + # on-join channel topic line + def handle_332(self, event): + channel = event["server"].get_channel(event["args"][1]) + + channel.set_topic(event["arbitrary"]) + self.events.on("received.numeric.332").call(channel=channel, + server=event["server"], topic=event["arbitrary"]) + + # channel topic changed + def topic(self, event): + user = event["server"].get_user(event["prefix"].nickname) + channel = event["server"].get_channel(event["args"][0]) + channel.set_topic(event["arbitrary"]) + self.events.on("received.topic").call(channel=channel, + server=event["server"], topic=event["arbitrary"], user=user) + + # on-join channel topic set by/at + def handle_333(self, event): + channel = event["server"].get_channel(event["args"][1]) + + topic_setter_hostmask = event["args"][2] + topic_setter = Utils.seperate_hostmask(topic_setter_hostmask) + topic_time = int(event["args"][3]) if event["args"][3].isdigit( + ) else None + + channel.set_topic_setter(topic_setter.nickname, topic_setter.username, + topic_setter.hostname) + channel.set_topic_time(topic_time) + self.events.on("received.numeric.333").call(channel=channel, + setter=topic_setter.nickname, set_at=topic_time, + server=event["server"]) + + # /names response, also on-join user list + def handle_353(self, event): + channel = event["server"].get_channel(event["args"][2]) + nicknames = event["arbitrary"].split() + for nickname in nicknames: + modes = set([]) + + while nickname[0] in event["server"].mode_prefixes: + modes.add(event["server"].mode_prefixes[nickname[0]]) + nickname = nickname[1:] + + if "userhost-in-names" in event["server"].capabilities: + hostmask = Utils.seperate_hostmask(nickname) + nickname = hostmask.nickname + user = event["server"].get_user(hostmask.nickname) + user.username = hostmask.username + user.hostname = hostmask.hostname + else: + user = event["server"].get_user(nickname) + user.join_channel(channel) + channel.add_user(user) + + for mode in modes: + channel.add_mode(mode, nickname) + + # on-join user list has finished + def handle_366(self, event): + event["server"].send_whox(event["args"][1], "n", "ahnrtu", "111") + + # on user joining channel + def join(self, event): + account = None + realname = None + if len(event["args"]) == 2: + channel = event["server"].get_channel(event["args"][0]) + if not event["args"] == "*": + account = event["args"][1] + realname = event["arbitrary"] + else: + channel = event["server"].get_channel(event["last"]) + + if not event["server"].is_own_nickname(event["prefix"].nickname): + user = event["server"].get_user(event["prefix"].nickname) + if not user.username and not user.hostname: + user.username = event["prefix"].username + user.hostname = event["prefix"].hostname + + if account: + user.identified_account = account + user.identified_account_id = event["server"].get_user( + account).get_id() + if realname: + user.realname = realname + + channel.add_user(user) + user.join_channel(channel) + self.events.on("received.join").call(channel=channel, + user=user, server=event["server"], account=account, + realname=realname) + else: + if channel.name in event["server"].attempted_join: + del event["server"].attempted_join[channel.name] + self.events.on("self.join").call(channel=channel, + server=event["server"], account=account, realname=realname) + channel.send_mode() + + # on user parting channel + def part(self, event): + channel = event["server"].get_channel(event["args"][0]) + reason = event["arbitrary"] or "" + + if not event["server"].is_own_nickname(event["prefix"].nickname): + user = event["server"].get_user(event["prefix"].nickname) + self.events.on("received.part").call(channel=channel, + reason=reason, user=user, server=event["server"]) + channel.remove_user(user) + user.part_channel(channel) + if not len(user.channels): + event["server"].remove_user(user) + else: + self.events.on("self.part").call(channel=channel, + reason=reason, server=event["server"]) + event["server"].remove_channel(channel) + + # unknown command sent by us, oops! + def handle_421(self, event): + print("warning: unknown command '%s'." % event["args"][1]) + + # a user has disconnected! + def quit(self, event): + reason = event["arbitrary"] or "" + + if not event["server"].is_own_nickname(event["prefix"].nickname): + user = event["server"].get_user(event["prefix"].nickname) + event["server"].remove_user(user) + self.events.on("received.quit").call(reason=reason, + user=user, server=event["server"]) + else: + event["server"].disconnect() + + # the server is telling us about its capabilities! + def cap(self, event): + capabilities_list = (event["arbitrary"] or "").split(" ") + capabilities = {} + for capability in capabilities_list: + argument = None + if "=" in capability: + capability, argument = capability.split("=", 1) + capabilities[capability] = argument + + subcommand = event["args"][1].lower() + is_multiline = len(event["args"]) > 2 and event["args"][2] == "*" + + if subcommand == "ls": + event["server"].server_capabilities.update(capabilities) + if not is_multiline: + matched_capabilities = set(event["server" + ].server_capabilities.keys()) & CAPABILITIES + if matched_capabilities: + event["server"].queue_capabilities(matched_capabilities) + + self.events.on("received.cap.ls").call( + capabilities=event["server"].server_capabilities, + server=event["server"]) + + if event["server"].has_capability_queue(): + event["server"].send_capability_queue() + else: + event["server"].send_capability_end() + elif subcommand == "new": + event["server"].capabilities.update(set(capabilities.keys())) + self.events.on("received.cap.new").call(server=event["server"], + capabilities=capabilities) + elif subcommand == "del": + event["server"].capabilities.difference_update(set( + capabilities.keys())) + self.events.on("received.cap.del").call(server=event["server"], + capabilities=capabilities) + elif subcommand == "ack": + event["server"].capabilities.update(capabilities) + if not is_multiline: + self.events.on("received.cap.ack").call( + capabilities=event["server"].capabilities, + server=event["server"]) + + if not event["server"].waiting_for_capabilities(): + event["server"].send_capability_end() + elif subcommand == "nack": + event["server"].send_capability_end() + + # the server is asking for authentication + def authenticate(self, event): + self.events.on("received.authenticate").call( + message=event["args"][0], server=event["server"]) + + # someone has changed their nickname + def nick(self, event): + new_nickname = event["arbitrary"] + if not event["server"].is_own_nickname(event["prefix"].nickname): + user = event["server"].get_user(event["prefix"].nickname) + old_nickname = user.nickname + user.set_nickname(new_nickname) + event["server"].change_user_nickname(old_nickname, new_nickname) + + self.events.on("received.nick").call(new_nickname=new_nickname, + old_nickname=old_nickname, user=user, server=event["server"]) + else: + old_nickname = event["server"].nickname + event["server"].set_own_nickname(new_nickname) + + self.events.on("self.nick").call(server=event["server"], + new_nickname=new_nickname, old_nickname=old_nickname) + + # something's mode has changed + def mode(self, event): + user = event["server"].get_user(event["prefix"].nickname) + target = event["args"][0] + is_channel = target[0] in event["server"].channel_types + if is_channel: + channel = event["server"].get_channel(target) + remove = False + args = event["args"][2:] + _args = args[:] + modes = RE_MODES.findall(event["args"][1]) + for chunk in modes: + remove = chunk[0] == "-" + for mode in chunk[1:]: + if mode in event["server"].channel_modes: + channel.change_mode(remove, mode) + elif mode in event["server"].mode_prefixes.values( + ) and len(args): + channel.change_mode(remove, mode, args.pop(0)) + else: + args.pop(0) + self.events.on("received.mode.channel").call(modes=modes, + mode_args=_args, channel=channel, server=event["server"], + user=user) + elif event["server"].is_own_nickname(target): + modes = RE_MODES.findall(event["last"]) + for chunk in modes: + remove = chunk[0] == "-" + for mode in chunk[1:]: + event["server"].change_own_mode(remove, mode) + self.events.on("self.mode").call(modes=modes, + server=event["server"]) + + # someone (maybe me!) has been invited somewhere + def invite(self, event): + target_channel = event["last"] + user = event["server"].get_user(event["prefix"].nickname) + target_user = event["server"].get_user(event["args"][0]) + self.events.on("received.invite").call(user=user, + target_channel=target_channel, server=event["server"], + target_user=target_user) + + # we've received a message + def privmsg(self, event): + user = event["server"].get_user(event["prefix"].nickname) + message = event["arbitrary"] or "" + message_split = message.split(" ") + target = event["args"][0] + action = message.startswith("\x01ACTION ") + if action: + message = message.replace("\x01ACTION ", "", 1) + if message.endswith("\x01"): + message = message[:-1] + + if "account" in event["tags"]: + user.identified_account = event["tags"]["account"] + user.identified_account_id = event["server"].get_user( + event["tags"]["account"]).get_id() + + kwargs = {"message": message, "message_split": message_split, + "server": event["server"], "tags": event["tags"], + "action": action} + + if target[0] in event["server"].channel_types: + channel = event["server"].get_channel(event["args"][0]) + self.events.on("received.message.channel").call( + user=user, channel=channel, **kwargs) + channel.buffer.add_line(user.nickname, message, action) + elif event["server"].is_own_nickname(target): + self.events.on("received.message.private").call( + user=user, **kwargs) + user.buffer.add_line(user.nickname, message, action) + + # we've received a notice + def notice(self, event): + message = event["arbitrary"] or "" + message_split = message.split(" ") + target = event["args"][0] + + if not event["prefix"] or event["prefix"].hostmask == event["server" + ].name or target == "*" or (not event["prefix"].hostname and + not event["server"].name): + event["server"].name = event["prefix"].hostmask + + self.events.on("received.server-notice").call( + message=message, message_split=message_split, + server=event["server"]) + else: + user = event["server"].get_user(event["prefix"].nickname) + + if target[0] in event["server"].channel_types: + channel = event["server"].get_channel(target) + self.events.on("received.notice.channel").call( + message=message, message_split=message_split, user=user, + server=event["server"], channel=channel, + tags=event["tags"]) + elif event["server"].is_own_nickname(target): + self.events.on("received.notice.private").call( + message=message, message_split=message_split, user=user, + server=event["server"], tags=event["tags"]) + + # IRCv3 TAGMSG, used to send tags without any other information + def tagmsg(self, event): + user = event["server"].get_user(event["prefix"].nickname) + target = event["args"][0] + + if target[0] in event["server"].channel_types: + channel = event["server"].get_channel(target) + self.events.on("received.tagmsg.channel").call(channel=channel, + user=user, tags=event["tags"], server=event["server"]) + elif event["server"].is_own_nickname(target): + self.events.on("received.tagmsg.private").call( + user=user, tags=event["tags"], server=event["server"]) + + # IRCv3 AWAY, used to notify us that a client we can see has changed /away + def away(self, event): + user = event["server"].get_user(event["prefix"].nickname) + message = event["arbitrary"] + if message: + user.away = True + self.events.on("received.away.on").call(user=user, + server=event["server"], message=message) + else: + user.away = False + self.events.on("received.away.off").call(user=user, + server=event["server"]) + + def batch(self, event): + identifier = event["args"][0] + modifier, identifier = identifier[0], identifier[1:] + if modifier == "+": + event["server"].batches[identifier] = [] + else: + lines = event["server"].batches[identifier] + del event["server"].batches[identifier] + for line in lines: + self.handle(event["server"], line) + + # IRCv3 CHGHOST, a user's username and/or hostname has changed + def chghost(self, event): + username = event["args"][0] + hostname = event["args"][1] + + if not event["server"].is_own_nickname(event["prefix"].nickname): + target = event["server"].get_user("nickanme") + else: + target = event["server"] + target.username = username + target.hostname = hostname + + def account(self, event): + user = event["server"].get_user(event["prefix"].nickname) + + if not event["args"][0] == "*": + user.identified_account = event["args"][0] + user.identified_account_id = event["server"].get_user( + event["args"][0]).get_id() + self.events.on("received.account.login").call(user=user, + server=event["server"], account=event["args"][0]) + else: + user.identified_account = None + user.identified_account_id = None + self.events.on("received.account.logout").call(user=user, + server=event["server"]) + + # response to a WHO command for user information + def handle_352(self, event): + user = event["server"].get_user(event["args"][5]) + user.username = event["args"][2] + user.hostname = event["args"][3] + # response to a WHOX command for user information, including account name + def handle_354(self, event): + if event["args"][1] == "111": + username = event["args"][2] + hostname = event["args"][3] + nickname = event["args"][4] + account = event["args"][5] + realname = event["last"] + + user = event["server"].get_user(nickname) + user.username = username + user.hostname = hostname + user.realname = realname + if not account == "0": + user.identified_account = account + + # response to an empty mode command + def handle_324(self, event): + channel = event["server"].get_channel(event["args"][1]) + modes = event["args"][2] + if modes[0] == "+" and modes[1:]: + for mode in modes[1:]: + if mode in event["server"].channel_modes: + channel.add_mode(mode) + + # channel creation unix timestamp + def handle_329(self, event): + channel = event["server"].get_channel(event["args"][1]) + channel.creation_timestamp = int(event["args"][2]) + + # nickname already in use + def handle_433(self, event): + pass + + # we need a registered nickname for this channel + def handle_477(self, event): + channel_name = Utils.irc_lower(event["server"], event["args"][1]) + if channel_name in event["server"].attempted_join: + self.bot.add_timer("rejoin", 5, + channel_name=event["args"][1], + key=event["server"].attempted_join[channel_name], + server_id=event["server"].id) + + # someone's been kicked from a channel + def kick(self, event): + user = event["server"].get_user(event["prefix"].nickname) + target = event["args"][1] + channel = event["server"].get_channel(event["args"][0]) + reason = event["arbitrary"] or "" + + if not event["server"].is_own_nickname(target): + target_user = event["server"].get_user(target) + self.events.on("received.kick").call(channel=channel, + reason=reason, target_user=target_user, user=user, + server=event["server"]) + else: + self.events.on("self.kick").call(channel=channel, + reason=reason, user=user, server=event["server"]) diff --git a/src/IRCServer.py b/src/IRCServer.py new file mode 100644 index 00000000..d88cc38a --- /dev/null +++ b/src/IRCServer.py @@ -0,0 +1,402 @@ +import collections, socket, ssl, sys, time +from . import IRCChannel, IRCUser, Utils + +THROTTLE_LINES = 4 +THROTTLE_SECONDS = 1 +READ_TIMEOUT_SECONDS = 120 +PING_INTERVAL_SECONDS = 30 + +class Server(object): + def __init__(self, bot, events, id, alias, hostname, port, password, + ipv4, tls, nickname, username, realname): + self.connected = False + self.bot = bot + self.events = events + self.id = id + self.alias = alias + self.target_hostname = hostname + self.port = port + self.tls = tls + self.password = password + self.ipv4 = ipv4 + self.original_nickname = nickname + self.original_username = username or nickname + self.original_realname = realname or nickname + self.name = None + + self._capability_queue = set([]) + self._capabilities_waiting = set([]) + self.capabilities = set([]) + self.server_capabilities = {} + self.batches = {} + + self.write_buffer = b"" + self.buffered_lines = [] + self.read_buffer = b"" + self.recent_sends = [] + + self.users = {} + self.new_users = set([]) + self.channels = {} + + self.own_modes = {} + self.mode_prefixes = collections.OrderedDict( + {"@": "o", "+": "v"}) + self.channel_modes = [] + self.channel_types = ["#"] + self.case_mapping = "rfc1459" + + self.last_read = time.monotonic() + self.last_send = None + + self.attempted_join = {} + self.ping_sent = False + + if ipv4: + self.socket = socket.socket(socket.AF_INET, + socket.SOCK_STREAM) + else: + self.socket = socket.socket(socket.AF_INET6, + socket.SOCK_STREAM) + + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + self.socket.settimeout(5.0) + + if self.tls: + self.tls_wrap() + self.cached_fileno = self.socket.fileno() + self.events.on("timer.rejoin").hook(self.try_rejoin) + + def __repr__(self): + return "IRCServer.Server(%s)" % self.__str__() + def __str__(self): + if self.alias: + return self.alias + return "%s:%s%s" % (self.target_hostname, "+" if self.tls else "", + self.port) + def fileno(self): + fileno = self.socket.fileno() + return self.cached_fileno if fileno == -1 else fileno + + def tls_wrap(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS) + context.options |= ssl.OP_NO_SSLv2 + context.options |= ssl.OP_NO_SSLv3 + context.options |= ssl.OP_NO_TLSv1 + + context.load_default_certs() + if self.get_setting("ssl-verify", True): + context.verify_mode = ssl.CERT_REQUIRED + + client_certificate = self.bot.config.get("ssl-certificate", None) + client_key = self.bot.config.get("ssl-key", None) + if client_certificate and client_key: + context.load_cert_chain(client_certificate, keyfile=client_key) + + self.socket = context.wrap_socket(self.socket) + + def connect(self): + self.socket.connect((self.target_hostname, self.port)) + self.send_capibility_ls() + + if self.password: + self.send_pass(self.password) + + self.send_user(self.original_username, self.original_realname) + self.send_nick(self.original_nickname) + self.connected = True + def disconnect(self): + self.connected = False + try: + self.socket.shutdown(socket.SHUT_RDWR) + except: + pass + try: + self.socket.close() + except: + pass + + def set_setting(self, setting, value): + self.bot.database.server_settings.set(self.id, setting, + value) + def get_setting(self, setting, default=None): + return self.bot.database.server_settings.get(self.id, + setting, default) + def find_settings(self, pattern, default=[]): + return self.bot.database.server_settings.find(self.id, + pattern, default) + def find_settings_prefix(self, prefix, default=[]): + return self.bot.database.server_settings.find_prefix( + self.id, prefix, default) + def del_setting(self, setting): + self.bot.database.server_settings.delete(self.id, setting) + def get_all_user_settings(self, setting, default=[]): + return self.bot.database.user_settings.find_all_by_setting( + self.id, setting, default) + def find_all_user_channel_settings(self, setting, default=[]): + return self.bot.database.user_channel_settings.find_all_by_setting( + self.id, setting, default) + + def set_own_nickname(self, nickname): + self.nickname = nickname + self.nickname_lower = Utils.irc_lower(self, nickname) + def is_own_nickname(self, nickname): + return Utils.irc_equals(self, nickname, self.nickname) + + def add_own_mode(self, mode, arg=None): + self.own_modes[mode] = arg + def remove_own_mode(self, mode): + del self.own_modes[mode] + def change_own_mode(self, remove, mode, arg=None): + if remove: + self.remove_own_mode(mode) + else: + self.add_own_mode(mode, arg) + + def has_user(self, nickname): + return Utils.irc_lower(self, nickname) in self.users + def get_user(self, nickname): + if not self.has_user(nickname): + user_id = self.get_user_id(nickname) + new_user = IRCUser.User(nickname, user_id, self, self.bot) + self.events.on("new.user").call(user=new_user, server=self) + self.users[new_user.nickname_lower] = new_user + self.new_users.add(new_user) + return self.users[Utils.irc_lower(self, nickname)] + def get_user_id(self, nickname): + self.bot.database.users.add(self.id, nickname) + return self.bot.database.users.get_id(self.id, nickname) + def remove_user(self, user): + del self.users[user.nickname_lower] + for channel in user.channels: + channel.remove_user(user) + + def change_user_nickname(self, old_nickname, new_nickname): + user = self.users.pop(Utils.irc_lower(self, old_nickname)) + user._id = self.get_user_id(new_nickname) + self.users[Utils.irc_lower(self, new_nickname)] = user + def has_channel(self, channel_name): + return channel_name[0] in self.channel_types and Utils.irc_lower( + self, channel_name) in self.channels + def get_channel(self, channel_name): + if not self.has_channel(channel_name): + channel_id = self.get_channel_id(channel_name) + new_channel = IRCChannel.Channel(channel_name, channel_id, + self, self.bot) + self.events.on("new.channel").call(channel=new_channel, + server=self) + self.channels[new_channel.name] = new_channel + return self.channels[Utils.irc_lower(self, channel_name)] + def get_channel_id(self, channel_name): + self.bot.database.channels.add(self.id, channel_name) + return self.bot.database.channels.get_id(self.id, channel_name) + def remove_channel(self, channel): + for user in channel.users: + user.part_channel(channel) + del self.channels[channel.name] + def parse_line(self, line): + if not line: + return + self.bot.line_handler.handle(self, line) + self.check_users() + def check_users(self): + for user in self.new_users: + if not len(user.channels): + self.remove_user(user) + self.new_users.clear() + def read(self): + data = b"" + try: + data = self.read_buffer + self.socket.recv(4096) + except (ConnectionResetError, socket.timeout): + self.disconnect() + return [] + self.read_buffer = b"" + data_lines = [line.strip(b"\r") for line in data.split(b"\n")] + if data_lines[-1]: + self.read_buffer = data_lines[-1] + data_lines.pop(-1) + decoded_lines = [] + for line in data_lines: + try: + line = line.decode(self.get_setting( + "encoding", "utf8")) + except: + try: + line = line.decode(self.get_setting( + "fallback-encoding", "latin-1")) + except: + continue + decoded_lines.append(line) + if not decoded_lines: + self.disconnect() + self.last_read = time.monotonic() + self.ping_sent = False + return decoded_lines + + def until_next_ping(self): + if self.ping_sent: + return None + return max(0, (self.last_read+PING_INTERVAL_SECONDS + )-time.monotonic()) + def ping_due(self): + return self.until_next_ping() == 0 + + def until_read_timeout(self): + return max(0, (self.last_read+READ_TIMEOUT_SECONDS + )-time.monotonic()) + def read_timed_out(self): + return self.until_read_timeout == 0 + + def send(self, data): + encoded = data.split("\n")[0].strip("\r").encode("utf8") + if len(encoded) > 450: + encoded = encoded[:450] + self.buffered_lines.append(encoded + b"\r\n") + if self.bot.args.verbose: + self.bot.log.info(">%s | %s", [str(self), encoded.decode("utf8")]) + def _send(self): + if not len(self.write_buffer): + self.write_buffer = self.buffered_lines.pop(0) + self.write_buffer = self.write_buffer[self.socket.send( + self.write_buffer):] + + now = time.monotonic() + self.recent_sends.append(now) + self.last_send = now + def waiting_send(self): + return bool(len(self.write_buffer)) or bool(len(self.buffered_lines)) + def throttle_done(self): + return self.send_throttle_timeout() == 0 + def send_throttle_timeout(self): + if len(self.write_buffer): + return 0 + + now = time.monotonic() + popped = 0 + for i, recent_send in enumerate(self.recent_sends[:]): + time_since = now-recent_send + if time_since >= THROTTLE_SECONDS: + self.recent_sends.pop(i-popped) + popped += 1 + + if len(self.recent_sends) < THROTTLE_LINES: + return 0 + + time_left = self.recent_sends[0]+THROTTLE_SECONDS + time_left = time_left-now + return time_left + + def send_user(self, username, realname): + self.send("USER %s 0 * :%s" % (username, realname)) + def send_nick(self, nickname): + self.send("NICK %s" % nickname) + + def send_capibility_ls(self): + self.send("CAP LS 302") + def queue_capability(self, capability): + self._capability_queue.add(capability) + def queue_capabilities(self, capabilities): + self._capability_queue.update(capabilities) + def send_capability_queue(self): + if self.has_capability_queue(): + capabilities = " ".join(self._capability_queue) + self._capability_queue.clear() + self.send_capability_request(capabilities) + def has_capability_queue(self): + return bool(len(self._capability_queue)) + def send_capability_request(self, capability): + self.send("CAP REQ :%s" % capability) + def send_capability_end(self): + self.send("CAP END") + def send_authenticate(self, text): + self.send("AUTHENTICATE %s" % text) + def send_starttls(self): + self.send("STARTTLS") + + def waiting_for_capabilities(self): + return bool(len(self._capabilities_waiting)) + def wait_for_capability(self, capability): + self._capabilities_waiting.add(capability) + def capability_done(self, capability): + self._capabilities_waiting.remove(capability) + if not self._capabilities_waiting: + self.send_capability_end() + + def send_pass(self, password): + self.send("PASS %s" % password) + + def send_ping(self, nonce="hello"): + self.send("PING :%s" % nonce) + def send_pong(self, nonce="hello"): + self.send("PONG :%s" % nonce) + + def try_rejoin(self, event): + if event["server_id"] == self.id and event["channel_name" + ] in self.attempted_join: + self.send_join(event["channel_name"], event["key"]) + def send_join(self, channel_name, key=None): + self.send("JOIN %s%s" % (channel_name, + "" if key == None else " %s" % key)) + def send_part(self, channel_name, reason=None): + self.send("PART %s%s" % (channel_name, + "" if reason == None else " %s" % reason)) + def send_quit(self, reason="Leaving"): + self.send("QUIT :%s" % reason) + + def send_message(self, target, message, prefix=None): + full_message = message if not prefix else prefix+message + self.send("PRIVMSG %s :%s" % (target, full_message)) + + action = full_message.startswith("\01ACTION " + ) and full_message.endswith("\01") + + if action: + message = full_message.split("\01ACTION ", 1)[1][:-1] + + full_message_split = full_message.split() + if self.has_channel(target): + channel = self.get_channel(target) + channel.buffer.add_line(None, message, action, True) + self.events.on("self.message.channel").call( + message=full_message, message_split=full_message_split, + channel=channel, action=action, server=self) + else: + user = self.get_user(target) + user.buffer.add_line(None, message, action, True) + self.events.on("self.message.private").call( + message=full_message, message_split=full_message_split, + user=user, action=action, server=self) + + def send_notice(self, target, message): + self.send("NOTICE %s :%s" % (target, message)) + + def send_mode(self, target, mode=None, args=None): + self.send("MODE %s%s%s" % (target, "" if mode == None else " %s" % mode, + "" if args == None else " %s" % args)) + + def send_topic(self, channel_name, topic): + self.send("TOPIC %s :%s" % (channel_name, topic)) + def send_kick(self, channel_name, target, reason=None): + self.send("KICK %s %s%s" % (channel_name, target, + "" if reason == None else " :%s" % reason)) + def send_names(self, channel_name): + self.send("NAMES %s" % channel_name) + def send_list(self, search_for=None): + self.send( + "LIST%s" % "" if search_for == None else " %s" % search_for) + def send_invite(self, target, channel_name): + self.send("INVITE %s %s" % (target, channel_name)) + + def send_whois(self, target): + self.send("WHOIS %s" % target) + def send_whowas(self, target, amount=None, server=None): + self.send("WHOWAS %s%s%s" % (target, + "" if amount == None else " %s" % amount, + "" if server == None else " :%s" % server)) + def send_who(self, filter=None): + self.send("WHO%s" % ("" if filter == None else " %s" % filter)) + def send_whox(self, mask, filter, fields, label=None): + self.send("WHO %s %s%%%s%s" % (mask, filter, fields, + ","+label if label else "")) diff --git a/src/IRCUser.py b/src/IRCUser.py new file mode 100644 index 00000000..1b2fdbdc --- /dev/null +++ b/src/IRCUser.py @@ -0,0 +1,62 @@ +import uuid +from . import IRCBuffer, Utils + +class User(object): + def __init__(self, nickname, id, server, bot): + self.server = server + self.set_nickname(nickname) + self._id = id + self.username = None + self.hostname = None + self.realname = None + self.bot = bot + self.channels = set([]) + + self.identified_account = None + self.identified_account_override = None + + self.identified_account_id = None + self.identified_account_id_override = None + self.away = False + self.buffer = IRCBuffer.Buffer(bot, server) + + def __repr__(self): + return "IRCUser.User(%s|%s)" % (self.server.name, self.name) + + def get_id(self): + return (self.identified_account_id_override or + self.identified_account_id or self._id) + def get_identified_account(self): + return (self.identified_account_override or self.identified_account) + + def set_nickname(self, nickname): + self.nickname = nickname + self.nickname_lower = Utils.irc_lower(self.server, nickname) + self.name = self.nickname_lower + def join_channel(self, channel): + self.channels.add(channel) + def part_channel(self, channel): + self.channels.remove(channel) + def set_setting(self, setting, value): + self.bot.database.user_settings.set(self.get_id(), setting, value) + def get_setting(self, setting, default=None): + return self.bot.database.user_settings.get(self.get_id(), setting, + default) + def find_settings(self, pattern, default=[]): + return self.bot.database.user_settings.find(self.get_id(), pattern, + default) + def find_settings_prefix(self, prefix, default=[]): + return self.bot.database.user_settings.find_prefix(self.get_id(), + prefix, default) + def del_setting(self, setting): + self.bot.database.user_settings.delete(self.get_id(), setting) + def get_channel_settings_per_setting(self, setting, default=[]): + return self.bot.database.user_channel_settings.find_by_setting( + self.get_id(), setting, default) + + def send_message(self, message, prefix=None): + self.server.send_message(self.nickname, message, prefix=prefix) + def send_notice(self, message): + self.server.send_notice(self.nickname, message) + def send_ctcp_response(self, command, args): + self.send_notice("\x01%s %s\x01" % (command, args)) diff --git a/src/Logging.py b/src/Logging.py new file mode 100644 index 00000000..3f5815d6 --- /dev/null +++ b/src/Logging.py @@ -0,0 +1,48 @@ +import logging, logging.handlers, os, sys, time + +class BitBotFormatter(logging.Formatter): + def formatTime(self, record, datefmt=None): + ct = self.converter(record.created) + if datefmt: + if "%f" in datefmt: + msec = "%03d" % record.msecs + datefmt = datefmt.replace("%f", msec) + s = time.strftime(datefmt, ct) + else: + t = time.strftime("%Y-%m-%d %H:%M:%S", ct) + s = "%s.%03d" % (t, record.msecs) + return s + +class Log(object): + def __init__(self, bot, directory, filename): + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG) + + formatter = BitBotFormatter( + "%(asctime)s [%(levelname)s] %(message)s", + "%Y-%m-%dT%H:%M:%S.%fZ") + formatter.converter = time.gmtime + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(logging.INFO) + stdout_handler.setFormatter(formatter) + self.logger.addHandler(stdout_handler) + + file_handler = logging.handlers.TimedRotatingFileHandler( + os.path.join(directory, filename), when="midnight", backupCount=5) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + + def debug(self, message, params, **kwargs): + self._log(message, params, logging.DEBUG, kwargs) + def info(self, message, params, **kwargs): + self._log(message, params, logging.INFO, kwargs) + def warn(self, message, params, **kwargs): + self._log(message, params, logging.WARN, kwargs) + def error(self, message, params, **kwargs): + self._log(message, params, logging.ERROR, kwargs) + def critical(self, message, params, **kwargs): + self._log(message, params, logging.CRITICAL, kwargs) + def _log(self, message, params, level, kwargs): + self.logger.log(level, message, *params, **kwargs) diff --git a/src/ModuleManager.py b/src/ModuleManager.py new file mode 100644 index 00000000..2a3ae713 --- /dev/null +++ b/src/ModuleManager.py @@ -0,0 +1,147 @@ +import glob, imp, inspect, os, sys, uuid + +BITBOT_HOOKS_MAGIC = "__bitbot_hooks" + +class ModuleException(Exception): + pass +class ModuleWarning(Exception): + pass + +class ModuleNotFoundException(ModuleException): + pass +class ModuleNameCollisionException(ModuleException): + pass +class ModuleLoadException(ModuleException): + pass +class ModuleUnloadException(ModuleException): + pass + +class ModuleNotLoadedWarning(ModuleWarning): + pass + +class BaseModule(object): + def __init__(self, bot, events, exports): + pass + +class ModuleManager(object): + def __init__(self, bot, events, exports, directory): + self.bot = bot + self.events = events + self.exports = exports + self.directory = directory + self.modules = {} + self.waiting_requirement = {} + def list_modules(self): + return sorted(glob.glob(os.path.join(self.directory, "*.py"))) + + def _module_name(self, path): + return os.path.basename(path).rsplit(".py", 1)[0].lower() + def _module_path(self, name): + return os.path.join(self.directory, "%s.py" % name) + + def _load_module(self, name): + path = self._module_path(name) + + with open(path) as module_file: + while True: + line = module_file.readline().strip() + line_split = line.split(" ") + if line and line.startswith("#--"): + # this is a hashflag + if line == "#--ignore": + # nope, ignore this module. + raise ModuleNotLoadedWarning("module ignored") + elif line_split[0] == "#--require-config" and len( + line_split) > 1: + if not line_split[1].lower() in self.bot.config or not self.bot.config[ + line_split[1].lower()]: + # nope, required config option not present. + raise ModuleNotLoadedWarning( + "required config not present") + elif line_split[0] == "#--require-module" and len( + line_split) > 1: + if not "bitbot_%s" % line_split[1].lower() in sys.modules: + if not line_split[1].lower() in self.waiting_requirement: + self.waiting_requirement[line_split[1].lower()] = set([]) + self.waiting_requirement[line_split[1].lower()].add(path) + raise ModuleNotLoadedWarning( + "waiting for requirement") + else: + break + module = imp.load_source(name, path) + + if not hasattr(module, "Module"): + raise ModuleLoadException("module '%s' doesn't have a " + "'Module' class.") + if not inspect.isclass(module.Module): + raise ModuleLoadException("module '%s' has a 'Module' attribute " + "but it is not a class.") + + context = str(uuid.uuid4()) + context_events = self.events.new_context(context) + context_exports = self.exports.new_context(context) + module_object = module.Module(self.bot, context_events, + context_exports) + + if not hasattr(module_object, "_name"): + module_object._name = name.title() + for attribute_name in dir(module_object): + attribute = getattr(module_object, attribute_name) + if inspect.ismethod(attribute) and hasattr(attribute, + BITBOT_HOOKS_MAGIC): + hooks = getattr(attribute, BITBOT_HOOKS_MAGIC) + for hook in hooks: + context_events.on(hook["event"]).hook(attribute, + **hook["kwargs"]) + + module_object._context = context + module_object._import_name = name + + assert not module_object._name in self.modules, ( + "module name '%s' attempted to be used twice.") + return module_object + + def load_module(self, name): + try: + module = self._load_module(name) + except ModuleWarning as warning: + self.bot.log.error("Module '%s' not loaded", [name]) + raise + except Exception as e: + self.bot.log.error("Failed to load module \"%s\": %s", + [name, str(e)]) + raise + + self.modules[module._import_name] = module + if name in self.waiting_requirement: + for requirement_name in self.waiting_requirement: + self.load_module(requirement_name) + self.bot.log.info("Module '%s' loaded", [name]) + + def load_modules(self, whitelist=[], blacklist=[]): + for path in self.list_modules(): + name = self._module_name(path) + if name in whitelist or (not whitelist and not name in blacklist): + try: + self.load_module(name) + except ModuleWarning: + pass + + def unload_module(self, name): + if not name in self.modules: + raise ModuleNotFoundException() + module = self.modules[name] + del self.modules[name] + + context = module._context + self.events.purge_context(context) + self.exports.purge_context(context) + + del sys.modules[name] + references = sys.getrefcount(module) + del module + references -= 1 # 'del module' removes one reference + references -= 1 # one of the refs is from getrefcount + + self.bot.log.info("Module '%s' unloaded (%d reference%s)", + [name, references, "" if references == 1 else "s"]) diff --git a/src/Timer.py b/src/Timer.py new file mode 100644 index 00000000..7ac83630 --- /dev/null +++ b/src/Timer.py @@ -0,0 +1,39 @@ +import time, uuid + +class Timer(object): + def __init__(self, id, bot, events, event_name, delay, + next_due=None, **kwargs): + self.id = id + self.bot = bot + self.events = events + self.event_name = event_name + self.delay = delay + if next_due: + self.next_due = next_due + else: + self.set_next_due() + self.kwargs = kwargs + self._done = False + self.call_count = 0 + + def set_next_due(self): + self.next_due = time.time()+self.delay + + def due(self): + return self.time_left() <= 0 + + def time_left(self): + return self.next_due-time.time() + + def call(self): + self._done = True + self.call_count +=1 + self.events.on("timer").on(self.event_name).call( + timer=self, **self.kwargs) + + def redo(self): + self._done = False + self.set_next_due() + + def done(self): + return self._done diff --git a/src/Utils.py b/src/Utils.py new file mode 100644 index 00000000..cd0e0cfa --- /dev/null +++ b/src/Utils.py @@ -0,0 +1,287 @@ +import json, re, traceback, urllib.request, urllib.parse, urllib.error, ssl +import string +import bs4 +from . import ModuleManager + +USER_AGENT = ("Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36") +REGEX_HTTP = re.compile("https?://", re.I) +ASCII_UPPER = string.ascii_uppercase +ASCII_LOWER = string.ascii_lowercase +STRICT_RFC1459_UPPER = ASCII_UPPER+r'\[]' +STRICT_RFC1459_LOWER = ASCII_LOWER+r'|{}' +RFC1459_UPPER = STRICT_RFC1459_UPPER+"^" +RFC1459_LOWER = STRICT_RFC1459_LOWER+"~" + +def remove_colon(s): + if s.startswith(":"): + s = s[1:] + return s + +def arbitrary(s, n): + return remove_colon(" ".join(s[n:])) + +# case mapping lowercase/uppcase logic +def _multi_replace(s, chars1, chars2): + for char1, char2 in zip(chars1, chars2): + s = s.replace(char1, char2) + return s +def irc_lower(server, s): + if server.case_mapping == "ascii": + return _multi_replace(s, ASCII_UPPER, ASCII_LOWER) + elif server.case_mapping == "rfc1459": + return _multi_replace(s, RFC1459_UPPER, RFC1459_LOWER) + elif server.case_mapping == "strict-rfc1459": + return _multi_replace(s, STRICT_RFC1459_UPPER, STRICT_RFC1459_LOWER) + else: + raise ValueError("unknown casemapping '%s'" % server.case_mapping) + +# compare a string while respecting case mapping +def irc_equals(server, s1, s2): + return irc_lower(server, s1) == irc_lower(server, s2) + +class IRCHostmask(object): + def __init__(self, nickname, username, hostname, hostmask): + self.nickname = nickname + self.username = username + self.hostname = hostname + self.hostmask = hostmask + def __repr__(self): + return "Utils.IRCHostmask(%s)" % self.__str__() + def __str__(self): + return self.hostmask + +def seperate_hostmask(hostmask): + hostmask = remove_colon(hostmask) + first_delim = hostmask.find("!") + second_delim = hostmask.find("@") + nickname, username, hostname = hostmask, None, None + if first_delim > -1 and second_delim > first_delim: + nickname, username = hostmask.split("!", 1) + username, hostname = username.split("@", 1) + elif second_delim > -1: + nickname, hostname = hostmask.split("@", 1) + return IRCHostmask(nickname, username, hostname, hostmask) + +def get_url(url, **kwargs): + if not urllib.parse.urlparse(url).scheme: + url = "http://%s" % url + url_parsed = urllib.parse.urlparse(url) + + method = kwargs.get("method", "GET") + get_params = kwargs.get("get_params", "") + post_params = kwargs.get("post_params", None) + headers = kwargs.get("headers", {}) + if get_params: + get_params = "?%s" % urllib.parse.urlencode(get_params) + if post_params: + post_params = urllib.parse.urlencode(post_params).encode("utf8") + url = "%s%s" % (url, get_params) + try: + url.encode("latin-1") + except UnicodeEncodeError: + if kwargs.get("code"): + return 0, False + return False + + request = urllib.request.Request(url, post_params) + request.add_header("Accept-Language", "en-US") + request.add_header("User-Agent", USER_AGENT) + for header, value in headers.items(): + request.add_header(header, value) + request.method = method + + try: + response = urllib.request.urlopen(request, timeout=5) + except urllib.error.HTTPError as e: + traceback.print_exc() + if kwargs.get("code"): + return e.code, False + return False + except urllib.error.URLError as e: + traceback.print_exc() + if kwargs.get("code"): + return -1, False + return False + except ssl.CertificateError as e: + traceback.print_exc() + if kwargs.get("code"): + return -1, False, + return False + + response_content = response.read() + encoding = response.info().get_content_charset() + if kwargs.get("soup"): + return bs4.BeautifulSoup(response_content, kwargs.get("parser", "lxml")) + if not encoding: + soup = bs4.BeautifulSoup(response_content, kwargs.get("parser", "lxml")) + metas = soup.find_all("meta") + for meta in metas: + if "charset=" in meta.get("content", ""): + encoding = meta.get("content").split("charset=", 1)[1 + ].split(";", 1)[0] + elif meta.get("charset", ""): + encoding = meta.get("charset") + else: + continue + break + if not encoding: + for item in soup.contents: + if isinstance(item, bs4.Doctype): + if item == "html": + encoding = "utf8" + else: + encoding = "latin-1" + break + response_content = response_content.decode(encoding or "utf8") + data = response_content + if kwargs.get("json") and data: + try: + data = json.loads(response_content) + except json.decoder.JSONDecodeError: + traceback.print_exc() + return False + if kwargs.get("code"): + return response.code, data + else: + return data + +COLOR_WHITE, COLOR_BLACK, COLOR_BLUE, COLOR_GREEN = 0, 1, 2, 3 +COLOR_RED, COLOR_BROWN, COLOR_PURPLE, COLOR_ORANGE = 4, 5, 6, 7 +COLOR_YELLOW, COLOR_LIGHTGREEN, COLOR_CYAN, COLOR_LIGHTCYAN = (8, 9, + 10, 11) +COLOR_LIGHTBLUE, COLOR_PINK, COLOR_GREY, COLOR_LIGHTGREY = (12, 13, + 14, 15) +FONT_BOLD, FONT_ITALIC, FONT_UNDERLINE, FONT_INVERT = ("\x02", "\x1D", + "\x1F", "\x16") +FONT_COLOR, FONT_RESET = "\x03", "\x0F" + +def color(s, foreground, background=None): + foreground = str(foreground).zfill(2) + if background: + background = str(background).zfill(2) + return "%s%s%s%s%s" % (FONT_COLOR, foreground, + "" if not background else ",%s" % background, s, FONT_COLOR) + +def bold(s): + return "%s%s%s" % (FONT_BOLD, s, FONT_BOLD) + +def underline(s): + return "%s%s%s" % (FONT_UNDERLINE, s, FONT_UNDERLINE) + +TIME_SECOND = 1 +TIME_MINUTE = TIME_SECOND*60 +TIME_HOUR = TIME_MINUTE*60 +TIME_DAY = TIME_HOUR*24 +TIME_WEEK = TIME_DAY*7 + +def time_unit(seconds): + since = None + unit = None + if seconds >= TIME_WEEK: + since = seconds/TIME_WEEK + unit = "week" + elif seconds >= TIME_DAY: + since = seconds/TIME_DAY + unit = "day" + elif seconds >= TIME_HOUR: + since = seconds/TIME_HOUR + unit = "hour" + elif seconds >= TIME_MINUTE: + since = seconds/TIME_MINUTE + unit = "minute" + else: + since = seconds + unit = "second" + since = int(since) + if since > 1: + unit = "%ss" % unit # pluralise the unit + return [since, unit] + +REGEX_PRETTYTIME = re.compile("\d+[wdhms]", re.I) + +SECONDS_MINUTES = 60 +SECONDS_HOURS = SECONDS_MINUTES*60 +SECONDS_DAYS = SECONDS_HOURS*24 +SECONDS_WEEKS = SECONDS_DAYS*7 + +def from_pretty_time(pretty_time): + seconds = 0 + for match in re.findall(REGEX_PRETTYTIME, pretty_time): + number, unit = int(match[:-1]), match[-1].lower() + if unit == "m": + number = number*SECONDS_MINUTES + elif unit == "h": + number = number*SECONDS_HOURS + elif unit == "d": + number = number*SECONDS_DAYS + elif unit == "w": + number = number*SECONDS_WEEKS + seconds += number + if seconds > 0: + return seconds + +UNIT_SECOND = 5 +UNIT_MINUTE = 4 +UNIT_HOUR = 3 +UNIT_DAY = 2 +UNIT_WEEK = 1 +def to_pretty_time(total_seconds, minimum_unit=UNIT_SECOND, max_units=6): + minutes, seconds = divmod(total_seconds, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + weeks, days = divmod(days, 7) + out = "" + + units = 0 + if weeks and minimum_unit >= UNIT_WEEK and units < max_units: + out += "%dw" % weeks + units += 1 + if days and minimum_unit >= UNIT_DAY and units < max_units: + out += "%dd" % days + units += 1 + if hours and minimum_unit >= UNIT_HOUR and units < max_units: + out += "%dh" % hours + units += 1 + if minutes and minimum_unit >= UNIT_MINUTE and units < max_units: + out += "%dm" % minutes + units += 1 + if seconds and minimum_unit >= UNIT_SECOND and units < max_units: + out += "%ds" % seconds + units += 1 + return out + +IS_TRUE = ["true", "yes", "on", "y"] +IS_FALSE = ["false", "no", "off", "n"] +def bool_or_none(s): + s = s.lower() + if s in IS_TRUE: + return True + elif s in IS_FALSE: + return False +def int_or_none(s): + stripped_s = s.lstrip("0") + if stripped_s.isdigit(): + return int(stripped_s) + +def get_closest_setting(event, setting, default=None): + server = event["server"] + if "channel" in event: + closest = event["channel"] + elif "target" in event and "is_channel" in event and event["is_channel"]: + closest = event["target"] + else: + closest = event["user"] + return closest.get_setting(setting, server.get_setting(setting, default)) + +def prevent_highlight(nickname): + return nickname[0]+"\u200d"+nickname[1:] + +def hook(event, **kwargs): + def _hook_func(func): + if not hasattr(func, ModuleManager.BITBOT_HOOKS_MAGIC): + setattr(func, ModuleManager.BITBOT_HOOKS_MAGIC, []) + getattr(func, ModuleManager.BITBOT_HOOKS_MAGIC).append( + {"event": event, "kwargs": kwargs}) + return func + return _hook_func |
