fix(bot): stop lying in Discord: failed summaries, deep links, and the unused privileged intent (phase 1 of v4.3.0) #517

Merged
claude-bot merged 3 commits from fix/phase1-bot into main 2026-09-06 01:59:45 +00:00
Contributor

Closes #372, closes #391, closes #394. Phase 1 of the v4.3.0 build order (#514), shipping early as v4.2.3. Three commits, one per issue, on one branch because they share cogs/notifications.py.

  • #372 _handle_session_summarised branches on extra["error"] before it reads summary. A failure gets its own red "Summary Failed" embed naming the session, saying the recording was kept, and pointing at the retry on the session page; no feedback reactions and no transcript_feedback mapping, so a stray 👍 cannot attach a vote to a summary that does not exist. The raw error is logged, never posted. The backend's failure payload carried nothing but the error, so both failure call sites now send title, confirmed time and campaign name; the bot falls back title → campaign + date → date → "your latest session", never "Untitled Session".
  • #391 session_url_for / session_url_extra in bot_pubsub.py, splatted into all twelve session-scoped enqueue sites (for session_summarised inside _notify_bot_summarised, so success, failure and the queue timeout all carry it). When APP_URL is unset the field is omitted rather than half-built. On the bot, one _add_quest_board_link helper is called by all thirteen templates; the summary footer's linkless "Full transcript available on Quest Board" becomes a real link. Both routes confirmed to exist in the frontend.
  • #394 intents.message_content removed (no on_message, no prefix commands anywhere). PrivilegedIntentsRequired is caught alongside LoginFailure in a new testable _run_discord_bot: the gateway client stops with one log line naming the exact portal toggle while /health keeps serving, rather than a process exit that Compose's unless-stopped would restart into the same wall of tracebacks. Four docs corrected, including .env.example, which had the two intents backwards.

Additive payload fields only; BOT_CONTRACT_VERSION unchanged, check_version_sync.py passes. Bot suite 318 passed (+40), backend full suite 2340 passed / 13 skipped, ruff clean. Each commit verified in isolation so the series bisects. No migration.

🤖 Generated with Claude Code

Closes #372, closes #391, closes #394. Phase 1 of the v4.3.0 build order (#514), shipping early as v4.2.3. Three commits, one per issue, on one branch because they share `cogs/notifications.py`. - **#372** `_handle_session_summarised` branches on `extra["error"]` before it reads `summary`. A failure gets its own red "Summary Failed" embed naming the session, saying the recording was kept, and pointing at the retry on the session page; no feedback reactions and no `transcript_feedback` mapping, so a stray 👍 cannot attach a vote to a summary that does not exist. The raw error is logged, never posted. The backend's failure payload carried nothing but the error, so both failure call sites now send title, confirmed time and campaign name; the bot falls back title → campaign + date → date → "your latest session", never "Untitled Session". - **#391** `session_url_for` / `session_url_extra` in `bot_pubsub.py`, splatted into all twelve session-scoped enqueue sites (for `session_summarised` inside `_notify_bot_summarised`, so success, failure and the queue timeout all carry it). When `APP_URL` is unset the field is omitted rather than half-built. On the bot, one `_add_quest_board_link` helper is called by all thirteen templates; the summary footer's linkless "Full transcript available on Quest Board" becomes a real link. Both routes confirmed to exist in the frontend. - **#394** `intents.message_content` removed (no `on_message`, no prefix commands anywhere). `PrivilegedIntentsRequired` is caught alongside `LoginFailure` in a new testable `_run_discord_bot`: the gateway client stops with one log line naming the exact portal toggle while `/health` keeps serving, rather than a process exit that Compose's `unless-stopped` would restart into the same wall of tracebacks. Four docs corrected, including `.env.example`, which had the two intents backwards. Additive payload fields only; `BOT_CONTRACT_VERSION` unchanged, `check_version_sync.py` passes. Bot suite 318 passed (+40), backend full suite 2340 passed / 13 skipped, ruff clean. Each commit verified in isolation so the series bisects. No migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The backend fires `session_summarised` for both outcomes: with a `summary`
on success, and with an `error` and no summary when the pipeline failed.
The bot only ever knew about the first shape. A failure therefore reached
the channel as a green "📜 Session Summary Ready — <title>" embed whose
body read "No summary was generated.", followed by three seeded feedback
reactions asking the party to rate the accuracy of nothing. The one person
who could act on it — the GM — had no reason to look.

The handler now branches on `extra["error"]` before it reads `summary`,
and the failure gets its own message: a red "⚠️ Summary Failed" headline,
a body that says the recording was kept (a failed session's audio is not
swept, so re-running it is a real option), and a "What now?" field
pointing at the retry on the session page. No feedback reactions, and no
transcript_feedback message mapping, so a stray 👍 cannot attach a vote to
a summary that does not exist. The raw error is logged, never posted: it
is an internal exception string and the channel is the whole party.

