@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,208 @@
1
+ /**
2
+ * Default text-flush floor. `chat.appendStream` is Tier 4 (100+/min), so 600ms
3
+ * (~100/min) keeps comfortable headroom while streaming noticeably more
4
+ * smoothly than the legacy `chat.update` path (~1/sec). The `WebClient` retries
5
+ * 429s honoring `Retry-After`, so this is a soft floor, not a correctness gate.
6
+ */
7
+ const DEFAULT_MIN_INTERVAL_MS = 600;
8
+ /** Slack caps `markdown_text` at 12k chars per `appendStream` call. */
9
+ const APPEND_CHAR_LIMIT = 12000;
10
+ export class NativeMessageStream {
11
+ buffer = "";
12
+ queue = Promise.resolve();
13
+ lastFlushedAt = 0;
14
+ flushTimer;
15
+ finished = false;
16
+ /** Current streamed message ts (undefined until the first `startStream`). */
17
+ curTs;
18
+ /** Buffer chars already appended as text to the current message. */
19
+ curPosted = 0;
20
+ /** ts of the first streamed message (for the returned MessageRef). */
21
+ firstTsValue;
22
+ /** Set once `startStream` has failed and we've fallen back to the legacy transport. */
23
+ legacy;
24
+ /** Set once a chunk append has failed/been refused, so we stop trying. */
25
+ chunksDisabled = false;
26
+ transport;
27
+ makeFallback;
28
+ onStartFailure;
29
+ onChunkFailure;
30
+ minIntervalMs;
31
+ constructor(config) {
32
+ this.transport = config.transport;
33
+ this.makeFallback = config.fallback;
34
+ this.onStartFailure = config.onStartFailure;
35
+ this.onChunkFailure = config.onChunkFailure;
36
+ this.minIntervalMs = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
37
+ }
38
+ /** The first streamed message's ts (or the fallback's), available after finish(). */
39
+ get firstTs() {
40
+ return this.firstTsValue;
41
+ }
42
+ append(fullText) {
43
+ if (this.legacy) {
44
+ this.legacy.append(fullText);
45
+ return;
46
+ }
47
+ if (fullText === this.buffer)
48
+ return;
49
+ this.buffer = fullText;
50
+ this.scheduleFlush();
51
+ }
52
+ /**
53
+ * Append a structured chunk (`task_update` / `plan_update` / `blocks`) to the
54
+ * streamed message. Flushes any pending text first so the chunk lands AFTER
55
+ * the text emitted so far. No-op (firing `onChunkFailure` once) when the
56
+ * stream has fallen back to legacy or chunks were already refused.
57
+ */
58
+ appendChunk(chunk) {
59
+ if (this.legacy || this.chunksDisabled) {
60
+ if (!this.chunksDisabled) {
61
+ this.chunksDisabled = true;
62
+ this.onChunkFailure?.(new Error("native streaming unavailable"));
63
+ }
64
+ return;
65
+ }
66
+ // Drop the throttle gate so the chunk (and the text before it) flush
67
+ // promptly — tool-progress shouldn't wait out the text cadence.
68
+ if (this.flushTimer) {
69
+ clearTimeout(this.flushTimer);
70
+ this.flushTimer = undefined;
71
+ }
72
+ this.queue = this.queue.then(() => this.flushChunk(chunk));
73
+ }
74
+ async finish(finalBlocks) {
75
+ this.finished = true;
76
+ if (this.flushTimer) {
77
+ clearTimeout(this.flushTimer);
78
+ this.flushTimer = undefined;
79
+ }
80
+ this.enqueueFlush();
81
+ await this.queue;
82
+ if (this.legacy) {
83
+ await this.legacy.finish();
84
+ return;
85
+ }
86
+ // Finalize the streamed message (no-op if we never started one).
87
+ if (this.curTs) {
88
+ try {
89
+ await this.transport.stopStream(this.curTs, finalBlocks);
90
+ }
91
+ catch (err) {
92
+ console.error("[native-stream] stopStream failed:", err);
93
+ }
94
+ }
95
+ }
96
+ scheduleFlush() {
97
+ if (this.flushTimer)
98
+ return;
99
+ const elapsed = Date.now() - this.lastFlushedAt;
100
+ const delay = Math.max(0, this.minIntervalMs - elapsed);
101
+ this.flushTimer = setTimeout(() => {
102
+ this.flushTimer = undefined;
103
+ this.enqueueFlush();
104
+ }, delay);
105
+ }
106
+ enqueueFlush() {
107
+ this.queue = this.queue.then(() => this.flushText());
108
+ }
109
+ /** Ensure the stream is started; on failure, fall back to legacy and replay. Returns false if failed over. */
110
+ async ensureStarted() {
111
+ if (this.curTs)
112
+ return true;
113
+ try {
114
+ this.curTs = await this.transport.startStream();
115
+ this.firstTsValue = this.curTs;
116
+ return true;
117
+ }
118
+ catch (err) {
119
+ this.failOverToLegacy(err);
120
+ return false;
121
+ }
122
+ }
123
+ /** Append all un-posted buffer text to the current message, chunked under the 12k per-call cap. */
124
+ async flushText() {
125
+ if (this.legacy)
126
+ return; // appends are forwarded directly once failed over
127
+ if (this.curPosted >= this.buffer.length)
128
+ return; // nothing new
129
+ if (!(await this.ensureStarted()))
130
+ return;
131
+ try {
132
+ let cursor = this.curPosted;
133
+ while (cursor < this.buffer.length) {
134
+ const end = Math.min(cursor + APPEND_CHAR_LIMIT, this.buffer.length);
135
+ await this.transport.appendText(this.curTs, this.buffer.slice(cursor, end));
136
+ cursor = end;
137
+ this.curPosted = cursor;
138
+ }
139
+ }
140
+ catch (err) {
141
+ // A mid-stream append failure shouldn't sink the stream; the next flush
142
+ // retries from `curPosted` (only advanced on success).
143
+ console.error(`[native-stream] appendText failed (ts=${this.curTs}, posted=${this.curPosted}/${this.buffer.length}):`, err);
144
+ }
145
+ finally {
146
+ this.lastFlushedAt = Date.now();
147
+ }
148
+ }
149
+ /** Flush pending text, then append one structured chunk. */
150
+ async flushChunk(chunk) {
151
+ if (this.legacy || this.chunksDisabled)
152
+ return;
153
+ // Start the stream even if no text yet — a tool call can be the first thing
154
+ // the agent emits (`startStream` accepts a content-less open; the chunk is
155
+ // the message's first content).
156
+ if (!(await this.ensureStarted())) {
157
+ this.disableChunks(new Error("startStream failed"));
158
+ return;
159
+ }
160
+ await this.flushTextInline();
161
+ try {
162
+ await this.transport.appendChunks(this.curTs, [chunk]);
163
+ }
164
+ catch (err) {
165
+ console.error(`[native-stream] appendChunks failed (ts=${this.curTs}):`, err);
166
+ this.disableChunks(err);
167
+ }
168
+ finally {
169
+ this.lastFlushedAt = Date.now();
170
+ }
171
+ }
172
+ /** Append pending text to the current (already-started) message; swallow failures. */
173
+ async flushTextInline() {
174
+ if (this.curPosted >= this.buffer.length)
175
+ return;
176
+ try {
177
+ let cursor = this.curPosted;
178
+ while (cursor < this.buffer.length) {
179
+ const end = Math.min(cursor + APPEND_CHAR_LIMIT, this.buffer.length);
180
+ await this.transport.appendText(this.curTs, this.buffer.slice(cursor, end));
181
+ cursor = end;
182
+ this.curPosted = cursor;
183
+ }
184
+ }
185
+ catch (err) {
186
+ console.error(`[native-stream] appendText (pre-chunk) failed (ts=${this.curTs}):`, err);
187
+ }
188
+ }
189
+ disableChunks(err) {
190
+ if (this.chunksDisabled)
191
+ return;
192
+ this.chunksDisabled = true;
193
+ this.onChunkFailure?.(err);
194
+ }
195
+ /**
196
+ * Switch to the legacy `chat.update` transport and replay the full buffer so
197
+ * no text is lost ("opting in can never break a bot"). The full buffer is
198
+ * replayed because `append()` forwards the accumulated full text once
199
+ * `this.legacy` is set, so the legacy stream owns the whole response.
200
+ */
201
+ failOverToLegacy(err) {
202
+ console.warn("[native-stream] startStream failed; using legacy transport:", err);
203
+ this.onStartFailure?.(err);
204
+ const legacy = this.makeFallback();
205
+ legacy.append(this.buffer);
206
+ this.legacy = legacy;
207
+ }
208
+ }
@@ -0,0 +1,35 @@
1
+ import type { BotNode } from "@copilotkit/channels-ui";
2
+ import type { KnownBlock } from "@slack/types";
3
+ /**
4
+ * Stable `action_id` of the native AI feedback row's `feedback_buttons`
5
+ * element. The adapter intercepts clicks on this id (routing them to the
6
+ * app's feedback callback) before they reach the engine's interaction
7
+ * dispatch — see `adapter.ts`.
8
+ */
9
+ export declare const FEEDBACK_ACTION_ID = "ck-fb";
10
+ /**
11
+ * Build the native AI-feedback row (`context_actions` + `feedback_buttons`)
12
+ * attached to a finalized streamed reply via `chat.stopStream`'s `blocks`.
13
+ * The clicked button's `value` ("positive" / "negative") carries the sentiment.
14
+ */
15
+ export declare function buildFeedbackBlocks(opts?: {
16
+ positiveLabel?: string;
17
+ negativeLabel?: string;
18
+ }): KnownBlock[];
19
+ /**
20
+ * Render a cross-platform component IR tree (already expanded by `renderToIR`
21
+ * and pre-bound by the action registry, so event props are `{ id }`) into a
22
+ * Slack Block Kit `KnownBlock[]`.
23
+ *
24
+ * The renderer is total: unknown intrinsic types are skipped rather than
25
+ * throwing. Per-element Slack limits are applied via {@link truncateText} and
26
+ * {@link clampArray}; nothing is silently dropped — overflowing collections
27
+ * clamp and, at the top level, append an explicit overflow signal block.
28
+ */
29
+ export declare function renderBlockKit(ir: BotNode[]): KnownBlock[];
30
+ /** Render IR to Slack blocks, extracting a top-level <Message accent="#hex"> color for an attachment wrapper. */
31
+ export declare function renderSlackMessage(ir: BotNode[]): {
32
+ blocks: KnownBlock[];
33
+ accent?: string;
34
+ };
35
+ //# sourceMappingURL=block-kit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"block-kit.d.ts","sourceRoot":"","sources":["../../src/render/block-kit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,KAAK,EAAuB,UAAU,EAAE,MAAM,cAAc,CAAC;AAIpE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,UAAU,CAAC;AAE1C;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,CAAC,EAAE;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GAAG,UAAU,EAAE,CA+Bf;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,UAAU,EAAE,CAiB1D;AAED,iHAAiH;AACjH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG;IACjD,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CASA"}
@@ -0,0 +1,399 @@
1
+ import { markdownToMrkdwn } from "../markdown-to-mrkdwn.js";
2
+ import { SLACK_LIMITS, clampArray, truncateText } from "./budget.js";
3
+ /**
4
+ * Stable `action_id` of the native AI feedback row's `feedback_buttons`
5
+ * element. The adapter intercepts clicks on this id (routing them to the
6
+ * app's feedback callback) before they reach the engine's interaction
7
+ * dispatch — see `adapter.ts`.
8
+ */
9
+ export const FEEDBACK_ACTION_ID = "ck-fb";
10
+ /**
11
+ * Build the native AI-feedback row (`context_actions` + `feedback_buttons`)
12
+ * attached to a finalized streamed reply via `chat.stopStream`'s `blocks`.
13
+ * The clicked button's `value` ("positive" / "negative") carries the sentiment.
14
+ */
15
+ export function buildFeedbackBlocks(opts) {
16
+ const block = {
17
+ type: "context_actions",
18
+ elements: [
19
+ {
20
+ type: "feedback_buttons",
21
+ action_id: FEEDBACK_ACTION_ID,
22
+ positive_button: {
23
+ text: {
24
+ type: "plain_text",
25
+ text: truncateText(opts?.positiveLabel ?? "Good response", SLACK_LIMITS.buttonText),
26
+ },
27
+ value: "positive",
28
+ },
29
+ negative_button: {
30
+ text: {
31
+ type: "plain_text",
32
+ text: truncateText(opts?.negativeLabel ?? "Bad response", SLACK_LIMITS.buttonText),
33
+ },
34
+ value: "negative",
35
+ },
36
+ },
37
+ ],
38
+ };
39
+ return [block];
40
+ }
41
+ /**
42
+ * Render a cross-platform component IR tree (already expanded by `renderToIR`
43
+ * and pre-bound by the action registry, so event props are `{ id }`) into a
44
+ * Slack Block Kit `KnownBlock[]`.
45
+ *
46
+ * The renderer is total: unknown intrinsic types are skipped rather than
47
+ * throwing. Per-element Slack limits are applied via {@link truncateText} and
48
+ * {@link clampArray}; nothing is silently dropped — overflowing collections
49
+ * clamp and, at the top level, append an explicit overflow signal block.
50
+ */
51
+ export function renderBlockKit(ir) {
52
+ const blocks = [];
53
+ for (const node of ir) {
54
+ renderNode(node, blocks);
55
+ }
56
+ // Top-level budget: clamp to the per-message block ceiling, leaving room for
57
+ // an overflow-signal context block when we had to drop anything.
58
+ const { items, overflow } = clampArray(blocks, SLACK_LIMITS.blocksPerMessage);
59
+ if (overflow <= 0)
60
+ return items;
61
+ // Drop the last kept block to make room for the signal so we land at exactly
62
+ // the ceiling (49 kept + 1 signal = 50) instead of exceeding it.
63
+ const kept = items.slice(0, SLACK_LIMITS.blocksPerMessage - 1);
64
+ const dropped = overflow + 1;
65
+ kept.push(overflowSignal(dropped));
66
+ return kept;
67
+ }
68
+ /** Render IR to Slack blocks, extracting a top-level <Message accent="#hex"> color for an attachment wrapper. */
69
+ export function renderSlackMessage(ir) {
70
+ const blocks = renderBlockKit(ir);
71
+ // Top-level single <Message accent="..."> → use its accent as the attachment color.
72
+ if (ir.length === 1 && ir[0] && ir[0].type === "message") {
73
+ const accent = ir[0].props.accent;
74
+ if (typeof accent === "string" && accent.length > 0)
75
+ return { blocks, accent };
76
+ }
77
+ return { blocks };
78
+ }
79
+ function overflowSignal(count) {
80
+ return {
81
+ type: "context",
82
+ elements: [{ type: "mrkdwn", text: `_…+${count} more blocks truncated_` }],
83
+ };
84
+ }
85
+ /** Render a single IR node, pushing zero or more blocks onto `out`. */
86
+ function renderNode(node, out) {
87
+ if (typeof node.type !== "string")
88
+ return; // non-intrinsic — already expanded away
89
+ const props = node.props ?? {};
90
+ switch (node.type) {
91
+ case "message": {
92
+ // The message container is not a block; flatten its children.
93
+ for (const child of childNodes(node))
94
+ renderNode(child, out);
95
+ return;
96
+ }
97
+ case "header": {
98
+ out.push({
99
+ type: "header",
100
+ text: {
101
+ type: "plain_text",
102
+ text: truncateText(collectText(node), SLACK_LIMITS.headerText),
103
+ },
104
+ });
105
+ return;
106
+ }
107
+ case "section":
108
+ case "markdown": {
109
+ out.push({
110
+ type: "section",
111
+ text: {
112
+ type: "mrkdwn",
113
+ text: truncateText(markdownToMrkdwn(collectText(node)), SLACK_LIMITS.sectionText),
114
+ },
115
+ });
116
+ return;
117
+ }
118
+ case "fields": {
119
+ const fieldChildren = childNodes(node).filter((c) => c.type === "field");
120
+ const { items } = clampArray(fieldChildren, SLACK_LIMITS.fieldsPerSection);
121
+ out.push({
122
+ type: "section",
123
+ fields: items.map((f) => ({
124
+ type: "mrkdwn",
125
+ text: truncateText(fieldMrkdwn(f), SLACK_LIMITS.fieldText),
126
+ })),
127
+ });
128
+ return;
129
+ }
130
+ case "field": {
131
+ // Standalone field (rare) → single-field section.
132
+ out.push({
133
+ type: "section",
134
+ fields: [
135
+ {
136
+ type: "mrkdwn",
137
+ text: truncateText(fieldMrkdwn(node), SLACK_LIMITS.fieldText),
138
+ },
139
+ ],
140
+ });
141
+ return;
142
+ }
143
+ case "context": {
144
+ const { items } = clampArray(childNodes(node), SLACK_LIMITS.contextElements);
145
+ out.push({
146
+ type: "context",
147
+ elements: items.map((c) => ({
148
+ type: "mrkdwn",
149
+ text: markdownToMrkdwn(collectText(c)),
150
+ })),
151
+ });
152
+ return;
153
+ }
154
+ case "actions": {
155
+ const { items } = clampArray(childNodes(node), SLACK_LIMITS.actionsElements);
156
+ // A multi-select can't live in an `actions` block (Slack allows
157
+ // multi_static_select only in section/input blocks), so peel each one off
158
+ // into its own dispatching input block; the rest stay as action elements.
159
+ // Flush the pending actions block BEFORE each peeled-off input so blocks
160
+ // stay in source order (e.g. [Button, Select multi] → actions, then input).
161
+ let elements = [];
162
+ const flush = () => {
163
+ if (elements.length > 0) {
164
+ out.push({ type: "actions", elements });
165
+ elements = [];
166
+ }
167
+ };
168
+ for (const child of items) {
169
+ if (child.type === "select" && child.props.multi) {
170
+ flush();
171
+ out.push(multiSelectInput(child));
172
+ continue;
173
+ }
174
+ const el = renderActionElement(child);
175
+ if (el !== null)
176
+ elements.push(el);
177
+ }
178
+ flush();
179
+ return;
180
+ }
181
+ case "image": {
182
+ const url = (props.url ?? props.image_url);
183
+ out.push({
184
+ type: "image",
185
+ image_url: url ?? "",
186
+ alt_text: (props.alt ?? props.altText ?? ""),
187
+ });
188
+ return;
189
+ }
190
+ case "divider": {
191
+ out.push({ type: "divider" });
192
+ return;
193
+ }
194
+ case "input": {
195
+ out.push({
196
+ type: "input",
197
+ dispatch_action: true,
198
+ element: {
199
+ type: "plain_text_input",
200
+ action_id: truncateText(idFromHandler(props.onSubmit) ?? "input", SLACK_LIMITS.actionId),
201
+ multiline: !!props.multiline,
202
+ },
203
+ label: {
204
+ type: "plain_text",
205
+ text: truncateText(String(props.placeholder ?? " "), 150),
206
+ },
207
+ });
208
+ return;
209
+ }
210
+ case "text": {
211
+ // Bare top-level text → a mrkdwn section.
212
+ const value = String(props.value ?? "");
213
+ out.push({
214
+ type: "section",
215
+ text: {
216
+ type: "mrkdwn",
217
+ text: truncateText(markdownToMrkdwn(value), SLACK_LIMITS.sectionText),
218
+ },
219
+ });
220
+ return;
221
+ }
222
+ case "table": {
223
+ // Native Slack Table block: rows of `{ type: "raw_text", text }` cells.
224
+ // Header row from `columns`, data rows from `row`/`cell` children.
225
+ // Not yet in `@slack/types`, so the block is built plain and cast.
226
+ const cellOf = (text) => ({
227
+ type: "raw_text",
228
+ text: truncateText(text, SLACK_LIMITS.cellText),
229
+ });
230
+ const columnsProp = props.columns;
231
+ const columns = columnsProp
232
+ ? clampArray(columnsProp, SLACK_LIMITS.tableColumns).items
233
+ : undefined;
234
+ const rows = [];
235
+ if (columns && columns.length > 0) {
236
+ rows.push(columns.map((c) => cellOf(c.header)));
237
+ }
238
+ const rowNodes = childNodes(node).filter((c) => c.type === "row");
239
+ const { items: dataRows } = clampArray(rowNodes, SLACK_LIMITS.tableRows);
240
+ for (const rowNode of dataRows) {
241
+ const cells = childNodes(rowNode).filter((c) => c.type === "cell");
242
+ rows.push(cells.map((cell) => cellOf(collectText(cell))));
243
+ }
244
+ const block = { type: "table", rows };
245
+ if (columns) {
246
+ block.column_settings = columns.map((c) => ({
247
+ align: c.align ?? "left",
248
+ }));
249
+ }
250
+ out.push(block);
251
+ return;
252
+ }
253
+ case "raw": {
254
+ const value = props.value;
255
+ const native = Array.isArray(value) ? value : [value];
256
+ for (const b of native) {
257
+ if (b != null)
258
+ out.push(b);
259
+ }
260
+ return;
261
+ }
262
+ default:
263
+ // Unknown intrinsic — skip silently (total renderer).
264
+ return;
265
+ }
266
+ }
267
+ /**
268
+ * Render one interactive element inside an `actions` block. Returns `null` for
269
+ * children that aren't renderable as action elements (so callers can filter).
270
+ */
271
+ function renderActionElement(node) {
272
+ if (typeof node.type !== "string")
273
+ return null;
274
+ const props = node.props ?? {};
275
+ switch (node.type) {
276
+ case "button": {
277
+ const action_id = truncateText(buttonActionId(props), SLACK_LIMITS.actionId);
278
+ const el = {
279
+ type: "button",
280
+ action_id,
281
+ text: {
282
+ type: "plain_text",
283
+ text: truncateText(collectText(node), SLACK_LIMITS.buttonText),
284
+ },
285
+ };
286
+ // Link button: opens the URL natively. Slack still requires an action_id
287
+ // (kept above); clicks on a url button are not dispatched as actions.
288
+ if (typeof props.url === "string" && props.url.length > 0) {
289
+ el.url = props.url;
290
+ }
291
+ if (props.value !== undefined) {
292
+ el.value = truncateText(JSON.stringify(props.value), SLACK_LIMITS.buttonValue);
293
+ }
294
+ if (props.style === "primary" || props.style === "danger") {
295
+ el.style = props.style;
296
+ }
297
+ return el;
298
+ }
299
+ case "select": {
300
+ const action_id = truncateText(idFromHandler(props.onSelect) ?? "select", SLACK_LIMITS.actionId);
301
+ const options = props.options ??
302
+ [];
303
+ const { items } = clampArray(options, SLACK_LIMITS.selectOptions);
304
+ const el = {
305
+ type: "static_select",
306
+ action_id,
307
+ placeholder: {
308
+ type: "plain_text",
309
+ text: String(props.placeholder ?? " "),
310
+ },
311
+ options: items.map((o) => ({
312
+ text: { type: "plain_text", text: truncateText(o.label, 75) },
313
+ value: truncateText(String(o.value), 150),
314
+ })),
315
+ };
316
+ return el;
317
+ }
318
+ default:
319
+ return null;
320
+ }
321
+ }
322
+ /**
323
+ * Render a `<Select multi>` as a dispatching input block holding a
324
+ * `multi_static_select` (which Slack forbids inside an `actions` block). The
325
+ * block_actions payload carries `selected_options`, decoded to a `string[]`.
326
+ */
327
+ function multiSelectInput(node) {
328
+ const props = node.props ?? {};
329
+ const action_id = truncateText(idFromHandler(props.onSelect) ?? "select", SLACK_LIMITS.actionId);
330
+ const options = props.options ?? [];
331
+ const { items } = clampArray(options, SLACK_LIMITS.selectOptions);
332
+ return {
333
+ type: "input",
334
+ dispatch_action: true,
335
+ element: {
336
+ type: "multi_static_select",
337
+ action_id,
338
+ placeholder: {
339
+ type: "plain_text",
340
+ text: String(props.placeholder ?? " "),
341
+ },
342
+ options: items.map((o) => ({
343
+ text: { type: "plain_text", text: truncateText(o.label, 75) },
344
+ value: truncateText(String(o.value), 150),
345
+ })),
346
+ },
347
+ label: {
348
+ type: "plain_text",
349
+ text: truncateText(String(props.placeholder ?? " "), 150),
350
+ },
351
+ };
352
+ }
353
+ /** Derive a button's `action_id`: prefer the registry-stamped id, else a stable fallback. */
354
+ function buttonActionId(props) {
355
+ const fromHandler = idFromHandler(props.onClick);
356
+ if (fromHandler)
357
+ return fromHandler;
358
+ return props.value !== undefined ? JSON.stringify(props.value) : "action";
359
+ }
360
+ /** Extract `{ id }` stamped onto an event prop by the action registry, if present. */
361
+ function idFromHandler(handler) {
362
+ if (handler && typeof handler === "object" && "id" in handler) {
363
+ const id = handler.id;
364
+ if (typeof id === "string")
365
+ return id;
366
+ }
367
+ return undefined;
368
+ }
369
+ /** The expanded `children` of an IR node as an `BotNode[]` (empty if none). */
370
+ function childNodes(node) {
371
+ const children = node.props?.children;
372
+ if (Array.isArray(children))
373
+ return children;
374
+ if (children &&
375
+ typeof children === "object" &&
376
+ "type" in children) {
377
+ return [children];
378
+ }
379
+ return [];
380
+ }
381
+ /** A field's mrkdwn text: a bold `label` line (when set) above the value. */
382
+ function fieldMrkdwn(node) {
383
+ const value = markdownToMrkdwn(collectText(node));
384
+ const label = node.props.label;
385
+ return typeof label === "string" && label.length > 0
386
+ ? `*${label}*\n${value}`
387
+ : value;
388
+ }
389
+ /** Concatenate the `value` of all descendant `text` nodes (depth-first). */
390
+ function collectText(node) {
391
+ if (typeof node.type === "string" && node.type === "text") {
392
+ return String(node.props?.value ?? "");
393
+ }
394
+ let acc = "";
395
+ for (const child of childNodes(node)) {
396
+ acc += collectText(child);
397
+ }
398
+ return acc;
399
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=block-kit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"block-kit.test.d.ts","sourceRoot":"","sources":["../../src/render/block-kit.test.ts"],"names":[],"mappings":""}