def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ database_uri = Config.get("database", "uri") if database_uri.startswith("sqlite"): connectable = create_engine( database_uri, connect_args={"check_same_thread": False}, poolclass=pool.NullPool, ) else: connectable = create_engine(database_uri, poolclass=pool.NullPool) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=database_uri.startswith("sqlite"), ) with context.begin_transaction(): context.run_migrations()
def __init__(self, session: Session, connect: bool = True, is_cycle: bool = False) -> None: self.users: Dict[str, ts3bot.User] = {} self.session = session self.is_cycle = is_cycle if is_cycle: self.client_nick = Config.get("cycle_login", "nickname") self.channel_id = None else: # Register commands self.commands: List[Command] = [] for _ in commands.__commands__: if Config.has_option("commands", _) and not Config.getboolean( "commands", _): LOG.info("Skipping command.%s", _) continue mod = cast(Command, import_module(f"ts3bot.commands.{_}")) mod.REGEX = re.compile(mod.MESSAGE_REGEX) LOG.info("Registered command.%s", _) self.commands.append(mod) # Register translation settings i18n.set("load_path", [str(Path(__file__).parent / "i18n")]) i18n.set("filename_format", "{locale}.json") i18n.set("enable_memoization", True) i18n.set("skip_locale_root_data", True) i18n.set("locale", "de") self.client_nick = Config.get("bot_login", "nickname") self.channel_id = Config.get("teamspeak", "channel_id") self.ts3c: Optional[ts3.query.TS3ServerConnection] = None self.own_id: int = 0 self.own_uid: str = "" if connect: self.connect()
def init_logger(name: str, is_test: bool = False) -> None: if not Path("logs").exists(): Path("logs").mkdir() logger = logging.getLogger() if os.environ.get("ENV", "") == "dev": level = logging.DEBUG else: level = logging.INFO logger.setLevel(level) fmt = logging.Formatter( "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S") # Only write to file outside of tests if not is_test: hldr = logging.handlers.TimedRotatingFileHandler(f"logs/{name}.log", when="W0", encoding="utf-8", backupCount=16) hldr.setFormatter(fmt) logger.addHandler(hldr) stream = logging.StreamHandler(sys.stdout) stream.setFormatter(fmt) stream.setLevel(level) logger.addHandler(stream) sentry_dsn = Config.get("sentry", "dsn") if sentry_dsn: import sentry_sdk # type: ignore from sentry_sdk.integrations.sqlalchemy import ( SqlalchemyIntegration, ) # type: ignore def before_send(event: Any, hint: Any) -> Any: if "exc_info" in hint: _, exc_value, _ = hint["exc_info"] if isinstance(exc_value, KeyboardInterrupt): return None return event sentry_sdk.init( dsn=sentry_dsn, before_send=before_send, release=VERSION, send_default_pii=True, integrations=[SqlalchemyIntegration()], )
def connect(self) -> None: if self.is_cycle: username = Config.get("cycle_login", "username") password = Config.get("cycle_login", "password") else: username = Config.get("bot_login", "username") password = Config.get("bot_login", "password") # Connect to TS3 self.ts3c = ts3.query.TS3ServerConnection( f"{Config.get('teamspeak', 'protocol')}://{username}:{password}@" f"{Config.get('teamspeak', 'hostname')}") # Select server and change nick self.exec_("use", sid=Config.get("teamspeak", "server_id")) current_nick = self.exec_("whoami") if current_nick[0]["client_nickname"] != self.client_nick: self.exec_("clientupdate", client_nickname=self.client_nick) # TODO: Replace clientfind/clientinfo with info from whoami self.own_id = self.exec_("clientfind", pattern=self.client_nick)[0]["clid"] self.own_uid = self.exec_( "clientinfo", clid=self.own_id)[0]["client_unique_identifier"] if not self.is_cycle: # Subscribe to events self.exec_("servernotifyregister", event="channel", id=self.channel_id) self.exec_("servernotifyregister", event="textprivate") self.exec_("servernotifyregister", event="server") # Move to target channel if current_nick[0]["client_channel_id"] != self.channel_id: self.exec_("clientmove", clid=self.own_id, cid=self.channel_id)
def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = Config.get("database", "uri") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations()
def connect(self): if self.is_cycle: username = Config.get("cycle_login", "username") password = Config.get("cycle_login", "password") else: username = Config.get("bot_login", "username") password = Config.get("bot_login", "password") # Connect to TS3 self.ts3c = ts3.query.TS3ServerConnection( "{}://{}:{}@{}".format( Config.get("teamspeak", "protocol"), username, password, Config.get("teamspeak", "hostname"), ) ) # Select server and change nick self.exec_("use", sid=Config.get("teamspeak", "server_id")) current_nick = self.exec_("whoami") if current_nick[0]["client_nickname"] != self.client_nick: self.exec_("clientupdate", client_nickname=self.client_nick) self.own_id: int = self.exec_("clientfind", pattern=self.client_nick)[0]["clid"] self.own_uid = self.exec_("clientinfo", clid=self.own_id)[0][ "client_unique_identifier" ] if not self.is_cycle: # Subscribe to events self.exec_("servernotifyregister", event="channel", id=self.channel_id) self.exec_("servernotifyregister", event="textprivate") self.exec_("servernotifyregister", event="server") # Move to target channel if current_nick[0]["client_channel_id"] != self.channel_id: self.exec_("clientmove", clid=self.own_id, cid=self.channel_id)
help= "Migrates the old database (<2020) to the current version. Uses cycle account", ) sub_migrate.add_argument( "source_database", help="Use a URI of the following schema: mysql+mysqldb://" "<user>:<password>@<host>[:<port>]/<dbname>. " "Requires sqlalchemy[mysql]", ) sub.add_parser("bot", help="Runs the main bot") args = parser.parse_args() session: typing.Optional[Session] = None if args.mode in ["bot", "cycle", "migrate"]: session = create_session(Config.get("database", "uri")) if args.mode == "bot": init_logger("bot") Bot(session).loop() elif args.mode == "cycle": init_logger("cycle") Cycle( session, verify_all=args.all, verify_linked_worlds=args.relink, verify_ts3=args.ts3, verify_world=args.world, ).run() elif args.mode == "migrate": init_logger("migrate")
def handle(bot: Bot, event: events.TextMessage, match: Match) -> None: sheet_channel_id = Config.get("teamspeak", "sheet_channel_id") if sheet_channel_id == 0: return current_state: CommandingDict = { "EBG": [], "Red": [], "Green": [], "Blue": [] } if match.group(1) == "help" and event.uid in Config.whitelist_admin: bot.send_message( event.id, "!sheet <ebg,red,green,blue,remove,reset>\n!sheet set <ebg,red,green,blue,remove> <name>", is_translation=False, ) return if match.group(1) == "reset" and event.uid in Config.whitelist_admin: pass # Don't load the current file, just use the defaults elif match.group(1) == "set" and event.uid in Config.whitelist_admin: # Force-set an entry _match = re.match( "!sheet set (ebg|red|green|blue|r|g|b|remove) (.*)", event.message.strip(), ) if not _match: bot.send_message(event.id, "invalid_input") return if STATE_FILE.exists(): current_state = cast(CommandingDict, json.loads(STATE_FILE.read_text())) if _match.group(1) == "remove": current_state = _remove_lead(current_state, name_field=_match.group(2)) else: # Add new entry new_state = _add_lead( current_state, wvw_map=_match.group(1), note="", name=_match.group(2), ) if not new_state: bot.send_message(event.id, "sheet_map_full") return current_state = new_state elif match.group(1) in [ "ebg", "red", "green", "blue", "r", "g", "b", "remove" ]: if STATE_FILE.exists(): current_state = json.loads(STATE_FILE.read_text()) if match.group(1) == "remove": current_state = _remove_lead(current_state, uid=event.uid) else: new_state = _add_lead( current_state, wvw_map=match.group(1), note=match.group(2), uid=event.uid, name=event.name, ) if not new_state: bot.send_message(event.id, "sheet_map_full") return current_state = new_state else: bot.send_message(event.id, "invalid_input") return # Build new table desc = "[table][tr][td] | Map | [/td][td] | Lead | [/td][td] | Note | [/td][td] | Date | [/td][/tr]" for _map, leads in cast(IterType, current_state.items()): if len(leads) == 0: desc += f"[tr][td]{_map}[/td][td]-[/td][td]-[/td][td]-[/td][/tr]" continue for lead in leads: desc += ( f"[tr][td]{_map}[/td][td]{lead['lead']}[/td][td]{_encode(lead['note'])}[/td]" f"[td]{lead['date']}[/td][/tr]") desc += ( f"[/table]\n[hr]Last change: {_tidy_date()}\n\n" f"Link to bot: [URL=client://0/{bot.own_uid}]{Config.get('bot_login', 'nickname')}[/URL]\n" # Add link to self "Usage:\n" "- !sheet red/green/blue (note)\t—\tRegister your lead with an optional note (20 characters).\n" "- !sheet remove\t—\tRemove the lead") bot.exec_("channeledit", cid=sheet_channel_id, channel_description=desc) bot.send_message(event.id, "sheet_changed") STATE_FILE.write_text(json.dumps(current_state))
help= "Only verifies everyone on a world marked as is_linked, ignores cycle_hours", action="store_true", ) sub_cycle.add_argument( "--ts3", help="Verify everyone known to the TS3 server, this is the default", action="store_true", ) sub_cycle.add_argument("--world", help="Verify world (id)", type=int) sub.add_parser("bot", help="Runs the main bot") args = parser.parse_args() if args.mode == "bot": init_logger("bot") Bot(create_session(Config.get("database", "uri"))).loop() elif args.mode == "cycle": init_logger("cycle") Cycle( create_session(Config.get("database", "uri")), verify_all=args.all, verify_ts3=args.ts3, ).run() else: parser.print_help() # TODO: !help: Respond with appropriate commands # TODO: Wrapper for servergroupaddclient/servergroupdelclient # TODO: API timeout, async rewrite
def sync_groups( bot: ts3_bot.Bot, cldbid: str, account: typing.Optional[ts3bot.database.models.Account], remove_all=False, skip_whitelisted=False, ) -> SyncGroupChanges: def sg_dict(_id, _name): return {"sgid": _id, "name": _name} def _add_group(group: ServerGroup): """ Adds a user to a group if necessary, updates `server_group_ids`. :param group: :return: """ if int(group["sgid"]) in server_group_ids: return False try: bot.exec_("servergroupaddclient", sgid=str(group["sgid"]), cldbid=cldbid) logging.info("Added user dbid:%s to group %s", cldbid, group["name"]) server_group_ids.append(int(group["sgid"])) group_changes["added"].append(group["name"]) return True except ts3.TS3Error: # User most likely doesn't have the group logging.exception( "Failed to add cldbid:%s to group %s for some reason.", cldbid, group["name"], ) def _remove_group(group: ServerGroup): """ Removes a user from a group if necessary, updates `server_group_ids`. :param group: :return: """ if int(group["sgid"]) in server_group_ids: try: bot.exec_("servergroupdelclient", sgid=str(group["sgid"]), cldbid=cldbid) logging.info("Removed user dbid:%s from group %s", cldbid, group["name"]) server_group_ids.remove(int(group["sgid"])) group_changes["removed"].append(group["name"]) return True except ts3.TS3Error: # User most likely doesn't have the group logging.exception( "Failed to remove cldbid:%s from group %s for some reason.", cldbid, group["name"], ) return False server_groups = bot.exec_("servergroupsbyclientid", cldbid=cldbid) server_group_ids = [int(_["sgid"]) for _ in server_groups] group_changes: SyncGroupChanges = {"removed": [], "added": []} # Get groups the user is allowed to have if account and account.is_valid and not remove_all: valid_guild_groups: typing.List[ ts3bot.database.models.LinkAccountGuild] = account.guild_groups() valid_world_group: typing.Optional[ ts3bot.database.models.WorldGroup] = account.world_group( bot.session) else: valid_guild_groups = [] valid_world_group = None valid_guild_group_ids = [g.guild.group_id for g in valid_guild_groups] valid_guild_mapper = {g.guild.group_id: g for g in valid_guild_groups} # Get all valid groups world_groups: typing.List[int] = [ _.group_id for _ in bot.session.query(ts3bot.database.models.WorldGroup).options( load_only(ts3bot.database.models.WorldGroup.group_id)) ] guild_groups: typing.List[int] = [ _.group_id for _ in bot.session.query(ts3bot.database.models.Guild).filter( ts3bot.database.models.Guild.group_id.isnot(None)).options( load_only(ts3bot.database.models.Guild.group_id)) ] generic_world = { "sgid": int(Config.get("teamspeak", "generic_world_id")), "name": "Generic World", } generic_guild = { "sgid": int(Config.get("teamspeak", "generic_guild_id")), "name": "Generic Guild", } # Remove user from all other known invalid groups invalid_groups = [] for server_group in server_groups: sgid = int(server_group["sgid"]) # Skip known valid groups if (server_group["name"] == "Guest" or sgid == generic_world or sgid == generic_guild or (len(valid_guild_groups) > 0 and sgid in valid_guild_group_ids) or (valid_world_group and sgid == valid_world_group.group_id)): continue # Skip users with whitelisted group if skip_whitelisted and server_group.get( "name") in Config.whitelist_groups: logging.info( "Skipping cldbid:%s due to whitelisted group: %s", cldbid, server_group.get("name"), ) return group_changes # Skip unknown groups if sgid not in guild_groups and sgid not in world_groups: continue invalid_groups.append(server_group) for server_group in invalid_groups: _remove_group(server_group) # User has additional guild groups but shouldn't if len(valid_guild_group_ids) == 0: for _group in Config.additional_guild_groups: for server_group in server_groups: if server_group["name"] == _group: _remove_group(server_group) break # User is missing generic guild if len(valid_guild_group_ids ) > 0 and generic_guild["sgid"] not in server_group_ids: _add_group(generic_guild) # User has generic guild but shouldn't if len(valid_guild_group_ids ) == 0 and generic_guild["sgid"] in server_group_ids: _remove_group(generic_guild) # Sync guild groups left_guilds = [ gid for gid in server_group_ids if gid in guild_groups and gid not in valid_guild_group_ids ] # Guilds that shouldn't be applied to the user joined_guilds = [ gid for gid in valid_guild_group_ids if gid not in server_group_ids ] # Guild that are missing in the user's group list # Remove guilds that should not be applied if len(guild_groups) > 0: for group_id in left_guilds: _remove_group( sg_dict(group_id, valid_guild_mapper[group_id].guild.name)) # Join guilds if len(valid_guild_group_ids) > 0: for group_id in joined_guilds: _add_group( sg_dict(group_id, valid_guild_mapper[group_id].guild.name)) # User is missing generic world if (valid_world_group and valid_world_group.is_linked and generic_world["sgid"] not in server_group_ids and len(valid_guild_group_ids) == 0): _add_group(generic_world) # User has generic world but shouldn't if generic_world["sgid"] in server_group_ids and ( not valid_world_group or not valid_world_group.is_linked or len(valid_guild_group_ids) > 0): _remove_group(generic_world) # User is missing home world if valid_world_group and valid_world_group.group_id not in server_group_ids: _add_group( sg_dict(valid_world_group.group_id, valid_world_group.world.proper_name)) return group_changes
def verify_user( self, client_unique_id: str, client_database_id: str, client_id: str ) -> bool: """ Verify a user if they are in a known group, otherwise nothing is done. Groups are revoked/updated if necessary :param client_unique_id: The client's UUID :param client_database_id: The database ID :param client_id: The client's temporary ID during the session :return: True if the user has/had a known group and False if the user is new """ def revoked(response: str): if account: account.invalidate(self.session) changes = ts3bot.sync_groups( self, client_database_id, account, remove_all=True ) reason = "unknown reason" if response == "groups_revoked_missing_key": reason = "missing API key" elif response == "groups_revoked_invalid_key": reason = "invalid API key" logging.info( "Revoked user's (cldbid:%s) groups (%s) due to %s.", client_database_id, changes["removed"], reason, ) self.send_message(client_id, response) # Get all current groups server_groups = self.exec_("servergroupsbyclientid", cldbid=client_database_id) known_groups: typing.List[int] = ( [ _.group_id for _ in self.session.query(ts3bot.database.models.WorldGroup).options( load_only(ts3bot.database.models.WorldGroup.group_id) ) ] + [ _.group_id for _ in self.session.query(ts3bot.database.models.Guild) .filter(ts3bot.database.models.Guild.group_id.isnot(None)) .options(load_only(ts3bot.database.models.Guild.group_id)) ] + [ int(Config.get("teamspeak", "generic_world_id")), int(Config.get("teamspeak", "generic_guild_id")), ] ) # Check if user has any known groups has_group = False has_skip_group = False for server_group in server_groups: if int(server_group.get("sgid", -1)) in known_groups: has_group = True if server_group.get("name") in Config.whitelist_groups: has_skip_group = True # Skip users without any known groups if not has_group: return False # Skip users in whitelisted groups that should be ignored like # guests, music bots, etc if has_skip_group: return True # Grab user's account info account = models.Account.get_by_identity(self.session, client_unique_id) # User does not exist in DB if not account: revoked("groups_revoked_missing_key") return True # User was checked, don't check again if ts3bot.timedelta_hours( datetime.datetime.today() - account.last_check ) < Config.getfloat("verify", "on_join_hours"): return True logging.debug("Checking %s/%s", account, client_unique_id) try: account.update(self.session) # Sync groups ts3bot.sync_groups(self, client_database_id, account) except ts3bot.InvalidKeyException: revoked("groups_revoked_invalid_key") except ( requests.RequestException, ts3bot.RateLimitException, ts3bot.ApiErrBadData, ): logging.exception("Error during API call") return True
def sync_groups( bot: ts3_bot.Bot, cldbid: str, account: Optional[models.Account], remove_all: bool = False, skip_whitelisted: bool = False, ) -> SyncGroupChanges: def _add_group(group: ServerGroup) -> bool: """ Adds a user to a group if necessary, updates `server_group_ids`. :param group: :return: """ if int(group["sgid"]) in server_group_ids: return False try: bot.exec_("servergroupaddclient", sgid=str(group["sgid"]), cldbid=cldbid) LOG.info("Added user dbid:%s to group %s", cldbid, group["name"]) server_group_ids.append(int(group["sgid"])) group_changes["added"].append(group["name"]) except ts3.TS3Error: # User most likely doesn't have the group LOG.exception( "Failed to add cldbid:%s to group %s for some reason.", cldbid, group["name"], ) return True def _remove_group(group: ServerGroup) -> bool: """ Removes a user from a group if necessary, updates `server_group_ids`. :param group: :return: """ if int(group["sgid"]) in server_group_ids: try: bot.exec_("servergroupdelclient", sgid=str(group["sgid"]), cldbid=cldbid) LOG.info("Removed user dbid:%s from group %s", cldbid, group["name"]) server_group_ids.remove(int(group["sgid"])) group_changes["removed"].append(group["name"]) return True except ts3.TS3Error: # User most likely doesn't have the group LOG.exception( "Failed to remove cldbid:%s from group %s for some reason.", cldbid, group["name"], ) return False server_groups = bot.exec_("servergroupsbyclientid", cldbid=cldbid) server_group_ids = [int(_["sgid"]) for _ in server_groups] group_changes: SyncGroupChanges = {"removed": [], "added": []} # Get groups the user is allowed to have if account and account.is_valid and not remove_all: valid_guild_groups: List[ models.LinkAccountGuild] = account.guild_groups() is_part_of_alliance = account.is_in_alliance() else: valid_guild_groups = [] is_part_of_alliance = False valid_guild_group_ids = cast( List[int], [g.guild.group_id for g in valid_guild_groups]) valid_guild_mapper = {g.guild.group_id: g for g in valid_guild_groups} # Get all valid groups guild_groups: List[int] = [ _.group_id for _ in bot.session.query(models.Guild).filter( models.Guild.group_id.isnot(None)).options( load_only(models.Guild.group_id)) ] generic_alliance_group = ServerGroup( sgid=int(Config.get("teamspeak", "generic_alliance_id")), name="Generic Alliance Group", ) generic_guild = ServerGroup(sgid=int( Config.get("teamspeak", "generic_guild_id")), name="Generic Guild") # Remove user from all other known invalid groups invalid_groups = [] for server_group in server_groups: sgid = int(server_group["sgid"]) # Skip known valid groups if (server_group["name"] == "Guest" or sgid == generic_alliance_group or sgid == generic_guild or (len(valid_guild_groups) > 0 and sgid in valid_guild_group_ids) or sgid == generic_alliance_group["sgid"]): continue # Skip users with whitelisted group if skip_whitelisted and server_group.get( "name") in Config.whitelist_groups: LOG.info( "Skipping cldbid:%s due to whitelisted group: %s", cldbid, server_group.get("name"), ) return group_changes # Skip unknown groups if sgid not in guild_groups: continue invalid_groups.append(server_group) for server_group in invalid_groups: _remove_group(server_group) # User has additional guild groups but shouldn't if len(valid_guild_group_ids) == 0: for _group in Config.additional_guild_groups: for server_group in server_groups: if server_group["name"] == _group: _remove_group(server_group) break # User is missing generic guild if len(valid_guild_group_ids ) > 0 and generic_guild["sgid"] not in server_group_ids: _add_group(generic_guild) # User has generic guild but shouldn't if len(valid_guild_group_ids ) == 0 and generic_guild["sgid"] in server_group_ids: _remove_group(generic_guild) # Sync guild groups left_guilds = [ gid for gid in server_group_ids if gid in guild_groups and gid not in valid_guild_group_ids ] # Guilds that shouldn't be applied to the user joined_guilds = [ gid for gid in valid_guild_group_ids if gid not in server_group_ids ] # Guild that are missing in the user's group list # Remove guilds that should not be applied if len(guild_groups) > 0: for group_id in left_guilds: _remove_group( ServerGroup(sgid=group_id, name=valid_guild_mapper[group_id].guild.name)) # Join guilds if len(valid_guild_group_ids) > 0: for group_id in joined_guilds: _add_group( ServerGroup(sgid=group_id, name=valid_guild_mapper[group_id].guild.name)) # User is missing alliance group if is_part_of_alliance and generic_alliance_group[ "sgid"] not in server_group_ids: _add_group(generic_alliance_group) # User has alliance group but shouldn't if not is_part_of_alliance and generic_alliance_group[ "sgid"] in server_group_ids: _remove_group(generic_alliance_group) return group_changes