Naming the session needed the backend's help. The failure payload carried
only `{"error": ...}`, so the bot had nothing but "Untitled Session" to
print — the least useful thing to tell a GM who has several sessions in
flight. Both failure call sites now send the session's title (raw, so ""
stays "" rather than becoming a placeholder), its confirmed time, and —
where it is already in scope — the campaign name. The bot falls back
title → campaign + date → date → "your latest session", and never emits
"Untitled Session" on this path.

test_provider_concurrency's fake session row grows `title` and
`confirmed_time`. It is a partial stand-in for a Session ORM row and both
columns exist on every real one; the failure path reads them now, so the
stub has to have them. Both are None there deliberately — an untitled,
unconfirmed session is exactly the case the new fallback chain exists for.

Additive payload fields only; BOT_CONTRACT_VERSION is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Thirteen notification templates name a session, and exactly one of them
said where to find it. A player reading " 24 hours until Boss Fight!" in
Discord had to go and find the session themselves; a GM reading "📜
Session Summary Ready" got a footer that read "Full transcript available
to GMs on Quest Board" and no way to follow it, because Discord embed
footers cannot hold links at all. The one template that did link — the
vote reminder — built its URL inline at the single call site that knew
how, which is why it was the only one.

Backend: `session_url_for` / `session_url_extra` in `bot_pubsub.py`, next
to the other payload-shaping helper, build the URL once. Every event the
backend enqueues about a session splats `**session_url_extra(...)` into
its `extra` — reminders, at-risk warnings, vote posts and vote updates,
confirmations, RSVP prompts, proposals, cancellations, completions,
summary ready/failed/approved, audio-deletion warnings, and shelf
reveals. The vote reminder's inline f-string is now the same helper.

For `session_summarised` the splat lives inside `_notify_bot_summarised`
rather than at its three callers, so success, failure and the queue
timeout all carry the link and none of them can be the one that forgets.

The helper returns None — and the field is omitted — when `APP_URL` is
unset, rather than emitting a relative or half-built URL. A Discord embed
can only carry an absolute URL, so a partial one renders as literal text
in the channel; an instance without APP_URL gets the embed it had before.

Bot: one `_add_quest_board_link(embed, extra)` helper, called
unconditionally by every template, since the "should there be a link"
decision belongs to the backend that either sent the field or did not.
The summary footer's signpost sentence moves into that field, where it is
a real link, and only falls back to the footer when there is no URL.

`campaign_url_for` is included as the counterpart for a campaign-level
event, with no caller yet — every event the backend sends today names a
session.

Additive payload field, so BOT_CONTRACT_VERSION is unchanged: it governs
the `/api/bot/*` request/response shapes, not the event vocabulary, and an
older bot simply ignores a key it does not read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(bot): drop the unused privileged message_content intent (#394)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 31s
CI / Backend lint (ruff) (pull_request) Successful in 36s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m3s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m37s
CI / Bot tests and audit (pull_request) Successful in 1m52s
CI / Docker image build (pull_request) Successful in 4m24s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m14s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 15m5s
7d229dd236
The bot asked Discord for MESSAGE_CONTENT and never read a message. Every
command is a slash command, there is no on_message handler anywhere in
bot/, and no prefix command has ever been registered — command_prefix="!"
is only there because commands.Bot requires the argument.

The cost of asking was borne entirely by self-hosters. MESSAGE_CONTENT is
privileged, so it has to be ticked in the Discord Developer Portal; miss
it and the gateway answers PrivilegedIntentsRequired. That exception was
not caught — the startup handler knew only about LoginFailure — so the
process died, Compose restarted it, and the deployment sat in a crash
loop whose logs were a wall of identical tracebacks. A toggle nobody
needed, failing closed.

Removing the intent removes the failure. SERVER MEMBERS stays and is now
documented as what it is — required, not "optional, for display names" —
because voice-recv resolves an SSRC to a user through the member cache
and per-speaker attribution is the whole recording feature.

PrivilegedIntentsRequired is now caught anyway, for the next intent
someone adds and for a portal toggle switched off under a running
deployment. It is handled the way LoginFailure already was, and for the
same reason: both are configuration mistakes that no amount of retrying
fixes, so the gateway client stops while the aiohttp server keeps serving
/health. The container stays up and healthy-looking rather than
restarting for ever, and the log holds one line naming the exact toggle.
That path moves into `_run_discord_bot`, which returns whether the bot
started, so it can be tested without standing up the web app.

.env.example, the bot's Settings docstring, docs/INTEGRATIONS.md and a new
docs/OPERATIONS.md troubleshooting entry are corrected to match: enable
Server Members, leave Message Content off.

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 01:44:17 +00:00
claude-bot deleted branch fix/phase1-bot 2026-09-06 01:59:45 +00:00
Sign in to join this conversation.
No description provided.