Skip to content

feat(slack): mirror support thread messages into the Slack thread - #7

Draft
crstian19 wants to merge 2 commits into
fix/slack-webhook-validationfrom
feat/slack-thread-mirroring
Draft

crstian19 wants to merge 2 commits into
fix/slack-webhook-validationfrom
feat/slack-thread-mirroring

Conversation

@crstian19

@crstian19 crstian19 commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #6 (fix/slack-webhook-validation). See Merge procedure: the base does not move to main on its own.

What it does

Every human message posted in a tracked Discord support thread is mirrored as a reply under the thread's Slack announcement, so the whole conversation can be followed from Slack.

Product decision this PR makes: every human message in the public support threads (SUPPORT_CHANNEL_IDS) is copied into the internal Slack channel. Bot messages and the forum opening message are not mirrored (see below). If that is not what we want, this PR should not merge as is.

Why it needs a bot token

Threaded replies are addressed by the parent message's ts, and an Incoming Webhook answers ok without ever returning one. Slack's docs say so explicitly:

"Since the initial webhook request does not return the message timestamp, you must retrieve it separately using methods like the Events API […] or the conversations.history method."

chat.postMessage returns it directly, so moving to the Web API is a requirement, not a preference.

Pieces

  • SlackApiClient (bot/slack.py) — posts through chat.postMessage with a bot token.
    • Reads ok/error inside the HTTP 200 body instead of trusting the status (the Web API reports failures there).
    • Retry policy: retries ratelimited, internal_error, service_unavailable, network errors (httpx exceptions) and non-JSON bodies (e.g. an HTML 502 from a proxy), with a 0.5/1/2 s backoff (4 attempts total). Any other Slack error (not_in_channel, channel_not_found, invalid_auth, …) is permanent and is not retried.
    • Serialises posts behind a lock with ~1 s spacing to respect Slack's per-channel rate limit.
    • Validates its configuration once at startup: a token without SLACK_CHANNEL_ID, a channel without a token, or a token that is not xoxb- disables token mode with one clear error in the logs (same approach as fix(slack): reject URLs that are not Incoming Webhooks #6 for the webhook URL) instead of silently falling back to the webhook.
  • bot/thread_map.py — persists discord_thread_id → (slack_ts, slack_channel) in SQLite inside the Docker volume (vector_db/slack_threads.db), so a redeploy does not orphan live threads. Purged after 30 days on on_ready.
  • NanBot._mirror_to_slack — runs as a background task from on_message, so a slow Slack (30 s timeout plus retries) never delays the bot's own answer in Discord. Uses clean_content (mentions arrive as @name, not <@123…>), names attachments as [attachments: …], and replies in the Slack channel stored with the thread, even if SLACK_CHANNEL_ID changed since.
  • Escaping — &, <, > are escaped in every user-controlled field, including the reply's fallback text (what Slack shows in notifications), so <!channel>/<!here> or disguised links cannot get through. The body is escaped first and truncated afterwards to stay under Slack's 3000-char section limit without splitting an entity.
  • The webhook stays as a fallback — without a bot token the bot keeps announcing new threads, just without mirroring. Deploying this without the token is not a regression.

What is not mirrored

Bot messages (the LLM answers are long; Slack carries the human conversation) and a forum post's opening message, which is already the body of the announcement.

Merge procedure

The repo has delete_branch_on_merge=false, so when #6 merges GitHub will not retarget this PR to main: it would stay based on fix/slack-webhook-validation. Both PRs are merged with a merge commit (--merge, not squash), so #6's commit e19ca22, which this branch is built on, lands in main unchanged and no rebase --onto is needed:

  1. Merge fix(slack): reject URLs that are not Incoming Webhooks #6 with gh pr merge 6 --merge.
  2. Change this PR's base to main: gh pr edit 7 --base main.
  3. Push to trigger CI, because a base change alone does not run it. A plain git rebase origin/main + git push --force-with-lease will do, and should apply cleanly because main then contains e19ca22.
  4. Merge with gh pr merge 7 --merge.

CI: ci.yml only runs on pull requests against main, so CI has not run on this PR yet. It will run for the first time after the retarget/rebase in steps 2–3; wait for it before merging.

Post-merge manual steps

Deploy does git pull + docker compose up on the server and never touches .env, so until these are done the bot keeps running in webhook mode (announcements only):

  1. In the Slack app: add the bot scope chat:write and (re)install the app to the workspace to get the xoxb-… token.
  2. In the destination Slack channel: /invite @<app> (otherwise every post fails with not_in_channel).
  3. On the server, add to .env:
    SLACK_BOT_TOKEN=xoxb-…   # bot token with chat:write
    SLACK_CHANNEL_ID=C…      # destination channel ID; the webhook URL no longer decides it
    
  4. docker compose up -d and check the logs: no SLACK_BOT_TOKEN …/SLACK_CHANNEL_ID … error at startup, then open a test thread in #support, reply in it, and confirm the reply appears under the Slack announcement.

Verification

  • pytest: 116 passed (fix(slack): reject URLs that are not Incoming Webhooks #6 alone: 67; this PR adds 49). Before the review fixes the branch had 90; the fixes added 26 tests.
  • ruff check . and ruff format --check . clean.
  • Every review fix was mutation-checked: 21 mutations (reverting each fix: raw fallback text, removing the on_message mirror call, removing the throttle sleep, removing the lock, awaiting the mirror inline, ignoring the stored channel, truncating before escaping, splitting entities, skipping the xoxb- check, not retrying internal_error/network errors/non-JSON, removing the startup purge, storing a None ts, the misleading log, content instead of clean_content, …) — all 21 are killed by at least one test.
  • Throttle tests run with the real _MIN_INTERVAL = 1.05 and a recording asyncio.sleep: two sequential posts → the second waits ≈1.05 s; two concurrent posts via asyncio.gather never overlap.
  • Not re-run after the review fixes: an end-to-end run inside the container against a real Slack workspace. That needs the post-merge setup above.

@crstian19
crstian19 marked this pull request as draft August 20, 2026 13:40
@crstian19
crstian19 force-pushed the fix/slack-webhook-validation branch from ed4115f to e19ca22 Compare October 20, 2026 08:54
Cristian Gutierrez added 2 commits October 20, 2026 11:00
Every human message posted in a tracked Discord thread is echoed as a reply
under the Slack announcement, so the conversation can be followed from Slack.

This needs the Web API, not the webhook. Threaded replies are addressed by
the parent message's ts, and an Incoming Webhook answers "ok" without ever
returning one — Slack's own docs point to the Events API or
conversations.history to recover it. chat.postMessage returns it directly.

- SlackApiClient posts through chat.postMessage with a bot token, reads the
  ok/error field inside the 200 body rather than trusting the status, and
  serialises posts ~1s apart to respect the per-channel rate limit.
- SlackThreadMap persists discord_thread_id -> slack_ts in SQLite inside the
  Docker volume, so a redeploy does not orphan live threads. Purged at 30d.
- The webhook stays as a fallback: with no bot token the bot still announces
  threads, just without mirroring, so a deploy without the token is not a
  regression.

Skipped when mirroring: bots (the bot's own LLM answers are long, and Slack
should carry the human conversation) and a forum post's opening message,
which is already the body of the announcement.
Escaping and limits
- build_reply_payload escapes the body once and uses it in both the block and
  the fallback `text`. The fallback is what Slack shows in notifications, so a
  raw `<!channel>` or a disguised link there defeated the escaping.
- The body is escaped first and truncated afterwards, to fit Slack's 3000-char
  section limit (one "<" becomes "&lt;", so 2000 raw chars could grow to 8000),
  and the cut never splits an entity.

Mirroring
- on_message runs the mirror as a background task (kept in self._bg_tasks), so
  a slow Slack (30 s timeout plus retries) no longer delays the bot's answer.
- Replies go to the Slack channel stored with the thread mapping, not the
  current SLACK_CHANNEL_ID, via a new `channel` argument on post() and
  reply_in_thread().
- Mirrored text uses message.clean_content, so Discord mentions reach Slack
  as @name instead of raw <@123...> IDs.
- User-visible strings in Slack are English ("[attachments: ...]"); the
  unreachable "(sin texto)" branch is gone.

Startup and storage
- SlackApiClient validates its configuration once in __init__: a token without
  a channel, a channel without a token, or a token that is not xoxb- disables
  token mode with one error, instead of silently falling back to the webhook.
- A failure to store the thread mapping gets its own log line ("announced in
  Slack but could not store the thread mapping") instead of being reported as
  a failed Slack notification; nothing is stored when the announcement fails.

Tests cover the on_message wiring, the background task, the ~1 s throttle
and lock (sequential and concurrent posts), every retried failure
(internal_error, service_unavailable, network errors, an HTML 502), the
startup purge, and the fixture now closes its SQLite connection. README,
AGENTS.md and .env.example describe token mode and the retry policy.
@crstian19
crstian19 force-pushed the feat/slack-thread-mirroring branch from d5e65ac to d8d81a8 Compare October 20, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants