Skip to content

Bot-id-form channel mentions (<@B…>) aren't recognized: top-level ones are silently dropped, in-thread ones misfire as ambient #630

Description

@ianTPE

A member hand-typed @qm in a channel and got no response. Their client encoded the mention as the bot id <@B0BND0YAX4J> rather than the bot user id <@U0BNGML9RHS>. qm recognizes only the user-id form, and for the bot-id form Slack fires no app_mention event — the only path that dispatches a top-level channel mention — so the message is mirrored (handled=f) and dropped. Verified with a controlled debug-logging test; line refs against local HEAD (f9aec5e).

Same bot, two ids

bots.info for B0BND0YAX4J returns user_id: U0BNGML9RHS, app_id: A0BP7BCE0DN, and that app has exactly one bot user in the workspace, so <@B0BND0YAX4J> is a legitimate mention of qm. Its rich_text block carries {"type":"user","user_id":"B0BND0YAX4J"} — Slack stored the mention pointing at the bot id, and the token survives entity-decoding and known-mention resolution in the mirror unchanged (src/slack/mirror.ts:108), so this isn't a mirror artifact.

Controlled verification (SLACK_LOG_LEVEL=debug)

Two channel messages, same minute, debug-logging every inbound socket event:

message mention form inbound event(s) app_mention? run? result
<@U0BNGML9RHS> …USERID bot user id message and app_mention yes yes (runs row, done) reply sent
<@B0BND0YAX4J> …BOTID bot id message only no none handled=f, silent

So in this workspace Slack fires app_mention only for the bot-user-id form (n=1 each, client-typed, private channel — observed behavior, not proven universal across channel types / API-posted messages).

What actually happens to a bot-id mention

Dispatch of a top-level channel mention comes solely from the app_mention handler (src/slack/events.ts:44); the message handler mirrors the row and returns at events.ts:140 for any top-level post. So:

  • B-form top-level mention → always dropped (no app_mention, and the message path returns before dispatching).
  • B-form thread reply → misclassified as ambient. Not being recognized as a mention, it skips the U-form early return (events.ts:141), and in a thread where qm already has stake it dispatches as an unprompted ambient turn (events.ts:137, events.ts:154) rather than an addressed one — carrying the raw <@B…> token; with no stake it's skipped entirely.

Recognition is user-id-only in several places, not just one:

// src/slack/message-gating.ts:3
export function mentionsBot(text: string, botUserId: string): boolean {
  return botUserId ? text.includes(`<@${botUserId}>`) : false;
}

callers pass only botUserId (events.ts:137, mirror.ts:119); and the ID-collection (mirror.ts:67), replacement (mrkdwn.ts:6), and — critically — addressed-turn mention stripping stripMention (mrkdwn.ts:13, called at turn-handler.ts:197) are all U\w+-only. So even once detection is fixed, the core would still receive the raw <@B…> token unless stripMention is fixed too.

Fix

  1. Recognize the bot-id form everywhere the user-id form is. Extend mentionsBot, stripMention, the mention-ID collection, and replacement to also match <@${ownBotId}>, with a regex that also covers the legacy <@ID|label> form. ownBotId is in scope at these sites (event deps events.ts:23, mirror closure mirror.ts:24, populated from auth.test at index.ts:212). For name resolution, special-case ownBotId to the bot handle — do not feed the bot id to users.info (mirror.ts:72directory.ts:409 has no B→U translation and the lookup fails). threadHasBotStake (message-gating.ts:20) inherits this for free, since it calls mentionsBot.

  2. Dispatch the bot-id-form mention from the message handler, scoped to exactly the class app_mention won't fire for — when a message mentions the bot via the bot-id form and not the user-id form, dispatch it as an addressed turn instead of dropping / mis-dispatching it (a message carrying both forms is covered by fix 1 stripping both and this condition deferring to app_mention). Constraints, all code-checked:

    • Match app_mention's payload. Build the Incoming like the app_mention path (events.ts:52): omit unprompted and botAuthored — the existing thread-message dispatch sets unprompted: true (events.ts:154), which would push the turn back through ambient detection. files, thread_ts, ts, user, ackGate are available in the handler.
    • Human author only. shouldProcessMessage drops qm's own messages (message-gating.ts:30) but not peer bots (the bot_message subtype is allowed at :32, covered by test slack-index.integration.test.ts:835), so the fallback must require m.user and reject m.bot_id, m.bot_profile, and subtype === "bot_message" to avoid a bot-to-bot loop.
    • Edits are already handledmessage_changed mirrors and returns at events.ts:82 and is rejected by shouldProcessMessage (message-gating.ts:32) — provided the fallback sits after that branch.

Scoped deliberately: the bot-user-id form still dispatches solely via app_mention, unchanged, so the working path isn't touched. This is not a general fallback for app_mention delivery failures (e.g. #626) — that would reintroduce a message-vs-app_mention dispatch race and wouldn't help when the socket drops the event entirely.

Two honest caveats: I can't prove from code that Slack never emits app_mention for the bot-id form on some other channel/client surface — only that no counterpart was observed in the tested case; and if it ever did, the channel:ts dedupe (message-gating.ts:107) is a per-process in-memory LRU (index.ts:111), so it wouldn't dedupe across instances.

Finally, add a rate-limited/counter signal when a bot-id-form mention is handled, to quantify how often this form occurs and close the observability gap — the drop is otherwise fully silent (same silent-fail-closed family as #607 / #626).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions