diff --git a/plugins/@local/hermes_contact/README.md b/plugins/@local/hermes_contact/README.md new file mode 100644 index 0000000000..3ec5e5b773 --- /dev/null +++ b/plugins/@local/hermes_contact/README.md @@ -0,0 +1,85 @@ +# Hermes Contact Bridge + +Local KyB3r Modmail plugin that lets trusted bot/webhook-authored Hermes messages create staff-initiated Modmail threads. KyB3r's core `process_commands()` intentionally ignores bot-authored messages, so normal `?contact` posted by Hermes is visible in Discord but does not execute. + +## Install / enable + +1. Ensure plugins are enabled (`ENABLE_PLUGINS=true` / `enable_plugins`). +2. Load the local plugin: + +```text +?plugin add @local/hermes_contact +``` + +or add `@local/hermes_contact` to the Modmail `plugins` config and restart/reload. + +3. As a Modmail Owner, allow the command channel: + +```text +?hcontactallow 1503774146725679274 +``` + +`1503774146725679274` is the current `#test` channel ID used during development; use the real command channel in production. + +## Usage + +From an allowed channel, Hermes can post: + +```text +?hcontact -- initial message +``` + +Examples: + +```text +?hcontact 170389710034829313 -- Hey there — Kharkiv Farm on EU 19 is showing 6 bee houses. The current limit is 5, so please remove or sell one bee house to come back into compliance. +``` + +Silent contact without the standard Modmail contact DM: + +```text +?hcontact 170389710034829313 silent -- Initial message only. +``` + +## Initial message sender/personality + +The bridge sends the optional initial message as a configured personality user. The default is Dr. Phil: + +```text +1507060361327673414 +``` + +Check the current sender: + +```text +?hcontactsender +``` + +Set a different sender/personality: + +```text +?hcontactsender +``` + +The selected sender is saved in Modmail config under: + +```text +hermes_contact_sender_user_id +``` + +## Admin commands + +```text +?hcontactallow [channel_id] +?hcontactdeny [channel_id] +?hcontactchannels +?hcontactsender [user_id] +``` + +## Safety + +- The listener intentionally does **not** ignore bot/webhook authors. +- To avoid turning every channel into a bot-command bridge, it only executes in explicitly allowed channel IDs stored under `hermes_contact_channel_ids`. +- It ignores messages from the Modmail bot itself to avoid loops. +- It refuses bot recipients and blocked users, and reports existing open threads instead of creating duplicates. +- The initial message sender defaults to Dr. Phil but is configurable. diff --git a/plugins/@local/hermes_contact/hermes_contact.py b/plugins/@local/hermes_contact/hermes_contact.py new file mode 100644 index 0000000000..93cb05ce8d --- /dev/null +++ b/plugins/@local/hermes_contact/hermes_contact.py @@ -0,0 +1,262 @@ +"""Trusted bridge for staff-initiated Modmail contact from bot-authored messages. + +KyB3r Modmail intentionally ignores commands authored by bots/webhooks in +Bot.process_commands(). Hermes sends Discord messages as a bot/webhook, so normal +`?contact` will be posted but never executed. This local plugin listens directly +for a restricted bridge command in explicitly allowed channels and creates the +thread via Modmail internals. +""" + +import re +from typing import Optional, Tuple + +import discord +from discord.ext import commands + +from core import checks +from core.models import PermissionLevel + + +USER_RE = re.compile(r"^<@!?(\d+)>$|^(\d+)$") + + +class HermesContact(commands.Cog): + """Create Modmail threads from trusted bridge messages and optional initial text.""" + + COMMAND = "?hcontact" + CHANNEL_KEY = "hermes_contact_channel_ids" + SENDER_KEY = "hermes_contact_sender_user_id" + DEFAULT_SENDER_USER_ID = 1507060361327673414 # Dr. Phil personality + + def __init__(self, bot): + self.bot = bot + + def _allowed_channels(self): + raw = self.bot.config.get(self.CHANNEL_KEY, []) or [] + if isinstance(raw, str): + raw = [x.strip() for x in raw.replace(",", " ").split() if x.strip()] + return {str(x) for x in raw} + + def _sender_user_id(self): + raw = self.bot.config.get(self.SENDER_KEY, self.DEFAULT_SENDER_USER_ID) + try: + return int(raw) + except (TypeError, ValueError): + return self.DEFAULT_SENDER_USER_ID + + async def _save_allowed_channels(self, channel_ids): + self.bot.config[self.CHANNEL_KEY] = sorted({str(x) for x in channel_ids}) + await self.bot.config.update() + + async def _save_sender_user_id(self, user_id: int): + self.bot.config[self.SENDER_KEY] = int(user_id) + await self.bot.config.update() + + async def _message_as_sender(self, message: discord.Message, content: str): + """Return a light proxy message whose author is the configured personality. + + Thread.reply()/Thread.send() use message-like objects by attribute. This + keeps the original bridge message as the timestamp/source while changing + the displayed/logged author for the initial user-facing message. + """ + sender = await self.bot.get_or_fetch_user(self._sender_user_id()) + + class MessageProxy: + def __init__(self, original, author, content_override): + self._original = original + self.author = author + self.content = content_override + self.attachments = [] + self.stickers = [] + self.embeds = [] + self.channel = original.channel + self.created_at = original.created_at + self.id = original.id + self.reference = getattr(original, "reference", None) + self.message_snapshots = [] + + def __getattr__(self, item): + return getattr(self._original, item) + + return MessageProxy(message, sender, content) + + def _parse(self, content: str) -> Tuple[Optional[int], bool, Optional[str], Optional[str]]: + """Return (user_id, silent, initial_message, error).""" + content = content.strip() + if not content.startswith(self.COMMAND): + return None, False, None, "not-command" + + rest = content[len(self.COMMAND):].strip() + if not rest: + return None, False, None, "Usage: ?hcontact [silent] -- optional initial message" + + if " -- " in rest: + head, initial = rest.split(" -- ", 1) + initial = initial.strip() or None + elif rest.startswith("-- "): + return None, False, None, "Missing user before `--`." + else: + head, initial = rest, None + + parts = head.split() + if not parts: + return None, False, None, "Missing user." + + user_token = parts[0] + match = USER_RE.match(user_token) + if not match: + return None, False, None, "First argument must be a user ID or mention." + user_id = int(match.group(1) or match.group(2)) + + opts = {p.lower() for p in parts[1:]} + unknown = opts - {"silent", "silently"} + if unknown: + return None, False, None, f"Unknown option(s): {', '.join(sorted(unknown))}" + + return user_id, bool(opts & {"silent", "silently"}), initial, None + + async def _create_contact(self, message: discord.Message, user_id: int, silent: bool, initial: Optional[str]): + allowed = self._allowed_channels() + if str(message.channel.id) not in allowed: + return await message.channel.send( + f"Hermes contact bridge is not enabled in this channel. " + f"An Owner can run `?hcontactallow {message.channel.id}` first." + ) + + try: + user = await self.bot.get_or_fetch_user(user_id) + except discord.NotFound: + return await message.channel.send(f"Could not find user `{user_id}`.") + + if getattr(user, "bot", False): + return await message.channel.send(f"{user} is a bot; not creating a Modmail thread.") + + existing = await self.bot.threads.find(recipient=user) + if existing: + if getattr(existing, "snoozed", False): + await existing.restore_from_snooze() + self.bot.threads.cache[existing.id] = existing + thread = existing + await message.channel.send(f"Unsnoozed existing thread for {user.mention}.") + else: + where = f" in {existing.channel.mention}" if existing.channel else "" + return await message.channel.send(f"A thread for {user.mention} already exists{where}.") + elif await self.bot.is_blocked(user): + return await message.channel.send(f"{user.mention} is currently blocked from contacting {self.bot.user.name}.") + else: + thread = await self.bot.threads.create( + recipient=user, + creator=message.author, + category=None, + manual_trigger=True, + ) + if thread.cancelled: + return + await thread.wait_until_ready() + + embed = discord.Embed( + title="Created Thread", + description=f"Thread started by {message.author.mention} for {user.mention} via Hermes bridge.", + color=self.bot.main_color, + ) + await thread.channel.send(embed=embed) + + if not silent and not self.bot.config.get("thread_contact_silently"): + try: + description = self.bot.formatter.format( + self.bot.config["thread_creation_contact_response"], creator=message.author + ) + em = discord.Embed( + title=self.bot.config["thread_creation_contact_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=f"{message.author}", + icon_url=message.author.display_avatar.url if message.author.display_avatar else None, + ) + await user.send(embed=em) + except discord.Forbidden: + await thread.channel.send("⚠️ Contact DM could not be delivered; user may have DMs closed.") + + if initial: + try: + sender_message = await self._message_as_sender(message, initial) + await thread.reply(sender_message, content=initial) + except Exception as exc: # keep bridge failure visible to staff + await thread.channel.send(f"⚠️ Thread created, but initial message failed: `{type(exc).__name__}: {exc}`") + raise + + await message.channel.send(f"Created/updated Modmail thread for {user.mention}: {thread.channel.mention}") + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + # This intentionally does NOT ignore bot/webhook authors; that is the point of the bridge. + if message.guild is None or message.guild != self.bot.modmail_guild: + return + if not message.content.strip().startswith(self.COMMAND): + return + if message.author == self.bot.user: + # Avoid loops if this Modmail bot itself ever echoes the bridge command. + return + + user_id, silent, initial, error = self._parse(message.content) + if error == "not-command": + return + if error: + return await message.channel.send(error) + if user_id is None: + return await message.channel.send("Missing user.") + await self._create_contact(message, user_id, silent, initial) + + @commands.command(name="hcontactallow", usage="[channel_id]") + @checks.has_permissions(PermissionLevel.OWNER) + async def hcontactallow(self, ctx, channel_id: Optional[int] = None): + """Allow Hermes bridge contact commands in a channel.""" + channel_id = channel_id or ctx.channel.id + allowed = self._allowed_channels() + allowed.add(str(channel_id)) + await self._save_allowed_channels(allowed) + await ctx.send(f"Hermes contact bridge enabled in channel `{channel_id}`.") + + @commands.command(name="hcontactdeny", usage="[channel_id]") + @checks.has_permissions(PermissionLevel.OWNER) + async def hcontactdeny(self, ctx, channel_id: Optional[int] = None): + """Remove a channel from Hermes bridge contact commands.""" + channel_id = channel_id or ctx.channel.id + allowed = self._allowed_channels() + allowed.discard(str(channel_id)) + await self._save_allowed_channels(allowed) + await ctx.send(f"Hermes contact bridge disabled in channel `{channel_id}`.") + + @commands.command(name="hcontactchannels") + @checks.has_permissions(PermissionLevel.OWNER) + async def hcontactchannels(self, ctx): + """List channels allowed to use the Hermes contact bridge.""" + allowed = self._allowed_channels() + if not allowed: + return await ctx.send("No Hermes contact bridge channels are configured.") + mentions = [] + for channel_id in sorted(allowed): + channel = self.bot.modmail_guild.get_channel(int(channel_id)) + mentions.append(channel.mention if channel else f"`{channel_id}`") + await ctx.send("Hermes contact bridge channels: " + ", ".join(mentions)) + + @commands.command(name="hcontactsender", usage="[user_id]") + @checks.has_permissions(PermissionLevel.OWNER) + async def hcontactsender(self, ctx, user_id: Optional[int] = None): + """Get or set the user/personality displayed for initial hcontact messages.""" + if user_id is None: + return await ctx.send(f"Hermes contact bridge sender is `{self._sender_user_id()}`.") + try: + user = await self.bot.get_or_fetch_user(user_id) + except discord.NotFound: + return await ctx.send(f"Could not find user `{user_id}`.") + await self._save_sender_user_id(user_id) + await ctx.send(f"Hermes contact bridge sender set to {user} (`{user_id}`).") + + +async def setup(bot): + await bot.add_cog(HermesContact(bot))