aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGravatar jesopo2018-09-24 15:13:27 +0100
committerGravatar jesopo2018-09-24 15:13:27 +0100
commitecb9d7cb3f4435457560e03201bbed57a469d548 (patch)
tree5a010f97c209558cdd2d40327d41e6806aedde94 /src
parentRemove empty spaces in coins.py (diff)
signature
Move most code in root directory to src/
Diffstat (limited to 'src')
-rw-r--r--src/Config.py16
-rw-r--r--src/Database.py369
-rw-r--r--src/EventManager.py226
-rw-r--r--src/Exports.py44
-rw-r--r--src/IRCBot.py197
-rw-r--r--src/IRCBuffer.py48
-rw-r--r--src/IRCChannel.py132
-rw-r--r--src/IRCLineHandler.py593
-rw-r--r--src/IRCServer.py402
-rw-r--r--src/IRCUser.py62
-rw-r--r--src/Logging.py48
-rw-r--r--src/ModuleManager.py147
-rw-r--r--src/Timer.py39
-rw-r--r--src/Utils.py287
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