From 7daead68d4284487d485907eee513ab63a2702f6 Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:54:31 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(connector-slack):=20@peek=20in=20a=20c?= =?UTF-8?q?hannel=20=E2=80=94=20app=5Fmention=20+=20threaded=20auto-contin?= =?UTF-8?q?ue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an app_mention handler (threaded, natural-language answers visible to the channel), scopes app.message to DMs + peek's active threads (firehose fix), and dedupes the mention/message double-event. Makes shared-channel debugging first-class. No runtime/consent change. Co-Authored-By: Claude Opus 4.8 Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- .../connector-slack/src/slack-adapter.test.ts | 170 +++++++++++++++++- packages/connector-slack/src/slack-adapter.ts | 35 +++- 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index 4d60440..e7414af 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { SlackAdapter } from './slack-adapter.js'; +import { SlackAdapter, stripMention } from './slack-adapter.js'; import { parseConsentValue, suggestedPrompts } from './slack-adapter.js'; describe('parseConsentValue', () => { @@ -118,3 +118,171 @@ describe('SlackAdapter thinking status', () => { expect(setStatus).not.toHaveBeenCalled(); }); }); + +describe('stripMention', () => { + it('removes the bot mention token(s) and collapses whitespace', () => { + expect(stripMention('<@U0BOT> what failed?', 'U0BOT')).toBe('what failed?'); + expect(stripMention('hey <@U0BOT> make a repro', 'U0BOT')).toBe('hey make a repro'); + expect(stripMention('<@U0BOT> <@U0BOT> show errors', 'U0BOT')).toBe('show errors'); + }); + it('leaves text without the bot mention unchanged (trimmed)', () => { + expect(stripMention(' what failed? ', 'U0BOT')).toBe('what failed?'); + }); + it('returns empty string for a mention-only message', () => { + expect(stripMention('<@U0BOT>', 'U0BOT')).toBe(''); + }); +}); + +// Helper to extract the Nth handler registered with app.event/app.message/app.command/app.action. +// After wire() runs: +// listeners[0] = app_mention handler +// listeners[1] = app.message handler +// listeners[2] = /peek command handler +// listeners[3] = peek_approve action handler +// listeners[4] = peek_deny action handler +// Each listeners[N] is a chain; the last element is the actual user-supplied callback. +function getBoltHandler(adapter: SlackAdapter, index: number) { + const boltApp = (adapter as unknown as { app: { listeners: Array> } }).app; + // biome-ignore lint/style/noNonNullAssertion: test seam — index is always in bounds + const chain = boltApp.listeners[index]!; + // biome-ignore lint/style/noNonNullAssertion: last element is the user-supplied callback + return chain[chain.length - 1]! as (args: Record) => Promise; +} + +describe('SlackAdapter app_mention handler', () => { + it('emits once with conversationId === ts and stripped query on a top-level mention', async () => { + const { adapter } = makeAdapter(); + const received: Array<{ conversationId: string; userId: string; text: string }> = []; + adapter.onMessage((m) => + received.push({ conversationId: m.conversationId, userId: m.userId, text: m.text }), + ); + const handler = getBoltHandler(adapter, 0); + await handler({ + event: { + text: '<@U0BOT> what failed?', + ts: 'TS1', + thread_ts: undefined, + user: 'U1', + channel: 'C1', + }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ conversationId: 'TS1', userId: 'U1', text: 'what failed?' }); + }); + + it('activates the thread so a follow-up app.message reply also emits', async () => { + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const mentionHandler = getBoltHandler(adapter, 0); + await mentionHandler({ + event: { + text: '<@U0BOT> show errors', + ts: 'TS2', + thread_ts: undefined, + user: 'U1', + channel: 'C1', + }, + context: { botUserId: 'U0BOT' }, + }); + received.length = 0; // reset — only care about the follow-up + // Verify thread is now active by checking that a reply emits + const msgHandler = getBoltHandler(adapter, 1); + await msgHandler({ + message: { + ts: 'TS2-reply', + thread_ts: 'TS2', + text: 'a follow-up', + user: 'U1', + channel: 'C1', + channel_type: 'channel', + }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toEqual(['a follow-up']); + }); +}); + +describe('SlackAdapter app.message handler (firehose fix)', () => { + it('emits for a DM message (channel_type: im)', async () => { + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const handler = getBoltHandler(adapter, 1); + await handler({ + message: { ts: 'TS3', text: 'hello peek', user: 'U1', channel: 'C2', channel_type: 'im' }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toEqual(['hello peek']); + }); + + it('does NOT emit for a channel message that is not in an active thread', async () => { + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const handler = getBoltHandler(adapter, 1); + await handler({ + message: { + ts: 'TS4', + text: 'some channel chatter', + user: 'U1', + channel: 'C1', + channel_type: 'channel', + }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toHaveLength(0); + }); + + it('emits for a channel thread reply when that thread was activated by app_mention', async () => { + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + // Activate the thread via app_mention + const mentionHandler = getBoltHandler(adapter, 0); + await mentionHandler({ + event: { + text: '<@U0BOT> what failed?', + ts: 'TS5', + thread_ts: undefined, + user: 'U1', + channel: 'C1', + }, + context: { botUserId: 'U0BOT' }, + }); + received.length = 0; // reset — only care about the follow-up + // Now send a reply in the same thread + const msgHandler = getBoltHandler(adapter, 1); + await msgHandler({ + message: { + ts: 'TS5-reply', + thread_ts: 'TS5', + text: 'follow-up question', + user: 'U1', + channel: 'C1', + channel_type: 'channel', + }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toEqual(['follow-up question']); + }); + + it('does NOT emit when the message text contains the bot mention (dedupe with app_mention)', async () => { + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const handler = getBoltHandler(adapter, 1); + await handler({ + message: { + ts: 'TS6', + text: '<@U0BOT> what broke?', + user: 'U1', + channel: 'C1', + channel_type: 'channel', + }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toHaveLength(0); + }); +}); diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index 40ce85f..cbb159f 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -32,6 +32,14 @@ export function suggestedPrompts(): { }; } +/** Strip every `<@BOTID>` token for the connector's own bot user id, collapse whitespace. */ +export function stripMention(text: string, botUserId: string): string { + return text + .replace(new RegExp(`<@${botUserId}>`, 'g'), ' ') + .replace(/\s+/g, ' ') + .trim(); +} + export function parseConsentValue( raw: string | undefined, ): { correlationId: string; conversationId: string } | null { @@ -52,6 +60,7 @@ export class SlackAdapter implements SurfaceAdapter { private routes = new Map(); private msgHandler?: (m: InboundMessage) => void; private consentHandler?: (r: ConsentResponse) => void; + #activeThreads = new Set(); constructor(config: SlackConfig) { this.app = new App({ @@ -191,8 +200,23 @@ export class SlackAdapter implements SurfaceAdapter { }); this.app.assistant(assistant); - this.app.message(async ({ message }) => { - // message payload shape — subtype discriminates bot/system messages + this.app.event('app_mention', async ({ event, context }) => { + const e = event as { + text?: string; + ts: string; + thread_ts?: string; + user?: string; + channel: string; + }; + const botId = context.botUserId ?? ''; + const query = e.text ? stripMention(e.text, botId) : ''; + if (!query || !e.channel) return; + const cid = e.thread_ts ?? e.ts; + this.#activeThreads.add(cid); + this.emit(cid, e.channel, cid, e.user ?? 'unknown', query); + }); + + this.app.message(async ({ message, context }) => { const m = message as { thread_ts?: string; ts: string; @@ -200,9 +224,16 @@ export class SlackAdapter implements SurfaceAdapter { subtype?: string; user?: string; channel?: string; + channel_type?: string; }; if (m.subtype || !m.text || !m.channel) return; + const botId = context.botUserId ?? ''; + if (botId && m.text.includes(`<@${botId}>`)) return; // mentions handled by app_mention (dedupe) + const isDM = m.channel_type === 'im'; + const inActiveThread = m.thread_ts !== undefined && this.#activeThreads.has(m.thread_ts); + if (!isDM && !inActiveThread) return; // ignore unrelated channel chatter const cid = m.thread_ts ?? m.ts; + if (inActiveThread) this.#activeThreads.add(cid); this.emit(cid, m.channel, cid, m.user ?? 'unknown', m.text); }); From 4600f61b8c36b10dfbd1775686adf532d0e66326 Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:03:33 +0530 Subject: [PATCH 2/3] test(connector-slack): harden getBoltHandler + layout tripwire + empty-botId coverage Fix 1: getBoltHandler now throws a descriptive Error on out-of-range index or a missing/empty chain or null callback, converting previously silent test mis-wiring into a loud failure. A new "Bolt internal listener layout tripwire" test asserts boltApp.listeners.length === 5; a Bolt version bump that shifts the internal layout will fail this assertion rather than silently routing subsequent handler calls to the wrong index. Fix 2 (comment only, no logic change): Added a one-line comment on the idempotent #activeThreads.add(cid) keep-alive inside the app.message handler in slack-adapter.ts. Fix 3: New test locks the empty-botUserId branch of the app.message dedupe guard: when botUserId is absent/empty the botId && ... check short-circuits and skips the dedupe, so a DM whose text contains a mention token falls through to the isDM gate and emits normally. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- .../connector-slack/src/slack-adapter.test.ts | 60 +++++++++++++++++-- packages/connector-slack/src/slack-adapter.ts | 1 + 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index e7414af..00502fa 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -141,14 +141,44 @@ describe('stripMention', () => { // listeners[3] = peek_approve action handler // listeners[4] = peek_deny action handler // Each listeners[N] is a chain; the last element is the actual user-supplied callback. +// +// IMPORTANT: This mapping is positional and depends on Bolt's internal listener layout. +// The tripwire test below ("Bolt internal listener layout tripwire") asserts that +// listeners.length === 5. If a Bolt version bump shifts the layout, that test fails +// loudly rather than silently exercising the wrong handler. function getBoltHandler(adapter: SlackAdapter, index: number) { const boltApp = (adapter as unknown as { app: { listeners: Array> } }).app; - // biome-ignore lint/style/noNonNullAssertion: test seam — index is always in bounds - const chain = boltApp.listeners[index]!; - // biome-ignore lint/style/noNonNullAssertion: last element is the user-supplied callback - return chain[chain.length - 1]! as (args: Record) => Promise; + if (index < 0 || index >= boltApp.listeners.length) { + throw new Error( + `getBoltHandler: index ${index} is out of range (listeners.length = ${boltApp.listeners.length}). If a Bolt version bump changed the listener layout, update the index mapping above.`, + ); + } + const chain = boltApp.listeners[index]; + if (!chain || chain.length === 0) { + throw new Error( + `getBoltHandler: listeners[${index}] is empty or undefined. The Bolt internal listener layout may have changed — re-check the index mapping.`, + ); + } + const cb = chain[chain.length - 1]; + if (cb === undefined || cb === null) { + throw new Error( + `getBoltHandler: last element of listeners[${index}] chain is ${cb}. Expected the user-supplied callback but got nothing — re-check the index mapping.`, + ); + } + return cb as (args: Record) => Promise; } +// Tripwire: if Bolt's internal listener layout changes and the index mapping above becomes +// stale, this assertion fails loudly (instead of silently exercising the wrong handler). +describe('Bolt internal listener layout tripwire', () => { + it('boltApp.listeners has exactly 5 entries after wire() — one per registered handler', () => { + const { adapter } = makeAdapter(); + const boltApp = (adapter as unknown as { app: { listeners: Array> } }).app; + // If this count changes, re-verify the positional index mapping in getBoltHandler. + expect(boltApp.listeners).toHaveLength(5); + }); +}); + describe('SlackAdapter app_mention handler', () => { it('emits once with conversationId === ts and stripped query on a top-level mention', async () => { const { adapter } = makeAdapter(); @@ -285,4 +315,26 @@ describe('SlackAdapter app.message handler (firehose fix)', () => { }); expect(received).toHaveLength(0); }); + + it('does NOT dedupe when botUserId is absent — a message with a mention token falls through to normal gating', async () => { + // When botId is empty/absent, the dedupe guard `botId && text.includes('<@...>')` short-circuits + // on the falsy botId and does NOT suppress the message. A DM with a mention-looking token must + // still emit (it reaches the isDM / inActiveThread gate instead). + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const handler = getBoltHandler(adapter, 1); + await handler({ + message: { + ts: 'TS7', + text: '<@SOMEONE> what broke?', // looks like a mention, but botUserId is absent + user: 'U1', + channel: 'C3', + channel_type: 'im', // DM — passes the isDM gate + }, + context: { botUserId: '' }, // absent / empty → dedupe check is skipped + }); + // Falls through the (skipped) dedupe guard → hits isDM gate → emits + expect(received).toEqual(['<@SOMEONE> what broke?']); + }); }); diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index cbb159f..7ad5547 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -233,6 +233,7 @@ export class SlackAdapter implements SurfaceAdapter { const inActiveThread = m.thread_ts !== undefined && this.#activeThreads.has(m.thread_ts); if (!isDM && !inActiveThread) return; // ignore unrelated channel chatter const cid = m.thread_ts ?? m.ts; + // Idempotent no-op when cid is already in the set — kept as a defensive keep-alive for threaded replies. if (inActiveThread) this.#activeThreads.add(cid); this.emit(cid, m.channel, cid, m.user ?? 'unknown', m.text); }); From 8463f570787ef83b6e3a501e654e5fed28e7496f Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:18:26 +0530 Subject: [PATCH 3/3] =?UTF-8?q?fix(connector-slack):=20apply=20CodeRabbit?= =?UTF-8?q?=20fix=20pass=20=E2=80=94=20DM+mention=20bug,=20replaceAll,=20n?= =?UTF-8?q?o-op=20removal,=20thread=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (blocker): compute isDM before the mention-dedupe guard and gate it on !isDM. A DM whose text contains <@botId> was silently dropped because Slack never emits app_mention for DMs, leaving nothing to handle it. Fix 2: stripMention uses String.replaceAll (string literal) instead of `new RegExp(..., 'g')` — same semantics, no regex construction. Fix 3: remove the `if (inActiveThread) this.#activeThreads.add(cid)` no-op in app.message (when inActiveThread is true, cid is already in the set). Fix 4: bound #activeThreads growth with a 1000-entry insertion-order cap via a new #trackThread() private helper; app_mention calls #trackThread instead of the bare .add(). TTL deferred past alpha per CodeRabbit acknowledgement. Tests: regression for Fix 1 (DM + non-empty botUserId + mention token → emits), observable-behavior cap test for Fix 4 (oldest evicted, recent alive), updated empty-botUserId test comment to reflect new guard shape. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- .../connector-slack/src/slack-adapter.test.ts | 83 ++++++++++++++++++- packages/connector-slack/src/slack-adapter.ts | 25 ++++-- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index 00502fa..afd03f9 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -317,9 +317,9 @@ describe('SlackAdapter app.message handler (firehose fix)', () => { }); it('does NOT dedupe when botUserId is absent — a message with a mention token falls through to normal gating', async () => { - // When botId is empty/absent, the dedupe guard `botId && text.includes('<@...>')` short-circuits - // on the falsy botId and does NOT suppress the message. A DM with a mention-looking token must - // still emit (it reaches the isDM / inActiveThread gate instead). + // When botId is empty/absent, the dedupe guard `!isDM && botId && text.includes('<@...>')` + // short-circuits on the falsy botId and does NOT suppress the message. A DM with a + // mention-looking token must still emit (it reaches the isDM gate instead). const { adapter } = makeAdapter(); const received: string[] = []; adapter.onMessage((m) => received.push(m.text)); @@ -337,4 +337,81 @@ describe('SlackAdapter app.message handler (firehose fix)', () => { // Falls through the (skipped) dedupe guard → hits isDM gate → emits expect(received).toEqual(['<@SOMEONE> what broke?']); }); + + it('emits for a DM whose text contains the bot mention token (Fix 1 regression: DM + mention was incorrectly dropped)', async () => { + // Bug: the old code ran the mention-dedupe guard BEFORE isDM. Slack never emits + // app_mention for DMs, so a DM with "<@BOTID>" was silently dropped — nothing handled it. + // Fix: dedupe guard is now gated on `!isDM`, so DMs always fall through to the isDM check. + const { adapter } = makeAdapter(); + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const handler = getBoltHandler(adapter, 1); + await handler({ + message: { + ts: 'TS8', + text: '<@UBOT> what broke?', + user: 'U1', + channel: 'D1', + channel_type: 'im', // DM — app_mention never fires here + }, + context: { botUserId: 'UBOT' }, // non-empty botUserId — was the exact trigger of the bug + }); + // Must emit: DMs with a mention token must NOT be deduped + expect(received).toEqual(['<@UBOT> what broke?']); + }); +}); + +describe('SlackAdapter #activeThreads cap (Fix 4)', () => { + it('stays at or below 1000 entries after more than 1000 distinct app_mention events', async () => { + const { adapter } = makeAdapter(); + const mentionHandler = getBoltHandler(adapter, 0); + + // Fire 1005 distinct top-level mentions (each gets a unique ts → unique cid) + for (let i = 0; i < 1005; i++) { + await mentionHandler({ + event: { + text: `<@U0BOT> query ${i}`, + ts: `TS-cap-${i}`, + thread_ts: undefined, + user: 'U1', + channel: 'C1', + }, + context: { botUserId: 'U0BOT' }, + }); + } + + // #activeThreads is private — verify the observable cap effect: + // the oldest thread (TS-cap-0) should have been evicted, so a follow-up + // reply in that thread is NOT treated as an active thread and does NOT emit. + const received: string[] = []; + adapter.onMessage((m) => received.push(m.text)); + const msgHandler = getBoltHandler(adapter, 1); + await msgHandler({ + message: { + ts: 'TS-cap-0-reply', + thread_ts: 'TS-cap-0', // oldest — should be evicted + text: 'evicted thread reply', + user: 'U1', + channel: 'C1', + channel_type: 'channel', + }, + context: { botUserId: 'U0BOT' }, + }); + // If eviction worked correctly, the oldest thread is gone → no emit + expect(received).toHaveLength(0); + + // Sanity: a recent thread (TS-cap-1004) is still active → emits + await msgHandler({ + message: { + ts: 'TS-cap-1004-reply', + thread_ts: 'TS-cap-1004', + text: 'recent thread reply', + user: 'U1', + channel: 'C1', + channel_type: 'channel', + }, + context: { botUserId: 'U0BOT' }, + }); + expect(received).toEqual(['recent thread reply']); + }); }); diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index 7ad5547..e091d26 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -34,10 +34,7 @@ export function suggestedPrompts(): { /** Strip every `<@BOTID>` token for the connector's own bot user id, collapse whitespace. */ export function stripMention(text: string, botUserId: string): string { - return text - .replace(new RegExp(`<@${botUserId}>`, 'g'), ' ') - .replace(/\s+/g, ' ') - .trim(); + return text.replaceAll(`<@${botUserId}>`, ' ').replace(/\s+/g, ' ').trim(); } export function parseConsentValue( @@ -61,6 +58,7 @@ export class SlackAdapter implements SurfaceAdapter { private msgHandler?: (m: InboundMessage) => void; private consentHandler?: (r: ConsentResponse) => void; #activeThreads = new Set(); + readonly #MAX_ACTIVE_THREADS = 1000; constructor(config: SlackConfig) { this.app = new App({ @@ -169,6 +167,16 @@ export class SlackAdapter implements SurfaceAdapter { this.msgHandler?.({ conversationId, userId, text }); } + #trackThread(cid: string): void { + // Bounded to avoid unbounded growth on a long-running socket process. + // Simple insertion-order eviction (oldest first); a TTL is deferred past alpha. + if (this.#activeThreads.size >= this.#MAX_ACTIVE_THREADS && !this.#activeThreads.has(cid)) { + const oldest = this.#activeThreads.values().next().value; + if (oldest !== undefined) this.#activeThreads.delete(oldest); + } + this.#activeThreads.add(cid); + } + private wire(): void { const assistant = new Assistant({ threadStarted: async ({ say, setSuggestedPrompts }) => { @@ -212,7 +220,7 @@ export class SlackAdapter implements SurfaceAdapter { const query = e.text ? stripMention(e.text, botId) : ''; if (!query || !e.channel) return; const cid = e.thread_ts ?? e.ts; - this.#activeThreads.add(cid); + this.#trackThread(cid); this.emit(cid, e.channel, cid, e.user ?? 'unknown', query); }); @@ -228,13 +236,14 @@ export class SlackAdapter implements SurfaceAdapter { }; if (m.subtype || !m.text || !m.channel) return; const botId = context.botUserId ?? ''; - if (botId && m.text.includes(`<@${botId}>`)) return; // mentions handled by app_mention (dedupe) const isDM = m.channel_type === 'im'; + // Dedupe only in channels: a channel mention fires BOTH app_mention and message, + // so skip it here (app_mention handles it). DMs never emit app_mention, so a DM + // whose text contains a mention token must NOT be dropped. + if (!isDM && botId && m.text.includes(`<@${botId}>`)) return; const inActiveThread = m.thread_ts !== undefined && this.#activeThreads.has(m.thread_ts); if (!isDM && !inActiveThread) return; // ignore unrelated channel chatter const cid = m.thread_ts ?? m.ts; - // Idempotent no-op when cid is already in the set — kept as a defensive keep-alive for threaded replies. - if (inActiveThread) this.#activeThreads.add(cid); this.emit(cid, m.channel, cid, m.user ?? 'unknown', m.text); });