afterhours-shift-manager/docs/sticky-schedule-post.md
seahaven-openswe[bot] 254f6b989f
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
[#135] Stick weekly schedule post to bottom of channel (#139)
2026-06-27 15:45:01 -04:00

7.2 KiB

Evaluation: sticky / reused weekly schedule post

Spike for issue #135 — decide how the Monday two-week schedule post should behave across weeks. This document records the decision and the rationale; the recommended option (b) is implemented in the same PR.

Background

src/weekly-post/app.py (the Monday 7am ET Lambda) originally, every week:

  1. chat_delete the previous week's schedule post (stored ts from get_schedule_post),
  2. chat_postMessage a brand-new two-week schedule message, and
  3. save_schedule_post(channel_id, new_ts, …).

So every Monday the post got a new ts (new permalink), re-notified the channel, and lost any thread/reactions. During the week it steadily sank as people chatted.

The infrastructure to edit in place already exists: in-week shift changes (pick / drop / swap / admin) call _refresh_schedule_post() (src/slack-bot/app.py), which does a chat_update against the stored ts.

Options considered

(a) Reuse the same post across weeks — pin for reachability

On Monday, chat_update the existing message to roll the two-week window forward instead of delete + repost, and add a native Slack pin so the post is reachable from the channel header.

  • Pros: stable permalink; no weekly re-notification; keeps any thread/reactions; trivial change (reuses the _refresh_schedule_post chat_update pattern already in the codebase).
  • Cons: the message stays where it was first posted — it does not rise to the bottom as the channel gets new activity. A native pin only surfaces the post in the channel's pinned-items panel (the header); it does not hold the message at the bottom of the timeline, which is the actual requirement from #135.

Slack has no native "sticky" message. Stickybot-style bots subscribe to channel message events and delete + repost the bot message whenever someone else posts, so it is always last.

  • Pros: the schedule post is always at the literal bottom of the channel timeline — exactly the behavior #135 asks for.
  • Cons: every bump is a new ts (the permalink changes); the post re-surfaces / re-notifies on activity; requires a new message.channels event subscription, the channels:history scope (a one-time reinstall), and debounce machinery to avoid thrashing on bursts.

(c) Status quo — delete + repost weekly

  • Pros: the weekly repost bumps the post to the bottom once a week.
  • Cons: new permalink every week; a weekly re-notification; loses thread/reactions; the post still sinks for the rest of the week.

Decision

Adopt (b) true bottom-of-channel stickiness. Adam wants literal bottom-of-channel placement of the schedule post, and explicitly accepts the tradeoffs that come with it:

  1. The permalink changes on each bump — every delete + repost produces a new ts, so any saved link to the post goes stale.
  2. The post re-surfaces / re-notifies on activity — re-posting pings the channel (subject to the debounce window below), rather than sitting quietly.
  3. New machinery — a message.channels event subscription, the channels:history scope (one-time reinstall), and a debounce to avoid thrashing on bursts.

The native pin (option a's complement) was rejected: it only gives header reachability — the post shows up in the pinned-items panel — but it does not hold the message at the bottom of the timeline, which is the requirement. A pin is therefore not a substitute for bottom-stickiness.

What changes (option b)

Weekly rollover — src/weekly-post/app.py

  • Drop the unconditional chat_delete + always-repost. On Monday, chat_update the stored post to roll the two-week window forward, keeping the same ts (the activity bump is what moves it to the bottom; the rollover just refreshes content).
  • First-run / recovery fallback: when there is no stored post or the chat_update fails (e.g. the message was deleted manually), chat_postMessage a fresh message and store its ts.
  • The pins:write scope and the _pin_schedule_post helper are removed — the pin is fully replaced by bottom-stickiness.

Activity bump — src/slack-bot/app.py

The bump lives in the slack-bot Lambda, which already owns the Slack events request URL. On a message.channels event in the schedule channel (handle_channel_message):

  • Debounced so a burst of chatter triggers at most one bump: SCHEDULE_BUMP_DEBOUNCE_SECONDS = 180 (3 minutes). The last bump time is stored as last_bump_ts on the schedule-post record, so the debounce check reads no channel history.
  • Idempotent / safe — the bump is skipped when:
    • the delivery is a Slack retry (X-Slack-Retry-Num set) — under process_before_response=True the delete+repost runs before the 200 ack, so a slow run can be retried; we never bump on a retry (the next real message bumps anyway),
    • the triggering message is a thread reply (thread_ts) — a threaded reply doesn't push the schedule down the main timeline,
    • there is no stored post,
    • the triggering message is the bot's own (bot_id) or a non-user message subtype (edits, deletes, joins, …),
    • the debounce window has not elapsed, or
    • the bot isn't in the channel (not_in_channel).
  • Otherwise it stamps last_bump_ts optimistically before the delete/repost (so a retry fired mid-bump — or after the run dies before the final save — is debounced and can't orphan a duplicate), chat_deletes the stored post, chat_postMessages the current build_week_schedule blocks, then save_schedule_posts the new ts. If the old message is already gone, it still reposts so the channel always ends with the schedule.

Interaction with _refresh_schedule_post and the rollover

All three paths converge on the single stored ts and never fight:

  • Monday rollover — chat_update the stored ts (content refresh; same message), clearing last_bump_ts so the next activity is free to bump.
  • In-week shift edits — _refresh_schedule_post chat_updates the same stored ts; it does not change the ts or touch last_bump_ts.
  • Activity bump — deletes the stored ts, reposts the same content at the bottom, and saves the new ts. Subsequent rollovers and edits then operate on that new ts.

Because only the bump ever mints a new ts (and it always re-saves it immediately), the other two paths always read the current ts from the record.

Scope / manifest impact

  • channels:history (bot scope) and message.channels (bot event) are added to slack-app-manifest.yaml so the slack-bot Lambda receives channel messages and can run the bump. This is a new scope + event, so the app must be reinstalled once for them to take effect.
  • pins:write is removed — the pin is no longer used.

Follow-ups (not in this PR)

  • After deploy, reinstall the Slack app so channels:history / message.channels take effect, then confirm a new channel message bumps the schedule post to the bottom (and that bursts are debounced).
  • If the re-notification on each bump proves noisy, revisit the debounce window or consider posting without a broadcast.