Skip to content

Commit 5648289

Browse files
committed
Group conversation ALPHA #143
1 parent 4f09b92 commit 5648289

File tree

5 files changed

+197
-50
lines changed

5 files changed

+197
-50
lines changed

CHANGELOG.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66
This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html);
77
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.
88

9-
# v3.9.5-dev1
9+
# v3.10.0-dev2
10+
11+
## Added
12+
13+
- Ability to have group conversations. ([GH #143](https://github.com/kyb3r/modmail/issues/143))
1014

1115
## Fixed
1216

bot.py

+20-8
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,15 @@ async def process_dm_modmail(self, message: discord.Message) -> None:
964964
logger.error("Failed to send message:", exc_info=True)
965965
await self.add_reaction(message, blocked_emoji)
966966
else:
967+
for user in thread.recipients:
968+
# send to all other recipients
969+
if user != message.author:
970+
try:
971+
await thread.send(message, user)
972+
except Exception:
973+
# silently ignore
974+
logger.error("Failed to send message:", exc_info=True)
975+
967976
await self.add_reaction(message, sent_emoji)
968977
self.dispatch("thread_reply", thread, False, message, False, False)
969978

@@ -1224,9 +1233,10 @@ async def on_typing(self, channel, user, _):
12241233

12251234
thread = await self.threads.find(channel=channel)
12261235
if thread is not None and thread.recipient:
1227-
if await self.is_blocked(thread.recipient):
1228-
return
1229-
await thread.recipient.trigger_typing()
1236+
for user in thread.recipients:
1237+
if await self.is_blocked(user):
1238+
continue
1239+
await user.trigger_typing()
12301240

12311241
async def handle_reaction_events(self, payload):
12321242
user = self.get_user(payload.user_id)
@@ -1286,20 +1296,22 @@ async def handle_reaction_events(self, payload):
12861296
if not thread:
12871297
return
12881298
try:
1289-
_, linked_message = await thread.find_linked_messages(
1299+
_, *linked_message = await thread.find_linked_messages(
12901300
message.id, either_direction=True
12911301
)
12921302
except ValueError as e:
12931303
logger.warning("Failed to find linked message for reactions: %s", e)
12941304
return
12951305

1296-
if self.config["transfer_reactions"] and linked_message is not None:
1306+
if self.config["transfer_reactions"] and linked_message is not [None]:
12971307
if payload.event_type == "REACTION_ADD":
1298-
if await self.add_reaction(linked_message, reaction):
1299-
await self.add_reaction(message, reaction)
1308+
for msg in linked_message:
1309+
await self.add_reaction(msg, reaction)
1310+
await self.add_reaction(message, reaction)
13001311
else:
13011312
try:
1302-
await linked_message.remove_reaction(reaction, self.user)
1313+
for msg in linked_message:
1314+
await msg.remove_reaction(reaction, self.user)
13031315
await message.remove_reaction(reaction, self.user)
13041316
except (discord.HTTPException, discord.InvalidArgument) as e:
13051317
logger.warning("Failed to remove reaction: %s", e)

cogs/modmail.py

+55-4
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,48 @@ async def title(self, ctx, *, name: str):
681681
await ctx.message.pin()
682682
await self.bot.add_reaction(ctx.message, sent_emoji)
683683

684+
@commands.command(cooldown_after_parsing=True)
685+
@checks.has_permissions(PermissionLevel.SUPPORTER)
686+
@checks.thread_only()
687+
@commands.cooldown(1, 600, BucketType.channel)
688+
async def adduser(self, ctx, *, user: discord.Member):
689+
"""Adds a user to a modmail thread"""
690+
691+
curr_thread = await self.bot.threads.find(recipient=user)
692+
if curr_thread:
693+
em = discord.Embed(
694+
title="Error",
695+
description=f"User is already in a thread: {curr_thread.channel.mention}.",
696+
color=self.bot.error_color,
697+
)
698+
await ctx.send(embed=em)
699+
else:
700+
em = discord.Embed(
701+
title="New Thread (Group)",
702+
description=f"{ctx.author.name} has added you to a Modmail thread.",
703+
color=self.bot.main_color,
704+
)
705+
if self.bot.config["show_timestamp"]:
706+
em.timestamp = datetime.utcnow()
707+
em.set_footer(text=f"{ctx.author}", icon_url=ctx.author.avatar_url)
708+
await user.send(embed=em)
709+
710+
em = discord.Embed(
711+
title="New User",
712+
description=f"{ctx.author.name} has added {user.name} to the Modmail thread.",
713+
color=self.bot.main_color,
714+
)
715+
if self.bot.config["show_timestamp"]:
716+
em.timestamp = datetime.utcnow()
717+
em.set_footer(text=f"{user}", icon_url=user.avatar_url)
718+
719+
for i in ctx.thread.recipients:
720+
await i.send(embed=em)
721+
722+
await ctx.thread.add_user(user)
723+
sent_emoji, _ = await self.bot.retrieve_emoji()
724+
await self.bot.add_reaction(ctx.message, sent_emoji)
725+
684726
@commands.group(invoke_without_command=True)
685727
@checks.has_permissions(PermissionLevel.SUPPORTER)
686728
async def logs(self, ctx, *, user: User = None):
@@ -1463,15 +1505,19 @@ async def repair(self, ctx):
14631505
and message.embeds[0].footer.text
14641506
):
14651507
user_id = match_user_id(message.embeds[0].footer.text)
1508+
other_recipients = match_other_recipients(ctx.channel.topic)
1509+
for n, uid in enumerate(other_recipients):
1510+
other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid)
1511+
14661512
if user_id != -1:
14671513
recipient = self.bot.get_user(user_id)
14681514
if recipient is None:
14691515
self.bot.threads.cache[user_id] = thread = Thread(
1470-
self.bot.threads, user_id, ctx.channel
1516+
self.bot.threads, user_id, ctx.channel, other_recipients
14711517
)
14721518
else:
14731519
self.bot.threads.cache[user_id] = thread = Thread(
1474-
self.bot.threads, recipient, ctx.channel
1520+
self.bot.threads, recipient, ctx.channel, other_recipients
14751521
)
14761522
thread.ready = True
14771523
logger.info(
@@ -1516,13 +1562,18 @@ async def repair(self, ctx):
15161562
await thread.channel.send(embed=embed)
15171563
except discord.HTTPException:
15181564
pass
1565+
1566+
other_recipients = match_other_recipients(ctx.channel.topic)
1567+
for n, uid in enumerate(other_recipients):
1568+
other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid)
1569+
15191570
if recipient is None:
15201571
self.bot.threads.cache[user.id] = thread = Thread(
1521-
self.bot.threads, user_id, ctx.channel
1572+
self.bot.threads, user_id, ctx.channel, other_recipients
15221573
)
15231574
else:
15241575
self.bot.threads.cache[user.id] = thread = Thread(
1525-
self.bot.threads, recipient, ctx.channel
1576+
self.bot.threads, recipient, ctx.channel, other_recipients
15261577
)
15271578
thread.ready = True
15281579
logger.info("Setting current channel's topic to User ID and created new thread.")

core/thread.py

+92-36
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
days,
2020
match_title,
2121
match_user_id,
22+
match_other_recipients,
2223
truncate,
2324
format_channel_name,
2425
)
@@ -34,6 +35,7 @@ def __init__(
3435
manager: "ThreadManager",
3536
recipient: typing.Union[discord.Member, discord.User, int],
3637
channel: typing.Union[discord.DMChannel, discord.TextChannel] = None,
38+
other_recipients: typing.List[typing.Union[discord.Member, discord.User]] = [],
3739
):
3840
self.manager = manager
3941
self.bot = manager.bot
@@ -45,6 +47,7 @@ def __init__(
4547
raise CommandError("Recipient cannot be a bot.")
4648
self._id = recipient.id
4749
self._recipient = recipient
50+
self._other_recipients = other_recipients
4851
self._channel = channel
4952
self.genesis_message = None
5053
self._ready_event = asyncio.Event()
@@ -54,7 +57,7 @@ def __init__(
5457
self._cancelled = False
5558

5659
def __repr__(self):
57-
return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id})'
60+
return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id}, other_recipienets={len(self._other_recipients)})'
5861

