From f9a1adf6656131a39204032cc24fb4c1ff4b4540 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:13:36 +0000 Subject: [PATCH] feat(automations): Run Slack channel automations on named bot posts A Slack channel-message automation dropped every bot post, so an automation meant to triage Sentry alert posts could never run. Bot posts now publish with postedByBot, and they run only automations whose match names the bot in userId. Other automations in the channel still ignore bot posts, so a noisy bot cannot use up their run limits. Co-Authored-By: Michael Warkentin --- .../src/content/docs/concepts/automations.md | 2 +- .../src/chat/event-automations/README.md | 6 ++- .../src/chat/event-automations/ingest.ts | 9 +++- .../src/chat/slack/channel-message-events.ts | 52 ++++++++++++------ .../slack/channel-message-automations.test.ts | 53 ++++++++++++++++++- 5 files changed, 101 insertions(+), 21 deletions(-) diff --git a/packages/docs/src/content/docs/concepts/automations.md b/packages/docs/src/content/docs/concepts/automations.md index 002a96c0cd..76a95098cb 100644 --- a/packages/docs/src/content/docs/concepts/automations.md +++ b/packages/docs/src/content/docs/concepts/automations.md @@ -52,7 +52,7 @@ whenever a new issue is created in Linear team SRE, investigate it and comment f when someone posts a new message in this channel, answer it if you can, otherwise tag me ``` -A Slack channel-message automation runs for each new top-level message in the channel where you create it. It does not run for thread replies, for messages that mention Junior, or for bot messages. Junior replies in the thread of the message. Junior must be a member of the channel. The automation can watch only its own channel, and it always runs with Junior's own credentials, because anyone in the channel can write the message. Tell the channel that Junior reads every new message there. +A Slack channel-message automation runs for each new top-level message in the channel where you create it. It does not run for thread replies or for messages that mention Junior. It runs for a bot message, such as a Sentry alert, only when the automation names that bot's Slack user ID in `match.userId`. Junior replies in the thread of the message. Junior must be a member of the channel. The automation can watch only its own channel, and it always runs with Junior's own credentials, because anyone in the channel can write the message. Tell the channel that Junior reads every new message there. If the plugin is disabled, the automation remains visible but cannot receive events until a compatible plugin is enabled again. Each plugin page lists the resources and events it can publish. diff --git a/packages/junior/src/chat/event-automations/README.md b/packages/junior/src/chat/event-automations/README.md index 67b13be9bf..63fd5e8bf8 100644 --- a/packages/junior/src/chat/event-automations/README.md +++ b/packages/junior/src/chat/event-automations/README.md @@ -7,8 +7,10 @@ an event. Plugins do not know which conversations or automations consume it. Slack ingress also publishes core `slack` / `channel` / `message.posted` events (`slack/channel-message-events.ts`) for new top-level messages that no other -route handles. Bot posts and thread replies do not publish. A Watch cannot select -these events. An automation can watch only the channel where it posts, so +route handles. Thread replies and Slackbot posts do not publish. Other bot posts +publish with `postedByBot`, but they run only automations whose `match` names +the bot in `userId`. Bots can post often and would use up the run limit of +every other automation in the channel. A Watch cannot select these events. An automation can watch only the channel where it posts, so private channel text stays in that channel. Runs always use system credentials, because anyone in the channel writes the Event text. Each run replies in the thread of the source message. diff --git a/packages/junior/src/chat/event-automations/ingest.ts b/packages/junior/src/chat/event-automations/ingest.ts index d0e06d32b2..bf09f58b89 100644 --- a/packages/junior/src/chat/event-automations/ingest.ts +++ b/packages/junior/src/chat/event-automations/ingest.ts @@ -29,7 +29,10 @@ import { } from "@/chat/event-automations/run-limit"; import { notifyAutomationBlocked } from "@/chat/automations/blocked-notice"; import { botConfig } from "@/chat/config"; -import { slackChannelMessageReplyThreadTs } from "@/chat/slack/channel-message-events"; +import { + slackChannelMessageReplyThreadTs, + slackChannelMessageRunsAutomation, +} from "@/chat/slack/channel-message-events"; import { logWarn } from "@/chat/logging"; import { captureMessage } from "@/chat/sentry"; import type { EventAutomation } from "@/chat/event-automations/types"; @@ -87,7 +90,9 @@ export async function ingestEventAutomations( const event = eventSchema.parse(input); const db = getDb(); const nowMs = options.nowMs ?? Date.now(); - const tasks = await findMatchingEventAutomations(db, event, options.teamId); + const tasks = ( + await findMatchingEventAutomations(db, event, options.teamId) + ).filter((task) => slackChannelMessageRunsAutomation(event, task)); let dispatched = 0; const errors: unknown[] = []; for (const task of tasks) { diff --git a/packages/junior/src/chat/slack/channel-message-events.ts b/packages/junior/src/chat/slack/channel-message-events.ts index e6f59a57b6..bf9c06ba83 100644 --- a/packages/junior/src/chat/slack/channel-message-events.ts +++ b/packages/junior/src/chat/slack/channel-message-events.ts @@ -5,10 +5,11 @@ * core owned, like `junior`. Anyone in the channel writes the Event text, so an * automation can watch only its own channel and always uses system credentials. */ -import type { - Event, - EventInput, - PluginEvents, +import { + eventMatchFieldsSchema, + type Event, + type EventInput, + type PluginEvents, } from "@sentry/junior-plugin-api"; import type { EventAutomation } from "@/chat/event-automations/types"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; @@ -23,6 +24,14 @@ const CHANNEL_RESOURCE_TYPE = "channel"; const MESSAGE_POSTED_EVENT = "message.posted"; const CHANNEL_ID_PATTERN = /^[CG][A-Z0-9]{2,}$/; +const CHANNEL_MESSAGE_MATCH_FIELDS = eventMatchFieldsSchema.parse({ + userId: { + kind: "string", + description: + "Slack user ID of the message author. Required to run on bot posts, such as alert bots.", + }, +}); + /** Event registration for new top-level Slack channel messages. */ export function slackChannelMessageEvents(): Pick< PluginEvents, @@ -38,9 +47,10 @@ export function slackChannelMessageEvents(): Pick< }, supportedEvents: [MESSAGE_POSTED_EVENT], suggestedEvents: [], + matchFields: CHANNEL_MESSAGE_MATCH_FIELDS, guidance: { [MESSAGE_POSTED_EVENT]: - "A person posted a new top-level message in this channel. A reply goes in that message's thread. If the instructions do not apply to this message, end with no action.", + "A person or bot posted a new top-level message in this channel. A bot post runs the automation only when match names the bot in userId. A reply goes in that message's thread. If the instructions do not apply to this message, end with no action.", }, }, ], @@ -82,8 +92,8 @@ export function requireSlackChannelMessageScope( /** * Build the Event for one new top-level channel message, or nothing. * - * Thread replies and bot posts, including Slackbot, do not count. Bots can - * post often and use up the run limit of the automation. + * Thread replies and Slackbot posts do not count. Other bot posts are marked + * with `postedByBot`, so only automations that name the bot can run on them. */ export function slackChannelMessageEvent(input: { botId?: string; @@ -94,30 +104,42 @@ export function slackChannelMessageEvent(input: { ts: string; userId: string; }): EventInput | undefined { - if ( - input.botId || - input.subtype === "bot_message" || - input.userId === "USLACKBOT" - ) { - return undefined; - } + if (input.userId === "USLACKBOT") return undefined; if (input.threadTs && input.threadTs !== input.ts) return undefined; if (!CHANNEL_ID_PATTERN.test(input.channelId)) return undefined; + const postedByBot = Boolean(input.botId || input.subtype === "bot_message"); return { eventKey: `${input.channelId}:${input.ts}`, eventType: MESSAGE_POSTED_EVENT, identifier: input.channelId, occurredAtMs: Math.floor(Number(input.ts) * 1000), - trustedSummary: `<@${input.userId}> posted a new message in <#${input.channelId}>.`, + trustedSummary: `${postedByBot ? "The bot " : ""}<@${input.userId}> posted a new message in <#${input.channelId}>.`, data: { channelId: input.channelId, messageTs: input.ts, userId: input.userId, + ...(postedByBot ? { postedByBot: true } : undefined), }, ...(input.text?.trim() ? { untrustedText: input.text } : undefined), }; } +/** + * Decide whether a channel-message Event may run one automation. + * + * A bot post runs only automations whose match names its author in `userId`. + * Bots can post often and would use up the run limit of every other + * automation in the channel. + */ +export function slackChannelMessageRunsAutomation( + event: Event, + automation: EventAutomation, +): boolean { + if (event.namespace !== SLACK_EVENT_NAMESPACE) return true; + if (event.data?.postedByBot !== true) return true; + return automation.trigger.match?.userId !== undefined; +} + /** Return the source message thread for a channel-message run. */ export function slackChannelMessageReplyThreadTs( event: Event, diff --git a/packages/junior/tests/integration/slack/channel-message-automations.test.ts b/packages/junior/tests/integration/slack/channel-message-automations.test.ts index 26bd16724a..094fc6b619 100644 --- a/packages/junior/tests/integration/slack/channel-message-automations.test.ts +++ b/packages/junior/tests/integration/slack/channel-message-automations.test.ts @@ -37,13 +37,17 @@ const TEAM_ID = "T123"; let fixture: LocalJuniorSqlFixture; let queue: ConversationWorkQueueTestAdapter; -function trigger(identifier = "current_channel") { +function trigger( + identifier = "current_channel", + match?: Record, +) { return { namespace: "slack", identifier, resourceType: "channel", label: "New messages in #discuss-integration", events: ["message.posted"], + ...(match ? { match } : undefined), }; } @@ -52,6 +56,7 @@ async function postChannelMessage(input: { channel?: string; threadTs?: string; ts: string; + user?: string; }) { const state = getStateAdapter(); await state.connect(); @@ -181,6 +186,52 @@ describe("Slack channel-message automations", () => { ).toMatchObject({ credentialMode: "system" }); }); + it("runs for bot posts only when its match names the bot", async () => { + const catalog = getEventCatalog(); + const channelContext = context( + "U123", + "C123", + "public", + undefined, + TEAM_ID, + ); + const { automation } = (await execute( + createEventAutomationTool(channelContext, catalog), + { + instruction: "Triage each new alert.", + title: "Triage alerts", + outcomes: [ + { action: "send_message", destination: "current_conversation" }, + ], + trigger: trigger("current_channel", { userId: "U777" }), + }, + )) as { automation: { id: string } }; + + // A post from another bot does not run it. + await postChannelMessage({ + botId: "B888", + ts: "1712346.0001", + user: "U888", + }); + expect(queue.sentRecords()).toEqual([]); + + // A post from the named bot runs it and replies in the alert thread. + await postChannelMessage({ + botId: "B777", + ts: "1712346.0002", + user: "U777", + }); + const sent = queue.sentRecords(); + expect(sent).toHaveLength(1); + const dispatch = await getDispatchRecord( + sent[0]!.conversationId.replace(/^agent-dispatch:/, ""), + ); + expect(dispatch).toMatchObject({ + destination: { channelId: "C123", threadTs: "1712346.0002" }, + metadata: { eventAutomationId: automation.id }, + }); + }); + it("watches only the channel where the automation posts", async () => { const catalog = getEventCatalog(); const channelContext = context(