@copilotkit/channels-slack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/dist/__tests__/assistant.test.d.ts +2 -0
- package/dist/__tests__/assistant.test.d.ts.map +1 -0
- package/dist/__tests__/assistant.test.js +153 -0
- package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
- package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
- package/dist/__tests__/auto-close-streaming.test.js +139 -0
- package/dist/__tests__/built-ins.test.d.ts +2 -0
- package/dist/__tests__/built-ins.test.d.ts.map +1 -0
- package/dist/__tests__/built-ins.test.js +67 -0
- package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
- package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
- package/dist/__tests__/chunked-message-stream.test.js +226 -0
- package/dist/__tests__/conversation-store.test.d.ts +2 -0
- package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
- package/dist/__tests__/conversation-store.test.js +209 -0
- package/dist/__tests__/download-files.test.d.ts +2 -0
- package/dist/__tests__/download-files.test.d.ts.map +1 -0
- package/dist/__tests__/download-files.test.js +142 -0
- package/dist/__tests__/ephemeral.test.d.ts +2 -0
- package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
- package/dist/__tests__/ephemeral.test.js +85 -0
- package/dist/__tests__/event-renderer.test.d.ts +2 -0
- package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/event-renderer.test.js +521 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
- package/dist/__tests__/message-stream.test.d.ts +2 -0
- package/dist/__tests__/message-stream.test.d.ts.map +1 -0
- package/dist/__tests__/message-stream.test.js +116 -0
- package/dist/__tests__/native-renderer.test.d.ts +2 -0
- package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/native-renderer.test.js +236 -0
- package/dist/__tests__/native-stream.test.d.ts +2 -0
- package/dist/__tests__/native-stream.test.d.ts.map +1 -0
- package/dist/__tests__/native-stream.test.js +235 -0
- package/dist/__tests__/reactions.test.d.ts +2 -0
- package/dist/__tests__/reactions.test.d.ts.map +1 -0
- package/dist/__tests__/reactions.test.js +193 -0
- package/dist/__tests__/slack-listener.test.d.ts +2 -0
- package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
- package/dist/__tests__/slack-listener.test.js +559 -0
- package/dist/__tests__/views.test.d.ts +2 -0
- package/dist/__tests__/views.test.d.ts.map +1 -0
- package/dist/__tests__/views.test.js +143 -0
- package/dist/adapter.d.ts +188 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +859 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +368 -0
- package/dist/assistant.d.ts +19 -0
- package/dist/assistant.d.ts.map +1 -0
- package/dist/assistant.js +124 -0
- package/dist/auto-close-streaming.d.ts +62 -0
- package/dist/auto-close-streaming.d.ts.map +1 -0
- package/dist/auto-close-streaming.js +273 -0
- package/dist/built-in-context.d.ts +22 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +65 -0
- package/dist/built-in-tools.d.ts +23 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +45 -0
- package/dist/chunked-message-stream.d.ts +67 -0
- package/dist/chunked-message-stream.d.ts.map +1 -0
- package/dist/chunked-message-stream.js +134 -0
- package/dist/codec.d.ts +18 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +18 -0
- package/dist/codec.test.d.ts +2 -0
- package/dist/codec.test.d.ts.map +1 -0
- package/dist/codec.test.js +19 -0
- package/dist/conversation-store.d.ts +92 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +196 -0
- package/dist/download-files.d.ts +59 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +116 -0
- package/dist/event-renderer.d.ts +98 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +493 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/ingress-normalize.d.ts +75 -0
- package/dist/ingress-normalize.d.ts.map +1 -0
- package/dist/ingress-normalize.js +153 -0
- package/dist/ingress-normalize.test.d.ts +2 -0
- package/dist/ingress-normalize.test.d.ts.map +1 -0
- package/dist/ingress-normalize.test.js +187 -0
- package/dist/interaction.d.ts +30 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +219 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +137 -0
- package/dist/markdown-to-mrkdwn.d.ts +21 -0
- package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
- package/dist/markdown-to-mrkdwn.js +99 -0
- package/dist/message-stream.d.ts +44 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +70 -0
- package/dist/native-stream.d.ts +131 -0
- package/dist/native-stream.d.ts.map +1 -0
- package/dist/native-stream.js +208 -0
- package/dist/render/block-kit.d.ts +35 -0
- package/dist/render/block-kit.d.ts.map +1 -0
- package/dist/render/block-kit.js +399 -0
- package/dist/render/block-kit.test.d.ts +2 -0
- package/dist/render/block-kit.test.d.ts.map +1 -0
- package/dist/render/block-kit.test.js +289 -0
- package/dist/render/budget.d.ts +24 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +30 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +49 -0
- package/dist/render/modal.d.ts +13 -0
- package/dist/render/modal.d.ts.map +1 -0
- package/dist/render/modal.js +108 -0
- package/dist/render/transport.d.ts +42 -0
- package/dist/render/transport.d.ts.map +1 -0
- package/dist/render/transport.js +1 -0
- package/dist/render.d.ts +23 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +20 -0
- package/dist/sanitizing-http-agent.d.ts +28 -0
- package/dist/sanitizing-http-agent.d.ts.map +1 -0
- package/dist/sanitizing-http-agent.js +51 -0
- package/dist/slack-listener.d.ts +73 -0
- package/dist/slack-listener.d.ts.map +1 -0
- package/dist/slack-listener.js +126 -0
- package/dist/slack-listener.test.d.ts +2 -0
- package/dist/slack-listener.test.d.ts.map +1 -0
- package/dist/slack-listener.test.js +115 -0
- package/dist/types.d.ts +153 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/package.json +73 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Pure, Bolt-free Slack ingress semantics. Shared by the local Slack adapter's
|
|
2
|
+
// Bolt listener AND the Intelligence-side webhook ingress (OSS-362), so the
|
|
3
|
+
// platform-specific parsing (mention stripping, stable event-id derivation,
|
|
4
|
+
// real-user-message filtering, field extraction) lives in ONE place instead of
|
|
5
|
+
// being duplicated. No `@slack/bolt`, no Slack credentials, no network.
|
|
6
|
+
//
|
|
7
|
+
// What stays per-side (NOT here): response policy (respondTo), credentialed
|
|
8
|
+
// thread-ownership reads (store.has), assistant-pane state, and building the
|
|
9
|
+
// reply target / egress route. Each side wraps these pure helpers with its own
|
|
10
|
+
// policy + transport.
|
|
11
|
+
// Matches both the plain `<@U123>` form and Slack's labeled `<@U123|handle>`
|
|
12
|
+
// form, so neither leaves a `|handle>` fragment behind after stripping.
|
|
13
|
+
const MENTION_RE = /<@[UW][A-Z0-9]+(?:\|[^>]+)?>/g;
|
|
14
|
+
/** Strip `<@U…>` / `<@U…|handle>` mention tokens and collapse whitespace. */
|
|
15
|
+
export const stripMentions = (text) => text.replace(MENTION_RE, "").replace(/\s+/g, " ").trim();
|
|
16
|
+
/**
|
|
17
|
+
* Derive a stable per-delivery id for inbound idempotency. Prefer the Events
|
|
18
|
+
* API envelope `event_id` (stable across Slack's automatic retries), then the
|
|
19
|
+
* message's `client_msg_id`, then a synthesized `${channel}:${ts}`. Returns
|
|
20
|
+
* undefined only when none is available — never fabricate a random id (that
|
|
21
|
+
* would defeat dedup).
|
|
22
|
+
*/
|
|
23
|
+
export function deriveEventId(body, event, channel) {
|
|
24
|
+
const envelopeEventId = body?.event_id;
|
|
25
|
+
if (envelopeEventId)
|
|
26
|
+
return envelopeEventId;
|
|
27
|
+
if (event.client_msg_id)
|
|
28
|
+
return event.client_msg_id;
|
|
29
|
+
if (event.ts)
|
|
30
|
+
return `${channel}:${event.ts}`;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Narrow to plain, user-authored, non-bot, non-subtyped messages with text.
|
|
35
|
+
* Rejects subtypes (edits/joins/…) except `file_share`, bot posts, and
|
|
36
|
+
* app-only messages. Anything that fails this is uninteresting to a bot.
|
|
37
|
+
*/
|
|
38
|
+
export function isPlainUserMessage(message, botUserId) {
|
|
39
|
+
if (!message || typeof message !== "object")
|
|
40
|
+
return false;
|
|
41
|
+
const m = message;
|
|
42
|
+
// Reject subtyped messages EXCEPT file uploads (`file_share`), which are real
|
|
43
|
+
// user messages carrying a `files` array we want to deliver.
|
|
44
|
+
if (m.subtype && m.subtype !== "file_share")
|
|
45
|
+
return false;
|
|
46
|
+
// Loop guard: skip the bot's own posts (matched by bot user id).
|
|
47
|
+
if (botUserId && m.user === botUserId)
|
|
48
|
+
return false;
|
|
49
|
+
// Skip messages with NO user (true bot-only / app-only posts). Messages
|
|
50
|
+
// posted via a USER token (xoxp-) belonging to an app have BOTH `user` and
|
|
51
|
+
// `bot_id` set — those are real user messages and should pass.
|
|
52
|
+
if (m.bot_id && !m.user)
|
|
53
|
+
return false;
|
|
54
|
+
if (typeof m.channel !== "string")
|
|
55
|
+
return false;
|
|
56
|
+
if (typeof m.text !== "string")
|
|
57
|
+
return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const hasFilesOn = (o) => Array.isArray(o?.files) &&
|
|
61
|
+
o.files.length > 0;
|
|
62
|
+
/**
|
|
63
|
+
* Map a raw Slack payload (Events API envelope or slash-command body) to the
|
|
64
|
+
* platform-neutral ingress shape, or `undefined` when it isn't a turn/command
|
|
65
|
+
* worth delivering (bot echo, subtype, empty). The caller applies its own
|
|
66
|
+
* policy/entitlement gating and builds the reply route. First slice covers
|
|
67
|
+
* app_mention, direct/thread messages, and slash commands.
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeSlackEvent(body, botUserId) {
|
|
70
|
+
// Slash command: a flat form body, no Events API `event`.
|
|
71
|
+
if (body.command) {
|
|
72
|
+
const channel = body.channel_id ?? "";
|
|
73
|
+
return {
|
|
74
|
+
kind: "command",
|
|
75
|
+
command: body.command,
|
|
76
|
+
text: (body.text ?? "").trim(),
|
|
77
|
+
channel,
|
|
78
|
+
senderUserId: body.user_id,
|
|
79
|
+
triggerId: body.trigger_id,
|
|
80
|
+
eventId: body.trigger_id
|
|
81
|
+
? `${body.command}:${body.user_id}:${body.trigger_id}`
|
|
82
|
+
: undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const event = body.event;
|
|
86
|
+
if (!event)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (event.type === "app_mention") {
|
|
89
|
+
const channel = String(event.channel ?? "");
|
|
90
|
+
const userText = stripMentions(String(event.text ?? ""));
|
|
91
|
+
const hasFiles = hasFilesOn(event);
|
|
92
|
+
if (!userText && !hasFiles)
|
|
93
|
+
return undefined;
|
|
94
|
+
return {
|
|
95
|
+
kind: "turn",
|
|
96
|
+
source: "app_mention",
|
|
97
|
+
channel,
|
|
98
|
+
threadTs: event.thread_ts ?? event.ts,
|
|
99
|
+
ts: event.ts,
|
|
100
|
+
userText,
|
|
101
|
+
senderUserId: event.user,
|
|
102
|
+
eventId: deriveEventId(body, event, channel),
|
|
103
|
+
hasFiles,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (event.type === "message") {
|
|
107
|
+
if (!isPlainUserMessage(event, botUserId))
|
|
108
|
+
return undefined;
|
|
109
|
+
const channel = event.channel;
|
|
110
|
+
const text = (event.text ?? "").trim();
|
|
111
|
+
const hasFiles = hasFilesOn(event);
|
|
112
|
+
if (!text && !hasFiles)
|
|
113
|
+
return undefined;
|
|
114
|
+
const isDM = event.channel_type === "im";
|
|
115
|
+
const eventId = deriveEventId(body, event, channel);
|
|
116
|
+
if (isDM) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "turn",
|
|
119
|
+
source: "direct_message",
|
|
120
|
+
channel,
|
|
121
|
+
ts: event.ts,
|
|
122
|
+
// Strip mention tokens for parity with app_mention/thread_reply — a DM
|
|
123
|
+
// that @-mentions the bot shouldn't leak the raw `<@U…>` into userText.
|
|
124
|
+
userText: stripMentions(text),
|
|
125
|
+
senderUserId: event.user,
|
|
126
|
+
eventId,
|
|
127
|
+
hasFiles,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (!event.thread_ts)
|
|
131
|
+
return undefined; // top-level channel chatter
|
|
132
|
+
// A threaded @-mention is delivered as BOTH an `app_mention` and this
|
|
133
|
+
// `message` event; app_mention handles it, so skip the duplicate here
|
|
134
|
+
// (mirrors the native Slack listener) to avoid a double response. Match
|
|
135
|
+
// both the plain `<@U…>` and labeled `<@U…|handle>` mention forms — same
|
|
136
|
+
// form set as MENTION_RE — so a labeled mention doesn't slip through.
|
|
137
|
+
if (botUserId &&
|
|
138
|
+
(text.includes(`<@${botUserId}>`) || text.includes(`<@${botUserId}|`))) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
kind: "turn",
|
|
143
|
+
source: "thread_reply",
|
|
144
|
+
channel,
|
|
145
|
+
threadTs: event.thread_ts,
|
|
146
|
+
userText: stripMentions(text),
|
|
147
|
+
senderUserId: event.user,
|
|
148
|
+
eventId,
|
|
149
|
+
hasFiles,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingress-normalize.test.d.ts","sourceRoot":"","sources":["../src/ingress-normalize.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { stripMentions, deriveEventId, isPlainUserMessage, normalizeSlackEvent, } from "./ingress-normalize.js";
|
|
3
|
+
describe("stripMentions", () => {
|
|
4
|
+
it("strips <@U…> tokens and collapses whitespace", () => {
|
|
5
|
+
expect(stripMentions("<@U123> hello world")).toBe("hello world");
|
|
6
|
+
expect(stripMentions("<@W999> hi")).toBe("hi");
|
|
7
|
+
expect(stripMentions("<@U1>")).toBe("");
|
|
8
|
+
});
|
|
9
|
+
it("strips the labeled <@U…|handle> form without leaving a |handle> fragment", () => {
|
|
10
|
+
expect(stripMentions("<@U123|alice> hello")).toBe("hello");
|
|
11
|
+
expect(stripMentions("<@W999|Bob Smith> hi")).toBe("hi");
|
|
12
|
+
expect(stripMentions("<@U1|x>")).toBe("");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("deriveEventId", () => {
|
|
16
|
+
it("prefers the Events API envelope event_id", () => {
|
|
17
|
+
expect(deriveEventId({ event_id: "Ev1" }, { ts: "100.1" }, "C1")).toBe("Ev1");
|
|
18
|
+
});
|
|
19
|
+
it("falls back to client_msg_id, then channel:ts, then undefined", () => {
|
|
20
|
+
expect(deriveEventId({}, { client_msg_id: "cm1", ts: "100.1" }, "C1")).toBe("cm1");
|
|
21
|
+
expect(deriveEventId({}, { ts: "100.1" }, "C1")).toBe("C1:100.1");
|
|
22
|
+
expect(deriveEventId({}, {}, "C1")).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("isPlainUserMessage", () => {
|
|
26
|
+
it("accepts a plain user message", () => {
|
|
27
|
+
expect(isPlainUserMessage({ channel: "C1", text: "hi", user: "U1" }, "BOT")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it("rejects subtypes (except file_share), bot echoes, and app-only posts", () => {
|
|
30
|
+
expect(isPlainUserMessage({ channel: "C1", text: "x", subtype: "channel_join" }, "BOT")).toBe(false);
|
|
31
|
+
expect(isPlainUserMessage({ channel: "C1", text: "x", user: "BOT" }, "BOT")).toBe(false);
|
|
32
|
+
expect(isPlainUserMessage({ channel: "C1", text: "x", bot_id: "B1" }, "BOT")).toBe(false);
|
|
33
|
+
expect(isPlainUserMessage({ channel: "C1", text: "x", subtype: "file_share", user: "U1" }, "BOT")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("normalizeSlackEvent", () => {
|
|
37
|
+
it("maps an app_mention to a turn with stripped text + thread anchor", () => {
|
|
38
|
+
const n = normalizeSlackEvent({
|
|
39
|
+
event_id: "Ev1",
|
|
40
|
+
event: {
|
|
41
|
+
type: "app_mention",
|
|
42
|
+
channel: "C1",
|
|
43
|
+
text: "<@U1> hello",
|
|
44
|
+
user: "U2",
|
|
45
|
+
ts: "100.1",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
expect(n).toEqual({
|
|
49
|
+
kind: "turn",
|
|
50
|
+
source: "app_mention",
|
|
51
|
+
channel: "C1",
|
|
52
|
+
threadTs: "100.1",
|
|
53
|
+
ts: "100.1",
|
|
54
|
+
userText: "hello",
|
|
55
|
+
senderUserId: "U2",
|
|
56
|
+
eventId: "Ev1",
|
|
57
|
+
hasFiles: false,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it("drops an empty mention with no files", () => {
|
|
61
|
+
expect(normalizeSlackEvent({
|
|
62
|
+
event: { type: "app_mention", channel: "C1", text: "<@U1>", ts: "1" },
|
|
63
|
+
})).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
it("maps a DM message to a direct_message turn", () => {
|
|
66
|
+
const n = normalizeSlackEvent({
|
|
67
|
+
event_id: "Ev2",
|
|
68
|
+
event: {
|
|
69
|
+
type: "message",
|
|
70
|
+
channel: "D1",
|
|
71
|
+
channel_type: "im",
|
|
72
|
+
text: "hi there",
|
|
73
|
+
user: "U2",
|
|
74
|
+
ts: "100.2",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(n).toMatchObject({
|
|
78
|
+
kind: "turn",
|
|
79
|
+
source: "direct_message",
|
|
80
|
+
channel: "D1",
|
|
81
|
+
userText: "hi there",
|
|
82
|
+
ts: "100.2",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
it("maps a threaded reply to a thread_reply turn with stripped text", () => {
|
|
86
|
+
const n = normalizeSlackEvent({
|
|
87
|
+
event: {
|
|
88
|
+
type: "message",
|
|
89
|
+
channel: "C1",
|
|
90
|
+
text: "<@U1> reply",
|
|
91
|
+
user: "U2",
|
|
92
|
+
ts: "100.3",
|
|
93
|
+
thread_ts: "100.0",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(n).toMatchObject({
|
|
97
|
+
kind: "turn",
|
|
98
|
+
source: "thread_reply",
|
|
99
|
+
threadTs: "100.0",
|
|
100
|
+
userText: "reply",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
it("skips a threaded reply that @-mentions the bot (app_mention handles it — no double turn)", () => {
|
|
104
|
+
// Slack delivers a threaded @-mention as BOTH an app_mention and a
|
|
105
|
+
// message event; with the bot's own id known, the message branch must
|
|
106
|
+
// defer to app_mention so the managed path doesn't respond twice.
|
|
107
|
+
expect(normalizeSlackEvent({
|
|
108
|
+
event: {
|
|
109
|
+
type: "message",
|
|
110
|
+
channel: "C1",
|
|
111
|
+
text: "<@BOT> reply",
|
|
112
|
+
user: "U2",
|
|
113
|
+
ts: "100.4",
|
|
114
|
+
thread_ts: "100.0",
|
|
115
|
+
},
|
|
116
|
+
}, "BOT")).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
it("skips a threaded reply that mentions the bot in the labeled <@U|handle> form", () => {
|
|
119
|
+
expect(normalizeSlackEvent({
|
|
120
|
+
event: {
|
|
121
|
+
type: "message",
|
|
122
|
+
channel: "C1",
|
|
123
|
+
text: "<@BOT|assistant> reply",
|
|
124
|
+
user: "U2",
|
|
125
|
+
ts: "100.7",
|
|
126
|
+
thread_ts: "100.0",
|
|
127
|
+
},
|
|
128
|
+
}, "BOT")).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
it("still delivers a threaded reply that mentions a DIFFERENT user", () => {
|
|
131
|
+
expect(normalizeSlackEvent({
|
|
132
|
+
event: {
|
|
133
|
+
type: "message",
|
|
134
|
+
channel: "C1",
|
|
135
|
+
text: "<@U9> reply",
|
|
136
|
+
user: "U2",
|
|
137
|
+
ts: "100.5",
|
|
138
|
+
thread_ts: "100.0",
|
|
139
|
+
},
|
|
140
|
+
}, "BOT")).toMatchObject({ source: "thread_reply", userText: "reply" });
|
|
141
|
+
});
|
|
142
|
+
it("strips mention tokens from a DM turn (parity with app_mention/thread_reply)", () => {
|
|
143
|
+
const n = normalizeSlackEvent({
|
|
144
|
+
event: {
|
|
145
|
+
type: "message",
|
|
146
|
+
channel: "D1",
|
|
147
|
+
channel_type: "im",
|
|
148
|
+
text: "<@U1> hi there",
|
|
149
|
+
user: "U2",
|
|
150
|
+
ts: "100.6",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
expect(n).toMatchObject({ source: "direct_message", userText: "hi there" });
|
|
154
|
+
});
|
|
155
|
+
it("drops top-level channel chatter (no thread_ts) and bot echoes", () => {
|
|
156
|
+
expect(normalizeSlackEvent({
|
|
157
|
+
event: {
|
|
158
|
+
type: "message",
|
|
159
|
+
channel: "C1",
|
|
160
|
+
text: "hi",
|
|
161
|
+
user: "U2",
|
|
162
|
+
ts: "1",
|
|
163
|
+
},
|
|
164
|
+
})).toBeUndefined();
|
|
165
|
+
expect(normalizeSlackEvent({
|
|
166
|
+
event: { type: "message", channel: "C1", text: "x", bot_id: "B1" },
|
|
167
|
+
})).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
it("maps a slash command", () => {
|
|
170
|
+
const n = normalizeSlackEvent({
|
|
171
|
+
command: "/triage",
|
|
172
|
+
text: "now",
|
|
173
|
+
channel_id: "C1",
|
|
174
|
+
user_id: "U2",
|
|
175
|
+
trigger_id: "T1",
|
|
176
|
+
});
|
|
177
|
+
expect(n).toEqual({
|
|
178
|
+
kind: "command",
|
|
179
|
+
command: "/triage",
|
|
180
|
+
text: "now",
|
|
181
|
+
channel: "C1",
|
|
182
|
+
senderUserId: "U2",
|
|
183
|
+
triggerId: "T1",
|
|
184
|
+
eventId: "/triage:U2:T1",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { InteractionEvent, IncomingReaction, IncomingModalSubmit, IncomingModalClose } from "@copilotkit/channels";
|
|
2
|
+
import type { ConversationKey } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Stable string key shared by ingress (onTurn) and interaction decoding so the
|
|
5
|
+
* bot's awaitChoice waiters resolve. Both paths MUST derive the conversation key
|
|
6
|
+
* from this single helper — a mismatch silently strands the waiter.
|
|
7
|
+
*/
|
|
8
|
+
export declare function conversationKeyOf(key: ConversationKey): string;
|
|
9
|
+
/**
|
|
10
|
+
* Decode a Slack `block_actions` payload into a bot `InteractionEvent`.
|
|
11
|
+
*
|
|
12
|
+
* Carries ONLY the opaque minted action id (`ck:...`) plus the tiny, non-sensitive
|
|
13
|
+
* button/option value — there is NO resume-data smuggling through the payload.
|
|
14
|
+
* Durability rides on the ActionStore keyed by that opaque id, not on what Slack
|
|
15
|
+
* round-trips back to us.
|
|
16
|
+
*/
|
|
17
|
+
export declare function decodeInteraction(raw: unknown): InteractionEvent | undefined;
|
|
18
|
+
/** Decode a Slack `reaction_added`/`reaction_removed` event into an `IncomingReaction`. */
|
|
19
|
+
export declare function decodeReaction(event: unknown, added: boolean): IncomingReaction | undefined;
|
|
20
|
+
/** Decode a Slack `view_submission` payload into an `IncomingModalSubmit`. */
|
|
21
|
+
export declare function decodeViewSubmission(view: unknown, user?: {
|
|
22
|
+
id: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
}): IncomingModalSubmit;
|
|
25
|
+
/** Decode a Slack `view_closed` payload into an `IncomingModalClose`. */
|
|
26
|
+
export declare function decodeViewClosed(view: unknown, user?: {
|
|
27
|
+
id: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
}): IncomingModalClose;
|
|
30
|
+
//# sourceMappingURL=interaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interaction.d.ts","sourceRoot":"","sources":["../src/interaction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EACnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAE/D;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAE9D;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CA2F5E;AAkBD,2FAA2F;AAC3F,wBAAgB,cAAc,CAC5B,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,OAAO,GACb,gBAAgB,GAAG,SAAS,CAgC9B;AAgGD,8EAA8E;AAC9E,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACnC,mBAAmB,CAarB;AAED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACnC,kBAAkB,CAYpB"}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { DM_SCOPE } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Stable string key shared by ingress (onTurn) and interaction decoding so the
|
|
4
|
+
* bot's awaitChoice waiters resolve. Both paths MUST derive the conversation key
|
|
5
|
+
* from this single helper — a mismatch silently strands the waiter.
|
|
6
|
+
*/
|
|
7
|
+
export function conversationKeyOf(key) {
|
|
8
|
+
return `${key.channelId}::${key.scope}`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Decode a Slack `block_actions` payload into a bot `InteractionEvent`.
|
|
12
|
+
*
|
|
13
|
+
* Carries ONLY the opaque minted action id (`ck:...`) plus the tiny, non-sensitive
|
|
14
|
+
* button/option value — there is NO resume-data smuggling through the payload.
|
|
15
|
+
* Durability rides on the ActionStore keyed by that opaque id, not on what Slack
|
|
16
|
+
* round-trips back to us.
|
|
17
|
+
*/
|
|
18
|
+
export function decodeInteraction(raw) {
|
|
19
|
+
const body = raw;
|
|
20
|
+
if (body.type !== "block_actions")
|
|
21
|
+
return undefined;
|
|
22
|
+
const action = body.actions?.[0];
|
|
23
|
+
if (!action?.action_id)
|
|
24
|
+
return undefined;
|
|
25
|
+
const channelId = body.channel?.id ?? body.container?.channel_id;
|
|
26
|
+
if (!channelId)
|
|
27
|
+
return undefined;
|
|
28
|
+
// An EXPLICIT thread ts means the click happened inside a thread — including
|
|
29
|
+
// an assistant-pane DM, which is threaded even though its channel id starts
|
|
30
|
+
// with "D". Only fall back to the message's own ts (a thread root) for the
|
|
31
|
+
// scope, never conflate the two.
|
|
32
|
+
const explicitThreadTs = body.message?.thread_ts ?? body.container?.thread_ts;
|
|
33
|
+
const threadTs = explicitThreadTs ?? body.message?.ts ?? body.container?.message_ts;
|
|
34
|
+
const isDm = channelId.startsWith("D");
|
|
35
|
+
// Scope MUST match what the listener emits per turn (see assistant.ts /
|
|
36
|
+
// adapter.ts), or the HITL `awaitChoice` waiter is stranded and the run
|
|
37
|
+
// never resumes: the thread ts for ANY threaded conversation (assistant-pane
|
|
38
|
+
// DMs included), and DM_SCOPE only for a genuinely unthreaded DM.
|
|
39
|
+
const scope = explicitThreadTs
|
|
40
|
+
? explicitThreadTs
|
|
41
|
+
: isDm
|
|
42
|
+
? DM_SCOPE
|
|
43
|
+
: (threadTs ?? "");
|
|
44
|
+
const conversationKey = conversationKeyOf({ channelId, scope });
|
|
45
|
+
const replyTarget = {
|
|
46
|
+
channel: channelId,
|
|
47
|
+
threadTs: isDm && !explicitThreadTs ? undefined : threadTs,
|
|
48
|
+
};
|
|
49
|
+
// Tiny, non-sensitive value: the clicked button's value (or selected option
|
|
50
|
+
// value), JSON-parsed if it round-trips, otherwise the raw string. A
|
|
51
|
+
// multi_static_select reports `selected_options` (an array) → a `string[]`.
|
|
52
|
+
let value;
|
|
53
|
+
if (action.selected_options) {
|
|
54
|
+
value = action.selected_options.map((o) => parseValue(o.value));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
value = parseValue(action.value ?? action.selected_option?.value);
|
|
58
|
+
}
|
|
59
|
+
const user = body.user?.id
|
|
60
|
+
? { id: body.user.id, name: body.user.name ?? body.user.username }
|
|
61
|
+
: undefined;
|
|
62
|
+
// The picker message's ts: an onClick `thread.update(message.ref, …)`
|
|
63
|
+
// targets this message in place (the adapter's `update` reads `channel`
|
|
64
|
+
// off the ref).
|
|
65
|
+
const messageTs = body.message?.ts ?? body.container?.message_ts;
|
|
66
|
+
const messageRef = messageTs
|
|
67
|
+
? { id: messageTs, channel: channelId }
|
|
68
|
+
: undefined;
|
|
69
|
+
// Stable per-click id for inbound dedup: the channel + picker message ts +
|
|
70
|
+
// the action's own ts uniquely identify one click. Fall back to trigger_id
|
|
71
|
+
// (single-use per interaction) when those refs are absent. Undefined only if
|
|
72
|
+
// neither is available — never fabricate (that would defeat dedup).
|
|
73
|
+
const eventId = channelId && messageTs && action.action_ts
|
|
74
|
+
? `${channelId}:${messageTs}:${action.action_ts}`
|
|
75
|
+
: body.trigger_id;
|
|
76
|
+
return {
|
|
77
|
+
id: action.action_id,
|
|
78
|
+
conversationKey,
|
|
79
|
+
replyTarget,
|
|
80
|
+
value,
|
|
81
|
+
user,
|
|
82
|
+
messageRef,
|
|
83
|
+
triggerId: body.trigger_id,
|
|
84
|
+
eventId,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** JSON-parse a control value so non-string option values round-trip; else keep the raw string. */
|
|
88
|
+
function parseValue(raw) {
|
|
89
|
+
if (typeof raw !== "string")
|
|
90
|
+
return raw;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Decode a Slack `reaction_added`/`reaction_removed` event into an `IncomingReaction`. */
|
|
99
|
+
export function decodeReaction(event, added) {
|
|
100
|
+
const e = event;
|
|
101
|
+
if (e.item?.type !== "message")
|
|
102
|
+
return undefined;
|
|
103
|
+
const channel = e.item.channel;
|
|
104
|
+
const ts = e.item.ts;
|
|
105
|
+
if (!channel || !ts || !e.reaction)
|
|
106
|
+
return undefined;
|
|
107
|
+
const scope = channel.startsWith("D") ? DM_SCOPE : ts;
|
|
108
|
+
return {
|
|
109
|
+
rawEmoji: e.reaction,
|
|
110
|
+
added,
|
|
111
|
+
user: e.user ? { id: e.user } : undefined,
|
|
112
|
+
conversationKey: conversationKeyOf({ channelId: channel, scope }),
|
|
113
|
+
// Thread the reply under the reacted message (channel/thread reactions);
|
|
114
|
+
// DMs stay flat. A handler replying via thread.post/runAgent must land
|
|
115
|
+
// under the reacted message, not at the channel root. Carry the reactor id
|
|
116
|
+
// as `recipientUserId` (parity with onTurn): `chat.startStream` REQUIRES
|
|
117
|
+
// `recipient_user_id` when streaming to a channel, so without it the
|
|
118
|
+
// adapter's first native channel stream for this target fails — and the
|
|
119
|
+
// adapter then flips its own `nativeStreamingOk` to false, downgrading the
|
|
120
|
+
// whole workspace to the legacy transport.
|
|
121
|
+
replyTarget: {
|
|
122
|
+
channel,
|
|
123
|
+
...(scope === DM_SCOPE ? {} : { threadTs: ts }),
|
|
124
|
+
...(e.user ? { recipientUserId: e.user } : {}),
|
|
125
|
+
},
|
|
126
|
+
messageId: ts,
|
|
127
|
+
// Update-capable ref (channel + ts) so an onReaction handler can swap the
|
|
128
|
+
// reacted message's UI in place via thread.update.
|
|
129
|
+
messageRef: { id: ts, channel },
|
|
130
|
+
threadId: ts,
|
|
131
|
+
raw: event,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Flatten a Slack view's `state.values` to a flat `fieldId → value` map. The
|
|
136
|
+
* modal vocabulary names every block id == action id (the field id), so for
|
|
137
|
+
* each block we take the inner element keyed by that same block id, falling
|
|
138
|
+
* back to the first element. Text inputs expose `value`; selects/radios expose
|
|
139
|
+
* `selected_option.value`.
|
|
140
|
+
*/
|
|
141
|
+
function flattenViewValues(view) {
|
|
142
|
+
const out = {};
|
|
143
|
+
const values = view.state?.values ?? {};
|
|
144
|
+
for (const blockId of Object.keys(values)) {
|
|
145
|
+
const inner = values[blockId];
|
|
146
|
+
const el = inner[blockId] ?? Object.values(inner)[0];
|
|
147
|
+
if (!el)
|
|
148
|
+
continue;
|
|
149
|
+
out[blockId] = el.value ?? el.selected_option?.value;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Decode a view's `private_metadata` into the conversation context + the
|
|
155
|
+
* author's original metadata. When the string is a `__cpk` envelope (stamped at
|
|
156
|
+
* open time), return the derived `conversationKey`/`replyTarget` and restore the
|
|
157
|
+
* author's `pm`. Otherwise (absent, non-JSON, or a plain author string from a
|
|
158
|
+
* modal opened some other way) preserve back-compat: pass the raw string
|
|
159
|
+
* through as `privateMetadata`, with no conversationKey/replyTarget.
|
|
160
|
+
*/
|
|
161
|
+
function decodeModalContext(privateMetadata) {
|
|
162
|
+
if (privateMetadata === undefined)
|
|
163
|
+
return {};
|
|
164
|
+
let parsed;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(privateMetadata);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Non-JSON string → treat as a plain author privateMetadata.
|
|
170
|
+
return { privateMetadata };
|
|
171
|
+
}
|
|
172
|
+
if (!parsed ||
|
|
173
|
+
typeof parsed !== "object" ||
|
|
174
|
+
typeof parsed.__cpk !== "object" ||
|
|
175
|
+
parsed.__cpk === null ||
|
|
176
|
+
typeof parsed.__cpk.channel !== "string") {
|
|
177
|
+
// Valid JSON but not our envelope → treat the original string as plain
|
|
178
|
+
// author metadata (e.g. a modal opened with a JSON private_metadata).
|
|
179
|
+
return { privateMetadata };
|
|
180
|
+
}
|
|
181
|
+
const env = parsed;
|
|
182
|
+
const channelId = env.__cpk.channel;
|
|
183
|
+
const threadTs = env.__cpk.threadTs;
|
|
184
|
+
const scope = threadTs ?? DM_SCOPE;
|
|
185
|
+
return {
|
|
186
|
+
conversationKey: conversationKeyOf({ channelId, scope }),
|
|
187
|
+
replyTarget: { channel: channelId, ...(threadTs ? { threadTs } : {}) },
|
|
188
|
+
privateMetadata: env.pm,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Decode a Slack `view_submission` payload into an `IncomingModalSubmit`. */
|
|
192
|
+
export function decodeViewSubmission(view, user) {
|
|
193
|
+
const v = view;
|
|
194
|
+
const ctx = decodeModalContext(v.private_metadata);
|
|
195
|
+
return {
|
|
196
|
+
callbackId: v.callback_id ?? "",
|
|
197
|
+
values: flattenViewValues(v),
|
|
198
|
+
user,
|
|
199
|
+
privateMetadata: ctx.privateMetadata,
|
|
200
|
+
...(ctx.conversationKey ? { conversationKey: ctx.conversationKey } : {}),
|
|
201
|
+
...(ctx.replyTarget ? { replyTarget: ctx.replyTarget } : {}),
|
|
202
|
+
platform: "slack",
|
|
203
|
+
raw: view,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Decode a Slack `view_closed` payload into an `IncomingModalClose`. */
|
|
207
|
+
export function decodeViewClosed(view, user) {
|
|
208
|
+
const v = view;
|
|
209
|
+
const ctx = decodeModalContext(v.private_metadata);
|
|
210
|
+
return {
|
|
211
|
+
callbackId: v.callback_id ?? "",
|
|
212
|
+
user,
|
|
213
|
+
privateMetadata: ctx.privateMetadata,
|
|
214
|
+
...(ctx.conversationKey ? { conversationKey: ctx.conversationKey } : {}),
|
|
215
|
+
...(ctx.replyTarget ? { replyTarget: ctx.replyTarget } : {}),
|
|
216
|
+
platform: "slack",
|
|
217
|
+
raw: view,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interaction.test.d.ts","sourceRoot":"","sources":["../src/interaction.test.ts"],"names":[],"mappings":""}
|