@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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/dist/__tests__/assistant.test.d.ts +2 -0
  4. package/dist/__tests__/assistant.test.d.ts.map +1 -0
  5. package/dist/__tests__/assistant.test.js +153 -0
  6. package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
  7. package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
  8. package/dist/__tests__/auto-close-streaming.test.js +139 -0
  9. package/dist/__tests__/built-ins.test.d.ts +2 -0
  10. package/dist/__tests__/built-ins.test.d.ts.map +1 -0
  11. package/dist/__tests__/built-ins.test.js +67 -0
  12. package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
  13. package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
  14. package/dist/__tests__/chunked-message-stream.test.js +226 -0
  15. package/dist/__tests__/conversation-store.test.d.ts +2 -0
  16. package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
  17. package/dist/__tests__/conversation-store.test.js +209 -0
  18. package/dist/__tests__/download-files.test.d.ts +2 -0
  19. package/dist/__tests__/download-files.test.d.ts.map +1 -0
  20. package/dist/__tests__/download-files.test.js +142 -0
  21. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  22. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  23. package/dist/__tests__/ephemeral.test.js +85 -0
  24. package/dist/__tests__/event-renderer.test.d.ts +2 -0
  25. package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
  26. package/dist/__tests__/event-renderer.test.js +521 -0
  27. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
  28. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
  29. package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
  30. package/dist/__tests__/message-stream.test.d.ts +2 -0
  31. package/dist/__tests__/message-stream.test.d.ts.map +1 -0
  32. package/dist/__tests__/message-stream.test.js +116 -0
  33. package/dist/__tests__/native-renderer.test.d.ts +2 -0
  34. package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
  35. package/dist/__tests__/native-renderer.test.js +236 -0
  36. package/dist/__tests__/native-stream.test.d.ts +2 -0
  37. package/dist/__tests__/native-stream.test.d.ts.map +1 -0
  38. package/dist/__tests__/native-stream.test.js +235 -0
  39. package/dist/__tests__/reactions.test.d.ts +2 -0
  40. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  41. package/dist/__tests__/reactions.test.js +193 -0
  42. package/dist/__tests__/slack-listener.test.d.ts +2 -0
  43. package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
  44. package/dist/__tests__/slack-listener.test.js +559 -0
  45. package/dist/__tests__/views.test.d.ts +2 -0
  46. package/dist/__tests__/views.test.d.ts.map +1 -0
  47. package/dist/__tests__/views.test.js +143 -0
  48. package/dist/adapter.d.ts +188 -0
  49. package/dist/adapter.d.ts.map +1 -0
  50. package/dist/adapter.js +859 -0
  51. package/dist/adapter.test.d.ts +2 -0
  52. package/dist/adapter.test.d.ts.map +1 -0
  53. package/dist/adapter.test.js +368 -0
  54. package/dist/assistant.d.ts +19 -0
  55. package/dist/assistant.d.ts.map +1 -0
  56. package/dist/assistant.js +124 -0
  57. package/dist/auto-close-streaming.d.ts +62 -0
  58. package/dist/auto-close-streaming.d.ts.map +1 -0
  59. package/dist/auto-close-streaming.js +273 -0
  60. package/dist/built-in-context.d.ts +22 -0
  61. package/dist/built-in-context.d.ts.map +1 -0
  62. package/dist/built-in-context.js +65 -0
  63. package/dist/built-in-tools.d.ts +23 -0
  64. package/dist/built-in-tools.d.ts.map +1 -0
  65. package/dist/built-in-tools.js +45 -0
  66. package/dist/chunked-message-stream.d.ts +67 -0
  67. package/dist/chunked-message-stream.d.ts.map +1 -0
  68. package/dist/chunked-message-stream.js +134 -0
  69. package/dist/codec.d.ts +18 -0
  70. package/dist/codec.d.ts.map +1 -0
  71. package/dist/codec.js +18 -0
  72. package/dist/codec.test.d.ts +2 -0
  73. package/dist/codec.test.d.ts.map +1 -0
  74. package/dist/codec.test.js +19 -0
  75. package/dist/conversation-store.d.ts +92 -0
  76. package/dist/conversation-store.d.ts.map +1 -0
  77. package/dist/conversation-store.js +196 -0
  78. package/dist/download-files.d.ts +59 -0
  79. package/dist/download-files.d.ts.map +1 -0
  80. package/dist/download-files.js +116 -0
  81. package/dist/event-renderer.d.ts +98 -0
  82. package/dist/event-renderer.d.ts.map +1 -0
  83. package/dist/event-renderer.js +493 -0
  84. package/dist/index.d.ts +34 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +23 -0
  87. package/dist/ingress-normalize.d.ts +75 -0
  88. package/dist/ingress-normalize.d.ts.map +1 -0
  89. package/dist/ingress-normalize.js +153 -0
  90. package/dist/ingress-normalize.test.d.ts +2 -0
  91. package/dist/ingress-normalize.test.d.ts.map +1 -0
  92. package/dist/ingress-normalize.test.js +187 -0
  93. package/dist/interaction.d.ts +30 -0
  94. package/dist/interaction.d.ts.map +1 -0
  95. package/dist/interaction.js +219 -0
  96. package/dist/interaction.test.d.ts +2 -0
  97. package/dist/interaction.test.d.ts.map +1 -0
  98. package/dist/interaction.test.js +137 -0
  99. package/dist/markdown-to-mrkdwn.d.ts +21 -0
  100. package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
  101. package/dist/markdown-to-mrkdwn.js +99 -0
  102. package/dist/message-stream.d.ts +44 -0
  103. package/dist/message-stream.d.ts.map +1 -0
  104. package/dist/message-stream.js +70 -0
  105. package/dist/native-stream.d.ts +131 -0
  106. package/dist/native-stream.d.ts.map +1 -0
  107. package/dist/native-stream.js +208 -0
  108. package/dist/render/block-kit.d.ts +35 -0
  109. package/dist/render/block-kit.d.ts.map +1 -0
  110. package/dist/render/block-kit.js +399 -0
  111. package/dist/render/block-kit.test.d.ts +2 -0
  112. package/dist/render/block-kit.test.d.ts.map +1 -0
  113. package/dist/render/block-kit.test.js +289 -0
  114. package/dist/render/budget.d.ts +24 -0
  115. package/dist/render/budget.d.ts.map +1 -0
  116. package/dist/render/budget.js +30 -0
  117. package/dist/render/budget.test.d.ts +2 -0
  118. package/dist/render/budget.test.d.ts.map +1 -0
  119. package/dist/render/budget.test.js +49 -0
  120. package/dist/render/modal.d.ts +13 -0
  121. package/dist/render/modal.d.ts.map +1 -0
  122. package/dist/render/modal.js +108 -0
  123. package/dist/render/transport.d.ts +42 -0
  124. package/dist/render/transport.d.ts.map +1 -0
  125. package/dist/render/transport.js +1 -0
  126. package/dist/render.d.ts +23 -0
  127. package/dist/render.d.ts.map +1 -0
  128. package/dist/render.js +20 -0
  129. package/dist/sanitizing-http-agent.d.ts +28 -0
  130. package/dist/sanitizing-http-agent.d.ts.map +1 -0
  131. package/dist/sanitizing-http-agent.js +51 -0
  132. package/dist/slack-listener.d.ts +73 -0
  133. package/dist/slack-listener.d.ts.map +1 -0
  134. package/dist/slack-listener.js +126 -0
  135. package/dist/slack-listener.test.d.ts +2 -0
  136. package/dist/slack-listener.test.d.ts.map +1 -0
  137. package/dist/slack-listener.test.js +115 -0
  138. package/dist/types.d.ts +153 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +20 -0
  141. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ingress-normalize.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=interaction.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interaction.test.d.ts","sourceRoot":"","sources":["../src/interaction.test.ts"],"names":[],"mappings":""}