diff --git a/wormhole/utils.py b/wormhole/utils.py index 5890f54..12cb285 100644 --- a/wormhole/utils.py +++ b/wormhole/utils.py @@ -261,8 +261,10 @@ def build_relay_embed( em = discord.Embed(description=message.content or "*[no text]*", colour=colour, timestamp=message.created_at) em.set_author(name=message.author.display_name, icon_url=message.author.display_avatar.url) - footer_icon = message.guild.icon.url if message.guild.icon else discord.Embed.Empty - em.set_footer(text=f"🌐 {guild_label} • #{message.channel.name}", icon_url=footer_icon) + if message.guild.icon: + em.set_footer(text=f"🌐 {guild_label} • #{message.channel.name}", icon_url=message.guild.icon.url) + else: + em.set_footer(text=f"🌐 {guild_label} • #{message.channel.name}") if message.attachments: img_set = False diff --git a/wormhole/wormhole.py b/wormhole/wormhole.py index e3fdf27..b6722e0 100644 --- a/wormhole/wormhole.py +++ b/wormhole/wormhole.py @@ -1405,4 +1405,1079 @@ class Wormhole(commands.Cog): lines.append(f"{'āœ…' if am.get(k) else 'āŒ'} `{k}`") await ctx.send(embed=info_embed("\n".join(lines), title=f"Auto-mod — {name}")) - # ━━━━━━━━━━━━━━━━━━━━━━ā \ No newline at end of file + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # INVITES + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="invite", invoke_without_command=True) + async def wh_inv(self, ctx): await ctx.send_help(ctx.command) + + @wh_inv.command(name="create") + async def wh_inv_create(self, ctx, name: str, max_uses: int = 0, expires_minutes: int = 0): + """Create invite code. 0=unlimited/no expiry.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + code = generate_invite_code() + inv = {"uses": 0, "max_uses": max_uses or None, + "expires_at": datetime.fromtimestamp(time.time() + expires_minutes * 60, tz=timezone.utc).isoformat() if expires_minutes > 0 else None, + "creator_id": ctx.author.id} + async with self.config.networks() as n: n[name].setdefault("invites", {})[code] = inv + await ctx.send(embed=ok_embed(f"Invite for **{name}**:\n```\n{code}\n```\nJoin: `{ctx.clean_prefix}wh invite use {code}`", title="šŸ”— Invite")) + + @wh_inv.command(name="vanity") + async def wh_inv_vanity(self, ctx, name: str, *, word: str): + """Set a vanity invite word (e.g. `gaming` → `[p]wh invite use gaming`).""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + word = word.lower().strip().replace(" ", "-") + # Check uniqueness + for n, nd in (await self.config.networks()).items(): + if nd.get("vanity_invite") == word and n != name: + return await ctx.send(embed=err_embed(f"Vanity `{word}` is taken.")) + async with self.config.networks() as n: n[name]["vanity_invite"] = word + await ctx.send(embed=ok_embed(f"Vanity invite → `{word}`. Join: `{ctx.clean_prefix}wh invite use {word}`")) + + @wh_inv.command(name="use", aliases=["join"]) + @commands.guild_only() + async def wh_inv_use(self, ctx, code: str): + """Use an invite code or vanity name to join.""" + nets = await self.config.networks() + target = None + inv_data = None + is_vanity = False + + # Check vanity first + for n, d in nets.items(): + if d.get("vanity_invite") and d["vanity_invite"].lower() == code.lower(): + target = n; is_vanity = True; break + + # Then check codes + if not target: + for n, d in nets.items(): + if code in d.get("invites", {}): + target = n; inv_data = d["invites"][code]; break + + if not target: + return await ctx.send(embed=err_embed("Invalid invite.")) + + d = nets[target] + if inv_data: + if inv_data.get("expires_at"): + if datetime.now(timezone.utc) > datetime.fromisoformat(inv_data["expires_at"]): + async with self.config.networks() as n: n[target].get("invites", {}).pop(code, None) + return await ctx.send(embed=err_embed("Expired.")) + if inv_data.get("max_uses") and inv_data["uses"] >= inv_data["max_uses"]: + return await ctx.send(embed=err_embed("Max uses reached.")) + + # Standard checks + if d.get("allowlist_servers") and ctx.guild.id not in d["allowlist_servers"]: return await ctx.send(embed=err_embed("Not on allowlist.")) + if ctx.guild.id in d.get("banned_servers", []): return await ctx.send(embed=err_embed("Server banned.")) + if ctx.channel.id in d["channels"]: return await ctx.send(embed=err_embed("Already linked.")) + ex = await self._net_for_ch(ctx.channel.id) + if ex: return await ctx.send(embed=err_embed(f"Already in **{ex}**.")) + + async with self.config.networks() as n: + n[target]["channels"].append(ctx.channel.id) + if inv_data and not is_vanity: n[target]["invites"][code]["uses"] += 1 + d = n[target] + await ctx.send(embed=ok_embed(f"Linked to **{target}**! ({len(d['channels'])} channels)")) + await self._status(target, d, ctx.channel, f"šŸ“” **{ctx.guild.name}** joined via invite.") + if d.get("welcome_message"): await ctx.send(embed=info_embed(d["welcome_message"], title=f"šŸ‘‹ Welcome!")) + if d.get("motd"): await ctx.send(embed=info_embed(d["motd"], title=f"šŸ“‹ MOTD")) + + @wh_inv.command(name="revoke") + async def wh_inv_revoke(self, ctx, name: str, code: str): + name = name.lower(); d = await self._net(name) + if not d or (not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author)): return + async with self.config.networks() as n: n[name].get("invites", {}).pop(code, None) + await ctx.send(embed=ok_embed(f"Invite `{code}` revoked.")) + + @wh_inv.command(name="list") + async def wh_inv_list(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + invs = d.get("invites", {}) + van = d.get("vanity_invite") + lines = [] + if van: lines.append(f"🌟 Vanity: `{van}`") + for c, i in invs.items(): + lines.append(f"`{c}` — {i['uses']}/{i.get('max_uses') or 'āˆž'}") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No invites.", title=f"Invites — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # ANNOUNCEMENTS & PORTAL + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="announce") + async def wh_announce(self, ctx, name: str, *, message: str): + """Broadcast to all channels + DM subscribers.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + em = announce_embed(message, title=f"šŸ“¢ {name}") + em.set_footer(text=f"From {ctx.author.display_name}") + sent = 0 + for ch_id in d["channels"]: + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.send(embed=em); sent += 1 + except: pass + # Also DM subscribers + dm_sent = 0 + for uid in d.get("dm_subscribers", []): + u = self.bot.get_user(uid) + if u: + try: await u.send(embed=em); dm_sent += 1 + except: pass + await ctx.send(embed=ok_embed(f"Sent to {sent} channels + {dm_sent} DM subscribers.")) + await self._audit(name, "announce", ctx.author) + + @wh.command(name="portal") + @commands.guild_only() + async def wh_portal(self, ctx, name: str = None): + """Create/refresh portal status embed.""" + if not name: + name = await self._net_for_ch(ctx.channel.id) + if not name: return await ctx.send(embed=err_embed("Not linked.")) + else: name = name.lower() + d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + embed = build_portal_embed(name, d, len(d["channels"]), d.get("total_messages", 0)) + existing = d.get("portal_messages", {}).get(str(ctx.channel.id)) + if existing: + try: + msg = await ctx.channel.fetch_message(existing) + await msg.edit(embed=embed) + return await ctx.send(embed=ok_embed("Portal refreshed!"), delete_after=5) + except: pass + msg = await ctx.send(embed=embed) + try: await msg.pin() + except: pass + async with self.config.networks() as n: n[name].setdefault("portal_messages", {})[str(ctx.channel.id)] = msg.id + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # STARBOARD + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="starboard", aliases=["star"], invoke_without_command=True) + async def wh_star(self, ctx): await ctx.send_help(ctx.command) + + @wh_star.command(name="enable") + async def wh_star_on(self, ctx, name: str, channel: discord.TextChannel, threshold: int = 3): + """Enable cross-network starboard.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["starboard_enabled"] = True + n[name]["starboard_channel"] = channel.id + n[name]["starboard_threshold"] = threshold + await ctx.send(embed=ok_embed(f"Starboard → {channel.mention} (threshold: {threshold}⭐)")) + + @wh_star.command(name="disable") + async def wh_star_off(self, ctx, name: str): + await self._toggle(ctx, name, "starboard_enabled", False) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # KARMA / REPUTATION + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="karma", invoke_without_command=True) + async def wh_karma(self, ctx): await ctx.send_help(ctx.command) + + @wh_karma.command(name="enable") + async def wh_k_on(self, ctx, name: str, emoji: str = "šŸ‘"): + """Enable karma. React with the emoji on relayed messages.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: + n[name]["karma_enabled"] = True + n[name]["karma_emoji"] = emoji + await ctx.send(embed=ok_embed(f"Karma enabled (react {emoji} to give karma).")) + + @wh_karma.command(name="disable") + async def wh_k_off(self, ctx, name: str): + await self._toggle(ctx, name, "karma_enabled", False) + + @wh_karma.command(name="check") + async def wh_k_check(self, ctx, name: str, user: discord.User = None): + """Check karma score.""" + name = name.lower(); d = await self._net(name) + if not d: return + user = user or ctx.author + score = d.get("karma_scores", {}).get(str(user.id), 0) + await ctx.send(embed=info_embed(f"**{user.display_name}** has **{score}** karma on **{name}**.", title="⭐ Karma")) + + @wh_karma.command(name="leaderboard", aliases=["lb"]) + async def wh_k_lb(self, ctx, name: str): + """Karma leaderboard.""" + name = name.lower(); d = await self._net(name) + if not d: return + scores = d.get("karma_scores", {}) + top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:10] + medals = ["šŸ„‡", "🄈", "šŸ„‰"] + [f"**{i}.**" for i in range(4, 11)] + lines = [] + for i, (uid, sc) in enumerate(top): + u = self.bot.get_user(int(uid)) + lines.append(f"{medals[i]} {u or uid} — **{sc}** karma") + await ctx.send(embed=info_embed("\n".join(lines) if lines else "No karma yet.", title=f"Karma — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # HIGHLIGHTS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="highlight", aliases=["hl"], invoke_without_command=True) + async def wh_hl(self, ctx): await ctx.send_help(ctx.command) + + @wh_hl.command(name="add") + async def wh_hl_add(self, ctx, name: str, *, keyword: str): + """Get DM'd when a keyword appears in the network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + uid = str(ctx.author.id) + async with self.config.networks() as n: + hl = n[name].setdefault("highlights", {}).setdefault(uid, []) + if keyword.lower() not in [k.lower() for k in hl]: + hl.append(keyword) + await ctx.send(embed=ok_embed(f"Highlight `{keyword}` added for **{name}**.")) + + @wh_hl.command(name="remove") + async def wh_hl_rm(self, ctx, name: str, *, keyword: str): + name = name.lower() + async with self.config.networks() as n: + if name in n: + hl = n[name].get("highlights", {}).get(str(ctx.author.id), []) + n[name]["highlights"][str(ctx.author.id)] = [k for k in hl if k.lower() != keyword.lower()] + await ctx.send(embed=ok_embed(f"Highlight `{keyword}` removed.")) + + @wh_hl.command(name="list") + async def wh_hl_ls(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + kws = d.get("highlights", {}).get(str(ctx.author.id), []) + await ctx.send(embed=info_embed("\n".join(f"• `{k}`" for k in kws) if kws else "No highlights.", title=f"Your Highlights — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # SCHEDULED MESSAGES + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="schedule") + async def wh_schedule(self, ctx, name: str, minutes: int, *, message: str): + """Schedule a message to be sent to the network in X minutes.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + send_at = datetime.fromtimestamp(time.time() + minutes * 60, tz=timezone.utc).isoformat() + async with self.config.networks() as n: + n[name].setdefault("scheduled_messages", []).append({ + "content": message, "send_at": send_at, "author_id": ctx.author.id + }) + await ctx.send(embed=ok_embed(f"Scheduled for {minutes} min from now.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # AUDIT LOG + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="audit") + async def wh_audit(self, ctx, name: str, count: int = 20): + """View recent audit log entries.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + entries = d.get("audit_log", [])[-count:] + if not entries: return await ctx.send(embed=info_embed("No audit entries.")) + lines = [] + for e in reversed(entries): + ts = e.get("timestamp", "?")[:16] + lines.append(f"`{ts}` **{e.get('action')}** by {e.get('user')} {e.get('target', '')}") + await ctx.send(embed=info_embed("\n".join(lines[:25]), title=f"Audit — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # BLACKOUT + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="blackout", invoke_without_command=True) + async def wh_bo(self, ctx): await ctx.send_help(ctx.command) + + @wh_bo.command(name="add") + async def wh_bo_add(self, ctx, name: str, start_hour: int, end_hour: int, *, days: str = "0,1,2,3,4,5,6"): + """Add blackout: freeze during hours (UTC). Days: 0=Mon..6=Sun""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + try: day_list = [int(x.strip()) for x in days.split(",")] + except: return await ctx.send(embed=err_embed("Invalid days.")) + async with self.config.networks() as n: + n[name].setdefault("blackout_schedules", []).append({"start_hour": start_hour, "end_hour": end_hour, "days": day_list}) + await ctx.send(embed=ok_embed(f"Blackout {start_hour:02d}:00–{end_hour:02d}:00 UTC on {days}.")) + + @wh_bo.command(name="clear") + async def wh_bo_clear(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + if not await self._is_staff(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + async with self.config.networks() as n: n[name]["blackout_schedules"] = []; n[name]["frozen"] = False + await ctx.send(embed=ok_embed("Blackout cleared & unfrozen.")) + + @wh_bo.command(name="list") + async def wh_bo_ls(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return + ss = d.get("blackout_schedules", []) + dn = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"] + lines = [f"{s['start_hour']:02d}:00–{s['end_hour']:02d}:00 — {','.join(dn[x] for x in s.get('days',[]))}" for s in ss] + await ctx.send(embed=info_embed("\n".join(lines) if lines else "None.", title=f"Blackout — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # BACKUP & RESTORE + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="backup") + async def wh_backup(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + if not await self._is_owner(d, ctx.author.id) and not await self.bot.is_owner(ctx.author): return + data = {"name": name, "version": self.__version__, "exported": datetime.now(timezone.utc).isoformat(), "config": d} + f = discord.File(fp=io.BytesIO(json.dumps(data, indent=2, default=str).encode()), filename=f"wormhole-{name}.json") + await ctx.send(embed=ok_embed("Backup:"), file=f) + + @wh.command(name="restore") + async def wh_restore(self, ctx, new_name: str = None): + if not ctx.message.attachments: return await ctx.send(embed=err_embed("Attach a JSON file.")) + try: backup = json.loads((await ctx.message.attachments[0].read()).decode()) + except: return await ctx.send(embed=err_embed("Invalid JSON.")) + cfg = backup.get("config") + if not cfg: return await ctx.send(embed=err_embed("Missing config.")) + name = (new_name or backup.get("name", "restored")).lower() + if name in await self.config.networks(): return await ctx.send(embed=err_embed(f"**{name}** exists.")) + cfg["owner_id"] = ctx.author.id + cfg["channels"] = [] + cfg["portal_messages"] = {} + cfg["created_at"] = datetime.now(timezone.utc).isoformat() + await self._save(name, cfg) + self.cooldowns[name] = CooldownBucket(cfg.get("rate_limit_rate", 5), cfg.get("rate_limit_per", 10.0)) + self.dup_detectors[name] = DuplicateDetector() + self.raid_detectors[name] = RaidDetector() + await ctx.send(embed=ok_embed(f"**{name}** restored! Link channels with `{ctx.clean_prefix}wh open {name}`.")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # GLOBAL BLOCKLIST + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.group(name="global", invoke_without_command=True) + @checks.is_owner() + async def wh_gl(self, ctx): await ctx.send_help(ctx.command) + + @wh_gl.command(name="ban-user") + @checks.is_owner() + async def wh_gl_bu(self, ctx, user: discord.User): + async with self.config.global_banned_users() as bl: + if user.id not in bl: bl.append(user.id) + await ctx.send(embed=ok_embed(f"{user.mention} globally blocked.")) + + @wh_gl.command(name="unban-user") + @checks.is_owner() + async def wh_gl_uu(self, ctx, user: discord.User): + async with self.config.global_banned_users() as bl: + if user.id in bl: bl.remove(user.id) + await ctx.send(embed=ok_embed("Unblocked.")) + + @wh_gl.command(name="ban-server") + @checks.is_owner() + async def wh_gl_bs(self, ctx, gid: int): + async with self.config.global_banned_servers() as bl: + if gid not in bl: bl.append(gid) + await ctx.send(embed=ok_embed("Server globally blocked.")) + + @wh_gl.command(name="unban-server") + @checks.is_owner() + async def wh_gl_us(self, ctx, gid: int): + async with self.config.global_banned_servers() as bl: + if gid in bl: bl.remove(gid) + await ctx.send(embed=ok_embed("Unblocked.")) + + @wh_gl.command(name="list") + @checks.is_owner() + async def wh_gl_ls(self, ctx): + users = await self.config.global_banned_users() + servers = await self.config.global_banned_servers() + lines = [f"User: {self.bot.get_user(u) or u}" for u in users] + [f"Server: {self.bot.get_guild(g) or g}" for g in servers] + await ctx.send(embed=info_embed("\n".join(lines) if lines else "Empty.", title="Global Blocklist")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # PROFILES & STATS + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="profile") + async def wh_profile(self, ctx, name: str, user: discord.User = None): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + user = user or ctx.author + p = d.get("user_profiles", {}).get(str(user.id)) + if not p: return await ctx.send(embed=info_embed(f"{user.display_name} has no activity on **{name}**.")) + em = discord.Embed(title=f"šŸ‘¤ {user.display_name} — {name}", colour=COLOUR_INFO) + em.set_thumbnail(url=user.display_avatar.url) + em.add_field(name="Messages", value=f"{p.get('messages',0):,}", inline=True) + em.add_field(name="First seen", value=p.get("first_seen","?")[:10], inline=True) + em.add_field(name="Servers", value=str(len(p.get("servers",[]))), inline=True) + karma = d.get("karma_scores", {}).get(str(user.id), 0) + if d.get("karma_enabled"): em.add_field(name="Karma", value=str(karma), inline=True) + status = [] + if user.id == d["owner_id"]: status.append("šŸ‘‘ Owner") + elif user.id in d.get("staff_ids", []): status.append("⭐ Staff") + if user.id in d.get("banned_users", []): status.append("🚫 Banned") + if user.id in d.get("muted_users", []): status.append("šŸ”‡ Muted") + if user.id in d.get("dm_subscribers", []): status.append("šŸ“§ DM Sub") + if status: em.add_field(name="Status", value=" ".join(status), inline=False) + await ctx.send(embed=em) + + @wh.command(name="stats") + async def wh_stats(self, ctx, name: str): + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + profiles = d.get("user_profiles", {}) + top = sorted(profiles.items(), key=lambda x: x[1].get("messages", 0), reverse=True)[:5] + em = discord.Embed(title=f"šŸ“Š {name}", colour=COLOUR_INFO) + em.add_field(name="Messages", value=f"{d.get('total_messages',0):,}", inline=True) + em.add_field(name="Channels", value=str(len(d["channels"])), inline=True) + em.add_field(name="Users", value=str(len(profiles)), inline=True) + em.add_field(name="DM subs", value=str(len(d.get("dm_subscribers",[]))), inline=True) + em.add_field(name="Mode", value=f"`{d.get('relay_mode','webhook')}`", inline=True) + em.add_field(name="Created", value=d.get("created_at","?")[:10], inline=True) + if top: + lb = [] + for i, (uid, pr) in enumerate(top): + u = self.bot.get_user(int(uid)) + lb.append(f"{'šŸ„‡šŸ„ˆšŸ„‰'[i] if i < 3 else f'{i+1}.'} {u or uid} — {pr.get('messages',0):,}") + em.add_field(name="šŸ† Top", value="\n".join(lb), inline=False) + await ctx.send(embed=em) + + @wh.command(name="rules") + async def wh_rules(self, ctx, name: str): + """Display network rules.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + rules = d.get("rules", "") + if not rules: return await ctx.send(embed=info_embed("No rules set.")) + await ctx.send(embed=info_embed(rules, title=f"šŸ“œ Rules — {name}")) + + @wh.command(name="motd") + async def wh_motd(self, ctx, name: str): + """Display the message of the day.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + motd = d.get("motd", "") + if not motd: return await ctx.send(embed=info_embed("No MOTD set.")) + await ctx.send(embed=info_embed(motd, title=f"šŸ“‹ MOTD — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # SEARCH + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="search") + async def wh_search(self, ctx, name: str, *, query: str): + """Search recent messages across the network.""" + name = name.lower(); d = await self._net(name) + if not d: return await ctx.send(embed=err_embed("Not found.")) + results = [] + for ch_id in d["channels"][:10]: # Limit to 10 channels + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + async for msg in ch.history(limit=100): + if query.lower() in (msg.content or "").lower(): + results.append(f"**{msg.author.display_name}** in #{ch.name}: {truncate(msg.content, 80)}") + if len(results) >= 15: break + except: pass + if len(results) >= 15: break + await ctx.send(embed=info_embed("\n".join(results) if results else "No results.", title=f"šŸ” Search — {name}")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # DEBUG + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="debug") + @commands.guild_only() + async def wh_debug(self, ctx): + """Diagnose relay issues — shows cog state for this channel.""" + lines = [] + lines.append(f"**Cog ready:** {'āœ…' if self._ready.is_set() else 'āŒ (init still running!)'}") + lines.append(f"**Bot user:** {self.bot.user} (ID: {self.bot.user.id})") + + # Intents + intents = self.bot.intents + lines.append(f"**Intents:** message_content={'āœ…' if intents.message_content else 'āŒ MISSING'} " + f"members={'āœ…' if intents.members else 'āŒ'} " + f"reactions={'āœ…' if intents.reactions else 'āŒ'}") + + # Network for this channel + nets = await self.config.networks() + lines.append(f"**Total networks:** {len(nets)}") + + net_name = None + nd = None + for n, d in nets.items(): + if ctx.channel.id in d.get("channels", []): + net_name = n; nd = d; break + + if not net_name: + lines.append(f"\nāŒ **This channel is NOT linked to any network.**") + lines.append(f"Channel ID: `{ctx.channel.id}`") + # Show all registered channel IDs for debugging + all_ch = [] + for n, d in nets.items(): + for cid in d.get("channels", []): + all_ch.append(f"`{cid}` → {n}") + if all_ch: + lines.append(f"Registered channels:\n" + "\n".join(all_ch[:15])) + else: + lines.append(f"\nāœ… **Network:** `{net_name}`") + lines.append(f"**Frozen:** {'āš ļø YES' if nd.get('frozen') else 'āŒ No'}") + lines.append(f"**Relay mode:** `{nd.get('relay_mode', 'webhook')}`") + lines.append(f"**Channels:** {len(nd.get('channels', []))}") + lines.append(f"**Total messages:** {nd.get('total_messages', 0):,}") + + # Test webhook + if nd.get("relay_mode", "webhook") == "webhook": + try: + perms = ctx.channel.permissions_for(ctx.guild.me) + lines.append(f"**Manage Webhooks perm:** {'āœ…' if perms.manage_webhooks else 'āŒ MISSING — need this!'}") + if perms.manage_webhooks: + wh = await self._wh(ctx.channel) + lines.append(f"**Webhook:** āœ… `{wh.name}` (ID: {wh.id})") + except Exception as e: + lines.append(f"**Webhook:** āŒ Error: `{e}`") + + # List connected channels and their status + ch_lines = [] + for cid in nd.get("channels", []): + ch = self.bot.get_channel(cid) + if ch: + perms = ch.permissions_for(ch.guild.me) + wh_ok = "āœ…" if perms.manage_webhooks else "āš ļø no wh" + send_ok = "āœ…" if perms.send_messages else "āŒ no send" + ch_lines.append(f"• **{ch.guild.name}** › #{ch.name} {wh_ok} {send_ok}") + else: + ch_lines.append(f"• `{cid}` — āš ļø channel not visible to bot") + if ch_lines: + lines.append(f"\n**Connected channels:**\n" + "\n".join(ch_lines[:15])) + + # Prefixes + pfx = await self.bot.get_prefix(ctx.message) + if isinstance(pfx, str): pfx = [pfx] + pfx_display = [f"`{p}`" if p.strip() else f"`(empty)`" for p in pfx[:5]] + lines.append(f"\n**Prefixes:** {', '.join(pfx_display)}") + empty_pfx = any(p == "" for p in pfx) + if empty_pfx: + lines.append("āš ļø **Empty prefix detected!** This would block ALL messages from relay.") + + await ctx.send(embed=info_embed("\n".join(lines), title="šŸ”§ Wormhole Debug")) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # MESSAGE RELAY ENGINE + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + await self._ready.wait() + if not message.guild or message.author.bot: + return + if not isinstance(message.channel, (discord.TextChannel, discord.Thread)): + return + + 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: + return + + if is_thread and not nd.get("sync_threads"): return + if nd.get("frozen"): return + + # Ignore commands (skip empty prefixes — they'd match everything) + try: + pfx = await self.bot.get_prefix(message) + if isinstance(pfx, str): pfx = [pfx] + if any(p and message.content.startswith(p) for p in pfx): return + except Exception: + pass + + # NSFW gate + if nd.get("nsfw_gate") and hasattr(message.channel, "is_nsfw") and message.channel.is_nsfw(): return + + # Global blocklist + 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 checks + 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 + + # Content filter + 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: pass + return + + # Attachment filter + 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: 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: 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(f"🚨 Raid detected! Network auto-frozen.")) + await self._status(net_name, nd, None, "🚨 **Raid detected — network auto-frozen!** Staff: use `wh set freeze false` 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: 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: 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: 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)) + + # Build payload + relay_mode = nd.get("relay_mode", "webhook") + nick = nd.get("server_nicknames", {}).get(str(message.guild.id)) + mc = nd.get("mention_control", {}) + content = sanitise_mentions(message.content or "", mc) + + # Reply context + 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) + preview = truncate(ref.content, 100) if ref.content else "*[attachment]*" + content = f"> **↩ {ref.author.display_name}:** {preview}\n{content}" + except: 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) + + # Embeds + extra_embeds = [] + if nd.get("forward_embeds") and message.embeds: + extra_embeds = [e for e in message.embeds if e.type == "rich"] + + 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) + + # Relay to channels + mapping: Dict[int, int] = {} + for ch_id in nd["channels"]: + 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 + try: + if ch_mode == "webhook": + try: + wh = await self._wh(ch) + files = [] + for a in message.attachments: + try: files.append(await a.to_file()) + except: pass + send_content = content if content else None + # Ensure we have something to send + if not send_content and not files and not extra_embeds: + send_content = "*[empty message]*" + sent = await wh.send( + content=send_content, + username=truncate(uname, 80), + avatar_url=avatar, + files=files or discord.utils.MISSING, + embeds=extra_embeds or discord.utils.MISSING, + wait=True, + ) + mapping[ch_id] = sent.id + except (discord.NotFound, discord.InvalidData): + # Webhook was deleted — refresh cache and retry once + try: + wh = await self._wh(ch, force_refresh=True) + files2 = [] + for a in message.attachments: + try: files2.append(await a.to_file()) + except: pass + send_content = content if content else None + if not send_content and not files2 and not extra_embeds: + send_content = "*[empty message]*" + sent = await wh.send( + content=send_content, + username=truncate(uname, 80), + avatar_url=avatar, + files=files2 or discord.utils.MISSING, + embeds=extra_embeds or discord.utils.MISSING, + wait=True, + ) + mapping[ch_id] = sent.id + except Exception: + # Fall back to embed mode + log.warning("Webhook retry failed for %s, falling back to embed", ch_id) + em = build_relay_embed(message, nick, nd.get("colour")) + sent = await ch.send(embeds=[em] + extra_embeds[:9]) + mapping[ch_id] = sent.id + except discord.Forbidden: + # No webhook perms — fall back to embed mode + log.warning("No webhook perms in %s, falling back to embed", ch_id) + em = build_relay_embed(message, nick, nd.get("colour")) + sent = await ch.send(embeds=[em] + extra_embeds[:9]) + mapping[ch_id] = sent.id + elif ch_mode == "embed": + em = build_relay_embed(message, nick, nd.get("colour")) + sent = await ch.send(embeds=[em] + extra_embeds[:9]) + mapping[ch_id] = sent.id + elif ch_mode == "compact": + g = nick or message.guild.name + files = [] + for a in message.attachments: + try: files.append(await a.to_file()) + except: pass + sent = await ch.send(content=compact_format(g, message.author.display_name, content), files=files or None) + mapping[ch_id] = sent.id + 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 + 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) + + # DM relay + await self._relay_to_dm_subs(net_name, nd, message) + + # Keyword highlights + await self._check_highlights(net_name, nd, message.content, message.author.id) + + # ── Edit sync ─────────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message): + await self._ready.wait() + if not after.guild or after.author.bot or before.content == after.content: return + eff = after.channel.id + if isinstance(after.channel, discord.Thread): eff = after.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_edits"): return + mapping = self.msg_map.get_relayed(net, after.id) + if not mapping: return + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.edit_message(mid, content=after.content or "") + elif cm == "embed": + msg = await ch.fetch_message(mid) + if msg.embeds: + e = msg.embeds[0]; e.description = after.content or "*[no text]*" + await msg.edit(embed=e) + else: + msg = await ch.fetch_message(mid) + cp = msg.content.find(":** ") + if cp != -1: await msg.edit(content=truncate(msg.content[:cp+4] + after.content, 2000)) + except: pass + + # ── Delete sync ───────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message): + await self._ready.wait() + if not message.guild or message.author.bot: return + eff = message.channel.id + if isinstance(message.channel, discord.Thread): eff = message.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_deletes"): return + mapping = self.msg_map.get_relayed(net, message.id) + if not mapping: return + mode = nd.get("relay_mode", "webhook") + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + cm = self._get_override(nd, ch_id, "relay_mode") or mode + if cm == "webhook": + wh = await self._wh(ch) + await wh.delete_message(mid) + else: + msg = await ch.fetch_message(mid) + await msg.delete() + except: pass + + # ── Reaction sync + karma + starboard ────────────────────────────────── + + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User): + await self._ready.wait() + if user.bot: return + msg = reaction.message + if not msg.guild: return + eff = msg.channel.id + if isinstance(msg.channel, discord.Thread): eff = msg.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd: return + + # Reaction sync + if nd.get("sync_reactions"): + mapping = self.msg_map.get_relayed(net, msg.id) + if mapping: + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + rm = await ch.fetch_message(mid) + await rm.add_reaction(reaction.emoji) + except: pass + + # Karma + if nd.get("karma_enabled") and str(reaction.emoji) == nd.get("karma_emoji", "šŸ‘"): + # Find the original author + orig_id = self.msg_map.get_original(net, msg.id) + target_msg = msg + if orig_id: + # This is a relayed message — find original author via profiles or just give to the content + pass # Karma for relayed messages: credit the original sender + if target_msg.author.id != user.id: # Can't karma yourself + uid = str(target_msg.author.id) + async with self.config.networks() as ns: + if net in ns: + ns[net].setdefault("karma_scores", {}) + ns[net]["karma_scores"][uid] = ns[net]["karma_scores"].get(uid, 0) + 1 + + # Starboard + if nd.get("starboard_enabled") and str(reaction.emoji) == "⭐": + threshold = nd.get("starboard_threshold", 3) + star_ch_id = nd.get("starboard_channel") + if not star_ch_id: return + star_ch = self.bot.get_channel(star_ch_id) + if not star_ch: return + + # Count total stars across all relayed copies + original + total_stars = 0 + for r in msg.reactions: + if str(r.emoji) == "⭐": + total_stars += r.count + + if total_stars >= threshold: + starred = nd.get("starred_messages", {}) + msg_key = str(msg.id) + img_url = None + if msg.attachments: + for a in msg.attachments: + if a.content_type and a.content_type.startswith("image/"): + img_url = a.url; break + em = build_star_embed( + msg.author.display_name, msg.author.display_avatar.url, + msg.content or "*[no text]*", total_stars, + msg.guild.name, msg.channel.name, img_url) + + if msg_key in starred: + # Update existing + try: + board_msg = await star_ch.fetch_message(starred[msg_key]["board_msg_id"]) + await board_msg.edit(embed=em) + async with self.config.networks() as ns: + if net in ns: ns[net]["starred_messages"][msg_key]["stars"] = total_stars + except: pass + else: + try: + board_msg = await star_ch.send(embed=em) + async with self.config.networks() as ns: + if net in ns: + ns[net].setdefault("starred_messages", {})[msg_key] = {"stars": total_stars, "board_msg_id": board_msg.id} + except: pass + + @commands.Cog.listener() + async def on_reaction_remove(self, reaction: discord.Reaction, user: discord.User): + await self._ready.wait() + if user.bot: return + msg = reaction.message + if not msg.guild: return + eff = msg.channel.id + if isinstance(msg.channel, discord.Thread): eff = msg.channel.parent_id + net = await self._net_for_ch(eff) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_reactions"): return + mapping = self.msg_map.get_relayed(net, msg.id) + if not mapping: return + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + rm = await ch.fetch_message(mid) + await rm.remove_reaction(reaction.emoji, self.bot.user) + except: pass + + # ── Typing indicator sync ─────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_typing(self, channel, user, when): + await self._ready.wait() + if user.bot or not hasattr(channel, "guild") or not channel.guild: + return + net = await self._net_for_ch(channel.id) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_typing"): return + for ch_id in nd["channels"]: + if ch_id == channel.id: continue + ch = self.bot.get_channel(ch_id) + if ch: + try: await ch.typing() + except: pass + + # ── Pin sync ──────────────────────────────────────────────────────────── + + @commands.Cog.listener() + async def on_guild_channel_pins_update(self, channel, last_pin): + await self._ready.wait() + if not hasattr(channel, "guild"): return + net = await self._net_for_ch(channel.id) + if not net: return + nd = await self._net(net) + if not nd or not nd.get("sync_pins"): return + try: + pins = await channel.pins() + if not pins: return + latest = pins[0] + mapping = self.msg_map.get_relayed(net, latest.id) + if not mapping: return + for ch_id, mid in mapping.items(): + ch = self.bot.get_channel(ch_id) + if not ch: continue + try: + msg = await ch.fetch_message(mid) + await msg.pin() + except: pass + except: pass + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # HELP + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + @wh.command(name="help") + async def wh_help(self, ctx): + """Full command reference.""" + p = ctx.clean_prefix + e1 = discord.Embed(title="šŸŒ€ Wormhole v3.2.0 — Commands (1/3)", colour=COLOUR_NEUTRAL, + description="The ultimate cross-server relay: webhooks, DMs, embeds, starboard, auto-mod, invites, karma, portals, highlights, search & more.") + e1.add_field(name="šŸ“” Networks", value=( + f"`{p}wh create/delete/open/close/list/info/stats`\n" + f"`{p}wh discover` — public networks\n" + f"`{p}wh transfer @user`\n" + f"`{p}wh announce ` — broadcast\n" + f"`{p}wh portal [name]` — status embed\n" + f"`{p}wh search `\n" + f"`{p}wh schedule `\n" + f"`{p}wh rules/motd ` — display"), inline=False) + e1.add_field(name="āš™ļø Settings (`wh set`)", value=( + f"`relay-mode` `webhooks` `name-mode` `image-mode` `custom-icon` `custom-name`\n" + f"`description` `colour` `ratelimit` `slowmode` `relay-delay` `log-channel` `nickname`\n" + f"`welcome` `motd` `rules` `tags` `public` `max-filesize` `blocked-extensions`\n" + f"`freeze` `silent` `nsfw-gate` `sync-edits/deletes/reactions/replies/stickers/threads/pins/typing`\n" + f"`forward-embeds` `strip-everyone/roles/users` `channel-override`"), inline=False) + + e2 = discord.Embed(title="šŸŒ€ Commands (2/3)", colour=COLOUR_NEUTRAL) + e2.add_field(name="šŸ“§ DM Relay (`wh dm`)", value=( + f"`enable/disable `\n" + f"`mode `\n" + f"`subscribe/unsubscribe `\n" + f"`send ` — send from DM\n" + f"`list` — your subscriptions"), inline=True) + e2.add_field(name="⭐ Staff", value=f"`{p}wh staff add/remove/list`", inline=True) + e2.add_field(name="šŸ”— Invites (`wh invite`)", value=( + f"`create [max] [min]`\n" + f"`vanity `\n" + f"`use ` — join\n" + f"`revoke/list`"), inline=True) + e2.add_field(name="šŸ›”ļø Mod (`wh mod`)", value=( + f"`ban/unban/mute/unmute @user`\n" + f"`ban-server/unban-server/mute-server/unmute-server`\n" + f"`allowlist-add/remove` `purge [n]`"), inline=False) + e2.add_field(name="šŸ¤– Auto-Mod (`wh automod`)", value=( + f"`enable/disable` `anti-spam/mentions/caps/invite/link`\n" + f"`anti-zalgo/spoiler/emote-spam/newlines/raid` `status`"), inline=False) + + e3 = discord.Embed(title="šŸŒ€ Commands (3/3)", colour=COLOUR_NEUTRAL) + e3.add_field(name="šŸ” Filters", value="`add-word/remove-word` `add-regex/remove-regex` `list`", inline=True) + e3.add_field(name="⭐ Starboard", value=f"`{p}wh starboard enable #ch [threshold]`\n`disable`", inline=True) + e3.add_field(name="šŸ’Ž Karma", value=f"`{p}wh karma enable/disable/check/leaderboard`", inline=True) + e3.add_field(name="šŸ”” Highlights", value=f"`{p}wh highlight add/remove/list `", inline=True) + e3.add_field(name="šŸŒ™ Blackout", value=f"`{p}wh blackout add/clear/list`", inline=True) + e3.add_field(name="šŸ’¾ Backup", value=f"`{p}wh backup/restore`", inline=True) + e3.add_field(name="šŸ‘¤ Profiles", value=f"`{p}wh profile [@user]`", inline=True) + e3.add_field(name="šŸ“‹ Audit", value=f"`{p}wh audit [count]`", inline=True) + e3.add_field(name="🌐 Global", value=f"`{p}wh global ban-user/unban-user/ban-server/unban-server/list`", inline=True) + e3.set_footer(text="Wormhole v3.2.0 • EveCogs • github.com/everestmcarthur/EveCogs") + + await ctx.send(embeds=[e1, e2, e3])