diff --git a/apps/api/src/handlers/slack/helpers/event-normalization.test.ts b/apps/api/src/handlers/slack/helpers/event-normalization.test.ts index dcacec127..4dfb57e00 100644 --- a/apps/api/src/handlers/slack/helpers/event-normalization.test.ts +++ b/apps/api/src/handlers/slack/helpers/event-normalization.test.ts @@ -4,6 +4,7 @@ import type { SlackEvent } from '@roomote/slack'; import { enrichSlackMessageEvent, + getIgnoredAutomatedSlackMentionLog, isRoutableAutomatedSlackAppMention, } from './event-normalization'; @@ -157,4 +158,89 @@ describe('event-normalization', () => { false, ); }); + + describe('getIgnoredAutomatedSlackMentionLog', () => { + it('logs a workflow message mentioning a stale bot user instead of Roomote', () => { + // Real-world shape: a Slack Workflow Builder message whose template still + // mentions the bot user of a previous Roomote installation. + const event = { + type: 'message', + subtype: 'bot_message', + channel: 'C123', + bot_id: 'B_WORKFLOW', + app_id: 'A_WORKFLOW', + username: 'Roomote - Quick Win', + text: '<@U_OLD_ROOMOTE> fix this using the attached Jira card', + ts: '1712345678.000600', + thread_ts: '1712345678.000100', + } as unknown as SlackEvent; + + const log = getIgnoredAutomatedSlackMentionLog(event, slackInstallation); + + expect(log).toContain('U_OLD_ROOMOTE'); + expect(log).toContain('U_ROOMOTE'); + expect(log).toContain('subtype=bot_message'); + expect(log).toContain('app_id=A_WORKFLOW'); + }); + + it('returns null when the message mentions the Roomote bot user', () => { + const event = { + type: 'message', + subtype: 'bot_message', + channel: 'C123', + bot_id: 'B_WORKFLOW', + app_id: 'A_WORKFLOW', + text: '<@U_ROOMOTE> investigate this deployment', + ts: '1712345678.000700', + } as unknown as SlackEvent; + + expect( + getIgnoredAutomatedSlackMentionLog(event, slackInstallation), + ).toBeNull(); + }); + + it('returns null for messages without any user mention', () => { + const event = { + type: 'message', + subtype: 'bot_message', + channel: 'C123', + bot_id: 'B_WORKFLOW', + app_id: 'A_WORKFLOW', + text: 'deployment finished', + ts: '1712345678.000800', + } as unknown as SlackEvent; + + expect( + getIgnoredAutomatedSlackMentionLog(event, slackInstallation), + ).toBeNull(); + }); + + it('returns null for Roomote-authored messages', () => { + const event = { + type: 'message', + subtype: 'bot_message', + channel: 'C123', + bot_id: 'B_WORKFLOW', + app_id: 'A_ROOMOTE', + text: '<@U_SOMEONE> here is the summary you asked for', + ts: '1712345678.000900', + } as unknown as SlackEvent; + + expect( + getIgnoredAutomatedSlackMentionLog(event, slackInstallation), + ).toBeNull(); + }); + + it('returns null for non-message events', () => { + const event = { + type: 'reaction_added', + user: 'U123', + item: { channel: 'C123', ts: '1712345678.001000' }, + } as never; + + expect( + getIgnoredAutomatedSlackMentionLog(event, slackInstallation), + ).toBeNull(); + }); + }); }); diff --git a/apps/api/src/handlers/slack/helpers/event-normalization.ts b/apps/api/src/handlers/slack/helpers/event-normalization.ts index fb06cf2f9..118de633e 100644 --- a/apps/api/src/handlers/slack/helpers/event-normalization.ts +++ b/apps/api/src/handlers/slack/helpers/event-normalization.ts @@ -11,7 +11,10 @@ import type { AutomatedSlackAppMentionEvent, SlackWebhookEvent, } from '../types.js'; -import { mentionsSlackBot } from './mention-routing.js'; +import { + getMentionedSlackUserIds, + mentionsSlackBot, +} from './mention-routing.js'; function normalizeSlackReactionName(reaction: string): string { return reaction.trim().toLowerCase().split('::')[0] ?? ''; @@ -207,6 +210,46 @@ export function isRoomoteAuthoredSlackEvent( ); } +// Automated (app/bot-authored) messages that mention users without mentioning +// the installed Roomote bot are discarded, which is invisible in logs and has +// hidden real misconfigurations — e.g. a Slack workflow whose message template +// still mentions the bot user of a previous Roomote installation. Surface a +// log line naming the mentioned IDs so those drops are diagnosable. +export function getIgnoredAutomatedSlackMentionLog( + event: SlackWebhookEvent, + slackInstallation: SlackInstallation, +): string | null { + if (event.type !== 'message' && event.type !== 'app_mention') { + return null; + } + + if (isRoomoteAuthoredSlackEvent(event, slackInstallation)) { + return null; + } + + const slackEvent = event as SlackEvent; + const mentionedUserIds = getMentionedSlackUserIds(slackEvent); + + if ( + mentionedUserIds.length === 0 || + (slackInstallation.botUserId && + mentionedUserIds.includes(slackInstallation.botUserId)) + ) { + return null; + } + + const subtype = getSlackEventStringField(event, 'subtype') ?? 'none'; + const appId = getSlackEventStringField(event, 'app_id') ?? 'none'; + const botId = getSlackEventStringField(event, 'bot_id') ?? 'none'; + + return ( + `[SlackWebhook] Ignoring automated message mentioning [${mentionedUserIds.join(', ')}] ` + + `but not the Roomote bot user ${slackInstallation.botUserId} ` + + `(channel=${slackEvent.channel}, ts=${slackEvent.ts}, thread_ts=${slackEvent.thread_ts ?? 'none'}, ` + + `subtype=${subtype}, app_id=${appId}, bot_id=${botId})` + ); +} + export function isRoutableAutomatedSlackAppMention( event: SlackWebhookEvent, slackInstallation: SlackInstallation, diff --git a/apps/api/src/handlers/slack/helpers/mention-routing.ts b/apps/api/src/handlers/slack/helpers/mention-routing.ts index 3902c19cd..d2c9d3e44 100644 --- a/apps/api/src/handlers/slack/helpers/mention-routing.ts +++ b/apps/api/src/handlers/slack/helpers/mention-routing.ts @@ -55,7 +55,9 @@ export function getSlackMentionDirectiveText( return candidateLines.join(' '); } -function getMentionedSlackUserIds(message: SlackMentionTextSource): string[] { +export function getMentionedSlackUserIds( + message: SlackMentionTextSource, +): string[] { return Array.from( getSlackMentionDirectiveText(message).matchAll(/<@([^>|]+)(?:\|[^>]+)?>/g), ) diff --git a/apps/api/src/handlers/slack/index.ts b/apps/api/src/handlers/slack/index.ts index 97b43251c..aaa739d4d 100644 --- a/apps/api/src/handlers/slack/index.ts +++ b/apps/api/src/handlers/slack/index.ts @@ -18,6 +18,7 @@ import { import { dispatchSlackEvent } from './dispatch/events.js'; import { handleSlackInteractivePayload } from './dispatch/interactive.js'; import { + getIgnoredAutomatedSlackMentionLog, getSlackWebhookEventLogDetails, isAppAuthoredSlackEvent, isRoomoteAuthoredSlackEvent, @@ -155,6 +156,15 @@ slack.post('/', async (c) => { (!isTopLevelAppMessageEvent || isRoomoteAuthoredSlackEvent(event, slackInstallation)) ) { + const ignoredMentionLog = getIgnoredAutomatedSlackMentionLog( + event, + slackInstallation, + ); + + if (ignoredMentionLog) { + apiLogger.info(ignoredMentionLog); + } + return c.json({ ok: true }); }