From 858b0565e8874cfc6b23baf58bef16f126f980f3 Mon Sep 17 00:00:00 2001 From: Coolton Date: Tue, 1 Sep 2026 11:54:03 +0000 Subject: [PATCH] fix: enforce slack message guardrails --- src/mastra/chat/adapter.ts | 25 +++++++++++++ src/mastra/chat/handlers.ts | 50 +++++++++++++++++++------ src/mastra/chat/message-policy.ts | 62 +++++++++++++++++++++++++++++++ src/mastra/chat/state.ts | 1 + src/mastra/types/thread.ts | 1 + 5 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 src/mastra/chat/message-policy.ts diff --git a/src/mastra/chat/adapter.ts b/src/mastra/chat/adapter.ts index a90e9cf..5bd898a 100644 --- a/src/mastra/chat/adapter.ts +++ b/src/mastra/chat/adapter.ts @@ -1,4 +1,6 @@ import { SlackAdapter } from '@chat-adapter/slack'; +import type { FetchOptions, FetchResult, Message } from 'chat'; +import { isPingGroupOnly, shouldIgnoreMessage } from './message-policy'; const mentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; @@ -19,6 +21,13 @@ export class SlackAgentAdapter extends SlackAdapter { // restart, and the thread re-learns its recipient from the next live message. private readonly recipients = new Map(); + private shouldIgnoreRaw(raw: unknown): boolean { + return ( + shouldIgnoreMessage(raw, this.botUserId) || + isPingGroupOnly(raw, this.botUserId) + ); + } + private recipientKey(threadId: string): string { return `stream-recipient:${threadId}`; } @@ -27,6 +36,9 @@ export class SlackAgentAdapter extends SlackAdapter { ...args: Parameters ): ReturnType { const [event] = args; + if (this.shouldIgnoreRaw(event)) { + return; + } const { chat } = this; const userId = event.user; const teamId = event.team_id ?? event.team; @@ -61,6 +73,19 @@ export class SlackAgentAdapter extends SlackAdapter { return super.handleMessageEvent(...args); } + override async fetchMessages( + threadId: string, + options?: FetchOptions + ): Promise> { + const result = await super.fetchMessages(threadId, options); + return { + ...result, + messages: result.messages.filter( + (message: Message) => !this.shouldIgnoreRaw(message.raw) + ), + }; + } + override async stream( ...args: Parameters ): ReturnType { diff --git a/src/mastra/chat/handlers.ts b/src/mastra/chat/handlers.ts index 4dfccb2..eb70f1e 100644 --- a/src/mastra/chat/handlers.ts +++ b/src/mastra/chat/handlers.ts @@ -5,7 +5,8 @@ import { logger } from '../lib/logger'; import { attachments } from './attachments'; import { slack } from './client'; import { handleCommand } from './commands'; -import { rawText, withoutLeadingMentions } from './message'; +import { stop } from './commands/stop'; +import { isStopCommand, messageShouldBeExcluded } from './message-policy'; import { offerOptIn } from './onboarding'; import { threadState } from './state'; @@ -37,15 +38,6 @@ function isFromBot(message: Message): boolean { ); } -function isComment(message: Message): boolean { - for (const line of rawText(message).split('\n')) { - if (withoutLeadingMentions(line).trimStart().startsWith('##')) { - return true; - } - } - return false; -} - async function runTurn({ defaultHandler, message, @@ -75,10 +67,16 @@ export async function onMention( message: Message, defaultHandler: DefaultHandler ): Promise { + if (messageShouldBeExcluded(message, slack.botUserId)) { + return; + } await captureSearchToken({ raw: message.raw, thread }); if (isFromBot(message)) { return; } + if ((await threadState(thread))?.stopped) { + return; + } if (!(await isUserAllowed(message.author.userId))) { await offerOptIn({ thread, user: message.author }); return; @@ -86,6 +84,12 @@ export async function onMention( if (slack.decodeThreadId(message.threadId).threadTs === message.id) { await thread.setState({ respondOnThreadMessages: true }); } + const isStop = isStopCommand(message.raw, slack.userName, slack.botUserId); + if (isStop) { + await thread.setState({ stopped: true, respondOnThreadMessages: false }); + await stop({ message, thread }); + return; + } if (await handleCommand({ message, thread })) { return; } @@ -97,11 +101,17 @@ export async function onSubscribedMessage( message: Message, defaultHandler: DefaultHandler ): Promise { + if (messageShouldBeExcluded(message, slack.botUserId)) { + return; + } await captureSearchToken({ raw: message.raw, thread }); - if (isFromBot(message) || isComment(message)) { + if (isFromBot(message)) { return; } const state = await threadState(thread); + if (state?.stopped) { + return; + } const isFollowingThread = state?.respondOnThreadMessages === true; if (!(isFollowingThread || message.isMention)) { return; @@ -112,6 +122,12 @@ export async function onSubscribedMessage( if (!(await isUserAllowed(message.author.userId))) { return; } + const isStop = isStopCommand(message.raw, slack.userName, slack.botUserId); + if (isStop) { + await thread.setState({ stopped: true, respondOnThreadMessages: false }); + await stop({ message, thread }); + return; + } if (await handleCommand({ message, thread })) { return; } @@ -127,14 +143,26 @@ export async function onDirectMessage( message: Message, defaultHandler: DefaultHandler ): Promise { + if (messageShouldBeExcluded(message, slack.botUserId)) { + return; + } await captureSearchToken({ raw: message.raw, thread }); if (isFromBot(message)) { return; } + if ((await threadState(thread))?.stopped) { + return; + } if (!(await isUserAllowed(message.author.userId))) { await offerOptIn({ thread, user: message.author }); return; } + const isStop = isStopCommand(message.raw, slack.userName, slack.botUserId); + if (isStop) { + await thread.setState({ stopped: true, respondOnThreadMessages: false }); + await stop({ message, thread }); + return; + } if (await handleCommand({ message, thread })) { return; } diff --git a/src/mastra/chat/message-policy.ts b/src/mastra/chat/message-policy.ts new file mode 100644 index 0000000..232cc9a --- /dev/null +++ b/src/mastra/chat/message-policy.ts @@ -0,0 +1,62 @@ +import type { Message } from 'chat'; + +const slackText = (raw: unknown): string | undefined => { + if (!raw || typeof raw !== 'object' || !('text' in raw)) { + return; + } + const { text } = raw as { text?: unknown }; + return typeof text === 'string' ? text : undefined; +}; + +export function rawMessageText(raw: unknown): string { + return slackText(raw) ?? ''; +} + +export function directlyMentionsBot(text: string, botUserId?: string): boolean { + return Boolean( + botUserId && + new RegExp(`<@${escapeRegExp(botUserId)}(?:\\|[^>]+)?>`).test(text) + ); +} + +export function shouldIgnoreMessage(raw: unknown, botUserId?: string): boolean { + const text = rawMessageText(raw); + if (text.startsWith('##')) { + return true; + } + return text.startsWith('<>') && !directlyMentionsBot(text, botUserId); +} + +export function isPingGroupOnly(raw: unknown, botUserId?: string): boolean { + const text = rawMessageText(raw); + return text.includes(']+)?>` + : `@${escapedName}`; + return new RegExp(`^(?:${mention}|@${escapedName})\\s+!stop\\s*$`, 'i').test( + text + ); +} + +export function messageShouldBeExcluded( + message: Message, + botUserId?: string +): boolean { + return ( + shouldIgnoreMessage(message.raw, botUserId) || + isPingGroupOnly(message.raw, botUserId) + ); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); +} diff --git a/src/mastra/chat/state.ts b/src/mastra/chat/state.ts index 65ee31c..3dfdd6b 100644 --- a/src/mastra/chat/state.ts +++ b/src/mastra/chat/state.ts @@ -4,6 +4,7 @@ import type { ThreadState } from '../types'; const threadStateSchema = z.looseObject({ respondOnThreadMessages: z.boolean().optional(), + stopped: z.boolean().optional(), searchToken: z.string().optional(), }); diff --git a/src/mastra/types/thread.ts b/src/mastra/types/thread.ts index b83d74b..0ff9b81 100644 --- a/src/mastra/types/thread.ts +++ b/src/mastra/types/thread.ts @@ -1,4 +1,5 @@ export interface ThreadState { respondOnThreadMessages?: boolean; searchToken?: string; + stopped?: boolean; }