5962
async def wait_until_ready(self) -> None:
6063
"""Blocks execution until the thread is fully set up."""
@@ -80,6 +83,10 @@ def channel(self) -> typing.Union[discord.TextChannel, discord.DMChannel]:
8083
def recipient(self) -> typing.Optional[typing.Union[discord.User, discord.Member]]:
8184
return self._recipient
8285

86+
@property
87+
def recipients(self) -> typing.List[typing.Union[discord.User, discord.Member]]:
88+
return [self._recipient] + self._other_recipients
89+
8390
@property
8491
def ready(self) -> bool:
8592
return self._ready_event.is_set()
@@ -103,6 +110,23 @@ def cancelled(self, flag: bool):
103110
for i in self.wait_tasks:
104111
i.cancel()
105112

113+
@classmethod
114+
async def from_channel(
115+
cls, manager: "ThreadManager", channel: discord.TextChannel
116+
) -> "Thread":
117+
recipient_id = match_user_id(
118+
channel.topic
119+
) # there is a chance it grabs from another recipient's main thread
120+
recipient = manager.bot.get_user(recipient_id) or await manager.bot.fetch_user(
121+
recipient_id
122+
)
123+
124+
other_recipients = match_other_recipients(channel.topic)
125+
for n, uid in enumerate(other_recipients):
126+
other_recipients[n] = manager.bot.get_user(uid) or await manager.bot.fetch_user(uid)
127+
128+
return cls(manager, recipient or recipient_id, channel, other_recipients)
129+
106130
async def setup(self, *, creator=None, category=None, initial_message=None):
107131
"""Create the thread channel and other io related initialisation tasks"""
108132
self.bot.dispatch("thread_initiate", self, creator, category, initial_message)
@@ -619,23 +643,30 @@ async def find_linked_messages(
619643
except ValueError:
620644
raise ValueError("Malformed thread message.")
621645

622-
async for msg in self.recipient.history():
623-
if either_direction:
624-
if msg.id == joint_id:
625-
return message1, msg
646+
messages = [message1]
647+
for user in self.recipients:
648+
async for msg in user.history():
649+
if either_direction:
650+
if msg.id == joint_id:
651+
return message1, msg
626652

627-
if not (msg.embeds and msg.embeds[0].author.url):
628-
continue
629-
try:
630-
if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id:
631-
return message1, msg
632-
except ValueError:
633-
continue
634-
raise ValueError("DM message not found. Plain messages are not supported.")
653+
if not (msg.embeds and msg.embeds[0].author.url):
654+
continue
655+
try:
656+
if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id:
657+
messages.append(msg)
658+
break
659+
except ValueError:
660+
continue
661+
662+
if len(messages) > 1:
663+
return messages
664+
665+
raise ValueError("DM message not found.")
635666

636667
async def edit_message(self, message_id: typing.Optional[int], message: str) -> None:
637668
try:
638-
message1, message2 = await self.find_linked_messages(message_id)
669+
message1, *message2 = await self.find_linked_messages(message_id)
639670
except ValueError:
640671
logger.warning("Failed to edit message.", exc_info=True)
641672
raise
@@ -644,10 +675,11 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) ->
644675
embed1.description = message
645676

