diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb75a3ce6..c003f1db37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. -# v3.9.5-dev1 +# v3.10.0-dev2 + +## Added + +- Ability to have group conversations. ([GH #143](https://github.com/kyb3r/modmail/issues/143)) ## Fixed diff --git a/bot.py b/bot.py index eccdfd3868..456e340218 100644 --- a/bot.py +++ b/bot.py @@ -964,6 +964,15 @@ async def process_dm_modmail(self, message: discord.Message) -> None: logger.error("Failed to send message:", exc_info=True) await self.add_reaction(message, blocked_emoji) else: + for user in thread.recipients: + # send to all other recipients + if user != message.author: + try: + await thread.send(message, user) + except Exception: + # silently ignore + logger.error("Failed to send message:", exc_info=True) + await self.add_reaction(message, sent_emoji) self.dispatch("thread_reply", thread, False, message, False, False) @@ -1224,9 +1233,10 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: - if await self.is_blocked(thread.recipient): - return - await thread.recipient.trigger_typing() + for user in thread.recipients: + if await self.is_blocked(user): + continue + await user.trigger_typing() async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) @@ -1286,20 +1296,22 @@ async def handle_reaction_events(self, payload): if not thread: return try: - _, linked_message = await thread.find_linked_messages( + _, *linked_message = await thread.find_linked_messages( message.id, either_direction=True ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return - if self.config["transfer_reactions"] and linked_message is not None: + if self.config["transfer_reactions"] and linked_message is not [None]: if payload.event_type == "REACTION_ADD": - if await self.add_reaction(linked_message, reaction): - await self.add_reaction(message, reaction) + for msg in linked_message: + await self.add_reaction(msg, reaction) + await self.add_reaction(message, reaction) else: try: - await linked_message.remove_reaction(reaction, self.user) + for msg in linked_message: + await msg.remove_reaction(reaction, self.user) await message.remove_reaction(reaction, self.user) except (discord.HTTPException, discord.InvalidArgument) as e: logger.warning("Failed to remove reaction: %s", e) diff --git a/cogs/modmail.py b/cogs/modmail.py index 08930aa946..ef816a99c3 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -681,6 +681,48 @@ async def title(self, ctx, *, name: str): await ctx.message.pin() await self.bot.add_reaction(ctx.message, sent_emoji) + @commands.command(cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def adduser(self, ctx, *, user: discord.Member): + """Adds a user to a modmail thread""" + + curr_thread = await self.bot.threads.find(recipient=user) + if curr_thread: + em = discord.Embed( + title="Error", + description=f"User is already in a thread: {curr_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + else: + em = discord.Embed( + title="New Thread (Group)", + description=f"{ctx.author.name} has added you to a Modmail thread.", + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = datetime.utcnow() + em.set_footer(text=f"{ctx.author}", icon_url=ctx.author.avatar_url) + await user.send(embed=em) + + em = discord.Embed( + title="New User", + description=f"{ctx.author.name} has added {user.name} to the Modmail thread.", + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = datetime.utcnow() + em.set_footer(text=f"{user}", icon_url=user.avatar_url) + + for i in ctx.thread.recipients: + await i.send(embed=em) + + await ctx.thread.add_user(user) + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) async def logs(self, ctx, *, user: User = None): @@ -1463,15 +1505,19 @@ async def repair(self, ctx): and message.embeds[0].footer.text ): user_id = match_user_id(message.embeds[0].footer.text) + other_recipients = match_other_recipients(ctx.channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid) + if user_id != -1: recipient = self.bot.get_user(user_id) if recipient is None: self.bot.threads.cache[user_id] = thread = Thread( - self.bot.threads, user_id, ctx.channel + self.bot.threads, user_id, ctx.channel, other_recipients ) else: self.bot.threads.cache[user_id] = thread = Thread( - self.bot.threads, recipient, ctx.channel + self.bot.threads, recipient, ctx.channel, other_recipients ) thread.ready = True logger.info( @@ -1516,13 +1562,18 @@ async def repair(self, ctx): await thread.channel.send(embed=embed) except discord.HTTPException: pass + + other_recipients = match_other_recipients(ctx.channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid) + if recipient is None: self.bot.threads.cache[user.id] = thread = Thread( - self.bot.threads, user_id, ctx.channel + self.bot.threads, user_id, ctx.channel, other_recipients ) else: self.bot.threads.cache[user.id] = thread = Thread( - self.bot.threads, recipient, ctx.channel + self.bot.threads, recipient, ctx.channel, other_recipients ) thread.ready = True logger.info("Setting current channel's topic to User ID and created new thread.") diff --git a/core/thread.py b/core/thread.py index 8eccb04c02..5638acbe44 100644 --- a/core/thread.py +++ b/core/thread.py @@ -19,6 +19,7 @@ days, match_title, match_user_id, + match_other_recipients, truncate, format_channel_name, ) @@ -34,6 +35,7 @@ def __init__( manager: "ThreadManager", recipient: typing.Union[discord.Member, discord.User, int], channel: typing.Union[discord.DMChannel, discord.TextChannel] = None, + other_recipients: typing.List[typing.Union[discord.Member, discord.User]] = [], ): self.manager = manager self.bot = manager.bot @@ -45,6 +47,7 @@ def __init__( raise CommandError("Recipient cannot be a bot.") self._id = recipient.id self._recipient = recipient + self._other_recipients = other_recipients self._channel = channel self.genesis_message = None self._ready_event = asyncio.Event() @@ -54,7 +57,7 @@ def __init__( self._cancelled = False def __repr__(self): - return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id})' + return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id}, other_recipienets={len(self._other_recipients)})' async def wait_until_ready(self) -> None: """Blocks execution until the thread is fully set up.""" @@ -80,6 +83,10 @@ def channel(self) -> typing.Union[discord.TextChannel, discord.DMChannel]: def recipient(self) -> typing.Optional[typing.Union[discord.User, discord.Member]]: return self._recipient + @property + def recipients(self) -> typing.List[typing.Union[discord.User, discord.Member]]: + return [self._recipient] + self._other_recipients + @property def ready(self) -> bool: return self._ready_event.is_set() @@ -103,6 +110,23 @@ def cancelled(self, flag: bool): for i in self.wait_tasks: i.cancel() + @classmethod + async def from_channel( + cls, manager: "ThreadManager", channel: discord.TextChannel + ) -> "Thread": + recipient_id = match_user_id( + channel.topic + ) # there is a chance it grabs from another recipient's main thread + recipient = manager.bot.get_user(recipient_id) or await manager.bot.fetch_user( + recipient_id + ) + + other_recipients = match_other_recipients(channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = manager.bot.get_user(uid) or await manager.bot.fetch_user(uid) + + return cls(manager, recipient or recipient_id, channel, other_recipients) + async def setup(self, *, creator=None, category=None, initial_message=None): """Create the thread channel and other io related initialisation tasks""" self.bot.dispatch("thread_initiate", self, creator, category, initial_message) @@ -619,23 +643,30 @@ async def find_linked_messages( except ValueError: raise ValueError("Malformed thread message.") - async for msg in self.recipient.history(): - if either_direction: - if msg.id == joint_id: - return message1, msg + messages = [message1] + for user in self.recipients: + async for msg in user.history(): + if either_direction: + if msg.id == joint_id: + return message1, msg - if not (msg.embeds and msg.embeds[0].author.url): - continue - try: - if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: - return message1, msg - except ValueError: - continue - raise ValueError("DM message not found. Plain messages are not supported.") + if not (msg.embeds and msg.embeds[0].author.url): + continue + try: + if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + messages.append(msg) + break + except ValueError: + continue + + if len(messages) > 1: + return messages + + raise ValueError("DM message not found.") async def edit_message(self, message_id: typing.Optional[int], message: str) -> None: try: - message1, message2 = await self.find_linked_messages(message_id) + message1, *message2 = await self.find_linked_messages(message_id) except ValueError: logger.warning("Failed to edit message.", exc_info=True) raise @@ -644,10 +675,11 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed1.description = message tasks = [self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1)] - if message2 is not None: - embed2 = message2.embeds[0] - embed2.description = message - tasks += [message2.edit(embed=embed2)] + if message2 is not [None]: + for m2 in message2: + embed2 = message2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] elif message1.embeds[0].author.name.startswith("Persistent Note"): tasks += [self.bot.api.edit_note(message1.id, message)] @@ -657,14 +689,16 @@ async def delete_message( self, message: typing.Union[int, discord.Message] = None, note: bool = True ) -> None: if isinstance(message, discord.Message): - message1, message2 = await self.find_linked_messages(message1=message, note=note) + message1, *message2 = await self.find_linked_messages(message1=message, note=note) else: - message1, message2 = await self.find_linked_messages(message, note=note) + message1, *message2 = await self.find_linked_messages(message, note=note) + print(message1, message2) tasks = [] if not isinstance(message, discord.Message): tasks += [message1.delete()] - elif message2 is not None: - tasks += [message2.delete()] + elif message2 is not [None]: + for m2 in message2: + tasks += [m2.delete()] elif message1.embeds[0].author.name.startswith("Persistent Note"): tasks += [self.bot.api.delete_note(message1.id)] if tasks: @@ -750,16 +784,18 @@ async def reply( ) ) + user_msg_tasks = [] tasks = [] - try: - user_msg = await self.send( - message, - destination=self.recipient, - from_mod=True, - anonymous=anonymous, - plain=plain, + for user in self.recipients: + user_msg_tasks.append( + self.send( + message, destination=user, from_mod=True, anonymous=anonymous, plain=plain, + ) ) + + try: + user_msg = await asyncio.gather(*user_msg_tasks) except Exception as e: logger.error("Message delivery failed:", exc_info=True) if isinstance(e, discord.Forbidden): @@ -1063,9 +1099,23 @@ def get_notifications(self) -> str: return " ".join(mentions) - async def set_title(self, title) -> None: + async def set_title(self, title: str) -> None: + user_id = match_user_id(self.channel.topic) + ids = ",".join(i.id for i in self._other_recipients) + + await self.channel.edit( + topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}" + ) + + async def add_user(self, user: typing.Union[discord.Member, discord.User]) -> None: + title = match_title(self.channel.topic) user_id = match_user_id(self.channel.topic) - await self.channel.edit(topic=f"Title: {title}\nUser ID: {user_id}") + self._other_recipients.append(user) + + ids = ",".join(str(i.id) for i in self._other_recipients) + await self.channel.edit( + topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}" + ) class ThreadManager: @@ -1127,11 +1177,13 @@ async def find( await thread.close(closer=self.bot.user, silent=True, delete_channel=False) thread = None else: - channel = discord.utils.get( - self.bot.modmail_guild.text_channels, topic=f"User ID: {recipient_id}" + channel = discord.utils.find( + lambda x: str(recipient_id) in x.topic if x.topic else False, + self.bot.modmail_guild.text_channels, ) + if channel: - thread = Thread(self, recipient or recipient_id, channel) + thread = await Thread.from_channel(self, channel) if thread.recipient: # only save if data is valid self.cache[recipient_id] = thread @@ -1161,10 +1213,14 @@ async def _find_from_channel(self, channel): except discord.NotFound: recipient = None + other_recipients = match_other_recipients(channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid) + if recipient is None: - thread = Thread(self, user_id, channel) + thread = Thread(self, user_id, channel, other_recipients) else: - self.cache[user_id] = thread = Thread(self, recipient, channel) + self.cache[user_id] = thread = Thread(self, recipient, channel, other_recipients) thread.ready = True return thread diff --git a/core/utils.py b/core/utils.py index 2dfb319a8e..b95200cdfe 100644 --- a/core/utils.py +++ b/core/utils.py @@ -21,7 +21,9 @@ "human_join", "days", "cleanup_code", + "match_title", "match_user_id", + "match_other_recipients", "create_not_found_embed", "parse_alias", "normalize_alias", @@ -30,7 +32,6 @@ "escape_code_block", "format_channel_name", "tryint", - "match_title", ] @@ -217,6 +218,9 @@ def cleanup_code(content: str) -> str: return content.strip("` \n") +TOPIC_OTHER_RECIPIENTS_REGEX = re.compile( + r"Other Recipients:\s*((?:\d{17,21},*)+)", flags=re.IGNORECASE +) TOPIC_TITLE_REGEX = re.compile(r"\bTitle: (.*)\n(?:User ID: )\b", flags=re.IGNORECASE | re.DOTALL) TOPIC_UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) @@ -260,6 +264,26 @@ def match_user_id(text: str) -> int: return -1 +def match_other_recipients(text: str) -> int: + """ + Matches a title in the format of "Other Recipients: XXXX,XXXX" + + Parameters + ---------- + text : str + The text of the user ID. + + Returns + ------- + Optional[str] + The title if found + """ + match = TOPIC_OTHER_RECIPIENTS_REGEX.search(text) + if match is not None: + return list(map(int, match.group(1).split(","))) + return [] + + def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: # Single reference of Color.red() embed = discord.Embed(