diff --git a/wormhole/listeners/relay.py b/wormhole/listeners/relay.py new file mode 100644 index 0000000..932878b --- /dev/null +++ b/wormhole/listeners/relay.py @@ -0,0 +1,539 @@ +""" +on_message → relay pipeline and _do_relay core logic. + +This is the heart of Wormhole — every message in a network channel flows +through on_message → gate checks → _do_relay → send to all target channels. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Dict, Optional + +import discord +from redbot.core import commands + +from ..models.permissions import Role +from ..services.emoji import resolve_foreign_emojis, build_emoji_embeds_and_files +from ..ui.views import reply_jump_view +from ..utils import ( + apply_mention_policy, + build_relay_embed, + check_attachment_filters, + check_automod, + check_filters, + compact_format, + info_embed, + sanitise_mentions, + truncate, + warn_embed, +) + +log = logging.getLogger("red.evecogs.wormhole") + + +class RelayListener: + """Mixin — on_message listener and _do_relay engine.""" + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + await self._ready.wait() + if not message.guild or message.author.bot: + return + if not isinstance(message.channel, (discord.TextChannel, discord.Thread)): + return + + _tracing = message.channel.id in self._trace_channels + trace: list[str] = [] + if _tracing: + trace.append("✅ Guild text-channel message from non-bot") + + eff_ch = message.channel.id + is_thread = isinstance(message.channel, discord.Thread) + if is_thread: + eff_ch = message.channel.parent_id + + nets = await self.config.networks() + net_name = None + nd = None + for n, d in nets.items(): + if eff_ch in d.get("channels", []): + net_name = n + nd = d + break + if not net_name: + if _tracing: + trace.append(f"❌ **Channel `{eff_ch}` not in any network**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + + if _tracing: + trace.append(f"✅ Network: `{net_name}`") + + # ── Gate checks ──────────────────────────────────────────────────── + + if is_thread and not nd.get("sync_threads"): + if _tracing: + trace.append("❌ **Blocked: thread sync disabled**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + + if nd.get("frozen"): + if _tracing: + trace.append("❌ **Blocked: network frozen**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + + # Command filtering + if message.content: + try: + ctx = await self.bot.get_context(message) + if ctx.valid: + if _tracing: + trace.append(f"❌ **Blocked: command filter** prefix=`{ctx.prefix!r}` cmd=`{ctx.command}`") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace")) + except Exception: + pass + return + except Exception: + pass + + # Mirror channel (receive-only) + if eff_ch in nd.get("mirror_channels", []): + return + + # Media-only filter + if nd.get("media_only"): + if not message.attachments and not message.stickers and not ( + message.embeds and any(e.type in ("image", "video", "gifv") for e in message.embeds) + ): + return + + # NSFW gate + if nd.get("nsfw_gate") and hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw(): + return + + # Global blocklists + if message.author.id in await self.config.global_banned_users(): + return + if message.guild.id in await self.config.global_banned_servers(): + return + + # Per-network blocklists + if message.author.id in nd.get("banned_users", []): + return + if message.author.id in nd.get("muted_users", []): + return + if message.guild.id in nd.get("banned_servers", []): + return + if message.guild.id in nd.get("muted_servers", []): + return + + # Server allowlist — if set, ONLY these servers can relay + allowlist = nd.get("allowlist_servers", []) + if allowlist and message.guild.id not in allowlist: + return + + # Rules acceptance gate + if nd.get("rules_required"): + accepted = nd.get("rules_accepted", {}) + if str(message.author.id) not in accepted: + try: + prefix = await self.bot.get_prefix(message) + if isinstance(prefix, list): + prefix = prefix[0] + await message.channel.send( + embed=warn_embed( + f"{message.author.mention}, you must accept the network rules before messaging.\n" + f"Use `{prefix}wh accept {net_name}` to view and accept." + ), + delete_after=15, + ) + except Exception: + pass + return + + # Content filters + if message.content: + if check_filters(message.content, nd.get("word_filters", []), nd.get("regex_filters", [])): + try: + await message.delete() + await message.channel.send( + embed=warn_embed(f"{message.author.mention}, blocked by filter."), delete_after=5 + ) + except Exception: + pass + return + + # Attachment filters + if message.attachments: + exts = set(nd.get("blocked_extensions", [])) + reason = check_attachment_filters(message.attachments, exts, nd.get("max_filesize")) + if reason: + try: + await message.delete() + await message.channel.send( + embed=warn_embed(f"{message.author.mention}, {reason}"), delete_after=5 + ) + except Exception: + pass + return + + # Auto-moderation + am = nd.get("automod", {}) + if am.get("enabled") and message.content: + if am.get("anti_spam"): + det = self.dup_detectors.get(net_name) + if det and det.is_duplicate(net_name, message.author.id, message.content): + try: + await message.delete() + await message.channel.send(embed=warn_embed("Spam detected."), delete_after=5) + except Exception: + pass + await self._log(nd, warn_embed(f"Auto-mod spam: {message.author}")) + return + if am.get("anti_raid"): + rd = self.raid_detectors.get(net_name) + if rd and rd.record(net_name, message.author.id): + async with self.config.networks() as ns: + if net_name in ns: + ns[net_name]["frozen"] = True + await self._log(nd, warn_embed("🚨 Raid detected! Network auto-frozen.")) + await self._status( + net_name, nd, None, + "🚨 **Raid detected — network auto-frozen!** Staff: use `wh set unfreeze` to unfreeze.", + ) + return + reason = check_automod(message.content, am) + if reason: + try: + await message.delete() + await message.channel.send( + embed=warn_embed(f"{message.author.mention}: {reason}"), delete_after=5 + ) + except Exception: + pass + await self._log(nd, warn_embed(f"Auto-mod: {reason} — {message.author}")) + return + + # Rate limit + bucket = self.cooldowns.get(net_name) + if bucket and bucket.is_rate_limited(message.author.id, net_name): + try: + await message.add_reaction("🕐") + except Exception: + pass + return + + # Slowmode + sm = nd.get("slowmode", 0) + if sm > 0: + last = self.slowmode_tracker.get(net_name, {}).get(message.author.id, 0) + if time.monotonic() - last < sm: + try: + await message.add_reaction("🐌") + except Exception: + pass + return + self.slowmode_tracker.setdefault(net_name, {})[message.author.id] = time.monotonic() + + # Relay delay + delay = nd.get("relay_delay", 0) + if delay > 0: + await asyncio.sleep(min(delay, 30)) + + # AFK system + try: + await self._check_afk(net_name, nd, message) + except Exception as exc: + log.debug("AFK check error (relay continues): %s", exc) + + # Auto-responses + try: + await self._check_auto_responses(net_name, nd, message) + except Exception as exc: + log.debug("Auto-response error (relay continues): %s", exc) + + # ── Relay ───────────────────────────────────────────────────────── + if _tracing: + trace.append("✅ All gate checks passed — calling _do_relay") + try: + await self._do_relay(net_name, nd, nets, message, eff_ch) + if _tracing: + trace.append("✅ **_do_relay completed successfully**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — RELAYED")) + except Exception: + pass + except Exception as exc: + log.error("Relay engine error net=%s ch=%s: %s", net_name, message.channel.id, exc, exc_info=True) + if _tracing: + trace.append(f"❌ **_do_relay EXCEPTION: `{exc}`**") + try: + await message.channel.send(embed=info_embed("\n".join(trace), title="🔍 Trace — ERROR")) + except Exception: + pass + + async def _do_relay( + self, net_name: str, nd: dict, nets: dict, message: discord.Message, eff_ch: int + ) -> None: + """Core relay logic — build payload, send to all target channels.""" + + relay_mode = nd.get("relay_mode", "webhook") + nick = nd.get("server_nicknames", {}).get(str(message.guild.id)) + + # ── Mention policy ───────────────────────────────────────────────── + mp = nd.get("mention_policy", {}) + server_overrides = nd.get("server_mention_overrides", {}).get(str(message.guild.id)) + active_policy = server_overrides if server_overrides else mp + exempt = nd.get("mention_exempt_users", []) + optouts = set(nd.get("mention_optout_users", [])) + if active_policy: + content = apply_mention_policy( + message.content or "", active_policy, message.author.id, exempt, optouts + ) + else: + mc = nd.get("mention_control", {}) + content = sanitise_mentions(message.content or "", mc) + + # ── Identity ─────────────────────────────────────────────────────── + is_anon = nd.get("anonymous", False) + if is_anon: + anon_name = self._anon_name(nd, message.author.id) + avatar = self._anon_avatar(message.author.id) + uname = anon_name + else: + avatar = self._avatar(message, nd.get("image_mode", "user"), nd.get("custom_icon")) + uname = self._name(message, nd.get("name_mode", "both"), nd.get("custom_name"), nick) + + user_colour = nd.get("user_colours", {}).get(str(message.author.id)) + + # ── Reply context ────────────────────────────────────────────────── + reply_jump_urls: Dict[int, str] = {} + reply_fallback_url: Optional[str] = None + if nd.get("sync_replies") and message.reference and message.reference.message_id: + try: + ref = message.reference.cached_message or await message.channel.fetch_message( + message.reference.message_id + ) + ref_name = self._anon_name(nd, ref.author.id) if is_anon else ref.author.display_name + preview = truncate(ref.content, 100) if ref.content else "*[attachment]*" + content = f"> **↩ {ref_name}:** {preview}\n{content}" + + if ref.guild: + reply_fallback_url = ( + f"https://discord.com/channels/{ref.guild.id}/{ref.channel.id}/{ref.id}" + ) + orig_ref_id = self.msg_map.get_original(net_name, ref.id) + ref_relayed = ( + self.msg_map.forward.get(net_name, {}).get(orig_ref_id, {}) + if orig_ref_id + else self.msg_map.forward.get(net_name, {}).get(ref.id, {}) + ) + for cid, mid in ref_relayed.items(): + target_ch = self.bot.get_channel(cid) + if target_ch and target_ch.guild: + reply_jump_urls[cid] = ( + f"https://discord.com/channels/{target_ch.guild.id}/{cid}/{mid}" + ) + if orig_ref_id: + relayed_ch_ids = set(ref_relayed.keys()) + for net_ch_id in nd.get("channels", []): + if net_ch_id not in relayed_ch_ids and net_ch_id not in reply_jump_urls: + net_ch = self.bot.get_channel(net_ch_id) + if net_ch and net_ch.guild: + reply_jump_urls[net_ch_id] = ( + f"https://discord.com/channels/{net_ch.guild.id}/{net_ch_id}/{orig_ref_id}" + ) + reply_jump_urls.setdefault(ref.channel.id, reply_fallback_url) + except Exception: + content = f"> ↩ *[reply]*\n{content}" + + # ── Stickers ─────────────────────────────────────────────────────── + if nd.get("sync_stickers") and message.stickers: + sl = [f"[Sticker: {s.name}]({s.url})" for s in message.stickers] + content = (content + "\n" if content else "") + "\n".join(sl) + + # ── Forward embeds ───────────────────────────────────────────────── + extra_embeds: list[discord.Embed] = [] + if nd.get("forward_embeds") and message.embeds: + extra_embeds = [e for e in message.embeds if e.type == "rich"] + + # ── Resolve foreign emojis ───────────────────────────────────────── + emoji_img_data: list[tuple[str, bytes]] = [] + if content: + content, emoji_img_data = await resolve_foreign_emojis(self.bot, content) + + # ── Build target list ────────────────────────────────────────────── + relay_targets = [cid for cid in nd["channels"] if cid != eff_ch] + # Include mirror (receive-only) channels + relay_targets.extend(nd.get("mirror_channels", [])) + for bridge_net in nd.get("bridge_to", []): + bd = nets.get(bridge_net) + if bd and not bd.get("frozen"): + relay_targets.extend(bd.get("channels", [])) + + # ── Disallow all mentions in relay ────────────────────────────────── + no_mentions = discord.AllowedMentions.none() + + # ── Send to each target channel ──────────────────────────────────── + mapping: Dict[int, int] = {} + for ch_id in relay_targets: + if ch_id == eff_ch: + continue + ch = self.bot.get_channel(ch_id) + if not ch: + continue + ch_mode = self._get_override(nd, ch_id, "relay_mode") or relay_mode + + # Reply jump button + jump_url = reply_jump_urls.get(ch_id) or reply_fallback_url + _reply_view = reply_jump_view(jump_url) if jump_url else None + _view_kw = {"view": _reply_view} if _reply_view else {} + + # Fresh emoji embeds/files (File objects are single-use) + emoji_embeds, emoji_files = build_emoji_embeds_and_files(emoji_img_data) + + try: + sent_msg = None + if ch_mode == "webhook": + sent_msg = await self._relay_webhook( + ch, message, content, uname, avatar, extra_embeds, + emoji_embeds, emoji_files, emoji_img_data, _view_kw, nick, nd, + ) + elif ch_mode == "embed": + ee, ef = build_emoji_embeds_and_files(emoji_img_data) + em = build_relay_embed(message, nick, user_colour or nd.get("colour")) + all_embeds = [em] + (extra_embeds or []) + ee + sent_msg = await ch.send( + embeds=all_embeds[:10], + files=ef or None, + allowed_mentions=no_mentions, + **_view_kw, + ) + elif ch_mode == "compact": + g = nick or message.guild.name + display = anon_name if is_anon else message.author.display_name + files = [] + for a in message.attachments: + try: + files.append(await a.to_file()) + except Exception: + pass + ce, cf = build_emoji_embeds_and_files(emoji_img_data) + files.extend(cf) + sent_msg = await ch.send( + content=compact_format(g, display, content), + files=files or None, + embeds=ce[:10] or None, + allowed_mentions=no_mentions, + **_view_kw, + ) + + if sent_msg: + mapping[ch_id] = sent_msg.id + if nd.get("ephemeral_delay", 0) > 0: + await self._schedule_ephemeral_delete(sent_msg, nd["ephemeral_delay"]) + + except Exception as exc: + log.error("Relay fail ch=%s net=%s: %s", ch_id, net_name, exc, exc_info=True) + + if mapping: + self.msg_map.add(net_name, message.id, mapping) + + # Stats + profile + analytics + async with self.config.networks() as ns: + if net_name in ns: + ns[net_name]["total_messages"] = ns[net_name].get("total_messages", 0) + 1 + await self._update_profile(net_name, message.author, message.guild.id) + await self._record_analytics(net_name, message.author.id) + + # DM relay + highlights + await self._relay_to_dm_subs(net_name, nd, message) + await self._check_highlights(net_name, nd, message.content, message.author.id) + + async def _relay_webhook( + self, ch, message, content, uname, avatar, extra_embeds, + emoji_embeds, emoji_files, emoji_img_data, view_kw, nick, nd, + ) -> Optional[discord.Message]: + """Send via webhook with automatic retry/fallback.""" + files = [] + for a in message.attachments: + try: + files.append(await a.to_file()) + except Exception: + pass + files.extend(emoji_files) + all_embeds = (extra_embeds or []) + emoji_embeds + send_content = content if content else None + if not send_content and not files and not all_embeds: + send_content = "*[empty message]*" + + no_mentions = discord.AllowedMentions.none() + + try: + wh = await self._wh(ch) + return await wh.send( + content=send_content, + username=truncate(uname, 80), + avatar_url=avatar, + files=files or discord.utils.MISSING, + embeds=all_embeds[:10] or discord.utils.MISSING, + wait=True, + allowed_mentions=no_mentions, + **view_kw, + ) + except (discord.NotFound, discord.InvalidData): + # Webhook stale — retry with fresh one + try: + wh = await self._wh(ch, force_refresh=True) + files2 = [] + for a in message.attachments: + try: + files2.append(await a.to_file()) + except Exception: + pass + ee2, ef2 = build_emoji_embeds_and_files(emoji_img_data) + files2.extend(ef2) + all_embeds2 = (extra_embeds or []) + ee2 + send_content2 = content if content else None + if not send_content2 and not files2 and not all_embeds2: + send_content2 = "*[empty message]*" + return await wh.send( + content=send_content2, + username=truncate(uname, 80), + avatar_url=avatar, + files=files2 or discord.utils.MISSING, + embeds=all_embeds2[:10] or discord.utils.MISSING, + wait=True, + allowed_mentions=no_mentions, + **view_kw, + ) + except Exception: + log.warning("Webhook retry failed for %s, falling back to embed", ch.id) + em = build_relay_embed(message, nick, nd.get("colour")) + return await ch.send( + embeds=[em] + (extra_embeds or [])[:9], + allowed_mentions=no_mentions, + **view_kw, + ) + except discord.Forbidden: + log.warning("No webhook perms in %s, falling back to embed", ch.id) + em = build_relay_embed(message, nick, nd.get("colour")) + return await ch.send( + embeds=[em] + (extra_embeds or [])[:9], + allowed_mentions=no_mentions, + **view_kw, + )