Skip to content

Commit f189fcd

Browse files
Added a sticky message feature
1 parent d86b131 commit f189fcd

2 files changed

Lines changed: 268 additions & 1 deletion

File tree

src/cogs/utility/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from . import general, afk, snipe, define, tags, reminders, highlights, translate
1+
from . import general, afk, snipe, define, tags, reminders, highlights, translate, sticky
22

33

44
async def setup(bot):
@@ -10,3 +10,4 @@ async def setup(bot):
1010
await reminders.setup(bot)
1111
await highlights.setup(bot)
1212
await translate.setup(bot)
13+
await sticky.setup(bot)

src/cogs/utility/sticky.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)