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
-
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:72 → directory.ts:409 has no B→U translation and the lookup fails). threadHasBotStake (message-gating.ts:20) inherits this for free, since it calls mentionsBot.
-
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 handled —
message_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).
A member hand-typed
@qmin 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 noapp_mentionevent — 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.infoforB0BND0YAX4Jreturnsuser_id: U0BNGML9RHS,app_id: A0BP7BCE0DN, and that app has exactly one bot user in the workspace, so<@B0BND0YAX4J>is a legitimate mention of qm. Itsrich_textblock 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:
app_mention?<@U0BNGML9RHS> …USERIDmessageandapp_mentionrunsrow, done)<@B0BND0YAX4J> …BOTIDmessageonlyhandled=f, silentSo in this workspace Slack fires
app_mentiononly 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_mentionhandler (src/slack/events.ts:44); the message handler mirrors the row and returns atevents.ts:140for any top-level post. So:app_mention, and the message path returns before dispatching).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:
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 strippingstripMention(mrkdwn.ts:13, called atturn-handler.ts:197) are allU\w+-only. So even once detection is fixed, the core would still receive the raw<@B…>token unlessstripMentionis fixed too.Fix
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.ownBotIdis in scope at these sites (event depsevents.ts:23, mirror closuremirror.ts:24, populated fromauth.testatindex.ts:212). For name resolution, special-caseownBotIdto the bot handle — do not feed the bot id tousers.info(mirror.ts:72→directory.ts:409has no B→U translation and the lookup fails).threadHasBotStake(message-gating.ts:20) inherits this for free, since it callsmentionsBot.Dispatch the bot-id-form mention from the message handler, scoped to exactly the class
app_mentionwon'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 toapp_mention). Constraints, all code-checked:Incominglike theapp_mentionpath (events.ts:52): omitunpromptedandbotAuthored— the existing thread-message dispatch setsunprompted: true(events.ts:154), which would push the turn back through ambient detection.files,thread_ts,ts,user,ackGateare available in the handler.shouldProcessMessagedrops qm's own messages (message-gating.ts:30) but not peer bots (thebot_messagesubtype is allowed at:32, covered by testslack-index.integration.test.ts:835), so the fallback must requirem.userand rejectm.bot_id,m.bot_profile, andsubtype === "bot_message"to avoid a bot-to-bot loop.message_changedmirrors and returns atevents.ts:82and is rejected byshouldProcessMessage(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 forapp_mentiondelivery failures (e.g. #626) — that would reintroduce a message-vs-app_mentiondispatch 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_mentionfor 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, thechannel:tsdedupe (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).