Skip to content

Commit 341ae21

Browse files
authored
feat(slack): handle global and message shortcuts (#94881)
Merged via squash. Prepared head SHA: 32dea12 Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete
1 parent 378c413 commit 341ae21

6 files changed

Lines changed: 344 additions & 9 deletions

File tree

docs/channels/slack.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1409,10 +1409,14 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
14091409
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
14101410
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
14111411
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
1412-
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
1412+
- Block actions, shortcuts, and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
14131413
- block actions: selected values, labels, picker values, and `workflow_*` metadata
1414+
- global shortcuts: callback and actor metadata, routed to the actor's direct session
1415+
- message shortcuts: callback, actor, channel, thread, and selected-message context
14141416
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
14151417

1418+
Define global or message shortcuts in your Slack app configuration and use any non-empty callback ID. OpenClaw acknowledges matching shortcut payloads, applies the same DM/channel sender policy as other Slack interactions, and queues the sanitized event for the routed agent session. Trigger IDs and response URLs are redacted from agent context.
1419+
14161420
## Configuration reference
14171421

14181422
Primary reference: [Configuration reference - Slack](/gateway/config-channels#slack).

extensions/slack/src/monitor/context.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
55
import { describe, expect, it } from "vitest";
66
import { createSlackMonitorContext } from "./context.js";
77

8-
function createTestContext() {
8+
function createTestContext(params?: {
9+
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
10+
}) {
911
return createSlackMonitorContext({
1012
cfg: {
1113
channels: { slack: { enabled: true } },
12-
session: { dmScope: "main" },
14+
session: { dmScope: params?.dmScope ?? "main" },
1315
} as OpenClawConfig,
1416
accountId: "default",
1517
botToken: "xoxb-test",
@@ -98,4 +100,15 @@ describe("createSlackMonitorContext resolveSlackSystemEventSessionKey", () => {
98100
}),
99101
).toBe("agent:main:slack:channel:c_thread:thread:1712345678.123456");
100102
});
103+
104+
it("routes channel-less direct interactions to the sender session", () => {
105+
const ctx = createTestContext({ dmScope: "per-channel-peer" });
106+
107+
expect(
108+
ctx.resolveSlackSystemEventSessionKey({
109+
channelType: "im",
110+
senderId: "U_SHORTCUT",
111+
}),
112+
).toBe("agent:main:slack:direct:u_shortcut");
113+
});
101114
});

extensions/slack/src/monitor/context.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -308,20 +308,19 @@ export function createSlackMonitorContext(params: {
308308
threadTs?: string | null;
309309
}) => {
310310
const channelId = normalizeOptionalString(p.channelId) ?? "";
311-
if (!channelId) {
312-
return params.mainKey;
313-
}
311+
const senderId = normalizeOptionalString(p.senderId) ?? "";
314312
const channelType = normalizeSlackChannelType(p.channelType, channelId);
315313
const isDirectMessage = channelType === "im";
314+
if (!channelId && (!isDirectMessage || !senderId)) {
315+
return params.mainKey;
316+
}
316317
const isGroup = channelType === "mpim";
317318
const from = isDirectMessage
318-
? `slack:${channelId}`
319+
? `slack:${channelId || senderId}`
319320
: isGroup
320321
? `slack:group:${channelId}`
321322
: `slack:channel:${channelId}`;
322323
const chatType = isDirectMessage ? "direct" : isGroup ? "group" : "channel";
323-
const senderId = normalizeOptionalString(p.senderId) ?? "";
324-
325324
// Resolve through shared channel/account bindings so system events route to
326325
// the same agent session as regular inbound messages.
327326
try {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Slack plugin module implements shortcut interaction behavior.
2+
import type { GlobalShortcut, MessageShortcut, SlackShortcutMiddlewareArgs } from "@slack/bolt";
3+
import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime";
4+
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
5+
import { authorizeSlackSystemEventSender } from "../auth.js";
6+
import type { SlackMonitorContext } from "../context.js";
7+
8+
type SlackShortcutBody = GlobalShortcut | MessageShortcut;
9+
10+
function resolveMessageThreadTs(body: MessageShortcut): string | undefined {
11+
const threadTs = body.message.thread_ts;
12+
return typeof threadTs === "string" && threadTs.trim() ? threadTs.trim() : undefined;
13+
}
14+
15+
async function handleSlackShortcut(params: {
16+
ctx: SlackMonitorContext;
17+
trackEvent?: () => void;
18+
args: SlackShortcutMiddlewareArgs;
19+
formatSystemEvent: (payload: Record<string, unknown>) => string;
20+
}): Promise<void> {
21+
const { ack, body } = params.args;
22+
await ack();
23+
if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) {
24+
params.ctx.runtime.log?.("slack:interaction drop shortcut payload (mismatched app/team)");
25+
return;
26+
}
27+
28+
const callbackId = body.callback_id?.trim();
29+
const userId = body.user?.id?.trim();
30+
if (!callbackId || !userId) {
31+
params.ctx.runtime.log?.("slack:interaction drop shortcut reason=invalid-payload");
32+
return;
33+
}
34+
params.trackEvent?.();
35+
36+
const isMessageShortcut = body.type === "message_action";
37+
const messageBody = isMessageShortcut ? body : undefined;
38+
const channelId = messageBody?.channel.id?.trim() || undefined;
39+
if (isMessageShortcut && !channelId) {
40+
params.ctx.runtime.log?.(
41+
`slack:interaction drop shortcut callback=${callbackId} user=${userId} reason=missing-channel`,
42+
);
43+
return;
44+
}
45+
const threadTs = messageBody ? resolveMessageThreadTs(messageBody) : undefined;
46+
const auth = await authorizeSlackSystemEventSender({
47+
ctx: params.ctx,
48+
senderId: userId,
49+
channelId,
50+
channelType: isMessageShortcut ? undefined : "im",
51+
expectedSenderId: userId,
52+
interactiveEvent: true,
53+
});
54+
if (!auth.allowed) {
55+
params.ctx.runtime.log?.(
56+
`slack:interaction drop shortcut callback=${callbackId} user=${userId} reason=${auth.reason ?? "unauthorized"}`,
57+
);
58+
return;
59+
}
60+
61+
const interactionType = isMessageShortcut ? "message_shortcut" : "global_shortcut";
62+
const messageTs = messageBody?.message.ts || messageBody?.message_ts;
63+
const eventPayload = {
64+
interactionType,
65+
actionId: `shortcut:${callbackId}`,
66+
callbackId,
67+
userId,
68+
teamId: body.team?.id ?? body.user.team_id,
69+
triggerId: body.trigger_id,
70+
actionTs: body.action_ts,
71+
channelId,
72+
channelName: messageBody?.channel.name,
73+
messageTs,
74+
threadTs,
75+
messageUserId: messageBody?.message.user,
76+
messageText: messageBody?.message.text,
77+
responseUrl: messageBody?.response_url,
78+
};
79+
const sessionKey = params.ctx.resolveSlackSystemEventSessionKey({
80+
channelId,
81+
channelType: auth.channelType,
82+
senderId: userId,
83+
threadTs,
84+
});
85+
const contextKey = [
86+
"slack:interaction:shortcut",
87+
interactionType,
88+
callbackId,
89+
channelId,
90+
messageTs,
91+
body.action_ts,
92+
]
93+
.filter(Boolean)
94+
.join(":");
95+
96+
params.ctx.runtime.log?.(
97+
`slack:interaction ${interactionType} callback=${callbackId} user=${userId} channel=${channelId ?? "direct"}`,
98+
);
99+
const queued = enqueueSystemEvent(params.formatSystemEvent(eventPayload), {
100+
sessionKey,
101+
contextKey,
102+
deliveryContext: {
103+
channel: "slack",
104+
to: auth.channelType === "im" ? `user:${userId}` : `channel:${channelId}`,
105+
accountId: params.ctx.accountId,
106+
threadId: threadTs,
107+
},
108+
});
109+
if (queued) {
110+
requestHeartbeat({
111+
source: "hook",
112+
intent: "immediate",
113+
reason: "hook:slack-interaction",
114+
sessionKey,
115+
heartbeat: { target: "last" },
116+
});
117+
}
118+
}
119+
120+
export function registerSlackShortcutHandler(params: {
121+
ctx: SlackMonitorContext;
122+
trackEvent?: () => void;
123+
formatSystemEvent: (payload: Record<string, unknown>) => string;
124+
}): void {
125+
if (typeof params.ctx.app.shortcut !== "function") {
126+
return;
127+
}
128+
params.ctx.app.shortcut(/.+/, async (args: SlackShortcutMiddlewareArgs<SlackShortcutBody>) => {
129+
await handleSlackShortcut({
130+
ctx: params.ctx,
131+
trackEvent: params.trackEvent,
132+
args,
133+
formatSystemEvent: params.formatSystemEvent,
134+
});
135+
});
136+
}

0 commit comments

Comments
 (0)