646677
tasks = [self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1)]
647-
if message2 is not None:
648-
embed2 = message2.embeds[0]
649-
embed2.description = message
650-
tasks += [message2.edit(embed=embed2)]
678+
if message2 is not [None]:
679+
for m2 in message2:
680+
embed2 = message2.embeds[0]
681+
embed2.description = message
682+
tasks += [m2.edit(embed=embed2)]
651683
elif message1.embeds[0].author.name.startswith("Persistent Note"):
652684
tasks += [self.bot.api.edit_note(message1.id, message)]
653685

@@ -657,14 +689,16 @@ async def delete_message(
657689
self, message: typing.Union[int, discord.Message] = None, note: bool = True
658690
) -> None:
659691
if isinstance(message, discord.Message):
660-
message1, message2 = await self.find_linked_messages(message1=message, note=note)
692+
message1, *message2 = await self.find_linked_messages(message1=message, note=note)
661693
else:
662-
message1, message2 = await self.find_linked_messages(message, note=note)
694+
message1, *message2 = await self.find_linked_messages(message, note=note)
695+
print(message1, message2)
663696
tasks = []
664697
if not isinstance(message, discord.Message):
665698
tasks += [message1.delete()]
666-
elif message2 is not None:
667-
tasks += [message2.delete()]
699+
elif message2 is not [None]:
700+
for m2 in message2:
701+
tasks += [m2.delete()]
668702
elif message1.embeds[0].author.name.startswith("Persistent Note"):
669703
tasks += [self.bot.api.delete_note(message1.id)]
670704
if tasks:
@@ -750,16 +784,18 @@ async def reply(
750784
)
751785
)
752786

