async def spellbot(self, prefix, params, message): """ Configure SpellBot for your server. _Requires the "SpellBot Admin" role._ The following subcommands are supported: * `config`: Just show the current configuration for this server. * `channel <list>`: Set SpellBot to only respond in the given list of channels. * `prefix <string>`: Set SpellBot prefix for commands in text channels. * `scope <server|channel>`: Set matchmaking scope to server-wide or channel-only. * `expire <number>`: Set the number of minutes before pending games expire. * `friendly <on|off>`: Allow or disallow friendly queueing with mentions. & <subcommand> [subcommand parameters] """ if not is_admin(message.channel, message.author): return await message.channel.send(s("not_admin")) if not params: return await message.channel.send(s("spellbot_missing_subcommand")) self.ensure_server_exists(message.channel.guild.id) command = params[0] if command == "channels": await self.spellbot_channels(prefix, params[1:], message) elif command == "prefix": await self.spellbot_prefix(prefix, params[1:], message) elif command == "scope": await self.spellbot_scope(prefix, params[1:], message) elif command == "expire": await self.spellbot_expire(prefix, params[1:], message) elif command == "friendly": await self.spellbot_friendly(prefix, params[1:], message) elif command == "config": await self.spellbot_config(prefix, params[1:], message) else: await message.channel.send( s("spellbot_unknown_subcommand", command=command))
async def spellbot_prefix(self, prefix, params, message): if not params: return await message.channel.send(s("spellbot_prefix_none")) prefix_str = params[0][0:10] server = (self.session.query(Server).filter( Server.guild_xid == message.channel.guild.id).one_or_none()) server.prefix = prefix_str return await message.channel.send( s("spellbot_prefix", prefix=prefix_str))
async def leave(self, prefix, params, message): """ Leave your place in the queue. """ user = self.ensure_user_exists(message.author) if not user.waiting: return await message.channel.send(s("leave_already")) user.dequeue() await message.channel.send(s("leave"))
async def spellbot_scope(self, prefix, params, message): if not params: return await message.channel.send(s("spellbot_scope_none")) scope_str = params[0].lower() if scope_str not in ("server", "channel"): return await message.channel.send(s("spellbot_scope_bad")) server = (self.session.query(Server).filter( Server.guild_xid == message.channel.guild.id).one_or_none()) server.scope = scope_str await message.channel.send(s("spellbot_scope", scope=scope_str))
async def spellbot_expire(self, prefix, params, message): if not params: return await message.channel.send(s("spellbot_expire_none")) expire = to_int(params[0]) if not expire or not (0 < expire <= 60): return await message.channel.send(s("spellbot_expire_bad")) server = (self.session.query(Server).filter( Server.guild_xid == message.channel.guild.id).one_or_none()) server.expire = expire await message.channel.send(s("spellbot_expire", expire=expire))
async def spellbot_channels(self, prefix, params, message): if not params: return await message.channel.send(s("spellbot_channels_none")) self.session.query(Channel).filter_by( guild_xid=message.channel.guild.id).delete() for param in params: self.session.add( Channel(guild_xid=message.channel.guild.id, name=param)) await message.channel.send( s("spellbot_channels", channels=", ".join([f"#{param}" for param in params])))
async def spellbot_friendly(self, prefix, params, message): if not params: return await message.channel.send(s("spellbot_friendly_none")) friendly_str = params[0].lower() if friendly_str not in ("off", "on"): return await message.channel.send(s("spellbot_friendly_bad")) server = (self.session.query(Server).filter( Server.guild_xid == message.channel.guild.id).one_or_none()) server.friendly = friendly_str == "on" await message.channel.send( s("spellbot_friendly", friendly=friendly_str))
async def status(self, prefix, params, message): """ Show some details about the queues on your server. """ server = self.ensure_server_exists(message.channel.guild.id) average = WaitTime.average( self.session, guild_xid=server.guild_xid, channel_xid=message.channel.id, scope=server.scope, window_min=AVG_QUEUE_TIME_WINDOW_MIN, ) if average: wait = naturaldelta(timedelta(seconds=average)) await message.channel.send(s("status", wait=wait)) else: await message.channel.send(s("status_unknown"))
async def safe_react_error(message: discord.Message) -> None: try: await message.add_reaction(RED_X) except discord.errors.Forbidden: await message.channel.send(s("reaction_permissions_required")) except discord.errors.HTTPException as e: logger.exception( "warning: discord (%s): could react to message: %s", _user_or_guild_log_part(message), e, )
async def process(self, message, prefix): """Process a command message.""" tokens = message.content.split(" ") request, params = tokens[0].lstrip(prefix).lower(), tokens[1:] params = list(filter(None, params)) # ignore any empty string parameters if not request: return matching = [ command for command in self.commands if command.startswith(request) ] if not matching: await message.channel.send(s("not_a_command", request=request), file=None) return if len(matching) > 1 and request not in matching: possible = ", ".join(f"{prefix}{m}" for m in matching) await message.channel.send(s("did_you_mean", possible=possible), file=None) else: command = request if request in matching else matching[0] method = getattr(self, command) if not method.allow_dm and str(message.channel.type) == "private": return await message.author.send(s("no_dm")) logging.debug("%s%s (params=%s, message=%s)", prefix, command, params, message) self.session = self.data.Session() try: await method(prefix, params, message) self.session.commit() except exc.SQLAlchemyError as e: logging.exception(f"error: {request}:", e) self.session.rollback() raise finally: self.session.close()
async def begin(self, prefix, params, message): """ Confirm creation of games for the given event id. _Requires the "SpellBot Admin" role._ & <event id> """ if not is_admin(message.channel, message.author): return await message.channel.send(s("not_admin")) if not params: return await message.channel.send(s("begin_no_params")) event_id = to_int(params[0]) if not event_id: return await message.channel.send(s("begin_bad_event")) event = self.session.query(Event).filter( Event.id == event_id).one_or_none() if not event: return await message.channel.send(s("begin_bad_event")) if event.started: return await message.channel.send(s("begin_event_already_started")) for game in event.games: players_str = ", ".join( sorted([f"<@{user.xid}>" for user in game.users])) found_discord_users = [] for game_user in game.users: discord_user = self.get_user(game_user.xid) if not discord_user: # game_user has left the server since event created warning = s("begin_user_left", players=players_str) await message.channel.send(warning) else: found_discord_users.append(discord_user) if len(found_discord_users) != len(game.users): continue game.url = self.create_game() game.status = "started" response = game.to_str() for discord_user in found_discord_users: await discord_user.send(response) await message.channel.send( s("game_created", id=game.id, url=game.url, players=players_str))
async def safe_remove_reaction(message: discord.Message, emoji: str, user: discord.User) -> None: try: await message.remove_reaction(emoji, user) except discord.errors.Forbidden: await message.channel.send(s("reaction_permissions_required")) except ( discord.errors.HTTPException, discord.errors.NotFound, discord.errors.InvalidArgument, ) as e: logger.exception( "warning: discord (%s): could not remove reaction: %s", _user_or_guild_log_part(message), e, )
async def help(self, prefix, params, message): """ Sends you this help message. """ usage = "" for command in self.commands: method = getattr(self, command) doc = method.__doc__.split("&") use, params = doc[0], ", ".join( [param.strip() for param in doc[1:]]) use = inspect.cleandoc(use) transformed = "" for line in use.split("\n"): if line: if line.startswith("*"): transformed += f"\n{line}" else: transformed += f"{line} " else: transformed += "\n\n" use = transformed use = use.replace("\n", "\n> ") use = re.sub(r"([^>])\s+$", r"\1", use, flags=re.M) title = f"{prefix}{command}" if params: title = f"{title} {params}" usage += f"\n`{title}`" usage += f"\n> {use}" usage += "\n" usage += "---" usage += (" \nPlease report any bugs and suggestions at" " <https://github.com/lexicalunit/spellbot/issues>!") usage += "\n" usage += ( "\n💜 You can help keep SpellBot running by supporting me on Ko-fi! " "<https://ko-fi.com/Y8Y51VTHZ>") await message.channel.send(s("dm_sent")) for page in paginate(usage): await message.author.send(page)
async def cleanup_expired_games(self): """Culls games older than the given window of minutes.""" session = self.data.Session() try: expired = Game.expired(session) for game in expired: for user in game.users: discord_user = self.get_user(user.xid) if discord_user: await discord_user.send( s("expired", window=game.server.expire)) user.queued_at = None user.game = None game.tags = [] # cascade delete tag associations session.delete(game) session.commit() except exc.SQLAlchemyError as e: logging.exception("error: cleanup_expired_games:", e) session.rollback() raise finally: session.close()
async def game(self, prefix, params, message): """ Create a game between mentioned users. _Requires the "SpellBot Admin" role._ Operates similarly to the `!play` command with a few key deferences. First, see that command's usage help for more details. Then, here are the differences: * The user who issues this command is **NOT** added to the game themselves. * You must mention all of the players to be seated in the game. * Optional: Add a message by using " -- " followed by the message content. & [similar parameters as !play] [-- An optional additional message to send.] """ if not is_admin(message.channel, message.author): return await message.channel.send(s("not_admin")) optional_message = None try: sentry = params.index("--") optional_message = " ".join(params[sentry + 1:]) params = params[0:sentry] except ValueError: pass if optional_message and len(optional_message) >= 255: return await message.channel.send(s("game_message_too_long")) params = [param.lower() for param in params] mentions = message.mentions if message.channel.type != "private" else [] power, size = power_and_size_from_params(params) if not size or not (1 < size <= 4): return await message.channel.send(s("game_size_bad")) if power and not (1 <= power <= 10): return await message.channel.send(s("game_power_bad")) if len(mentions) > size: return await message.channel.send( s("game_too_many_mentions", size=size)) elif len(mentions) < size: return await message.channel.send( s("game_too_few_mentions", size=size)) mentioned_users = [] for mentioned in mentions: mentioned_user = self.ensure_user_exists(mentioned) if mentioned_user.waiting: mentioned_user.dequeue() mentioned_users.append(mentioned_user) self.session.commit() tag_names = tag_names_from_params(params) if len(tag_names) > 5: return await message.channel.send(s("game_too_many_tags")) tags = [] for tag_name in tag_names: tag = self.session.query(Tag).filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name) self.session.add(tag) tags.append(tag) server = self.ensure_server_exists(message.channel.guild.id) self.session.commit() now = datetime.utcnow() expires_at = now + timedelta(minutes=server.expire) url = self.create_game() game = Game( channel_xid=message.channel.id if server.scope == "channel" else None, created_at=now, expires_at=expires_at, guild_xid=server.guild_xid, power=power, size=size, updated_at=now, url=url, status="started", message=optional_message, users=mentioned_users, tags=tags, ) self.session.add(game) self.session.commit() player_response = game.to_str() for player in mentioned_users: discord_user = self.get_user(player.xid) await discord_user.send(player_response) player.queued_at = None players_str = ", ".join( sorted([f"<@{user.xid}>" for user in mentioned_users])) await message.channel.send( s("game_created", id=game.id, url=game.url, players=players_str))
async def play(self, prefix, params, message): """ Enter a play queue for a game on SpellTable. You can get in a queue with a friend by mentioning them in the command with the @ character. You can also change the number of players from the default of four by using, for example, `!play size:2` to create a two player game. Up to five tags can be given as well. For example, `!play no-combo proxy` has two tags: `no-combo` and `proxy`. Look on your server for what tags are being used by your community members. Tags can **not** be a number like `5`. Be careful when using tags because the matchmaker will only pair you up with other players who've used **EXACTLY** the same tags. You can also specify a power level like `!play power:7` for example and the matchmaker will attempt to find a game with similar power levels for you. Note that players who specify a power level will never get paired up with players who have not, and vice versa. You will also not be matched up _exactly_ by power level as there is a fudge factor involved. If your server's admins have set the scope for your server to "channel", then matchmaking will only happen between other players who run this command in the same channel as you did. The default scope for matchmaking is server-wide. & [@mention-1] [@mention-2] [...] [size:N] [power:N] [tag-1] [tag-2] [...] """ user = self.ensure_user_exists(message.author) if user.waiting: return await message.channel.send(s("play_already")) params = [param.lower() for param in params] mentions = message.mentions if message.channel.type != "private" else [] server = self.ensure_server_exists(message.channel.guild.id) self.session.commit() friendly = server.friendly if server.friendly is not None else True power, size = power_and_size_from_params(params) if not size or not (1 < size < 5): return await message.channel.send(s("play_size_bad")) if power and not (1 <= power <= 10): return await message.channel.send(s("play_power_bad")) if friendly and len(mentions) + 1 > size: return await message.channel.send(s("play_too_many_mentions")) mentioned_users = [] if friendly: for mentioned in mentions: mentioned_user = self.ensure_user_exists(mentioned) if mentioned_user.waiting: return await message.channel.send( s("play_mention_already", user=mentioned)) mentioned_users.append(mentioned_user) tag_names = tag_names_from_params(params) if len(tag_names) > 5: return await message.channel.send(s("play_too_many_tags")) tags = [] for tag_name in tag_names: tag = self.session.query(Tag).filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name) self.session.add(tag) tags.append(tag) user.enqueue( server=server, channel_xid=message.channel.id, include=mentioned_users, size=size, power=power, tags=tags, ) self.session.commit() found_discord_users = [] if len(user.game.users) == size: for game_user in user.game.users: discord_user = self.get_user(game_user.xid) if not discord_user: # game_user has left the server since queueing game_user.dequeue() else: found_discord_users.append(discord_user) await message.channel.send(s("dm_sent")) if len(found_discord_users ) == size: # all players matched, game is ready user.game.url = self.create_game() user.game.status = "started" game_created_at = datetime.utcnow() response = user.game.to_str() for game_user, discord_user in zip(user.game.users, found_discord_users): await discord_user.send(response) WaitTime.log( self.session, guild_xid=server.guild_xid, channel_xid=message.channel.id, seconds=(game_created_at - game_user.queued_at).total_seconds(), ) game_user.queued_at = None else: # still waiting on more players, game is pending response = user.game.to_str() await message.author.send(response) if friendly: for mention in mentions: await mention.send(response)
async def event(self, prefix, params, message): """ Create many games in batch from an attached CSV data file. _Requires the "SpellBot Admin" role._ For example, if your event is for a Modern tournement you might attach a CSV file with a comment like `!event Player1Username Player2Username`. This would assume that the players' discord user names are found in the "Player1Username" and "Player2Username" CSV columns. The game size is deduced from the number of column names given, so we know the games created in this example are `size:2`. Games will not be created immediately. This is to allow you to verify things look ok. This command will also give you directions on how to actually start the games for this event as part of its reply. * Optional: Add a message by using " -- " followed by the message content. & <column 1> <column 2> ... <column 3> [-- An optional message to add.] """ if not is_admin(message.channel, message.author): return await message.channel.send(s("not_admin")) if not message.attachments: return await message.channel.send(s("event_no_data")) if not params: return await message.channel.send(s("event_no_params")) optional_message = None try: sentry = params.index("--") optional_message = " ".join(params[sentry + 1:]) params = params[0:sentry] except ValueError: pass if optional_message and len(optional_message) >= 255: return await message.channel.send(s("game_message_too_long")) size = len(params) if not (1 < size <= 4): return await message.channel.send(s("event_bad_play_count")) attachment = message.attachments[0] if not attachment.filename.lower().endswith(".csv"): return await message.channel.send(s("event_not_csv")) bdata = await message.attachments[0].read() sdata = bdata.decode("utf-8") has_header = csv.Sniffer().has_header(sdata) if not has_header: return await message.channel.send(s("event_no_header")) server = self.ensure_server_exists(message.channel.guild.id) reader = csv.reader(StringIO(sdata)) header = [column.lower().strip() for column in next(reader)] params = [param.lower().strip() for param in params] if any(param not in header for param in params): return await message.channel.send(s("event_no_header")) columns = [header.index(param) for param in params] event = Event() self.session.add(event) self.session.commit() members = message.channel.guild.members member_lookup = { member.name.lower().strip(): member for member in members } for i, row in enumerate(reader): values = [row[column] for column in columns] players_s = ", ".join([f'"{value}"' for value in values]) player_names = [value.lower().strip() for value in values] if any(not player_name for player_name in player_names): warning = s("event_missing_player", row=i + 1, players=players_s) await message.channel.send(warning) continue player_discord_users = [] for player_name in player_names: player_discord_user = member_lookup.get(player_name) if player_discord_user: player_discord_users.append(player_discord_user) else: warning = s( "event_missing_user", row=i + 1, name=player_name, players=players_s, ) await message.channel.send(warning) if len(player_discord_users) != size: continue player_users = [ self.ensure_user_exists(player_discord_user) for player_discord_user in player_discord_users ] for player_user in player_users: if player_user.waiting: player_user.dequeue() self.session.commit() now = datetime.utcnow() expires_at = now + timedelta(minutes=server.expire) game = Game( created_at=now, expires_at=expires_at, guild_xid=message.channel.guild.id, size=size, updated_at=now, status="ready", message=optional_message, users=player_users, event=event, ) self.session.add(game) self.session.commit() if not event.games: self.session.delete(event) return await message.channel.send(s("event_empty")) await message.channel.send( s("event_created", prefix=prefix, event_id=event.id))