Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/mastra/chat/adapter.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<string, Recipient>();

private shouldIgnoreRaw(raw: unknown): boolean {
return (
shouldIgnoreMessage(raw, this.botUserId) ||
isPingGroupOnly(raw, this.botUserId)
);
}

private recipientKey(threadId: string): string {
return `stream-recipient:${threadId}`;
}
Expand All @@ -27,6 +36,9 @@ export class SlackAgentAdapter extends SlackAdapter {
...args: Parameters<SlackAdapter['handleMessageEvent']>
): ReturnType<SlackAdapter['handleMessageEvent']> {
const [event] = args;
if (this.shouldIgnoreRaw(event)) {
return;
}
const { chat } = this;
const userId = event.user;
const teamId = event.team_id ?? event.team;
Expand Down Expand Up @@ -61,6 +73,19 @@ export class SlackAgentAdapter extends SlackAdapter {
return super.handleMessageEvent(...args);
}

override async fetchMessages(
threadId: string,
options?: FetchOptions
): Promise<FetchResult<unknown>> {
const result = await super.fetchMessages(threadId, options);
return {
...result,
messages: result.messages.filter(
(message: Message) => !this.shouldIgnoreRaw(message.raw)
),
};
}

override async stream(
...args: Parameters<SlackAdapter['stream']>
): ReturnType<SlackAdapter['stream']> {
Expand Down
50 changes: 39 additions & 11 deletions src/mastra/chat/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -75,17 +67,29 @@ export async function onMention(
message: Message,
defaultHandler: DefaultHandler
): Promise<void> {
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;
}
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;
}
Expand All @@ -97,11 +101,17 @@ export async function onSubscribedMessage(
message: Message,
defaultHandler: DefaultHandler
): Promise<void> {
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;
Expand All @@ -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;
}
Expand All @@ -127,14 +143,26 @@ export async function onDirectMessage(
message: Message,
defaultHandler: DefaultHandler
): Promise<void> {
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;
}
Expand Down
62 changes: 62 additions & 0 deletions src/mastra/chat/message-policy.ts
Original file line number Diff line number Diff line change
@@ -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('<!subteam^') && !directlyMentionsBot(text, botUserId);
}

export function isStopCommand(
raw: unknown,
botUserName: string,
botUserId?: string
): boolean {
const text = rawMessageText(raw).trim();
const escapedName = escapeRegExp(botUserName);
const mention = botUserId
? `<@${escapeRegExp(botUserId)}(?:\\|[^>]+)?>`
: `@${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, '\\$&');
}
1 change: 1 addition & 0 deletions src/mastra/chat/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
1 change: 1 addition & 0 deletions src/mastra/types/thread.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface ThreadState {
respondOnThreadMessages?: boolean;
searchToken?: string;
stopped?: boolean;
}