787+
user_msg_tasks = []
753788
tasks = []
754789

755-
try:
756-
user_msg = await self.send(
757-
message,
758-
destination=self.recipient,
759-
from_mod=True,
760-
anonymous=anonymous,
761-
plain=plain,
790+
for user in self.recipients:
791+
user_msg_tasks.append(
792+
self.send(
793+
message, destination=user, from_mod=True, anonymous=anonymous, plain=plain,
794+
)
762795
)
796+
797+
try:
798+
user_msg = await asyncio.gather(*user_msg_tasks)
763799
except Exception as e:
764800
logger.error("Message delivery failed:", exc_info=True)
765801
if isinstance(e, discord.Forbidden):
@@ -1063,9 +1099,23 @@ def get_notifications(self) -> str:
10631099

10641100
return " ".join(mentions)
10651101

1066-
async def set_title(self, title) -> None:
1102+
async def set_title(self, title: str) -> None:
1103+
user_id = match_user_id(self.channel.topic)
1104+
ids = ",".join(i.id for i in self._other_recipients)
1105+
1106+
await self.channel.edit(
1107+
topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}"
1108+
)
1109+
1110+
async def add_user(self, user: typing.Union[discord.Member, discord.User]) -> None:
1111+
title = match_title(self.channel.topic)
10671112
user_id = match_user_id(self.channel.topic)
1068-
await self.channel.edit(topic=f"Title: {title}\nUser ID: {user_id}")
1113+
self._other_recipients.append(user)
1114+
1115+
ids = ",".join(str(i.id) for i in self._other_recipients)
1116+
await self.channel.edit(
1117+
topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}"
1118+
)
10691119

10701120

10711121
class ThreadManager:
@@ -1127,11 +1177,13 @@ async def find(
11271177
await thread.close(closer=self.bot.user, silent=True, delete_channel=False)
11281178
thread = None
11291179
else:
1130-
channel = discord.utils.get(
1131-
self.bot.modmail_guild.text_channels, topic=f"User ID: {recipient_id}"
1180+
channel = discord.utils.find(
1181+
lambda x: str(recipient_id) in x.topic if x.topic else False,
1182+
self.bot.modmail_guild.text_channels,
11321183
)
1184+
11331185
if channel:
1134-
thread = Thread(self, recipient or recipient_id, channel)
1186+
thread = await Thread.from_channel(self, channel)
11351187
if thread.recipient:
11361188
# only save if data is valid
11371189
self.cache[recipient_id] = thread
@@ -1161,10 +1213,14 @@ async def _find_from_channel(self, channel):
11611213
except discord.NotFound:
11621214
recipient = None
11631215

1216+
other_recipients = match_other_recipients(channel.topic)
1217+
for n, uid in enumerate(other_recipients):
1218+
other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid)
1219+
11641220
if recipient is None:
1165-
thread = Thread(self, user_id, channel)
1221+
thread = Thread(self, user_id, channel, other_recipients)
11661222
else:
1167-
self.cache[user_id] = thread = Thread(self, recipient, channel)
1223+
self.cache[user_id] = thread = Thread(self, recipient, channel, other_recipients)
11681224
thread.ready = True
11691225

11701226
return thread

0 commit comments

Comments
 (0)