Skip to content

Commit 4376aa6

Browse files
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

File tree

0 commit comments

Comments
 (0)