@copilotkit/channels-slack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/dist/__tests__/assistant.test.d.ts +2 -0
- package/dist/__tests__/assistant.test.d.ts.map +1 -0
- package/dist/__tests__/assistant.test.js +153 -0
- package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
- package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
- package/dist/__tests__/auto-close-streaming.test.js +139 -0
- package/dist/__tests__/built-ins.test.d.ts +2 -0
- package/dist/__tests__/built-ins.test.d.ts.map +1 -0
- package/dist/__tests__/built-ins.test.js +67 -0
- package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
- package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
- package/dist/__tests__/chunked-message-stream.test.js +226 -0
- package/dist/__tests__/conversation-store.test.d.ts +2 -0
- package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
- package/dist/__tests__/conversation-store.test.js +209 -0
- package/dist/__tests__/download-files.test.d.ts +2 -0
- package/dist/__tests__/download-files.test.d.ts.map +1 -0
- package/dist/__tests__/download-files.test.js +142 -0
- package/dist/__tests__/ephemeral.test.d.ts +2 -0
- package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
- package/dist/__tests__/ephemeral.test.js +85 -0
- package/dist/__tests__/event-renderer.test.d.ts +2 -0
- package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/event-renderer.test.js +521 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
- package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
- package/dist/__tests__/message-stream.test.d.ts +2 -0
- package/dist/__tests__/message-stream.test.d.ts.map +1 -0
- package/dist/__tests__/message-stream.test.js +116 -0
- package/dist/__tests__/native-renderer.test.d.ts +2 -0
- package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/native-renderer.test.js +236 -0
- package/dist/__tests__/native-stream.test.d.ts +2 -0
- package/dist/__tests__/native-stream.test.d.ts.map +1 -0
- package/dist/__tests__/native-stream.test.js +235 -0
- package/dist/__tests__/reactions.test.d.ts +2 -0
- package/dist/__tests__/reactions.test.d.ts.map +1 -0
- package/dist/__tests__/reactions.test.js +193 -0
- package/dist/__tests__/slack-listener.test.d.ts +2 -0
- package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
- package/dist/__tests__/slack-listener.test.js +559 -0
- package/dist/__tests__/views.test.d.ts +2 -0
- package/dist/__tests__/views.test.d.ts.map +1 -0
- package/dist/__tests__/views.test.js +143 -0
- package/dist/adapter.d.ts +188 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +859 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +368 -0
- package/dist/assistant.d.ts +19 -0
- package/dist/assistant.d.ts.map +1 -0
- package/dist/assistant.js +124 -0
- package/dist/auto-close-streaming.d.ts +62 -0
- package/dist/auto-close-streaming.d.ts.map +1 -0
- package/dist/auto-close-streaming.js +273 -0
- package/dist/built-in-context.d.ts +22 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +65 -0
- package/dist/built-in-tools.d.ts +23 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +45 -0
- package/dist/chunked-message-stream.d.ts +67 -0
- package/dist/chunked-message-stream.d.ts.map +1 -0
- package/dist/chunked-message-stream.js +134 -0
- package/dist/codec.d.ts +18 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +18 -0
- package/dist/codec.test.d.ts +2 -0
- package/dist/codec.test.d.ts.map +1 -0
- package/dist/codec.test.js +19 -0
- package/dist/conversation-store.d.ts +92 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +196 -0
- package/dist/download-files.d.ts +59 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +116 -0
- package/dist/event-renderer.d.ts +98 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +493 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/ingress-normalize.d.ts +75 -0
- package/dist/ingress-normalize.d.ts.map +1 -0
- package/dist/ingress-normalize.js +153 -0
- package/dist/ingress-normalize.test.d.ts +2 -0
- package/dist/ingress-normalize.test.d.ts.map +1 -0
- package/dist/ingress-normalize.test.js +187 -0
- package/dist/interaction.d.ts +30 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +219 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +137 -0
- package/dist/markdown-to-mrkdwn.d.ts +21 -0
- package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
- package/dist/markdown-to-mrkdwn.js +99 -0
- package/dist/message-stream.d.ts +44 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +70 -0
- package/dist/native-stream.d.ts +131 -0
- package/dist/native-stream.d.ts.map +1 -0
- package/dist/native-stream.js +208 -0
- package/dist/render/block-kit.d.ts +35 -0
- package/dist/render/block-kit.d.ts.map +1 -0
- package/dist/render/block-kit.js +399 -0
- package/dist/render/block-kit.test.d.ts +2 -0
- package/dist/render/block-kit.test.d.ts.map +1 -0
- package/dist/render/block-kit.test.js +289 -0
- package/dist/render/budget.d.ts +24 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +30 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +49 -0
- package/dist/render/modal.d.ts +13 -0
- package/dist/render/modal.d.ts.map +1 -0
- package/dist/render/modal.js +108 -0
- package/dist/render/transport.d.ts +42 -0
- package/dist/render/transport.d.ts.map +1 -0
- package/dist/render/transport.js +1 -0
- package/dist/render.d.ts +23 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +20 -0
- package/dist/sanitizing-http-agent.d.ts +28 -0
- package/dist/sanitizing-http-agent.d.ts.map +1 -0
- package/dist/sanitizing-http-agent.js +51 -0
- package/dist/slack-listener.d.ts +73 -0
- package/dist/slack-listener.d.ts.map +1 -0
- package/dist/slack-listener.js +126 -0
- package/dist/slack-listener.test.d.ts +2 -0
- package/dist/slack-listener.test.d.ts.map +1 -0
- package/dist/slack-listener.test.js +115 -0
- package/dist/types.d.ts +153 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/package.json +73 -0
|
@@ -0,0 +1,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 @@
|
|
|
1
|
+
{"version":3,"file":"block-kit.test.d.ts","sourceRoot":"","sources":["../../src/render/block-kit.test.ts"],"names":[],"mappings":""}
|