Commit 4376aa6
authored
feat(sticky): self-healing, single-sticky guarantee and bottom-pinning
Reworks the sticky lifecycle so that exactly one sticky per channel is
maintained, it is automatically restored if it disappears, duplicates
are cleaned up, and it always stays at the bottom of the channel after
new user activity.
This commit rolls up several related fixes and one new capability into
a single change to sticky.py.
────────────────────────────────────────────────────────────────────────
1. Auto-restore on delete / purge
────────────────────────────────────────────────────────────────────────
New listeners `on_message_delete` and `on_bulk_message_delete` detect
when the tracked sticky message (or the whole batch it belonged to) is
removed and immediately trigger a repost. Previously the cog would
only repost on the next user message, leaving the sticky missing for
an unpredictable amount of time.
- `on_message_delete` reacts only when `message.id == last_id`.
- `on_bulk_message_delete` deduplicates affected channels and checks
each one once.
────────────────────────────────────────────────────────────────────────
2. Periodic self-heal check loop
────────────────────────────────────────────────────────────────────────
Adds a background task `_sticky_check_loop` started in a new
`cog_load` hook and cleanly cancelled in `cog_unload`. Every
`CHECK_INTERVAL` (60 s) it iterates all guilds and every configured
sticky channel and calls `_ensure_single_sticky`, so a silently
missing sticky is recovered even without any user activity.
New manual command `[p]sticky check` runs the same routine on demand.
────────────────────────────────────────────────────────────────────────
3. Exactly one sticky per channel (duplicate cleanup)
────────────────────────────────────────────────────────────────────────
`_ensure_single_sticky` now performs three things under the channel
lock:
1. verifies the tracked message still exists and is authored by the
bot,
2. scans the last 100 messages for bot posts that look like our
sticky — matched by the `sticky_cmd:<guild>:<channel>:` button
custom_id prefix, or by exact content match for text-only
stickies,
3. deletes every duplicate (except the tracked one, unless
`force_repost=True`),
4. reposts the sticky when the tracked one is gone.
All delete paths (delete listeners, `sticky remove`, manage-view
Remove button) funnel through this method so the "exactly one sticky"
invariant is enforced everywhere.
────────────────────────────────────────────────────────────────────────
4. Move the sticky to the bottom on new messages
────────────────────────────────────────────────────────────────────────
`_ensure_single_sticky` gained a `force_repost` parameter:
- `force_repost=False` (default): repost only if the tracked sticky
is missing — used by the periodic loop, `[p]sticky check`,
`on_message_delete` and `on_bulk_message_delete`.
- `force_repost=True`: always delete the tracked sticky and post a
fresh one, so the sticky moves to the bottom after user activity.
`_repost_worker` calls it with `force_repost=True` once its debounce
window has elapsed without a new trigger, so a burst of user messages
results in exactly one repost at the end of the burst.
────────────────────────────────────────────────────────────────────────
5. Race-condition fixes (no more duplicate reposts under spam)
────────────────────────────────────────────────────────────────────────
Three independent races could previously produce several identical
stickies in the same channel:
a) **Cancellation during `channel.send()`**
`_schedule_repost` used to cancel the running debounce task
whenever a new message arrived. If the cancel landed while the
task was inside `await channel.send(...)`, Discord still created
the message but the follow-up `data["last_id"] = new_msg.id` was
never executed. The result was an untracked sticky, and the next
repost saw the old (deleted) `last_id`, concluded the sticky was
missing, and posted yet another copy.
Fix: cancellation is gone. `_schedule_repost` now sets a
per-channel `_repost_again` flag. `_repost_worker` (replacing
`_debounced_repost`) loops: sleep `delay`, check the flag; if a
new trigger arrived during the sleep, restart the debounce
window; otherwise call `_ensure_single_sticky` once and exit.
No more mid-post cancellation → no orphan messages.
b) **Discord indexing lag after send**
Right after `channel.send()` returns, `channel.fetch_message()`
can briefly fail with NotFound. `_ensure_single_sticky`
interpreted that as "sticky is gone" and triggered an extra
repost.
Fix: new `_recent_posts` map ({message_id: monotonic_ts}, TTL
`RECENT_POST_TTL = 15 s`) records every message the cog just
sent. `_ensure_single_sticky` trusts a `last_id` present in the
cache without calling `fetch_message`, and
`_find_sticky_duplicates` skips recent posts so a freshly posted
sticky is not misidentified as a duplicate.
c) **Concurrent delete events**
The old check-then-act pattern read `last_id` outside the lock,
so two delete events could both see the sticky as missing and
each post a copy.
Fix: config data is now read inside the channel lock and the
repost logic moved into `_post_sticky_locked`, which re-checks
`last_id` right before sending. Callers use the lock-wrapping
`_post_sticky` public wrapper.
────────────────────────────────────────────────────────────────────────
6. Internal-delete tracking
────────────────────────────────────────────────────────────────────────
Deletes initiated by the cog itself (during repost, `sticky remove`,
manage-view Remove, duplicate cleanup) are now recorded in
`_internal_deletes` ({message_id: monotonic_ts}, 60 s TTL). The
`on_message_delete` listener ignores those entries, preventing
feedback loops between "delete old sticky" and "sticky was deleted →
repost".
────────────────────────────────────────────────────────────────────────
7. Debug logging
────────────────────────────────────────────────────────────────────────
All debug-log strings are now in English, consistent with the rest of
the cog. New events are surfaced explicitly so the source of a repost
is easy to identify in the debug channel:
🗑️ Sticky message in <#…> was deleted — reposting.
🔁 Sticky missing in <#…> — reposting.
🔁 Force-reposting sticky in <#…> (move to bottom).
🧹 Removed duplicate sticky <id> in <#…>.
────────────────────────────────────────────────────────────────────────
Files touched
────────────────────────────────────────────────────────────────────────
- `sticky.py`
- added import: `time`
- added constants: `CHECK_INTERVAL`, `RECENT_POST_TTL`
- added state: `_internal_deletes`, `_check_task`, `_repost_again`,
`_recent_posts`
- added hooks: `cog_load`
- changed hook: `cog_unload` (cancels the check loop, clears new
state)
- added helpers: `_mark_internal_delete`, `_is_internal_delete`
- changed: `apply_sticky` (marks internal deletes, records recent
posts)
- added: `_post_sticky_locked` (actual repost logic under held lock)
- changed: `_post_sticky` (now a lock-wrapping wrapper)
- added: `_find_sticky_duplicates`
- added: `_ensure_single_sticky` (`force_repost` parameter)
- added: `_sticky_check_loop`
- added: `_repost_worker` (replaces `_debounced_repost`)
- changed: `_schedule_repost` (flag-based, no cancel)
- added listeners: `on_message_delete`, `on_bulk_message_delete`
- added command: `sticky check`
- changed: manage-view `remove` and `sticky remove` (mark internal
deletes)
────────────────────────────────────────────────────────────────────────
Backward compatibility
────────────────────────────────────────────────────────────────────────
No config schema changes. Existing stickies keep working without
migration. The first run of the periodic loop (or a single
`[p]sticky check`) cleans up any duplicates that accumulated before
this patch.1 parent ce72d4c commit 4376aa6
1 file changed
Lines changed: 324 additions & 61 deletions
0 commit comments