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
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/concepts/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions packages/junior/src/chat/event-automations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions packages/junior/src/chat/event-automations/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
52 changes: 37 additions & 15 deletions packages/junior/src/chat/slack/channel-message-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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.",
},
},
],
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
) {
return {
namespace: "slack",
identifier,
resourceType: "channel",
label: "New messages in #discuss-integration",
events: ["message.posted"],
...(match ? { match } : undefined),
};
}

Expand All @@ -52,6 +56,7 @@ async function postChannelMessage(input: {
channel?: string;
threadTs?: string;
ts: string;
user?: string;
}) {
const state = getStateAdapter();
await state.connect();
Expand Down Expand Up @@ -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(
Expand Down
Loading