class Channel(object): def __init__(self, parent, host='127.0.0.1', port=6668, name='undefined', topic='undefined'): self.notify = Logger( 'Channel') # TODO - generate unique hashes for each channel? self.parent = parent self.host = host self.port = port self.name = name self.topic = topic def setup_channel(self): self.notify.warning('attempting to establish connection to server...') with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((self.host, self.parent.port)) s.sendall(bytes([1])) try: data = s.recv(4096) self.notify.debug('received data - {}'.format(data)) self.handle_data(Packet(data)) except Exception as e: raise Exception(e) def handle_data(self, packet): if packet.data[0] == 2: self.notify.info('successfully established connection to server!')
def load_from_file(cls, path: str): try: global_conf = load_from_file(path) cls.config.behavior = load_from_file(global_conf['behavior']) cls.config.waypoint = load_from_file(global_conf['waypoint']) except Exception as e: Logger.warning("Unable to load global config: 'global.json'")
class CharacterHistoryService: CACHE_GROUP = "history" CACHE_MAX_AGE = 86400 def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.cache_service = registry.get_instance("cache_service") def get_character_history(self, name, server_num): cache_key = "%s.%d.json" % (name, server_num) t = int(time.time()) # check cache for fresh value cache_result = self.cache_service.retrieve(self.CACHE_GROUP, cache_key) if cache_result and cache_result.last_modified > (t - self.CACHE_MAX_AGE): # TODO set cache age result = json.loads(cache_result.data) else: url = self.get_pork_url(server_num, name) try: r = requests.get( url, headers={"User-Agent": f"Tyrbot {self.bot.version}"}, timeout=5) result = r.json() except ReadTimeout: self.logger.warning("Timeout while requesting '%s'" % url) result = None except Exception as e: self.logger.error( "Error requesting history for url '%s'" % url, e) result = None if result: # store result in cache self.cache_service.store(self.CACHE_GROUP, cache_key, json.dumps(result)) elif cache_result: # check cache for any value, even expired result = json.loads(cache_result.data) if result: # TODO set cache age return map(lambda x: DictObject(x), result) else: return None def get_pork_url(self, dimension, char_name): return "http://pork.budabot.jkbff.com/pork/history.php?server=%d&name=%s" % ( dimension, char_name)
class JobScheduler: def __init__(self): self.logger = Logger("job_scheduler") self.jobs = [] self.job_id_index = 0 def inject(self, registry): pass def start(self): pass def check_for_scheduled_jobs(self, timestamp): while self.jobs and self.jobs[0]["time"] <= timestamp: try: job = self.jobs.pop(0) job["callback"](job["time"], *job["args"], **job["kwargs"]) except Exception as e: self.logger.warning("Error processing scheduled job", e) def delayed_job(self, callback, delay, *args, **kwargs): return self.scheduled_job(callback, int(time.time()) + delay, *args, **kwargs) def scheduled_job(self, callback, scheduled_time, *args, **kwargs): job_id = self._get_next_job_id() new_job = { "id": job_id, "callback": callback, "args": args, "kwargs": kwargs, "time": scheduled_time } self._insert_job(new_job) return job_id def cancel_job(self, job_id): for index, job in enumerate(self.jobs): if job["id"] == job_id: return self.jobs.pop(index) return None def _insert_job(self, new_job): for index, job in enumerate(self.jobs): if job["time"] > new_job["time"]: self.jobs.insert(index, new_job) return self.jobs.append(new_job) def _get_next_job_id(self): self.job_id_index += 1 return self.job_id_index
class CharacterHistoryService: CACHE_GROUP = "history" CACHE_MAX_AGE = 86400 def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.cache_service = registry.get_instance("cache_service") def get_character_history(self, name, server_num): cache_key = "%s.%d.json" % (name, server_num) # check cache for fresh value cache_result = self.cache_service.retrieve(self.CACHE_GROUP, cache_key, self.CACHE_MAX_AGE) if cache_result: # TODO set cache age result = json.loads(cache_result) else: url = "http://pork.budabot.jkbff.com/pork/history.php?server=%d&name=%s" % (server_num, name) try: r = requests.get(url, timeout=5) result = r.json() except ReadTimeout: self.logger.warning("Timeout while requesting '%s'" % url) result = None except Exception as e: self.logger.error("Error requesting history for url '%s'" % url, e) result = None if result: # store result in cache self.cache_service.store(self.CACHE_GROUP, cache_key, json.dumps(result)) else: # check cache for any value, even expired # TODO set cache age cache_obj = self.cache_service.retrieve(self.CACHE_GROUP, cache_key) if cache_obj: result = json.loads(cache_obj) if result: return map(lambda x: DictObject(x), result) else: return None
class CharacterHistoryService: CACHE_GROUP = "history" CACHE_MAX_AGE = 86400 def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.cache_service = registry.get_instance("cache_service") def get_character_history(self, name, server_num): cache_key = "%s.%d.json" % (name, server_num) # check cache for fresh value cache_result = self.cache_service.retrieve(self.CACHE_GROUP, cache_key, self.CACHE_MAX_AGE) if cache_result: result = json.loads(cache_result) else: url = "http://pork.budabot.jkbff.com/pork/history.php?server=%d&name=%s" % ( server_num, name) r = requests.get(url) try: result = r.json() except ValueError as e: self.logger.warning( "Error marshalling value as json: %s" % r.text, e) result = None if result: # store result in cache self.cache_service.store(self.CACHE_GROUP, cache_key, json.dumps(result)) else: # check cache for any value, even expired result = self.cache_service.retrieve(self.CACHE_GROUP, cache_key) if result: return map(lambda x: DictObject(x), result) else: return None
def do_request(**kwargs): """ Realiza una petición a un recurso de Gmail API. :param kwargs: Parámetros de la petición. :type kwargs: dict. :return: Respuesta de Gmail API. """ """ Puede que ocurra un error de rateLimitExceeded o userRateLimitExceeded. En ese caso la documentación oficial recomienda implementar un exponential backoff https://developers.google.com/gmail/api/guides/migrate-from-emapi https://developers.google.com/drive/v2/web/handle-errors https://github.com/google/google-api-python-client/blob/master/googleapiclient/http.py#L65 """ from core.logger import Logger if "userId" not in kwargs: kwargs["userId"] = "me" Logger.info("Executing request...") # Reintentamos 3 veces. for n in range(0, 3): try: Logger.info("Try #{}".format(n + 1)) response = method(**kwargs).execute(num_retries=3) return response except errors.HttpError, e: Logger.info(e) Logger.info("Execution failed...") Logger.info("Status: {}".format(e.resp.status)) Logger.info("Reason: {}".format(e.resp.reason)) if e.resp.status in [403, 429, 503] or \ e.resp.reason in ["rateLimitExceeded", "userRateLimitExceeded"]: Logger.warning("Error {}. Retrying".format(e.resp.status)) time.sleep((2 ** n) + random.randint(0, 1000) / 1000) else: Logger.error("Unknown error: {}".format(e)) raise e
class DB: SQLITE = "sqlite" MYSQL = "mysql" def __init__(self): self.conn = None self.enhanced_like_regex = re.compile( r"(\s+)(\S+)\s+<EXTENDED_LIKE=(\d+)>\s+\?(\s*)", re.IGNORECASE) self.lastrowid = None self.logger = Logger(__name__) self.type = None self.transaction_level = 0 def sqlite_row_factory(self, cursor: sqlite3.Cursor, row): d = {} for idx, col in enumerate(cursor.description): d[col[0]] = row[idx] return d def connect_mysql(self, host, port, username, password, database_name): self.type = self.MYSQL self.conn = mysql.connector.connect(user=username, password=password, host=host, port=port, database=database_name, charset="utf8", autocommit=True) self.exec("SET collation_connection = 'utf8_general_ci'") self.exec("SET sql_mode = 'TRADITIONAL,ANSI'") self.create_db_version_table() def connect_sqlite(self, filename): self.type = self.SQLITE self.conn = sqlite3.connect(filename, isolation_level=None, check_same_thread=False) self.conn.row_factory = self.sqlite_row_factory self.create_db_version_table() def create_db_version_table(self): self.exec( "CREATE TABLE IF NOT EXISTS db_version (file VARCHAR(255) NOT NULL, version VARCHAR(255) NOT NULL, verified SMALLINT NOT NULL)" ) def _execute_wrapper(self, sql, params, callback): if self.type == self.MYSQL: # buffered=True - https://stackoverflow.com/a/33632767/280574 cur = self.conn.cursor(dictionary=True, buffered=True) else: cur = self.conn.cursor() start_time = time.time() try: cur.execute( sql if self.type == self.SQLITE else sql.replace("?", "%s"), params) except Exception as e: raise SqlException( "SQL Error: '%s' for '%s' [%s]" % (str(e), sql, ", ".join(map(lambda x: str(x), params)))) from e elapsed = time.time() - start_time if elapsed > 0.5: self.logger.warning("slow query (%fs) '%s' for params: %s" % (elapsed, sql, str(params))) result = callback(cur) cur.close() return result def query_single(self, sql, params=None, extended_like=False): if params is None: params = [] if extended_like: sql, params = self.handle_extended_like(sql, params) sql, params = self.format_sql(sql, params) def map_result(cur): row = cur.fetchone() return DictObject(row) if row else None return self._execute_wrapper(sql, params, map_result) def query(self, sql, params=None, extended_like=False): if params is None: params = [] if extended_like: sql, params = self.handle_extended_like(sql, params) sql, params = self.format_sql(sql, params) def map_result(cur): return list(map(lambda row: DictObject(row), cur.fetchall())) return self._execute_wrapper(sql, params, map_result) def exec(self, sql, params=None, extended_like=False): if params is None: params = [] if extended_like: sql, params = self.handle_extended_like(sql, params) sql, params = self.format_sql(sql, params) def map_result(cur): return [cur.rowcount, cur.lastrowid] row_count, lastrowid = self._execute_wrapper(sql, params, map_result) self.lastrowid = lastrowid return row_count def last_insert_id(self): return self.lastrowid def format_sql(self, sql, params=None): # TODO check for AUTOINCREMENT in sql and log warning if self.type == self.SQLITE: sql = sql.replace("AUTO_INCREMENT", "AUTOINCREMENT") sql = sql.replace(" INT ", " INTEGER ") sql = sql.replace("INSERT IGNORE", "INSERT OR IGNORE") return sql, params def handle_extended_like(self, sql, params): original_params = params.copy() params = list(map(lambda x: [x], params)) for match in self.enhanced_like_regex.finditer(sql): field = match.group(2) index = int(match.group(3)) extra_sql, vals = self._get_extended_params( field, original_params[index].split(" ")) sql = self.enhanced_like_regex.sub( match.group(1) + "(" + " AND ".join(extra_sql) + ")" + match.group(4), sql, 1) # remove current param and add generated params in its place del params[index] params.insert(index, vals) return sql, [item for sublist in params for item in sublist] def _get_extended_params(self, field, params): extra_sql = [] vals = [] for p in params: if p.startswith("-") and p != "-": vals.append("%" + p[1:] + "%") extra_sql.append(field + " NOT LIKE ?") else: vals.append("%" + p + "%") extra_sql.append(field + " LIKE ?") return extra_sql, vals def get_connection(self): return self.conn def load_sql_file(self, sqlfile, base_path): filename = base_path + os.sep + sqlfile db_version = self.get_db_version(filename) file_version = self.get_file_version(filename) if db_version: if parse_version(file_version) > parse_version(db_version): self.logger.debug("loading sql file '%s'" % sqlfile) self._load_file(filename) self.exec( "UPDATE db_version SET version = ?, verified = 1 WHERE file = ?", [int(file_version), filename]) else: self.logger.debug("loading sql file '%s'" % sqlfile) self._load_file(filename) self.exec( "INSERT INTO db_version (file, version, verified) VALUES (?, ?, 1)", [filename, int(file_version)]) def get_file_version(self, filename): return str(int(os.path.getmtime(filename))) def get_db_version(self, filename): row = self.query_single( "SELECT version FROM db_version WHERE file = ?", [filename]) if row: return row.version else: return None def _load_file(self, filename): with open(filename, mode="r", encoding="UTF-8") as f: with self.transaction(): cur = self.conn.cursor() line_num = 1 for line in f.readlines(): try: sql, _ = self.format_sql(line) sql = sql.strip() if sql and not sql.startswith("--"): cur.execute(sql) except Exception as e: raise Exception( "sql error in file '%s' on line %d: %s" % (filename, line_num, str(e))) line_num += 1 cur.close() def get_type(self): return self.type # transaction support def transaction(self): return self def __enter__(self): # called when entering `with` code block self.begin_transaction() def __exit__(self, exc_type, exc_val, exc_tb): # called when exiting `with` code block # if exc_type, exc_val or exc_tb is not None, there was an exception # otherwise the code block exited normally if exc_type is None: self.commit_transaction() else: self.rollback_transaction() # False here indicates that if there was an exception, it should not be suppressed but instead propagated return False def begin_transaction(self): if self.transaction_level == 0: self.exec("BEGIN;") self.transaction_level += 1 def commit_transaction(self): if self.transaction_level == 1: self.exec("COMMIT;") self.transaction_level -= 1 def rollback_transaction(self): if self.transaction_level == 1: self.exec("ROLLBACK;") self.transaction_level -= 1
class JobScheduler: def __init__(self): self.logger = Logger(__name__) self.jobs = [] self.job_id_index = 0 def check_for_scheduled_jobs(self, timestamp): while self.jobs and self.jobs[0]["time"] <= timestamp: try: job = self.jobs.pop(0) job["callback"](job["time"], *job["args"], **job["kwargs"]) except Exception as e: self.logger.warning("Error processing scheduled job", e) def delayed_job(self, callback, delay, *args, **kwargs): """ Args: callback: (time: Int, *args, *kwargs) -> void) delay: int *args **kwargs """ return self.scheduled_job(callback, int(time.time()) + delay, *args, **kwargs) def scheduled_job(self, callback, scheduled_time, *args, **kwargs): """ Args: callback: (time: Int, *args, *kwargs) -> void) scheduled_time: int *args **kwargs """ if len(inspect.signature(callback).parameters) < 1: raise Exception("Incorrect number of arguments for handler '%s.%s()'" % (callback.__module__, callback.__name__)) job_id = self._get_next_job_id() new_job = { "id": job_id, "callback": callback, "args": args, "kwargs": kwargs, "time": scheduled_time } self._insert_job(new_job) return job_id def cancel_job(self, job_id): for index, job in enumerate(self.jobs): if job["id"] == job_id: return self.jobs.pop(index) return None def _insert_job(self, new_job): for index, job in enumerate(self.jobs): if job["time"] > new_job["time"]: self.jobs.insert(index, new_job) return self.jobs.append(new_job) def _get_next_job_id(self): self.job_id_index += 1 return self.job_id_index
class Tyrbot: CONNECT_EVENT = "connect" PRIVATE_MSG_EVENT = "private_msg" def __init__(self): super().__init__() self.logger = Logger(__name__) self.ready = False self.packet_handlers = {} self.superadmin = None self.status: BotStatus = BotStatus.SHUTDOWN self.dimension = None self.last_timer_event = 0 self.start_time = int(time.time()) self.version = "0.7-beta" self.incoming_queue = FifoQueue() self.mass_message_queue = None self.conns = DictObject() self.primary_conn_id = None def inject(self, registry): self.db = registry.get_instance("db") self.character_service: CharacterService = registry.get_instance( "character_service") self.public_channel_service: PublicChannelService = registry.get_instance( "public_channel_service") self.text: Text = registry.get_instance("text") self.setting_service: SettingService = registry.get_instance( "setting_service") self.access_service: AccessService = registry.get_instance( "access_service") self.event_service = registry.get_instance("event_service") self.job_scheduler = registry.get_instance("job_scheduler") def init(self, config, registry, mmdb_parser): self.mmdb_parser = mmdb_parser self.superadmin = config.superadmin.capitalize() self.dimension = config.server.dimension self.db.exec( "CREATE TABLE IF NOT EXISTS command_config (command VARCHAR(50) NOT NULL, sub_command VARCHAR(50) NOT NULL, access_level VARCHAR(50) NOT NULL, channel VARCHAR(50) NOT NULL, " "module VARCHAR(50) NOT NULL, enabled SMALLINT NOT NULL, verified SMALLINT NOT NULL)" ) self.db.exec( "CREATE TABLE IF NOT EXISTS event_config (event_type VARCHAR(50) NOT NULL, event_sub_type VARCHAR(50) NOT NULL, handler VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, " "module VARCHAR(50) NOT NULL, enabled SMALLINT NOT NULL, verified SMALLINT NOT NULL, is_hidden SMALLINT NOT NULL)" ) self.db.exec( "CREATE TABLE IF NOT EXISTS timer_event (event_type VARCHAR(50) NOT NULL, event_sub_type VARCHAR(50) NOT NULL, handler VARCHAR(255) NOT NULL, next_run INT NOT NULL)" ) self.db.exec( "CREATE TABLE IF NOT EXISTS setting (name VARCHAR(50) NOT NULL, value VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, module VARCHAR(50) NOT NULL, verified SMALLINT NOT NULL)" ) self.db.exec( "CREATE TABLE IF NOT EXISTS command_alias (alias VARCHAR(50) NOT NULL, command VARCHAR(1024) NOT NULL, enabled SMALLINT NOT NULL)" ) self.db.exec( "CREATE TABLE IF NOT EXISTS command_usage (command VARCHAR(255) NOT NULL, handler VARCHAR(255) NOT NULL, char_id INT NOT NULL, channel VARCHAR(20) NOT NULL, created_at INT NOT NULL)" ) self.db.exec( "CREATE TABLE IF NOT EXISTS ban_list (char_id INT NOT NULL, sender_char_id INT NOT NULL, created_at INT NOT NULL, finished_at INT NOT NULL, reason VARCHAR(255) NOT NULL, ended_early SMALLINT NOT NULL)" ) self.db.exec("UPDATE db_version SET verified = 0") self.db.exec( "UPDATE db_version SET verified = 1 WHERE file = 'db_version'") # prepare commands, events, and settings self.db.exec("UPDATE command_config SET verified = 0") self.db.exec("UPDATE event_config SET verified = 0") self.db.exec("UPDATE setting SET verified = 0") with self.db.transaction(): registry.pre_start_all() registry.start_all() # remove commands, events, and settings that are no longer registered self.db.exec("DELETE FROM db_version WHERE verified = 0") self.db.exec("DELETE FROM command_config WHERE verified = 0") self.db.exec("DELETE FROM event_config WHERE verified = 0") self.db.exec( "DELETE FROM timer_event WHERE handler NOT IN (SELECT handler FROM event_config WHERE event_type = ?)", ["timer"]) self.db.exec("DELETE FROM setting WHERE verified = 0") self.status = BotStatus.RUN def pre_start(self): self.access_service.register_access_level("superadmin", 10, self.check_superadmin) self.event_service.register_event_type(self.CONNECT_EVENT) self.event_service.register_event_type(self.PRIVATE_MSG_EVENT) def start(self): self.setting_service.register( "core.system", "symbol", "!", TextSettingType(["!", "#", "*", "@", "$", "+", "-"]), "Symbol for executing bot commands") self.setting_service.register( "core.system", "org_channel_max_page_length", 7500, NumberSettingType([4500, 6000, 7500, 9000, 10500, 12000]), "Maximum size of blobs in org channel") self.setting_service.register( "core.system", "private_message_max_page_length", 7500, NumberSettingType([4500, 6000, 7500, 9000, 10500, 12000]), "Maximum size of blobs in private messages") self.setting_service.register( "core.system", "private_channel_max_page_length", 7500, NumberSettingType([4500, 6000, 7500, 9000, 10500, 12000]), "Maximum size of blobs in private channel") self.setting_service.register( "core.system", "accept_commands_from_slave_bots", False, BooleanSettingType(), "Accept and respond to commands sent to slave bots (only applies if you have added slave bots in the config)" ) self.setting_service.register("core.colors", "header_color", "#FFFF00", ColorSettingType(), "Color for headers") self.setting_service.register("core.colors", "header2_color", "#FCA712", ColorSettingType(), "Color for sub-headers") self.setting_service.register("core.colors", "highlight_color", "#00BFFF", ColorSettingType(), "Color for highlight") self.setting_service.register("core.colors", "notice_color", "#FF8C00", ColorSettingType(), "Color for important notices") self.setting_service.register("core.colors", "neutral_color", "#E6E1A6", ColorSettingType(), "Color for neutral faction") self.setting_service.register("core.colors", "omni_color", "#FA8484", ColorSettingType(), "Color for omni faction") self.setting_service.register("core.colors", "clan_color", "#F79410", ColorSettingType(), "Color for clan faction") self.setting_service.register("core.colors", "unknown_color", "#FF0000", ColorSettingType(), "Color for unknown faction") self.setting_service.register("core.colors", "org_channel_color", "#89D2E8", ColorSettingType(), "Default org channel color") self.setting_service.register("core.colors", "private_channel_color", "#89D2E8", ColorSettingType(), "Default private channel color") self.setting_service.register("core.colors", "private_message_color", "#89D2E8", ColorSettingType(), "Default private message color") self.setting_service.register("core.colors", "blob_color", "#FFFFFF", ColorSettingType(), "Default blob content color") self.register_packet_handler(server_packets.PrivateMessage.id, self.handle_private_message, priority=40) def check_superadmin(self, char_id): char_name = self.character_service.resolve_char_to_name(char_id) return char_name == self.superadmin def connect(self, config): for i, bot in enumerate(config.bots): if "id" in bot: _id = bot.id else: _id = "bot" + str(i) if i == 0: self.primary_conn_id = _id conn = self.create_conn(_id) conn.connect(config.server.host, config.server.port) # only create the mass_message_queue if there is at least 1 non-main bot if not bot.is_main and not self.mass_message_queue: self.mass_message_queue = FifoQueue() packet = conn.login(bot.username, bot.password, bot.character, is_main=bot.is_main) if not packet: self.status = BotStatus.ERROR return False else: self.incoming_queue.put((conn, packet)) self.create_conn_thread( conn, None if bot.is_main else self.mass_message_queue) return True def create_conn_thread(self, conn: Conn, mass_message_queue=None): def read_packets(): try: while self.status == BotStatus.RUN: packet = conn.read_packet(1) if packet: self.incoming_queue.put((conn, packet)) while mass_message_queue and not mass_message_queue.empty( ) and conn.packet_queue.is_empty(): packet = mass_message_queue.get_or_default(block=False) if packet: conn.add_packet_to_queue(packet) except (EOFError, OSError) as e: self.status = BotStatus.ERROR self.logger.error("", e) raise e dthread = threading.Thread(target=read_packets, daemon=True) dthread.start() def create_conn(self, _id): if _id in self.conns: raise Exception(f"A connection with id {_id} already exists") def failure_callback(): self.status = BotStatus.ERROR conn = Conn(_id, failure_callback) self.conns[_id] = conn return conn def disconnect(self): # wait for all threads to stop reading packets, then disconnect them all time.sleep(2) for _id, conn in self.get_conns(): conn.disconnect() def run(self): start = time.time() # wait for flood of packets from login to stop sending time_waited = 0 while time_waited < 2: if not self.iterate(1): time_waited += 1 self.logger.info("Login complete (%fs)" % (time.time() - start)) start = time.time() self.event_service.fire_event("connect", None) self.event_service.run_timer_events_at_startup() self.event_service.check_for_timer_events(int(start)) self.logger.info("Connect events finished (%fs)" % (time.time() - start)) time_waited = 0 while time_waited < 2: if not self.iterate(1): time_waited += 1 self.ready = True timestamp = int(time.time()) while self.status == BotStatus.RUN: try: timestamp = int(time.time()) self.check_for_timer_events(timestamp) self.iterate() except Exception as e: self.logger.error("", e) # run any pending jobs/events self.check_for_timer_events(timestamp + 1) return self.status def check_for_timer_events(self, timestamp): # timer events will execute no more often than once per second if self.last_timer_event < timestamp: self.last_timer_event = timestamp self.job_scheduler.check_for_scheduled_jobs(timestamp) self.event_service.check_for_timer_events(timestamp) def register_packet_handler(self, packet_id: int, handler, priority=50): """ Call during pre_start Args: packet_id: int handler: (conn, packet) -> void priority: int """ if len(inspect.signature(handler).parameters) != 2: raise Exception( "Incorrect number of arguments for handler '%s.%s()'" % (handler.__module__, handler.__name__)) handlers = self.packet_handlers.get(packet_id, []) handlers.append(DictObject({"priority": priority, "handler": handler})) self.packet_handlers[packet_id] = sorted(handlers, key=lambda x: x.priority) def remove_packet_handler(self, packet_id, handler): handlers = self.packet_handlers.get(packet_id, []) for h in handlers: if h.handler == handler: handlers.remove(h) def iterate(self, timeout=0.1): conn, packet = self.incoming_queue.get_or_default(block=True, timeout=timeout, default=(None, None)) if packet: if isinstance(packet, server_packets.SystemMessage): packet = self.system_message_ext_msg_handling(packet) self.logger.log_chat(conn, "SystemMessage", None, packet.extended_message.get_message()) elif isinstance(packet, server_packets.PublicChannelMessage): packet = self.public_channel_message_ext_msg_handling(packet) elif isinstance(packet, server_packets.BuddyAdded) and packet.char_id == 0: return for handler in self.packet_handlers.get(packet.id, []): handler.handler(conn, packet) return packet def public_channel_message_ext_msg_handling( self, packet: server_packets.PublicChannelMessage): msg = packet.message if msg.startswith("~&") and msg.endswith("~"): try: msg = msg[2:-1].encode("utf-8") category_id = self.mmdb_parser.read_base_85(msg[0:5]) instance_id = self.mmdb_parser.read_base_85(msg[5:10]) template = self.mmdb_parser.get_message_string( category_id, instance_id) params = self.mmdb_parser.parse_params(msg[10:]) packet.extended_message = ExtendedMessage( category_id, instance_id, template, params) except Exception as e: self.logger.error( "Error handling extended message for packet: " + str(packet), e) return packet def system_message_ext_msg_handling(self, packet: server_packets.SystemMessage): try: category_id = 20000 instance_id = packet.message_id template = self.mmdb_parser.get_message_string( category_id, instance_id) params = self.mmdb_parser.parse_params(packet.message_args) packet.extended_message = ExtendedMessage(category_id, instance_id, template, params) except Exception as e: self.logger.error( "Error handling extended message: " + str(packet), e) return packet def send_org_message(self, msg, add_color=True, conn=None): if not conn: conn = self.get_primary_conn() if not conn.org_channel_id: self.logger.debug( f"Ignoring message to org channel for {conn.id} since the org_channel_id is unknown" ) else: color = self.setting_service.get( "org_channel_color").get_font_color() if add_color else "" pages = self.get_text_pages( msg, conn, self.setting_service.get( "org_channel_max_page_length").get_value()) for page in pages: packet = client_packets.PublicChannelMessage( conn.org_channel_id, color + page, "") conn.add_packet_to_queue(packet) def send_private_message(self, char_id, msg, add_color=True, conn=None): if not conn: conn = self.get_primary_conn() if char_id is None: raise Exception("Cannot send message, char_id is empty") else: color = self.setting_service.get( "private_message_color").get_font_color() if add_color else "" pages = self.get_text_pages( msg, conn, self.setting_service.get( "private_message_max_page_length").get_value()) for page in pages: self.logger.log_tell( conn, "To", self.character_service.get_char_name(char_id), page) packet = client_packets.PrivateMessage(char_id, color + page, "\0") conn.add_packet_to_queue(packet) def send_private_channel_message(self, msg, private_channel_id=None, add_color=True, conn=None): if not conn: conn = self.get_primary_conn() if private_channel_id is None: private_channel_id = conn.get_char_id() color = self.setting_service.get( "private_channel_color").get_font_color() if add_color else "" pages = self.get_text_pages( msg, conn, self.setting_service.get( "private_channel_max_page_length").get_value()) for page in pages: packet = client_packets.PrivateChannelMessage( private_channel_id, color + page, "\0") conn.send_packet(packet) def send_mass_message(self, char_id, msg, add_color=True, conn=None): if not conn: conn = self.get_primary_conn() if not char_id: self.logger.warning("Could not send message to empty char_id") else: color = self.setting_service.get( "private_message_color").get_font_color() if add_color else "" pages = self.get_text_pages( msg, conn, self.setting_service.get( "private_message_max_page_length").get_value()) for page in pages: if self.mass_message_queue: packet = client_packets.PrivateMessage( char_id, color + page, "\0") self.mass_message_queue.put(packet) else: packet = client_packets.PrivateMessage( char_id, color + page, "spam") self.get_primary_conn().send_packet(packet) def send_message_to_other_org_channels(self, msg, from_conn: Conn): for _id, conn in self.get_conns( lambda x: x.is_main and x.org_id and x != from_conn): self.send_org_message(msg, conn=conn) def handle_private_message(self, conn: Conn, packet: server_packets.PrivateMessage): char_name = self.character_service.get_char_name(packet.char_id) self.logger.log_tell(conn, "From", char_name, packet.message) self.event_service.fire_event( self.PRIVATE_MSG_EVENT, DictObject({ "char_id": packet.char_id, "name": char_name, "message": packet.message, "conn": conn })) def get_text_pages(self, msg, conn, max_page_length): if isinstance(msg, ChatBlob): return self.text.paginate(msg, conn, max_page_length=max_page_length) else: return [self.text.format_message(msg, conn)] def is_ready(self): return self.ready def shutdown(self): self.status = BotStatus.SHUTDOWN def restart(self): self.status = BotStatus.RESTART def get_primary_conn_id(self): return self.primary_conn_id def get_primary_conn(self): return self.conns[self.get_primary_conn_id()] def get_conn_by_char_id(self, char_id): for _id, conn in self.get_conns(): if char_id == conn.get_char_id(): return conn return None def get_conn_by_org_id(self, org_id): for _id, conn in self.get_conns(): if conn.org_id == org_id: return conn return None # placeholder to keep track of things that need to be fixed/updated def get_temp_conn(self): return self.get_primary_conn() def get_conns(self, conn_filter=None): if conn_filter: return [(_id, conn) for _id, conn in self.conns.items() if conn_filter(conn)] else: return self.conns.items()
class EventService: def __init__(self): self.handlers = {} self.logger = Logger(__name__) self.event_types = [] self.db_cache = {} def inject(self, registry): self.db = registry.get_instance("db") self.util = registry.get_instance("util") def pre_start(self): self.register_event_type("timer") def start(self): # process decorators for _, inst in Registry.get_all_instances().items(): for name, method in get_attrs(inst).items(): if hasattr(method, "event"): attrs = getattr(method, "event") handler = getattr(inst, name) self.register(handler, attrs.event_type, attrs.description, inst.module_name, attrs.is_hidden, attrs.is_enabled) def register_event_type(self, event_type): """ Call during pre_start Args: event_type (str) """ event_type = event_type.lower() if event_type in self.event_types: self.logger.error( "Could not register event type '%s': event type already registered" % event_type) return self.logger.debug("Registering event type '%s'" % event_type) self.event_types.append(event_type) def is_event_type(self, event_base_type): return event_base_type in self.event_types def register(self, handler, event_type, description, module, is_hidden, is_enabled): """ Call during pre_start Args: handler: (event_type, event_data) -> void event_type: str description: str module: str is_hidden: bool is_enabled: bool """ if len(inspect.signature(handler).parameters) != 2: raise Exception( "Incorrect number of arguments for handler '%s.%s()'" % (handler.__module__, handler.__name__)) event_base_type, event_sub_type = self.get_event_type_parts(event_type) module = module.lower() handler_name = self.util.get_handler_name(handler) is_hidden = 1 if is_hidden else 0 is_enabled = 1 if is_enabled else 0 if event_base_type not in self.event_types: self.logger.error( "Could not register handler '%s' for event type '%s': event type does not exist" % (handler_name, event_type)) return if not description: self.logger.warning( "No description for event_type '%s' and handler '%s'" % (event_type, handler_name)) row = self.db.query_single( "SELECT 1 FROM event_config WHERE event_type = ? AND handler = ?", [event_base_type, handler_name]) if row is None: # add new event commands self.db.exec( "INSERT INTO event_config (event_type, event_sub_type, handler, description, module, enabled, verified, is_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ event_base_type, event_sub_type, handler_name, description, module, is_enabled, 1, is_hidden ]) if event_base_type == "timer": self.db.exec( "INSERT INTO timer_event (event_type, event_sub_type, handler, next_run) VALUES (?, ?, ?, ?)", [ event_base_type, event_sub_type, handler_name, int(time.time()) ]) else: # mark command as verified self.db.exec( "UPDATE event_config SET verified = ?, module = ?, description = ?, event_sub_type = ?, is_hidden = ? WHERE event_type = ? AND handler = ?", [ 1, module, description, event_sub_type, is_hidden, event_base_type, handler_name ]) if event_base_type == "timer": self.db.exec( "UPDATE timer_event SET event_sub_type = ? WHERE event_type = ? AND handler = ?", [event_sub_type, event_base_type, handler_name]) # load command handler self.handlers[handler_name] = handler def fire_event(self, event_type, event_data=None): event_base_type, event_sub_type = self.get_event_type_parts(event_type) if event_base_type not in self.event_types: self.logger.error( "Could not fire event type '%s': event type does not exist" % event_type) return data = self.get_handlers(event_base_type, event_sub_type) for row in data: self.call_handler(row.handler, event_type, event_data) def call_handler(self, handler_method, event_type, event_data): handler = self.handlers.get(handler_method, None) if not handler: self.logger.error( "Could not find handler callback for event type '%s' and handler '%s'" % (event_type, handler_method)) return try: handler(event_type, event_data) except Exception as e: self.logger.error("error processing event '%s'" % event_type, e) def get_event_type_parts(self, event_type): parts = event_type.lower().split(":", 1) if len(parts) == 2: return parts[0], parts[1] else: return parts[0], "" def get_event_type_key(self, event_base_type, event_sub_type): return event_base_type + ":" + event_sub_type def check_for_timer_events(self, current_timestamp): data = self.db.query( "SELECT e.event_type, e.event_sub_type, e.handler, t.next_run FROM timer_event t " "JOIN event_config e ON t.event_type = e.event_type AND t.handler = e.handler " "WHERE t.next_run <= ? AND e.enabled = 1", [current_timestamp]) for row in data: self.execute_timed_event(row, current_timestamp) def execute_timed_event(self, row, current_timestamp): event_type_key = self.get_event_type_key(row.event_type, row.event_sub_type) # timer event run times should be consistent, so we base the next run time off the last run time, # instead of the current timestamp next_run = row.next_run + int(row.event_sub_type) # prevents timer events from getting too far behind, or having a large "catch-up" after # the bot has been offline for a time if next_run < current_timestamp: next_run = current_timestamp + int(row.event_sub_type) with self.db.transaction(): self.db.exec( "UPDATE timer_event SET next_run = ? WHERE event_type = ? AND handler = ?", [next_run, row.event_type, row.handler]) self.call_handler(row.handler, event_type_key, None) def update_event_status(self, event_base_type, event_sub_type, event_handler, enabled_status): # clear cache self.db_cache[event_base_type + ":" + event_sub_type] = None return self.db.exec( "UPDATE event_config SET enabled = ? WHERE event_type = ? AND event_sub_type = ? AND handler LIKE ?", [enabled_status, event_base_type, event_sub_type, event_handler]) def get_event_types(self): return self.event_types def get_handlers(self, event_base_type, event_sub_type): # check first in cache result = self.db_cache.get(event_base_type + ":" + event_sub_type, None) if result is not None: return result else: result = self.db.query( "SELECT handler FROM event_config WHERE event_type = ? AND event_sub_type = ? AND enabled = 1", [event_base_type, event_sub_type]) # store result in cache self.db_cache[event_base_type + ":" + event_sub_type] = result return result def run_timer_events_at_startup(self): t = int(time.time()) data = self.db.query( "SELECT e.event_type, e.event_sub_type, e.handler, t.next_run FROM timer_event t " "JOIN event_config e ON t.event_type = e.event_type AND t.handler = e.handler " "WHERE e.event_type = ? AND e.enabled = 1", ["timer"]) for row in data: handler = self.handlers[row.handler] attrs = getattr(handler, "event") if attrs.get("run_at_startup", False): self.execute_timed_event(row, t)
class PorkService: def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.character_service = registry.get_instance("character_service") def pre_start(self): self.bot.register_packet_handler(server_packets.CharacterLookup.id, self.update) self.bot.register_packet_handler(server_packets.CharacterName.id, self.update) def start(self): self.db.exec( "CREATE TABLE IF NOT EXISTS player ( char_id BIGINT PRIMARY KEY, first_name VARCHAR(30) NOT NULL, name VARCHAR(20) NOT NULL, last_name VARCHAR(30) NOT NULL, " "level SMALLINT NOT NULL, breed VARCHAR(20) NOT NULL, gender VARCHAR(20) NOT NULL, faction VARCHAR(20) NOT NULL, profession VARCHAR(20) NOT NULL, " "profession_title VARCHAR(50) NOT NULL, ai_rank VARCHAR(20) NOT NULL, ai_level SMALLINT, org_id INT DEFAULT NULL, org_name VARCHAR(255) NOT NULL, " "org_rank_name VARCHAR(20) NOT NULL, org_rank_id SMALLINT NOT NULL, dimension SMALLINT NOT NULL, head_id INT NOT NULL, pvp_rating SMALLINT NOT NULL, " "pvp_title VARCHAR(20) NOT NULL, source VARCHAR(50) NOT NULL, last_updated INT NOT NULL )" ) # forces a lookup from remote PoRK server # this should not be called directly unless you are requesting info for a char on a different server # since cache will not be used and the result will not update the cache automatically def request_char_info(self, char_name, server_num): url = self.get_pork_url(server_num, char_name) try: r = requests.get(url, timeout=5) result = r.json() except ReadTimeout: self.logger.warning("Timeout while requesting '%s'" % url) result = None except ValueError as e: self.logger.debug( "Error marshalling value as json for url '%s': %s" % (url, r.text), e) result = None char_info = None if result: char_info_json = result[0] org_info_json = result[1] if result[1] else {} char_info = DictObject({ "name": char_info_json["NAME"], "char_id": char_info_json["CHAR_INSTANCE"], "first_name": char_info_json["FIRSTNAME"], "last_name": char_info_json["LASTNAME"], "level": char_info_json["LEVELX"], "breed": char_info_json["BREED"], "dimension": char_info_json["CHAR_DIMENSION"], "gender": char_info_json["SEX"], "faction": char_info_json["SIDE"], "profession": char_info_json["PROF"], "profession_title": char_info_json["PROFNAME"], "ai_rank": char_info_json["RANK_name"], "ai_level": char_info_json["ALIENLEVEL"], "pvp_rating": char_info_json["PVPRATING"], "pvp_title": char_info_json["PVPTITLE"] or "", "head_id": char_info_json["HEADID"], "org_id": org_info_json.get("ORG_INSTANCE", 0), "org_name": org_info_json.get("NAME", ""), "org_rank_name": org_info_json.get("RANK_TITLE", ""), "org_rank_id": org_info_json.get("RANK", 0), "source": "people.anarchy-online.com", "cache_age": 0 }) return char_info # standard method to get character pork data when character is on the same server def get_character_info(self, char_name_or_id, max_cache_age=86400): char_id = self.character_service.resolve_char_to_id(char_name_or_id) char_name = self.character_service.resolve_char_to_name( char_name_or_id) t = int(time.time()) # if there is an entry in database and it is within the cache time, use that db_char_info = self.get_from_database(char_id=char_id, char_name=char_name) if db_char_info: db_char_info.cache_age = t - db_char_info.last_updated if db_char_info.cache_age < max_cache_age and db_char_info.source != "chat_server": return db_char_info # if we can't resolve to a char_name, we can't make a call to pork if not char_name: return db_char_info char_info = self.request_char_info(char_name, self.bot.dimension) if char_info and (char_id is None or char_info.char_id == char_id): self.save_character_info(char_info) return char_info else: # return cached info from database, even tho it's old, and set cache_age (if it exists) if db_char_info: db_char_info.cache_age = t - db_char_info.last_updated return db_char_info # forces a skeleton object into the player table in the case that PoRK does not return any data # call this method if you don't need the data now but want to ensure there is a record in the database def load_character_info(self, char_id, char_name=None): char_info = self.get_character_info(char_id) if not char_info and char_name: char_info = self.get_character_info(char_name) if not char_info: char_info = DictObject({ "name": "Unknown:" + str(char_id), "char_id": char_id, "first_name": "", "last_name": "", "level": 0, "breed": "", "dimension": self.bot.dimension, "gender": "", "faction": "", "profession": "", "profession_title": "", "ai_rank": "", "ai_level": 0, "pvp_rating": 0, "pvp_title": "", "head_id": 0, "org_id": 0, "org_name": "", "org_rank_name": "", "org_rank_id": 6, "source": "stub" }) self.save_character_info(char_info) def save_character_info(self, char_info): if char_info["dimension"] != self.bot.dimension: return self.db.exec("DELETE FROM player WHERE char_id = ?", [char_info["char_id"]]) insert_sql = """ INSERT IGNORE INTO player ( char_id, name, first_name, last_name, level, breed, gender, faction, profession, profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, dimension, head_id, pvp_rating, pvp_title, source, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ self.db.exec(insert_sql, [ char_info["char_id"], char_info["name"], char_info["first_name"], char_info["last_name"], char_info["level"], char_info["breed"], char_info["gender"], char_info["faction"], char_info["profession"], char_info["profession_title"], char_info["ai_rank"], char_info["ai_level"], char_info["org_id"], char_info["org_name"], char_info["org_rank_name"], char_info["org_rank_id"], char_info["dimension"], char_info["head_id"], char_info["pvp_rating"], char_info["pvp_title"], char_info["source"], int(time.time()) ]) def get_from_database(self, char_id=None, char_name=None): if char_id: return self.db.query_single( "SELECT char_id, name, first_name, last_name, level, breed, gender, faction, profession, " "profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, " "dimension, head_id, pvp_rating, pvp_title, source, last_updated " "FROM player WHERE char_id = ?", [char_id]) elif char_name: return self.db.query_single( "SELECT char_id, name, first_name, last_name, level, breed, gender, faction, profession, " "profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, " "dimension, head_id, pvp_rating, pvp_title, source, last_updated " "FROM player WHERE name = ?", [char_name]) else: return None def update(self, conn, packet): # don't update if we didn't get a valid response if packet.char_id == 4294967295: return character = self.get_from_database(char_id=packet.char_id) if character: if character.name != packet.name: self.db.exec("UPDATE player SET name = ? WHERE char_id = ?", [packet.name, packet.char_id]) else: insert_sql = """ INSERT IGNORE INTO player ( char_id, name, first_name, last_name, level, breed, gender, faction, profession, profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, dimension, head_id, pvp_rating, pvp_title, source, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" self.db.exec(insert_sql, [ packet.char_id, packet.name, "", "", 0, "", "", "", "", "", "", 0, 0, "", "", 6, self.bot.dimension, 0, 0, "", "chat_server", int(time.time()) ]) def find_orgs(self, search): return self.db.query( "SELECT DISTINCT org_name, org_id FROM player WHERE org_name <EXTENDED_LIKE=0> ?", [search], extended_like=True) def get_pork_url(self, dimension, char_name): return "http://people.anarchy-online.com/character/bio/d/%d/name/%s/bio.xml?data_type=json" % ( dimension, char_name)
class SettingService: def __init__(self): self.logger = Logger(__name__) self.settings = {} self.db_cache = {} def inject(self, registry): self.db = registry.get_instance("db") self.util = registry.get_instance("util") def start(self): # process decorators for _, inst in Registry.get_all_instances().items(): for name, method in get_attrs(inst).items(): if hasattr(method, "setting"): setting_name, value, description, obj = getattr(method, "setting") handler = getattr(inst, name) module = self.util.get_module_name(handler) self.register(setting_name, value, description, obj, module) def register(self, name, value, description, setting: SettingType, module): name = name.lower() module = module.lower() setting.set_name(name) setting.set_description(description) if not description: self.logger.warning("No description specified for setting '%s'" % name) if " " in name: raise Exception("One or more spaces found in setting name '%s' for module '%s'" % (name, module)) row = self.db.query_single("SELECT name, value, description FROM setting WHERE name = ?", [name]) if row is None: self.logger.debug("Adding setting '%s'" % name) self.db.exec( "INSERT INTO setting (name, value, description, module, verified) VALUES (?, ?, ?, ?, ?)", [name, "", description, module, 1]) # verify default value is a valid value, and is formatted appropriately setting.set_value(value) else: self.logger.debug("Updating setting '%s'" % name) self.db.exec( "UPDATE setting SET description = ?, verified = ?, module = ? WHERE name = ?", [description, 1, module, name]) self.settings[name] = setting def get_value(self, name): # check cache first result = self.db_cache.get(name, None) if result: return result.value else: row = self.db.query_single("SELECT value FROM setting WHERE name = ?", [name]) # store result in cache self.db_cache[name] = row return row.value if row else None def set_value(self, name, value): # clear cache self.db_cache[name] = None self.db.exec("UPDATE setting SET value = ? WHERE name = ?", [value, name]) def get(self, name): name = name.lower() setting = self.settings.get(name, None) if setting: return setting else: return None
class PorkService: def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.character_service = registry.get_instance("character_service") def pre_start(self): self.bot.add_packet_handler(server_packets.CharacterLookup.id, self.update) self.bot.add_packet_handler(server_packets.CharacterName.id, self.update) def request_char_info(self, char_name, server_num): url = self.get_pork_url(server_num, char_name) try: r = requests.get(url, timeout=5) result = r.json() except ReadTimeout: self.logger.warning("Timeout while requesting '%s'" % url) result = None except ValueError as e: self.logger.debug( "Error marshalling value as json for url '%s': %s" % (url, r.text), e) result = None char_info = None if result: char_info_json = result[0] org_info_json = result[1] if result[1] else {} char_info = DictObject({ "name": char_info_json["NAME"], "char_id": char_info_json["CHAR_INSTANCE"], "first_name": char_info_json["FIRSTNAME"], "last_name": char_info_json["LASTNAME"], "level": char_info_json["LEVELX"], "breed": char_info_json["BREED"], "dimension": char_info_json["CHAR_DIMENSION"], "gender": char_info_json["SEX"], "faction": char_info_json["SIDE"], "profession": char_info_json["PROF"], "profession_title": char_info_json["PROFNAME"], "ai_rank": char_info_json["RANK_name"], "ai_level": char_info_json["ALIENLEVEL"], "pvp_rating": char_info_json["PVPRATING"], "pvp_title": char_info_json["PVPTITLE"] or "", "head_id": char_info_json["HEADID"], "org_id": org_info_json.get("ORG_INSTANCE", 0), "org_name": org_info_json.get("NAME", ""), "org_rank_name": org_info_json.get("RANK_TITLE", ""), "org_rank_id": org_info_json.get("RANK", 0), "source": "people.anarchy-online.com", "cache_age": 0 }) return char_info def get_character_info(self, char, max_cache_age=86400): char_id = self.character_service.resolve_char_to_id(char) char_name = self.character_service.resolve_char_to_name(char) t = int(time.time()) # if there is an entry in database and it is within the cache time, use that db_char_info = self.get_from_database(char_id=char_id, char_name=char_name) if db_char_info: db_char_info.cache_age = t - db_char_info.last_updated if db_char_info.cache_age < max_cache_age and db_char_info.source != "chat_server": return db_char_info # if we can't resolve to a char_name, we can't make a call to pork if not char_name: return db_char_info char_info = self.request_char_info(char_name, self.bot.dimension) if char_info and char_info.char_id == char_id: self.save_character_info(char_info) return char_info else: # return cached info from database, even tho it's old, and set cache_age (if it exists) if db_char_info: db_char_info.cache_age = t - db_char_info.last_updated return db_char_info def load_character_info(self, char_id): char_info = self.get_character_info(char_id) if not char_info: char_info = DictObject({ "name": "Unknown:" + str(char_id), "char_id": char_id, "first_name": "", "last_name": "", "level": 0, "breed": "", "dimension": self.bot.dimension, "gender": "", "faction": "", "profession": "", "profession_title": "", "ai_rank": "", "ai_level": 0, "pvp_rating": 0, "pvp_title": "", "head_id": 0, "org_id": 0, "org_name": "", "org_rank_name": "", "org_rank_id": 6, "source": "stub" }) self.save_character_info(char_info) def save_character_info(self, char_info): if char_info["dimension"] != self.bot.dimension: return self.db.exec("DELETE FROM player WHERE char_id = ?", [char_info["char_id"]]) insert_sql = """ INSERT INTO player ( char_id, name, first_name, last_name, level, breed, gender, faction, profession, profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, dimension, head_id, pvp_rating, pvp_title, source, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ self.db.exec(insert_sql, [ char_info["char_id"], char_info["name"], char_info["first_name"], char_info["last_name"], char_info["level"], char_info["breed"], char_info["gender"], char_info["faction"], char_info["profession"], char_info["profession_title"], char_info["ai_rank"], char_info["ai_level"], char_info["org_id"], char_info["org_name"], char_info["org_rank_name"], char_info["org_rank_id"], char_info["dimension"], char_info["head_id"], char_info["pvp_rating"], char_info["pvp_title"], char_info["source"], int(time.time()) ]) def get_from_database(self, char_id=None, char_name=None): if char_id: return self.db.query_single( "SELECT char_id, name, first_name, last_name, level, breed, gender, faction, profession, " "profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, " "dimension, head_id, pvp_rating, pvp_title, source, last_updated " "FROM player WHERE char_id = ?", [char_id]) elif char_name: return self.db.query_single( "SELECT char_id, name, first_name, last_name, level, breed, gender, faction, profession, " "profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, " "dimension, head_id, pvp_rating, pvp_title, source, last_updated " "FROM player WHERE name = ?", [char_name]) else: return None def update(self, packet): # don't update if we didn't get a valid response if packet.char_id == 4294967295: return character = self.get_from_database(char_id=packet.char_id) if character: if character.name != packet.name: self.db.exec("UPDATE player SET name = ? WHERE char_id = ?", [packet.name, packet.char_id]) else: insert_sql = """ INSERT INTO player ( char_id, name, first_name, last_name, level, breed, gender, faction, profession, profession_title, ai_rank, ai_level, org_id, org_name, org_rank_name, org_rank_id, dimension, head_id, pvp_rating, pvp_title, source, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" self.db.exec(insert_sql, [ packet.char_id, packet.name, "", "", 0, "", "", "", "", "", "", 0, 0, "", "", 6, self.bot.dimension, 0, 0, "", "chat_server", int(time.time()) ]) def find_orgs(self, search): return self.db.query( "SELECT DISTINCT org_name, org_id FROM player WHERE org_name <EXTENDED_LIKE=0> ?", [search], extended_like=True) def get_pork_url(self, dimension, char_name): return "http://people.anarchy-online.com/character/bio/d/%d/name/%s/bio.xml?data_type=json" % ( dimension, char_name)
class TowerController: TOWER_ATTACK_EVENT = "tower_attack" TOWER_VICTORY_EVENT = "tower_victory" TOWER_BATTLE_OUTCOME_ID = 42949672962 ALL_TOWERS_ID = 42949672960 ATTACK_1 = [ 506, 12753364 ] # The %s organization %s just entered a state of war! %s attacked the %s organization %s's tower in %s at location (%d,%d). ATTACK_2 = re.compile( "^(.+) just attacked the (clan|neutral|omni) organization (.+)'s tower in (.+) at location \((\d+), (\d+)\).\n$" ) VICTORY_1 = re.compile( "^Notum Wars Update: Victory to the (Clan|Neutral|Omni)s!!!$") VICTORY_2 = re.compile( "^The (Clan|Neutral|Omni) organization (.+) attacked the (Clan|Neutral|Omni) (.+) at their base in (.+). The attackers won!!$" ) VICTORY_3 = [ 506, 147506468 ] # 'Notum Wars Update: The %s organization %s lost their base in %s.' def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot: Tyrbot = registry.get_instance("bot") self.db: DB = registry.get_instance("db") self.text: Text = registry.get_instance("text") self.event_service: EventService = registry.get_instance( "event_service") self.pork_service: PorkService = registry.get_instance("pork_service") self.playfield_controller: PlayfieldController = registry.get_instance( "playfield_controller") self.public_channel_service: PublicChannelService = registry.get_instance( "public_channel_service") def pre_start(self): self.event_service.register_event_type(self.TOWER_ATTACK_EVENT) self.event_service.register_event_type(self.TOWER_VICTORY_EVENT) self.bot.add_packet_handler(server_packets.PublicChannelMessage.id, self.handle_public_channel_message) @command(command="lc", params=[], access_level="all", description= "See a list of playfields containing land control tower sites") def lc_list_cmd(self, request): data = self.db.query( "SELECT * FROM playfields WHERE id IN (SELECT DISTINCT playfield_id FROM tower_site) ORDER BY short_name" ) blob = "" for row in data: blob += "%s <highlight>%s<end>\n" % (self.text.make_chatcmd( row.long_name, "/tell <myname> lc %s" % row.short_name), row.short_name) return ChatBlob("Land Control Playfields", blob) @command(command="lc", params=[Any("playfield"), Int("site_number", is_optional=True)], access_level="all", description= "See a list of land control tower sites in a particular playfield" ) def lc_playfield_cmd(self, request, playfield_name, site_number): playfield = self.playfield_controller.get_playfield_by_name( playfield_name) if not playfield: return "Could not find playfield <highlight>%s<end>." % playfield_name if site_number: data = self.db.query( "SELECT t.*, p.short_name, p.long_name FROM tower_site t JOIN playfields p ON t.playfield_id = p.id WHERE t.playfield_id = ? AND site_number = ?", [playfield.id, site_number]) else: data = self.db.query( "SELECT t.*, p.short_name, p.long_name FROM tower_site t JOIN playfields p ON t.playfield_id = p.id WHERE t.playfield_id = ?", [playfield.id]) if not data: if site_number: return "Could not find tower info for <highlight>%s %d<end>." % ( playfield.long_name, site_number) else: return "Could not find tower info for <highlight>%s<end>." % playfield.long_name blob = "" for row in data: blob += "<pagebreak>" + self.format_site_info(row) + "\n\n" if site_number: title = "Tower Info: %s %d" % (playfield.long_name, site_number) else: title = "Tower Info: %s" % playfield.long_name return ChatBlob(title, blob) @event(event_type="connect", description="Check if All Towers channel is available") def handle_connect_event(self, event_type, event_data): if self.public_channel_service.org_id and not self.public_channel_service.get_channel_id( "All Towers"): self.logger.warning( "bot is a member of an org but does not have access to 'All Towers' channel and therefore will not receive tower attack messages" ) def format_site_info(self, row): blob = "Short name: <highlight>%s %d<end>\n" % (row.short_name, row.site_number) blob += "Long name: <highlight>%s, %s<end>\n" % (row.site_name, row.long_name) blob += "Level range: <highlight>%d-%d<end>\n" % (row.min_ql, row.max_ql) blob += "Coordinates: %s\n" % self.text.make_chatcmd( "%dx%d" % (row.x_coord, row.y_coord), "/waypoint %d %d %d" % (row.x_coord, row.y_coord, row.playfield_id)) return blob def handle_public_channel_message( self, packet: server_packets.PublicChannelMessage): if packet.channel_id == self.TOWER_BATTLE_OUTCOME_ID: victory = self.get_victory_event(packet) if victory: # self.logger.debug("tower victory packet: %s" % str(packet)) # lookup playfield playfield_name = victory.location.playfield.long_name victory.location.playfield = self.playfield_controller.get_playfield_by_name( playfield_name) or DictObject() victory.location.playfield.long_name = playfield_name self.event_service.fire_event(self.TOWER_VICTORY_EVENT, victory) elif packet.channel_id == self.ALL_TOWERS_ID: attack = self.get_attack_event(packet) if attack: # self.logger.debug("tower attack packet: %s" % str(packet)) # lookup playfield playfield_name = attack.location.playfield.long_name attack.location.playfield = self.playfield_controller.get_playfield_by_name( playfield_name) or DictObject() attack.location.playfield.long_name = playfield_name # lookup attacker name = attack.attacker.name faction = attack.attacker.faction org_name = attack.attacker.org_name attack.attacker = self.pork_service.get_character_info( name) or DictObject() attack.attacker.faction = faction or attack.attacker.get( "faction", "Unknown") attack.attacker.org_name = org_name self.event_service.fire_event(self.TOWER_ATTACK_EVENT, attack) def get_attack_event(self, packet: server_packets.PublicChannelMessage): if packet.extended_message and [ packet.extended_message.category_id, packet.extended_message.instance_id ] == self.ATTACK_1: params = packet.extended_message.params return DictObject({ "attacker": { "name": params[2], "faction": params[0].capitalize(), "org_name": params[1] }, "defender": { "faction": params[3].capitalize(), "org_name": params[4] }, "location": { "playfield": { "long_name": params[5] }, "x_coord": params[6], "y_coord": params[7] } }) else: match = self.ATTACK_2.match(packet.message) if match: return DictObject({ "attacker": { "name": match.group(1), "faction": "", "org_name": "" }, "defender": { "faction": match.group(2).capitalize(), "org_name": match.group(3) }, "location": { "playfield": { "long_name": match.group(4) }, "x_coord": match.group(5), "y_coord": match.group(6) } }) # Unknown attack self.logger.warning("Unknown tower attack: " + str(packet)) return None def get_victory_event(self, packet: server_packets.PublicChannelMessage): match = self.VICTORY_1.match(packet.message) if match: return None match = self.VICTORY_2.match(packet.message) if match: return DictObject({ "type": "attack", "winner": { "faction": match.group(1).capitalize(), "org_name": match.group(2) }, "loser": { "faction": match.group(3).capitalize(), "org_name": match.group(4) }, "location": { "playfield": { "long_name": match.group(5) } } }) if packet.extended_message and [ packet.extended_message.category_id, packet.extended_message.instance_id ] == self.VICTORY_3: params = packet.extended_message.params return DictObject({ "type": "terminated", "winner": { "faction": params[0].capitalize(), "org_name": params[1] }, "loser": { "faction": params[0].capitalize(), "org_name": params[1] }, "location": { "playfield": { "long_name": params[2] } } }) # Unknown victory self.logger.warning("Unknown tower victory: " + str(packet)) return None
class PrivateChannelController: MESSAGE_SOURCE = "private_channel" MESSAGE_SOURCE_UPDATE = "private_channel_update" def __init__(self): self.logger = Logger(__name__) self.private_channel_conn = None def inject(self, registry): self.bot = registry.get_instance("bot") self.private_channel_service = registry.get_instance( "private_channel_service") self.character_service = registry.get_instance("character_service") self.job_scheduler = registry.get_instance("job_scheduler") self.access_service = registry.get_instance("access_service") self.message_hub_service = registry.get_instance("message_hub_service") self.ban_service = registry.get_instance("ban_service") self.log_controller = registry.get_instance( "log_controller", is_optional=True) # TODO core module depending on standard module self.online_controller = registry.get_instance( "online_controller", is_optional=True) # TODO core module depending on standard module self.text: Text = registry.get_instance("text") self.setting_service: SettingService = registry.get_instance( "setting_service") def pre_start(self): self.message_hub_service.register_message_source(self.MESSAGE_SOURCE) self.message_hub_service.register_message_source( self.MESSAGE_SOURCE_UPDATE) def start(self): self.setting_service.register( self.module_name, "private_channel_prefix", "[Priv]", TextSettingType(["[Priv]", "[Guest]"]), "The name to show for messages coming from the private channel") self.setting_service.register( self.module_name, "private_channel_conn", "", TextSettingType(allow_empty=True), "The conn id or name to use for the private channel", extended_description= "If empty, the bot will use the primary conn. You MUST restart the bot after changing this value for the change to take effect." ) self.message_hub_service.register_message_destination( self.MESSAGE_SOURCE, self.handle_incoming_relay_message, [ "org_channel", "org_channel_update", "discord", "websocket_relay", "broadcast", "raffle", "shutdown_notice", "raid", "timers", "alliance" ], [self.MESSAGE_SOURCE, self.MESSAGE_SOURCE_UPDATE]) def handle_incoming_relay_message(self, ctx): self.bot.send_private_channel_message(ctx.formatted_message, conn=self.get_conn(None)) @command(command="join", params=[], access_level="member", description="Join the private channel") def join_cmd(self, request): self.private_channel_service.invite(request.sender.char_id, self.get_conn(request.conn)) @command(command="leave", params=[], access_level="all", description="Leave the private channel") def leave_cmd(self, request): self.private_channel_service.kick(request.sender.char_id, self.get_conn(request.conn)) @command(command="invite", params=[Character("character")], access_level="all", description="Invite a character to the private channel") def invite_cmd(self, request, char): if char.char_id: conn = self.get_conn(request.conn) if char.char_id in conn.private_channel: return f"<highlight>{char.name}</highlight> is already in the private channel." else: self.bot.send_private_message( char.char_id, f"You have been invited to the private channel by <highlight>{request.sender.name}</highlight>.", conn=conn) self.private_channel_service.invite(char.char_id, conn) return f"You have invited <highlight>{char.name}</highlight> to the private channel." else: return StandardMessage.char_not_found(char.name) @command(command="kick", params=[Character("character")], access_level="moderator", description="Kick a character from the private channel") def kick_cmd(self, request, char): if char.char_id: conn = self.get_conn(request.conn) if char.char_id not in conn.private_channel: return f"<highlight>{char.name}</highlight> is not in the private channel." else: # TODO use request.sender.access_level and char.access_level if self.access_service.has_sufficient_access_level( request.sender.char_id, char.char_id): self.bot.send_private_message( char.char_id, f"You have been kicked from the private channel by <highlight>{request.sender.name}</highlight>.", conn=conn) self.private_channel_service.kick(char.char_id, conn) return f"You have kicked <highlight>{char.name}</highlight> from the private channel." else: return f"You do not have the required access level to kick <highlight>{char.name}</highlight>." else: return StandardMessage.char_not_found(char.name) @command(command="kickall", params=[], access_level="moderator", description="Kick all characters from the private channel") def kickall_cmd(self, request): conn = self.get_conn(request.conn) self.bot.send_private_channel_message( f"Everyone will be kicked from this channel in 10 seconds. [by <highlight>{request.sender.name}</highlight>]", conn=conn) self.job_scheduler.delayed_job( lambda t: self.private_channel_service.kickall(conn), 10) @event(event_type="connect", description= "Load the conn ids as choice for private_channel_conn setting", is_system=True) def load_conns_into_setting_choice(self, event_type, event_data): options = [] for _id, conn in self.bot.get_conns(lambda x: x.is_main == True): options.append(conn.char_name) setting = self.setting_service.get("private_channel_conn") setting.options = options @event( event_type=BanService.BAN_ADDED_EVENT, description="Kick characters from the private channel who are banned", is_system=True) def ban_added_event(self, event_type, event_data): self.private_channel_service.kick_from_all(event_data.char_id) @event( event_type=PrivateChannelService.PRIVATE_CHANNEL_MESSAGE_EVENT, description="Relay messages from the private channel to the relay hub", is_system=True) def handle_private_channel_message_event(self, event_type, event_data): if self.bot.get_conn_by_char_id( event_data.char_id) or self.ban_service.get_ban( event_data.char_id): return sender = DictObject({ "char_id": event_data.char_id, "name": event_data.name }) self.message_hub_service.send_message( self.MESSAGE_SOURCE, sender, self.get_private_channel_prefix(), event_data.message) @event(event_type=PrivateChannelService.JOINED_PRIVATE_CHANNEL_EVENT, description="Notify when a character joins the private channel") def handle_private_channel_joined_event(self, event_type, event_data): if self.online_controller: char_info = self.online_controller.get_char_info_display( event_data.char_id, event_data.conn) else: char_info = self.character_service.resolve_char_to_name( event_data.char_id) msg = f"{char_info} has joined the private channel." if self.log_controller: msg += " " + self.log_controller.get_logon(event_data.char_id) self.bot.send_private_channel_message(msg, conn=event_data.conn) self.message_hub_service.send_message( self.MESSAGE_SOURCE_UPDATE, None, self.get_private_channel_prefix(), msg) @event(event_type=PrivateChannelService.LEFT_PRIVATE_CHANNEL_EVENT, description="Notify when a character leaves the private channel") def handle_private_channel_left_event(self, event_type, event_data): msg = f"<highlight>{event_data.name}</highlight> has left the private channel." if self.log_controller: msg += " " + self.log_controller.get_logoff(event_data.char_id) self.bot.send_private_channel_message(msg, conn=event_data.conn) self.message_hub_service.send_message( self.MESSAGE_SOURCE_UPDATE, None, self.get_private_channel_prefix(), msg) @event( event_type=PrivateChannelService.PRIVATE_CHANNEL_COMMAND_EVENT, description="Relay commands from the private channel to the relay hub", is_system=True) def outgoing_private_channel_message_event(self, event_type, event_data): sender = None if event_data.name: sender = DictObject({ "char_id": event_data.char_id, "name": event_data.name }) if isinstance(event_data.message, ChatBlob): pages = self.text.paginate( ChatBlob(event_data.message.title, event_data.message.msg), event_data.conn, self.setting_service.get( "org_channel_max_page_length").get_value()) if len(pages) < 4: for page in pages: self.message_hub_service.send_message( self.MESSAGE_SOURCE, sender, self.get_private_channel_prefix(), page) else: self.message_hub_service.send_message( self.MESSAGE_SOURCE, sender, self.get_private_channel_prefix(), event_data.message.title) else: self.message_hub_service.send_message( self.MESSAGE_SOURCE, sender, self.get_private_channel_prefix(), event_data.message) def get_conn(self, conn): if self.private_channel_conn: return self.private_channel_conn conn_id = self.setting_service.get_value("private_channel_conn") if conn_id: for _id, conn in self.bot.get_conns( lambda x: x.id == conn_id or x.char_name == conn_id): self.private_channel_conn = conn break if not self.private_channel_conn: self.logger.warning( f"Could not find conn with id '{conn_id}', defaulting to primary conn" ) self.private_channel_conn = self.bot.get_primary_conn() else: # use the primary conn if private_channel_conn is not set self.private_channel_conn = self.bot.get_primary_conn() return self.private_channel_conn def get_private_channel_prefix(self): return self.setting_service.get_value("private_channel_prefix")
class Tyrbot: CONNECT_EVENT = "connect" PACKET_EVENT = "packet" PRIVATE_MSG_EVENT = "private_msg" OUTGOING_ORG_MESSAGE_EVENT = "outgoing_org_message" OUTGOING_PRIVATE_MESSAGE_EVENT = "outgoing_private_message" OUTGOING_PRIVATE_CHANNEL_MESSAGE_EVENT = "outgoing_private_channel_message" def __init__(self): super().__init__() self.logger = Logger(__name__) self.ready = False self.packet_handlers = {} self.superadmin = None self.status: BotStatus = BotStatus.SHUTDOWN self.dimension = None self.last_timer_event = 0 self.start_time = int(time.time()) self.version = "0.5-beta" self.incoming_queue = FifoQueue() self.mass_message_queue = None self.conns = DictObject() def inject(self, registry): self.db = registry.get_instance("db") self.character_service: CharacterService = registry.get_instance("character_service") self.public_channel_service: PublicChannelService = registry.get_instance("public_channel_service") self.text: Text = registry.get_instance("text") self.setting_service: SettingService = registry.get_instance("setting_service") self.access_service: AccessService = registry.get_instance("access_service") self.event_service = registry.get_instance("event_service") self.job_scheduler = registry.get_instance("job_scheduler") def init(self, config, registry, paths, mmdb_parser): self.mmdb_parser = mmdb_parser self.superadmin = config.superadmin.capitalize() self.dimension = config.server.dimension self.db.exec("UPDATE db_version SET verified = 0") self.db.exec("UPDATE db_version SET verified = 1 WHERE file = 'db_version'") self.load_sql_files(paths) # prepare commands, events, and settings self.db.exec("UPDATE command_config SET verified = 0") self.db.exec("UPDATE event_config SET verified = 0") self.db.exec("UPDATE setting SET verified = 0") with self.db.transaction(): registry.pre_start_all() registry.start_all() # remove commands, events, and settings that are no longer registered self.db.exec("DELETE FROM db_version WHERE verified = 0") self.db.exec("DELETE FROM command_config WHERE verified = 0") self.db.exec("DELETE FROM event_config WHERE verified = 0") self.db.exec("DELETE FROM timer_event WHERE handler NOT IN (SELECT handler FROM event_config WHERE event_type = ?)", ["timer"]) self.db.exec("DELETE FROM setting WHERE verified = 0") self.status = BotStatus.RUN def pre_start(self): self.access_service.register_access_level("superadmin", 10, self.check_superadmin) self.event_service.register_event_type(self.CONNECT_EVENT) self.event_service.register_event_type(self.PACKET_EVENT) self.event_service.register_event_type(self.PRIVATE_MSG_EVENT) self.event_service.register_event_type(self.OUTGOING_ORG_MESSAGE_EVENT) self.event_service.register_event_type(self.OUTGOING_PRIVATE_MESSAGE_EVENT) self.event_service.register_event_type(self.OUTGOING_PRIVATE_CHANNEL_MESSAGE_EVENT) def start(self): self.setting_service.register_new("core.system", "symbol", "!", TextSettingType(["!", "#", "*", "@", "$", "+", "-"]), "Symbol for executing bot commands") self.setting_service.register_new("core.system", "org_channel_max_page_length", 7500, NumberSettingType([4500, 6000, 7500, 9000, 10500, 12000]), "Maximum size of blobs in org channel") self.setting_service.register_new("core.system", "private_message_max_page_length", 7500, NumberSettingType([4500, 6000, 7500, 9000, 10500, 12000]), "Maximum size of blobs in private messages") self.setting_service.register_new("core.system", "private_channel_max_page_length", 7500, NumberSettingType([4500, 6000, 7500, 9000, 10500, 12000]), "Maximum size of blobs in private channel") self.setting_service.register_new("core.system", "org_id", "", NumberSettingType(allow_empty=True), "Override the default org id", "This setting is is for development/debug purposes and should not be changed unless you understand the implications") self.setting_service.register_new("core.system", "org_name", "", TextSettingType(allow_empty=True), "The exact org name of the bot", "This setting is automatically set by the bot and should not be changed manually") self.setting_service.register_new("core.colors", "header_color", "#FFFF00", ColorSettingType(), "Color for headers") self.setting_service.register_new("core.colors", "header2_color", "#FCA712", ColorSettingType(), "Color for sub-headers") self.setting_service.register_new("core.colors", "highlight_color", "#00BFFF", ColorSettingType(), "Color for highlight") self.setting_service.register_new("core.colors", "notice_color", "#FF8C00", ColorSettingType(), "Color for important notices") self.setting_service.register_new("core.colors", "neutral_color", "#E6E1A6", ColorSettingType(), "Color for neutral faction") self.setting_service.register_new("core.colors", "omni_color", "#FA8484", ColorSettingType(), "Color for omni faction") self.setting_service.register_new("core.colors", "clan_color", "#F79410", ColorSettingType(), "Color for clan faction") self.setting_service.register_new("core.colors", "unknown_color", "#FF0000", ColorSettingType(), "Color for unknown faction") self.setting_service.register_new("core.colors", "org_channel_color", "#89D2E8", ColorSettingType(), "Default org channel color") self.setting_service.register_new("core.colors", "private_channel_color", "#89D2E8", ColorSettingType(), "Default private channel color") self.setting_service.register_new("core.colors", "private_message_color", "#89D2E8", ColorSettingType(), "Default private message color") self.setting_service.register_new("core.colors", "blob_color", "#FFFFFF", ColorSettingType(), "Default blob content color") self.register_packet_handler(server_packets.PrivateMessage.id, self.handle_private_message, priority=40) def check_superadmin(self, char_id): char_name = self.character_service.resolve_char_to_name(char_id) return char_name == self.superadmin def connect(self, config): conn = self.create_conn("main") conn.connect(config.server.host, config.server.port) packet = conn.login(config.username, config.password, config.character) if not packet: self.status = BotStatus.ERROR return False else: self.incoming_queue.put((conn, packet)) self.mass_message_queue = FifoQueue() self.create_conn_thread(conn, self.mass_message_queue) if "slaves" in config: for i, slave in enumerate(config.slaves): conn = self.create_conn("slave" + str(i)) conn.connect(config.server.host, config.server.port) packet = conn.login(slave.username, slave.password, slave.character) if not packet: self.status = BotStatus.ERROR return False else: self.incoming_queue.put((conn, packet)) self.create_conn_thread(conn, self.mass_message_queue) return True def create_conn_thread(self, conn: Conn, mass_message_queue=None): def read_packets(): try: while self.status == BotStatus.RUN: packet = conn.read_packet(1) if packet: self.incoming_queue.put((conn, packet)) while mass_message_queue and not mass_message_queue.empty() and conn.packet_queue.is_empty(): packet = mass_message_queue.get_or_default(block=False) if packet: conn.add_packet_to_queue(packet) except (EOFError, OSError) as e: self.status = BotStatus.ERROR self.logger.error("", e) raise e dthread = threading.Thread(target=read_packets, daemon=True) dthread.start() def create_conn(self, _id): if _id in self.conns: raise Exception(f"A connection with id {_id} already exists") conn = Conn(_id, self.disconnect) self.conns[_id] = conn return conn # passthrough def send_packet(self, packet): self.conns["main"].send_packet(packet) def disconnect(self): # wait for all threads to stop reading packets, then disconnect them all time.sleep(2) for _id, conn in self.conns.items(): conn.disconnect() def run(self): start = time.time() # wait for flood of packets from login to stop sending time_waited = 0 while time_waited < 5: if not self.iterate(1): time_waited += 1 self.logger.info("Login complete (%fs)" % (time.time() - start)) start = time.time() self.event_service.fire_event("connect", None) self.event_service.run_timer_events_at_startup() self.logger.info("Connect events finished (%fs)" % (time.time() - start)) self.ready = True timestamp = int(time.time()) while self.status == BotStatus.RUN: try: timestamp = int(time.time()) self.check_for_timer_events(timestamp) self.iterate() except (EOFError, OSError) as e: raise e except Exception as e: self.logger.error("", e) # run any pending jobs/events self.check_for_timer_events(timestamp + 1) return self.status def check_for_timer_events(self, timestamp): # timer events will execute no more often than once per second if self.last_timer_event < timestamp: self.last_timer_event = timestamp self.job_scheduler.check_for_scheduled_jobs(timestamp) self.event_service.check_for_timer_events(timestamp) def register_packet_handler(self, packet_id: int, handler, priority=50): """ Call during pre_start Args: packet_id: int handler: (conn, packet) -> void priority: int """ if len(inspect.signature(handler).parameters) != 2: raise Exception("Incorrect number of arguments for handler '%s.%s()'" % (handler.__module__, handler.__name__)) handlers = self.packet_handlers.get(packet_id, []) handlers.append(DictObject({"priority": priority, "handler": handler})) self.packet_handlers[packet_id] = sorted(handlers, key=lambda x: x.priority) def remove_packet_handler(self, packet_id, handler): handlers = self.packet_handlers.get(packet_id, []) for h in handlers: if h.handler == handler: handlers.remove(h) def iterate(self, timeout=0.1): conn, packet = self.incoming_queue.get_or_default(block=True, timeout=timeout, default=(None, None)) if packet: if isinstance(packet, server_packets.SystemMessage): packet = self.system_message_ext_msg_handling(packet) elif isinstance(packet, server_packets.PublicChannelMessage): packet = self.public_channel_message_ext_msg_handling(packet) if isinstance(packet, server_packets.BuddyAdded): if packet.char_id == 0: return for handler in self.packet_handlers.get(packet.id, []): handler.handler(conn, packet) self.event_service.fire_event("packet:" + str(packet.id), packet) return packet def public_channel_message_ext_msg_handling(self, packet: server_packets.PublicChannelMessage): msg = packet.message if msg.startswith("~&") and msg.endswith("~"): try: msg = msg[2:-1].encode("utf-8") category_id = self.mmdb_parser.read_base_85(msg[0:5]) instance_id = self.mmdb_parser.read_base_85(msg[5: 10]) template = self.mmdb_parser.get_message_string(category_id, instance_id) params = self.mmdb_parser.parse_params(msg[10:]) packet.extended_message = ExtendedMessage(category_id, instance_id, template, params) except Exception as e: self.logger.error("Error handling extended message for packet: " + str(packet), e) return packet def system_message_ext_msg_handling(self, packet: server_packets.SystemMessage): try: category_id = 20000 instance_id = packet.message_id template = self.mmdb_parser.get_message_string(category_id, instance_id) params = self.mmdb_parser.parse_params(packet.message_args) packet.extended_message = ExtendedMessage(category_id, instance_id, template, params) self.logger.log_chat("SystemMessage", None, packet.extended_message.get_message()) except Exception as e: self.logger.error("Error handling extended message: " + str(packet), e) return packet def send_org_message(self, msg, add_color=True, fire_outgoing_event=True, conn_id="main"): org_channel_id = self.public_channel_service.org_channel_id if org_channel_id is None: self.logger.debug("ignoring message to org channel since the org_channel_id is unknown") else: color = self.setting_service.get("org_channel_color").get_font_color() if add_color else "" pages = self.get_text_pages(msg, self.setting_service.get("org_channel_max_page_length").get_value()) for page in pages: packet = client_packets.PublicChannelMessage(org_channel_id, color + page, "") self.conns[conn_id].add_packet_to_queue(packet) if fire_outgoing_event: self.event_service.fire_event(self.OUTGOING_ORG_MESSAGE_EVENT, DictObject({"org_channel_id": org_channel_id, "message": msg})) def send_private_message(self, char, msg, add_color=True, fire_outgoing_event=True, conn_id="main"): char_id = self.character_service.resolve_char_to_id(char) if char_id is None: self.logger.warning("Could not send message to %s, could not find char id" % char) else: color = self.setting_service.get("private_message_color").get_font_color() if add_color else "" pages = self.get_text_pages(msg, self.setting_service.get("private_message_max_page_length").get_value()) for page in pages: self.logger.log_tell("To", self.character_service.get_char_name(char_id), page) packet = client_packets.PrivateMessage(char_id, color + page, "\0") self.conns[conn_id].add_packet_to_queue(packet) if fire_outgoing_event: self.event_service.fire_event(self.OUTGOING_PRIVATE_MESSAGE_EVENT, DictObject({"char_id": char_id, "message": msg})) def send_private_channel_message(self, msg, private_channel=None, add_color=True, fire_outgoing_event=True, conn_id="main"): if private_channel is None: private_channel_id = self.get_char_id() else: private_channel_id = self.character_service.resolve_char_to_id(private_channel) if private_channel_id is None: self.logger.warning("Could not send message to private channel %s, could not find private channel" % private_channel) else: color = self.setting_service.get("private_channel_color").get_font_color() if add_color else "" pages = self.get_text_pages(msg, self.setting_service.get("private_channel_max_page_length").get_value()) for page in pages: packet = client_packets.PrivateChannelMessage(private_channel_id, color + page, "\0") self.conns[conn_id].send_packet(packet) if fire_outgoing_event and private_channel_id == self.get_char_id(): self.event_service.fire_event(self.OUTGOING_PRIVATE_CHANNEL_MESSAGE_EVENT, DictObject({"private_channel_id": private_channel_id, "message": msg})) def send_mass_message(self, char_id, msg, add_color=True): if not char_id: self.logger.warning("Could not send message to empty char_id") else: color = self.setting_service.get("private_message_color").get_font_color() if add_color else "" pages = self.get_text_pages(msg, self.setting_service.get("private_message_max_page_length").get_value()) for page in pages: # self.logger.log_tell("To", self.character_service.get_char_name(char_id), page) if self.mass_message_queue: packet = client_packets.PrivateMessage(char_id, color + page, "\0") self.mass_message_queue.put(packet) else: packet = client_packets.PrivateMessage(char_id, color + page, "spam") self.conns["main"].send_packet(packet) def handle_private_message(self, conn: Conn, packet: server_packets.PrivateMessage): if conn.id != "main": return self.logger.log_tell("From", self.character_service.get_char_name(packet.char_id), packet.message) self.event_service.fire_event(self.PRIVATE_MSG_EVENT, packet) def get_text_pages(self, msg, max_page_length): if isinstance(msg, ChatBlob): return self.text.paginate(msg, max_page_length=max_page_length) else: return [self.text.format_message(msg)] def is_ready(self): return self.ready def shutdown(self): self.status = BotStatus.SHUTDOWN def restart(self): self.status = BotStatus.RESTART def load_sql_files(self, paths): dirs = flatmap(lambda x: os.walk(x), paths) dirs = filter(lambda y: not y[0].endswith("__pycache__"), dirs) def get_files(tup): return map(lambda x: os.path.join(tup[0], x), tup[2]) # get files from subdirectories files = flatmap(get_files, dirs) files = filter(lambda z: z.endswith(".sql"), files) base_path = os.getcwd() for file in files: self.db.load_sql_file(file, base_path) def get_char_name(self): return self.conns["main"].char_name def get_char_id(self): return self.conns["main"].char_id
class TowerMessagesController: MESSAGE_SOURCE = "tower_attacks" TOWER_ATTACK_EVENT = "tower_attack" TOWER_VICTORY_EVENT = "tower_victory" TOWER_BATTLE_OUTCOME_ID = 42949672962 ALL_TOWERS_ID = 42949672960 ATTACK_1 = [ 506, 12753364 ] # The %s organization %s just entered a state of war! %s attacked the %s organization %s's tower in %s at location (%d,%d). ATTACK_2 = re.compile( r"^(.+) just attacked the (clan|neutral|omni) organization (.+)'s tower in (.+) at location \((\d+), (\d+)\).\n$" ) VICTORY_1 = re.compile( r"^Notum Wars Update: Victory to the (Clan|Neutral|Omni)s!!!$") VICTORY_2 = re.compile( r"^The (Clan|Neutral|Omni) organization (.+) attacked the (Clan|Neutral|Omni) (.+) at their base in (.+). The attackers won!!$" ) VICTORY_3 = [ 506, 147506468 ] # 'Notum Wars Update: The %s organization %s lost their base in %s.' def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.text = registry.get_instance("text") self.util = registry.get_instance("util") self.event_service = registry.get_instance("event_service") self.command_alias_service = registry.get_instance( "command_alias_service") self.pork_service = registry.get_instance("pork_service") self.message_hub_service = registry.get_instance("message_hub_service") self.playfield_controller: PlayfieldController = registry.get_instance( "playfield_controller") def pre_start(self): self.message_hub_service.register_message_source(self.MESSAGE_SOURCE) self.event_service.register_event_type(self.TOWER_ATTACK_EVENT) self.event_service.register_event_type(self.TOWER_VICTORY_EVENT) self.bot.register_packet_handler( server_packets.PublicChannelMessage.id, self.handle_public_channel_message) def start(self): self.db.exec( "CREATE TABLE IF NOT EXISTS tower_attacker (id INT PRIMARY KEY AUTO_INCREMENT, att_org_name VARCHAR(50) NOT NULL, att_faction VARCHAR(10) NOT NULL, " "att_char_id INT, att_char_name VARCHAR(20) NOT NULL, att_level INT NOT NULL, att_ai_level INT NOT NULL, att_profession VARCHAR(15) NOT NULL, " "x_coord INT NOT NULL, y_coord INT NOT NULL, is_victory SMALLINT NOT NULL, " "tower_battle_id INT NOT NULL, created_at INT NOT NULL)") self.db.exec( "CREATE TABLE IF NOT EXISTS tower_battle (id INT PRIMARY KEY AUTO_INCREMENT, playfield_id INT NOT NULL, site_number INT NOT NULL, " "def_org_name VARCHAR(50) NOT NULL, def_faction VARCHAR(10) NOT NULL, is_finished INT NOT NULL, battle_type VARCHAR(20) NOT NULL, last_updated INT NOT NULL)" ) self.command_alias_service.add_alias("victory", "attacks") self.command_alias_service.add_alias("battles", "attacks") @command(command="attacks", params=[Const("battle"), Int("battle_id")], access_level="all", description="Show battle info for a specific battle") def attacks_battle_cmd(self, request, _, battle_id): battle = self.get_battle(battle_id) if not battle: return "Could not find battle with ID <highlight>%d</highlight>." % battle_id blob = self.check_for_all_towers_channel() + self.get_battle_blob( battle) return ChatBlob("Battle Info %d" % battle_id, blob) @command(command="attacks", params=[ Any("playfield", is_optional=True, allowed_chars="[a-z0-9 ]"), Int("site_number", is_optional=True), NamedParameters([ "defender_org", "attacker_org", "attacker", "victory", "page" ]) ], access_level="all", description="Show recent tower attacks and victories", extended_description= "Victory param can be 'true', 'false', or 'all' (default)") def attacks_cmd(self, request, playfield_name, site_number, named_params): playfield = None if playfield_name: playfield = self.playfield_controller.get_playfield_by_name_or_id( playfield_name) if not playfield: return f"Could not find playfield <highlight>{playfield_name}</highlight>." page_number = int(named_params.page or "1") victory = "all" if named_params.victory == "" else named_params.victory command_str = "attacks" if playfield_name: command_str += " " + playfield_name if site_number: command_str += " " + str(site_number) page_size = 10 offset = (page_number - 1) * page_size sql = "SELECT DISTINCT b.*, p.long_name, p.short_name FROM tower_battle b " \ "LEFT JOIN playfields p ON b.playfield_id = p.id " \ "LEFT JOIN tower_attacker a ON b.id = a.tower_battle_id " \ "WHERE 1=1" params = [] if playfield: sql += " AND b.playfield_id = ?" params.append(playfield.id) if site_number: sql += " AND b.site_number = ?" params.append(site_number) if victory != "all": command_str += f" --victory={victory}" sql += " AND b.is_finished = ?" if victory == "true": params.append(1) elif victory == "false": params.append(0) else: return "Named param <highlight>--victory</highlight> must be one of: 'true', 'false', or 'all' (default)." if named_params.defender_org: defender_org = named_params.defender_org command_str += f" --defender_org={defender_org}" sql += f" AND b.def_org_name <EXTENDED_LIKE={len(params)}> ?" params.append(defender_org) if named_params.attacker_org: attacker_org = named_params.attacker_org command_str += f" --attacker_org={attacker_org}" sql += f" AND a.att_org_name <EXTENDED_LIKE={len(params)}> ?" params.append(attacker_org) if named_params.attacker: attacker = named_params.attacker command_str += f" --attacker={attacker}" sql += f" AND a.att_char_name LIKE ? OR a.att_char_id = ?" params.append(attacker) params.append(attacker) sql += " ORDER BY b.last_updated DESC LIMIT ?, ?" params.append(offset) params.append(page_size) data = self.db.query(sql, params, extended_like=True) t = int(time.time()) blob = self.check_for_all_towers_channel() blob += self.text.get_paging_links(command_str, page_number, page_size == len(data)) blob += "\n\n" for row in data: blob += "\n<pagebreak>" blob += self.format_battle_info(row, t) blob += "<header2>Attackers:</header2>\n" sql2 = """SELECT a.*, COALESCE(a.att_level, 0) AS att_level, COALESCE(a.att_ai_level, 0) AS att_ai_level FROM tower_attacker a WHERE a.tower_battle_id = ? ORDER BY created_at DESC""" data2 = self.db.query(sql2, [row.id]) for row2 in data2: blob += "<tab>" + self.format_attacker(row2) if row2.is_victory: blob += " - <notice>Winner!</notice>" blob += "\n" if not data2: blob += "<tab>Unknown attacker\n" title = "Tower Attacks" if playfield: title += f" ({playfield.long_name}" if site_number: title += " " + str(site_number) title += ")" return ChatBlob(title, blob) @event(event_type="connect", description="Check if All Towers channel is available", is_system=True) def handle_connect_event(self, event_type, event_data): conn = self.bot.get_primary_conn() if conn.org_id and self.ALL_TOWERS_ID not in conn.channels: self.logger.warning( "The primary bot is a member of an org but does not have access to 'All Towers' channel and therefore will not be able to record tower attacks" ) def handle_public_channel_message( self, conn: Conn, packet: server_packets.PublicChannelMessage): # only listen to tower packets from first bot, to avoid triggering multiple times if conn != self.bot.get_primary_conn(): return if packet.channel_id == self.TOWER_BATTLE_OUTCOME_ID: victory = self.get_victory_event(packet) if victory: self.fire_victory_event(victory) elif packet.channel_id == self.ALL_TOWERS_ID: attack = self.get_attack_event(packet) if attack: self.fire_attack_event(attack) def fire_victory_event(self, obj): # self.logger.debug("tower victory packet: %s" % str(packet)) # lookup playfield playfield_name = obj.location.playfield.long_name obj.location.playfield = self.playfield_controller.get_playfield_by_name( playfield_name) or DictObject() obj.location.playfield.long_name = playfield_name t = int(time.time()) is_victory = 1 is_finished = 1 if obj.type == "attack": # get battle id and site_number last_updated = t - (6 * 3600) row = self.get_last_attack(obj.winner.faction, obj.winner.org_name, obj.loser.faction, obj.loser.org_name, obj.location.playfield.id, last_updated) if row: obj.battle_id = row.battle_id obj.location.site_number = row.site_number self.db.exec( "UPDATE tower_attacker SET is_victory = ? WHERE id = ?", [is_victory, row.attack_id]) self.db.exec( "UPDATE tower_battle SET is_finished = ?, last_updated = ? WHERE id = ?", [is_finished, t, row.battle_id]) else: obj.location.site_number = 0 self.db.exec( "INSERT INTO tower_battle (playfield_id, site_number, def_org_name, def_faction, is_finished, battle_type, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?)", [ obj.location.playfield.id, obj.location.site_number, obj.loser.org_name, obj.loser.faction, is_finished, obj.type, t ]) obj.battle_id = self.db.last_insert_id() attacker = obj.winner or {} self.db.exec( "INSERT INTO tower_attacker (att_org_name, att_faction, att_char_id, att_char_name, att_level, att_ai_level, att_profession, " "x_coord, y_coord, is_victory, tower_battle_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ attacker.get("org_name", ""), attacker.get("faction", ""), attacker.get("char_id", 0), attacker.get("name", ""), attacker.get("level", 0), attacker.get("ai_level", 0), attacker.get("profession", ""), 0, 0, is_victory, obj.battle_id, t ]) elif obj.type == "terminated": obj.location.site_number = 0 self.db.exec( "INSERT INTO tower_battle (playfield_id, site_number, def_org_name, def_faction, is_finished, battle_type, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?)", [ obj.location.playfield.id, obj.location.site_number, obj.loser.org_name, obj.loser.faction, is_finished, obj.type, t ]) obj.battle_id = self.db.last_insert_id() else: raise Exception("Unknown victory event type: '%s'" % obj.type) self.event_service.fire_event(self.TOWER_VICTORY_EVENT, obj) def fire_attack_event(self, obj): # self.logger.debug("tower attack packet: %s" % str(packet)) # lookup playfield playfield_name = obj.location.playfield.long_name obj.location.playfield = self.playfield_controller.get_playfield_by_name( playfield_name) or DictObject() obj.location.playfield.long_name = playfield_name # lookup attacker name = obj.attacker.name faction = obj.attacker.faction org_name = obj.attacker.org_name char_info = self.pork_service.get_character_info(name) obj.attacker = char_info or DictObject() obj.attacker.name = name obj.attacker.faction = faction or obj.attacker.get( "faction", "Unknown") obj.attacker.org_name = org_name obj.location.site_number = self.find_closest_site_number( obj.location.playfield.id, obj.location.x_coord, obj.location.y_coord) attacker = obj.attacker or {} defender = obj.defender location = obj.location t = int(time.time()) battle = self.find_or_create_battle(obj.location.playfield.id, obj.location.site_number, defender.org_name, defender.faction, "attack", t) obj.battle_id = battle.id self.db.exec( "INSERT INTO tower_attacker (att_org_name, att_faction, att_char_id, att_char_name, att_level, att_ai_level, att_profession, " "x_coord, y_coord, is_victory, tower_battle_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ attacker.get("org_name", ""), attacker.get("faction", ""), attacker.get("char_id", 0), attacker.get("name", ""), attacker.get("level", 0), attacker.get("ai_level", 0), attacker.get("profession", ""), location.x_coord, location.y_coord, 0, battle.id, t ]) attacker_id = self.db.last_insert_id() attacker_row = self.db.query_single( "SELECT * FROM tower_attacker WHERE id = ?", [attacker_id]) more_info = self.text.paginate_single( ChatBlob( "More Info", self.text.make_tellcmd("More Info", f"attacks battle {battle.id}")), self.bot.get_primary_conn()) msg = "%s attacked %s [%s] at %s %s %s" % ( self.format_attacker(attacker_row), defender.org_name, self.text.get_formatted_faction(defender.faction), location.playfield.get("short_name", location.playfield.get("long_name")), obj.location.site_number or "?", more_info) self.message_hub_service.send_message(self.MESSAGE_SOURCE, None, None, msg) self.event_service.fire_event(self.TOWER_ATTACK_EVENT, obj) def get_attack_event(self, packet: server_packets.PublicChannelMessage): t = int(time.time()) if packet.extended_message and [ packet.extended_message.category_id, packet.extended_message.instance_id ] == self.ATTACK_1: params = packet.extended_message.params return DictObject({ "timestamp": t, "attacker": { "name": params[2], "faction": params[0].capitalize(), "org_name": params[1] }, "defender": { "faction": params[3].capitalize(), "org_name": params[4] }, "location": { "playfield": { "long_name": params[5] }, "x_coord": params[6], "y_coord": params[7] } }) else: match = self.ATTACK_2.match(packet.message) if match: return DictObject({ "timestamp": t, "attacker": { "name": match.group(1), "faction": "", "org_name": "" }, "defender": { "faction": match.group(2).capitalize(), "org_name": match.group(3) }, "location": { "playfield": { "long_name": match.group(4) }, "x_coord": match.group(5), "y_coord": match.group(6) } }) # Unknown attack self.logger.warning("Unknown tower attack: " + str(packet)) return None def get_victory_event(self, packet: server_packets.PublicChannelMessage): match = self.VICTORY_1.match(packet.message) if match: return None t = int(time.time()) match = self.VICTORY_2.match(packet.message) if match: return DictObject({ "type": "attack", "timestamp": t, "winner": { "faction": match.group(1).capitalize(), "org_name": match.group(2) }, "loser": { "faction": match.group(3).capitalize(), "org_name": match.group(4) }, "location": { "playfield": { "long_name": match.group(5) } } }) if packet.extended_message and [ packet.extended_message.category_id, packet.extended_message.instance_id ] == self.VICTORY_3: params = packet.extended_message.params return DictObject({ "type": "terminated", "timestamp": t, "winner": { "faction": params[0].capitalize(), "org_name": params[1] }, "loser": { "faction": params[0].capitalize(), "org_name": params[1] }, "location": { "playfield": { "long_name": params[2] } } }) # Unknown victory self.logger.warning("Unknown tower victory: " + str(packet)) return None def format_attacker(self, row): level = ("%d/<green>%d</green>" % (row.att_level, row.att_ai_level) ) if row.att_ai_level > 0 else "%d" % row.att_level org = row.att_org_name + " " if row.att_org_name else "" return "%s (%s %s) %s[%s]" % ( row.att_char_name or "Unknown attacker", level, row.att_profession or "Unknown", org, self.text.get_formatted_faction( row.att_faction)) def find_closest_site_number(self, playfield_id, x_coord, y_coord): sql = "SELECT site_number FROM tower_site_bounds " \ "WHERE playfield_id = ? AND x_coord1 <= ? AND x_coord2 >= ? AND y_coord1 >= ? AND y_coord2 <= ? " \ "LIMIT 1" row = self.db.query_single( sql, [playfield_id, x_coord, x_coord, y_coord, y_coord]) if row: return row.site_number sql = """ SELECT site_number, ((x_distance * x_distance) + (y_distance * y_distance)) radius FROM (SELECT playfield_id, site_number, min_ql, max_ql, x_coord, y_coord, site_name, (x_coord - ?) as x_distance, (y_coord - ?) as y_distance FROM tower_site WHERE playfield_id = ?) t ORDER BY radius ASC LIMIT 1""" row = self.db.query_single(sql, [x_coord, y_coord, playfield_id]) if row: return row.site_number else: return 0 def find_or_create_battle(self, playfield_id, site_number, org_name, faction, battle_type, t): last_updated = t - (8 * 3600) is_finished = 0 sql = """ SELECT id FROM tower_battle WHERE playfield_id = ? AND site_number = ? AND is_finished = ? AND def_org_name = ? AND def_faction = ? AND last_updated >= ? """ battle = self.db.query_single(sql, [ playfield_id, site_number, is_finished, org_name, faction, last_updated ]) if battle: self.db.exec( "UPDATE tower_battle SET last_updated = ? WHERE id = ?", [t, battle.id]) battle_id = battle.id else: self.db.exec( "INSERT INTO tower_battle (playfield_id, site_number, def_org_name, def_faction, is_finished, battle_type, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?)", [ playfield_id, site_number, org_name, faction, is_finished, battle_type, t ]) battle_id = self.db.last_insert_id() return self.get_battle(battle_id) def get_last_attack(self, att_faction, att_org_name, def_faction, def_org_name, playfield_id, last_updated): is_finished = 0 sql = """ SELECT b.id AS battle_id, a.id AS attack_id, b.site_number FROM tower_battle b JOIN tower_attacker a ON a.tower_battle_id = b.id WHERE a.att_faction = ? AND a.att_org_name = ? AND b.def_faction = ? AND b.def_org_name = ? AND b.playfield_id = ? AND b.is_finished = ? AND b.last_updated >= ? ORDER BY last_updated DESC LIMIT 1""" return self.db.query_single(sql, [ att_faction, att_org_name, def_faction, def_org_name, playfield_id, is_finished, last_updated ]) def format_battle_info(self, row, t, verbose=False): blob = "" defeated = " - <notice>Defeated!</notice>" if row.is_finished else "" blob += f"Site: %s " % self.text.make_tellcmd( f"{row.short_name} {row.site_number}", f"lc {row.short_name} {row.site_number}") if not verbose: blob += self.text.make_tellcmd("More Info", "attacks battle %d" % row.id) blob += "\n" if verbose: if row.site_number: blob += f"Long name: <highlight>{row.site_name}, {row.long_name}</highlight>\n" blob += f"Level range: <highlight>{row.min_ql}-{row.max_ql}</highlight>\n" blob += "Coordinates: %s\n" % self.text.make_chatcmd( f"{row.x_coord}x{row.y_coord}", f"/waypoint {row.x_coord} {row.y_coord} {row.playfield_id}" ) else: blob += f"Long name: Unknown\n" blob += f"Level range: Unknown\n" blob += "Coordinates: Unknown\n" blob += f"Defender: %s [%s]%s\n" % (row.def_org_name, self.text.get_formatted_faction( row.def_faction), defeated) blob += "Last Activity: %s\n" % self.format_timestamp( row.last_updated, t) return blob def format_timestamp(self, t, current_t): return "<highlight>%s</highlight> (%s ago)" % ( self.util.format_datetime(t), self.util.time_to_readable(current_t - t)) def check_for_all_towers_channel(self): if self.ALL_TOWERS_ID not in self.bot.get_primary_conn().channels: return "Notice: The primary bot must belong to an org and be promoted to a rank that is high enough to have the All Towers channel (e.g., Squad Commander) in order for this command to work correctly.\n\n" else: return "" def get_battle_blob(self, battle): t = int(time.time()) attackers = self.db.query( "SELECT * FROM tower_attacker WHERE tower_battle_id = ? ORDER BY created_at DESC", [battle.id]) first_activity = attackers[-1].created_at if len( attackers) > 0 else battle.last_updated blob = "" blob += self.format_battle_info(battle, t, verbose=True) blob += "Duration: <highlight>%s</highlight>\n\n" % self.util.time_to_readable( battle.last_updated - first_activity) blob += "<header2>Attackers:</header2>\n" for row in attackers: blob += "<tab>" + self.format_attacker(row) blob += " " + self.format_timestamp(row.created_at, t) if row.is_victory: blob += " - <notice>Winner!</notice>" blob += "\n" return blob def get_battle(self, battle_id): return self.db.query_single( "SELECT b.*, p.short_name, p.long_name, t.site_name, t.x_coord, t.y_coord, t.min_ql, t.max_ql " "FROM tower_battle b " "LEFT JOIN playfields p ON p.id = b.playfield_id " "LEFT JOIN tower_site t ON b.playfield_id = t.playfield_id AND b.site_number = t.site_number " "WHERE b.id = ?", [battle_id])
class BuddyService: BUDDY_LOGON_EVENT = "buddy_logon" BUDDY_LOGOFF_EVENT = "buddy_logoff" def __init__(self): self.buddy_list_size = 0 self.logger = Logger(__name__) def inject(self, registry): self.character_service: CharacterService = registry.get_instance( "character_service") self.bot = registry.get_instance("bot") self.event_service = registry.get_instance("event_service") def pre_start(self): self.bot.register_packet_handler(server_packets.BuddyAdded.id, self.handle_add) self.bot.register_packet_handler(server_packets.BuddyRemoved.id, self.handle_remove) self.bot.register_packet_handler(server_packets.LoginOK.id, self.handle_login_ok) self.event_service.register_event_type(self.BUDDY_LOGON_EVENT) self.event_service.register_event_type(self.BUDDY_LOGOFF_EVENT) def handle_add(self, conn: Conn, packet: server_packets.BuddyAdded): if packet.char_id == 0: self.logger.warning("Buddy added or updated with char_id '0'") return buddy = conn.buddy_list.get(packet.char_id, { "types": [], "conn_id": conn.id }) buddy["online"] = packet.online conn.buddy_list[packet.char_id] = buddy # verify that buddy does not exist on any other conn for conn_id, other_conn in self.bot.get_conns(): if conn.id == conn_id: continue buddy = other_conn.buddy_list.get(packet.char_id, None) if buddy: # remove from other conn list del other_conn.buddy_list[packet.char_id] self.logger.warning( "Removing char '%s' from conn '%s' since it already exists on another conn" % (packet.char_id, conn.id)) other_conn.send_packet( client_packets.BuddyRemove(packet.char_id)) if packet.online == 1: self.event_service.fire_event(self.BUDDY_LOGON_EVENT, packet) else: self.event_service.fire_event(self.BUDDY_LOGOFF_EVENT, packet) def handle_remove(self, conn: Conn, packet: server_packets.BuddyRemoved): if packet.char_id == 0: self.logger.warning("Buddy removed with char_id '0'") if packet.char_id in conn.buddy_list: if len(conn.buddy_list[packet.char_id]["types"]) > 0: self.logger.warning( "Removing buddy %d that still has types %s" % (packet.char_id, conn.buddy_list[packet.char_id]["types"])) del conn.buddy_list[packet.char_id] def handle_login_ok(self, conn: Conn, packet): self.buddy_list_size += 1000 # conn.buddy_list[conn.char_id] = {"online": True, "types": ["conn"], "conn_id": conn.id} def add_buddy(self, char_id, _type): if not char_id: return False # check if we are trying to add a conn as a buddy if self.is_conn_char_id(char_id): return False buddy = self.get_buddy(char_id) if buddy: buddy["types"].append(_type) else: conn = self.get_conn_for_new_buddy() # TODO send ChatCommand packet in order to get back response - use FeatureFlag conn.send_packet(client_packets.BuddyAdd(char_id, "\1")) conn.buddy_list[char_id] = { "online": None, "types": [_type], "conn_id": conn.id } return True def is_conn_char_id(self, char_id): for _id, conn in self.bot.get_conns(): if conn.char_id == char_id: return True return False def remove_buddy(self, char_id, _type, force_remove=False): if not char_id: return False for _id, conn in self.bot.get_conns(): if char_id == conn.char_id: continue buddy = conn.buddy_list.get(char_id, None) if buddy: if _type in buddy["types"]: buddy["types"].remove(_type) if len(buddy["types"]) == 0 or force_remove: conn = self.bot.conns[buddy["conn_id"]] conn.send_packet(client_packets.BuddyRemove(char_id)) return True def get_buddy(self, char_id): for _id, conn in self.bot.get_conns(): if conn.char_id == char_id: return {"online": 1, "types": ["conn"], "conn_id": conn.id} elif char_id in conn.buddy_list: return conn.buddy_list[char_id] return None def is_online(self, char_id): buddy = self.get_buddy(char_id) if buddy is None: return None else: return buddy.get("online", None) def get_all_buddies(self): result = {} for _id, conn in self.bot.get_conns(): for char_id, buddy in conn.buddy_list.items(): # TODO what if buddies exist on multiple conns? result[char_id] = buddy return result def remove_all_buddies_by_type(self, _type): buddies = filter(lambda obj: _type in obj["types"], self.get_all_buddies()) for char_id, buddy in buddies.items(): buddy["types"].remove(_type) if len(buddy["types"]) == 0: conn = self.bot.conns[buddy["conn_id"]] conn.send_packet(client_packets.BuddyRemove(char_id)) def get_buddy_list_size(self): count = 0 for _id, conn in self.bot.get_conns(): count += len(conn.buddy_list) return count def get_conn_for_new_buddy(self): buddy_list_size = None selected_conn = None for _id, conn in self.bot.get_conns(): if buddy_list_size is None or len( conn.buddy_list) < buddy_list_size: buddy_list_size = len(conn.buddy_list) selected_conn = conn return selected_conn
class OrgPorkService: CACHE_GROUP = "org_roster" CACHE_MAX_AGE = 86400 def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.character_service = registry.get_instance("character_service") self.pork_service = registry.get_instance("pork_service") self.cache_service = registry.get_instance("cache_service") def get_org_info(self, org_id): cache_key = "%d.%d.json" % (org_id, self.bot.dimension) # check cache for fresh value cache_result = self.cache_service.retrieve(self.CACHE_GROUP, cache_key, self.CACHE_MAX_AGE) is_cache = False if cache_result: result = json.loads(cache_result) is_cache = True else: url = "http://people.anarchy-online.com/org/stats/d/%d/name/%d/basicstats.xml?data_type=json" % ( self.bot.dimension, org_id) r = requests.get(url) try: result = r.json() # if org has no members, org does not exist if result[0]["NUMMEMBERS"] == 0: result = None except ValueError as e: self.logger.warning( "Error marshalling value as json: %s" % r.text, e) result = None if result: # store result in cache self.cache_service.store(self.CACHE_GROUP, cache_key, json.dumps(result)) else: # check cache for any value, even expired cache_result = self.cache_service.retrieve( self.CACHE_GROUP, cache_key) if cache_result: result = json.loads(cache_result) is_cache = True if not result: return None org_info = result[0] org_members = result[1] last_updated = result[2] new_org_info = DictObject({ "counts": { "gender": { "Female": org_info["FEMALECOUNT"], "Male": org_info["MALECOUNT"], "Neuter": org_info["NEUTERCOUNT"], }, "breed": { "Atrox": org_info["ATROXCOUNT"], "Nanomage": org_info["NANORACECOUNT"], "Opifex": org_info["OPIFEXCOUNT"], "Solitus": org_info["SOLITUSCOUNT"], }, "profession": { "Monster": org_info["MONSTERCOUNT"], "Adventurer": org_info["ADVENTURERCOUNT"], "Agent": org_info["AGENTCOUNT"], "Bureaucrat": org_info["BTCOUNT"], "Doctor": org_info["DOCTORCOUNT"], "Enforcer": org_info["ENFCOUNT"], "Engineer": org_info["ENGINEEERCOUNT"], "Fixer": org_info["FIXERCOUNT"], "Keeper": org_info["KEEPERCOUNT"], "Martial Artist": org_info["MACOUNT"], "Meta-Physicist": org_info["METACOUNT"], "Nano-Technician": org_info["NANOCOUNT"], "Shade": org_info["SHADECOUNT"], "Soldier": org_info["SOLIDERCOUNT"], "Trader": org_info["TRADERCOUNT"], } }, "min_level": org_info["MINLVL"], "num_members": org_info["NUMMEMBERS"], "dimension": org_info["ORG_DIMENSION"], "governing_type": org_info["GOVERNINGNAME"], "max_level": org_info["MAXLVL"], "org_id": org_info["ORG_INSTANCE"], "objective": org_info["OBJECTIVE"], "description": org_info["DESCRIPTION"], "history": org_info["HISTORY"], "avg_level": org_info["AVGLVL"], "name": org_info["NAME"], "faction": org_info["SIDE_NAME"], "faction_id": org_info["SIDE"], }) with self.db.transaction(): members = {} for org_member in org_members: char_info = DictObject({ "name": org_member["NAME"], "char_id": org_member["CHAR_INSTANCE"], "first_name": org_member["FIRSTNAME"], "last_name": org_member["LASTNAME"], "level": org_member["LEVELX"], "breed": org_member["BREED"], "dimension": org_member["CHAR_DIMENSION"], "gender": org_member["SEX"], "faction": org_info["SIDE_NAME"], "profession": org_member["PROF"], "profession_title": org_member["PROF_TITLE"], "ai_rank": org_member["DEFENDER_RANK_TITLE"], "ai_level": org_member["ALIENLEVEL"], "pvp_rating": org_member["PVPRATING"], "pvp_title": none_to_empty_string(org_member["PVPTITLE"]), "head_id": org_member["HEADID"], "org_id": org_info.get("ORG_INSTANCE", 0), "org_name": org_info.get("NAME", ""), "org_rank_name": org_member.get("RANK_TITLE", ""), "org_rank_id": org_member.get("RANK", 0), "source": "people.anarchy-online.com" }) if not is_cache: self.pork_service.save_character_info(char_info) # prefetch char ids from chat server self.character_service._send_lookup_if_needed(char_info.name) members[char_info.char_id] = char_info if len(members) == 0: return None else: return DictObject({ "org_info": new_org_info, "org_members": members, "last_updated": int( datetime.datetime.strptime( last_updated, "%Y/%m/%d %H:%M:%S").timestamp()) })
class MigrateController: DATABASE_TYPE_MYSQL = "mysql" DATABASE_TYPE_SQLITE = "sqlite" def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.character_service = registry.get_instance("character_service") self.alts_service = registry.get_instance("alts_service") self.pork_service = registry.get_instance("pork_service") @event(event_type="connect", description="Configure migration controller", is_system=True) def connect_event(self, event_type, event_data): self.db2 = DB() # Optional: the name of the bot character that the budabot/bebot ran as # if the bot name is the same, then you can leave this blank, otherwise you must fill in this value bot_name = "" # Optional: the org_id of the org # the bot will use the org_id of the primary conn if this is not set, which is usually correct org_id = 0 # if your budabot/bebot used mysql, then uncomment the second line below and fill out the appropriate values # otherwise, if your budabot used sqlite, then uncomment the first line below and enter the path to the sqlite db file # do NOT uncomment both of them # REQUIRED: uncomment ONE of these two lines below # self.db2.connect_sqlite("./data/budabot.db") # self.db2.connect_mysql(host="localhost", port=3306, username="", password="", database_name="") self.bot_name = bot_name.lower( ) if bot_name else self.bot.get_primary_conn().get_char_name().lower() self.org_id = org_id if org_id else self.bot.get_primary_conn().org_id self.dimension = self.bot.dimension # TODO in each command, check if db has been initialized properly first @command(command="bebot", params=[Const("migrate"), Const("alts")], access_level="superadmin", description="Migrate alts from a Bebot database") def migrate_bebot_alts_cmd(self, request, _1, _2): data = self.db2.query( "SELECT a.alt, u1.char_id AS alt_char_id, a.main, u2.char_id AS main_char_id " "FROM alts a " f"LEFT JOIN {self.bot_name}_users u1 ON a.alt = u1.nickname " f"LEFT JOIN {self.bot_name}_users u2 ON a.main = u2.nickname " "WHERE a.confirmed = 1 " "ORDER BY main, alt") current_main = None current_main_id = None count_inactive = 0 count_success = 0 count_failure = 0 request.reply("Processing %s alt records..." % len(data)) for row in data: if row.main != current_main: current_main = row.main current_main_id = self.resolve_to_char_id( row.main, row.main_char_id) if not current_main_id: self.logger.warning( f"Could not resolve main char '{current_main}' to char id") count_inactive += 1 continue alt_id = self.resolve_to_char_id(row.alt, row.alt_char_id) if not alt_id: self.logger.warning( f"Could not resolve alt char '{row.alt}' to char id") count_inactive += 1 continue msg, result = self.alts_service.add_alt(current_main_id, alt_id) if result: count_success += 1 else: count_failure += 1 return f"<highlight>{count_success}</highlight> alts were migrated successfully, " \ f"<highlight>{count_failure}</highlight> alts failed to be added, " \ f"and <highlight>{count_inactive}</highlight> chars were inactive and could not be resolved to char ids." @command(command="bebot", params=[Const("migrate"), Const("logon")], access_level="superadmin", description="Migrate logon messages from a Bebot database") def migrate_bebot_logon_cmd(self, request, _1, _2): data = self.db2.query(f"SELECT message, id FROM {self.bot_name}_logon") request.reply("Processing %s logon records..." % len(data)) for row in data: self.db.exec( "INSERT INTO log_messages (char_id, logon) VALUES(?, ?)", [row.id, row.message]) return f"Successfully migrated <highlight>%d</highlight> logon messages." % len( data) @command(command="budabot", params=[Const("migrate"), Const("admins")], access_level="superadmin", description="Migrate admins from a Budabot database") def migrate_budabot_admins_cmd(self, request, _1, _2): data = self.db2.query( "SELECT a.name, p.charid AS char_id, CASE WHEN adminlevel = 4 THEN 'admin' WHEN adminlevel = 3 THEN 'moderator' END AS access_level " f"FROM admin_{self.bot_name} a LEFT JOIN players p ON (a.name = p.name AND p.dimension = ?) " "WHERE p.charid > 0", [self.dimension]) with self.db.transaction(): for row in data: char_id = self.resolve_to_char_id(row.name, row.char_id) if char_id and row.access_level: self.db.exec("DELETE FROM admin WHERE char_id = ?", [char_id]) self.db.exec( "INSERT INTO admin (char_id, access_level) VALUES (?, ?)", [char_id, row.access_level]) return f"Successfully migrated <highlight>%d</highlight> admin characters." % len( data) @command(command="budabot", params=[Const("migrate"), Const("banlist")], access_level="superadmin", description="Migrate ban list from a Budabot database") def migrate_budabot_banlist_cmd(self, request, _1, _2): data = self.db2.query( "SELECT b.charid AS char_id, p.charid AS sender_char_id, time AS created_at, banend AS finished_at, reason " f"FROM banlist_{self.bot_name} b JOIN players p ON (b.admin = p.name AND p.dimension = ?)" "WHERE p.charid > 0", [self.dimension]) with self.db.transaction(): for row in data: self.db.exec("DELETE FROM ban_list WHERE char_id = ?", [row.char_id]) self.db.exec( "INSERT INTO ban_list (char_id, sender_char_id, created_at, finished_at, reason, ended_early) VALUES (?, ?, ?, ?, ?, ?)", [ row.char_id, row.sender_char_id, row.created_at, row.finished_at, row.reason, 0 ]) return f"Successfully migrated <highlight>%d</highlight> banned characters." % len( data) @command(command="budabot", params=[Const("migrate"), Const("alts")], access_level="superadmin", description="Migrate alts from a Budabot database") def migrate_budabot_alts_cmd(self, request, _1, _2): data = self.db2.query( "SELECT p1.charid AS main_char_id, p2.charid AS alt_char_id " "FROM alts a JOIN players p1 ON (p1.name = a.main AND p1.dimension = ?) " "JOIN players p2 ON (p2.name = a.alt AND p2.dimension = ?)" "WHERE validated = 1 AND p1.charid > 0 AND p2.charid > 0 ORDER BY a.main ASC", [self.dimension, self.dimension]) with self.db.transaction(): current_main = 0 group_id = 0 for row in data: if row.main_char_id != current_main: current_main = row.main_char_id group_id = self.db.query_single( "SELECT (COALESCE(MAX(group_id), 0) + 1) AS next_group_id FROM alts" ).next_group_id self.db.exec("DELETE FROM alts WHERE char_id = ?", [row.main_char_id]) self.db.exec( "INSERT INTO alts (char_id, group_id, status) VALUES (?, ?, ?)", [row.main_char_id, group_id, AltsService.MAIN]) self.db.exec("DELETE FROM alts WHERE char_id = ?", [row.alt_char_id]) self.db.exec( "INSERT INTO alts (char_id, group_id, status) VALUES (?, ?, ?)", [row.alt_char_id, group_id, AltsService.CONFIRMED]) return f"Successfully migrated <highlight>%d</highlight> alt characters." % len( data) @command(command="budabot", params=[Const("migrate"), Const("members")], access_level="superadmin", description="Migrate members from a Budabot database") def migrate_budabot_members_cmd(self, request, _1, _2): data = self.db2.query( "SELECT m.name AS sender, p.charid AS char_id, m.autoinv AS auto_invite " f"FROM members_{self.bot_name} m JOIN players p ON (m.name = p.name AND p.dimension = ?) " "WHERE p.charid > 0", [self.dimension]) num = 0 for row in data: char_id = self.resolve_to_char_id(row.sender, row.char_id) if char_id: num += 1 self.db.exec("DELETE FROM member WHERE char_id = ?", [row.char_id]) self.db.exec( "INSERT INTO member (char_id, auto_invite) VALUES (?, ?)", [row.char_id, row.auto_invite]) return f"Successfully migrated <highlight>{num}</highlight> members." @command(command="budabot", params=[Const("migrate"), Const("quotes")], access_level="superadmin", description="Migrate quotes from a Budabot database") def migrate_budabot_quotes_cmd(self, request, _1, _2): data = self.db2.query( "SELECT q.poster, p.charid AS char_id, q.id, q.msg, q.dt " "FROM quote q LEFT JOIN players p ON (q.poster = p.name AND p.dimension = ?)", [self.dimension]) count_inactive = 0 request.reply("Processing %s quote records..." % len(data)) for row in data: char_id = self.resolve_to_char_id(row.poster, row.char_id) if not char_id: char_id = -1 count_inactive += 1 self.db.exec("DELETE FROM quote WHERE id = ?", [row.id]) self.db.exec( "INSERT INTO quote (id, char_id, created_at, content) VALUES (?, ?, ?, ?)", [row.id, char_id, row.dt, row.msg]) return f"Quotes successfully migrated. <highlight>{count_inactive}</highlight> posters were inactive and could not be resolved to char ids." @command(command="budabot", params=[Const("migrate"), Const("log_messages")], access_level="superadmin", description="Migrate log messages from a Budabot database") def migrate_budabot_log_messages_cmd(self, request, _1, _2): data = self.db2.query( f"SELECT p2.charid AS char_id, p1.sender, p1.name, p1.value " f"FROM preferences_{self.bot_name} p1 LEFT JOIN players p2 ON (p1.sender = p2.name AND p2.dimension = ?) " "WHERE p1.name = 'logon_msg' OR p1.name = 'logoff_msg'", [self.dimension]) count_inactive = 0 count_logon = 0 count_logoff = 0 request.reply("Processing %s log messages records..." % len(data)) for row in data: char_id = self.resolve_to_char_id(row.sender, row.char_id) if not char_id: count_inactive += 1 else: existing = self.db.query_single( "SELECT 1 FROM log_messages WHERE char_id = ?", [char_id]) if not existing: self.db.exec( "INSERT INTO log_messages (char_id, logon, logoff) VALUES (?, NULL, NULL)", [char_id]) if row.name == 'logon_msg' and row.value: self.db.exec( "UPDATE log_messages SET logon = ? WHERE char_id = ?", [row.value, char_id]) count_logon += 1 elif row.name == 'logoff_msg' and row.value: self.db.exec( "UPDATE log_messages SET logoff = ? WHERE char_id = ?", [row.value, char_id]) count_logoff += 1 return f"<highlight>{count_logon}</highlight> logon and <highlight>{count_logoff}</highlight> logoff messages successfully migrated. " \ f"<highlight>{count_inactive}</highlight> messages were from inactive characters that could not be resolved to char ids." @command(command="budabot", params=[Const("migrate"), Const("name_history")], access_level="superadmin", description="Migrate name history from a Budabot database") def migrate_budabot_name_history_cmd(self, request, _1, _2): data = self.db2.query( "SELECT charid AS char_id, name, dt AS created_at FROM name_history" ) request.reply( "Processing %s name history records. This may take some time..." % len(data)) with self.db.transaction(): for row in data: self.db.exec( "DELETE FROM name_history WHERE char_id = ? AND name = ?", [row.char_id, row.name]) self.db.exec( "INSERT INTO name_history (char_id, name, created_at) VALUES (?, ?, ?)", [row.char_id, row.name, row.created_at]) return f"Successfully migrated <highlight>%d</highlight> name history records." % len( data) @command(command="budabot", params=[Const("migrate"), Const("news")], access_level="superadmin", description="Migrate news from a Budabot database") def migrate_budabot_news_cmd(self, request, _1, _2): data = self.db2.query( "SELECT n.name AS poster, p.charid AS char_id, news, sticky, time AS created_at, deleted AS deleted_at " "FROM news n JOIN players p ON (n.name = p.name AND p.dimension = ?) WHERE p.charid > 0", [self.dimension]) for row in data: char_id = self.resolve_to_char_id(row.poster, row.char_id) if not char_id: char_id = -1 self.db.exec("DELETE FROM news WHERE char_id = ? AND news = ?", [char_id, row.news]) self.db.exec( "INSERT INTO news (char_id, news, sticky, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)", [ char_id, row.news, row.sticky, row.created_at, row.deleted_at ]) return f"Successfully migrated <highlight>%d</highlight> news records." % len( data) @command(command="budabot", params=[Const("migrate"), Const("notes")], access_level="superadmin", description="Migrate notes from a Budabot database") def migrate_budabot_notes_cmd(self, request, _1, _2): data = self.db2.query( "SELECT n.added_by AS sender, p.charid AS char_id, n.note, n.dt AS created_at " "FROM notes n JOIN players p ON (p.name = n.added_by AND p.dimension = ?) WHERE p.charid > 0", [self.dimension]) num = 0 for row in data: char_id = self.resolve_to_char_id(row.sender, row.char_id) if char_id: num += 1 self.db.exec( "DELETE FROM notes WHERE char_id = ? AND note = ?", [char_id, row.note]) self.db.exec( "INSERT INTO notes (char_id, note, created_at) VALUES (?, ?, ?)", [char_id, row.note, row.created_at]) return f"Successfully migrated <highlight>{num}</highlight> note records." @command(command="budabot", params=[Const("migrate"), Const("last_seen")], access_level="superadmin", description="Migrate last_seen data from a Budabot database") def migrate_budabot_last_seen_cmd(self, request, _1, _2): data = self.db2.query( "SELECT o.name AS sender, p.charid AS char_id, logged_off AS last_seen " f"FROM org_members_{self.bot_name} o JOIN players p ON (o.name = p.name AND p.dimension = ?) " "WHERE p.charid > 0", [self.dimension]) num = 0 with self.db.transaction(): for row in data: char_id = self.resolve_to_char_id(row.sender, row.char_id) if char_id: num += 1 self.db.exec("DELETE FROM last_seen WHERE char_id = ?", [char_id]) self.db.exec( "INSERT INTO last_seen (char_id, dt) VALUES (?, ?)", [char_id, row.last_seen]) return f"Successfully migrated <highlight>{num}</highlight> last seen records." @command(command="budabot", params=[Const("migrate"), Const("cloak_status")], access_level="superadmin", description="Migrate cloak status records from a Budabot database" ) def migrate_budabot_cloak_status_cmd(self, request, _1, _2): if not self.org_id: return "Could not migrate cloak status record since org id is not set." data = self.db2.query( "SELECT o.player AS name, p.charid AS char_id, action, time AS created_at " f"FROM org_city_{self.bot_name} o JOIN players p ON (o.player = p.name AND p.dimension = ?) " "WHERE p.charid > 0", [self.dimension]) num = 0 with self.db.transaction(): self.db.exec("DELETE FROM cloak_status WHERE org_id = ?", [self.org_id]) for row in data: char_id = self.resolve_to_char_id(row.name, row.char_id) if char_id: num += 1 self.db.exec( "INSERT INTO cloak_status (char_id, action, created_at, org_id) VALUES (?, ?, ?, ?)", [char_id, row.action, row.created_at, self.org_id]) return f"Successfully migrated <highlight>{num}</highlight> cloak status records." @command(command="budabot", params=[Const("migrate"), Const("org_activity")], access_level="superadmin", description="Migrate org activity records from a Budabot database" ) def migrate_budabot_org_activity_cmd(self, request, _1, _2): if not self.org_id: return "Could not migrate cloak status record since org id is not set." request.reply("Processing records. This may take some time...") data = self.db2.query( "SELECT o.actor AS actor_name, p1.charid AS actor_char_id, o.actee AS actee_name, p2.charid AS actee_char_id, action, time AS created_at " "FROM org_history o JOIN players p1 ON (o.actor = p1.name AND p1.dimension = ?) JOIN players p2 ON (o.actee = p2.name AND p2.dimension = ?) " "WHERE p1.charid > 0 AND p2.charid > 0", [self.dimension, self.dimension]) num = 0 with self.db.transaction(): self.db.exec("DELETE FROM org_activity WHERE org_id = ?", [self.org_id]) for row in data: actor_char_id = self.resolve_to_char_id( row.actor_name, row.actor_char_id) actee_char_id = self.resolve_to_char_id( row.actee_name, row.actee_char_id) if actor_char_id and actee_char_id: num += 1 self.db.exec( "INSERT INTO org_activity (actor_char_id, actee_char_id, action, created_at, org_id) VALUES (?, ?, ?, ?, ?)", [ actor_char_id, actee_char_id, row.action, row.created_at, self.org_id ]) return f"Successfully migrated <highlight>{num}</highlight> org activity records." @command( command="budabot", params=[Const("migrate"), Const("players")], access_level="superadmin", description="Migrate character info records from a Budabot database") def migrate_budabot_player_cmd(self, request, _1, _2): data = self.db2.query( "SELECT * FROM players WHERE charid > 0 AND dimension = ?", [self.dimension]) request.reply("Processing %s records. This may take some time..." % len(data)) num = 0 with self.db.transaction(): for row in data: if row.charid: num += 1 self.db.exec("DELETE FROM player WHERE char_id = ?", [row.charid]) self.db.exec( "INSERT INTO player (ai_level, ai_rank, breed, char_id, dimension, faction, first_name, gender, head_id, last_name, " "last_updated, level, name, org_id, org_name, org_rank_id, org_rank_name, profession, profession_title, pvp_rating, pvp_title, source) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ row.ai_level, row.ai_rank, row.breed, row.charid, row.dimension, row.faction, row.firstname, row.gender, row.head_id if row.head_id else 0, row.lastname, row.last_update, row.level, row.name, row.guild_id, row.guild, row.guild_rank_id or 0, row.guild_rank, row.profession, row.prof_title, row.pvp_rating if row.pvp_rating else 0, row.pvp_title if row.pvp_title else "", row.source ]) # maybe this is needed also? self.db.exec("DELETE FROM player WHERE char_id = 4294967295") return f"Successfully migrated <highlight>{num}</highlight> character info records." def resolve_to_char_id(self, name, char_id): if char_id and char_id > 0: return char_id char_id = self.character_service.resolve_char_to_id(name) if char_id: return char_id char_info = self.pork_service.get_character_info(name) if char_info: return char_info.char_id return None
class MigrateController: def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.db = registry.get_instance("db") self.character_service = registry.get_instance("character_service") self.alts_service = registry.get_instance("alts_service") self.pork_service = registry.get_instance("pork_service") def pre_start(self): self.bot_name = "bot_name" database = DictObject({ "type": "mysql", "name": "database_name", "username": "******", "password": "******", "host": "localhost", "port": 3306 }) self.db2 = DB() self.db2.connect_mysql(database.host, database.port, database.username, database.password, database.name) @command(command="bebot", params=[Const("migrate"), Const("alts")], access_level="superadmin", description="Migrate alts from a Bebot database") def migrate_alts_cmd(self, request, _1, _2): data = self.db2.query( "SELECT a.alt, u1.char_id AS alt_char_id, a.main, u2.char_id AS main_char_id " "FROM alts a " f"LEFT JOIN {self.bot_name}_users u1 ON a.alt = u1.nickname " f"LEFT JOIN {self.bot_name}_users u2 ON a.main = u2.nickname " "WHERE a.confirmed = 1 " "ORDER BY main, alt") current_main = None current_main_id = None count_inactive = 0 count_success = 0 count_failure = 0 request.reply("Processing %s alt records..." % len(data)) for row in data: if row.main != current_main: current_main = row.main current_main_id = self.resolve_to_char_id( row.main, row.main_char_id) if not current_main_id: self.logger.warning( f"Could not resolve main char '{current_main}' to char id") count_inactive += 1 continue alt_id = self.resolve_to_char_id(row.alt, row.alt_char_id) if not alt_id: self.logger.warning( f"Could not resolve alt char '{row.alt}' to char id") count_inactive += 1 continue msg, result = self.alts_service.add_alt(current_main_id, alt_id) if result: count_success += 1 else: count_failure += 1 return f"<highlight>{count_success}</highlight> alts were migrated successfully, " \ f"<highlight>{count_failure}</highlight> alts failed to be added, " \ f"and <highlight>{count_inactive}</highlight> chars were inactive and could not be resolved to char ids." @command(command="bebot", params=[Const("migrate"), Const("logon")], access_level="superadmin", description="Migrate logon messages from a Bebot database") def migrate_logon_cmd(self, request, _1, _2): data = self.db2.query(f"SELECT message, id FROM {self.bot_name}_logon") request.reply("Processing %s logon records..." % len(data)) for row in data: self.db.exec( "INSERT INTO log_messages (char_id, logon) VALUES(?, ?)", [row.id, row.message]) return f"Logon messages migrated successfully." @command(command="budabot", params=[Const("migrate"), Const("quotes")], access_level="superadmin", description="Migrate quotes from a Bebot database") def migrate_quotes_cmd(self, request, _1, _2): data = self.db2.query( "SELECT p.charid AS char_id, q.id, q.msg, q.dt FROM quote q LEFT JOIN players p ON q.poster = p.name" ) count_inactive = 0 request.reply("Processing %s quote records..." % len(data)) for row in data: char_id = self.resolve_to_char_id(row.poster, row.char_id) if not char_id: char_id = -1 count_inactive += 1 self.db.exec( "INSERT INTO quote (id, char_id, created_at, content) VALUES (?, ?, ?, ?)", [row.id, char_id, row.dt, row.msg]) return f"Quotes successfully migrated. <highlight>{count_inactive}</highlight> posters were inactive and could not be resolved to char ids." def resolve_to_char_id(self, name, char_id): if char_id and char_id > 0: return char_id char_id = self.character_service.resolve_char_to_id(name) if char_id: return char_id char_info = self.pork_service.get_character_info(name) if char_info: return char_info.char_id return None
class Bot: def __init__(self): self.socket = None self.char_id = None self.char_name = None self.logger = Logger(__name__) def connect(self, host, port): self.logger.info("Connecting to '%s:%d'" % (host, port)) self.socket = socket.create_connection((host, port), 10) def disconnect(self): if self.socket: self.socket.shutdown(socket.SHUT_RDWR) self.socket.close() self.socket = None def login(self, username, password, character): character = character.capitalize() # read seed packet self.logger.info("Logging in as '%s'" % character) seed_packet = self.read_packet(10) seed = seed_packet.seed # send back challenge key = generate_login_key(seed, username, password) login_request_packet = LoginRequest(0, username, key) self.send_packet(login_request_packet) # read character list character_list_packet: LoginCharacterList = self.read_packet() if isinstance(character_list_packet, LoginError): self.logger.error("Error logging in: %s" % character_list_packet.message) return False if character not in character_list_packet.names: self.logger.error("Character '%s' does not exist on this account" % character) return False index = character_list_packet.names.index(character) # select character self.char_id = character_list_packet.char_ids[index] self.char_name = character_list_packet.names[index] if character_list_packet.online_statuses[index]: sleep_duration = 20 self.logger.warning( "Character '%s' is already logged on, waiting %ds before proceeding" % (self.char_name, sleep_duration)) time.sleep(sleep_duration) login_select_packet = LoginSelect(self.char_id) self.send_packet(login_select_packet) # wait for OK packet = self.read_packet() if packet.id == LoginOK.id: self.logger.info("Connected!") return packet else: self.logger.error("Error logging in: %s" % packet.message) return False def read_packet(self, max_delay_time=1): """ Wait for packet from server. """ read, write, error = select.select([self.socket], [], [], max_delay_time) if not read: return None else: # Read data from server head = self.read_bytes(4) packet_type, packet_length = struct.unpack(">2H", head) data = self.read_bytes(packet_length) try: return ServerPacket.get_instance(packet_type, data) except Exception as e: self.logger.error( "Error parsing packet parameters for packet_type '%d' and payload: %s" % (packet_type, data), e) return None def send_packet(self, packet): data = packet.to_bytes() data = struct.pack(">2H", packet.id, len(data)) + data self.write_bytes(data) def read_bytes(self, num_bytes): data = bytes() while num_bytes > 0: chunk = self.socket.recv(num_bytes) if len(chunk) == 0: raise EOFError num_bytes -= len(chunk) data = data + chunk return data def write_bytes(self, data): num_bytes = len(data) while num_bytes > 0: sent = self.socket.send(data) if sent == 0: raise EOFError data = data[sent:] num_bytes -= sent
class BuddyManager: BUDDY_LOGON_EVENT = "buddy_logon" BUDDY_LOGOFF_EVENT = "buddy_logoff" def __init__(self): self.buddy_list = {} self.buddy_list_size = 1000 self.logger = Logger("buddy_manager") def inject(self, registry): self.character_manager: CharacterManager = registry.get_instance( "character_manager") self.bot = registry.get_instance("bot") self.event_manager = registry.get_instance("event_manager") def pre_start(self): self.bot.add_packet_handler(server_packets.BuddyAdded.id, self.handle_add) self.bot.add_packet_handler(server_packets.BuddyRemoved.id, self.handle_remove) self.bot.add_packet_handler(server_packets.LoginOK.id, self.handle_login_ok) self.event_manager.register_event_type(self.BUDDY_LOGON_EVENT) self.event_manager.register_event_type(self.BUDDY_LOGOFF_EVENT) def handle_add(self, packet): buddy = self.buddy_list.get(packet.char_id, {"types": []}) buddy["online"] = packet.online self.buddy_list[packet.char_id] = buddy if packet.online == 1: self.event_manager.fire_event(self.BUDDY_LOGON_EVENT, packet) else: self.event_manager.fire_event(self.BUDDY_LOGOFF_EVENT, packet) def handle_remove(self, packet): if packet.char_id in self.buddy_list: if len(self.buddy_list[packet.char_id]["types"]) > 0: self.logger.warning( "Removing buddy %d that still has types %s" % (packet.char_id, self.buddy_list[packet.char_id]["types"])) del self.buddy_list[packet.char_id] def handle_login_ok(self, packet): self.buddy_list_size += 1000 def add_buddy(self, char_id, _type): if char_id and char_id != self.bot.char_id: if char_id not in self.buddy_list: self.bot.send_packet(client_packets.BuddyAdd(char_id, "\1")) self.buddy_list[char_id] = {"online": None, "types": [_type]} elif _type not in self.buddy_list[char_id]["types"]: self.buddy_list[char_id]["types"].append(_type) return True else: return False def remove_buddy(self, char_id, _type, force_remove=False): if char_id: if char_id not in self.buddy_list: return False else: if _type in self.buddy_list[char_id]["types"]: self.buddy_list[char_id]["types"].remove(_type) if len(self.buddy_list[char_id]["types"]) == 0 or force_remove: self.bot.send_packet(client_packets.BuddyRemove(char_id)) return True else: return False def get_buddy(self, char): char_id = self.character_manager.resolve_char_to_id(char) return self.buddy_list.get(char_id, None) def is_online(self, char): char_id = self.character_manager.resolve_char_to_id(char) buddy = self.get_buddy(char_id) if buddy is None: return None else: return buddy.get("online", None) def get_all_buddies(self): return dict(self.buddy_list)
class RaidInstanceController: UNASSIGNED_RAID_INSTANCE_ID = 0 def __init__(self): self.logger = Logger(__name__) def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.text = registry.get_instance("text") self.setting_service = registry.get_instance("setting_service") self.util = registry.get_instance("util") self.character_service = registry.get_instance("character_service") self.private_channel_service = registry.get_instance( "private_channel_service") def start(self): self.db.exec( "CREATE TABLE IF NOT EXISTS raid_instance (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, conn_id VARCHAR(50) NOT NULL)" ) self.db.exec("DROP TABLE IF EXISTS raid_instance_char") self.db.exec( "CREATE TABLE raid_instance_char (raid_instance_id INT NOT NULL, char_id INT PRIMARY KEY, is_leader TINYINT NOT NULL)" ) self.setting_service.register( self.module_name, "raid_instance_relay_symbol", "@", TextSettingType(["!", "#", "*", "@", "$", "+", "-"]), "Symbol for external relay") @command(command="raidinstance", params=[], access_level="guest", description="Show the current raid instances") def raid_instance_cmd(self, request): blob = self.text.make_tellcmd("Refresh", "raidinstance") blob += " " blob += self.text.make_tellcmd("Apply", "raidinstance apply") blob += "\n\n" num_assigned = 0 num_unassigned = 0 raid_instances = self.get_raid_instances() data = self.get_raid_instance_chars() current_raid_instance_id = None for row in data: if row.raid_instance_id != current_raid_instance_id: conn = self.bot.conns.get(row.conn_id) bot_name = "(" + conn.char_name + ")" if conn else "" blob += f"\n<header2>{row.raid_instance_name} {bot_name}</header2>\n" current_raid_instance_id = row.raid_instance_id if not row.char_id: continue if row.raid_instance_id == self.UNASSIGNED_RAID_INSTANCE_ID: num_unassigned += 1 else: num_assigned += 1 blob += self.compact_char_display(row) add_leader_link = ( row.raid_instance_id != self.UNASSIGNED_RAID_INSTANCE_ID and not row.is_leader) blob += " " + self.get_assignment_links(raid_instances, row.name, add_leader_link) blob += "\n" blob += "\n" blob += "\n" + self.text.make_tellcmd("Clear All", "raidinstance clear") symbol = self.setting_service.get( "raid_instance_relay_symbol").get_value() blob += f"\n\nTip: You can use <highlight>{symbol}</highlight> at the start of your message to send it to all bot channels." blob += "\n\nInspired by the <highlight>RIS</highlight> module written for Bebot by <highlight>Bitnykk</highlight>" return ChatBlob( "Raid Instance (%d, %d)" % (num_assigned, num_unassigned), blob) @command(command="raidinstance", params=[Const("assign"), Any("raid_instance"), Character("char")], access_level="guest", description="Add a character to a raid instance", sub_command="leader") def raid_instance_assign_cmd(self, request, _, raid_instance_name, char): if not char.char_id: return StandardMessage.char_not_found(char.name) raid_instance = self.get_raid_instance(raid_instance_name) if not raid_instance: return f"Raid instance <highlight>{raid_instance_name}</highlight> does not exist." self.refresh_raid_instance_chars() row = self.db.query_single( "SELECT raid_instance_id FROM raid_instance_char WHERE char_id = ?", [char.char_id]) if row: if raid_instance.id == row.raid_instance_id: return f"Character <highlight>{char.name}</highlight> is already assigned to raid instance <highlight>{raid_instance.name}</highlight>." else: self.update_char_raid_instance(char.char_id, raid_instance.id) return f"Character <highlight>{char.name}</highlight> has been assigned to raid instance <highlight>{raid_instance.name}</highlight>." else: return f"Character <highlight>{char.name}</highlight> is not in the private channel." @command(command="raidinstance", params=[Const("clear")], access_level="guest", description="Remove all characters from all raid instances", sub_command="leader") def raid_instance_clear_cmd(self, request, _): self.db.exec("DELETE FROM raid_instance_char") return f"All characters have been removed from raid instances." @command(command="raidinstance", params=[Const("unassign"), Character("char")], access_level="guest", description="Remove a character from all raid instances", sub_command="leader") def raid_instance_unassign_cmd(self, request, _, char): if not char.char_id: return StandardMessage.char_not_found(char.name) self.refresh_raid_instance_chars() row = self.db.query_single( "SELECT r2.name FROM raid_instance_char r1 JOIN raid_instance r2 ON r1.raid_instance_id = r2.id WHERE r1.char_id = ?", [char.char_id]) if row: self.update_char_raid_instance(char.char_id, self.UNASSIGNED_RAID_INSTANCE_ID) return f"Character <highlight>{char.name}</highlight> has been removed from raid instance <highlight>{row.name}</highlight>." else: return f"Character <highlight>{char.name}</highlight> is not assigned to any raid instances." @command(command="raidinstance", params=[Const("leader"), Character("char")], access_level="guest", description="Set the leader for a raid instance", sub_command="leader") def raid_instance_leader_cmd(self, request, _, char): if not char.char_id: return StandardMessage.char_not_found(char.name) raid_instance = self.get_raid_instance_by_char(char.char_id) if not raid_instance: return f"Character <highlight>{char.name}</highlight> does not belong to a raid instance." self.set_leader(char.char_id, raid_instance.id) return f"Character <highlight>{char.name}</highlight> has been set as the leader for raid instance <highlight>{raid_instance.name}</highlight>." @command(command="raidinstance", params=[Const("apply")], access_level="guest", description="Apply the current raid instance configuration", sub_command="leader") def raid_instance_apply_cmd(self, request, _): for raid_instance in self.get_raid_instances(): conn = self.bot.conns.get(raid_instance.conn_id) if not conn: self.logger.warning( f"Could not find conn with id '{raid_instance.conn_id}'") continue private_channel = set() private_channel.update(conn.private_channel.keys()) data = self.db.query( "SELECT char_id FROM raid_instance_char WHERE raid_instance_id = ?", [raid_instance.id]) for row in data: if row.char_id not in private_channel: self.private_channel_service.invite(row.char_id, conn) private_channel.discard(row.char_id) for char_id in private_channel: self.private_channel_service.kick(char_id, conn) return "Raid instance configuration has been applied." @command( command="raidinstance", params=[Const("create"), Any("raid_instance_name"), Any("conn_id")], access_level="admin", description="Create or update a raid instance", sub_command="manage") def raid_instance_create_cmd(self, request, _, raid_instance_name, conn_id): conn = self.get_conn_by_id(conn_id) if not conn: return f"Could not find bot connection with ID <highlight>{conn_id}</highlight>." conn_display = f"{conn.char_name} ({conn.id})" if not conn.is_main: return f"Bot connection <highlight>{conn_display}</highlight> cannot be assigned to a raid instance because it is not a main bot." raid_instance = self.get_raid_instance(raid_instance_name) if raid_instance: if raid_instance.name == raid_instance_name and raid_instance.conn_id == conn.id: return f"Raid instance <highlight>{raid_instance_name}</highlight> already exists." else: self.db.exec( "UPDATE raid_instance SET name = ?, conn_id = ? WHERE id = ?", [raid_instance_name, conn.id, raid_instance.id]) return f"Raid instance <highlight>{raid_instance_name}</highlight> has been updated." else: self.db.exec( "INSERT INTO raid_instance (name, conn_id) VALUES (?, ?)", [raid_instance_name, conn.id]) return f"Raid instance <highlight>{raid_instance_name}</highlight> has been created and assigned to bot connection <highlight>{conn_display}</highlight>." @command(command="raidinstance", params=[Const("delete"), Any("raid_instance_name")], access_level="admin", description="Remove a raid instance", sub_command="manage") def raid_instance_delete_cmd(self, request, _, raid_instance_name): raid_instance = self.get_raid_instance(raid_instance_name) if not raid_instance: return f"Raid instance <highlight>{raid_instance_name}</highlight> does not exist." self.db.exec( "DELETE FROM raid_instance_char WHERE raid_instance_id = ?", [raid_instance.id]) self.db.exec("DELETE FROM raid_instance WHERE id = ?", [raid_instance.id]) return f"Raid instance <highlight>{raid_instance_name}</highlight> has been deleted." def get_raid_instance_chars(self): self.refresh_raid_instance_chars() data = self.db.query( "SELECT * FROM (" "SELECT p.*, COALESCE(p.name, r1.char_id) AS name, r2.id AS raid_instance_id, " "r1.is_leader, r2.name AS raid_instance_name, r2.conn_id " "FROM raid_instance r2 " "LEFT JOIN raid_instance_char r1 ON r1.raid_instance_id = r2.id " "LEFT JOIN player p ON r1.char_id = p.char_id " "UNION " "SELECT p.*, COALESCE(p.name, r3.char_id) AS name, r3.raid_instance_id, " "r3.is_leader, 'Unassigned' AS raid_instance_name, '' AS conn_id " "FROM raid_instance_char r3 " "LEFT JOIN player p ON r3.char_id = p.char_id " "WHERE r3.raid_instance_id = ?) " "ORDER BY raid_instance_id != ? DESC, raid_instance_name, is_leader, profession", [ self.UNASSIGNED_RAID_INSTANCE_ID, self.UNASSIGNED_RAID_INSTANCE_ID ]) return data def refresh_raid_instance_chars(self): current_raid_instances = set( map(lambda x: x.char_id, self.db.query("SELECT char_id FROM raid_instance_char"))) current_private_channel = set() for _id, conn in self.bot.get_conns(lambda x: x.is_main): current_private_channel.update(conn.private_channel.keys()) for char_id in current_private_channel.difference( current_raid_instances): self.db.exec( "INSERT INTO raid_instance_char (char_id, raid_instance_id, is_leader) VALUES (?, ?, 0)", [char_id, self.UNASSIGNED_RAID_INSTANCE_ID]) for char_id in current_raid_instances.difference( current_private_channel): self.db.exec( "DELETE FROM raid_instance_char WHERE char_id = ? AND raid_instance_id = ?", [char_id, self.UNASSIGNED_RAID_INSTANCE_ID]) def update_char_raid_instance(self, char_id, raid_instance_id): return self.db.exec( "UPDATE raid_instance_char SET raid_instance_id = ?, is_leader = 0 WHERE char_id = ?", [raid_instance_id, char_id]) def set_leader(self, char_id, raid_instance_id): self.db.exec( "UPDATE raid_instance_char SET is_leader = 0 WHERE raid_instance_id = ?", [raid_instance_id]) self.db.exec( "UPDATE raid_instance_char SET is_leader = 1 WHERE raid_instance_id = ? AND char_id = ?", [raid_instance_id, char_id]) def compact_char_display(self, char_info): if char_info.level: msg = "<highlight>%s</highlight> (%d/<green>%d</green>) %s" % ( char_info.name, char_info.level, char_info.ai_level, char_info.profession) elif char_info.name: msg = "<highlight>%s</highlight>" % char_info.name else: msg = "<highlight>Unknown(%d)</highlight>" % char_info.char_id if char_info.is_leader: msg += " [Leader]" return msg def get_assignment_links(self, raid_instances, char_name, add_leader_link): def map_row(x): if x.id == self.UNASSIGNED_RAID_INSTANCE_ID: return self.text.make_tellcmd( x.name, f"raidinstance unassign {char_name}") else: return self.text.make_tellcmd( x.name, f"raidinstance assign {x.name} {char_name}") links = list(map(map_row, raid_instances)) if add_leader_link: links.insert( 0, self.text.make_tellcmd("MakeLeader", f"raidinstance leader {char_name}")) return " ".join(links) def get_raid_instances(self): data = self.db.query( "SELECT id, name, conn_id FROM raid_instance ORDER BY name") data.append( DictObject({ "id": self.UNASSIGNED_RAID_INSTANCE_ID, "name": "Unassigned", "conn_id": None })) return data def get_raid_instance(self, raid_instance_name): return self.db.query_single( "SELECT id, name, conn_id FROM raid_instance WHERE name LIKE ?", [raid_instance_name]) def get_raid_instance_by_char(self, char_id): return self.db.query_single( "SELECT id, name, conn_id " "FROM raid_instance r1 JOIN raid_instance_char r2 ON r1.id = r2.raid_instance_id " "WHERE r2.char_id = ?", [char_id]) def get_conn_by_id(self, conn_id): conn = self.bot.conns.get(conn_id) if conn: return conn conns = self.bot.get_conns( lambda x: x.char_name.lower() == conn_id.lower()) if conns: return conns[0][1] return None
class SettingService: def __init__(self): self.logger = Logger(__name__) self.settings = {} self.db_cache = {} self.change_listeners = {} def inject(self, registry): self.db = registry.get_instance("db") self.util = registry.get_instance("util") def start(self): # process decorators for _, inst in Registry.get_all_instances().items(): for name, method in get_attrs(inst).items(): if hasattr(method, "setting"): setting_name, value, description, extended_description, obj = getattr( method, "setting") self.register(inst.module_name, setting_name, value, obj, description, extended_description) def register(self, module, name, value, setting: SettingType, description, extended_description=None): """Call during start""" name = name.lower() module = module.lower() setting.set_name(name) setting.set_description(description) setting.set_extended_description(extended_description) if not description: self.logger.warning("No description specified for setting '%s'" % name) if " " in name: raise Exception( "One or more spaces found in setting name '%s' for module '%s'" % (name, module)) row = self.db.query_single( "SELECT name, value, description FROM setting WHERE name = ?", [name]) if row is None: self.logger.debug("Adding setting '%s'" % name) self.db.exec( "INSERT INTO setting (name, value, description, module, verified) VALUES (?, ?, ?, ?, ?)", [name, "", description, module, 1]) # verify default value is a valid value, and is formatted appropriately setting.set_value(value) else: self.logger.debug("Updating setting '%s'" % name) self.db.exec( "UPDATE setting SET description = ?, verified = ?, module = ? WHERE name = ?", [description, 1, module, name]) self.settings[name] = setting def register_change_listener(self, setting_name, handler): """ Call during start Args: setting_name: str handler: (name: string, old_value, new_value) -> void """ if len(inspect.signature(handler).parameters) != 3: raise Exception( "Incorrect number of arguments for handler '%s.%s()'" % (handler.__module__, handler.__name__)) if setting_name in self.settings: if setting_name not in self.change_listeners: self.change_listeners[setting_name] = [] self.change_listeners[setting_name].append(handler) else: raise Exception( "Could not register change_listener for setting '%s' since it does not exist" % setting_name) def get_value(self, name): # check cache first result = self.db_cache.get(name, None) if result: return result.value else: row = self.db.query_single( "SELECT value FROM setting WHERE name = ?", [name]) # store result in cache self.db_cache[name] = row return row.value if row else None def set_value(self, name, value): old_value = self.get_value(name) # clear cache self.db_cache[name] = None self.db.exec("UPDATE setting SET value = ? WHERE name = ?", [value, name]) if name in self.change_listeners: for change_listener in self.change_listeners[name]: change_listener(name, old_value, value) def get(self, name): name = name.lower() setting = self.settings.get(name, None) if setting: return setting else: return None
class BuddyService: BUDDY_LOGON_EVENT = "buddy_logon" BUDDY_LOGOFF_EVENT = "buddy_logoff" def __init__(self): self.buddy_list = {} self.buddy_list_size = 0 self.logger = Logger(__name__) def inject(self, registry): self.character_service: CharacterService = registry.get_instance( "character_service") self.bot = registry.get_instance("bot") self.event_service = registry.get_instance("event_service") def pre_start(self): self.bot.register_packet_handler(server_packets.BuddyAdded.id, self.handle_add) self.bot.register_packet_handler(server_packets.BuddyRemoved.id, self.handle_remove) self.bot.register_packet_handler(server_packets.LoginOK.id, self.handle_login_ok) self.event_service.register_event_type(self.BUDDY_LOGON_EVENT) self.event_service.register_event_type(self.BUDDY_LOGOFF_EVENT) def handle_add(self, conn: Conn, packet): buddy = self.buddy_list[conn.id].get(packet.char_id, { "types": [], "conn_id": conn.id }) buddy["online"] = packet.online self.buddy_list[conn.id][packet.char_id] = buddy # verify that buddy does not exist on any other conn for conn_id, conn_buddy_list in self.buddy_list.items(): if conn.id != conn_id: buddy = conn_buddy_list.get(packet.char_id, None) if buddy: if buddy["online"] is None: # remove from other conn list del conn_buddy_list[packet.char_id] else: # remove from this conn self.logger.warning( "Removing char '%s' from conn '%s' since it already exists on another conn" % (packet.char_id, conn.id)) conn.send_packet( client_packets.BuddyRemove(packet.char_id)) if packet.online == 1: self.event_service.fire_event(self.BUDDY_LOGON_EVENT, packet) else: self.event_service.fire_event(self.BUDDY_LOGOFF_EVENT, packet) def handle_remove(self, conn: Conn, packet): conn_buddy_list = self.buddy_list[conn.id] if packet.char_id in conn_buddy_list: if len(conn_buddy_list[packet.char_id]["types"]) > 0: self.logger.warning( "Removing buddy %d that still has types %s" % (packet.char_id, conn_buddy_list[packet.char_id]["types"])) del conn_buddy_list[packet.char_id] def handle_login_ok(self, conn: Conn, packet): self.buddy_list_size += 1000 self.buddy_list[conn.id] = {} self.buddy_list[conn.id][conn.char_id] = { "online": True, "types": [], "conn_id": conn.id } def add_buddy(self, char_id, _type): if not char_id: return False # check if we are trying to add a conn as a buddy if self.is_conn_char_id(char_id): return False buddy = self.get_buddy(char_id) if buddy: buddy["types"].append(_type) else: conn = self.get_conn_for_new_buddy() if not conn: self.logger.warning( f"Could not add buddy '{char_id}' with type '{_type}' since buddy list is full" ) else: if conn.char_id != char_id: conn.send_packet(client_packets.BuddyAdd(char_id, "\1")) self.buddy_list[conn.id][char_id] = { "online": None, "types": [_type], "conn_id": conn.id } return True def is_conn_char_id(self, char_id): for _id, conn in self.bot.conns.items(): if conn.char_id == char_id: return True return False def remove_buddy(self, char_id, _type, force_remove=False): if char_id: buddy = self.get_buddy(char_id) if not buddy: return False if _type in buddy["types"]: buddy["types"].remove(_type) if len(buddy["types"]) == 0 or force_remove: if not self.is_conn_char_id(char_id): conn = self.bot.conns[buddy["conn_id"]] conn.send_packet(client_packets.BuddyRemove(char_id)) return True else: return False def get_buddy(self, char_id): for conn_id, conn_buddy_list in self.buddy_list.items(): if char_id in conn_buddy_list: return conn_buddy_list[char_id] return None def is_online(self, char_id): buddy = self.get_buddy(char_id) if buddy is None: return None else: return buddy.get("online", None) def get_all_buddies(self): result = {} for conn_id, conn_buddy_list in self.buddy_list.items(): for char_id, buddy in conn_buddy_list.items(): # TODO what if buddies exist on multiple conns? result[char_id] = buddy return result def get_buddy_list_size(self): count = 0 for conn_id, conn_buddy_list in self.buddy_list.items(): count += len(conn_buddy_list) return count def get_conn_for_new_buddy(self): buddy_list_size = 1001 _id = None for conn_id, conn_buddy_list in self.buddy_list.items(): if len(conn_buddy_list) < buddy_list_size: buddy_list_size = len(conn_buddy_list) _id = conn_id return self.bot.conns.get(_id, None)
class CommandService: PRIVATE_MESSAGE_CHANNEL = "msg" def __init__(self): self.handlers = collections.defaultdict(list) self.logger = Logger(__name__) self.channels = {} self.pre_processors = [] self.ignore_regexes = [ re.compile(r" is AFK \(Away from keyboard\) since ", re.IGNORECASE), re.compile(r"I am away from my keyboard right now", re.IGNORECASE), re.compile(r"Unknown command or access denied!", re.IGNORECASE), re.compile(r"I am responding", re.IGNORECASE), re.compile(r"I only listen", re.IGNORECASE), re.compile(r"Error!", re.IGNORECASE), re.compile(r"Unknown command input", re.IGNORECASE), re.compile(r"You have been auto invited", re.IGNORECASE), re.compile(r"^<font") ] def inject(self, registry): self.db = registry.get_instance("db") self.util = registry.get_instance("util") self.access_service: AccessService = registry.get_instance( "access_service") self.bot = registry.get_instance("bot") self.character_service: CharacterService = registry.get_instance( "character_service") self.event_service = registry.get_instance("event_service") self.setting_service: SettingService = registry.get_instance( "setting_service") self.command_alias_service = registry.get_instance( "command_alias_service") self.usage_service = registry.get_instance("usage_service") self.public_channel_service = registry.get_instance( "public_channel_service") self.ban_service = registry.get_instance("ban_service") def pre_start(self): self.bot.register_packet_handler(server_packets.PrivateMessage.id, self.handle_private_message) self.register_command_channel("Private Message", self.PRIVATE_MESSAGE_CHANNEL) def start(self): access_levels = {} # process decorators for _, inst in Registry.get_all_instances().items(): for name, method in get_attrs(inst).items(): if hasattr(method, "command"): cmd_name, params, access_level, description, help_file, sub_command, extended_description = getattr( method, "command") handler = getattr(inst, name) help_text = self.get_help_file(inst.module_name, help_file) command_key = self.get_command_key( cmd_name.lower(), sub_command.lower() if sub_command else "") al = access_levels.get(command_key, None) if al is not None and al != access_level.lower(): raise Exception( "Different access levels specified for forms of command '%s'" % command_key) access_levels[command_key] = access_level self.register(handler, cmd_name, params, access_level, description, inst.module_name, help_text, sub_command, extended_description) def register(self, handler, command, params, access_level, description, module, help_text=None, sub_command=None, extended_description=None, check_access=None): """ Call during pre_start Args: handler: (request, param1, param2, ...) -> str|ChatBlob|None command: str params: [CommandParam...] access_level: str description: str module: str help_text: str sub_command: str extended_description: str check_access: (char_id, access_level_label) -> bool """ if len(inspect.signature(handler).parameters) != len(params) + 1: raise Exception( "Incorrect number of arguments for handler '%s.%s()'" % (handler.__module__, handler.__qualname__)) command = command.lower() if sub_command: sub_command = sub_command.lower() else: sub_command = "" access_level = access_level.lower() module = module.lower() command_key = self.get_command_key(command, sub_command) if help_text is None: help_text = self.generate_help(command, description, params, extended_description) if check_access is None: check_access = self.access_service.check_access if not self.access_service.get_access_level_by_label(access_level): self.logger.error( "Could not add command '%s': could not find access level '%s'" % (command, access_level)) return for channel, label in self.channels.items(): row = self.db.query_single( "SELECT access_level, module, enabled, verified " "FROM command_config " "WHERE command = ? AND sub_command = ? AND channel = ?", [command, sub_command, channel]) if row is None: # add new command self.db.exec( "INSERT INTO command_config " "(command, sub_command, access_level, channel, module, enabled, verified) " "VALUES (?, ?, ?, ?, ?, 1, 1)", [command, sub_command, access_level, channel, module]) elif row.verified: if row.module != module: self.logger.warning( "module different for different forms of command '%s' and sub_command '%s'" % (command, sub_command)) else: # mark command as verified self.db.exec( "UPDATE command_config SET verified = 1, module = ? " "WHERE command = ? AND sub_command = ? AND channel = ?", [module, command, sub_command, channel]) # save reference to command handler r = re.compile(self.get_regex_from_params(params), re.IGNORECASE | re.DOTALL) self.handlers[command_key].append({ "regex": r, "callback": handler, "help": help_text, "description": description, "params": params, "check_access": check_access }) def register_command_pre_processor(self, pre_processor): """ Call during start Args: pre_processor: (context) -> bool """ self.pre_processors.append(pre_processor) def register_command_channel(self, label, value): """ Call during pre_start Args: label: str value: str """ if value in self.channels: self.logger.error( "Could not register command channel '%s': command channel already registered" % value) return self.logger.debug("Registering command channel '%s'" % value) self.channels[value] = label def is_command_channel(self, channel): return channel in self.channels def process_command(self, message: str, channel: str, char_id, reply, conn): try: context = DictObject({ "message": message, "char_id": char_id, "channel": channel, "reply": reply }) for pre_processor in self.pre_processors: if pre_processor(context) is False: return for regex in self.ignore_regexes: if regex.search(message): return # message = html.unescape(message) command_str, command_args = self.get_command_parts(message) # check for command alias command_alias_str = self.command_alias_service.get_alias_command_str( command_str, command_args) alias_depth_count = 0 while command_alias_str: alias_depth_count += 1 command_str, command_args = self.get_command_parts( command_alias_str) command_alias_str = self.command_alias_service.get_alias_command_str( command_str, command_args) if alias_depth_count > 20: raise Exception( "Command alias infinite recursion detected for command '%s'" % message) cmd_configs = self.get_command_configs(command_str, channel, 1) access_level = self.access_service.get_access_level(char_id) sender = SenderObj( char_id, self.character_service.resolve_char_to_name( char_id, "Unknown(%d)" % char_id), access_level) if cmd_configs: # given a list of cmd_configs that are enabled, see if one has regex that matches incoming command_str cmd_config, matches, handler = self.get_matches( cmd_configs, command_args) if matches: if handler["check_access"](char_id, cmd_config.access_level): response = handler["callback"]( CommandRequest(conn, channel, sender, reply), *self.process_matches(matches, handler["params"])) if response is not None: reply(response) # record command usage self.usage_service.add_usage( command_str, self.util.get_handler_name(handler["callback"]), char_id, channel) else: self.access_denied_response(message, sender, cmd_config, reply) else: # handlers were found, but no handler regex matched data = self.db.query( "SELECT command, sub_command, access_level FROM command_config " "WHERE command = ? AND channel = ? AND enabled = 1", [command_str, channel]) help_text = self.format_help_text(data, char_id) if help_text: reply( self.format_help_text_blob(command_str, help_text)) else: # the command is known, but no help is returned, therefore character does not have access to command reply("Access denied.") else: self.handle_unknown_command(command_str, command_args, channel, sender, reply) except Exception as e: self.logger.error("error processing command: %s" % message, e) reply("There was an error processing your request.") def handle_unknown_command(self, command_str, command_args, channel, sender, reply): reply(f"Error! Unknown command <highlight>{command_str}</highlight>.") def access_denied_response(self, message, sender, cmd_config, reply): reply("Access denied.") def get_command_parts(self, message): parts = message.split(" ", 1) if len(parts) == 2: return parts[0].lower(), " " + parts[1] else: return parts[0].lower(), "" def get_command_configs(self, command, channel=None, enabled=1, sub_command=None): sql = "SELECT command, sub_command, access_level, channel, enabled FROM command_config WHERE command = ?" params = [command] if channel: sql += " AND channel = ?" params.append(channel) if enabled: sql += " AND enabled = ?" params.append(enabled) if sub_command: sql += " AND sub_command = ?" params.append(sub_command) sql += " ORDER BY sub_command, channel" return self.db.query(sql, params) def get_matches(self, cmd_configs, command_args): for row in cmd_configs: command_key = self.get_command_key(row.command, row.sub_command) handlers = self.handlers[command_key] for handler in handlers: matches = handler["regex"].search(command_args) if matches: return row, matches, handler return None, None, None def process_matches(self, matches, params): groups = list(matches.groups()) processed = [] for param in params: processed.append(param.process_matches(groups)) return processed def format_help_text(self, data, char_id, show_regex=False): # filter out commands that character does not have access level for data = filter( lambda row: self.access_service.check_access( char_id, row.access_level), data) def get_regex(params): if show_regex: return "\n" + self.get_regex_from_params(params) else: return "" def read_help_text(row): command_key = self.get_command_key(row.command, row.sub_command) return filter( lambda x: x is not None, map( lambda handler: handler["help"] + get_regex(handler[ "params"]), self.handlers[command_key])) content = "\n\n".join(flatmap(read_help_text, data)) return content if content else None def format_help_text_blob(self, topic, help_text): return ChatBlob("Help (" + topic + ")", help_text) def get_help_file(self, module, help_file): if help_file: try: help_file = "./" + module.replace(".", "/") + "/" + help_file with open(help_file, mode="r", encoding="UTF-8") as f: return f.read().strip() except FileNotFoundError as e: self.logger.error("Error reading help file", e) return None def get_command_key(self, command, sub_command): if sub_command: return command + ":" + sub_command else: return command def get_command_key_parts(self, command_str): parts = command_str.split(":", 1) if len(parts) == 2: return parts[0], parts[1] else: return parts[0], "" def get_regex_from_params(self, params): # params must be wrapped with line-beginning and line-ending anchors in order to match # when no params are specified (eg. "^$") return "^" + "".join(map(lambda x: x.get_regex(), params)) + "$" def generate_help(self, command, description, params, extended_description=None): help_text = description + ":\n" + "<tab><symbol>" + command + " " + " ".join( map(lambda x: x.get_name(), params)) if extended_description: help_text += "\n" + extended_description return help_text def get_handlers(self, command_key): return self.handlers.get(command_key, None) def handle_private_message(self, conn: Conn, packet: server_packets.PrivateMessage): if not self.setting_service.get("accept_commands_from_slave_bots" ).get_value() and not conn.is_main: return # since the command symbol is not required for private messages, # the command_str must have length of at least 1 in order to be valid, # otherwise it is ignored if len(packet.message) < 1: return # ignore leading space message = packet.message.lstrip() def reply(msg): if self.bot.mass_message_queue and FeatureFlags.FORCE_LARGE_MESSAGES_FROM_SLAVES and \ isinstance(msg, ChatBlob) and len(msg.msg) > FeatureFlags.FORCE_LARGE_MESSAGES_FROM_SLAVES_THRESHOLD: self.bot.send_mass_message(packet.char_id, msg, conn=conn) else: self.bot.send_private_message(packet.char_id, msg, conn=conn) self.process_command(self.trim_command_symbol(message), self.PRIVATE_MESSAGE_CHANNEL, packet.char_id, reply, conn) def trim_command_symbol(self, s): symbol = self.setting_service.get("symbol").get_value() if s.startswith(symbol): s = s[len(symbol):] return s
class DiscordController: MESSAGE_SOURCE = "discord" COMMAND_CHANNEL = "discord" def __init__(self): self.dthread = None self.dqueue = [] self.aoqueue = [] self.logger = Logger(__name__) self.client = None self.command_handlers = [] def inject(self, registry): self.bot = registry.get_instance("bot") self.db = registry.get_instance("db") self.util = registry.get_instance("util") self.setting_service = registry.get_instance("setting_service") self.event_service = registry.get_instance("event_service") self.character_service: CharacterService = registry.get_instance( "character_service") self.text: Text = registry.get_instance("text") self.command_service = registry.get_instance("command_service") self.ban_service = registry.get_instance("ban_service") self.message_hub_service = registry.get_instance("message_hub_service") self.pork_service = registry.get_instance("pork_service") self.alts_service = registry.get_instance("alts_service") self.ts: TranslationService = registry.get_instance( "translation_service") self.getresp = self.ts.get_response def pre_start(self): self.event_service.register_event_type("discord_ready") self.event_service.register_event_type("discord_message") self.event_service.register_event_type("discord_channels") self.event_service.register_event_type("discord_command") self.event_service.register_event_type("discord_invites") self.message_hub_service.register_message_source(self.MESSAGE_SOURCE) self.command_service.register_command_channel("Discord", self.COMMAND_CHANNEL) def start(self): self.db.exec( "CREATE TABLE IF NOT EXISTS discord_char_link (discord_id BIGINT NOT NULL, char_id INT NOT NULL)" ) self.message_hub_service.register_message_destination( self.MESSAGE_SOURCE, self.handle_incoming_relay_message, [ "private_channel", "org_channel", "websocket_relay", "tell_relay", "shutdown_notice" ], [self.MESSAGE_SOURCE]) self.register_discord_command_handler( self.discord_link_cmd, "discord", [Const("link"), Character("ao_character")]) self.register_discord_command_handler(self.discord_unlink_cmd, "discord", [Const("unlink")]) self.ts.register_translation("module/discord", self.load_discord_msg) self.setting_service.register(self.module_name, "discord_enabled", False, BooleanSettingType(), "Enable the Discord relay") self.setting_service.register(self.module_name, "discord_bot_token", "", HiddenSettingType(allow_empty=True), "Discord bot token") self.setting_service.register( self.module_name, "discord_channel_id", "", TextSettingType(allow_empty=True), "Discord channel id for relaying messages to and from", "You can get the Discord channel ID by right-clicking on a channel name in Discord and then clicking \"Copy ID\"" ) self.setting_service.register(self.module_name, "discord_embed_color", "#00FF00", ColorSettingType(), "Discord embedded message color") self.setting_service.register( self.module_name, "relay_color_prefix", "#FCA712", ColorSettingType(), "Set the prefix color for messages coming from Discord") self.setting_service.register( self.module_name, "relay_color_name", "#808080", ColorSettingType(), "Set the color of the name for messages coming from Discord") self.setting_service.register( self.module_name, "relay_color_message", "#00DE42", ColorSettingType(), "Set the color of the content for messages coming from Discord") self.setting_service.register_change_listener( "discord_channel_id", self.update_discord_channel) self.setting_service.register_change_listener( "discord_enabled", self.update_discord_state) def load_discord_msg(self): with open("modules/standard/discord/discord.msg", mode="r", encoding="utf-8") as f: return hjson.load(f) @command(command="discord", params=[], access_level="member", description="See Discord info") def discord_cmd(self, request): servers = "" if self.client and self.client.guilds: for server in self.client.guilds: invites = self.text.make_tellcmd( self.getresp("module/discord", "get_invite"), "discord getinvite %s" % server.id) owner = server.owner.nick or re.sub( pattern=r"#\d+", repl="", string=str(server.owner)) servers += self.getresp( "module/discord", "server", { "server_name": server.name, "invite": invites, "m_count": str(len(server.members)), "owner": owner }) else: servers += self.getresp("module/discord", "no_server") subs = "" for channel in self.get_text_channels(): subs += self.getresp("module/discord", "sub", { "server_name": channel.guild.name, "channel_name": channel.name }) status = self.getresp( "module/discord", "connected" if self.is_connected() else "disconnected") blob = self.getresp( "module/discord", "blob", { "connected": status, "count": len(self.get_text_channels()), "servers": servers, "subs": subs }) return ChatBlob(self.getresp("module/discord", "title"), blob) @command(command="discord", params=[Const("relay")], access_level="moderator", sub_command="manage", description="Setup relaying of channels") def discord_relay_cmd(self, request, _): connect_link = self.text.make_tellcmd( self.getresp("module/discord", "connect"), "config setting discord_enabled set true") disconnect_link = self.text.make_tellcmd( self.getresp("module/discord", "disconnect"), "config setting discord_enabled set false") constatus = self.getresp( "module/discord", "connected" if self.is_connected() else "disconnected") subs = "" for channel in self.get_text_channels(): select_link = self.text.make_tellcmd( "select", "config setting discord_channel_id set %s" % channel.id) selected = "(selected)" if self.setting_service.get( "discord_channel_id").get_value() == channel.id else "" subs += self.getresp( "module/discord", "relay", { "server_name": channel.guild.name, "channel_name": channel.name, "select": select_link, "selected": selected }) blob = self.getresp( "module/discord", "blob_relay", { "connected": constatus, "connect_link": connect_link, "disconnect_link": disconnect_link, "count": len(self.get_text_channels()), "subs": subs }) return ChatBlob(self.getresp("module/discord", "relay_title"), blob) @command(command="discord", params=[Const("confirm"), Int("discord_id")], access_level="member", description="Confirm link of a Discord user") def discord_confirm_cmd(self, request, _, discord_id): main = self.alts_service.get_main(request.sender.char_id) if main.char_id != request.sender.char_id: return self.getresp("module/discord", "must_run_from_main", {"char": main.name}) self.db.exec( "DELETE FROM discord_char_link WHERE discord_id = ? OR char_id = ?", [discord_id, main.char_id]) self.db.exec( "INSERT INTO discord_char_link (discord_id, char_id) VALUES (?, ?)", [discord_id, main.char_id]) return self.getresp("module/discord", "link_success", {"discord_user": discord_id}) @command(command="discord", params=[Const("getinvite"), Int("server_id")], access_level="member", description="Get an invite for specified server", sub_command="getinvite") def discord_getinvite_cmd(self, request, _, server_id): if self.client and self.client.guilds: for server in self.client.guilds: if server.id == server_id: self.send_to_discord("get_invite", (request.sender.name, server)) return return self.getresp("module/discord", "no_dc", {"id": server_id}) @timerevent(budatime="1s", description="Discord relay queue handler", is_hidden=True) def handle_discord_queue_event(self, event_type, event_data): if self.dqueue: dtype, message = self.dqueue.pop(0) if dtype == "discord_message": if message.channel.type == ChannelType.private or message.content.startswith( self.setting_service.get("symbol").get_value()): self.handle_discord_command_event(message) else: self.handle_discord_message_event(message) elif dtype == "discord_ready": self.send_to_discord( "msg", DiscordTextMessage( f"{self.bot.get_primary_conn().get_char_name()} is now connected." )) self.event_service.fire_event(dtype, message) @timerevent(budatime="1m", description="Ensure the bot is connected to Discord", is_enabled=False, is_hidden=True, run_at_startup=True) def handle_connect_event(self, event_type, event_data): if not self.is_connected(): self.connect_discord_client() @event(event_type=AltsService.MAIN_CHANGED_EVENT_TYPE, description="Update discord character link when a main is changed", is_hidden=True) def handle_main_changed(self, event_type, event_data): old_row = self.db.query_single( "SELECT discord_id FROM discord_char_link WHERE char_id = ?", [event_data.old_main_id]) if old_row: new_row = self.db.query_single( "SELECT discord_id FROM discord_char_link WHERE char_id = ?", [event_data.new_main_id]) if not new_row: self.db.exec( "INSERT INTO discord_char_link (discord_id, char_id) VALUES (?, ?)", [old_row.discord_id, event_data.new_main_id]) @event(event_type="discord_invites", description="Handles invite requests", is_hidden=True) def handle_discord_invite_event(self, event_type, event_data): char_name = event_data[0] invites = event_data[1] blob = "" server_invites = "" if len(invites) > 0: for invite in invites: link = self.text.make_chatcmd( self.getresp("module/discord", "join"), "/start %s" % invite.url) timeleft = "Permanent" if invite.max_age == 0 else str( datetime.timedelta(seconds=invite.max_age)) used = str(invite.uses) if invite.uses is not None else "N/A" useleft = str( invite.max_uses) if invite.max_uses is not None else "N/A" if invite.channel is not None: channel = self.getresp("module/discord", "inv_channel", {"channel": invite.channel.name}) else: channel = None server_invites += self.getresp( "module/discord", "invite", { "server": invite.guild.name, "link": link, "time_left": timeleft, "count_used": used, "count_left": useleft, "channel": channel }) blob += self.getresp("module/discord", "blob_invites", {"invites": server_invites}) else: blob += "No invites currently exist." char_id = self.character_service.resolve_char_to_id(char_name) self.bot.send_private_message( char_id, ChatBlob(self.getresp("module/discord", "invite_title"), blob)) def handle_discord_command_event(self, message): if not self.find_discord_command_handler(message): reply = partial(self.discord_command_reply, channel=message.channel) row = self.db.query_single( "SELECT char_id FROM discord_char_link WHERE discord_id = ?", [message.author.id]) if row: message_str = self.command_service.trim_command_symbol( message.content) self.command_service.process_command( message_str, self.COMMAND_CHANNEL, row.char_id, reply, self.bot.get_primary_conn()) else: reply(self.getresp("module/discord", "discord_user_not_linked")) def handle_discord_message_event(self, message): if isinstance(message.author, Member): name = message.author.nick or message.author.name else: name = message.author.name chanclr = self.setting_service.get("relay_color_prefix") nameclr = self.setting_service.get("relay_color_name") mesgclr = self.setting_service.get("relay_color_message") formatted_message = "<grey>[</grey>%s<grey>]</grey> %s<grey>:</grey> %s" % ( chanclr.format_text("Discord"), nameclr.format_text(name), mesgclr.format_text(message.content)) self.message_hub_service.send_message(self.MESSAGE_SOURCE, None, None, formatted_message) def find_discord_command_handler(self, message): message_str = self.command_service.trim_command_symbol(message.content) command_str, command_args = self.command_service.get_command_parts( message_str) for handler in self.command_handlers: if handler.command == command_str: matches = handler.regex.search(command_args) if matches: ctx = DictObject({"message": message}) handler.callback( ctx, partial(self.discord_command_reply, channel=message.channel), self.command_service.process_matches( matches, handler.params)) return True return False def discord_command_reply(self, content, title=None, channel=None): if isinstance(content, ChatBlob): if not title: title = content.title content = content.page_prefix + content.msg + content.page_postfix if not title: title = "Command" title = self.format_message(title) if isinstance(content, str): msgcolor = self.setting_service.get( "discord_embed_color").get_int_value() pages = self.text.split_by_separators(self.format_message(content), 2048) # discord max is 2048 num_pages = len(pages) page_title = title for page_num, page in enumerate(pages, start=1): if num_pages > 1: page_title = title + f" (Page {page_num} / {num_pages})" self.send_to_discord( "command_reply", DiscordEmbedMessage(page_title, page, msgcolor, channel)) return if isinstance(content, DiscordMessage): self.send_to_discord("command_reply", content) else: self.logger.error("unable to process message for discord: " + content) def format_message(self, msg): msg = re.sub(r"<header>(.*?)</header>\n?", r"```less\n\1\n```", msg) msg = re.sub(r"<header2>(.*?)</header2>\n?", r"```yaml\n\1\n```", msg) msg = re.sub(r"<highlight>(.*?)</highlight>", r"`\1`", msg) return self.strip_html_tags(msg) def register_discord_command_handler(self, callback, command_str, params): """Call during start""" r = re.compile(self.command_service.get_regex_from_params(params), re.IGNORECASE | re.DOTALL) self.command_handlers.append( DictObject({ "callback": callback, "command": command_str, "params": params, "regex": r })) def connect_discord_client(self): token = self.setting_service.get("discord_bot_token").get_value() if not token: self.logger.warning( "Unable to connect to Discord, discord_bot_token has not been set" ) else: self.disconnect_discord_client() self.client = DiscordWrapper( self.setting_service.get("discord_channel_id").get_value(), self.dqueue, self.aoqueue) self.dthread = threading.Thread(target=self.run_discord_thread, args=(self.client, token), daemon=True) self.dthread.start() def run_discord_thread(self, client, token): try: self.logger.info("connecting to discord") client.loop.create_task(client.start(token)) client.loop.run_until_complete(client.relay_message()) except Exception as e: self.logger.error("discord connection lost", e) def disconnect_discord_client(self): if self.client: self.client.loop.create_task( self.client.logout_with_message( f"{self.bot.get_primary_conn().get_char_name()} is disconnecting..." )) self.client = None if self.dthread: self.dthread.join() self.dthread = None self.dqueue = [] self.aoqueue = [] def strip_html_tags(self, html): s = MLStripper() s.feed(html) return s.get_data() def discord_link_cmd(self, ctx, reply, args): char = args[1] if not char.char_id: reply(self.getresp("global", "char_not_found", {"char": char.name})) return main = self.alts_service.get_main(char.char_id) if main.char_id != char.char_id: reply(self.getresp("module/discord", "must_link_main")) return author = ctx.message.author discord_user = "******" % (author.name, author.discriminator, author.id) blob = self.getresp( "module/discord", "confirm_instructions", { "discord_user": discord_user, "confirm_link": self.text.make_tellcmd("Confirm", "discord confirm %d" % author.id) }) self.bot.send_private_message(char.char_id, ChatBlob("Discord Confirm Link", blob)) reply( self.getresp("module/discord", "link_response", {"char": char.name})) def discord_unlink_cmd(self, ctx, reply, args): self.db.exec("DELETE FROM discord_char_link WHERE discord_id = ?", [ctx.message.author.id]) reply(self.getresp("module/discord", "unlink_success")) def is_connected(self): # not self.client or not self.dthread.is_alive() return self.client and self.client.is_ready( ) and self.dthread and self.dthread.is_alive() def get_char_info_display(self, char_id): char_info = self.pork_service.get_character_info(char_id) if char_info: name = self.strip_html_tags(self.text.format_char_info(char_info)) else: name = self.character_service.resolve_char_to_name(char_id) return name def send_to_discord(self, message_type, data): self.aoqueue.append((message_type, data)) def handle_incoming_relay_message(self, ctx): if not self.is_connected(): return message = DiscordTextMessage( self.strip_html_tags(ctx.formatted_message)) self.send_to_discord("msg", message) def get_text_channels(self): if self.client: return self.client.get_text_channels() else: return [] def update_discord_channel(self, setting_name, old_value, new_value): if self.client: if not self.client.set_channel_id(new_value): self.logger.warning( f"Could not find discord channel '{new_value}'") def update_discord_state(self, setting_name, old_value, new_value): if setting_name == "discord_enabled": event_handlers = [ self.handle_connect_event, self.handle_discord_queue_event, self.handle_discord_invite_event ] for handler in event_handlers: event_handler = self.util.get_handler_name(handler) event_base_type, event_sub_type = self.event_service.get_event_type_parts( handler.event.event_type) self.event_service.update_event_status(event_base_type, event_sub_type, event_handler, 1 if new_value else 0) if not new_value: self.disconnect_discord_client()
class CommandService: PRIVATE_CHANNEL = "priv" ORG_CHANNEL = "org" PRIVATE_MESSAGE = "msg" def __init__(self): self.handlers = collections.defaultdict(list) self.logger = Logger(__name__) self.channels = {} self.ignore_regexes = [ re.compile(" is AFK \(Away from keyboard\) since ", re.IGNORECASE), re.compile("I am away from my keyboard right now", re.IGNORECASE), re.compile("Unknown command or access denied!", re.IGNORECASE), re.compile("I am responding", re.IGNORECASE), re.compile("I only listen", re.IGNORECASE), re.compile("Error!", re.IGNORECASE), re.compile("Unknown command input", re.IGNORECASE), re.compile("You have been auto invited", re.IGNORECASE), ] def inject(self, registry): self.db = registry.get_instance("db") self.util = registry.get_instance("util") self.access_service: AccessService = registry.get_instance( "access_service") self.bot: Tyrbot = registry.get_instance("bot") self.character_service: CharacterService = registry.get_instance( "character_service") self.setting_service: SettingService = registry.get_instance( "setting_service") self.command_alias_service = registry.get_instance( "command_alias_service") self.usage_service = registry.get_instance("usage_service") self.public_channel_service = registry.get_instance( "public_channel_service") self.ban_service = registry.get_instance("ban_service") def pre_start(self): self.bot.add_packet_handler(server_packets.PrivateMessage.id, self.handle_private_message) self.bot.add_packet_handler(server_packets.PrivateChannelMessage.id, self.handle_private_channel_message) self.bot.add_packet_handler(server_packets.PublicChannelMessage.id, self.handle_public_channel_message) self.register_command_channel("Private Message", self.PRIVATE_MESSAGE) self.register_command_channel("Org Channel", self.ORG_CHANNEL) self.register_command_channel("Private Channel", self.PRIVATE_CHANNEL) def start(self): # process decorators for _, inst in Registry.get_all_instances().items(): for name, method in get_attrs(inst).items(): if hasattr(method, "command"): cmd_name, params, access_level, description, help_file, sub_command, extended_description, check_access, aliases = getattr( method, "command") handler = getattr(inst, name) module = self.util.get_module_name(handler) help_text = self.get_help_file(module, help_file) self.register(handler, cmd_name, params, access_level, description, module, help_text, sub_command, extended_description, check_access) if len(inspect.signature( handler).parameters) != len(params) + 1: raise Exception( "Incorrect number of arguments for handler '%s.%s()'" % (handler.__module__, handler.__name__)) if aliases: for alias in aliases: self.command_alias_service.add_alias( alias, cmd_name) def register(self, handler, command, params, access_level, description, module, help_text=None, sub_command=None, extended_description=None, check_access=None): command = command.lower() if sub_command: sub_command = sub_command.lower() else: sub_command = "" access_level = access_level.lower() module = module.lower() command_key = self.get_command_key(command, sub_command) if help_text is None: help_text = self.generate_help(command, description, params, extended_description) if check_access is None: check_access = self.access_service.check_access if not self.access_service.get_access_level_by_label(access_level): self.logger.error( "Could not add command '%s': could not find access level '%s'" % (command, access_level)) return for channel, label in self.channels.items(): row = self.db.query_single( "SELECT access_level, module, enabled, verified " "FROM command_config " "WHERE command = ? AND sub_command = ? AND channel = ?", [command, sub_command, channel]) if row is None: # add new command commands self.db.exec( "INSERT INTO command_config " "(command, sub_command, access_level, channel, module, enabled, verified) " "VALUES (?, ?, ?, ?, ?, 1, 1)", [command, sub_command, access_level, channel, module]) elif row.verified: if row.module != module: self.logger.warning( "module different for different forms of command '%s' and sub_command '%s'" % (command, sub_command)) else: # mark command as verified self.db.exec( "UPDATE command_config SET verified = 1, module = ? " "WHERE command = ? AND sub_command = ? AND channel = ?", [module, command, sub_command, channel]) # save reference to command handler r = re.compile(self.get_regex_from_params(params), re.IGNORECASE | re.DOTALL) self.handlers[command_key].append({ "regex": r, "callback": handler, "help": help_text, "description": description, "params": params, "check_access": check_access }) def register_command_channel(self, label, value): if value in self.channels: self.logger.error( "Could not register command channel '%s': command channel already registered" % value) return self.logger.debug("Registering command channel '%s'" % value) self.channels[value] = label def is_command_channel(self, channel): return channel in self.channels def process_command(self, message: str, channel: str, char_id, reply): try: if self.ban_service.get_ban(char_id): # do nothing if character is banned self.logger.info( "ignored banned character %d for command '%s'" % (char_id, message)) return message = html.unescape(message) command_str, command_args = self.get_command_parts(message) # check for command alias command_alias = self.command_alias_service.check_for_alias( command_str) if command_alias: command_str, command_args = self.get_command_parts( command_alias + command_args) cmd_configs = self.get_command_configs(command_str, channel, 1) if cmd_configs: # given a list of cmd_configs that are enabled, see if one has regex that matches incoming command_str cmd_config, matches, handler = self.get_matches( cmd_configs, command_args) if matches: if handler["check_access"](char_id, cmd_config.access_level): sender = SenderObj( char_id, self.character_service.resolve_char_to_name( char_id, "Unknown(%d)" % char_id)) response = handler["callback"]( CommandRequest(channel, sender, reply), *self.process_matches(matches, handler["params"])) if response is not None: reply(response) # record command usage self.usage_service.add_usage( command_str, handler["callback"].__qualname__, char_id, channel) else: self.access_denied_response(char_id, cmd_config, reply) else: # handlers were found, but no handler regex matched help_text = self.get_help_text(char_id, command_str, channel) if help_text: reply(self.format_help_text(command_str, help_text)) else: reply("Error! Invalid syntax.") else: reply("Error! Unknown command <highlight>%s<end>." % command_str) except Exception as e: self.logger.error("error processing command: %s" % message, e) reply("There was an error processing your request.") def access_denied_response(self, char_id, cmd_config, reply): reply("Error! Access denied.") def get_command_parts(self, message): parts = message.split(" ", 1) if len(parts) == 2: return parts[0].lower(), " " + parts[1] else: return parts[0].lower(), "" def get_command_configs(self, command, channel=None, enabled=1, sub_command=None): sql = "SELECT command, sub_command, access_level, enabled FROM command_config WHERE command = ?" params = [command] if channel: sql += " AND channel = ?" params.append(channel) if enabled: sql += " AND enabled = ?" params.append(enabled) if sub_command: sql += " AND sub_command = ?" params.append(sub_command) sql += " ORDER BY sub_command, channel" return self.db.query(sql, params) def get_matches(self, cmd_configs, command_args): for row in cmd_configs: command_key = self.get_command_key(row.command, row.sub_command) handlers = self.handlers[command_key] for handler in handlers: # add leading space to search string to normalize input for command params matches = handler["regex"].search(command_args) if matches: return row, matches, handler return None, None, None def process_matches(self, matches, params): groups = list(matches.groups()) processed = [] for param in params: processed.append(param.process_matches(groups)) return processed def get_help_text(self, char, command_str, channel): data = self.db.query( "SELECT command, sub_command, access_level FROM command_config " "WHERE command = ? AND channel = ? AND enabled = 1", [command_str, channel]) # filter out commands that character does not have access level for data = filter( lambda row: self.access_service.check_access( char, row.access_level), data) def read_help_text(row): command_key = self.get_command_key(row.command, row.sub_command) return filter( lambda x: x is not None, map(lambda handler: handler["help"], self.handlers[command_key])) content = "\n\n".join(flatmap(read_help_text, data)) return content if content else None def format_help_text(self, topic, help_text): return ChatBlob("Help (" + topic + ")", help_text) def get_help_file(self, module, help_file): if help_file: try: help_file = "./" + module.replace(".", "/") + "/" + help_file with open(help_file) as f: return f.read().strip() except FileNotFoundError as e: self.logger.error("Error reading help file", e) return None def get_command_key(self, command, sub_command): if sub_command: return command + ":" + sub_command else: return command def get_command_key_parts(self, command_str): parts = command_str.split(":", 1) if len(parts) == 2: return parts[0], parts[1] else: return parts[0], "" def get_regex_from_params(self, params): # params must be wrapped with line-beginning and line-ending anchors in order to match # when no params are specified (eg. "^$") return "^" + "".join(map(lambda x: x.get_regex(), params)) + "$" def generate_help(self, command, description, params, extended_description=None): help_text = description + ":\n" + "<tab><symbol>" + command + " " + " ".join( map(lambda x: x.get_name(), params)) if extended_description: help_text += "\n" + extended_description return help_text def get_handlers(self, command_key): return self.handlers.get(command_key, None) def handle_private_message(self, packet: server_packets.PrivateMessage): # since the command symbol is not required for private messages, # the command_str must have length of at least 1 in order to be valid, # otherwise it is ignored if len(packet.message) < 1: return for regex in self.ignore_regexes: if regex.search(packet.message): return if packet.message[:1] == self.setting_service.get( "symbol").get_value(): command_str = packet.message[1:] else: command_str = packet.message self.process_command( command_str, self.PRIVATE_MESSAGE, packet.char_id, lambda msg: self.bot.send_private_message(packet.char_id, msg)) def handle_private_channel_message( self, packet: server_packets.PrivateChannelMessage): # since the command symbol is required in the private channel, # the command_str must have length of at least 2 in order to be valid, # otherwise it is ignored if len(packet.message) < 2: return symbol = packet.message[:1] command_str = packet.message[1:] if symbol == self.setting_service.get("symbol").get_value( ) and packet.private_channel_id == self.bot.char_id: self.process_command( command_str, self.PRIVATE_CHANNEL, packet.char_id, lambda msg: self.bot.send_private_channel_message(msg)) def handle_public_channel_message( self, packet: server_packets.PublicChannelMessage): # since the command symbol is required in the org channel, # the command_str must have length of at least 2 in order to be valid, # otherwise it is ignored if len(packet.message) < 2: return symbol = packet.message[:1] command_str = packet.message[1:] if symbol == self.setting_service.get("symbol").get_value( ) and self.public_channel_service.is_org_channel_id(packet.channel_id): self.process_command(command_str, self.ORG_CHANNEL, packet.char_id, lambda msg: self.bot.send_org_message(msg))
class MessageHubService: def __init__(self): self.logger = Logger(__name__) self.hub = {} self.sources = [] def inject(self, registry): self.bot = registry.get_instance("bot") self.setting_service = registry.get_instance("setting_service") self.character_service: CharacterService = registry.get_instance("character_service") self.text: Text = registry.get_instance("text") self.db = registry.get_instance("db") def start(self): self.db.exec("CREATE TABLE IF NOT EXISTS message_hub_subscriptions ( " "destination VARCHAR(50) NOT NULL," "source VARCHAR(50) NOT NULL" ")") def register_message_source(self, source): """Call during pre_start""" if source not in self.sources: self.sources.append(source) def register_message_destination(self, destination, callback, default_sources, invalid_sources=[]): """ Call during start Args: destination: str callback: (ctx) -> void default_sources: [str...] invalid_sources: [str...] """ if len(inspect.signature(callback).parameters) != 1: raise Exception("Incorrect number of arguments for handler '%s.%s()'" % (callback.__module__, callback.__name__)) if destination in self.hub: raise Exception("Message hub destination '%s' already subscribed" % destination) for source in default_sources: if source not in self.sources: self.logger.warning("Could not subscribe destination '%s' to source '%s' because source does not exist" % (destination, source)) # raise Exception("Could not subscribe destination '%s' to source '%s' because source does not exist" % (destination, source)) self.hub[destination] = (DictObject({"name": destination, "callback": callback, "sources": default_sources, "invalid_sources": invalid_sources})) self.reload_mapping(destination) def reload_mapping(self, destination): data = self.db.query("SELECT source FROM message_hub_subscriptions WHERE destination = ?", [destination]) if data: self.hub[destination].sources = list(map(lambda x: x.source, data)) def send_message(self, source, sender, channel_prefix, message): ctx = MessageHubContext(source, sender, channel_prefix, message, self.get_formatted_message(channel_prefix, sender, message)) for _, c in self.hub.items(): if source in c.sources: try: c.callback(ctx) except Exception as e: self.logger.error("", e) def subscribe_to_source(self, destination, source): if source not in self.sources: raise Exception("Message hub source '%s' doeselecs not exist" % source) obj = self.hub.get(destination, None) if not obj: raise Exception("Message hub destination '%s' does not exist" % destination) if source not in obj.sources: self.db.exec("DELETE FROM message_hub_subscriptions WHERE destination = ?", [destination]) obj.sources.append(source) for source in obj.sources: self.db.exec("INSERT INTO message_hub_subscriptions (destination, source)" "VALUES (?, ?)", [destination, source]) def unsubscribe_from_source(self, destination, source): # if source not in self.sources: # raise Exception("Message hub source '%s' does not exist" % source) obj = self.hub.get(destination, None) if not obj: raise Exception("Message hub destination '%s' does not exist" % destination) if source in obj.sources: self.db.exec("DELETE FROM message_hub_subscriptions WHERE destination = ?", [destination]) obj.sources.remove(source) for source in obj.sources: self.db.exec("INSERT INTO message_hub_subscriptions (destination, source)" "VALUES (?, ?)", [destination, source]) def get_formatted_message(self, channel_prefix, sender, message): formatted_message = "" if channel_prefix: formatted_message += f"{channel_prefix} " if sender: char_name = self.text.make_charlink(sender.name) formatted_message += f"{char_name}: " # TODO pagination should not happen until destination channel is known if isinstance(message, ChatBlob): message = self.text.paginate_single(message, self.bot.get_primary_conn()) formatted_message += message return formatted_message