diff --git a/cogs/repl.py b/cogs/repl.py index 6522b59..76a513b 100644 --- a/cogs/repl.py +++ b/cogs/repl.py @@ -5,7 +5,7 @@ import textwrap import traceback from contextlib import redirect_stdout -from typing import Set +from typing import Callable, Set, cast import discord from discord.ext import commands @@ -60,7 +60,7 @@ async def _eval(self, ctx: Context, *, body: str): except SyntaxError as e: return await ctx.send(self.get_syntax_error(e)) - func = env['func'] + func = cast(Callable, env['func']) try: with redirect_stdout(stdout): ret = await func() diff --git a/commandListener.py b/commandListener.py index 502ecd4..57e0f27 100644 --- a/commandListener.py +++ b/commandListener.py @@ -10,15 +10,16 @@ from discord import Member from discord.channel import TextChannel from discord.emoji import Emoji -from discord.ext.commands import (BadArgument, Cog, Context, - MissingRequiredArgument, command) -from discord.ext.commands.errors import CommandError, CommandInvokeError +from discord.ext.commands import BadArgument, Cog, Context, command +from discord.ext.commands.errors import (CommandError, CommandInvokeError, + MissingRequiredArgument) import config as cfg import messageFunctions as msgFnc from converters import (ArgArchivedEvent, ArgDate, ArgDateTime, ArgEvent, - ArgMessage, ArgRole, ArgTime, UnquotedStr) -from errors import MessageNotFound, RoleError, UnexpectedRole + ArgMessages, ArgRole, ArgTime, UnquotedStr) +from errors import (ExtraMessagesFound, MessageNotFound, RoleError, + UnexpectedRole) from event import Event from eventDatabase import EventDatabase from operationbot import OperationBot @@ -153,7 +154,7 @@ async def _create_event(self, ctx: Context, _date: datetime, # Create event and sort events, export event: Event = EventDatabase.createEvent(_date, sideop=sideop, platoon_size=platoon_size) - await msgFnc.createEventMessage(event, self.bot.eventchannel) + await msgFnc.get_or_create_messages(event, self.bot.eventchannel) if not batch: await msgFnc.sortEventMessages(self.bot) EventDatabase.toJson() # Update JSON file @@ -165,8 +166,8 @@ async def _create_event(self, ctx: Context, _date: datetime, async def show(self, ctx: Context, event: ArgEvent): message = await msgFnc.getEventMessage(event, self.bot) await ctx.send(message.jump_url) - await msgFnc.createEventMessage(event, cast(TextChannel, ctx.channel), - update_id=False) + await msgFnc.get_or_create_messages(event, cast(TextChannel, + ctx.channel), update_id=False) # Create event command @command(aliases=['c']) @@ -447,7 +448,8 @@ async def _add_role(self, event: Event, rolename: str, batch=False): # Adding the latest role failed, saving previously added roles await self._update_event(event, reorder=False) raise e - await self._update_event(event, reorder=False, export=(not batch)) + if not batch: + await self._update_event(event, reorder=False) @command(aliases=['ar']) async def addrole(self, ctx: Context, event: ArgEvent, *, @@ -481,8 +483,15 @@ async def addrole(self, ctx: Context, event: ArgEvent, *, await self._add_role(event, rolename) await ctx.send(f"Role {rolename} added to event {event}") + async def _remove_role(self, ctx: Context, event: ArgEvent, role: ArgRole, + check_additional=True): + role_name = role.name + event.remove_role(role, check_additional) + await self._update_event(event, reorder=False) + await ctx.send(f"Role {role_name} removed from {event}") + # Remove additional role from event command - @command(aliases=['rr']) + @command(aliases=['rr', 'removeadditionalrole', 'rar']) async def removerole(self, ctx: Context, event: ArgEvent, *, role: ArgRole): """ @@ -490,10 +499,17 @@ async def removerole(self, ctx: Context, event: ArgEvent, *, Example: removerole 1 Y1 (Bradley) Driver """ - role_name = role.name - event.removeAdditionalRole(role) - await self._update_event(event, reorder=False) - await ctx.send(f"Role {role_name} removed from {event}") + await self._remove_role(ctx, event, role, check_additional=True) + + @command(aliases=['rmr']) + async def removemainrole(self, ctx: Context, event: ArgEvent, + role: ArgRole): + """ + Remove a main role from the event. + + Example: removerole 1 B2 + """ + await self._remove_role(ctx, event, role, check_additional=False) @command(aliases=['rnr', 'rename']) async def renamerole(self, ctx: Context, event: ArgEvent, @@ -512,41 +528,34 @@ async def renamerole(self, ctx: Context, event: ArgEvent, @command(aliases=['rra']) async def removereaction(self, ctx: Context, event: ArgEvent, - reaction: str): + role: ArgRole): """ - Removes a role and the corresponding reaction from the event and updates the message. - """ # NOQA - self._find_remove_reaction(reaction, event) - await self._update_event(event, reorder=False) - await ctx.send(f"Reaction {reaction} removed from {event}") + DEPRECATED. Removes a role and the corresponding reaction from the event and updates the message. - def _find_remove_reaction(self, reaction: str, event: Event): - for group in event.roleGroups.values(): - for role in group.roles: - if role.name == reaction: - group.roles.remove(role) - return - raise BadArgument("No reaction found") + Deprecated: use removemainrole and removerole instead. + """ # NOQA + await ctx.send("This command is deprecated. Use `removerole` (`rr`) " + "and `removemainrole` (`rmr`) instead.") + await self._remove_role(ctx, event, role, check_additional=False) @command(aliases=['rg']) - async def removegroup(self, ctx: Context, eventMessage: ArgMessage, *, + async def removegroup(self, ctx: Context, eventMessages: ArgMessages, *, groupName: UnquotedStr): """ Remove a role group from the event. Example: removegroup 1 Bravo """ - event = EventDatabase.getEventByMessage(eventMessage.id) + event = EventDatabase.getEventByMessage(eventMessages[0].id) if not event.hasRoleGroup(groupName): await ctx.send(f"No role group found with name {groupName}") return # Remove reactions, remove role, update event, add reactions, export - for reaction in event.getReactionsOfGroup(groupName): - await eventMessage.remove_reaction(reaction, self.bot.user) event.removeRoleGroup(groupName) - await msgFnc.updateMessageEmbed(eventMessage, event) + await msgFnc.updateMessageEmbeds(eventMessages, event, + self.bot.eventchannel) EventDatabase.toJson() # Update JSON file await ctx.send(f"Group {groupName} removed from {event}") @@ -609,7 +618,7 @@ async def setterrain(self, ctx: Context, event: ArgEvent, *, """ Set event terrain. - Example: settime 1 Takistan + Example: setterrain 1 Takistan """ # Change terrain, update event, export event.setTerrain(terrain) @@ -816,15 +825,17 @@ async def archive(self, ctx: Context, event: ArgEvent): # Archive event and export EventDatabase.archiveEvent(event) try: - eventMessage = await msgFnc.getEventMessage(event, self.bot) + eventMessageList = await msgFnc.getEventMessages(event, self.bot) except MessageNotFound: await ctx.send(f"Internal error: event {event} without " "a message found") else: - await eventMessage.delete() + for eventMessage in eventMessageList: + await eventMessage.delete() - # Create new message - await msgFnc.createEventMessage(event, self.bot.eventarchivechannel) + # Create messages + await msgFnc.get_or_create_messages( + event, self.bot.eventarchivechannel) await ctx.send(f"Event {event} archived") @@ -832,13 +843,14 @@ async def _delete(self, event: Event, archived=False): # TODO: Move to a more appropriate location EventDatabase.removeEvent(event.id, archived=archived) try: - eventMessage = await msgFnc.getEventMessage( + eventMessageList = await msgFnc.getEventMessages( event, self.bot, archived=archived) except MessageNotFound: # Message already deleted, nothing to be done pass else: - await eventMessage.delete() + for eventMessage in eventMessageList: + await eventMessage.delete() EventDatabase.toJson(archive=archived) # Delete event command @@ -960,7 +972,7 @@ async def _load(self, _: Context, event: Event, data: str, raise ValueError("Malformed data") if target: # Display the loaded event in the command channel - await msgFnc.createEventMessage(event, target, update_id=False) + await msgFnc.get_or_create_messages(event, target, update_id=False) await self._update_event(event) # @command() @@ -972,23 +984,27 @@ async def _load(self, _: Context, event: Event, data: str, # await ctx.send("Event messages created") async def _update_event(self, event: Event, import_db=False, - reorder=True, export=True): + reorder=True, export=True, exact_number=True): # TODO: Move to a more appropriate location if import_db: await self.bot.import_database() # Event instance might have changed because of DB import, get again - event = EventDatabase.getEventByMessage(event.messageID) + event = EventDatabase.getEventByMessage(event.messageIDList[0]) try: - message = await msgFnc.getEventMessage(event, self.bot) - except MessageNotFound: - message = await msgFnc.createEventMessage(event, - self.bot.eventchannel) + messages = await msgFnc.getEventMessages(event, self.bot, + exact_number=exact_number) + except (MessageNotFound, ExtraMessagesFound) as e: + messages = await msgFnc.get_or_create_messages( + event, self.bot.eventchannel) + if isinstance(e, MessageNotFound): + # New messages were created, we need to reorder the messages + await msgFnc.sortEventMessages(self.bot) + else: + await msgFnc.updateMessageEmbeds(messages, event, + self.bot.eventchannel) + await msgFnc.updateReactions(event, bot=self.bot, reorder=reorder) - await msgFnc.updateMessageEmbed(eventMessage=message, - updatedEvent=event) - await msgFnc.updateReactions(event=event, message=message, - reorder=reorder) if export: EventDatabase.toJson() @@ -1020,7 +1036,7 @@ async def shutdown(self, ctx: Context): @Cog.listener() @staticmethod async def on_command_error(ctx: Context, error: Exception): - # pylint: disable=no-self-use, no-else-return + # pylint: disable=no-else-return if isinstance(error, MissingRequiredArgument): await ctx.send(f"Missing argument. See: `{CMD}help {ctx.command}`") return diff --git a/config.py b/config.py index a900458..962f3a7 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,22 @@ "\N{REGIONAL INDICATOR SYMBOL LETTER H}", "\N{REGIONAL INDICATOR SYMBOL LETTER I}", "\N{REGIONAL INDICATOR SYMBOL LETTER J}", + "\N{REGIONAL INDICATOR SYMBOL LETTER K}", + "\N{REGIONAL INDICATOR SYMBOL LETTER L}", + "\N{REGIONAL INDICATOR SYMBOL LETTER M}", + "\N{REGIONAL INDICATOR SYMBOL LETTER N}", + "\N{REGIONAL INDICATOR SYMBOL LETTER O}", + "\N{REGIONAL INDICATOR SYMBOL LETTER P}", + "\N{REGIONAL INDICATOR SYMBOL LETTER Q}", + "\N{REGIONAL INDICATOR SYMBOL LETTER R}", + "\N{REGIONAL INDICATOR SYMBOL LETTER S}", + "\N{REGIONAL INDICATOR SYMBOL LETTER T}", + "\N{REGIONAL INDICATOR SYMBOL LETTER U}", + "\N{REGIONAL INDICATOR SYMBOL LETTER V}", + "\N{REGIONAL INDICATOR SYMBOL LETTER W}", + "\N{REGIONAL INDICATOR SYMBOL LETTER X}", + "\N{REGIONAL INDICATOR SYMBOL LETTER Y}", + "\N{REGIONAL INDICATOR SYMBOL LETTER Z}", ] ADDITIONAL_ROLE_NAMES = [ diff --git a/converters.py b/converters.py index 89c9dbc..11a3952 100644 --- a/converters.py +++ b/converters.py @@ -1,6 +1,6 @@ import re from datetime import date, datetime, time -from typing import cast +from typing import List, Union, cast from discord import Message from discord.ext.commands.context import Context @@ -184,7 +184,7 @@ async def convert(cls, _: Context, arg: str) -> time: class ArgMessage(Message): @classmethod - async def convert(cls, ctx: Context, arg: str) -> Message: + async def convert(cls, ctx: Context, arg: Union[str, int]) -> Message: try: event_id = int(arg) except ValueError as e: @@ -198,3 +198,11 @@ async def convert(cls, ctx: Context, arg: str) -> Message: raise BadArgument(str(e)) from e return message + + +class ArgMessages(list): + @classmethod + async def convert(cls, ctx: Context, arg: str) -> List[Message]: + event = await ArgEvent.convert(ctx, arg) + return [await ArgMessage.convert(ctx, id) + for id in event.messageIDList] diff --git a/errors.py b/errors.py index 46f7530..9addd19 100644 --- a/errors.py +++ b/errors.py @@ -10,6 +10,10 @@ class MessageNotFound(Exception): pass +class ExtraMessagesFound(Exception): + pass + + class UnexpectedRole(Exception): pass diff --git a/event.py b/event.py index dd190d7..2c75031 100644 --- a/event.py +++ b/event.py @@ -7,7 +7,7 @@ import config as cfg from additional_role_group import AdditionalRoleGroup from errors import RoleError, RoleGroupNotFound, RoleNotFound, RoleTaken -from role import Role +from role import ReactionEmoji, Role from roleGroup import RoleGroup from secret import PLATOON_SIZE @@ -20,8 +20,12 @@ COLOR = 0xFF4500 SIDEOP_COLOR = 0x0045FF WW2_SIDEOP_COLOR = 0x808080 +# TODO: Change to some reasonable number or remove completely +# 36 additional Emotes # first embed - better: len(cfg.ADDITIONAL_ROLE_EMOJIS) +# + something +MAX_REACTIONS = 56 # Discord API limitation -MAX_REACTIONS = 20 +REACTIONS_PER_MESSAGE = 20 class User: @@ -46,7 +50,7 @@ def __init__(self, date: datetime.datetime, guildEmojis: Tuple[Emoji, ...], self.mods = MODS self.color = COLOR if not sideop else SIDEOP_COLOR self.roleGroups: Dict[str, RoleGroup] = {} - self.messageID = 0 + self.messageIDList = [0] self.id = eventID self.sideop = sideop if platoon_size is None: @@ -219,10 +223,9 @@ def reorder(self): self.roleGroups = newGroups return warnings - # Return an embed for the event - def createEmbed(self) -> Embed: + def _create_embed(self, title: str) -> Embed: date = self.date.strftime(f"%a %Y-%m-%d - %H:%M {cfg.TIME_ZONE}") - title = f"{self.title} ({date})" + title = f"{title} ({date})" local_time = f"" server_port = (f"\nServer port: **{self.port}**" if self.port != cfg.PORT_DEFAULT else "") @@ -240,22 +243,85 @@ def createEmbed(self) -> Embed: f"{server_port}" f"{event_description}" f"{mods}") - eventEmbed = Embed(title=title, description=description, - colour=self.color) + embed = Embed(title=title, description=description, colour=self.color) + embed.set_footer(text="Event ID: " + str(self.id)) + return embed + + def create_dummy_embed(self) -> Embed: + """Return the first embed for the event""" + return self._create_embed(self.title) + + def createEmbeds(self) -> Tuple[List[Embed], List[List[ReactionEmoji]]]: + """Return a list of embeds and their corresponding reactions for the + event""" + eventEmbed = self._create_embed(self.title) + reactions = [] # Add field to embed for every rolegroup for group in self.roleGroups.values(): - if len(group.roles) > 0: + if len(group) > 0 and group.name != "Additional": + # The Additional group is handled separately eventEmbed.add_field(name=group.name, value=str(group), inline=group.isInline) + reactions += group.get_reactions() elif group.name == "Dummy": eventEmbed.add_field(name="\N{ZERO WIDTH SPACE}", value="\N{ZERO WIDTH SPACE}", inline=group.isInline) - eventEmbed.set_footer(text="Event ID: " + str(self.id)) + if len(self.roleGroups["Additional"]) == 0: + # There are no additional roles, the embed is ready + return ([eventEmbed], [reactions]) + + # Handle additional roles + if len(self.getReactions()) <= REACTIONS_PER_MESSAGE: + # All roles fit in a single message + additional = self.roleGroups["Additional"] + eventEmbed.add_field(name=additional.name, value=str(additional), + inline=additional.isInline) + return ([eventEmbed], [reactions + additional.get_reactions()]) + + embeds, additional_reactions = self.createAdditionalEmbeds() + return ([eventEmbed] + embeds, [reactions] + additional_reactions) + + def createAdditionalEmbeds(self) -> Tuple[List[Embed], + List[List[ReactionEmoji]]]: + """Creates additional embeds. + + The number of embeds depend on the Additional roles group""" + embeds: List[Embed] = [] + all_reactions: List[List[ReactionEmoji]] = [] + group = self.roleGroups["Additional"] + + # Substract 1 because REACTIONS_PER_MESSAGE roles still fit in a single + # message, otherwise we'd get an empty extra embed on the threshold + embed_count = ((len(group) - 1) // REACTIONS_PER_MESSAGE) + 1 + + for embed_number in range(embed_count): + role_list = "" + reactions = [] + first = embed_number * REACTIONS_PER_MESSAGE + last = (embed_number + 1) * REACTIONS_PER_MESSAGE + for role in group.roles[first:last]: + role_list += f'{str(role)}\n' + reactions.append(role.emoji) + if role_list == "": + # Didn't add any roles -> skipping this embed. Discord doesn't + # like embeds with empty fields. This should only happen if + # this function was called when Additional group is empty + continue + eventEmbed = self._create_embed("Additional Roles") + if embed_count > 1: + embed_counter = f" ({embed_number + 1}/{embed_count})" + else: + embed_counter = "" + eventEmbed.add_field(name=f"{group.name}{embed_counter}", + value=role_list, inline=False) + eventEmbed.set_footer(text="Event ID: " + str(self.id)) + embeds.append(eventEmbed) + all_reactions.append(reactions) - return eventEmbed + return (embeds, all_reactions) # Add default role groups def _add_default_role_groups(self): @@ -269,27 +335,28 @@ def _add_default_roles(self): # Only add role if the group exists if groupName in self.roleGroups: emoji = self.normalEmojis[name] - newRole = Role(name, emoji, False) + newRole = Role(name, emoji, groupName, show_name=False) self.roleGroups[groupName].addRole(newRole) # Add an additional role to the event def addAdditionalRole(self, name: str) -> str: # check if this role already exists - for roleGroup in self.roleGroups.values(): - role: Role - for role in roleGroup.roles: - if role.name == name: - raise RoleError(f"Role with name {name} already exists, " - "not adding new role") + try: + self.findRoleWithName(name) + except RoleNotFound: + pass + else: + raise RoleError(f"Role with name {name} already exists, " + "not adding new role") # Find next emoji for additional role - if self.countReactions() >= MAX_REACTIONS: + if self.reaction_count >= MAX_REACTIONS: raise RoleError(f"Too many roles, not adding role {name}") emoji = cfg.ADDITIONAL_ROLE_EMOJIS[self.additional_role_count] # Create role - newRole = Role(name, emoji, show_name=True) + newRole = Role(name, emoji, "Additional", show_name=True) # Add role to additional roles self.roleGroups["Additional"].addRole(newRole) @@ -306,12 +373,12 @@ def renameAdditionalRole(self, role: Role, new_name: str): self._check_additional(role) role.name = new_name - def removeAdditionalRole(self, role: Union[str, Role]): + def remove_role(self, role: Role, check_additional=True): """Remove an additional role from the event.""" # Remove role from additional roles - if isinstance(role, Role): + if check_additional: self._check_additional(role) - self.roleGroups["Additional"].removeRole(role) + self.roleGroups[role.group_name].removeRole(role) def removeRoleGroup(self, groupName: str) -> bool: """ @@ -355,19 +422,12 @@ def _getNormalEmojis(self, guildEmojis) -> Dict[str, Emoji]: return normalEmojis - def getReactions(self) -> List[Union[str, Emoji]]: + def getReactions(self) -> List[ReactionEmoji]: """Return reactions of all roles and extra reactions""" reactions = [] - for roleGroup in self.roleGroups.values(): - role: Role - for role in roleGroup.roles: - emoji = role.emoji - # Skip the ZEUS reaction. Zeuses can only be signed up using - # the signup command - if not (isinstance(emoji, Emoji) - and emoji.name == cfg.EMOJI_ZEUS): - reactions.append(role.emoji) + for role_group in self.roleGroups.values(): + reactions += role_group.get_reactions() if self.sideop: if cfg.ATTENDANCE_EMOJI: @@ -375,11 +435,12 @@ def getReactions(self) -> List[Union[str, Emoji]]: return reactions - def countReactions(self) -> int: + @property + def reaction_count(self) -> int: """Count how many reactions a message should have.""" return len(self.getReactions()) - def getReactionsOfGroup(self, groupName: str) -> List[Union[str, Emoji]]: + def getReactionsOfGroup(self, groupName: str) -> List[ReactionEmoji]: """Find reactions of a given role group.""" reactions = [] @@ -423,6 +484,9 @@ def hasRoleGroup(self, groupName: str) -> bool: def get_additional_role(self, role_name: str) -> Role: return self.roleGroups["Additional"][role_name] + def getReactionsPerMessage(self) -> int: + return REACTIONS_PER_MESSAGE + def signup(self, roleToSet: Role, user: discord.abc.User, replace=False) \ -> Tuple[Optional[Role], User]: """Add username to role. @@ -491,7 +555,7 @@ def toJson(self, brief_output=False) -> Dict[str, Any]: data["mods"] = self.mods if not brief_output: data["color"] = self.color - data["messageID"] = self.messageID + data["messageIDList"] = self.messageIDList data["platoon_size"] = self.platoon_size data["sideop"] = self.sideop data["roleGroups"] = roleGroupsData @@ -509,7 +573,7 @@ def fromJson(self, eventID, data: dict, emojis, manual_load=False): self.mods = str(data.get("mods", MODS)) if not manual_load: self.color = int(data.get("color", COLOR)) - self.messageID = int(data.get("messageID", 0)) + self.messageIDList = list(data.get("messageIDList", [0])) self.platoon_size = str(data.get("platoon_size", PLATOON_SIZE)) self.sideop = bool(data.get("sideop", False)) # TODO: Handle missing roleGroups diff --git a/eventDatabase.py b/eventDatabase.py index 7ef9a4c..5c1fd65 100644 --- a/eventDatabase.py +++ b/eventDatabase.py @@ -1,7 +1,7 @@ import json import os from datetime import datetime -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from discord import Emoji @@ -88,8 +88,9 @@ def getEventByMessage(cls, messageID: int, archived=False) -> Event: collection = cls.events for event in collection.values(): - if event.messageID == messageID: - return event + for eventMessageID in event.messageIDList: + if eventMessageID == messageID: + return event raise EventNotFound(f"No event found with message ID {messageID}") @classmethod @@ -126,23 +127,33 @@ def getArchivedEventByID(cls, eventID: int): @classmethod def sortEvents(cls): - sortedEvents = [] - messageIDs = [] + sortedEvents: List[Event] = [] + messageIDLists: List[int] = [] # Store existing events for event in cls.events.values(): sortedEvents.append(event) - messageIDs.append(event.messageID) + messageIDLists += event.messageIDList # Sort events based on date and time sortedEvents.sort(key=lambda event: event.date, reverse=True) - messageIDs.sort(reverse=True) + messageIDLists.sort(reverse=True) # Fill events again cls.events = {} for event in sortedEvents: # event = sortedEvents[index] - event.messageID = messageIDs.pop() + + event.messageIDList = [] + embeds, _ = event.createEmbeds() + for _ in embeds: + # Each event requires one message per embed + try: + message_id = messageIDLists.pop() + except IndexError: + # No more messages left, rest will be created later + continue + event.messageIDList.append(message_id) cls.events[event.id] = event @classmethod diff --git a/eventListener.py b/eventListener.py index a082f1b..705820f 100644 --- a/eventListener.py +++ b/eventListener.py @@ -43,7 +43,8 @@ async def on_ready(self): self.bot.processing = False @Cog.listener() - async def on_raw_reaction_add(self, payload: RawReactionActionEvent): + async def on_raw_reaction_add(self, payload: RawReactionActionEvent) \ + -> None: if payload.member == self.bot.user or \ payload.channel_id != self.bot.eventchannel.id: @@ -77,11 +78,10 @@ async def on_raw_reaction_add(self, payload: RawReactionActionEvent): f"({user.name}#{user.discriminator})\n" f"{message.jump_url}") return - else: - await self._handle_signup(event, payload.emoji, user, message) + await self._handle_signup(event, payload.emoji, user) async def _handle_signup(self, event: Event, partial_emoji: PartialEmoji, - user: User, message: Message): + user: User) -> None: # Get emoji string if partial_emoji.is_custom_emoji(): emoji: Union[PartialEmoji, str] = partial_emoji @@ -135,7 +135,9 @@ async def _handle_signup(self, event: Event, partial_emoji: PartialEmoji, old_role = f"{removed_role.display_name} -> " # Update discord embed - await msgFnc.updateMessageEmbed(message, event) + eventMessages = await msgFnc.getEventMessages(event, self.bot) + await msgFnc.updateMessageEmbeds(eventMessages, event, + self.bot.eventchannel) EventDatabase.toJson() delta_message = "" diff --git a/messageFunctions.py b/messageFunctions.py index a0df3be..7f1f495 100644 --- a/messageFunctions.py +++ b/messageFunctions.py @@ -1,28 +1,65 @@ from typing import Dict, List, Union, cast -from discord import Emoji, Message, NotFound, TextChannel +from discord import Message, NotFound, TextChannel from discord.embeds import Embed +from discord.emoji import Emoji from discord.errors import Forbidden +from discord.partial_emoji import PartialEmoji +from discord.reaction import Reaction -from errors import MessageNotFound, RoleError +from errors import ExtraMessagesFound, MessageNotFound, RoleError from event import Event from eventDatabase import EventDatabase from operationbot import OperationBot +from role import ReactionEmoji -async def getEventMessage(event: Event, bot: OperationBot, archived=False) \ +async def getEventMessage(event: Event, bot: OperationBot = None, + archived=False, message_id=None, + channel: TextChannel = None) \ -> Message: - """Get a message related to an event.""" - if archived: - channel = bot.eventarchivechannel - else: - channel = bot.eventchannel + """Get the a single message related to an event. + if message_id is not set, returns the first (main) message""" + if channel is None: + if bot is None: + raise ValueError("Requires either the `channel` or `bot` " + "argument to be provided") + if archived: + channel = bot.eventarchivechannel + else: + channel = bot.eventchannel + if message_id is None: + message_id = event.messageIDList[0] try: - return await channel.fetch_message(event.messageID) + return await channel.fetch_message(message_id) except NotFound as e: raise MessageNotFound("No event message found with " - f"message ID {event.messageID}") from e + f"message ID {message_id}") from e + + +async def getEventMessages(event: Event, bot: OperationBot = None, + archived=False, channel: TextChannel = None, + exact_number=True) \ + -> List[Message]: + """Get all messages related to an event. + + Raises MessageNotFound if the message is missing or if the event embeds + require more messages than can currently be found. + + If exact_number is set, also raises an error if too many messages can be + found.""" + messages = [] + for messageID in event.messageIDList: + messages.append(await getEventMessage( + event, bot=bot, archived=archived, message_id=messageID, + channel=channel)) + embeds, _ = event.createEmbeds() + if len(messages) < len(embeds): + raise MessageNotFound("Not all event messages found") + if exact_number and len(messages) > len(embeds): + raise ExtraMessagesFound("Too many event messages found") + return messages async def sortEventMessages(bot: OperationBot): @@ -31,40 +68,81 @@ async def sortEventMessages(bot: OperationBot): Raises MessageNotFound if messages are missing.""" EventDatabase.sortEvents() - event: Event for event in EventDatabase.events.values(): + messageList = await get_or_create_messages(event, bot.eventchannel) + await updateMessageEmbeds(messageList, event, bot.eventchannel) + for event in EventDatabase.events.values(): + # Updating reactions takes a while, so we do it in a separate task + await updateReactions(event, bot=bot) + + +async def get_or_create_messages(event: Event, channel: TextChannel, + update_id=True) -> List[Message]: + """Create new or missing event messages, delete extra messages.""" + try: + all_messages = await getEventMessages(event, channel=channel) + except (MessageNotFound, ExtraMessagesFound): + pass + else: + # All messages were found without issues, nothing to do + return all_messages + + messages: List[Message] = [] + not_found: List[int] = [] + for message_id in event.messageIDList: try: - message = await getEventMessage(event, bot) - except MessageNotFound as e: - raise MessageNotFound(f"sortEventMessages: {e}") from e - await updateMessageEmbed(message, event) - await updateReactions(event, message=message) - - -# from EventDatabase -async def createEventMessage(event: Event, channel: TextChannel, - update_id=True) -> Message: - """Create a new event message.""" - # Create embed and message - embed = event.createEmbed() - message = await channel.send(embed=embed) + message = await getEventMessage(event, message_id=message_id, + channel=channel) + messages.append(message) + except MessageNotFound: + not_found.append(message_id) + + # Get all message IDs that corresponded to an existing message + message_ids = [x for x in event.messageIDList if x not in not_found] + + embeds, _ = event.createEmbeds() + existing_messages = len(message_ids) + difference = existing_messages - len(embeds) + if difference > 0: + # We have extra messages that need to be deleted. We could try to reuse + # the messages for other events instead of deleting, but keeping track + # of that would be too complicated. + for message_id in message_ids[difference:]: + message = await channel.fetch_message(message_id) + if message in messages: + messages.remove(message) + await message.delete() + elif difference < 0: + # We have too few messages, create new ones + for i in range(abs(difference)): + # The embeds will be correct if there were no existing messages + # to begin with (when running the `show` command). Otherwise the + # will be updated afterwards anyway. + message = await channel.send(embed=embeds[existing_messages + i]) + messages.append(message) + if update_id: - event.messageID = message.id + message_ids = [message.id for message in messages] + event.messageIDList = list(message_ids) - return message + return messages -# was: EventDatabase.updateEvent -async def updateMessageEmbed(eventMessage: Message, updatedEvent: Event) \ +async def updateMessageEmbeds(eventMessageList: List[Message], + event: Event, channel: TextChannel) \ -> None: """Update the embed and footer of a message.""" - newEventEmbed = updatedEvent.createEmbed() - await eventMessage.edit(embed=newEventEmbed) + embeds, _ = event.createEmbeds() + if len(embeds) == len(eventMessageList): + for message, embed in zip(eventMessageList, embeds): + await message.edit(embed=embed) + else: + messages = await get_or_create_messages(event, channel) + await updateMessageEmbeds(messages, event, channel) -# from EventDatabase -async def updateReactions(event: Event, message: Message = None, bot=None, - reorder=False): +async def updateReactions(event: Event, messageList: List[Message] = None, + bot: OperationBot = None, reorder=False): """ Update reactions of an event message. @@ -72,70 +150,56 @@ async def updateReactions(event: Event, message: Message = None, bot=None, the function with reorder = True causes all reactions to be removed and reinserted in the correct order. """ - if message is None: + # TODO make efficient again (if necessary, works quiet well rn) + # TODO: Only add missing reactions / remove old ones instead of removing + # everything + if not messageList: if bot is None: - raise ValueError("Requires either the `message` or `bot` argument" - " to be provided") - message = await getEventMessage(event, bot) - - reactions: List[Union[Emoji, str]] = event.getReactions() - reactionsCurrent = message.reactions - reactionEmojisCurrent = {} - reactionsToRemove = [] - reactionEmojisToAdd = [] - - # Find current reaction emojis - for reaction in reactionsCurrent: - reactionEmojisCurrent[reaction.emoji] = reaction - - if list(reactionEmojisCurrent) == reactions: - # Emojis are already correct, no need for further edits - return - - if reorder: - # Re-adding all reactions in order to put them in the correct order - await message.clear_reactions() - reactionEmojisToAdd = reactions - else: - # Find emojis to remove - for emoji, reaction in reactionEmojisCurrent.items(): - if emoji not in reactions: - reactionsToRemove.append(reaction) - - # Find emojis to add - for emoji in reactions: - if emoji not in reactionEmojisCurrent.keys(): - reactionEmojisToAdd.append(emoji) - - # Remove existing unintended reactions - for reaction in reactionsToRemove: - await message.clear_reaction(reaction) - - # Add missing emojis - for emoji in reactionEmojisToAdd: - try: - await message.add_reaction(emoji) - except Forbidden as e: - if e.code == 30010: - raise RoleError("Too many reactions, not adding role " - f"{emoji}. This should not happen.") from e - -# async def createMessages(events: Dict[int, Event], bot): -# # Update event message contents and add reactions - -# # Clear events channel -# if cfg.PURGE_ON_CONNECT: -# await bot.eventchannel.purge(limit=100) - -# for event in events.values(): -# await createEventMessage(event, bot.eventchannel) -# for event in events.values(): -# message = await getEventMessage(event, bot) -# await updateMessageEmbed(message, event) -# await updateReactions(event, bot=bot) - - -def messageEventId(message: Message) -> int: + raise ValueError("Requires either the `messageList` or `bot` " + "argument to be provided") + messageList = await getEventMessages(event, bot) + + # TODO: reactions is a list of lists: outer list per embed, inner list + # reactions of that embed. Should loop over messages and lists of reactions + _, all_reactions = event.createEmbeds() + for message, reactions in zip(messageList, all_reactions): + current_reactions: Dict[Union[Emoji, PartialEmoji, str], Reaction] = {} + new_reactions: List[ReactionEmoji] = [] + # Find current reaction emojis + for reaction in message.reactions: + current_reactions[reaction.emoji] = reaction + + if list(current_reactions) == reactions: + # Emojis are already correct, moving to next message + continue + + if reorder: + # Re-adding all reactions in order to put them in the correct order + await message.clear_reactions() + new_reactions = reactions + else: + # Find emojis to remove + for emoji, reaction in current_reactions.items(): + if emoji not in reactions: + await message.clear_reaction(reaction) + + # Find emojis to add + for new_reaction in reactions: + if new_reaction not in current_reactions.keys(): + new_reactions.append(new_reaction) + + # Add missing emojis + for new_reaction in new_reactions: + try: + await message.add_reaction(new_reaction) + except Forbidden as e: + if e.code == 30010: + raise RoleError( + f"Too many reactions, not adding role {new_reaction}. " + "This should not happen.") from e + + +def _messageEventId(message: Message) -> int: if len(message.embeds) == 0: raise ValueError("Message has no embeds") footer = message.embeds[0].footer @@ -149,40 +213,37 @@ def messageEventId(message: Message) -> int: async def syncMessages(events: Dict[int, Event], bot: OperationBot): sorted_events = sorted(list(events.values()), key=lambda event: event.date) for event in sorted_events: - try: - message = await getEventMessage(event, bot) - except MessageNotFound: - print(f"Missing a message for event {event}, creating") - await createEventMessage(event, bot.eventchannel) - else: - if messageEventId(message) == event.id: - print(f"Found message {message.id} for event {event}") + missing_ids = [] + new_ids = [] + for message_id in event.messageIDList: + try: + message = await getEventMessage(event, bot, + message_id=message_id) + except MessageNotFound: + print(f"Missing a message for event {event}, creating") + message = await bot.eventchannel.send( + embed=event.create_dummy_embed()) + new_ids.append(message.id) + missing_ids.append(message_id) else: - print(f"Found incorrect message for event {event}, deleting " - f"and creating") - # Technically multiple events might have the same saved - # messageID but it's simpler to just recreate messages here if - # the event ID doesn't match - await message.delete() - await createEventMessage(event, bot.eventchannel) + if _messageEventId(message) == event.id: + print(f"Found message {message.id} for event {event}") + else: + print(f"Found incorrect message for event {event}, " + f"deleting and creating") + # Technically multiple events might have the same saved + # messageID but it's simpler to just recreate messages here + # if the event ID doesn't match + await message.delete() + await _send_message(event, bot.eventchannel) + missing_ids.append(message_id) + # Remove missing or deleted IDs + event.messageIDList = [x for x in event.messageIDList + if x not in missing_ids] + new_ids await sortEventMessages(bot) -# async def importMessages(events: Dict[int, Event], bot): -# found = 0 -# async for message in bot.eventchannel.history(): -# if len(message.embeds) > 0: -# print("embeds", message.embeds) -# footer = message.embeds[0].footer.text -# print("footer", footer) -# event_id = int(footer.split(' ')[-1]) -# if event_id in events: -# events[event_id].messageID = message.id -# found += 1 -# else: -# print("Found a message {} with unknown event id {}" -# .format(message.id, event_id)) -# if found >= len(events): -# print("Found all messages") -# break +async def _send_message(event: Event, channel: TextChannel): + message = await channel.send(embed=event.create_dummy_embed()) + event.messageIDList.append(message.id) diff --git a/role.py b/role.py index 401b894..8011856 100644 --- a/role.py +++ b/role.py @@ -3,15 +3,19 @@ from discord import Emoji +ReactionEmoji = Union[Emoji, str] + + class Role: - def __init__(self, name: str, emoji: Union[str, Emoji], + def __init__(self, name: str, emoji: ReactionEmoji, group_name: str, show_name: bool = False): self.name = name self.emoji = emoji self.show_name = show_name self.userID: Optional[int] = None self.userName = "" + self.group_name = group_name def __str__(self): # Add name after emote if it should display @@ -48,7 +52,7 @@ def fromJson(self, data: dict, manual_load=False): self.userName = data["userName"] @property - def display_name(self) -> Union[str, Emoji]: + def display_name(self) -> Union[str, ReactionEmoji]: if self.show_name: return f"{self.emoji} {self.name}" return self.emoji diff --git a/roleGroup.py b/roleGroup.py index d2bb157..cd76884 100644 --- a/roleGroup.py +++ b/roleGroup.py @@ -4,7 +4,7 @@ import config as cfg from errors import RoleNotFound, UnexpectedRole -from role import Role +from role import ReactionEmoji, Role class RoleGroup: @@ -36,7 +36,7 @@ def addRole(self, role: Role): self.roles.append(role) # Remove role from the group - def removeRole(self, role: Union[str, Role]): + def removeRole(self, role: Union[str, Role]) -> None: try: if isinstance(role, str): name = role @@ -45,8 +45,18 @@ def removeRole(self, role: Union[str, Role]): name = role.name self.roles.remove(role) except (KeyError, ValueError) as e: - raise RoleNotFound("Could not find an additional role to remove " - f"with the name {name}") from e + raise RoleNotFound(f"Could not find a role named {name} to remove " + f"from group {self.name}") from e + + def get_reactions(self) -> List[ReactionEmoji]: + reactions = [] + for role in self.roles: + emoji = role.emoji + # Skip the ZEUS reaction. Zeuses can only be signed up using + # the signup command + if not (isinstance(emoji, Emoji) and emoji.name == cfg.EMOJI_ZEUS): + reactions.append(role.emoji) + return reactions def __str__(self) -> str: roleGroupString = "" @@ -90,7 +100,7 @@ def fromJson(self, data: dict, emojis: Tuple[Emoji, ...], if not manual_load: # Only create new roles if we're not loading data manually from # the command channel - role = Role(roleData["name"], roleEmoji, + role = Role(roleData["name"], roleEmoji, self.name, self.get_corrected_name(roleData)) self.roles.append(role) else: