feat(bot/webapp): a misconfigured channel fails loudly, and Discord votes answer back (phase 5 lane A) #523

Merged
claude-bot merged 3 commits from feat/phase5a-notifications-and-votes into main 2026-09-06 04:20:51 +00:00
Contributor

Closes #369, closes #371. Phase 5 lane A of the v4.3.0 build order (#514). Three commits. One migration: f0ab1c2d3e4f (nullable JSONB campaigns.discord_delivery_status) on e9fa0b1c2d3e; alembic heads is one head. No BOT_CONTRACT_VERSION bump (reasoning below).

#369 a wrong channel ID no longer swallows every notification. _resolve_channel returns why it failed (invalid_id / not_found / forbidden / not_text / unknown, plus cannot_send from a Forbidden on the post itself, the commonest misconfiguration after a wrong ID), and the bot reports it through a new additive POST /api/bot/campaigns/{id}/delivery-status. The record is written before the stream entry is acked; if the record fails too, that is logged at ERROR with campaign, reason and event type, so nothing is ever both unrecorded and acked. NULL is not "ok" and nothing is backfilled. The status is on CampaignResponse and CampaignDetail shows a banner at the top of the page, outside the collapsed Edit form; it clears on the first successful post, including the first after a restart. The "Send test message" button had three further problems (admin-only on a GM page, reported success on publishing to Redis, tested both fields as one) and is replaced by two GM-authorised, synchronous tests via POST /api/campaigns/{id}/discord-test: the channel test calls a new POST /test-channel on the bot which resolves and posts before answering and writes the verdict; the webhook test is guarded by is_discord_webhook_url. The channel input validates 17–20 digits with a hint.

#371 Discord votes answer back. Reactions are not interactions, so every reaction now gets a self-deleting channel message mentioning the player (15 s success, 30 s failure with the reason); DMs are gone from the cog, reconciled with #409 and #392 (the unlinked-user prompt is a self-deleting channel note; the fallback survives). vote_service.notify_vote_cast is called by both the web and bot vote paths. bot_submit_vote returns 409 on a closed poll; api_client raises VotingClosedError (a QuestBoardApiError subclass) and the bot removes the reaction and repeats the sentence with no retry offer; confirming a session relabels the poll "Voting closed" and drops the instructions but keeps the option list. "Maybe" is scoped down to a legend on the poll (a third reaction per slot would mean ten seeded reactions per poll). Slate editing needed more than wiring up the unused API calls: the bot's reaction→slot mapping is positional, so both timeslot endpoints publish a new session_slate_changed event and the bot rebuilds embed, reactions and mapping in place, mapping first. SessionDetail gains "+ Add another time option" and a per-slot Remove with a confirm naming the votes it drops. The Redis in-memory fallback warns loudly and every dropped reaction is logged.

Contract: session_slate_changed is a new event type an older bot ignores; the 409 becomes the transport failure an older bot already handles (it says the vote did not count, which is true). No shape changed and no older bot can be led into recording a vote incorrectly.

Rebased over lane B (#521): VotingClosedError now sits on lane B's QuestBoardApiError, and the rebase caught a live bug where the closed-poll message would have rendered as the generic text; a test now asserts the seam. Bot 432 passed, backend 2405 passed / 13 skipped, frontend 539 passed, ruff and version-sync clean, migration round-trips up/down/up on Postgres 16. Follow-up filed as #522 (a constructor guard on QuestBoardApiError).

🤖 Generated with Claude Code

Closes #369, closes #371. Phase 5 lane A of the v4.3.0 build order (#514). Three commits. **One migration**: `f0ab1c2d3e4f` (nullable JSONB `campaigns.discord_delivery_status`) on `e9fa0b1c2d3e`; `alembic heads` is one head. No `BOT_CONTRACT_VERSION` bump (reasoning below). **#369 a wrong channel ID no longer swallows every notification.** `_resolve_channel` returns *why* it failed (`invalid_id` / `not_found` / `forbidden` / `not_text` / `unknown`, plus `cannot_send` from a `Forbidden` on the post itself, the commonest misconfiguration after a wrong ID), and the bot reports it through a new additive `POST /api/bot/campaigns/{id}/delivery-status`. The record is written before the stream entry is acked; if the record fails too, that is logged at ERROR with campaign, reason and event type, so nothing is ever both unrecorded and acked. NULL is not "ok" and nothing is backfilled. The status is on `CampaignResponse` and `CampaignDetail` shows a banner at the top of the page, outside the collapsed Edit form; it clears on the first successful post, including the first after a restart. The "Send test message" button had three further problems (admin-only on a GM page, reported success on publishing to Redis, tested both fields as one) and is replaced by two GM-authorised, synchronous tests via `POST /api/campaigns/{id}/discord-test`: the channel test calls a new `POST /test-channel` on the bot which resolves *and posts* before answering and writes the verdict; the webhook test is guarded by `is_discord_webhook_url`. The channel input validates 17–20 digits with a hint. **#371 Discord votes answer back.** Reactions are not interactions, so every reaction now gets a self-deleting channel message mentioning the player (15 s success, 30 s failure with the reason); DMs are gone from the cog, reconciled with #409 and #392 (the unlinked-user prompt is a self-deleting channel note; the ❗ fallback survives). `vote_service.notify_vote_cast` is called by both the web and bot vote paths. `bot_submit_vote` returns 409 on a closed poll; `api_client` raises `VotingClosedError` (a `QuestBoardApiError` subclass) and the bot removes the reaction and repeats the sentence with no retry offer; confirming a session relabels the poll "Voting closed" and drops the instructions but keeps the option list. "Maybe" is scoped down to a legend on the poll (a third reaction per slot would mean ten seeded reactions per poll). Slate editing needed more than wiring up the unused API calls: the bot's reaction→slot mapping is positional, so both timeslot endpoints publish a new `session_slate_changed` event and the bot rebuilds embed, reactions and mapping in place, mapping first. `SessionDetail` gains "+ Add another time option" and a per-slot Remove with a confirm naming the votes it drops. The Redis in-memory fallback warns loudly and every dropped reaction is logged. **Contract:** `session_slate_changed` is a new event type an older bot ignores; the 409 becomes the transport failure an older bot already handles (it says the vote did not count, which is true). No shape changed and no older bot can be led into recording a vote incorrectly. Rebased over lane B (#521): `VotingClosedError` now sits on lane B's `QuestBoardApiError`, and the rebase caught a live bug where the closed-poll message would have rendered as the generic text; a test now asserts the seam. Bot 432 passed, backend 2405 passed / 13 skipped, frontend 539 passed, ruff and version-sync clean, migration round-trips up/down/up on Postgres 16. Follow-up filed as #522 (a constructor guard on `QuestBoardApiError`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The bot notification channel is a Discord snowflake a GM types into a plain
text field. When it is wrong — a typo, a channel deleted since, a channel the
bot cannot see or cannot post in — `_resolve_channel` returned None, logged a
warning nobody reads, and `_process_entry` XACKed the entry exactly as if it
had been delivered. Every notification that campaign would ever send vanished,
permanently, with no error anywhere in the product.

The ack stays: an entry the bot cannot handle must not wedge the consumer group
for every other campaign. What changes is that it is no longer the end of the
story.

- `_resolve_channel` now returns *why* it failed (invalid_id, not_found,
  forbidden, not_text, unknown) with a sentence written for the GM who typed
  the ID, and `on_bot_notify` reports it through a new additive endpoint,
  `POST /api/bot/campaigns/{id}/delivery-status`, before returning — so the
  record is written before the caller acks. `discord.Forbidden` raised by the
  *post* is caught separately as `cannot_send`: a channel the bot can see but
  not speak in was indistinguishable from success.
- The record lands in a new nullable JSONB `campaigns.discord_delivery_status`
  (migration `f0ab1c2d3e4f`, chained on `e9fa0b1c2d3e`; one head). NULL is
  deliberately not "ok" — a channel nobody has posted to has demonstrated
  nothing — so nothing is backfilled.
- `CampaignResponse` exposes it to every member, not just the GM: it holds a
  channel ID, a reason code and a sentence, no secrets, and a player wondering
  why the reminders stopped is owed the same answer. `CampaignDetail` renders
  it as a banner at the *top of the page*, outside the collapsed Edit form —
  putting the warning behind the same disclosure as the broken field would
  inherit the invisibility this fixes.
- If the record itself cannot be written, that is logged at error with the
  campaign, reason and event type, because the entry is about to be acked and
  the log line is then the only trace that will ever exist.
- The warning clears itself: the first successful post per campaign reports
  "ok", including the first after a restart, so a banner left by a dead process
  cannot outlive the problem.

The "Send test message" button is fixed in the same change, because it had the
same lie in it. It called the admin-only `/api/admin/settings/bot/test-notify`
from a page any GM can open (403 for every self-hoster who is not their own
admin), it published to Redis and reported success on the *publish*, and it
tested the bot channel and the webhook as one thing. There are now two buttons,
one per field: `POST /api/campaigns/{id}/discord-test` is GM-authorised and
synchronous — for a channel it calls a new `POST /test-channel` on the bot's
internal server, which resolves and posts before answering, and writes the
result to `discord_delivery_status` so a passing test clears the banner and a
failing one raises it. The channel field also rejects anything that is not
17–20 digits before it can be saved, with a line saying where to find the ID.

No `BOT_CONTRACT_VERSION` bump: `/api/bot/campaigns/{id}/delivery-status` is
purely additive, and an older bot simply never calls it. `/test-channel` is
backend→bot rather than part of that contract, and a backend talking to an
older bot gets a 404 it reports as "upgrade the bot image" rather than blaming
the channel ID.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reacting to a poll or an RSVP message produced nothing a player could see. A
vote that reached Quest Board and one that was dropped looked identical — the
reaction stayed on the message either way — and `auto_close_voting` would later
pick a time from whatever tally happened to arrive. #409 answered the failure
half with a DM; this finishes it and moves the answer into the channel.

Acknowledgement. Reactions are not interactions, so there is no ephemeral reply
available: every acknowledgement is a real message that mentions the reacting
user and deletes itself (15 s on success, 30 s on failure). Success needed one
too, and a DM per successful vote would be unusable — and DMs are closed for a
large share of users, which is the #392 lesson — so DMs are gone from this cog
entirely. If the channel message cannot be sent, the -on-the-message fallback
from #409 still applies. The receipt names the option and, on a removal, says
what removing actually did.

Symmetry. `bot_submit_vote` fired no notification, where the identical vote cast
in the web app fired a "🗳️ Vote Update" embed into the very channel the reaction
came from — so the channel's own tally silently went stale. The inline block in
`routers/votes.py` is now `vote_service.notify_vote_cast`, called by both.

Closed polls. `bot_submit_vote` had no status check at all, where the web
endpoint refuses a session that is not proposed: a reaction on an
already-confirmed session upserted a vote into a decision that had been made. It
answers 409 now with a sentence naming the confirmed time — a distinct code from
the 400 used for a malformed request, so the bot can tell "too late" from "you
sent nonsense" — and `api_client` raises `VotingClosedError` rather than a
generic HTTP error, because "try again in a minute" is exactly the wrong advice.
The bot removes the reaction (the only visible record of the vote) and repeats
Quest Board's sentence. And confirming a session now relabels its poll message
"🔒 Voting closed", so it stops inviting reactions in the first place.

"Maybe" stays out of Discord, deliberately. A third reaction per slot means ten
seeded reactions on a five-option poll, ten to clear on every rebuild, mutual
exclusion between each pair, and five more seconds of rate-limited seeding per
post — a lot of machinery for the least decisive of the three answers. The poll
carries a one-line legend instead, which also finally states the rule that was
only ever in a docstring: add = yes, **remove = no**, and "maybe" lives in the
web app.

Slate editing. `addTimeslot`/`removeTimeslot` had existed in the frontend API
client since the beginning with no caller, so the only way to change a live
slate was "Reopen for voting", which replaces it and discards every vote.
`SessionDetail` now offers "+ Add another time option" and a per-slot Remove
(never down to fewer than two options, and the confirm names how many votes it
would delete). Because the bot's reaction→slot mapping is *positional*, a slate
edit that did not reach Discord would leave 🇧 pointing at whatever now sits
second: both endpoints publish `session_slate_changed`, and the bot rebuilds the
poll embed, its reactions and its mapping from the current slate — mapping
first, so a reaction landing mid-rebuild is read against the slate the embed
already shows.

Redis fallback. `_get_redis` returning None was completely silent when no
REDIS_URL was set. Every mapping then lives and dies with the process, so after
a restart the polls keep their reactions and record nothing — a channel that
looks alive and is dead. That now warns once, loudly, at the moment the fallback
engages, and every dropped reaction is logged with its message id (including on
a removal, where there is no author id to tell "ours" from "not ours" and so no
message is posted).

No `BOT_CONTRACT_VERSION` bump, on the judgement that neither change makes an
older bot get anything *wrong*. `session_slate_changed` is a new event type an
older bot logs as unknown and ignores, leaving the stale poll it would have had
anyway. The 409 is a new status on an existing endpoint: an older bot's
`raise_for_status()` turns it into the transport failure it already handles, so
it tells the player the vote did not count — true — and offers a retry that
cannot help, which is worse advice than the new bot gives but better than the
silent acceptance it replaces. No request or response shape changed, and no
older bot can be led into recording a vote incorrectly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(bot): keep the option list on a closed poll, drop only the instructions (#371)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 37s
CI / Docker image build (pull_request) Successful in 37s
CI / Backend lint (ruff) (pull_request) Successful in 42s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 57s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m30s
CI / Bot tests and audit (pull_request) Successful in 1m51s
CI / Backend migration, tests, and audit (pull_request) Successful in 6m49s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 17m32s
ad0db91378
The relabel cleared every field, so a poll that had been decided showed the
winning time and nothing about what the table had been asked to choose between.
The options are the record of the question; only the "How to vote" block is
actually false once voting is closed, so only that goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-06 04:03:24 +00:00
claude-bot deleted branch feat/phase5a-notifications-and-votes 2026-09-06 04:20:51 +00:00
Sign in to join this conversation.
No description provided.