@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
package/dist/codec.js ADDED
@@ -0,0 +1,18 @@
1
+ import { renderSlackMessage } from "./render/block-kit.js";
2
+ import { normalizeSlackEvent } from "./ingress-normalize.js";
3
+ /**
4
+ * Pure Slack codec — both directions, no Bolt and no Slack credentials. Shared
5
+ * by the local Slack adapter and (via the Connector Outbox / webhook ingress,
6
+ * OSS-362/363) the managed path, so platform semantics live in one place:
7
+ *
8
+ * - `renderEgress`: IR → Block Kit.
9
+ * - `normalizeIngress`: raw Slack payload (Events API envelope or slash-command
10
+ * body) → platform-neutral ingress event. `normalizeIngress` is a Slack
11
+ * extension on top of the generic egress-only {@link PlatformCodec}; the
12
+ * caller applies its own policy/entitlement gating and builds the route.
13
+ */
14
+ export const slackCodec = {
15
+ platform: "slack",
16
+ renderEgress: renderSlackMessage,
17
+ normalizeIngress: normalizeSlackEvent,
18
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=codec.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.test.d.ts","sourceRoot":"","sources":["../src/codec.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { slackCodec } from "./codec.js";
3
+ describe("slackCodec", () => {
4
+ it("is the slack platform codec", () => {
5
+ expect(slackCodec.platform).toBe("slack");
6
+ });
7
+ it("renders IR to Slack Block Kit via the shared pure renderer", () => {
8
+ const ir = [
9
+ {
10
+ type: "section",
11
+ props: { children: [{ type: "text", props: { value: "hi" } }] },
12
+ },
13
+ ];
14
+ const out = slackCodec.renderEgress(ir);
15
+ expect(out.blocks).toEqual([
16
+ { type: "section", text: { type: "mrkdwn", text: "hi" } },
17
+ ]);
18
+ });
19
+ });
@@ -0,0 +1,92 @@
1
+ import type { HttpAgent } from "@ag-ui/client";
2
+ import type { WebClient } from "@slack/web-api";
3
+ import type { ConversationKey, ReplyTarget } from "./types.js";
4
+ import { type FileDeliveryConfig } from "./download-files.js";
5
+ /**
6
+ * One ongoing Slack conversation with the bot. Built fresh per turn from
7
+ * Slack's stored history — Slack itself is our durable storage, so the
8
+ * bridge keeps no separate state.
9
+ */
10
+ export interface AgentSession {
11
+ threadId: string;
12
+ agent: HttpAgent;
13
+ replyTarget: ReplyTarget;
14
+ }
15
+ /**
16
+ * Backed entirely by Slack: every turn pulls the current thread (or DM)
17
+ * history via the Web API, translates it into the AG-UI message shape,
18
+ * and hands a fresh HttpAgent to the turn-runner. Bridge restarts are
19
+ * automatically robust because the bridge holds no state to lose.
20
+ *
21
+ * The only in-memory state is a participation cache — a `Set` of thread
22
+ * keys the bot has already replied to. It's a pure performance hint
23
+ * (avoids one Slack API call per thread-reply event in active
24
+ * conversations) and is rebuilt lazily after a restart by the same
25
+ * Slack lookup that already produces the answer.
26
+ */
27
+ export declare class SlackConversationStore {
28
+ private readonly client;
29
+ private readonly botUserId;
30
+ /** Bot token used to download uploaded files from their `url_private`. */
31
+ private readonly botToken;
32
+ private readonly filesConfig;
33
+ /** Stable threadIds → conversation keys ("channelId::scope"). */
34
+ private readonly participated;
35
+ constructor(args: {
36
+ client: WebClient;
37
+ botUserId: string;
38
+ botToken: string;
39
+ files?: FileDeliveryConfig;
40
+ });
41
+ private keyOf;
42
+ /**
43
+ * A *fresh* AG-UI threadId per turn.
44
+ *
45
+ * We deliberately do NOT reuse a stable per-conversation threadId.
46
+ * Slack is our durable history (every turn is rebuilt from it via
47
+ * {@link fetchHistory}), so the LangGraph thread only needs to live for
48
+ * the duration of one turn. Reusing a stable threadId across turns lets
49
+ * the server-side thread accumulate the agent's *internal* messages
50
+ * (tool calls/results that never round-trip through Slack); on the next
51
+ * turn `@ag-ui/langgraph` regenerates state and the now-larger existing
52
+ * thread no longer matches the incoming history, surfacing as a
53
+ * "Message not found" failure. A unique thread per turn sidesteps that
54
+ * entirely. Restart-recovery for interrupts still works because the
55
+ * picker carries its turn's threadId in Slack message metadata (see
56
+ * `recoverFromStaleClick`).
57
+ */
58
+ private newThreadId;
59
+ /**
60
+ * Does the bot own this thread? "Ownership" means the bot has at least
61
+ * one prior reply in it (which is the natural definition since the bot
62
+ * only ever replies when @-mentioned or in a thread it already owns).
63
+ *
64
+ * Cached in-process; the first call after a restart is one Slack API
65
+ * round-trip, subsequent calls are O(1).
66
+ */
67
+ has(key: ConversationKey): Promise<boolean>;
68
+ /**
69
+ * Build a fresh AgentSession for this conversation by fetching its
70
+ * Slack history and translating it into the AG-UI message shape.
71
+ * `makeAgent` produces the HttpAgent; we set its `messages` to the
72
+ * translated history.
73
+ */
74
+ getOrCreate(key: ConversationKey, replyTarget: ReplyTarget, makeAgent: (threadId: string) => HttpAgent): Promise<AgentSession>;
75
+ /**
76
+ * No-op: the next call rebuilds from Slack, so there's nothing to
77
+ * persist. Kept on the API for symmetry with the previous interface
78
+ * (the turn-runner still calls it; we just do nothing).
79
+ */
80
+ save(_key: ConversationKey, _session: AgentSession): void;
81
+ /** Fetch Slack history for either a thread or a DM and translate. */
82
+ private fetchHistory;
83
+ /**
84
+ * Translate a chronological run of Slack messages to AG-UI messages,
85
+ * folding (a) our bot's chunked replies into a single assistant turn,
86
+ * (b) skipping our `:wrench:` / `:white_check_mark:` status messages
87
+ * and the `_thinking…_` placeholder, and (c) stripping `<@bot>`
88
+ * mention tokens from user text.
89
+ */
90
+ private translate;
91
+ }
92
+ //# sourceMappingURL=conversation-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation-store.d.ts","sourceRoot":"","sources":["../src/conversation-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE/D,OAAO,EAGL,KAAK,kBAAkB,EAExB,MAAM,qBAAqB,CAAC;AAE7B;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,CAAC;IACjB,WAAW,EAAE,WAAW,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,sBAAsB;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAY;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;gBAEtC,IAAI,EAAE;QAChB,MAAM,EAAE,SAAS,CAAC;QAClB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,CAAC,EAAE,kBAAkB,CAAC;KAC5B;IAOD,OAAO,CAAC,KAAK;IAIb;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,WAAW;IAInB;;;;;;;OAOG;IACG,GAAG,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,OAAO,CAAC;IAqBjD;;;;;OAKG;IACG,WAAW,CACf,GAAG,EAAE,eAAe,EACpB,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,SAAS,GACzC,OAAO,CAAC,YAAY,CAAC;IAWxB;;;;OAIG;IACH,IAAI,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,EAAE,YAAY,GAAG,IAAI;IAIzD,qEAAqE;YACvD,YAAY;IAsB1B;;;;;;OAMG;YACW,SAAS;CA4DxB"}
@@ -0,0 +1,196 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DM_SCOPE } from "./types.js";
3
+ import { buildFileContentParts, } from "./download-files.js";
4
+ /**
5
+ * Backed entirely by Slack: every turn pulls the current thread (or DM)
6
+ * history via the Web API, translates it into the AG-UI message shape,
7
+ * and hands a fresh HttpAgent to the turn-runner. Bridge restarts are
8
+ * automatically robust because the bridge holds no state to lose.
9
+ *
10
+ * The only in-memory state is a participation cache — a `Set` of thread
11
+ * keys the bot has already replied to. It's a pure performance hint
12
+ * (avoids one Slack API call per thread-reply event in active
13
+ * conversations) and is rebuilt lazily after a restart by the same
14
+ * Slack lookup that already produces the answer.
15
+ */
16
+ export class SlackConversationStore {
17
+ client;
18
+ botUserId;
19
+ /** Bot token used to download uploaded files from their `url_private`. */
20
+ botToken;
21
+ filesConfig;
22
+ /** Stable threadIds → conversation keys ("channelId::scope"). */
23
+ participated = new Set();
24
+ constructor(args) {
25
+ this.client = args.client;
26
+ this.botUserId = args.botUserId;
27
+ this.botToken = args.botToken;
28
+ this.filesConfig = args.files ?? {};
29
+ }
30
+ keyOf(k) {
31
+ return `${k.channelId}::${k.scope}`;
32
+ }
33
+ /**
34
+ * A *fresh* AG-UI threadId per turn.
35
+ *
36
+ * We deliberately do NOT reuse a stable per-conversation threadId.
37
+ * Slack is our durable history (every turn is rebuilt from it via
38
+ * {@link fetchHistory}), so the LangGraph thread only needs to live for
39
+ * the duration of one turn. Reusing a stable threadId across turns lets
40
+ * the server-side thread accumulate the agent's *internal* messages
41
+ * (tool calls/results that never round-trip through Slack); on the next
42
+ * turn `@ag-ui/langgraph` regenerates state and the now-larger existing
43
+ * thread no longer matches the incoming history, surfacing as a
44
+ * "Message not found" failure. A unique thread per turn sidesteps that
45
+ * entirely. Restart-recovery for interrupts still works because the
46
+ * picker carries its turn's threadId in Slack message metadata (see
47
+ * `recoverFromStaleClick`).
48
+ */
49
+ newThreadId(k) {
50
+ return `slack-${k.channelId}-${k.scope}-${randomUUID()}`;
51
+ }
52
+ /**
53
+ * Does the bot own this thread? "Ownership" means the bot has at least
54
+ * one prior reply in it (which is the natural definition since the bot
55
+ * only ever replies when @-mentioned or in a thread it already owns).
56
+ *
57
+ * Cached in-process; the first call after a restart is one Slack API
58
+ * round-trip, subsequent calls are O(1).
59
+ */
60
+ async has(key) {
61
+ if (this.participated.has(this.keyOf(key)))
62
+ return true;
63
+ // DM "scope" is a sentinel — no Slack thread to query. We treat the
64
+ // listener-level DM gate as authoritative; DMs always go through.
65
+ if (key.scope === DM_SCOPE)
66
+ return false;
67
+ try {
68
+ const r = await this.client.conversations.replies({
69
+ channel: key.channelId,
70
+ ts: key.scope,
71
+ limit: 200,
72
+ });
73
+ const messages = (r.messages ?? []);
74
+ const owned = messages.some((m) => m.user === this.botUserId);
75
+ if (owned)
76
+ this.participated.add(this.keyOf(key));
77
+ return owned;
78
+ }
79
+ catch (err) {
80
+ console.warn("[store] has() lookup failed:", err);
81
+ return false;
82
+ }
83
+ }
84
+ /**
85
+ * Build a fresh AgentSession for this conversation by fetching its
86
+ * Slack history and translating it into the AG-UI message shape.
87
+ * `makeAgent` produces the HttpAgent; we set its `messages` to the
88
+ * translated history.
89
+ */
90
+ async getOrCreate(key, replyTarget, makeAgent) {
91
+ const threadId = this.newThreadId(key);
92
+ const agent = makeAgent(threadId);
93
+ const history = await this.fetchHistory(key);
94
+ agent.messages = history;
95
+ // Eagerly mark as ours so a follow-up arriving before the bot's first
96
+ // reply has settled into Slack still gets matched as "owned".
97
+ this.participated.add(this.keyOf(key));
98
+ return { threadId, agent, replyTarget };
99
+ }
100
+ /**
101
+ * No-op: the next call rebuilds from Slack, so there's nothing to
102
+ * persist. Kept on the API for symmetry with the previous interface
103
+ * (the turn-runner still calls it; we just do nothing).
104
+ */
105
+ save(_key, _session) {
106
+ /* intentionally empty */
107
+ }
108
+ /** Fetch Slack history for either a thread or a DM and translate. */
109
+ async fetchHistory(key) {
110
+ try {
111
+ if (key.scope === DM_SCOPE) {
112
+ const r = await this.client.conversations.history({
113
+ channel: key.channelId,
114
+ limit: 100,
115
+ });
116
+ const slackMsgs = (r.messages ?? []).reverse(); // oldest first
117
+ return this.translate(slackMsgs);
118
+ }
119
+ const r = await this.client.conversations.replies({
120
+ channel: key.channelId,
121
+ ts: key.scope,
122
+ limit: 200,
123
+ });
124
+ return this.translate((r.messages ?? []));
125
+ }
126
+ catch (err) {
127
+ console.warn("[store] fetchHistory failed:", err);
128
+ return [];
129
+ }
130
+ }
131
+ /**
132
+ * Translate a chronological run of Slack messages to AG-UI messages,
133
+ * folding (a) our bot's chunked replies into a single assistant turn,
134
+ * (b) skipping our `:wrench:` / `:white_check_mark:` status messages
135
+ * and the `_thinking…_` placeholder, and (c) stripping `<@bot>`
136
+ * mention tokens from user text.
137
+ */
138
+ async translate(messages) {
139
+ const MENTION_RE = /<@[UW][A-Z0-9]+>/g;
140
+ const isStatusOrPlaceholder = (text) => text.startsWith(":wrench:") ||
141
+ text.startsWith(":white_check_mark:") ||
142
+ text === "_thinking…_" ||
143
+ text === "_…(continued)_";
144
+ const out = [];
145
+ for (const m of messages) {
146
+ if (m.subtype && m.subtype !== "file_share")
147
+ continue;
148
+ // Bot's own messages: matched by user id, or by bot_id when the API
149
+ // omits `user` (legacy bot integrations). A user-token app message has
150
+ // BOTH user and bot_id — `!m.user` keeps those classified as user.
151
+ const isBot = m.user === this.botUserId || (!!m.bot_id && !m.user);
152
+ const hasFiles = !isBot && Array.isArray(m.files) && m.files.length > 0;
153
+ if (!m.text && !hasFiles)
154
+ continue;
155
+ if (isBot && isStatusOrPlaceholder(m.text ?? ""))
156
+ continue;
157
+ let text = m.text ?? "";
158
+ if (!isBot) {
159
+ text = text.replace(MENTION_RE, "").replace(/\s+/g, " ").trim();
160
+ }
161
+ const role = isBot ? "assistant" : "user";
162
+ // User message with uploaded files → multimodal content (the agent's
163
+ // model reads the images / decoded text). The bridge only delivers;
164
+ // the app decides what to do with the bytes.
165
+ if (hasFiles) {
166
+ const { parts, notes } = await buildFileContentParts(m.files, this.botToken, this.filesConfig);
167
+ const content = [];
168
+ if (text)
169
+ content.push({ type: "text", text });
170
+ content.push(...parts);
171
+ if (notes.length > 0) {
172
+ content.push({
173
+ type: "text",
174
+ text: `[attachment notes: ${notes.join("; ")}]`,
175
+ });
176
+ }
177
+ if (content.length === 0)
178
+ continue; // nothing usable
179
+ out.push({ id: m.ts ?? "", role, content });
180
+ continue;
181
+ }
182
+ if (!text)
183
+ continue;
184
+ // Fold consecutive same-role *string* messages — our chunked bot
185
+ // replies are one assistant turn, just rendered as N Slack messages.
186
+ const tail = out[out.length - 1];
187
+ if (tail && tail.role === role && typeof tail.content === "string") {
188
+ tail.content = `${tail.content}\n${text}`;
189
+ }
190
+ else {
191
+ out.push({ id: m.ts ?? "", role, content: text });
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+ }
@@ -0,0 +1,59 @@
1
+ /** The subset of a Slack file object we use. */
2
+ export interface SlackFileRef {
3
+ id?: string;
4
+ name?: string;
5
+ mimetype?: string;
6
+ filetype?: string;
7
+ url_private?: string;
8
+ size?: number;
9
+ }
10
+ /** A base64 data source, shared by every binary media part. */
11
+ type MediaDataSource = {
12
+ type: "data";
13
+ value: string;
14
+ mimeType: string;
15
+ };
16
+ /**
17
+ * AG-UI multimodal content parts (shape the runtime's converter expects).
18
+ *
19
+ * Binary media (image/audio/video/document) is passed straight through as a
20
+ * data part; the agent's model decides what it can actually consume. Most
21
+ * models read images and PDFs; far fewer accept audio or video. The bridge
22
+ * stays transport-only and does not gate on model capability.
23
+ */
24
+ export type AgentContentPart = {
25
+ type: "text";
26
+ text: string;
27
+ } | {
28
+ type: "image";
29
+ source: MediaDataSource;
30
+ } | {
31
+ type: "audio";
32
+ source: MediaDataSource;
33
+ } | {
34
+ type: "video";
35
+ source: MediaDataSource;
36
+ } | {
37
+ type: "document";
38
+ source: MediaDataSource;
39
+ };
40
+ /** Tunables for inbound file handling (all optional; sane defaults). */
41
+ export interface FileDeliveryConfig {
42
+ /** Skip a single file larger than this many bytes. Default 8 MiB. */
43
+ maxBytesPerFile?: number;
44
+ /** Process at most this many files per message. Default 5. */
45
+ maxFiles?: number;
46
+ /** Truncate decoded text files to this many bytes. Default 200 KiB. */
47
+ maxTextBytes?: number;
48
+ }
49
+ /**
50
+ * Download a message's files and turn them into AG-UI content parts. Returns
51
+ * the parts plus human-readable `notes` for anything skipped (appended to the
52
+ * message as a text part by the caller).
53
+ */
54
+ export declare function buildFileContentParts(files: SlackFileRef[], botToken: string, config?: FileDeliveryConfig): Promise<{
55
+ parts: AgentContentPart[];
56
+ notes: string[];
57
+ }>;
58
+ export {};
59
+ //# sourceMappingURL=download-files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-files.d.ts","sourceRoot":"","sources":["../src/download-files.ts"],"names":[],"mappings":"AAaA,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,+DAA+D;AAC/D,KAAK,eAAe,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzE;;;;;;;GAOG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AAElD,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAwCD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,YAAY,EAAE,EACrB,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,kBAAuB,GAC9B,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAwFzD"}
@@ -0,0 +1,116 @@
1
+ const DEFAULTS = {
2
+ maxBytesPerFile: 8 * 1024 * 1024,
3
+ maxFiles: 5,
4
+ maxTextBytes: 200 * 1024,
5
+ };
6
+ const TEXT_MIME_PREFIXES = ["text/"];
7
+ const TEXT_MIME_EXACT = new Set([
8
+ "application/json",
9
+ "application/csv",
10
+ "application/xml",
11
+ "application/x-ndjson",
12
+ "application/yaml",
13
+ ]);
14
+ function isText(mime) {
15
+ return (TEXT_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
16
+ TEXT_MIME_EXACT.has(mime));
17
+ }
18
+ /**
19
+ * The AG-UI media part type that carries this MIME, or null if it isn't a
20
+ * binary medium we pass through. Images/audio/video go by their top-level
21
+ * type; PDFs map to `document`. Everything else is left to the text path or
22
+ * skipped.
23
+ */
24
+ function mediaPartType(mime) {
25
+ if (mime.startsWith("image/"))
26
+ return "image";
27
+ if (mime.startsWith("audio/"))
28
+ return "audio";
29
+ if (mime.startsWith("video/"))
30
+ return "video";
31
+ if (mime === "application/pdf")
32
+ return "document";
33
+ return null;
34
+ }
35
+ /**
36
+ * Download a message's files and turn them into AG-UI content parts. Returns
37
+ * the parts plus human-readable `notes` for anything skipped (appended to the
38
+ * message as a text part by the caller).
39
+ */
40
+ export async function buildFileContentParts(files, botToken, config = {}) {
41
+ const maxBytes = config.maxBytesPerFile ?? DEFAULTS.maxBytesPerFile;
42
+ const maxFiles = config.maxFiles ?? DEFAULTS.maxFiles;
43
+ const maxText = config.maxTextBytes ?? DEFAULTS.maxTextBytes;
44
+ const parts = [];
45
+ const notes = [];
46
+ const considered = files.slice(0, maxFiles);
47
+ if (files.length > maxFiles) {
48
+ notes.push(`(only the first ${maxFiles} of ${files.length} files processed)`);
49
+ }
50
+ for (const f of considered) {
51
+ const label = f.name ?? f.id ?? "file";
52
+ const mime = (f.mimetype ?? "").toLowerCase();
53
+ const media = mediaPartType(mime);
54
+ if (!f.url_private) {
55
+ notes.push(`skipped "${label}": no download URL`);
56
+ continue;
57
+ }
58
+ if (!media && !isText(mime)) {
59
+ notes.push(`skipped "${label}" (${mime || f.filetype || "unknown"}): unsupported type`);
60
+ continue;
61
+ }
62
+ if (typeof f.size === "number" && f.size > maxBytes) {
63
+ notes.push(`skipped "${label}": ${f.size} bytes exceeds the ${maxBytes}-byte cap`);
64
+ continue;
65
+ }
66
+ let bytes;
67
+ try {
68
+ const res = await fetch(f.url_private, {
69
+ headers: { Authorization: `Bearer ${botToken}` },
70
+ });
71
+ if (!res.ok) {
72
+ notes.push(`skipped "${label}": download failed (HTTP ${res.status})`);
73
+ continue;
74
+ }
75
+ bytes = Buffer.from(await res.arrayBuffer());
76
+ }
77
+ catch (err) {
78
+ notes.push(`skipped "${label}": ${err.message}`);
79
+ continue;
80
+ }
81
+ if (bytes.byteLength > maxBytes) {
82
+ notes.push(`skipped "${label}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte cap`);
83
+ continue;
84
+ }
85
+ if (media) {
86
+ // Image/audio/video/PDF → a binary data part the model reads natively
87
+ // (subject to its modality support). The bridge just delivers it.
88
+ parts.push({
89
+ type: media,
90
+ source: {
91
+ type: "data",
92
+ value: bytes.toString("base64"),
93
+ mimeType: mime,
94
+ },
95
+ });
96
+ }
97
+ else {
98
+ // Truncate the BYTES then decode — slicing the decoded string by
99
+ // character index would corrupt multi-byte UTF-8 (and not actually
100
+ // bound the byte length). toString drops any malformed trailing bytes.
101
+ let buf = bytes;
102
+ let truncated = false;
103
+ if (buf.byteLength > maxText) {
104
+ buf = buf.subarray(0, maxText);
105
+ truncated = true;
106
+ }
107
+ const text = buf.toString("utf8");
108
+ parts.push({
109
+ type: "text",
110
+ text: `Attached file "${label}" (${mime}${truncated ? ", truncated" : ""}):\n` +
111
+ text,
112
+ });
113
+ }
114
+ }
115
+ return { parts, notes };
116
+ }
@@ -0,0 +1,98 @@
1
+ import type { KnownBlock } from "@slack/types";
2
+ import type { RunRenderer } from "@copilotkit/channels";
3
+ import type { NativeStreamTransport } from "./native-stream.js";
4
+ import type { SlackRenderTransport } from "./render/transport.js";
5
+ import type { SlackAssistantOptions } from "./types.js";
6
+ /**
7
+ * Construct a {@link RunRenderer} for a single agent run in Slack.
8
+ *
9
+ * The `subscriber` is passed to `runAgent`. After the run resolves, the
10
+ * run-loop reads `getCapturedToolCalls()` (filtering by which tools are
11
+ * registered) and `getPendingInterrupt()`, then calls `finish()` once at
12
+ * turn end. `markInterrupted()` is called when a new turn arrives for the
13
+ * same conversation while this run is still streaming — it appends a
14
+ * `_(interrupted)_` marker to any partial reply so the user sees a clear
15
+ * visual cue that the bot stopped.
16
+ *
17
+ * ## Native vs legacy text streaming
18
+ *
19
+ * When `nativeStreaming` is set, the run uses a SINGLE turn-scoped
20
+ * `chat.startStream` message for the whole turn: text from every AG-UI
21
+ * message accumulates into it (separated by blank lines), and tool calls
22
+ * surface as native `task_update` chunks INSIDE that message. The message is
23
+ * finalized once, at `finish()`, optionally carrying a feedback row. The
24
+ * legacy path keeps the prior behavior — one `chat.update` message per AG-UI
25
+ * text message, plus separate `:wrench:` tool-status rows.
26
+ */
27
+ export declare function createRunRenderer(args: {
28
+ /**
29
+ * The credentialed Slack side-effects (setStatus / postMessage / update),
30
+ * injected so this renderer never imports `@slack/web-api`. The native
31
+ * adapter wraps a `WebClient`; the managed Connector Outbox wraps its own
32
+ * sender.
33
+ */
34
+ transport: SlackRenderTransport;
35
+ target: {
36
+ channel: string;
37
+ threadTs?: string;
38
+ };
39
+ /**
40
+ * Custom-event names that should be treated as interrupts — captured
41
+ * for later dispatch to an `InterruptHandler`. Defaults to just
42
+ * `on_interrupt` (the name LangGraph's AG-UI adapter emits).
43
+ */
44
+ interruptEventNames?: ReadonlySet<string>;
45
+ /**
46
+ * Master toggle for surfacing tool-call progress in the UI. Defaults to
47
+ * `true`. When `false`, NO tool progress is shown on any surface — native
48
+ * in-message `task_update` chunks, legacy `:wrench:` rows, and the pane's
49
+ * "is using `tool`…" composer status are all suppressed (tools still run;
50
+ * only the display is hidden). When `true`, the surface is chosen by target:
51
+ * native streaming → `task_update` chunks; legacy / native-degraded →
52
+ * `:wrench:`/`:white_check_mark:` rows; pane → composer status (further
53
+ * gated by the pane's own `toolStatus`).
54
+ *
55
+ * Status rows dedup by `toolCallId` so a tool that fires
56
+ * `TOOL_CALL_START` twice (e.g. on graph resume after an interrupt)
57
+ * can't post two rows for the same logical call.
58
+ */
59
+ showToolStatus?: boolean;
60
+ /**
61
+ * Present whenever the reply is anchored to a thread we can set native status
62
+ * on (pane, channel @-mention, channel thread, or a flat DM via its carried
63
+ * inbound ts). Drives Slack's native `assistant.threads.setStatus`
64
+ * ("is thinking…") instead of a placeholder message. `threadTs` is the thread
65
+ * the status attaches to; `isPane` selects how *tool* progress is surfaced
66
+ * (pane → composer "is using `tool`…"; non-pane → `task_update`/`:wrench:`).
67
+ */
68
+ status?: {
69
+ threadTs: string;
70
+ isPane: boolean;
71
+ config?: SlackAssistantOptions["status"];
72
+ };
73
+ /**
74
+ * When set, agent text replies stream via `chat.startStream` (native) using
75
+ * this transport instead of `chat.update`. Falls back to the legacy
76
+ * transport automatically if `startStream` fails. `onChunkFailure` flips the
77
+ * renderer to `:wrench:` tool-status rows if structured chunks are
78
+ * unsupported.
79
+ */
80
+ nativeStreaming?: {
81
+ transport: NativeStreamTransport;
82
+ onStartFailure?: (err: unknown) => void;
83
+ /**
84
+ * Whether structured `task_update` chunks are known to work on this
85
+ * workspace (adapter-persisted across turns). Defaults to `true`.
86
+ */
87
+ taskChunks?: boolean;
88
+ /** Called the first time a chunk append fails, so the adapter can persist the degradation. */
89
+ onChunkFailure?: () => void;
90
+ };
91
+ /**
92
+ * Native AI-feedback row (built by the adapter when `feedback` is
93
+ * configured) attached to the finalized streamed reply via `stopStream`.
94
+ * Native path only; omitted when absent or on legacy fallback.
95
+ */
96
+ feedbackBlocks?: KnownBlock[];
97
+ }): RunRenderer;
98
+ //# sourceMappingURL=event-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-renderer.d.ts","sourceRoot":"","sources":["../src/event-renderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,KAAK,EACV,WAAW,EAGZ,MAAM,sBAAsB,CAAC;AAK9B,OAAO,KAAK,EAAc,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAClE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAwBxD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC;;;;;OAKG;IACH,SAAS,EAAE,oBAAoB,CAAC;IAChC,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/C;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC1C;;;;;;;;;;;;;OAaG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACP,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,qBAAqB,CAAC,QAAQ,CAAC,CAAC;KAC1C,CAAC;IACF;;;;;;OAMG;IACH,eAAe,CAAC,EAAE;QAChB,SAAS,EAAE,qBAAqB,CAAC;QACjC,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;QACxC;;;WAGG;QACH,UAAU,CAAC,EAAE,OAAO,CAAC;QACrB,8FAA8F;QAC9F,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;KAC7B,CAAC;IACF;;;;OAIG;IACH,cAAc,CAAC,EAAE,UAAU,EAAE,CAAC;CAC/B,GAAG,WAAW,CAwcd"}