|
| 1 | +""" |
| 2 | +Sticky Messages — keep a chosen message pinned to the bottom of a channel. |
| 3 | +
|
| 4 | +Commands (`sticky` group): |
| 5 | + sticky set <message> — create or update the sticky message for this channel |
| 6 | + sticky remove — remove the sticky message from this channel |
| 7 | + sticky list — list every channel with an active sticky in this server |
| 8 | +
|
| 9 | +Behaviour: whenever a new (non-bot) message is sent in a channel with an |
| 10 | +active sticky, the old sticky post is deleted and a fresh copy is sent |
| 11 | +underneath it after a short debounce window — so a busy channel doesn't |
| 12 | +trigger a delete+repost on every single message. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import asyncio |
| 18 | +import json |
| 19 | +import os |
| 20 | +from typing import Optional |
| 21 | + |
| 22 | +import discord |
| 23 | +from discord.ext import commands |
| 24 | + |
| 25 | +from config.emojis import get_emoji |
| 26 | + |
| 27 | +DATA_FILE = "data/sticky.json" |
| 28 | +MAX_CONTENT_LEN = 1000 |
| 29 | +MAX_STICKIES_PER_GUILD = 50 |
| 30 | +REPOST_DELAY = 3.0 # seconds — debounce window so bursts only trigger one repost |
| 31 | +DEFAULT_COLOR = 0x5865F2 |
| 32 | + |
| 33 | + |
| 34 | +def _load() -> dict: |
| 35 | + if not os.path.exists(DATA_FILE): |
| 36 | + return {} |
| 37 | + try: |
| 38 | + with open(DATA_FILE, "r", encoding="utf-8") as f: |
| 39 | + return json.load(f) |
| 40 | + except Exception: |
| 41 | + return {} |
| 42 | + |
| 43 | + |
| 44 | +def _save(data: dict): |
| 45 | + os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True) |
| 46 | + with open(DATA_FILE, "w", encoding="utf-8") as f: |
| 47 | + json.dump(data, f, indent=2) |
| 48 | + |
| 49 | + |
| 50 | +def _sticky_view(content: str, color: int) -> discord.ui.LayoutView: |
| 51 | + view = discord.ui.LayoutView() |
| 52 | + view.add_item(discord.ui.Container( |
| 53 | + discord.ui.TextDisplay(content=f"📌 {content}"), |
| 54 | + accent_colour=discord.Colour(color), |
| 55 | + )) |
| 56 | + return view |
| 57 | + |
| 58 | + |
| 59 | +def _feedback(content: str, ok: bool = True) -> discord.ui.LayoutView: |
| 60 | + view = discord.ui.LayoutView() |
| 61 | + view.add_item(discord.ui.Container( |
| 62 | + discord.ui.TextDisplay(content=f"{get_emoji('icon_tick') if ok else get_emoji('icon_cross')} {content}"), |
| 63 | + accent_colour=discord.Colour.green() if ok else discord.Colour.red(), |
| 64 | + )) |
| 65 | + return view |
| 66 | + |
| 67 | + |
| 68 | +class StickyCog(commands.Cog, name="Sticky"): |
| 69 | + """Keep a message stuck to the bottom of a channel.""" |
| 70 | + |
| 71 | + def __init__(self, bot: commands.Bot): |
| 72 | + self.bot = bot |
| 73 | + self.data: dict = _load() |
| 74 | + self._pending: set[int] = set() # channel_ids with a repost currently scheduled |
| 75 | + |
| 76 | + # ── helpers ────────────────────────────────────────────────────────── |
| 77 | + |
| 78 | + def _guild_data(self, guild_id: int) -> dict: |
| 79 | + return self.data.setdefault(str(guild_id), {}) |
| 80 | + |
| 81 | + def _get(self, guild_id: int, channel_id: int) -> Optional[dict]: |
| 82 | + return self._guild_data(guild_id).get(str(channel_id)) |
| 83 | + |
| 84 | + # ── commands ───────────────────────────────────────────────────────── |
| 85 | + |
| 86 | + @commands.hybrid_group( |
| 87 | + name="sticky", |
| 88 | + description="Create and manage sticky messages for this server.", |
| 89 | + invoke_without_command=True, |
| 90 | + ) |
| 91 | + @commands.guild_only() |
| 92 | + async def sticky(self, ctx: commands.Context): |
| 93 | + prefix = ctx.prefix or "." |
| 94 | + view = discord.ui.LayoutView() |
| 95 | + view.add_item(discord.ui.Container( |
| 96 | + discord.ui.TextDisplay(content="### 📌 Sticky Messages"), |
| 97 | + discord.ui.Separator(visible=True, spacing=discord.SeparatorSpacing.small), |
| 98 | + discord.ui.TextDisplay( |
| 99 | + content=( |
| 100 | + "Sticky messages stay pinned to the bottom of a channel — every time " |
| 101 | + "someone posts, I delete the old copy and repost it underneath.\n\n" |
| 102 | + f"**`{prefix}sticky set <message>`** — create or update the sticky message here\n" |
| 103 | + f"**`{prefix}sticky remove`** — remove the sticky message from this channel\n" |
| 104 | + f"**`{prefix}sticky list`** — list every sticky message in this server" |
| 105 | + ) |
| 106 | + ), |
| 107 | + )) |
| 108 | + await ctx.send(view=view, allowed_mentions=discord.AllowedMentions.none()) |
| 109 | + |
| 110 | + @sticky.command(name="set", description="Create or update the sticky message for this channel.") |
| 111 | + @commands.has_permissions(manage_messages=True) |
| 112 | + @commands.guild_only() |
| 113 | + async def sticky_set(self, ctx: commands.Context, *, message: str): |
| 114 | + message = message.strip() |
| 115 | + ephemeral = bool(ctx.interaction) |
| 116 | + |
| 117 | + if not message: |
| 118 | + return await ctx.send(view=_feedback("Please provide the message to stick.", ok=False), ephemeral=ephemeral) |
| 119 | + if len(message) > MAX_CONTENT_LEN: |
| 120 | + return await ctx.send( |
| 121 | + view=_feedback(f"Sticky messages can be at most {MAX_CONTENT_LEN} characters.", ok=False), |
| 122 | + ephemeral=ephemeral, |
| 123 | + ) |
| 124 | + |
| 125 | + gdata = self._guild_data(ctx.guild.id) |
| 126 | + existing = gdata.get(str(ctx.channel.id)) |
| 127 | + if existing is None and len(gdata) >= MAX_STICKIES_PER_GUILD: |
| 128 | + return await ctx.send( |
| 129 | + view=_feedback(f"This server already has the maximum of {MAX_STICKIES_PER_GUILD} sticky messages.", ok=False), |
| 130 | + ephemeral=ephemeral, |
| 131 | + ) |
| 132 | + |
| 133 | + # Delete the previous sticky post (if any) before sending the new one. |
| 134 | + old_message_id = existing.get("message_id") if existing else None |
| 135 | + if old_message_id: |
| 136 | + try: |
| 137 | + old_msg = await ctx.channel.fetch_message(old_message_id) |
| 138 | + await old_msg.delete() |
| 139 | + except (discord.NotFound, discord.Forbidden, discord.HTTPException): |
| 140 | + pass |
| 141 | + |
| 142 | + color = existing.get("color") if existing else DEFAULT_COLOR |
| 143 | + |
| 144 | + try: |
| 145 | + posted = await ctx.channel.send(view=_sticky_view(message, color)) |
| 146 | + except discord.Forbidden: |
| 147 | + return await ctx.send( |
| 148 | + view=_feedback("I don't have permission to send messages in this channel.", ok=False), |
| 149 | + ephemeral=ephemeral, |
| 150 | + ) |
| 151 | + |
| 152 | + gdata[str(ctx.channel.id)] = { |
| 153 | + "content": message, |
| 154 | + "color": color, |
| 155 | + "message_id": posted.id, |
| 156 | + "created_by": ctx.author.id, |
| 157 | + } |
| 158 | + _save(self.data) |
| 159 | + |
| 160 | + await ctx.send( |
| 161 | + view=_feedback("Sticky message set." if existing is None else "Sticky message updated."), |
| 162 | + ephemeral=ephemeral, |
| 163 | + ) |
| 164 | + |
| 165 | + @sticky.command(name="remove", aliases=["clear", "delete"], description="Remove the sticky message from this channel.") |
| 166 | + @commands.has_permissions(manage_messages=True) |
| 167 | + @commands.guild_only() |
| 168 | + async def sticky_remove(self, ctx: commands.Context): |
| 169 | + ephemeral = bool(ctx.interaction) |
| 170 | + gdata = self._guild_data(ctx.guild.id) |
| 171 | + entry = gdata.pop(str(ctx.channel.id), None) |
| 172 | + if entry is None: |
| 173 | + return await ctx.send( |
| 174 | + view=_feedback("This channel doesn't have a sticky message.", ok=False), |
| 175 | + ephemeral=ephemeral, |
| 176 | + ) |
| 177 | + _save(self.data) |
| 178 | + |
| 179 | + if entry.get("message_id"): |
| 180 | + try: |
| 181 | + old_msg = await ctx.channel.fetch_message(entry["message_id"]) |
| 182 | + await old_msg.delete() |
| 183 | + except (discord.NotFound, discord.Forbidden, discord.HTTPException): |
| 184 | + pass |
| 185 | + |
| 186 | + await ctx.send(view=_feedback("Sticky message removed."), ephemeral=ephemeral) |
| 187 | + |
| 188 | + @sticky.command(name="list", description="List every sticky message in this server.") |
| 189 | + @commands.has_permissions(manage_messages=True) |
| 190 | + @commands.guild_only() |
| 191 | + async def sticky_list(self, ctx: commands.Context): |
| 192 | + ephemeral = bool(ctx.interaction) |
| 193 | + gdata = self._guild_data(ctx.guild.id) |
| 194 | + if not gdata: |
| 195 | + return await ctx.send( |
| 196 | + view=_feedback("This server has no sticky messages yet.", ok=False), |
| 197 | + ephemeral=ephemeral, |
| 198 | + ) |
| 199 | + |
| 200 | + lines = [] |
| 201 | + for cid_str, entry in gdata.items(): |
| 202 | + content = entry.get("content", "") |
| 203 | + preview = content[:80] + ("…" if len(content) > 80 else "") |
| 204 | + lines.append(f"• <#{cid_str}> — {preview}") |
| 205 | + |
| 206 | + view = discord.ui.LayoutView() |
| 207 | + view.add_item(discord.ui.Container( |
| 208 | + discord.ui.TextDisplay(content=f"### 📌 Sticky Messages ({len(gdata)})"), |
| 209 | + discord.ui.Separator(visible=True, spacing=discord.SeparatorSpacing.small), |
| 210 | + discord.ui.TextDisplay(content="\n".join(lines)), |
| 211 | + )) |
| 212 | + await ctx.send(view=view, ephemeral=ephemeral, allowed_mentions=discord.AllowedMentions.none()) |
| 213 | + |
| 214 | + # ── restick logic ──────────────────────────────────────────────────── |
| 215 | + |
| 216 | + @commands.Cog.listener() |
| 217 | + async def on_message(self, message: discord.Message): |
| 218 | + if not message.guild or message.author.bot: |
| 219 | + return |
| 220 | + |
| 221 | + entry = self._get(message.guild.id, message.channel.id) |
| 222 | + if entry is None: |
| 223 | + return |
| 224 | + |
| 225 | + channel_id = message.channel.id |
| 226 | + if channel_id in self._pending: |
| 227 | + return # a repost is already scheduled for this channel — let it handle this burst |
| 228 | + self._pending.add(channel_id) |
| 229 | + |
| 230 | + task = asyncio.create_task(self._repost_after_delay(message.guild.id, channel_id)) |
| 231 | + task.add_done_callback(lambda t: self._pending.discard(channel_id)) |
| 232 | + |
| 233 | + async def _repost_after_delay(self, guild_id: int, channel_id: int): |
| 234 | + try: |
| 235 | + await asyncio.sleep(REPOST_DELAY) |
| 236 | + |
| 237 | + entry = self._get(guild_id, channel_id) |
| 238 | + if entry is None: |
| 239 | + return # sticky was removed while we were waiting |
| 240 | + |
| 241 | + channel = self.bot.get_channel(channel_id) |
| 242 | + if channel is None: |
| 243 | + return |
| 244 | + |
| 245 | + old_message_id = entry.get("message_id") |
| 246 | + if old_message_id: |
| 247 | + try: |
| 248 | + old_msg = await channel.fetch_message(old_message_id) |
| 249 | + await old_msg.delete() |
| 250 | + except (discord.NotFound, discord.Forbidden, discord.HTTPException): |
| 251 | + pass |
| 252 | + |
| 253 | + try: |
| 254 | + posted = await channel.send(view=_sticky_view(entry["content"], entry.get("color", DEFAULT_COLOR))) |
| 255 | + except (discord.Forbidden, discord.HTTPException): |
| 256 | + return |
| 257 | + |
| 258 | + entry["message_id"] = posted.id |
| 259 | + _save(self.data) |
| 260 | + except Exception: |
| 261 | + # Never let a background repost task crash the event loop silently. |
| 262 | + pass |
| 263 | + |
| 264 | + |
| 265 | +async def setup(bot: commands.Bot): |
| 266 | + await bot.add_cog(StickyCog(bot)) |
0 commit comments