diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d43937f..3f42330 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v2.3.3 - uses: actions/setup-python@v2 with: - python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax + python-version: '3.8' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - name: Install flake8 run: pip install flake8 @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@v2.3.3 - uses: actions/setup-python@v2 with: - python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax + python-version: '3.8' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - name: Intall Pylint run: pip install pylint pylint-exit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0435374..7679c90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: - uses: actions/checkout@v2.3.3 - uses: actions/setup-python@v2 with: - python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax + python-version: '3.8' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - name: Install pytest run: pip install pytest diff --git a/.gitignore b/.gitignore index 34c271f..4167d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ venv #Jetbrains IDE Settings .idea *.egg-info + +known_hosts \ No newline at end of file diff --git a/README.md b/README.md index 99b18cc..e3e8c01 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ optional arguments: Config file location ``` +#API +This project has an [OpenAPI Specification](https://swagger.io/specification/), that is available under `http://localhost:10137/v2/api-docs` once the service started. +A corresponding [Swagger UI](https://swagger.io/tools/swagger-ui/) is available under `http://localhost:10137/`. + +There are no api security mechanisms in place. It is assumed, that either a network based access policy will be enforced, or a reverse proxy handles authentication. + # Docker Compose ``` version: "3.8" diff --git a/bot.conf.example b/bot.conf.example index b1331ad..fb6f493 100644 --- a/bot.conf.example +++ b/bot.conf.example @@ -8,15 +8,27 @@ [teamspeak connection settings] +# can be "telnet" or "ssh". Default is "telnet" +protocol = telnet + #can be IP or FQDN host = 127.0.0.1 -# Default port for ServerQuery access. Shouldn't need to change this. +# 10011 = Default port for ServerQuery (telnet) +# 10022 = Default port for ServerQuery (ssh) port = 10011 user = serveradmin passwd = xxxxxxx +# Only for SSH. +# A standard known_hosts file. +# Host keys can be initially queried via 'ssh-keyscan.exe -t ssh-rsa -p $port $host' +# For some reason the above does not work on linux !? +# +# known_hosts_file = ./known_hosts + + ## Pool Settings # Maximum connections in the pool. Should be >= 2 pool_size = 4 diff --git a/bot/TS3Auth.py b/bot/TS3Auth.py index acc6574..0b1d6f7 100644 --- a/bot/TS3Auth.py +++ b/bot/TS3Auth.py @@ -11,19 +11,8 @@ h_auth = '[AuthCheck]' h_char_chk = '[CharacterCheck]' -############################# -# Functions - -""" -def log(msg,silent=False): - if not silent: - print (msg) - sys.stdout.flush() - with open(log_file,"a") as logger: - new_log = "%s %s\n" %(str(datetime.now()),msg) - logger.write(new_log) -""" +############################# # Class for an authentication request from user @@ -39,10 +28,14 @@ def __init__(self, api_key, required_servers, required_level, self.account_details = {} self.world = None self.users_server = None + self.name = None self.id = -1 self.guilds_error = False self.guilds = [] + self.guild_tags = [] + self.guild_names = [] + self.pushCharacterAuth() self.pushAccountAuth() @@ -88,8 +81,7 @@ def getAccountDetails(self): self.guilds_error = False # Players Guild Tags (Seems to order it by oldest guild first) - self.guild_tags = [] - self.guild_names = [] + for guild_id in self.guilds: try: ginfo = gw2api.guild_get(guild_id) diff --git a/bot/TS3Bot.py b/bot/TS3Bot.py index c5efdae..65b2a45 100644 --- a/bot/TS3Bot.py +++ b/bot/TS3Bot.py @@ -1,23 +1,16 @@ #!/usr/bin/python -import datetime # for date strings import logging -import re -import sqlite3 # Database -import threading -import traceback -from typing import List, Tuple, Union -import ts3 -from ts3.query import TS3QueryError - -from bot.command_checker import CommanderChecker from bot.config import Config from bot.db import ThreadSafeDBConnection -from bot.ts import TS3Facade, User -from bot.util import StringShortener -from .TS3Auth import AuthRequest +from bot.ts import TS3Facade +from .audit_service import AuditService +from .commander_service import CommanderService from .connection_pool import ConnectionPool +from .event_looper import EventLooper from .guild_service import GuildService +from .reset_roster_service import ResetRosterService +from .user_service import UserService LOG = logging.getLogger(__name__) @@ -33,476 +26,16 @@ def __init__(self, database: ThreadSafeDBConnection, self._config = config self._database_connection = database - self.guild_service = GuildService(database, self._ts_connection_pool, config) - - admin_data, _ = self._ts_facade.whoami() - self.name = admin_data.get('client_login_name') - self.client_id = admin_data.get('client_id') - - self.nickname = self._ts_facade.force_rename(config.bot_nickname) - - self.verified_group = config.verified_group - self.vgrp_id = self.groupFind(self.verified_group) - - self.c_audit_date = datetime.date.today() - self.commander_checker = CommanderChecker(self, config.poll_group_names) - - @property - def ts_connection_pool(self): - return self._ts_connection_pool - - # Helps find the group ID for a group name - def groupFind(self, group_to_find): - with self._ts_connection_pool.item() as ts_facade: - self.groups_list = ts_facade.servergroup_list() - for group in self.groups_list: - if group.get('name') == group_to_find: - return group.get('sgid') - return -1 - - def clientNeedsVerify(self, unique_client_id): - - with self._ts_connection_pool.item() as ts_facade: - client_db_id = ts_facade.client_db_id_from_uid(unique_client_id) - - # Check if user is in verified group - if any(perm_grp.get('name') == self.verified_group for perm_grp in ts_facade.servergroup_list_by_client(client_db_id)): - return False # User already verified - - # Check if user is authenticated in database and if so, re-adds them to the group - with self._database_connection.lock: - current_entries = self._database_connection.cursor.execute("SELECT * FROM users WHERE ts_db_id=?", (unique_client_id,)).fetchall() - if len(current_entries) > 0: - self.setPermissions(unique_client_id) - return False - - return True # User not verified - - def setPermissions(self, unique_client_id): - try: - # Add user to group - with self._ts_connection_pool.item() as facade: - client_db_id = facade.client_db_id_from_uid(unique_client_id) - LOG.debug("Adding Permissions: CLUID [%s] SGID: %s CLDBID: %s", unique_client_id, self.vgrp_id, client_db_id) - ex = facade.servergroup_client_add(servergroup_id=self.vgrp_id, client_db_id=client_db_id) - if ex: - LOG.error("Unable to add client to '%s' group. Does the group exist?", self.verified_group) - except ts3.query.TS3QueryError as err: - LOG.error("Setting permissions failed: %s", err) # likely due to bad client id - - def removePermissions(self, unique_client_id): - try: - with self._ts_connection_pool.item() as ts_facade: - client_db_id = ts_facade.client_db_id_from_uid(unique_client_id) - LOG.debug("Removing Permissions: CLUID [%s] SGID: %s CLDBID: %s", unique_client_id, self.vgrp_id, client_db_id) - - # Remove user from group - ex = ts_facade.servergroup_client_del(servergroup_id=self.vgrp_id, client_db_id=client_db_id) - if ex: - LOG.error("Unable to remove client from '%s' group. Does the group exist and are they member of the group?", self.verified_group) - # Remove users from all groups, except the whitelisted ones - if self._config.purge_completely: - # FIXME: remove channel groups as well - assigned_groups = ts_facade.servergroup_list_by_client(client_db_id) - if assigned_groups is not None: - for g in assigned_groups: - if g.get("name") not in self._config.purge_whitelist: - ts_facade.servergroup_client_del(servergroup_id=g.get("sgid"), client_db_id=client_db_id) - except TS3QueryError as err: - LOG.error("Removing permissions failed: %s", err) # likely due to bad client id + self.user_service = UserService(self._database_connection, self._ts_connection_pool, config) + self.audit_service = AuditService(self._database_connection, self._ts_connection_pool, config, self.user_service) + self.guild_service = GuildService(self._database_connection, self._ts_connection_pool, config) + self.commander_service = CommanderService(self._ts_connection_pool, self.user_service, config) + self.reset_roster_service = ResetRosterService(self._ts_connection_pool, config) - def removePermissionsByGW2Account(self, gw2account): - with self._database_connection.lock: - tsDbIds = self._database_connection.cursor.execute("SELECT ts_db_id FROM users WHERE account_name = ?", (gw2account,)).fetchall() - for tdi, in tsDbIds: - self.removePermissions(tdi) - LOG.debug("Removed permissions from %s", tdi) - self._database_connection.cursor.execute("DELETE FROM users WHERE account_name = ?", (gw2account,)) - changes = self._database_connection.cursor.execute("SELECT changes()").fetchone()[0] - self._database_connection.conn.commit() - return changes - - def getUserDBEntry(self, client_unique_id): - """ - Retrieves the DB entry for a unique client ID. - Is either a dictionary of database-field-names to values, or None if no such entry was found in the DB. - """ - with self._database_connection.lock: - entry = self._database_connection.cursor.execute("SELECT * FROM users WHERE ts_db_id=?", (client_unique_id,)).fetchall() - if len(entry) < 1: - # user not registered - return None - entry = entry[0] - keys = self._database_connection.cursor.description - assert len(entry) == len(keys) - return dict([(keys[i][0], entry[i]) for i in range(len(entry))]) - - def TsClientLimitReached(self, gw_acct_name): - with self._database_connection.lock: - current_entries = self._database_connection.cursor.execute("SELECT * FROM users WHERE account_name=?", (gw_acct_name,)).fetchall() - return len(current_entries) >= self._config.client_restriction_limit - - def addUserToDB(self, client_unique_id, account_name, api_key, created_date, last_audit_date): - with self._database_connection.lock: - # client_id = self.getActiveTsUserID(client_unique_id) - client_exists = self._database_connection.cursor.execute("SELECT * FROM users WHERE ts_db_id=?", (client_unique_id,)).fetchall() - if len(client_exists) > 1: - LOG.warning("Found multiple database entries for single unique teamspeakid %s.", client_unique_id) - if len(client_exists) != 0: # If client TS database id is in BOT's database. - self._database_connection.cursor.execute("""UPDATE users SET ts_db_id=?, account_name=?, api_key=?, created_date=?, last_audit_date=? WHERE ts_db_id=?""", - (client_unique_id, account_name, api_key, created_date, last_audit_date, client_unique_id)) - LOG.info("Teamspeak ID %s already in Database updating with new Account Name '%s'. (likely permissions changed by a Teamspeak Admin)", client_unique_id, account_name) - else: - self._database_connection.cursor.execute("INSERT INTO users ( ts_db_id, account_name, api_key, created_date, last_audit_date) VALUES(?,?,?,?,?)", - (client_unique_id, account_name, api_key, created_date, last_audit_date)) - self._database_connection.conn.commit() - - def removeUserFromDB(self, client_db_id): - with self._database_connection.lock: - self._database_connection.cursor.execute("DELETE FROM users WHERE ts_db_id=?", (client_db_id,)) - self._database_connection.conn.commit() - - # def updateGuildTags(self, client_db_id, auth): - def updateGuildTags(self, ts_facade, user, auth): - if auth.guilds_error: - LOG.error("Did not update guild groups for player '%s', as loading the guild groups caused an error.", auth.name) - return - uid = user.unique_id # self.getTsUniqueID(client_db_id) - client_db_id = user.ts_db_id - ts_groups = {sg.get("name"): sg.get("sgid") for sg in ts_facade.servergroup_list()} - ingame_member_of = set(auth.guild_names) - # names of all groups the user is in, not just guild ones - current_group_names = [] - try: - current_group_names = [g.get("name") for g in - ts_facade.servergroup_list_by_client(client_db_id=client_db_id)] - except TypeError: - # user had no groups (results in None, instead of an empty list) -> just stick with the [] - pass - - # data of all guild groups the user is in - param = ",".join(["'%s'" % (cgn.replace('"', '\\"').replace("'", "\\'"),) for cgn in current_group_names]) - # sanitisation is restricted to replacing single and double quotes. This should not be that much of a problem, since - # the input for the parameters here are the names of our own server groups on our TS server. - current_guild_groups = [] - hidden_groups = {} - with self._database_connection.lock: - current_guild_groups = self._database_connection.cursor.execute("SELECT ts_group, guild_name FROM guilds WHERE ts_group IN (%s)" % (param,)).fetchall() - # groups the user doesn't want to wear - hidden_groups = set( - [g[0] for g in self._database_connection.cursor.execute("SELECT g.ts_group FROM guild_ignores AS gi JOIN guilds AS g ON gi.guild_id = g.guild_id WHERE ts_db_id = ?", (uid,))]) - # REMOVE STALE GROUPS - for ggroup, gname in current_guild_groups: - if ggroup in hidden_groups: - LOG.info("Player %s chose to hide group '%s', which is now removed.", auth.name, ggroup) - ts_facade.servergroup_client_del(servergroup_id=ts_groups[ggroup], client_db_id=client_db_id) - elif gname not in ingame_member_of: - if ggroup not in ts_groups: - LOG.warning( - "Player %s should be removed from the TS group '%s' because they are not a member of guild '%s'." - " But no matching group exists." - " You should remove the entry for this guild from the db or check the spelling of the TS group in the DB. Skipping.", - ggroup, auth.name, gname) - else: - LOG.info("Player %s is no longer part of the guild '%s'. Removing attached group '%s'.", auth.name, gname, ggroup) - ts_facade.servergroup_client_del(servergroup_id=ts_groups[ggroup], client_db_id=client_db_id) - - # ADD DUE GROUPS - for g in ingame_member_of: - ts_group = None - with self._database_connection.lock: - ts_group = self._database_connection.cursor.execute("SELECT ts_group FROM guilds WHERE guild_name = ?", (g,)).fetchone() - if ts_group: - ts_group = ts_group[0] # first and only column, if a row exists - if ts_group not in current_group_names: - if ts_group in hidden_groups: - LOG.info("Player %s is entitled to TS group '%s', but chose to hide it. Skipping.", auth.name, ts_group) - else: - if ts_group not in ts_groups: - LOG.warning( - "Player %s should be assigned the TS group '%s' because they are member of guild '%s'." - " But the group does not exist. You should remove the entry for this guild from the db or create the group." - " Skipping.", - auth.name, ts_group, g) - else: - LOG.info("Player %s is member of guild '%s' and will be assigned the TS group '%s'.", auth.name, g, ts_group) - ts_facade.servergroup_client_add(servergroup_id=ts_groups[ts_group], client_db_id=client_db_id) + def listen_for_events(self): + active_loop = EventLooper(self._database_connection, self._ts_connection_pool, self._config, self.user_service, self.audit_service) + active_loop.start() + del active_loop def trigger_user_audit(self): - LOG.info("Auditing users") - threading.Thread(target=self._audit_users).start() - - def _audit_users(self): - self.c_audit_date = datetime.date.today() # Update current date everytime run - self.db_audit_list = [] - with self._database_connection.lock: - self.db_audit_list = self._database_connection.cursor.execute('SELECT * FROM users').fetchall() - for audit_user in self.db_audit_list: - - # Convert to single variables - audit_ts_id = audit_user[0] - audit_account_name = audit_user[1] - audit_api_key = audit_user[2] - # audit_created_date = audit_user[3] - audit_last_audit_date = audit_user[4] - - LOG.debug("Audit: User %s", audit_account_name) - LOG.debug("TODAY |%s| NEXT AUDIT |%s|", self.c_audit_date, audit_last_audit_date + datetime.timedelta(days=self._config.audit_period)) - - ts_uuid = self._ts_facade.client_db_id_from_uid(audit_ts_id) - if ts_uuid is None: - LOG.info("User %s is not found in TS DB and could be deleted.", audit_account_name) - # self.removeUserFromDB(audit_ts_id) - else: - # compare audit date - if self.c_audit_date >= audit_last_audit_date + datetime.timedelta(days=self._config.audit_period): - LOG.info("User %s is due for auditing!", audit_account_name) - auth = AuthRequest(audit_api_key, self._config.required_servers, int(self._config.required_level), audit_account_name) - if auth.success: - LOG.info("User %s is still on %s. Succesful audit!", audit_account_name, auth.world.get("name")) - # self.getTsDatabaseID(audit_ts_id) - with self._ts_connection_pool.item() as ts_facade: - self.updateGuildTags(ts_facade, User(ts_facade, unique_id=audit_ts_id), auth) - with self._database_connection.lock: - self._database_connection.cursor.execute("UPDATE users SET last_audit_date = ? WHERE ts_db_id= ?", (self.c_audit_date, audit_ts_id,)) - self._database_connection.conn.commit() - else: - LOG.info("User %s is no longer on our server. Removing access....", audit_account_name) - self.removePermissions(audit_ts_id) - self.removeUserFromDB(audit_ts_id) - - with self._database_connection.lock: - self._database_connection.cursor.execute('INSERT INTO bot_info (last_succesful_audit) VALUES (?)', (self.c_audit_date,)) - self._database_connection.conn.commit() - - def broadcastMessage(self): - broadcast_message = self._config.locale.get("bot_msg_broadcast") - self._ts_facade.send_text_message_to_current_channel(msg=broadcast_message) - - def loginEventHandler(self, event): - # raw_sgroups = event.parsed[0].get('client_servergroups') - client_type: int = int(event.parsed[0].get('client_type')) - raw_clid = event.parsed[0].get('clid') - raw_cluid = event.parsed[0].get('client_unique_identifier') - - if client_type == 1: # serverquery client, no need to send message or verify - return - - if raw_clid == self.client_id: - return - - if self.clientNeedsVerify(raw_cluid): - self._ts_facade.send_text_message_to_client(raw_clid, self._config.locale.get("bot_msg_verify")) - - def command_check(self, command_string) -> Union[Tuple[str, List[str]], Tuple[None, None]]: - for allowed_cmd in self._config.cmd_list: - if re.match(r'(^%s)\s*' % (allowed_cmd,), command_string): - toks = command_string.split() # no argument for split() splits on arbitrary whitespace - return toks[0], toks[1:] - return None, None - - def try_get(self, dictionary, key, lower=False, typer=lambda x: x, default=None): - v = typer(dictionary[key] if key in dictionary else default) - return v.lower() if lower and isinstance(v, str) else v - - def setResetroster(self, date, red=[], green=[], blue=[], ebg=[]): - leads = ([], red, green, blue, ebg) # keep RGB order! EBG as last! Pad first slot (header) with empty list - - with self._ts_connection_pool.item() as facade: - channels = [(p, c.replace("$DATE", date)) for p, c in self._config.reset_channels] - for i in range(len(channels)): - pattern, clean = channels[i] - lead = leads[i] - - TS3_MAX_SIZE_CHANNEL_NAME = 40 - shortened = StringShortener(TS3_MAX_SIZE_CHANNEL_NAME - len(clean)).shorten(lead) - newname = "%s%s" % (clean, ", ".join(shortened)) - - channel = facade.channel_find(pattern) - if channel is None: - LOG.warning("No channel found with pattern '%s'. Skipping.", pattern) - return - - _, ts3qe = facade.channel_edit(channel_id=channel.channel_id, new_channel_name=newname) - if ts3qe is not None and ts3qe.resp.error["id"] == "771": - # channel name already in use - # probably not a bug (channel still unused), but can be a config problem - LOG.info("Channel '%s' already exists. This is probably not a problem. Skipping.", newname) - return 0 - - def getActiveCommanders(self): - return self.commander_checker.execute() - - # def clientMessageHandler(self, clientsocket, message): - # mtype = self.try_get(message, "type", lower=True) - # mcommand = self.try_get(message, "command", lower=True) - # margs = self.try_get(message, "args", typer=lambda a: dict(a), default={}) - # mid = self.try_get(message, "message_id", typer=lambda a: int(a), default=-1) - - # LOG.debug("[%s] %s", mtype, mcommand) - - # if mtype == "post": - # # POST commands - # if mcommand == "setresetroster": - # mdate = self.try_get(margs, "date", default="dd.mm.yyyy") - # mred = self.try_get(margs, "rbl", default=[]) - # mgreen = self.try_get(margs, "gbl", default=[]) - # mblue = self.try_get(margs, "bbl", default=[]) - # mebg = self.try_get(margs, "ebg", default=[]) - # self.setResetroster(mdate, mred, mgreen, mblue, mebg) - # if mcommand == "createguild": - # mname = self.try_get(margs, "name", default=None) - # mtag = self.try_get(margs, "tag", default=None) - # mgroupname = self.try_get(margs, "tsgroup", default=mtag) - # mcontacts = self.try_get(margs, "contacts", default=[]) - # res = -1 if mname is None or mtag is None else self.createGuild(mname, mtag, mgroupname, mcontacts) - # clientsocket.respond(mid, mcommand, {"status": res}) - - # if mtype == "delete": - # # DELETE commands - # if mcommand == "user": - # mgw2account = self.try_get(margs, "gw2account", default="") - # LOG.info("Received request to delete user '%s' from the TS registration database.", mgw2account) - # changes = self.removePermissionsByGW2Account(mgw2account) - # clientsocket.respond(mid, mcommand, {"deleted": changes}) - # if mcommand == "guild": - # mname = self.try_get(margs, "name", default=None) - # LOG.info("Received request to delete guild %s", mname) - # res = self.removeGuild(mname) - # print(res) - # clientsocket.respond(mid, mcommand, {"status": res}) - - # Handler that is used every time an event (message) is received from teamspeak server - def messageEventHandler(self, event): - """ - *event* is a ts3.response.TS3Event instance, that contains the name - of the event and the data. - """ - LOG.debug("event.event: %s", event.event) - - message = event.parsed[0].get('msg') - rec_from_name = event.parsed[0].get('invokername').encode('utf-8') # fix any encoding issues introduced by Teamspeak - rec_from_uid = event.parsed[0].get('invokeruid') - rec_from_id = event.parsed[0].get('invokerid') - rec_type = event.parsed[0].get('targetmode') - - if rec_from_id == self.client_id: - return # ignore our own messages. - try: - # Type 2 means it was channel text - if rec_type == "2": - LOG.info("Received Text Channel Message from %s (%s) : %s", rec_from_name, rec_from_uid, message) - cmd, args = self.command_check(message) # sanitize the commands but also restricts commands to a list of known allowed commands - if cmd is not None: - if cmd == "ping": - LOG.info("Ping received from '%s'!", rec_from_name) - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_pong_response")) - if cmd == "hideguild": - if len(args) == 1: - LOG.info("User '%s' wants to hide guild '%s'.", rec_from_name, args[0]) - with self._database_connection.lock: - try: - tag_to_hide = args[0] - result = self._database_connection.cursor.execute("SELECT guild_id FROM guilds WHERE ts_group = ?", (tag_to_hide,)).fetchone() - if result is None: - LOG.debug("Failed. The group probably doesn't exist or the user is already hiding that group.") - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_unknown")) - else: - guild_db_id = result[0] - self._database_connection.cursor.execute( - "INSERT INTO guild_ignores(guild_id, ts_db_id, ts_name) VALUES(?, ?, ?)", - (guild_db_id, rec_from_uid, rec_from_name)) - self._database_connection.conn.commit() - LOG.debug("Success!") - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_success")) - except sqlite3.IntegrityError as ex: - self._database_connection.conn.rollback() - LOG.error("Database error during hideguild", exc_info=ex) - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_unknown")) - else: - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_help")) - elif cmd == "unhideguild": - if len(args) == 1: - LOG.info("User '%s' wants to unhide guild '%s'.", rec_from_name, args[0]) - with self._database_connection.lock: - self._database_connection.cursor.execute( - "DELETE FROM guild_ignores WHERE guild_id = (SELECT guild_id FROM guilds WHERE ts_group = ? AND ts_db_id = ?)", - (args[0], rec_from_uid)) - changes = self._database_connection.cursor.execute("SELECT changes()").fetchone()[0] - self._database_connection.conn.commit() - if changes > 0: - LOG.debug("Success!") - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_unhide_guild_success")) - else: - LOG.debug("Failed. Either the guild is unknown or the user had not hidden the guild anyway.") - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_unhide_guild_unknown")) - else: - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_unhide_guild_help")) - elif cmd == 'verifyme': - return # command disabled for now - # if self.clientNeedsVerify(rec_from_uid): - # log.info("Verify Request Recieved from user '%s'. Sending PM now...\n ...waiting for user response.", rec_from_name) - # self.ts_connection.ts3exec(lambda tsc: tsc.exec_("sendtextmessage", targetmode = 1, target = rec_from_id, msg = Config.locale.get("bot_msg_verify"))) - # else: - # log.info("Verify Request Recieved from user '%s'. Already verified, notified user.", rec_from_name) - # self.ts_connection.ts3exec(lambda tsc: tsc.exec_("sendtextmessage", targetmode = 1, target = rec_from_id, msg = Config.locale.get("bot_msg_alrdy_verified"))) - - # Type 1 means it was a private message - elif rec_type == '1': - LOG.info("Received Private Chat Message from %s (%s) : %s", rec_from_name, rec_from_uid, message) - - # reg_api_auth='\s*(\S+\s*\S+\.\d+)\s+(.*?-.*?-.*?-.*?-.*)\s*$' - reg_api_auth = r'\s*(.*?-.*?-.*?-.*?-.*)\s*$' - - # Command for verifying authentication - if re.match(reg_api_auth, message): - pair = re.search(reg_api_auth, message) - uapi = pair.group(1) - - if self.clientNeedsVerify(rec_from_uid): - LOG.info("Received verify response from %s", rec_from_name) - - auth = AuthRequest(uapi, self._config.required_servers, int(self._config.required_level)) - - LOG.debug('Name: |%s| API: |%s|', auth.name, uapi) - - if auth.success: - limit_hit = self.TsClientLimitReached(auth.name) - if self._config.DEBUG: - LOG.debug("Limit hit check: %s", limit_hit) - if not limit_hit: - LOG.info("Setting permissions for %s as verified.", rec_from_name) - - # set permissions - self.setPermissions(rec_from_uid) - - # get todays date - today_date = datetime.date.today() - - # Add user to database so we can query their API key over time to ensure they are still on our server - self.addUserToDB(rec_from_uid, auth.name, uapi, today_date, today_date) - self.updateGuildTags(self._ts_facade, User(self._ts_facade, unique_id=rec_from_uid), auth) - # self.updateGuildTags(rec_from_uid, auth) - LOG.debug("Added user to DB with ID %s", rec_from_uid) - - # notify user they are verified - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_success")) - else: - # client limit is set and hit - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_limit_Hit")) - LOG.info("Received API Auth from %s, but %s has reached the client limit.", rec_from_name, rec_from_name) - else: - # Auth Failed - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_fail")) - else: - LOG.debug("Received API Auth from %s, but %s is already verified. Notified user as such.", rec_from_name, rec_from_name) - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_alrdy_verified")) - else: - self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_rcv_default")) - LOG.info("Received bad response from %s [msg= %s]", rec_from_name, message.encode('utf-8')) - # sys.exit(0) - except Exception as ex: - LOG.error("BOT Event: Something went wrong during message received from teamspeak server. Likely bad user command/message.") - LOG.error(ex) - LOG.error(traceback.format_exc()) - return None + self.audit_service.trigger_user_audit() diff --git a/bot/__init__.py b/bot/__init__.py index d48bbc1..b4046db 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,4 +1,8 @@ from .TS3Bot import Bot +from .audit_service import AuditService +from .commander_service import CommanderService from .guild_service import GuildService +from .reset_roster_service import ResetRosterService +from .user_service import UserService -__all__ = ['GuildService', 'Bot'] +__all__ = ['Bot', 'UserService', 'CommanderService', 'ResetRosterService', 'AuditService', 'GuildService'] diff --git a/bot/__main__.py b/bot/__main__.py index 24fa9c7..bc56b39 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,14 +1,20 @@ import logging -from bot.main import main +from bot.main import main, parse_args from bot.util import initialize_logging LOG = logging.getLogger(__name__) def startup(): - initialize_logging() - main() + config, parser = parse_args() + + initialize_logging(config.logging_file, config.logging_level) + + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug("Config Sources:\n%s", parser.format_values()) + + main(config) if __name__ == '__main__': diff --git a/bot/audit_service.py b/bot/audit_service.py new file mode 100644 index 0000000..1dd49e1 --- /dev/null +++ b/bot/audit_service.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import datetime +import logging +import threading +from dataclasses import dataclass, field +from datetime import date +from queue import PriorityQueue + +from bot.TS3Auth import AuthRequest +from bot.config import Config +from bot.connection_pool import ConnectionPool +from bot.db import ThreadSafeDBConnection +from bot.ts import TS3Facade, User +from .user_service import UserService + +LOG = logging.getLogger(__name__) + +# Queue Priorities, lower entries will be handles before, larger entries. + +QUEUE_PRIORITY_AUDIT = 100 +QUEUE_PRIORITY_JOIN = 20 + + +@dataclass(order=True) +class AuditQueueEntry: + priority: int + account_name: str = field(compare=False) + api_key: str = field(compare=False) + client_unique_id: str = field(compare=False) + + +class AuditService: + def __init__(self, database_connection_pool: ThreadSafeDBConnection, ts_connection_pool: ConnectionPool[TS3Facade], config: Config, user_service: UserService): + self._user_service = user_service + self._database_connection = database_connection_pool + self._ts_connection_pool = ts_connection_pool + self._config = config + + self._audit_queue: PriorityQueue[AuditQueueEntry] = PriorityQueue() # pylint: disable=unsubscriptable-object + self._start_audit_queue_worker() + + def queue_user_audit(self, priority: int, account_name: str, api_key: str, client_unique_id: str): + queue_entry = AuditQueueEntry(priority, account_name=account_name, api_key=api_key, client_unique_id=client_unique_id) + LOG.debug("Adding entry to audit queue for : %s", account_name) + self._audit_queue.put(queue_entry) + + def _start_audit_queue_worker(self): + def worker(): + LOG.info("Audit Worker is ready and pulling audit jobs") + while True: + item = self._audit_queue.get() + LOG.debug('Working on %s. %s still in queue.', item.account_name, self._audit_queue.qsize()) + self.audit_user(item.account_name, item.api_key, item.client_unique_id) + LOG.debug('Finished %s', item.account_name) + self._audit_queue.task_done() + + # Log queue size if over ten + qsize: int = self._audit_queue.qsize() + if qsize >= 10: + LOG.info("Queue Size: %s", qsize) + + # start worker - in theory there could be more than one thread, but this will cause stress on the gw2-api, database and teamspeak + threading.Thread(name="AuditQueueWorker", target=worker, daemon=True).start() + + def audit_user(self, account_name, api_key, client_unique_id): + auth = AuthRequest(api_key, self._config.required_servers, int(self._config.required_level), account_name) + if auth.success: + LOG.info("User %s is still on %s. Successful audit!", account_name, auth.world.get("name")) + with self._ts_connection_pool.item() as ts_facade: + self._user_service.update_guild_tags(ts_facade, User(ts_facade, unique_id=client_unique_id), auth) + with self._database_connection.lock: + self._database_connection.cursor.execute("UPDATE users SET last_audit_date = ? WHERE ts_db_id= ?", ((date.today()), client_unique_id,)) + self._database_connection.conn.commit() + else: + LOG.info("User %s is no longer on our server. Removing access....", account_name) + self._user_service.remove_permissions(client_unique_id) + self._user_service.remove_user_from_db(client_unique_id) + + def trigger_user_audit(self): + LOG.info("Auditing users") + threading.Thread(name="FullAudit", target=self._audit_users, daemon=True).start() + + def _audit_users(self): + current_audit_date = datetime.date.today() # Update current date everytime run + + with self._database_connection.lock: + db_audit_list = self._database_connection.cursor.execute('SELECT * FROM users').fetchall() + for audit_user in db_audit_list: + + # Convert to single variables + audit_ts_id = audit_user[0] + audit_account_name = audit_user[1] + audit_api_key = audit_user[2] + # audit_created_date = audit_user[3] + audit_last_audit_date = audit_user[4] + + LOG.debug("Audit: User %s", audit_account_name) + LOG.debug("TODAY |%s| NEXT AUDIT |%s|", current_audit_date, audit_last_audit_date + datetime.timedelta(days=self._config.audit_period)) + + if current_audit_date >= audit_last_audit_date + datetime.timedelta(days=self._config.audit_period): # compare audit date + with self._ts_connection_pool.item() as ts_connection: + ts_uuid = ts_connection.client_db_id_from_uid(audit_ts_id) + if ts_uuid is None: + LOG.info("User %s is not found in TS DB and could be deleted.", audit_account_name) + self._database_connection.cursor.execute("UPDATE users SET last_audit_date = ? WHERE ts_db_id= ?", ((datetime.date.today()), audit_ts_id,)) + # self.removeUserFromDB(audit_ts_id) + else: + LOG.info("User %s is due for auditing! Queueing", audit_account_name) + self.queue_user_audit(QUEUE_PRIORITY_AUDIT, audit_account_name, audit_api_key, audit_ts_id) + + with self._database_connection.lock: + self._database_connection.cursor.execute('INSERT INTO bot_info (last_succesful_audit) VALUES (?)', (current_audit_date,)) + self._database_connection.conn.commit() + + def audit_user_on_join(self, client_unique_id): + db_entry = self._user_service.get_user_database_entry(client_unique_id) + if db_entry is not None: + account_name = db_entry["account_name"] + api_key = db_entry["api_key"] + self.queue_user_audit(QUEUE_PRIORITY_JOIN, account_name=account_name, api_key=api_key, client_unique_id=client_unique_id) diff --git a/bot/command_checker.py b/bot/commander_service.py similarity index 72% rename from bot/command_checker.py rename to bot/commander_service.py index 812a5f4..64c8e8f 100644 --- a/bot/command_checker.py +++ b/bot/commander_service.py @@ -1,35 +1,40 @@ import logging +from bot.config import Config from bot.ts.user import User +from .connection_pool import ConnectionPool +from .ts import TS3Facade +from .user_service import UserService LOG = logging.getLogger(__name__) -class CommanderChecker: - def __init__(self, ts3bot, commander_group_names): - self._commander_group_names = commander_group_names - self._ts3bot = ts3bot +class CommanderService: + def __init__(self, ts_connection_pool: ConnectionPool[TS3Facade], user_service: UserService, config: Config): + self._commander_group_names = config.poll_group_names + self._ts_connection_pool = ts_connection_pool + self._user_service = user_service - with self._ts3bot.ts_connection_pool.item() as facade: + with self._ts_connection_pool.item() as facade: channel_list, ex = facade.channelgroup_list() - cgroups = list(filter(lambda g: g.get("name") in commander_group_names, channel_list)) + cgroups = list(filter(lambda g: g.get("name") in self._commander_group_names, channel_list)) if len(cgroups) < 1: - LOG.info("Could not find any group of %s to determine commanders by. Disabling this feature.", str(commander_group_names)) + LOG.info("Could not find any group of %s to determine commanders by. Disabling this feature.", str(self._commander_group_names)) self._commander_groups = [] return self._commander_groups = [c.get("cgid") for c in cgroups] - def execute(self): + def get_active_commanders(self): if not self._commander_groups: return # disabled if no groups were found active_commanders = [] - with self._ts3bot.ts_connection_pool.item() as ts_facade: + with self._ts_connection_pool.item() as ts_facade: acs = ts_facade.channelgroup_client_list(self._commander_groups) LOG.info(acs) - active_commanders_entries = [(c, self._ts3bot.getUserDBEntry(ts_facade.client_get_name_from_dbid(client_dbid=c.get("cldbid")).get("cluid"))) for c in acs] + active_commanders_entries = [(c, self._user_service.get_user_database_entry(ts_facade.client_get_name_from_dbid(client_dbid=c.get("cldbid")).get("cluid"))) for c in acs] for ts_entry, db_entry in active_commanders_entries: if db_entry is not None: # or else the user with the commander group was not registered and therefore not in the DB user = User(ts_facade, ts_db_id=ts_entry.get("cldbid")) diff --git a/bot/config/config.py b/bot/config/config.py index 06c5c83..ad8e2ff 100644 --- a/bot/config/config.py +++ b/bot/config/config.py @@ -15,10 +15,12 @@ def __init__(self, config_file_path: str) -> None: configs.read(config_file_path, "utf-8") # Teamspeak Connection Settings + self.protocol = configs.get("teamspeak connection settings", "protocol", fallback="telnet") # telnet or ssh self.host = configs.get("teamspeak connection settings", "host") self.port = configs.get("teamspeak connection settings", "port") self.user = configs.get("teamspeak connection settings", "user") self.passwd = configs.get("teamspeak connection settings", "passwd") + self.known_hosts_file = configs.get("teamspeak connection settings", "known_hosts_file", fallback=None) self.pool_size = self._try_get(configs, "teamspeak connection settings", "pool_size", 4) self.pool_ttl = self._try_get(configs, "teamspeak connection settings", "pool_ttl", 600) @@ -42,7 +44,6 @@ def __init__(self, config_file_path: str) -> None: self.audit_period = int(configs.get("bot settings", "audit_period")) # How long a single user can go without being audited self.audit_interval = int(configs.get("bot settings", "audit_interval")) # how often the BOT audits all users self.client_restriction_limit = int(configs.get("bot settings", "client_restriction_limit")) - self.timer_msg_broadcast = int(configs.get("bot settings", "broadcast_message_timer")) # tryGet(config, section, key, default = None, lit_eval = False): self.purge_completely = self._try_get(configs, "bot settings", "purge_completely", False, True) diff --git a/bot/connection_pool/__init__.py b/bot/connection_pool/__init__.py index c0b2048..25e0f67 100644 --- a/bot/connection_pool/__init__.py +++ b/bot/connection_pool/__init__.py @@ -34,14 +34,14 @@ class Unhealthy(Expired): """Connection was unhealthy""" -T = TypeVar('T') +_T = TypeVar('_T') -class WrapperConnection(ContextManager[T]): +class WrapperConnection(ContextManager[_T]): """Used to package database connections in the connection pool to handle life cycle logic""" - connection: T + connection: _T - def __init__(self, pool, connection: T): + def __init__(self, pool, connection: _T): self.pool = pool self.connection = connection self.usage = 0 @@ -57,14 +57,14 @@ def reset(self): """Reset connection package status""" self.usage = self.last = self.created = 0 - def __enter__(self) -> T: + def __enter__(self) -> _T: return self.connection def __exit__(self, exc_type, exc_value, traceback): self.pool.release(self) -class ConnectionPool(Generic[T]): +class ConnectionPool(Generic[_T]): """Connection pool class, can be used for pymysql/memcache/redis/... 等 It can be called as follows: @@ -81,9 +81,9 @@ class ConnectionPool(Generic[T]): __wrappers = {} def __init__(self, - create: Callable[[], T], - destroy_function: Callable[[T], None] = None, - test_function: Callable[[T], bool] = None, + create: Callable[[], _T], + destroy_function: Callable[[_T], None] = None, + test_function: Callable[[_T], bool] = None, max_size: int = 10, max_usage: int = 0, ttl: int = 0, idle: int = 60, block: bool = True) -> None: @@ -113,7 +113,7 @@ def __init__(self, self._pool = queue.Queue() self._size = 0 - def item(self) -> WrapperConnection[T]: + def item(self) -> WrapperConnection[_T]: """ can be called by with ... as ... syntax pool = ConnectionPool(create=redis.Redis) @@ -165,7 +165,7 @@ def _destroy(self, wrapped): self._unwrapper(wrapped) self._size -= 1 - def _wrapper(self, conn: T) -> WrapperConnection[T]: + def _wrapper(self, conn: _T) -> WrapperConnection[_T]: if isinstance(conn, WrapperConnection): return conn @@ -201,3 +201,16 @@ def _test(self, wrapped): if self._test_function and not self._test_function(wrapped.connection): raise Unhealthy('Connection test determined that the connection is not healthy') + + def close(self): + self._lock.acquire() + + try: + q = self._pool + for items in range(0, self._size): + try: + self._destroy(q.get(timeout=10)) + except queue.Empty: + pass + finally: + self._lock.release() diff --git a/bot/db/database.py b/bot/db/database.py index 94c8d40..ffb2893 100644 --- a/bot/db/database.py +++ b/bot/db/database.py @@ -51,3 +51,6 @@ def __init__(self, db_name): self.conn = sqlite3.connect(db_name, check_same_thread=False, detect_types=sqlite3.PARSE_DECLTYPES) self.cursor = self.conn.cursor() self.lock = RLock() + + def close(self): + self.conn.close() diff --git a/bot/emblem_downloader.py b/bot/emblem_downloader.py index 5d3286f..af20964 100644 --- a/bot/emblem_downloader.py +++ b/bot/emblem_downloader.py @@ -1,7 +1,6 @@ import logging -from typing import Optional, Tuple +from typing import Optional -import binascii import requests LOG = logging.getLogger(__name__) @@ -10,13 +9,12 @@ EMBLEM_STATUS_HEADER = "Emblem-Status" -def download_guild_emblem(guild_id: str, guild_name: str, icon_size: int = 128) -> Tuple[Optional[int], Optional[bytes]]: +def download_guild_emblem(guild_id: str, icon_size: int = 128) -> Optional[bytes]: """ Download a guild emblem from emblem.werdes.net and checks if the response contains a real emblem or a fallback placeholder :param guild_id: ID of the guild as returned from the gw2 api - :param guild_name: Name of the guild, mainly used for the image_iud, which is actually a hash of the guilds name :param icon_size: Optional, size of the image. Anything over 128 is upscaled. - :return: Icon ID, Icon image data or both null + :return:Icon image data or null if not found """ icon_url = f"https://emblem.werdes.net/emblem/{guild_id}/{icon_size}" LOG.debug("Downloading guild emblem from %s", icon_url) @@ -26,11 +24,10 @@ def download_guild_emblem(guild_id: str, guild_name: str, icon_size: int = 128) if EMBLEM_STATUS_HEADER in response.headers: if response.headers[EMBLEM_STATUS_HEADER] == "OK": icon_image_data = response.content - if len(icon_image_data) > 100: # check that response actually contains some data - icon_id = binascii.crc32(guild_name.encode('utf8')) - return icon_id, icon_image_data - else: + if len(icon_image_data) <= 100: # check that response actually contains some data LOG.warning("Very small Response. Guild probably has no icon or an error occured.") + else: + return icon_image_data elif response.headers[EMBLEM_STATUS_HEADER] == "NotFound": LOG.info("No emblem found for guild.") else: @@ -41,4 +38,4 @@ def download_guild_emblem(guild_id: str, guild_name: str, icon_size: int = 128) LOG.warning("Icon download failed, HTTP Status code was: %s", response.status_code) # if anything failed, return None - return None, None + return None diff --git a/bot/event_looper.py b/bot/event_looper.py new file mode 100644 index 0000000..4cc45a8 --- /dev/null +++ b/bot/event_looper.py @@ -0,0 +1,251 @@ +import datetime +import logging +import re +import sqlite3 +import threading +from typing import Optional + +import time +from ts3.response import TS3Event + +from bot.TS3Auth import AuthRequest +from bot.db import ThreadSafeDBConnection +from bot.ts import Channel, TS3Facade, User +from .audit_service import AuditService +from .config import Config +from .connection_pool import ConnectionPool +from .user_service import UserService + +LOG = logging.getLogger(__name__) + + +class EventLooper: + def __init__(self, database_connection: ThreadSafeDBConnection, + ts_connection_pool: ConnectionPool[TS3Facade], + config: Config, + user_service: UserService, + audit_service: AuditService): + self._database_connection = database_connection + self._ts_connection_pool = ts_connection_pool + self._config = config + self._user_service = user_service + self._audit_service = audit_service + + self._lock = threading.RLock() + + self._ts_facade: Optional[TS3Facade] = None + self._own_client_id = None + + def start(self): + with self._lock: # prevent concurrency + with self._ts_connection_pool.item() as ts_facade: + self._ts_facade = ts_facade + + self._set_up_connection() + + # Forces script to loop forever while we wait for events to come in, unless connection timed out or exception occurs. + # Then it should loop a new bot into creation. + LOG.info("BOT now idle, waiting for requests.") + + self._loop_for_events() + + def _loop_for_events(self): + while self._ts_facade is not None and self._ts_facade.is_connected(): + response: TS3Event = self._ts_facade.wait_for_event(timeout=self._config.bot_sleep_idle) + if response is not None: + event_type: str = response.event + event_data = response.parsed[0] + + try: + self._handle_event(event_data, event_type) + except Exception as ex: + LOG.error("Error while handling the event", exc_info=ex) + + def _set_up_connection(self): + LOG.info("Setting up representative Connection: %s ...", self._ts_facade) + self._own_client_id = self._ts_facade.whoami().get('client_id') + self._ts_facade.force_rename(self._config.bot_nickname) + # Find the verify channel + verify_channel = self._find_verify_channel() + # Move ourselves to the Verify channel + self._move_to_channel(verify_channel, self._own_client_id) + # register for text events + self._ts_facade.server_notify_register(["textchannel", "textprivate", "server"]) + LOG.info("Set up representative Connection: %s", self._ts_facade) + + def _handle_event(self, event_data, event_type): + if event_type == 'notifytextmessage': # text message + if "msg" in event_data: + self._handle_message_event(event_data) # handle event + elif event_type == 'notifycliententerview': + if event_data["client_type"] == '0': # no server query client + self._handle_client_login(event_data) # handle event + elif event_type == 'notifyclientleftview': # client left + pass # this event is not of interest + else: + LOG.warning("Unhandled Event: %s", event_type) + + def _move_to_channel(self, channel: Channel, client_id): + chnl_err = self._ts_facade.client_move(client_id=client_id, channel_id=channel.channel_id) + if chnl_err: + LOG.warning("BOT Attempted to join channel '%s' (%s): %s", channel.channel_name, channel.channel_id, chnl_err.resp.error["msg"]) + else: + LOG.info("BOT has joined channel '%s' (%s).", channel.channel_name, channel.channel_id) + + def _find_verify_channel(self): + channel_name = self._config.channel_name + + found_channel = None + while found_channel is None: + found_channel = self._ts_facade.channel_find(channel_name) + if found_channel is None: + LOG.warning("Unable to locate channel with name '%s'. Sleeping for 10 seconds...", channel_name) + time.sleep(10) + else: + return found_channel + + # Handler that is used every time an event (message) is received from teamspeak server + def _handle_message_event(self, event_data): + """ + *event* is a ts3.response.TS3Event instance, that contains the name + of the event and the data. + """ + message = event_data.get('msg') + rec_from_name = event_data.get('invokername').encode('utf-8') # fix any encoding issues introduced by Teamspeak + rec_from_uid = event_data.get('invokeruid') + rec_from_id = event_data.get('invokerid') + rec_type = event_data.get('targetmode') + + if rec_from_id == self._own_client_id: + return # ignore our own messages. + try: + # Type 2 means it was channel text + if rec_type == "2": + LOG.info("Received Text Channel Message from %s (%s) : %s", rec_from_name, rec_from_uid, message) + cmd, args = self._extract_command(message) # sanitize the commands but also restricts commands to a list of known allowed commands + if cmd is not None: + if cmd == "ping": + LOG.info("Ping received from '%s'!", rec_from_name) + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_pong_response")) + if cmd == "hideguild": + if len(args) == 1: + LOG.info("User '%s' wants to hide guild '%s'.", rec_from_name, args[0]) + with self._database_connection.lock: + try: + tag_to_hide = args[0] + result = self._database_connection.cursor.execute("SELECT guild_id FROM guilds WHERE ts_group = ?", (tag_to_hide,)).fetchone() + if result is None: + LOG.debug("Failed. The group probably doesn't exist or the user is already hiding that group.") + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_unknown")) + else: + guild_db_id = result[0] + self._database_connection.cursor.execute( + "INSERT INTO guild_ignores(guild_id, ts_db_id, ts_name) VALUES(?, ?, ?)", + (guild_db_id, rec_from_uid, rec_from_name)) + self._database_connection.conn.commit() + LOG.debug("Success!") + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_success")) + except sqlite3.IntegrityError as ex: + self._database_connection.conn.rollback() + LOG.error("Database error during hideguild", exc_info=ex) + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_unknown")) + else: + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_hide_guild_help")) + elif cmd == "unhideguild": + if len(args) == 1: + LOG.info("User '%s' wants to unhide guild '%s'.", rec_from_name, args[0]) + with self._database_connection.lock: + self._database_connection.cursor.execute( + "DELETE FROM guild_ignores WHERE guild_id = (SELECT guild_id FROM guilds WHERE ts_group = ? AND ts_db_id = ?)", + (args[0], rec_from_uid)) + changes = self._database_connection.cursor.execute("SELECT changes()").fetchone()[0] + self._database_connection.conn.commit() + if changes > 0: + LOG.debug("Success!") + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_unhide_guild_success")) + else: + LOG.debug("Failed. Either the guild is unknown or the user had not hidden the guild anyway.") + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_unhide_guild_unknown")) + else: + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_unhide_guild_help")) + # Type 1 means it was a private message + elif rec_type == '1': + LOG.info("Received Private Chat Message from %s (%s) : %s", rec_from_name, rec_from_uid, message) + + # reg_api_auth='\s*(\S+\s*\S+\.\d+)\s+(.*?-.*?-.*?-.*?-.*)\s*$' + reg_api_auth = r'\s*(.*?-.*?-.*?-.*?-.*)\s*$' + + # Command for verifying authentication + if re.match(reg_api_auth, message): + pair = re.search(reg_api_auth, message) + uapi = pair.group(1) + + if self._user_service.check_client_needs_verify(rec_from_uid): + LOG.info("Received verify response from %s", rec_from_name) + + auth = AuthRequest(uapi, self._config.required_servers, int(self._config.required_level)) + + LOG.debug('Name: |%s| API: |%s|', auth.name, uapi) + + if auth.success: + limit_hit = self._user_service.is_ts_registration_limit_reached(auth.name) + if self._config.DEBUG: + LOG.debug("Limit hit check: %s", limit_hit) + if not limit_hit: + LOG.info("Setting permissions for %s as verified.", rec_from_name) + + # set permissions + self._user_service.set_permissions(rec_from_uid) + + # get todays date + today_date = datetime.date.today() + + # Add user to database so we can query their API key over time to ensure they are still on our server + self._user_service.add_user_to_database(rec_from_uid, auth.name, uapi, today_date, today_date) + self._user_service.update_guild_tags(self._ts_facade, User(self._ts_facade, unique_id=rec_from_uid), auth) + # self.updateGuildTags(rec_from_uid, auth) + LOG.debug("Added user to DB with ID %s", rec_from_uid) + + # notify user they are verified + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_success")) + else: + # client limit is set and hit + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_limit_Hit")) + LOG.info("Received API Auth from %s, but %s has reached the client limit.", rec_from_name, rec_from_name) + else: + # Auth Failed + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_fail")) + else: + LOG.debug("Received API Auth from %s, but %s is already verified. Notified user as such.", rec_from_name, rec_from_name) + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_alrdy_verified")) + else: + self._ts_facade.send_text_message_to_client(rec_from_id, self._config.locale.get("bot_msg_rcv_default")) + LOG.info("Received bad response from %s [msg= %s]", rec_from_name, message.encode('utf-8')) + # sys.exit(0) + except Exception as ex: + LOG.error("BOT Event: Something went wrong during message received from teamspeak server. Likely bad user command/message.", exc_info=ex) + return None + + def _handle_client_login(self, event_data): + # raw_sgroups = event_data.get('client_servergroups') + client_type: int = int(event_data.get('client_type')) + raw_clid = event_data.get('clid') + raw_cluid = event_data.get('client_unique_identifier') + + if client_type == 1: # serverquery client, no need to send message or verify + return + + if raw_clid == self._own_client_id: + return + + if self._user_service.check_client_needs_verify(raw_cluid): + self._ts_facade.send_text_message_to_client(raw_clid, self._config.locale.get("bot_msg_verify")) + else: + self._audit_service.audit_user_on_join(raw_cluid) + + def _extract_command(self, command_string): + for allowed_cmd in self._config.cmd_list: + if re.match(r'(^%s)\s*' % (allowed_cmd,), command_string): + toks = command_string.split() # no argument for split() splits on arbitrary whitespace + return toks[0], toks[1:] + return None, None diff --git a/bot/guild_service.py b/bot/guild_service.py index ff445b6..8bbf6ad 100644 --- a/bot/guild_service.py +++ b/bot/guild_service.py @@ -1,15 +1,21 @@ import logging +import binascii + import bot.gwapi as gw2api from bot.config import Config from bot.connection_pool import ConnectionPool from bot.db import ThreadSafeDBConnection -from bot.emblem_downloader import download_guild_emblem from bot.ts import TS3Facade, User +from .emblem_downloader import download_guild_emblem LOG = logging.getLogger(__name__) +def _generate_guild_icon_id(name) -> int: + return binascii.crc32(name.encode('utf8')) + + class GuildService: def __init__(self, database: ThreadSafeDBConnection, ts_connection_pool: ConnectionPool[TS3Facade], config: Config): self._database = database @@ -100,8 +106,9 @@ def create_guild(self, name, group_name, contacts): LOG.debug("Checks complete.") # Icon uploading - icon_id, icon_content = download_guild_emblem(guild_id, guild_name) # Returns None if no icon - if icon_id is not None and icon_content is not None: + icon_content = download_guild_emblem(guild_id) # Returns None if no icon + if icon_content is not None: + icon_id = _generate_guild_icon_id(guild_name) LOG.info("Uploading icon as '%s'", icon_id) ts_facade.upload_icon(icon_id, icon_content) @@ -109,7 +116,7 @@ def create_guild(self, name, group_name, contacts): # CREATE CHANNEL AND SUBCHANNELS # ################################## LOG.debug("Creating guild channel ...") - channel_list, _ = ts_facade.channel_list() + channel_list = ts_facade.channel_list() # assert channel and group both exist and parent channel is available all_guild_channels = [c for c in channel_list if c.get("pid") == parent.channel_id] @@ -182,8 +189,7 @@ def create_guild(self, name, group_name, contacts): # ADD CONTACTS # ################ LOG.debug("Adding contacts...") - cgroups, _ = ts_facade.channelgroup_list() - contactgroup = next((cg for cg in cgroups if cg.get("name") == self._config.guild_contact_channel_group), None) + contactgroup = self._find_contact_group(ts_facade) if contactgroup is None: LOG.debug("Can not find a group for guild contacts. Skipping.") else: @@ -192,21 +198,26 @@ def create_guild(self, name, group_name, contacts): accs = [row[0] for row in self._database.cursor.execute("SELECT ts_db_id FROM users WHERE lower(account_name) = lower(?)", (c,)).fetchall()] for acc in accs: try: - user = User(ts_facade, unique_id=acc) user = User(ts_facade, unique_id=acc) ex = ts_facade.set_client_channelgroup(channel_id=cinfo.get("cid"), channelgroup_id=contactgroup.get("cgid"), client_db_id=user.ts_db_id) # while we are at it, add the contacts to the guild group as well ts_facade.servergroup_client_add(servergroup_id=guild_servergroup_id, client_db_id=user.ts_db_id) errored = ex is not None - except Exception: - errored = True + except Exception as ex: + errored = ex if errored: LOG.error("Could not assign contact role '%s' to user '%s' with DB-unique-ID '%s' in " "guild channel for %s. Maybe the uid is not valid anymore.", - self._config.guild_contact_channel_group, c, acc, guild_name) + self._config.guild_contact_channel_group, c, acc, guild_name, exc_info=ex) return SUCCESS + def _find_contact_group(self, ts_facade): + cgroups, _ = ts_facade.channelgroup_list() + # check type == 1 to filter out template groups + contactgroup = next((cg for cg in cgroups if cg.get("name") == self._config.guild_contact_channel_group and cg.get("type") == "1"), None) + return contactgroup + def _create_guild_servergroup_permissions(self, icon_id): perms = [ ("b_group_is_permanent", 1), @@ -223,8 +234,8 @@ def _create_guild_servergroup_permissions(self, icon_id): def _sort_guild_groups_using_talk_power(self, groups, ts_facade): with self._database.lock: guildgroups = [g[0] for g in self._database.cursor.execute("SELECT ts_group FROM guilds ORDER BY ts_group").fetchall()] - for i in range(len(guildgroups)): - g = next((g for g in groups if g.get("name") == guildgroups[i]), None) + for i, guild_group in enumerate(guildgroups): + g = next((g for g in groups if g.get("name") == guild_group), None) if g is None: # error! Group deleted from TS, but not from DB! LOG.warning("Found guild '%s' in the database, but no coresponding server group! Skipping this entry, but it should be fixed!", guildgroups[i]) @@ -237,6 +248,10 @@ def _sort_guild_groups_using_talk_power(self, groups, ts_facade): # sort guild groups to have users grouped by their guild tag alphabetically in channels _, _ = ts_facade.servergroup_add_permission(g.get("sgid"), "i_client_talk_power", tp) + # sort guild groups in group list + sort_id = self._config.guilds_sort_id + i + _, _ = ts_facade.servergroup_add_permission(g.get("sgid"), "i_group_sort_id", sort_id) + @staticmethod def _build_channel_name(guild_info) -> str: guild_name = guild_info.get("name") @@ -316,4 +331,8 @@ def remove_guild(self, name): LOG.debug("Deleting group '%s'.", groupname) ts3_facade.servergroup_delete(group.get("sgid"), force=True) + guild_icon_id = _generate_guild_icon_id(guild_name) + + ts3_facade.remove_icon_if_exists(guild_icon_id) + return SUCCESS diff --git a/bot/gwapi/facade.py b/bot/gwapi/facade.py index a136102..66895ef 100644 --- a/bot/gwapi/facade.py +++ b/bot/gwapi/facade.py @@ -1,5 +1,6 @@ from typing import List, Optional +from cachetools import LRUCache, TTLCache, cached from gw2api import GuildWars2Client from .account import Account @@ -32,6 +33,7 @@ # +@cached(cache=TTLCache(maxsize=32, ttl=300)) # cache user specific clients for 5 min - creation takes quite long def _create_client(api_key: str = None) -> GuildWars2Client: return GuildWars2Client(version='v2', api_key=api_key) @@ -49,17 +51,20 @@ class ApiError(RuntimeError): pass +@cached(cache=TTLCache(maxsize=20, ttl=60 * 60)) # cache for 1h def guild_get(guild_id: str) -> Optional[AnonymousGuild]: result = _anonymousClient.guildid.get(guild_id) return _check_error(result) +@cached(cache=TTLCache(maxsize=10, ttl=300)) # cache clients for 5 min - creation takes quite long def guild_get_full(api_key: str, guild_id: str) -> Optional[Guild]: api = _create_client(api_key=api_key) result = api.guildid.get(guild_id) return _check_error(result) +@cached(cache=TTLCache(maxsize=32, ttl=600)) # cache for 10 min def guild_search(guild_name: str) -> Optional[str]: search_result = _anonymousClient.guildsearch.get(name=guild_name) search_result = _check_error(search_result) @@ -70,16 +75,19 @@ def guild_search(guild_name: str) -> Optional[str]: return search_result[0] +@cached(cache=TTLCache(maxsize=32, ttl=300)) # cache clients for 5 min - creation takes quite long def account_get(api_key: str) -> Account: api = _create_client(api_key=api_key) return _check_error(api.account.get()) +@cached(cache=TTLCache(maxsize=32, ttl=300)) # cache clients for 5 min - creation takes quite long def characters_get(api_key: str) -> List[Character]: api = _create_client(api_key=api_key) return _check_error(api.characters.get(page="0", page_size=200)) +@cached(cache=LRUCache(maxsize=10)) def worlds_get_ids() -> List[int]: return _check_error(_anonymousClient.worlds.get(ids=None)) @@ -88,6 +96,7 @@ def worlds_get_by_ids(ids: List[int]) -> List[World]: return _check_error(_anonymousClient.worlds.get(ids=ids)) +@cached(cache=LRUCache(maxsize=10)) def worlds_get_one(world_id: int = None) -> Optional[World]: worlds = worlds_get_by_ids([world_id]) if len(worlds) == 1: diff --git a/bot/main.py b/bot/main.py index 24537a2..e8a75c3 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,40 +1,60 @@ #!/usr/bin/python -import argparse import logging -from typing import Optional +from argparse import Namespace +from typing import Tuple +import configargparse import schedule -import sys import time # time for sleep function import ts3 -from ts3.response import TS3Response from bot import Bot from bot.config import Config from bot.connection_pool import ConnectionPool from bot.db import get_or_create_database -from bot.rest import HTTPServer from bot.rest import server -from bot.ts import Channel, TS3Facade, create_connection +from bot.ts import TS3Facade, create_connection +from bot.util import RepeatTimer LOG = logging.getLogger(__name__) -# Global states -verify_channel: Optional[Channel] = None -http_server: Optional[HTTPServer] = None - -def main(): # - global verify_channel, http_server - - args = parse_args() +def main(args: Namespace): # LOG.info("Initializing script....") config = Config(args.config_path) - database = get_or_create_database(config.db_file_name, config.current_version) + # setup ressoruces + database = get_or_create_database(config.db_file_name, config.current_version) ts_connection_pool: ConnectionPool[TS3Facade] = create_connection_pool(config) + # auditjob trigger + keepalives using the "scheduler" lib + job_thread = _create_job_thread() + job_thread.start() + + # create bot instance and let it loop + _continuous_loop(config, database, ts_connection_pool) + + # release resources gracefully + job_thread.cancel() + + LOG.info("Stopping Connection Pool...") + ts_connection_pool.close() + + LOG.info("Closing Database Connection...") + database.close() + + LOG.info("Bye!") + + +def _create_job_thread(): + job_thread = RepeatTimer(10.0, schedule.run_pending) + job_thread.setDaemon(True) + job_thread.setName("schedule_run_pending") + return job_thread + + +def _continuous_loop(config, database, ts_connection_pool): ####################################### # Begins the connect to Teamspeak ####################################### @@ -47,85 +67,31 @@ def main(): # bot_instance = Bot(database, ts_connection_pool, ts_facade, config) http_server = server.create_http_server(bot_instance, port=config.ipc_port) - http_server.start() - - LOG.info("BOT loaded into server (%s) as %s (%s). Nickname '%s'", config.server_id, bot_instance.name, bot_instance.client_id, bot_instance.nickname) - - # Find the verify channel - verify_channel = find_verify_channel(ts_facade, config.channel_name) - - # Move ourselves to the Verify chanel and register for text events - move_to_channel(ts_facade, verify_channel, bot_instance.client_id, config.channel_name) - - ts_facade.server_notify_register(["textchannel", "textprivate", "server"]) - - # Send message to the server that the BOT is up - # main_ts3conn.exec_("sendtextmessage", targetmode=3, target=server_id, msg=locale.get("bot_msg",(bot_nickname,))) - LOG.info("BOT is now registered to receive messages!") - - LOG.info("BOT Database Audit policies initiating.") - # Always audit users on initialize if user audit date is up (in case the script is reloaded several - # times before audit interval hits, so we can ensure we maintain user database accurately) - bot_instance.trigger_user_audit() - - # Set audit schedule job to run in X days - schedule.every(config.audit_interval).days.do(bot_instance.trigger_user_audit) - - LOG.info("BOT now online,sending broadcast.") - bot_instance.broadcastMessage() # Send initial message into channel - - # debug - # BOT.setResetroster(main_ts3conn, "2020-04-01", - # red = ["the name with the looooong name"], - # green = ["another really well hung name", "len", "oof. tat really a long one duuuude"], - # blue = ["[DUST] dude", "[DUST] anotherone", "[DUST] thecrusty dusty mucky man"], - # ebg = []) - # testguilds = [ - # ("Unleash Chaos", "uC", "uC"), - # ("Requiem of Execution", "RoE", "RoE"), - # ("Zum Henker", "ZH", "ZH"), - # ("Formation Wolke", "Zerg", "Zerg."), - # ("Zergs Rebellion", "Zerg", "Zerg"), - # ("Flussufer Beach Boys", "FBB", "FBB"), - # ("Ups Falsche Taste", "UPS", "UPS"), - # ("Rising River", "Side", "Side"), - # ("Demons of Dawn", "DoD", "DoD") - # ] - - # for gname, gtag, ggroup in testguilds: - # BOT.removeGuild(gname, gtag, ggroup) - # BOT.createGuild(gname, gtag, ggroup, ["len.1879", "jey.1111"]) - - # Forces script to loop forever while we wait for events to come in, unless connection timed out. Then it should loop a new bot into creation. - LOG.info("BOT now idle, waiting for requests.") - while ts_facade.is_connected(): - # auditjob + keepalive check - schedule.run_pending() - event: TS3Response - try: - event = ts_facade.wait_for_event(timeout=config.bot_sleep_idle) - if event: - if "msg" in event.parsed[0]: - # text message - bot_instance.messageEventHandler(event) # handle event - elif "reasonmsg" in event.parsed[0]: - # user left - pass - else: - bot_instance.loginEventHandler(event) - except Exception as ex: - LOG.error("Error while trying to handle event %s:", str(event), exc_info=ex) - - LOG.warning("It appears the BOT has lost connection to teamspeak. Trying to restart connection in %s seconds....", config.bot_sleep_conn_lost) - time.sleep(config.bot_sleep_conn_lost) - - except (ConnectionRefusedError, ts3.query.TS3TransportError): - LOG.warning("Unable to reach teamspeak server..trying again in %s seconds...", config.bot_sleep_conn_lost) + try: + http_server.start() + + LOG.info("BOT Database Audit policies initiating.") + # Always audit users on initialize if user audit date is up (in case the script is reloaded several + # times before audit interval hits, so we can ensure we maintain user database accurately) + bot_instance.trigger_user_audit() + + # # Set audit schedule job to run in X days + audit_job = schedule.every(config.audit_interval).days.do(bot_instance.trigger_user_audit) + + bot_instance.listen_for_events() + finally: + if audit_job is not None: + schedule.cancel_job(audit_job) + + if http_server is not None: + LOG.info("Stopping Http Server") + http_server.stop() + except (ConnectionRefusedError, ts3.query.TS3TransportError) as ex: + LOG.warning("Unable to reach teamspeak server..trying again in %s seconds...", config.bot_sleep_conn_lost, exc_info=ex) time.sleep(config.bot_sleep_conn_lost) except (KeyboardInterrupt, SystemExit): - LOG.info("Stopping...") - http_server.stop() - sys.exit(0) + LOG.info("Shutdown signal received. Shutting down:") + bot_loop_forever = False # stop loop def create_connection_pool(config): @@ -136,28 +102,25 @@ def create_connection_pool(config): max_usage=config.pool_max_usage, idle=config.pool_tti, ttl=config.pool_ttl) -def parse_args(): - parser = argparse.ArgumentParser(description='ts-gw2-verifyBot') - parser.add_argument('-c', '--config-path', dest='config_path', type=str, help='Config file location', default="./bot.conf") - return parser.parse_args() - - -def move_to_channel(ts_facade, channel: Channel, client_id, channel_name): - chnl_err = ts_facade.client_move(client_id=client_id, channel_id=channel.channel_id) - if chnl_err: - LOG.warning("BOT Attempted to join channel '%s' (%s): %s", channel_name, channel.channel_id, chnl_err.resp.error["msg"]) - else: - LOG.info("BOT has joined channel '%s' (%s).", channel_name, channel.channel_id) - - -def find_verify_channel(ts_repository, channel_name): - found_channel = None - while found_channel is None: - found_channel = ts_repository.channel_find(channel_name) - if found_channel is None: - LOG.warning("Unable to locate channel with name '%s'. Sleeping for 10 seconds...", channel_name) - time.sleep(10) - else: - return found_channel +def parse_args() -> Tuple[Namespace, configargparse.ArgumentParser]: + parser: configargparse.ArgumentParser = configargparse.ArgParser(description='ts-gw2-verifyBot') + parser.add_argument('-c', '--config-path', + env_var="CONFIG_PATH", + dest='config_path', type=str, + help='Config file location', + # is_config_file=True, # this is also input for other config entries + default="./bot.conf") + parser.add_argument('--logging-level', + env_var="LOGGING_LEVEL", + dest='logging_level', type=str, + help='Logging Level', + default="DEBUG") + parser.add_argument('--logging-file', + env_var="LOGGING_FILE", + dest='logging_file', type=str, + help='Logging File, disabled when empty', + default="./ts3bot.log") + args: Namespace = parser.parse_args() + return args, parser ####################################### diff --git a/bot/messages/english.py b/bot/messages/english.py index a1ba443..c13c98f 100644 --- a/bot/messages/english.py +++ b/bot/messages/english.py @@ -3,7 +3,7 @@ class English(Locale): def __init__(self): - super(English, self).__init__() + super().__init__() self.set("bot_msg_example", "\n\nExample:\n\t\t7895D172-4991-9546-CB5B-78B015B0D8A72BC0E007-4FAF-48C3-9BF1-DA1OAD241266") self.set("bot_msg_note", "\n\nNOTE: Guild Wars 2 API keys can be created/deleted via ArenaNet site [URL]account.arena.net/applications[/URL].") self.set("bot_msg_verify", "Hello there! I believe you requested verification?\n\n" @@ -14,9 +14,6 @@ def __init__(self): # Banner that we send to all users upon initialization self.set("bot_msg", "%s is alive once again! If you require verification, please reconnect!") - # Broadcast message - self.set("bot_msg_broadcast", "Hello there! You can begin verification by typing 'verifyme' in this channel.") - # Message sent for sucesful verification self.set("bot_msg_success", "Authentication was succesful! Thank you fellow adventurer. Please give our rules a read and have fun \\(^.^)/ If you don't see any people, please relog once.") diff --git a/bot/messages/german.py b/bot/messages/german.py index 0d0aff8..9cec22c 100644 --- a/bot/messages/german.py +++ b/bot/messages/german.py @@ -4,7 +4,7 @@ class German(Locale): def __init__(self): - super(German, self).__init__(English()) + super().__init__(fallback=English()) self.set("bot_msg_example", "\n\nBeispiel:\n\t\t7895D172-4991-9546-CB5B-78B015B0D8A72BC0E007-4FAF-48C3-9BF1-DA1OAD241266") self.set("bot_msg_note", "\n\nINFO: Guild Wars 2 API keys können über die ArenaNet-Seite [URL]account.arena.net/applications[/URL] erstellt/gelöscht werden.") self.set("bot_msg_verify", @@ -16,9 +16,6 @@ def __init__(self): # Banner that we send to all users upon initialization self.set("bot_msg", "%s ist wieder da! Falls du eine Freischaltung benötigst, verbinde dich bitte neu!") - # Broadcast message - self.set("bot_msg_broadcast", "Hallöchen! Du kannst die Freischaltung beginnen, indem du 'verifyme' in diesem Channel schreibst.") - # Message sent for sucesful verification self.set("bot_msg_success", "Freischaltung erfolgreich! Danke dir, Abenteurer." diff --git a/bot/messages/locale.py b/bot/messages/locale.py index 09d3b7b..8c8691e 100644 --- a/bot/messages/locale.py +++ b/bot/messages/locale.py @@ -32,7 +32,7 @@ def set(self, key, value): class MultiLocale(Locale): def __init__(self, locales, glue="\n\n-------------------------------------\n\n"): - super(MultiLocale, self).__init__() + super().__init__() self._locales = locales self._glue = glue diff --git a/bot/reset_roster_service.py b/bot/reset_roster_service.py new file mode 100644 index 0000000..812dc08 --- /dev/null +++ b/bot/reset_roster_service.py @@ -0,0 +1,49 @@ +import logging + +from bot import ts +from bot.config import Config +from bot.connection_pool import ConnectionPool +from bot.util import StringShortener + +LOG = logging.getLogger(__name__) + +TS3_MAX_SIZE_CHANNEL_NAME = 40 + + +class ResetRosterService: + def __init__(self, ts_connection_pool: ConnectionPool[ts.TS3Facade], config: Config): + self._config = config + self._ts_connection_pool = ts_connection_pool + + def set_reset_roster(self, date, red=None, green=None, blue=None, ebg=None): + leads = ( + [], + red if red is not None else [], + green if green is not None else [], + blue if blue is not None else [], + ebg if ebg is not None else [] + ) # keep RGB order! EBG as last! Pad first slot (header) with empty list + + with self._ts_connection_pool.item() as facade: + channels = [(p, c.replace("$DATE", date)) for p, c in self._config.reset_channels] + for i, reset_channel in enumerate(channels): + pattern, clean = reset_channel + lead = leads[i] + + shortened = StringShortener(TS3_MAX_SIZE_CHANNEL_NAME - len(clean)).shorten(lead) + new_channel_name = "%s%s" % (clean, ", ".join(shortened)) + + self._rename_channel_if_exists(facade, pattern, new_channel_name) + return 0 + + @staticmethod + def _rename_channel_if_exists(facade, channel_name_pattern, new_channel_name): + channel = facade.channel_find(channel_name_pattern) + if channel is None: + LOG.warning("No channel found with pattern '%s'. Skipping.", channel_name_pattern) + else: + _, ts3qe = facade.channel_edit(channel_id=channel.channel_id, new_channel_name=new_channel_name) + if ts3qe is not None and ts3qe.resp.error["id"] == "771": + # channel name already in use + # probably not a bug (channel still unused), but can be a config problem + LOG.info("Channel '%s' already exists. This is probably not a problem. Skipping.", new_channel_name) diff --git a/bot/rest/controller/__init__.py b/bot/rest/controller/__init__.py index 372fa99..9e3ff92 100644 --- a/bot/rest/controller/__init__.py +++ b/bot/rest/controller/__init__.py @@ -1,6 +1,7 @@ +from .commanders_controller import CommandersController from .guild_controller import GuildController from .health_controller import HealthController -from .other_controller import OtherController +from .registration_controller import RegistrationController from .reset_roster_controller import ResetRosterController -__all__ = ['GuildController', 'HealthController', 'OtherController', 'ResetRosterController'] +__all__ = ['GuildController', 'HealthController', 'RegistrationController', 'CommandersController', 'ResetRosterController'] diff --git a/bot/rest/controller/commanders_controller.py b/bot/rest/controller/commanders_controller.py new file mode 100644 index 0000000..5eccb9c --- /dev/null +++ b/bot/rest/controller/commanders_controller.py @@ -0,0 +1,20 @@ +import logging + +from werkzeug.exceptions import abort + +from bot.commander_service import CommanderService +from bot.rest.controller.abstract_controller import AbstractController + +LOG = logging.getLogger(__name__) + + +class CommandersController(AbstractController): + def __init__(self, commander_service: CommanderService): + super().__init__() + self._commander_service = commander_service + + def _routes(self): + @self.api.route("/commanders", methods=["GET"]) + def _active_commanders(): + acs = self._commander_service.get_active_commanders() + return acs if acs is not None else abort(503) diff --git a/bot/rest/controller/other_controller.py b/bot/rest/controller/registration_controller.py similarity index 57% rename from bot/rest/controller/other_controller.py rename to bot/rest/controller/registration_controller.py index fce87bf..f014817 100644 --- a/bot/rest/controller/other_controller.py +++ b/bot/rest/controller/registration_controller.py @@ -1,30 +1,24 @@ import logging from flask import request -from werkzeug.exceptions import abort -from bot import Bot +from bot import UserService from bot.rest.controller.abstract_controller import AbstractController from bot.rest.utils import try_get LOG = logging.getLogger(__name__) -class OtherController(AbstractController): - def __init__(self, bot: Bot): +class RegistrationController(AbstractController): + def __init__(self, user_service: UserService): super().__init__() - self._bot = bot + self._user_service = user_service def _routes(self): - @self.api.route("/commanders", methods=["GET"]) - def _active_commanders(): - acs = self._bot.getActiveCommanders() - return acs if acs is not None else abort(503, "") - @self.api.route("/registration", methods=["DELETE"]) def _delete_registration(): body = request.json gw2account = try_get(body, "gw2account", default="") LOG.info("Received request to delete user '%s' from the TS registration database.", gw2account) - changes = self._bot.removePermissionsByGW2Account(gw2account) + changes = self._user_service.delete_registration(gw2account) return {"changes": changes} diff --git a/bot/rest/controller/reset_roster_controller.py b/bot/rest/controller/reset_roster_controller.py index b5718ab..5b502a8 100644 --- a/bot/rest/controller/reset_roster_controller.py +++ b/bot/rest/controller/reset_roster_controller.py @@ -3,7 +3,7 @@ from flask import request from werkzeug.exceptions import abort -from bot import Bot +from bot import ResetRosterService from bot.rest.controller.abstract_controller import AbstractController from bot.rest.utils import try_get @@ -11,9 +11,9 @@ class ResetRosterController(AbstractController): - def __init__(self, bot: Bot): + def __init__(self, reset_roster_service: ResetRosterService): super().__init__() - self._bot = bot + self._service = reset_roster_service def _routes(self): @self.api.route("/resetroster", methods=["POST"]) @@ -25,5 +25,5 @@ def _reset_roster(): blue = try_get(body, "bbl", default=[]) ebg = try_get(body, "ebg", default=[]) LOG.info("Received request to set resetroster. RBL: %s GBL: %s, BBL: %s, EBG: %s", ", ".join(red), ", ".join(green), ", ".join(blue), ", ".join(ebg)) - res = self._bot.setResetroster(date, red, green, blue, ebg) + res = self._service.set_reset_roster(date, red, green, blue, ebg) return "OK" if res == 0 else abort(400, res) diff --git a/openapi-spec.yaml b/bot/rest/dist/static/openapi-spec.yaml similarity index 89% rename from openapi-spec.yaml rename to bot/rest/dist/static/openapi-spec.yaml index 363b838..8300711 100644 --- a/openapi-spec.yaml +++ b/bot/rest/dist/static/openapi-spec.yaml @@ -3,9 +3,23 @@ info: title: ts-gw2-verifyBot description: ts-gw2-verifyBot version: 1.0.0 -servers: - - url: 'http://localhost:10137' paths: + /health: + get: + summary: Simple health/liveness check + operationId: healthCheck + tags: + - meta + responses: + default: + $ref: '#/components/responses/genericErrorResponse' + 200: + description: Response with active commanders + content: + text/plain: + schema: + type: string + example: "OK" /registration: delete: summary: delete a registration for an account @@ -106,6 +120,7 @@ paths: properties: name: type: string + example: "Foo Fighters" responses: default: $ref: '#/components/responses/genericErrorResponse' @@ -154,7 +169,7 @@ components: contacts: type: array nullable: true - example: ['UserName.1234'] + example: [ 'UserName.1234' ] items: $ref: "#/components/schemas/GuildContact" GuildContact: diff --git a/bot/rest/dist/templates/index.html b/bot/rest/dist/templates/index.html new file mode 100644 index 0000000..687e3f5 --- /dev/null +++ b/bot/rest/dist/templates/index.html @@ -0,0 +1,61 @@ + + + + + + ts-gw2-verifyBot - Swagger UI + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/bot/rest/server.py b/bot/rest/server.py index 3bdbc61..3e9ddb1 100644 --- a/bot/rest/server.py +++ b/bot/rest/server.py @@ -3,10 +3,11 @@ import flask_cors import waitress # productive serve -from flask import Flask +from flask import Flask, render_template from werkzeug.exceptions import HTTPException -from bot.rest.controller import GuildController, HealthController, OtherController, ResetRosterController +from bot.rest.controller import CommandersController, GuildController, HealthController, RegistrationController, \ + ResetRosterController from bot.rest.utils import error_response LOG = logging.getLogger(__name__) @@ -14,13 +15,17 @@ class HTTPServer(Flask): def __init__(self, bot, port): - super().__init__(__name__) + super().__init__(__name__, + static_url_path='', + static_folder="dist/static", + template_folder="dist/templates") self.bot = bot self.port = port self._thread = self._create_thread() def start(self): LOG.debug("Starting HTTP Server...") + self._thread.setDaemon(True) self._thread.start() def _create_thread(self): @@ -41,17 +46,31 @@ def create_http_server(bot, port=8080): app = HTTPServer(bot, port) flask_cors.CORS(app) - register_controllers(app, bot) + register_controller(app, bot) register_error_handlers(flask=app) + + register_open_api_endpoints(app) + return app -def register_controllers(app, bot): +def register_open_api_endpoints(app): + @app.route('/') + def _dist(): + return render_template('index.html', swagger_ui_version="3.34.0") + + @app.route('/v2/api-docs') + def _dist2(): + return app.send_static_file('openapi-spec.yaml') + + +def register_controller(app, bot): controller = [ HealthController(), GuildController(bot.guild_service), - ResetRosterController(bot), - OtherController(bot), + ResetRosterController(bot.reset_roster_service), + RegistrationController(bot.user_service), + CommandersController(bot.commander_service) ] for ctrl in controller: app.register_blueprint(ctrl.api) @@ -59,5 +78,5 @@ def register_controllers(app, bot): def register_error_handlers(flask: Flask): @flask.errorhandler(HTTPException) - def _handle_error(e: HTTPException): - return error_response(e.code, e.name, e.description) + def _handle_error(exception: HTTPException): + return error_response(exception.code, exception.name, exception.description) diff --git a/bot/rest/utils/utils.py b/bot/rest/utils/utils.py index b0a0a63..cc69291 100644 --- a/bot/rest/utils/utils.py +++ b/bot/rest/utils/utils.py @@ -1,3 +1,3 @@ def try_get(dictionary, key, lower=False, typer=lambda x: x, default=None): - v = typer(dictionary[key] if key in dictionary else default) - return v.lower() if lower and isinstance(v, str) else v + value = typer(dictionary[key] if key in dictionary else default) + return value.lower() if lower and isinstance(value, str) else value diff --git a/bot/ts/TS3Facade.py b/bot/ts/TS3Facade.py index 7c97ec0..b5ca342 100644 --- a/bot/ts/TS3Facade.py +++ b/bot/ts/TS3Facade.py @@ -5,6 +5,7 @@ import ts3 from ts3 import TS3Error from ts3.filetransfer import TS3FileTransfer, TS3UploadError +from ts3.query import TS3TimeoutError from bot.ts.ThreadSafeTSConnection import ThreadSafeTSConnection, ignore_exception_handler, signal_exception_handler from bot.ts.types.channel_list_detail import ChannelListDetail @@ -20,11 +21,21 @@ def __init__(self, ts3_connection: ThreadSafeTSConnection): def close(self): self._ts3_connection.close() + def __str__(self): + return f"TS3Facade[{self._ts3_connection}]" + def is_connected(self): - return self._ts3_connection.ts3exec(lambda tc: tc.is_connected(), signal_exception_handler)[0] + return self._ts3_connection.ts3exec_raise(lambda tc: tc.is_connected()) # and self.version() is not None + + def version(self): + return self._ts3_connection.ts3exec_raise(lambda tc: tc.query("version").first()) def wait_for_event(self, timeout: int): - return self._ts3_connection.ts3exec(lambda tc: tc.wait_for_event(timeout=timeout), ignore_exception_handler)[0] + try: + resp = self._ts3_connection.ts3exec_raise(lambda tc: tc.wait_for_event(timeout=timeout)) + except TS3TimeoutError: + resp = None + return resp def send_text_message_to_client(self, target_client_id: int, msg: str): self._ts3_connection.ts3exec(lambda tsc: tsc.exec_("sendtextmessage", targetmode=1, target=target_client_id, msg=msg)) @@ -79,25 +90,24 @@ def channel_create(self, return ts_exec def channel_add_permission(self, channel_id: str, permission_id: str, permission_value: int, negated: bool = False, skip: bool = False): - return self._ts3_connection.ts3exec(lambda tsc: tsc.exec_("channeladdperm", - cid=channel_id, permsid=permission_id, - permvalue=permission_value, - permnegated=1 if negated else 0, - permskip=1 if skip else 0), - signal_exception_handler) + return self._ts3_connection.ts3exec_raise(lambda tsc: tsc.exec_("channeladdperm", + cid=channel_id, permsid=permission_id, + permvalue=permission_value, + permnegated=1 if negated else 0, + permskip=1 if skip else 0)) def channel_add_permissions(self, channel_id: str, permissions: List[Tuple[str, int]]): for permission_id, permission_value in permissions: self.channel_add_permission(channel_id, permission_id=permission_id, permission_value=permission_value) - def channel_list(self) -> Tuple[List[ChannelListDetail], Exception]: - return self._ts3_connection.ts3exec(lambda tc: tc.query("channellist").all(), signal_exception_handler) + def channel_list(self) -> List[ChannelListDetail]: + return self._ts3_connection.ts3exec_raise(lambda tc: tc.query("channellist").all()) def use(self, server_id: int): - self._ts3_connection.ts3exec(lambda tc: tc.exec_("use", sid=server_id)) + self._ts3_connection.ts3exec_raise(lambda tc: tc.exec_("use", sid=server_id)) def whoami(self) -> Tuple[WhoamiResponse, Exception]: - return self._ts3_connection.ts3exec(lambda ts_con: ts_con.query("whoami").first()) + return self._ts3_connection.ts3exec_raise(lambda ts_con: ts_con.query("whoami").first()) def upload_icon(self, icon_id, icon_data): def _ts_file_upload_hook(ts3_response: ts3.response.TS3QueryResponse): @@ -121,7 +131,7 @@ def _ts_file_upload_hook(ts3_response: ts3.response.TS3QueryResponse): name=icon_server_path, cid=0, # 0 = Serverwide query_resp_hook=_ts_file_upload_hook) - LOG.info(f"Icon {icon_local_file_name} uploaded as {icon_server_path}.") + LOG.info("Icon %s uploaded as %s.", icon_local_file_name, icon_server_path) except TS3Error as ts3error: LOG.error("Error Uploading icon %s.", icon_local_file_name) LOG.error(ts3error) @@ -185,10 +195,10 @@ def client_get_name_from_uid(self, client_uid: str): return self._ts3_connection.ts3exec(lambda t: t.query("clientgetnamefromuid", cluid=client_uid).first()) def client_get_name_from_dbid(self, client_dbid): - return self._ts3_connection.ts3exec(lambda t: t.query("clientgetnamefromdbid", cldbid=client_dbid).first())[0] + return self._ts3_connection.ts3exec_raise(lambda t: t.query("clientgetnamefromdbid", cldbid=client_dbid).first()) def client_info(self, client_id: str): - return self._ts3_connection.ts3exec(lambda t: t.query("clientinfo", clid=client_id).first())[0] + return self._ts3_connection.ts3exec_raise(lambda t: t.query("clientinfo", clid=client_id).first()) def client_db_id_from_uid(self, client_uid) -> Optional[str]: response, ex = self._ts3_connection.ts3exec(lambda t: t.query("clientgetdbidfromuid", cluid=client_uid).first().get("cldbid"), exception_handler=signal_exception_handler) @@ -202,14 +212,18 @@ def client_db_id_from_uid(self, client_uid) -> Optional[str]: raise ex def client_ids_from_uid(self, client_uid): - return self._ts3_connection.ts3exec(lambda t: t.query("clientgetids", cluid=client_uid).all())[0] + return self._ts3_connection.ts3exec_raise(lambda t: t.query("clientgetids", cluid=client_uid).all()) def force_rename(self, target_nickname: str): - return self._ts3_connection.forceRename(target_nickname=target_nickname) + return self._ts3_connection.force_rename(target_nickname=target_nickname) def channel_edit(self, channel_id: str, new_channel_name: str): return self._ts3_connection.ts3exec(lambda tsc: tsc.exec_("channeledit", cid=channel_id, channel_name=new_channel_name), signal_exception_handler) + def remove_icon_if_exists(self, icon_id: int): + icon_server_path: str = f"/icon_{icon_id}" + return self._ts3_connection.ts3exec(lambda tsc: tsc.exec_("ftdeletefile", cid=0, cpw=None, name=icon_server_path), ignore_exception_handler) + class Channel: def __init__(self, channel_id, channel_name): diff --git a/bot/ts/ThreadSafeTSConnection.py b/bot/ts/ThreadSafeTSConnection.py index e77894c..7f3cbae 100644 --- a/bot/ts/ThreadSafeTSConnection.py +++ b/bot/ts/ThreadSafeTSConnection.py @@ -19,6 +19,11 @@ def default_exception_handler(ex): return ex +def raise_exception_handler(ex): + """ raises the exception for further inspection """ + raise ex + + def signal_exception_handler(ex): """ returns the exception without printing it, useful for expected exceptions, signaling that an exception occurred """ return ex @@ -34,9 +39,9 @@ class ThreadSafeTSConnection: @property def uri(self): - return "telnet://%s:%s@%s:%s" % (self._user, self._password, self._host, str(self._port)) + return "%s://%s:%s@%s:%s" % (self._protocol, self._user, self._password, self._host, str(self._port)) - def __init__(self, user, password, host, port, keepalive_interval=None, server_id=None, bot_nickname=None): + def __init__(self, protocol, user, password, host, port, keepalive_interval=None, server_id=None, bot_nickname=None, known_hosts_file: str = None): """ Creates a new threadsafe TS3 connection. user: user to connect as @@ -51,40 +56,46 @@ def __init__(self, user, password, host, port, keepalive_interval=None, server_i bot_nickname: nickname for the bot. Could be suffixed, see gentleRename. If None is passed, no naming will take place. """ + self._protocol = protocol self._user = user self._password = password self._host = host self._port = port self._keepalive_interval = int(keepalive_interval) self._server_id = server_id + self._known_hosts_file = known_hosts_file + self._bot_nickname = bot_nickname + '-' + str(id(self)) self.lock = RLock() self.ts_connection = None # done in init() self._keepalive_job = None - self.init() + self._init() LOG.info("New Connection {self} is ready.") - def init(self): - if self.ts_connection is not None: - try: - self.ts_connection.close() - except Exception: - pass # may already be closed, doesn't matter. - self.ts_connection = ts3.query.TS3ServerConnection(self.uri) + def _init(self): + with self.lock: # lock for good measure + if self.ts_connection is not None: + pass - # This hack allows using the "quit" command, so the bot does not appear as "timed out" in the Ts3 Client & Server log - self.ts_connection.COMMAND_SET = set(self.ts_connection.COMMAND_SET) # creat copy of frozenset - self.ts_connection.COMMAND_SET.add('quit') # add command + tp_args = dict() + if self._protocol == "ssh" and self._known_hosts_file is not None: + tp_args["host_key"] = self._known_hosts_file - if self._keepalive_interval is not None: - if self._keepalive_job is not None: - schedule.cancel_job(self._keepalive_job) # to avoid accumulating keepalive calls during re-inits - self._keepalive_job = schedule.every(self._keepalive_interval).seconds.do(self.keepalive) - if self._server_id is not None: - self.ts3exec(lambda tc: tc.exec_("use", sid=self._server_id)) - if self._bot_nickname is not None: - self.forceRename(self._bot_nickname) + self.ts_connection = ts3.query.TS3ServerConnection(self.uri, tp_args=tp_args) + + # This hack allows using the "quit" command, so the bot does not appear as "timed out" in the Ts3 Client & Server log + self.ts_connection.COMMAND_SET = set(self.ts_connection.COMMAND_SET) # creat copy of frozenset + self.ts_connection.COMMAND_SET.add('quit') # add command + + if self._keepalive_interval is not None: + self._keepalive_job = schedule.every(self._keepalive_interval).seconds.do(self.keepalive) + + if self._server_id is not None: + self.ts3exec(lambda tc: tc.exec_("use", sid=self._server_id)) + + if self._bot_nickname is not None: + self.force_rename(self._bot_nickname) def __str__(self): return f"ThreadSafeTSConnection[{self._bot_nickname}]" @@ -98,7 +109,11 @@ def __exit__(self, exc_type, exc_value, tb): def keepalive(self): LOG.info(f"Keepalive {self}") - self.ts3exec(lambda tc: tc.send_keepalive()) + with self.lock: + self.ts3exec(lambda tc: tc.send_keepalive()) + + def ts3exec_raise(self, handler: Callable[[TS3ServerConnection], R]) -> R: + return self.ts3exec(handler, raise_exception_handler)[0] def ts3exec(self, handler: Callable[[TS3ServerConnection], R], @@ -128,7 +143,6 @@ def ts3exec(self, returns a tuple with the results of the two handlers (result first, exception result second). """ - reinit = False with self.lock: failed = True fails = 0 @@ -138,29 +152,40 @@ def ts3exec(self, failed = False try: res = handler(self.ts_connection) - except ts3.query.TS3TransportError: + except ts3.query.TS3TransportError as ts3tex: failed = True fails += 1 - LOG.error("Critical error on transport level! Attempt %s to restart the connection and send the command again.", str(fails), ) - reinit = True + if fails >= ThreadSafeTSConnection.RETRIES: + LOG.error("Critical error on transport level! Closing this Connection.", exc_info=ts3tex) + self.close() + raise ts3tex + else: + LOG.error("Error on transport level! Attempt %s to send the command again.", str(fails), ) except Exception as ex: exres = exception_handler(ex) - if reinit: - self.init() return res, exres def close(self): - LOG.info("Closing Connection: %s", self) - if self._keepalive_job is not None: - schedule.cancel_job(self._keepalive_job) + with self.lock: + LOG.info("Closing %s", self) + if self._keepalive_job is not None: + schedule.cancel_job(self._keepalive_job) - # This hack allows using the "quit" command, so the bot does not appear as "timed out" in the Ts3 Client & Server log - if self.ts_connection is not None: - self.ts_connection.exec_("quit") # send quit - self.ts_connection.close() # immediately quit - del self.ts_connection + # This hack allows using the "quit" command, so the bot does not appear as "timed out" in the Ts3 Client & Server log + if self.ts_connection is not None \ + and hasattr(self.ts_connection, "is_connected") \ + and self.ts_connection.is_connected(): + try: + self.ts_connection.exec_("quit") # send quit + self.ts_connection.close() # immediately quit + except ts3.query.TS3TransportError: + pass + except Exception as ex: + LOG.debug("Exception during closing the connection. This is usually not a problem.", exc_info=ex) + finally: + self.ts_connection = None - def gentleRename(self, nickname): + def _gentle_rename(self, nickname): """ Renames self to nickname, but attaches a running counter to the name if the nickname is already taken. @@ -175,7 +200,7 @@ def gentleRename(self, nickname): self._bot_nickname = new_nick return self._bot_nickname - def forceRename(self, target_nickname): + def force_rename(self, target_nickname): """ Attempts to forcefully rename self. If the chosen nickname is already taken, the bot will attempt to kick that user. @@ -202,7 +227,7 @@ def forceRename(self, target_nickname): target_nickname) else: LOG.info("Kicked user who was using the reserved registration bot name '%s'.", target_nickname) - target_nickname = self.gentleRename(target_nickname) + target_nickname = self._gentle_rename(target_nickname) LOG.info("Renamed self to '%s'.", target_nickname) else: self.ts3exec(lambda tc: tc.exec_("clientupdate", client_nickname=target_nickname)) @@ -213,8 +238,10 @@ def forceRename(self, target_nickname): def create_connection(config: Config, nickname: str) -> ThreadSafeTSConnection: - return ThreadSafeTSConnection(config.user, config.passwd, + return ThreadSafeTSConnection(config.protocol, + config.user, config.passwd, config.host, config.port, config.keepalive_interval, config.server_id, - nickname) + nickname, + known_hosts_file=config.known_hosts_file) diff --git a/bot/user_service.py b/bot/user_service.py new file mode 100644 index 0000000..ee78a3e --- /dev/null +++ b/bot/user_service.py @@ -0,0 +1,204 @@ +import logging + +import ts3 +from ts3.query import TS3QueryError + +from bot.config import Config +from bot.connection_pool import ConnectionPool +from bot.db import ThreadSafeDBConnection +from bot.ts import TS3Facade + +LOG = logging.getLogger(__name__) + + +class UserService: + def __init__(self, database: ThreadSafeDBConnection, ts_connection_pool: ConnectionPool[TS3Facade], config: Config): + self._database_connection = database + self._ts_connection_pool = ts_connection_pool + self._config = config + + self.verified_group = config.verified_group + self.vgrp_id = self._find_group_by_name(self.verified_group) + + def remove_user_from_db(self, client_db_id): + with self._database_connection.lock: + self._database_connection.cursor.execute("DELETE FROM users WHERE ts_db_id=?", (client_db_id,)) + self._database_connection.conn.commit() + + def update_guild_tags(self, ts_facade, user, auth): + if auth.guilds_error: + LOG.error("Did not update guild groups for player '%s', as loading the guild groups caused an error.", auth.name) + return + uid = user.unique_id # self.getTsUniqueID(client_db_id) + client_db_id = user.ts_db_id + ts_groups = {sg.get("name"): sg.get("sgid") for sg in ts_facade.servergroup_list()} + ingame_member_of = set(auth.guild_names) + # names of all groups the user is in, not just guild ones + current_group_names = [] + try: + current_group_names = [g.get("name") for g in + ts_facade.servergroup_list_by_client(client_db_id=client_db_id)] + except TypeError: + # user had no groups (results in None, instead of an empty list) -> just stick with the [] + pass + + # data of all guild groups the user is in + param = ",".join(["'%s'" % (cgn.replace('"', '\\"').replace("'", "\\'"),) for cgn in current_group_names]) + # sanitisation is restricted to replacing single and double quotes. This should not be that much of a problem, since + # the input for the parameters here are the names of our own server groups on our TS server. + current_guild_groups = [] + hidden_groups = {} + with self._database_connection.lock: + current_guild_groups = self._database_connection.cursor.execute("SELECT ts_group, guild_name FROM guilds WHERE ts_group IN (%s)" % (param,)).fetchall() + # groups the user doesn't want to wear + hidden_groups = set( + [g[0] for g in self._database_connection.cursor.execute("SELECT g.ts_group FROM guild_ignores AS gi JOIN guilds AS g ON gi.guild_id = g.guild_id WHERE ts_db_id = ?", (uid,))]) + # REMOVE STALE GROUPS + for ggroup, gname in current_guild_groups: + if ggroup in hidden_groups: + LOG.info("Player %s chose to hide group '%s', which is now removed.", auth.name, ggroup) + ts_facade.servergroup_client_del(servergroup_id=ts_groups[ggroup], client_db_id=client_db_id) + elif gname not in ingame_member_of: + if ggroup not in ts_groups: + LOG.warning( + "Player %s should be removed from the TS group '%s' because they are not a member of guild '%s'." + " But no matching group exists." + " You should remove the entry for this guild from the db or check the spelling of the TS group in the DB. Skipping.", + ggroup, auth.name, gname) + else: + LOG.info("Player %s is no longer part of the guild '%s'. Removing attached group '%s'.", auth.name, gname, ggroup) + ts_facade.servergroup_client_del(servergroup_id=ts_groups[ggroup], client_db_id=client_db_id) + + # ADD DUE GROUPS + for g in ingame_member_of: + ts_group = None + with self._database_connection.lock: + ts_group = self._database_connection.cursor.execute("SELECT ts_group FROM guilds WHERE guild_name = ?", (g,)).fetchone() + if ts_group: + ts_group = ts_group[0] # first and only column, if a row exists + if ts_group not in current_group_names: + if ts_group in hidden_groups: + LOG.info("Player %s is entitled to TS group '%s', but chose to hide it. Skipping.", auth.name, ts_group) + else: + if ts_group not in ts_groups: + LOG.warning( + "Player %s should be assigned the TS group '%s' because they are member of guild '%s'." + " But the group does not exist. You should remove the entry for this guild from the db or create the group." + " Skipping.", + auth.name, ts_group, g) + else: + LOG.info("Player %s is member of guild '%s' and will be assigned the TS group '%s'.", auth.name, g, ts_group) + ts_facade.servergroup_client_add(servergroup_id=ts_groups[ts_group], client_db_id=client_db_id) + + # Helps find the group ID for a group name + def _find_group_by_name(self, group_to_find): + with self._ts_connection_pool.item() as ts_facade: + self.groups_list = ts_facade.servergroup_list() + for group in self.groups_list: + if group.get('name') == group_to_find: + return group.get('sgid') + return -1 + + def check_client_needs_verify(self, unique_client_id): + with self._ts_connection_pool.item() as ts_facade: + client_db_id = ts_facade.client_db_id_from_uid(unique_client_id) + if client_db_id is None: + raise ValueError("User not found in Teamspeak Database.") + else: + # Check if user is in verified group + if any(perm_grp.get('name') == self.verified_group for perm_grp in ts_facade.servergroup_list_by_client(client_db_id)): + return False # User already verified + + # Check if user is authenticated in database and if so, re-adds them to the group + with self._database_connection.lock: + current_entries = self._database_connection.cursor.execute("SELECT * FROM users WHERE ts_db_id=?", (unique_client_id,)).fetchall() + if len(current_entries) > 0: + self.set_permissions(unique_client_id) + return False + + return True # User not verified + + def set_permissions(self, unique_client_id): + try: + # Add user to group + with self._ts_connection_pool.item() as facade: + client_db_id = facade.client_db_id_from_uid(unique_client_id) + if client_db_id is None: + LOG.warning("User not found in Database.") + else: + LOG.debug("Adding Permissions: CLUID [%s] SGID: %s CLDBID: %s", unique_client_id, self.vgrp_id, client_db_id) + ex = facade.servergroup_client_add(servergroup_id=self.vgrp_id, client_db_id=client_db_id) + if ex: + LOG.error("Unable to add client to '%s' group. Does the group exist?", self.verified_group) + except ts3.query.TS3QueryError as err: + LOG.error("Setting permissions failed: %s", err) # likely due to bad client id + + def remove_permissions(self, unique_client_id): + try: + with self._ts_connection_pool.item() as ts_facade: + client_db_id = ts_facade.client_db_id_from_uid(unique_client_id) + if client_db_id is None: + LOG.warning("User not found in Database.") + else: + LOG.debug("Removing Permissions: CLUID [%s] SGID: %s CLDBID: %s", unique_client_id, self.vgrp_id, client_db_id) + + # Remove user from group + ex = ts_facade.servergroup_client_del(servergroup_id=self.vgrp_id, client_db_id=client_db_id) + if ex: + LOG.error("Unable to remove client from '%s' group. Does the group exist and are they member of the group?", self.verified_group) + # Remove users from all groups, except the whitelisted ones + if self._config.purge_completely: + # FIXME: remove channel groups as well + assigned_groups = ts_facade.servergroup_list_by_client(client_db_id) + if assigned_groups is not None: + for g in assigned_groups: + if g.get("name") not in self._config.purge_whitelist: + ts_facade.servergroup_client_del(servergroup_id=g.get("sgid"), client_db_id=client_db_id) + except TS3QueryError as err: + LOG.error("Removing permissions failed: %s", err) # likely due to bad client id + + def delete_registration(self, gw2account): + with self._database_connection.lock: + tsDbIds = self._database_connection.cursor.execute("SELECT ts_db_id FROM users WHERE account_name = ?", (gw2account,)).fetchall() + for tdi, in tsDbIds: + self.remove_permissions(tdi) + LOG.debug("Removed permissions from %s", tdi) + self._database_connection.cursor.execute("DELETE FROM users WHERE account_name = ?", (gw2account,)) + changes = self._database_connection.cursor.execute("SELECT changes()").fetchone()[0] + self._database_connection.conn.commit() + return changes + + def get_user_database_entry(self, client_unique_id): + """ + Retrieves the DB entry for a unique client ID. + Is either a dictionary of database-field-names to values, or None if no such entry was found in the DB. + """ + with self._database_connection.lock: + entry = self._database_connection.cursor.execute("SELECT * FROM users WHERE ts_db_id=?", (client_unique_id,)).fetchall() + if len(entry) < 1: + # user not registered + return None + entry = entry[0] + keys = self._database_connection.cursor.description + assert len(entry) == len(keys) + return dict([(keys[i][0], entry[i]) for i in range(len(entry))]) + + def is_ts_registration_limit_reached(self, gw_acct_name): + with self._database_connection.lock: + current_entries = self._database_connection.cursor.execute("SELECT * FROM users WHERE account_name=?", (gw_acct_name,)).fetchall() + return len(current_entries) >= self._config.client_restriction_limit + + def add_user_to_database(self, client_unique_id, account_name, api_key, created_date, last_audit_date): + with self._database_connection.lock: + # client_id = self.getActiveTsUserID(client_unique_id) + client_exists = self._database_connection.cursor.execute("SELECT * FROM users WHERE ts_db_id=?", (client_unique_id,)).fetchall() + if len(client_exists) > 1: + LOG.warning("Found multiple database entries for single unique teamspeakid %s.", client_unique_id) + if len(client_exists) != 0: # If client TS database id is in BOT's database. + self._database_connection.cursor.execute("""UPDATE users SET ts_db_id=?, account_name=?, api_key=?, created_date=?, last_audit_date=? WHERE ts_db_id=?""", + (client_unique_id, account_name, api_key, created_date, last_audit_date, client_unique_id)) + LOG.info("Teamspeak ID %s already in Database updating with new Account Name '%s'. (likely permissions changed by a Teamspeak Admin)", client_unique_id, account_name) + else: + self._database_connection.cursor.execute("INSERT INTO users ( ts_db_id, account_name, api_key, created_date, last_audit_date) VALUES(?,?,?,?,?)", + (client_unique_id, account_name, api_key, created_date, last_audit_date)) + self._database_connection.conn.commit() diff --git a/bot/util/__init__.py b/bot/util/__init__.py index aca9a6b..67f1793 100644 --- a/bot/util/__init__.py +++ b/bot/util/__init__.py @@ -1,4 +1,5 @@ from .StringShortener import StringShortener from .logging import initialize_logging +from .repeat_timer import RepeatTimer -__all__ = ['StringShortener', 'initialize_logging'] +__all__ = ['StringShortener', 'initialize_logging', 'RepeatTimer'] diff --git a/bot/util/logging.py b/bot/util/logging.py index 05de079..1c457f2 100644 --- a/bot/util/logging.py +++ b/bot/util/logging.py @@ -1,17 +1,40 @@ import logging +from typing import Optional, Union + import sys -FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" +FORMAT = "%(asctime)s %(levelname)-6s [%(threadName)s] %(name)s: %(message)s" + +_nameToLevel = { + 'CRITICAL': logging.CRITICAL, + 'FATAL': logging.FATAL, + 'ERROR': logging.ERROR, + 'WARN': logging.WARNING, + 'WARNING': logging.WARNING, + 'INFO': logging.INFO, + 'DEBUG': logging.DEBUG +} + + +def _level_from_name(level: Union[str, int]) -> int: + result = _nameToLevel.get(level) + if result is not None: + return result + return level -def initialize_logging(file="./ts3bot.log", level: int = logging.DEBUG): +def initialize_logging(file: Optional[str] = "./ts3bot.log", level: Union[str, int] = logging.DEBUG): + level = _level_from_name(level) + formatter = logging.Formatter(FORMAT) - handlers = [] - for handler, handler_level in [(logging.StreamHandler(sys.stdout), logging.DEBUG), (logging.FileHandler(file, delay=True), logging.DEBUG)]: - handler.setLevel(handler_level) + handlers = [logging.StreamHandler(sys.stdout)] + if file is not None: + handlers.append(logging.FileHandler(file, delay=True)) + + for handler in handlers: + handler.setLevel(level) handler.setFormatter(formatter) - handlers.append(handler) # noinspection PyArgumentList logging.basicConfig( @@ -19,3 +42,6 @@ def initialize_logging(file="./ts3bot.log", level: int = logging.DEBUG): format=FORMAT, handlers=handlers ) + + # SSH Log is very verbose on DEBUG + logging.getLogger("paramiko.transport").setLevel(logging.INFO) diff --git a/bot/util/repeat_timer.py b/bot/util/repeat_timer.py new file mode 100644 index 0000000..0b2a630 --- /dev/null +++ b/bot/util/repeat_timer.py @@ -0,0 +1,7 @@ +from threading import Timer + + +class RepeatTimer(Timer): + def run(self): + while not self.finished.wait(self.interval): + self.function(*self.args, **self.kwargs) diff --git a/known_hosts b/known_hosts new file mode 100644 index 0000000..3952b35 --- /dev/null +++ b/known_hosts @@ -0,0 +1 @@ +[erklaerbaerraid.xyaren.de]:10022 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCPSU8qPYmbj00e5HGJLbAhpW0nqttczIpKixquX7fPEgGRm/VD8KJWPwVRm8twQ2ax+90OrZtWveXGopdrlcGebgwJMa9J4mV8Q51oTPIeHeVz76k04wtgy8LnHuSwGayfWePNf+7mFTL45u4XOBBWbnF1WcUs3AahaXJhgwKdbgljFqdQ9MWWbHQzSLIW7kyDMVnCYuoN6ULxeW1qzggeC1tVgURsGAlltwcb1Zq+2p7559kqfv/No6NxPqqA/8nC3yiVlrkFqjpzXejZaVlaULjn0WzLstYJccoBBazySjDhvg04YWvti4nxNtf4pGBxeSHoCg9/oq8iy4Mm2K1AwZCUzvlE2reEUy5mqg7yJrsZhhpbB2tYSJm0AY4J2w6gw8ttFc3doRKfZa1vOaPHQTDKt7JbLGLga2o82K1105GVM+saXCQOJsgUGxirCAi9m0murJNACgYNJjcIE0jNSmLLgs8kDsgA2F9p8m7z+IRdRADmm6M70BZjwXiKZSTqm7hWqyl7K5Wb8+F5eebI13oBIrvpJycV6QZdu47mQeL/B1e7uVAZHqZo9/gpidyPSyhB6dS11z4pIjPjv6MzCaL8tf1nu1fiuizDGZC4n++ZJgASawscMHFxaBuRDg7IVK0i55WPJmXirfWSaMdMQRvXqoRtZHc/k9GP2hSPKQ== \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 786617c..29222d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ ts3==2.0.0b2 Flask==1.1.2 flask-cors==3.0.9 waitress==1.4.4 -GuildWars2-API-Client==0.5.9 \ No newline at end of file +GuildWars2-API-Client==0.5.9 +cachetools==4.1.1 +ConfigArgParse==1.2.3 \ No newline at end of file diff --git a/tests/test_emblem_downloader.py b/tests/test_emblem_downloader.py index 3fa7ae9..b566dff 100644 --- a/tests/test_emblem_downloader.py +++ b/tests/test_emblem_downloader.py @@ -4,10 +4,8 @@ from bot.util import initialize_logging ANY_INVALID_GUILD_ID = "abc" -ANY_INVALID_GUILD_NAME = "def" ANY_VALID_GUILD_ID = "14762DCE-C2A4-E711-80D5-441EA14F1E44" -ANY_VALID_GUILD_NAME = "Lqibzzexgvkikpydotxsvijehyhexd" class TestEmblemDownloader(TestCase): @@ -16,13 +14,11 @@ def setUp(self) -> None: initialize_logging() def test__download_guild_emblem_returns_none_on_not_existing_guild(self): - icon_id, icon_data = download_guild_emblem(ANY_INVALID_GUILD_ID, ANY_INVALID_GUILD_NAME) - self.assertIsNone(icon_id) + icon_data = download_guild_emblem(ANY_INVALID_GUILD_ID) self.assertIsNone(icon_data) def test__download_guild_emblem_returns_on_existing_guild(self): - icon_id, icon_data = download_guild_emblem(ANY_VALID_GUILD_ID, ANY_VALID_GUILD_NAME) - self.assertIsNotNone(icon_id) + icon_data = download_guild_emblem(ANY_VALID_GUILD_ID) self.assertIsNotNone(icon_data) self.assertGreater(len(icon_data), 1000) self.assertEqual(icon_data[1:4].decode("ascii"), "PNG") # check image header