@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
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import { App, LogLevel } from "@slack/bolt";
|
|
2
|
+
import { toPlatformEmoji } from "@copilotkit/channels-ui";
|
|
3
|
+
import { SlackConversationStore } from "./conversation-store.js";
|
|
4
|
+
import { attachSlackListener } from "./slack-listener.js";
|
|
5
|
+
import { createRunRenderer } from "./event-renderer.js";
|
|
6
|
+
import { decodeInteraction, decodeReaction, decodeViewSubmission, decodeViewClosed, conversationKeyOf, } from "./interaction.js";
|
|
7
|
+
import { renderBlockKit, renderSlackMessage, buildFeedbackBlocks, FEEDBACK_ACTION_ID, } from "./render/block-kit.js";
|
|
8
|
+
import { renderSlackModal } from "./render/modal.js";
|
|
9
|
+
import { ChunkedMessageStream } from "./chunked-message-stream.js";
|
|
10
|
+
import { NativeMessageStream } from "./native-stream.js";
|
|
11
|
+
import { attachAssistant } from "./assistant.js";
|
|
12
|
+
import { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
|
|
13
|
+
import { markdownToMrkdwn } from "./markdown-to-mrkdwn.js";
|
|
14
|
+
import { DM_SCOPE, resolveSlackRespondToOptions } from "./types.js";
|
|
15
|
+
/** Slack `PlatformAdapter`: ingress via Bolt, egress via Block Kit + streaming. */
|
|
16
|
+
export class SlackAdapter {
|
|
17
|
+
opts;
|
|
18
|
+
platform = "slack";
|
|
19
|
+
capabilities;
|
|
20
|
+
ackDeadlineMs = 3000;
|
|
21
|
+
app;
|
|
22
|
+
client;
|
|
23
|
+
botUserId = "";
|
|
24
|
+
store;
|
|
25
|
+
sink;
|
|
26
|
+
/** Per-id cache for sender-profile resolution (repeat turns are cheap). */
|
|
27
|
+
userCache = new Map();
|
|
28
|
+
/** Set once the Assistant middleware is attached (when assistant !== false). */
|
|
29
|
+
assistantHandle;
|
|
30
|
+
/** Our team id (from auth.test); native channel streams need it. */
|
|
31
|
+
teamId;
|
|
32
|
+
/**
|
|
33
|
+
* In-memory native-streaming health for this workspace. Flipped to false the
|
|
34
|
+
* first time `chat.startStream` fails, so subsequent streams skip the native
|
|
35
|
+
* path and go straight to the legacy transport.
|
|
36
|
+
*/
|
|
37
|
+
nativeStreamingOk = true;
|
|
38
|
+
/**
|
|
39
|
+
* In-memory health of native structured `task_update` chunks for this
|
|
40
|
+
* workspace. Flipped to false the first time a chunk append fails (old
|
|
41
|
+
* workspace / missing `assistant:write`), so later turns surface tool
|
|
42
|
+
* progress as `:wrench:` rows instead of retrying chunks.
|
|
43
|
+
*/
|
|
44
|
+
nativeTaskChunksOk = true;
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
this.opts = opts;
|
|
47
|
+
const assistantEnabled = opts.assistant !== false;
|
|
48
|
+
this.capabilities = {
|
|
49
|
+
supportsModals: true,
|
|
50
|
+
supportsTyping: false,
|
|
51
|
+
supportsReactions: true,
|
|
52
|
+
supportsStreaming: true,
|
|
53
|
+
supportsEphemeral: true,
|
|
54
|
+
maxBlocksPerMessage: 50,
|
|
55
|
+
supportsSuggestedPrompts: assistantEnabled,
|
|
56
|
+
supportsThreadTitle: assistantEnabled,
|
|
57
|
+
};
|
|
58
|
+
this.app = new App({
|
|
59
|
+
token: opts.botToken,
|
|
60
|
+
appToken: opts.appToken,
|
|
61
|
+
signingSecret: opts.signingSecret,
|
|
62
|
+
socketMode: opts.socketMode ?? true,
|
|
63
|
+
logLevel: opts.logLevel ?? LogLevel.INFO,
|
|
64
|
+
// Without this, Bolt's constructor fires a background auth.test that
|
|
65
|
+
// can't be awaited or error-handled (and phones home from unit tests).
|
|
66
|
+
// start() owns initialization: app.init() below, then our own awaited
|
|
67
|
+
// auth.test — construction stays side-effect-free.
|
|
68
|
+
deferInitialization: true,
|
|
69
|
+
});
|
|
70
|
+
this.client = this.app.client;
|
|
71
|
+
this.store = new SlackConversationStore({
|
|
72
|
+
client: this.client,
|
|
73
|
+
botUserId: "",
|
|
74
|
+
botToken: opts.botToken,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async start(sink) {
|
|
78
|
+
this.sink = sink;
|
|
79
|
+
// Deferred from the constructor (see above); Bolt requires init() before
|
|
80
|
+
// app.start(), and doing it here surfaces auth/config errors to the caller.
|
|
81
|
+
await this.app.init();
|
|
82
|
+
// Resolve our own bot user id before attaching the listener so the loop
|
|
83
|
+
// guard (skip our own posts) is in place from the first event.
|
|
84
|
+
const auth = await this.client.auth.test();
|
|
85
|
+
this.botUserId = auth.user_id;
|
|
86
|
+
this.teamId = auth.team_id;
|
|
87
|
+
this.store.botUserId = this.botUserId;
|
|
88
|
+
// Attach the assistant-pane middleware FIRST (when enabled) so its
|
|
89
|
+
// `isAssistantThread` predicate is available to the message listener's
|
|
90
|
+
// no-double-delivery guard below.
|
|
91
|
+
if (this.opts.assistant !== false) {
|
|
92
|
+
this.assistantHandle = attachAssistant({
|
|
93
|
+
app: this.app,
|
|
94
|
+
sink,
|
|
95
|
+
opts: this.opts.assistant ?? {},
|
|
96
|
+
resolveUser: (id) => this.resolveUser(id),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
attachSlackListener({
|
|
100
|
+
app: this.app,
|
|
101
|
+
store: this.store,
|
|
102
|
+
botUserId: this.botUserId,
|
|
103
|
+
respondTo: resolveSlackRespondToOptions(this.opts.respondTo),
|
|
104
|
+
isAssistantThread: this.assistantHandle?.isAssistantThread,
|
|
105
|
+
onTurn: async (turn) => {
|
|
106
|
+
await sink.onTurn({
|
|
107
|
+
conversationKey: conversationKeyOf(turn.conversation),
|
|
108
|
+
// Carry the sender id so native channel streams can pass
|
|
109
|
+
// `recipient_user_id` to chat.startStream.
|
|
110
|
+
replyTarget: {
|
|
111
|
+
...turn.replyTarget,
|
|
112
|
+
recipientUserId: turn.senderUserId,
|
|
113
|
+
},
|
|
114
|
+
userText: turn.userText,
|
|
115
|
+
user: turn.senderUserId
|
|
116
|
+
? await this.resolveUser(turn.senderUserId)
|
|
117
|
+
: undefined,
|
|
118
|
+
// Stable per-delivery id for inbound dedup (Events API event_id, or a
|
|
119
|
+
// fallback derived by the listener); undefined when unavailable.
|
|
120
|
+
eventId: turn.eventId,
|
|
121
|
+
platform: "slack",
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
onCommand: async (cmd) => {
|
|
125
|
+
await sink.onCommand({
|
|
126
|
+
command: cmd.command,
|
|
127
|
+
text: cmd.text,
|
|
128
|
+
// Slack delivers args as free text only; structured `rawOptions`
|
|
129
|
+
// are a Discord-style capability, so they're left unset here.
|
|
130
|
+
conversationKey: conversationKeyOf(cmd.conversation),
|
|
131
|
+
replyTarget: cmd.replyTarget,
|
|
132
|
+
user: cmd.senderUserId
|
|
133
|
+
? await this.resolveUser(cmd.senderUserId)
|
|
134
|
+
: undefined,
|
|
135
|
+
// Stable per-invocation id for inbound dedup (command:user:trigger_id).
|
|
136
|
+
eventId: cmd.eventId,
|
|
137
|
+
platform: "slack",
|
|
138
|
+
triggerId: cmd.triggerId,
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
// Every block_actions click → decode to an opaque-id InteractionEvent and
|
|
143
|
+
// hand to the sink. The matching `ck:` action either resolves an awaiting
|
|
144
|
+
// HITL picker or dispatches via the ActionRegistry; unrelated clicks decode
|
|
145
|
+
// to events the bot harmlessly ignores.
|
|
146
|
+
this.app.action(/.*/, async ({ ack, body }) => {
|
|
147
|
+
await ack();
|
|
148
|
+
// Native feedback-row clicks are handled adapter-locally and never reach
|
|
149
|
+
// the engine's interaction dispatch (which would swallow the unknown id).
|
|
150
|
+
if (this.handleFeedbackClick(body))
|
|
151
|
+
return;
|
|
152
|
+
const evt = this.decodeInteraction(body);
|
|
153
|
+
if (evt)
|
|
154
|
+
await sink.onInteraction(evt);
|
|
155
|
+
});
|
|
156
|
+
this.app.event("reaction_added", async ({ event }) => {
|
|
157
|
+
// Loop guard: Slack delivers reaction events for the bot's OWN reactions
|
|
158
|
+
// (e.g. our addReaction egress), which would echo back as phantom user
|
|
159
|
+
// reactions. Skip them, like every other ingress path guards botUserId.
|
|
160
|
+
if (event.user === this.botUserId)
|
|
161
|
+
return;
|
|
162
|
+
const evt = decodeReaction(event, true);
|
|
163
|
+
if (!evt)
|
|
164
|
+
return;
|
|
165
|
+
// Enrich the reactor to parity with onTurn/onCommand so per-user
|
|
166
|
+
// attribution (e.g. senderContext(evt.user) → filter by email) works
|
|
167
|
+
// for reaction-triggered runs, not just mentions and commands.
|
|
168
|
+
if (event.user)
|
|
169
|
+
evt.user = await this.resolveUser(event.user);
|
|
170
|
+
await sink.onReaction(evt);
|
|
171
|
+
});
|
|
172
|
+
this.app.event("reaction_removed", async ({ event }) => {
|
|
173
|
+
if (event.user === this.botUserId)
|
|
174
|
+
return;
|
|
175
|
+
const evt = decodeReaction(event, false);
|
|
176
|
+
if (!evt)
|
|
177
|
+
return;
|
|
178
|
+
if (event.user)
|
|
179
|
+
evt.user = await this.resolveUser(event.user);
|
|
180
|
+
await sink.onReaction(evt);
|
|
181
|
+
});
|
|
182
|
+
// Modal submit: route to the engine and ack. When the handler returns
|
|
183
|
+
// per-field errors, ack with `response_action: "errors"` (keeps the modal
|
|
184
|
+
// open with inline messages); otherwise a plain ack closes the modal.
|
|
185
|
+
// A catch-all `/.*/` callback_id constraint defaults to `view_submission`.
|
|
186
|
+
this.app.view(/.*/, async ({ ack, body, view }) => {
|
|
187
|
+
const user = body.user?.id
|
|
188
|
+
? { id: body.user.id, name: body.user.name }
|
|
189
|
+
: undefined;
|
|
190
|
+
const result = await sink.onModalSubmit(decodeViewSubmission(view, user));
|
|
191
|
+
if (result?.errors) {
|
|
192
|
+
await ack({ response_action: "errors", errors: result.errors });
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
await ack();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// Modal dismissed (only delivered when the view set `notify_on_close`).
|
|
199
|
+
this.app.view({ type: "view_closed", callback_id: /.*/ }, async ({ ack, body, view }) => {
|
|
200
|
+
const user = body.user?.id
|
|
201
|
+
? { id: body.user.id, name: body.user.name }
|
|
202
|
+
: undefined;
|
|
203
|
+
await sink.onModalClose(decodeViewClosed(view, user));
|
|
204
|
+
await ack();
|
|
205
|
+
});
|
|
206
|
+
// Socket Mode ignores the port; HTTP mode binds it.
|
|
207
|
+
await this.app.start(this.opts.port ?? 0);
|
|
208
|
+
}
|
|
209
|
+
async stop() {
|
|
210
|
+
await this.app.stop();
|
|
211
|
+
}
|
|
212
|
+
render(ir) {
|
|
213
|
+
return renderBlockKit(ir);
|
|
214
|
+
}
|
|
215
|
+
async post(target, ir) {
|
|
216
|
+
const t = target;
|
|
217
|
+
const { blocks, accent } = renderSlackMessage(ir);
|
|
218
|
+
const summary = fallbackText(ir);
|
|
219
|
+
// Suppress Slack link/media unfurling: a card with many links (e.g. an
|
|
220
|
+
// issue_list of Linear URLs) would otherwise spawn a wall of preview
|
|
221
|
+
// attachments. The gen-UI card IS the presentation.
|
|
222
|
+
const base = {
|
|
223
|
+
channel: t.channel,
|
|
224
|
+
thread_ts: t.threadTs,
|
|
225
|
+
unfurl_links: false,
|
|
226
|
+
unfurl_media: false,
|
|
227
|
+
// Short one-line notification/a11y fallback. Slack does NOT render a
|
|
228
|
+
// top-level `text` as the message body when `blocks`/`attachments` are
|
|
229
|
+
// present, so this is safe on both paths.
|
|
230
|
+
text: summary,
|
|
231
|
+
};
|
|
232
|
+
// ACCENT path: render the colored attachment card. The attachment carries
|
|
233
|
+
// ONLY `{ color, blocks }` — adding a legacy `fallback` field alongside
|
|
234
|
+
// `blocks` makes Slack reject the payload with `invalid_attachments`.
|
|
235
|
+
const args = accent
|
|
236
|
+
? { ...base, attachments: [{ color: accent, blocks }] }
|
|
237
|
+
: { ...base, blocks };
|
|
238
|
+
const res = await this.client.chat.postMessage(args);
|
|
239
|
+
return { id: res.ts, channel: t.channel, ts: res.ts };
|
|
240
|
+
}
|
|
241
|
+
async update(ref, ir) {
|
|
242
|
+
const channel = channelOf(ref);
|
|
243
|
+
const { blocks, accent } = renderSlackMessage(ir);
|
|
244
|
+
const summary = fallbackText(ir);
|
|
245
|
+
// Mirror `post`'s accent/non-accent split. `chat.update` does not accept
|
|
246
|
+
// the `unfurl_*` flags, so they are only set on `postMessage`.
|
|
247
|
+
const args = accent
|
|
248
|
+
? {
|
|
249
|
+
channel,
|
|
250
|
+
ts: ref.id,
|
|
251
|
+
text: summary,
|
|
252
|
+
attachments: [{ color: accent, blocks }],
|
|
253
|
+
}
|
|
254
|
+
: {
|
|
255
|
+
channel,
|
|
256
|
+
ts: ref.id,
|
|
257
|
+
text: summary,
|
|
258
|
+
blocks,
|
|
259
|
+
};
|
|
260
|
+
await this.client.chat.update(args);
|
|
261
|
+
}
|
|
262
|
+
async stream(target, chunks) {
|
|
263
|
+
const t = target;
|
|
264
|
+
let firstTs;
|
|
265
|
+
let channel = t.channel;
|
|
266
|
+
// The shipped chat.update streamer — also the automatic fallback for native.
|
|
267
|
+
const makeLegacy = () => new ChunkedMessageStream({
|
|
268
|
+
postPlaceholder: async (text) => {
|
|
269
|
+
const posted = await this.client.chat.postMessage({
|
|
270
|
+
channel: t.channel,
|
|
271
|
+
thread_ts: t.threadTs,
|
|
272
|
+
text,
|
|
273
|
+
unfurl_links: false,
|
|
274
|
+
unfurl_media: false,
|
|
275
|
+
});
|
|
276
|
+
if (!posted.ts)
|
|
277
|
+
throw new Error("postMessage returned no ts");
|
|
278
|
+
if (!firstTs) {
|
|
279
|
+
firstTs = posted.ts;
|
|
280
|
+
channel = posted.channel ?? t.channel;
|
|
281
|
+
}
|
|
282
|
+
return posted.ts;
|
|
283
|
+
},
|
|
284
|
+
updateAt: async (ts, text) => {
|
|
285
|
+
await this.client.chat.update({ channel: t.channel, ts, text });
|
|
286
|
+
},
|
|
287
|
+
transform: (s) => markdownToMrkdwn(autoCloseOpenMarkdown(s)),
|
|
288
|
+
});
|
|
289
|
+
// Native streaming only where a thread exists (Slack requires streams to be
|
|
290
|
+
// thread replies); flat DMs always use the legacy streamer.
|
|
291
|
+
const useNative = this.opts.streaming !== "legacy" &&
|
|
292
|
+
!!t.threadTs &&
|
|
293
|
+
this.nativeStreamingOk;
|
|
294
|
+
let sink;
|
|
295
|
+
if (useNative) {
|
|
296
|
+
sink = new NativeMessageStream({
|
|
297
|
+
transport: this.nativeTransport(t, (ts, ch) => {
|
|
298
|
+
if (!firstTs) {
|
|
299
|
+
firstTs = ts;
|
|
300
|
+
channel = ch ?? t.channel;
|
|
301
|
+
}
|
|
302
|
+
}),
|
|
303
|
+
fallback: makeLegacy,
|
|
304
|
+
onStartFailure: () => {
|
|
305
|
+
this.nativeStreamingOk = false;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
sink = makeLegacy();
|
|
311
|
+
}
|
|
312
|
+
let acc = "";
|
|
313
|
+
for await (const chunk of chunks) {
|
|
314
|
+
acc += chunk;
|
|
315
|
+
sink.append(acc);
|
|
316
|
+
}
|
|
317
|
+
await sink.finish();
|
|
318
|
+
return { id: firstTs ?? "", channel, ts: firstTs };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Build the {@link NativeStreamTransport} (chat.startStream/appendStream/
|
|
322
|
+
* stopStream) for a thread target. `onFirstTs` records the first streamed
|
|
323
|
+
* message's ts/channel for the returned MessageRef. Native channel streams
|
|
324
|
+
* pass `recipient_user_id` (the turn sender) + `recipient_team_id`.
|
|
325
|
+
*/
|
|
326
|
+
nativeTransport(t, onFirstTs) {
|
|
327
|
+
const threadTs = t.threadTs;
|
|
328
|
+
if (!threadTs) {
|
|
329
|
+
// Native streaming is only wired up where a thread exists (Slack requires
|
|
330
|
+
// streams to be thread replies); the callers gate on this already.
|
|
331
|
+
throw new Error("native streaming requires a thread_ts");
|
|
332
|
+
}
|
|
333
|
+
// `recipient_user_id` / `recipient_team_id` are required when streaming to a
|
|
334
|
+
// channel and implicit in DMs / assistant threads — pass them only for
|
|
335
|
+
// non-DM targets (DM channel ids start with "D").
|
|
336
|
+
const isChannel = !t.channel.startsWith("D");
|
|
337
|
+
return {
|
|
338
|
+
startStream: async () => {
|
|
339
|
+
const args = {
|
|
340
|
+
channel: t.channel,
|
|
341
|
+
thread_ts: threadTs,
|
|
342
|
+
task_display_mode: "timeline",
|
|
343
|
+
...(isChannel && t.recipientUserId
|
|
344
|
+
? { recipient_user_id: t.recipientUserId }
|
|
345
|
+
: {}),
|
|
346
|
+
...(isChannel && this.teamId
|
|
347
|
+
? { recipient_team_id: this.teamId }
|
|
348
|
+
: {}),
|
|
349
|
+
};
|
|
350
|
+
const res = await this.client.chat.startStream(args);
|
|
351
|
+
if (!res.ts)
|
|
352
|
+
throw new Error("startStream returned no ts");
|
|
353
|
+
onFirstTs(res.ts, res.channel);
|
|
354
|
+
return res.ts;
|
|
355
|
+
},
|
|
356
|
+
appendText: async (ts, markdownText) => {
|
|
357
|
+
const args = {
|
|
358
|
+
channel: t.channel,
|
|
359
|
+
ts,
|
|
360
|
+
markdown_text: markdownText,
|
|
361
|
+
};
|
|
362
|
+
await this.client.chat.appendStream(args);
|
|
363
|
+
},
|
|
364
|
+
appendChunks: async (ts, chunks) => {
|
|
365
|
+
const args = {
|
|
366
|
+
channel: t.channel,
|
|
367
|
+
ts,
|
|
368
|
+
chunks,
|
|
369
|
+
};
|
|
370
|
+
await this.client.chat.appendStream(args);
|
|
371
|
+
},
|
|
372
|
+
stopStream: async (ts, finalBlocks) => {
|
|
373
|
+
const args = {
|
|
374
|
+
channel: t.channel,
|
|
375
|
+
ts,
|
|
376
|
+
...(finalBlocks && finalBlocks.length > 0
|
|
377
|
+
? { blocks: finalBlocks }
|
|
378
|
+
: {}),
|
|
379
|
+
};
|
|
380
|
+
await this.client.chat.stopStream(args);
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The credentialed {@link SlackRenderTransport} the run renderer calls
|
|
386
|
+
* (setStatus / postMessage / update), wrapping this adapter's `WebClient`.
|
|
387
|
+
* Extracted so `createRunRenderer` stays Bolt-free and the managed Connector
|
|
388
|
+
* Outbox can drive the identical renderer with its own sender.
|
|
389
|
+
*/
|
|
390
|
+
renderTransport() {
|
|
391
|
+
return {
|
|
392
|
+
setStatus: async (a) => {
|
|
393
|
+
await this.client.assistant.threads.setStatus(a);
|
|
394
|
+
},
|
|
395
|
+
postMessage: async (a) => {
|
|
396
|
+
const res = await this.client.chat.postMessage(a);
|
|
397
|
+
return { ts: res.ts };
|
|
398
|
+
},
|
|
399
|
+
updateMessage: async (a) => {
|
|
400
|
+
await this.client.chat.update(a);
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Route a `feedback_buttons` click to the configured feedback callback.
|
|
406
|
+
* Returns `true` if this was a feedback click (so the caller skips the
|
|
407
|
+
* engine's interaction dispatch). Best-effort: payload-shape or handler
|
|
408
|
+
* errors are logged, never thrown.
|
|
409
|
+
*/
|
|
410
|
+
handleFeedbackClick(raw) {
|
|
411
|
+
const feedback = this.opts.feedback;
|
|
412
|
+
if (!feedback)
|
|
413
|
+
return false;
|
|
414
|
+
const body = raw;
|
|
415
|
+
const action = body.actions?.[0];
|
|
416
|
+
if (action?.action_id !== FEEDBACK_ACTION_ID)
|
|
417
|
+
return false;
|
|
418
|
+
const sentiment = action.value === "negative" ? "negative" : "positive";
|
|
419
|
+
const channel = body.channel?.id ?? body.container?.channel_id;
|
|
420
|
+
const messageTs = body.message?.ts;
|
|
421
|
+
if (!channel || !messageTs) {
|
|
422
|
+
// It's a feedback click (so we still swallow it rather than forwarding to
|
|
423
|
+
// the engine), but the payload lacked the refs the handler needs.
|
|
424
|
+
console.warn("[slack-adapter] feedback click missing channel/message ts; ignoring");
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
void Promise.resolve(feedback.onFeedback({
|
|
428
|
+
sentiment,
|
|
429
|
+
channel,
|
|
430
|
+
messageTs,
|
|
431
|
+
threadTs: body.message?.thread_ts,
|
|
432
|
+
user: body.user?.id
|
|
433
|
+
? { id: body.user.id, name: body.user.name ?? body.user.username }
|
|
434
|
+
: undefined,
|
|
435
|
+
})).catch((err) => console.error("[slack-adapter] onFeedback handler failed:", err));
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
async delete(ref) {
|
|
439
|
+
await this.client.chat.delete({ channel: channelOf(ref), ts: ref.id });
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* True if `target` is a known assistant-pane thread (recorded by the
|
|
443
|
+
* Assistant middleware). Pane targets drive native status and back the
|
|
444
|
+
* pane-only `setSuggestedPrompts` / `setThreadTitle` methods.
|
|
445
|
+
*/
|
|
446
|
+
isPaneTarget(t) {
|
|
447
|
+
return Boolean(t.threadTs &&
|
|
448
|
+
this.assistantHandle?.isAssistantThread(t.channel, t.threadTs));
|
|
449
|
+
}
|
|
450
|
+
createRunRenderer(target) {
|
|
451
|
+
const t = target;
|
|
452
|
+
const assistantOpts = this.opts.assistant === false ? undefined : (this.opts.assistant ?? {});
|
|
453
|
+
const isPane = this.isPaneTarget(t);
|
|
454
|
+
// Native `setStatus` ("is thinking…") works for any thread we can anchor it
|
|
455
|
+
// to — pane, channel @-mention, tracked channel thread, or (via the carried
|
|
456
|
+
// inbound ts) a flat DM. `assistant: false` opts out everywhere.
|
|
457
|
+
const statusThreadTs = t.threadTs ?? t.statusTs;
|
|
458
|
+
const status = assistantOpts && statusThreadTs
|
|
459
|
+
? {
|
|
460
|
+
threadTs: statusThreadTs,
|
|
461
|
+
isPane,
|
|
462
|
+
config: assistantOpts.status ?? {},
|
|
463
|
+
}
|
|
464
|
+
: undefined;
|
|
465
|
+
// Native streaming wherever a thread exists (and the workspace supports it).
|
|
466
|
+
const useNative = this.opts.streaming !== "legacy" &&
|
|
467
|
+
!!t.threadTs &&
|
|
468
|
+
this.nativeStreamingOk;
|
|
469
|
+
return createRunRenderer({
|
|
470
|
+
transport: this.renderTransport(),
|
|
471
|
+
target: t,
|
|
472
|
+
interruptEventNames: this.opts.interruptEventNames,
|
|
473
|
+
showToolStatus: this.opts.showToolStatus,
|
|
474
|
+
status,
|
|
475
|
+
nativeStreaming: useNative
|
|
476
|
+
? {
|
|
477
|
+
transport: this.nativeTransport(t, () => { }),
|
|
478
|
+
onStartFailure: () => {
|
|
479
|
+
this.nativeStreamingOk = false;
|
|
480
|
+
},
|
|
481
|
+
// Pane threads keep composer status for tool progress; elsewhere
|
|
482
|
+
// surface it as in-message task_update chunks (when supported).
|
|
483
|
+
taskChunks: !isPane && this.nativeTaskChunksOk,
|
|
484
|
+
onChunkFailure: () => {
|
|
485
|
+
this.nativeTaskChunksOk = false;
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
: undefined,
|
|
489
|
+
// Native AI feedback row (opt-in); only attached to native streamed
|
|
490
|
+
// replies, so omit it on the legacy path.
|
|
491
|
+
feedbackBlocks: useNative && this.opts.feedback
|
|
492
|
+
? buildFeedbackBlocks({
|
|
493
|
+
positiveLabel: this.opts.feedback.positiveLabel,
|
|
494
|
+
negativeLabel: this.opts.feedback.negativeLabel,
|
|
495
|
+
})
|
|
496
|
+
: undefined,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/** Backs the capability-gated `Thread.setSuggestedPrompts` for pane threads. */
|
|
500
|
+
async setSuggestedPrompts(target, prompts, opts) {
|
|
501
|
+
const t = target;
|
|
502
|
+
if (!this.isPaneTarget(t)) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: "suggested prompts require an assistant-pane thread",
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
await this.client.assistant.threads.setSuggestedPrompts({
|
|
510
|
+
channel_id: t.channel,
|
|
511
|
+
thread_ts: t.threadTs,
|
|
512
|
+
prompts: prompts.map((p) => ({
|
|
513
|
+
title: p.title,
|
|
514
|
+
message: p.message,
|
|
515
|
+
})),
|
|
516
|
+
...(opts?.title ? { title: opts.title } : {}),
|
|
517
|
+
});
|
|
518
|
+
return { ok: true };
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
return { ok: false, error: e.message };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Backs the capability-gated `Thread.setTitle` for pane threads. */
|
|
525
|
+
async setThreadTitle(target, title) {
|
|
526
|
+
const t = target;
|
|
527
|
+
if (!this.isPaneTarget(t)) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
error: "thread title requires an assistant-pane thread",
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await this.client.assistant.threads.setTitle({
|
|
535
|
+
channel_id: t.channel,
|
|
536
|
+
thread_ts: t.threadTs,
|
|
537
|
+
title,
|
|
538
|
+
});
|
|
539
|
+
return { ok: true };
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
return { ok: false, error: e.message };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
decodeInteraction(raw) {
|
|
546
|
+
return decodeInteraction(raw);
|
|
547
|
+
}
|
|
548
|
+
async lookupUser(q) {
|
|
549
|
+
const query = q.query.trim().toLowerCase();
|
|
550
|
+
if (!query)
|
|
551
|
+
return undefined;
|
|
552
|
+
try {
|
|
553
|
+
let cursor;
|
|
554
|
+
do {
|
|
555
|
+
const r = (await this.client.users.list({ cursor, limit: 200 }));
|
|
556
|
+
for (const m of r.members ?? []) {
|
|
557
|
+
if (!m.id || m.deleted || m.is_bot)
|
|
558
|
+
continue;
|
|
559
|
+
const candidates = [
|
|
560
|
+
m.name,
|
|
561
|
+
m.real_name,
|
|
562
|
+
m.profile?.display_name,
|
|
563
|
+
m.profile?.email,
|
|
564
|
+
]
|
|
565
|
+
.filter((s) => Boolean(s))
|
|
566
|
+
.map((s) => s.toLowerCase());
|
|
567
|
+
if (candidates.some((c) => c === query || c.startsWith(query))) {
|
|
568
|
+
return {
|
|
569
|
+
id: m.id,
|
|
570
|
+
name: m.real_name ?? m.name,
|
|
571
|
+
handle: m.name,
|
|
572
|
+
email: m.profile?.email,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
cursor = r.response_metadata?.next_cursor || undefined;
|
|
577
|
+
} while (cursor);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Resolve a Slack user id to a richer `PlatformUser` (name + email) for each
|
|
586
|
+
* turn, cached by id so repeat turns in the same conversation are cheap.
|
|
587
|
+
* Tolerates lookup failure by falling back to a bare `{ id }`.
|
|
588
|
+
*/
|
|
589
|
+
async resolveUser(userId) {
|
|
590
|
+
const cached = this.userCache.get(userId);
|
|
591
|
+
if (cached)
|
|
592
|
+
return cached;
|
|
593
|
+
let user = { id: userId };
|
|
594
|
+
try {
|
|
595
|
+
const r = (await this.client.users.info({ user: userId }));
|
|
596
|
+
const u = r.user;
|
|
597
|
+
if (u?.id) {
|
|
598
|
+
user = {
|
|
599
|
+
id: u.id,
|
|
600
|
+
name: u.real_name ??
|
|
601
|
+
u.profile?.real_name ??
|
|
602
|
+
u.profile?.display_name ??
|
|
603
|
+
u.name,
|
|
604
|
+
email: u.profile?.email,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Fall back to the bare id on any lookup failure.
|
|
610
|
+
}
|
|
611
|
+
this.userCache.set(userId, user);
|
|
612
|
+
return user;
|
|
613
|
+
}
|
|
614
|
+
get conversationStore() {
|
|
615
|
+
const store = this.store;
|
|
616
|
+
return {
|
|
617
|
+
async getOrCreate(conversationKey, replyTarget, makeAgent) {
|
|
618
|
+
const idx = conversationKey.indexOf("::");
|
|
619
|
+
const channelId = idx >= 0 ? conversationKey.slice(0, idx) : conversationKey;
|
|
620
|
+
const scope = idx >= 0 ? conversationKey.slice(idx + 2) : DM_SCOPE;
|
|
621
|
+
const key = { channelId, scope };
|
|
622
|
+
const session = await store.getOrCreate(key, replyTarget, makeAgent);
|
|
623
|
+
return { agent: session.agent };
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Read the conversation's recent messages. Backs the capability-gated
|
|
629
|
+
* `Thread.getMessages()`. For a thread target we read its replies; a flat
|
|
630
|
+
* target (DM, no `threadTs`) has no thread to fetch, so we return `[]`.
|
|
631
|
+
* Capped defensively to the last 100 messages.
|
|
632
|
+
*/
|
|
633
|
+
async getMessages(target) {
|
|
634
|
+
const t = target;
|
|
635
|
+
const threadTs = t.threadTs;
|
|
636
|
+
if (!threadTs)
|
|
637
|
+
return [];
|
|
638
|
+
let messages = [];
|
|
639
|
+
try {
|
|
640
|
+
const r = (await this.client.conversations.replies({
|
|
641
|
+
channel: t.channel,
|
|
642
|
+
ts: threadTs,
|
|
643
|
+
limit: 100,
|
|
644
|
+
}));
|
|
645
|
+
messages = r.messages ?? [];
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
const out = [];
|
|
651
|
+
for (const m of messages.slice(-100)) {
|
|
652
|
+
// Skip Slack's own join/system subtype messages; keep regular messages
|
|
653
|
+
// and file shares.
|
|
654
|
+
if (m.subtype && m.subtype !== "file_share")
|
|
655
|
+
continue;
|
|
656
|
+
out.push({
|
|
657
|
+
text: m.text ?? "",
|
|
658
|
+
ts: m.ts,
|
|
659
|
+
isBot: Boolean(m.bot_id),
|
|
660
|
+
user: m.user ? await this.resolveUser(m.user) : undefined,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
async postFile(target, { bytes, filename, title, altText, }) {
|
|
666
|
+
const t = target;
|
|
667
|
+
try {
|
|
668
|
+
// Slack's `FilesUploadV2Arguments` union types `thread_ts` as a
|
|
669
|
+
// required `string` when present; omit the key entirely (rather
|
|
670
|
+
// than passing `undefined`) under exactOptionalPropertyTypes.
|
|
671
|
+
const args = {
|
|
672
|
+
channel_id: t.channel,
|
|
673
|
+
file: Buffer.from(bytes),
|
|
674
|
+
filename,
|
|
675
|
+
title,
|
|
676
|
+
alt_text: altText,
|
|
677
|
+
};
|
|
678
|
+
if (t.threadTs)
|
|
679
|
+
args.thread_ts = t.threadTs;
|
|
680
|
+
await this.client.files.uploadV2(args);
|
|
681
|
+
return { ok: true };
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
return { ok: false, error: e.message };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async addReaction(target, messageRef, emoji) {
|
|
688
|
+
const name = toPlatformEmoji(emoji, "slack") ?? emoji;
|
|
689
|
+
const channel = messageRef.channel ??
|
|
690
|
+
target.channel;
|
|
691
|
+
try {
|
|
692
|
+
await this.client.reactions.add({
|
|
693
|
+
channel,
|
|
694
|
+
timestamp: messageRef.id,
|
|
695
|
+
name,
|
|
696
|
+
});
|
|
697
|
+
return { ok: true };
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
return { ok: false, error: err.message };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async removeReaction(target, messageRef, emoji) {
|
|
704
|
+
const name = toPlatformEmoji(emoji, "slack") ?? emoji;
|
|
705
|
+
const channel = messageRef.channel ??
|
|
706
|
+
target.channel;
|
|
707
|
+
try {
|
|
708
|
+
await this.client.reactions.remove({
|
|
709
|
+
channel,
|
|
710
|
+
timestamp: messageRef.id,
|
|
711
|
+
name,
|
|
712
|
+
});
|
|
713
|
+
return { ok: true };
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
return { ok: false, error: err.message };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Post an ephemeral message visible only to `user`. Slack always supports
|
|
721
|
+
* native ephemeral, so `fallbackToDM` is ignored and `usedFallback` is
|
|
722
|
+
* always `false` on success. On API error returns `{ ok: false, error }`.
|
|
723
|
+
*/
|
|
724
|
+
async postEphemeral(target, user, ir, _opts) {
|
|
725
|
+
const t = target;
|
|
726
|
+
const userId = typeof user === "string" ? user : user.id;
|
|
727
|
+
const { blocks } = renderSlackMessage(ir);
|
|
728
|
+
const text = fallbackText(ir);
|
|
729
|
+
try {
|
|
730
|
+
const res = await this.client.chat.postEphemeral({
|
|
731
|
+
channel: t.channel,
|
|
732
|
+
user: userId,
|
|
733
|
+
...(t.threadTs ? { thread_ts: t.threadTs } : {}),
|
|
734
|
+
blocks,
|
|
735
|
+
text,
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
ok: true,
|
|
739
|
+
usedFallback: false,
|
|
740
|
+
ref: {
|
|
741
|
+
id: res.message_ts ?? "",
|
|
742
|
+
channel: t.channel,
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
return { ok: false, error: err.message };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/** Render a modal IR tree to a Slack `views.open` `View` (pure; backs `openModal`). */
|
|
751
|
+
renderModal(ir) {
|
|
752
|
+
return renderSlackModal(ir);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Open a modal against a Slack `trigger_id` via `views.open`. Degrades rather
|
|
756
|
+
* than throwing: a render error (unsupported element) or API failure (expired
|
|
757
|
+
* trigger, etc.) resolves to `{ ok: false, error }`.
|
|
758
|
+
*/
|
|
759
|
+
async openModal(target, triggerId, ir) {
|
|
760
|
+
try {
|
|
761
|
+
const t = target;
|
|
762
|
+
const view = renderSlackModal(ir);
|
|
763
|
+
// Slack `view_submission`/`view_closed` payloads are detached from the
|
|
764
|
+
// originating channel, so private_metadata is the only carrier of the
|
|
765
|
+
// conversation context. Stamp a `__cpk` envelope with the reply target,
|
|
766
|
+
// preserving any author-set private_metadata under `pm` for round-trip.
|
|
767
|
+
view.private_metadata = JSON.stringify({
|
|
768
|
+
__cpk: {
|
|
769
|
+
channel: t.channel,
|
|
770
|
+
...(t.threadTs ? { threadTs: t.threadTs } : {}),
|
|
771
|
+
},
|
|
772
|
+
...(view.private_metadata !== undefined
|
|
773
|
+
? { pm: view.private_metadata }
|
|
774
|
+
: {}),
|
|
775
|
+
});
|
|
776
|
+
await this.client.views.open({
|
|
777
|
+
trigger_id: triggerId,
|
|
778
|
+
view,
|
|
779
|
+
});
|
|
780
|
+
return { ok: true };
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
return { ok: false, error: err.message };
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** Construct a Slack `PlatformAdapter`. */
|
|
788
|
+
export function slack(opts) {
|
|
789
|
+
return new SlackAdapter(opts);
|
|
790
|
+
}
|
|
791
|
+
/** Read the channel stashed on a MessageRef by `post`/`stream`. */
|
|
792
|
+
function channelOf(ref) {
|
|
793
|
+
const channel = ref.channel;
|
|
794
|
+
return typeof channel === "string" ? channel : "";
|
|
795
|
+
}
|
|
796
|
+
/** Collect a node's descendant text into a single whitespace-joined string. */
|
|
797
|
+
function collectNodeText(node) {
|
|
798
|
+
const acc = [];
|
|
799
|
+
const visit = (n) => {
|
|
800
|
+
if (typeof n.type === "string" && n.type === "text") {
|
|
801
|
+
const value = n.props?.value;
|
|
802
|
+
if (value != null)
|
|
803
|
+
acc.push(String(value));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const children = n.props?.children;
|
|
807
|
+
const list = Array.isArray(children)
|
|
808
|
+
? children
|
|
809
|
+
: children && typeof children === "object" && "type" in children
|
|
810
|
+
? [children]
|
|
811
|
+
: [];
|
|
812
|
+
for (const child of list)
|
|
813
|
+
visit(child);
|
|
814
|
+
};
|
|
815
|
+
visit(node);
|
|
816
|
+
return acc.join(" ");
|
|
817
|
+
}
|
|
818
|
+
/** Depth-first search for the first node of `type` in the IR tree. */
|
|
819
|
+
function findFirst(ir, type) {
|
|
820
|
+
for (const node of ir) {
|
|
821
|
+
if (typeof node.type === "string" && node.type === type)
|
|
822
|
+
return node;
|
|
823
|
+
const children = node.props?.children;
|
|
824
|
+
const list = Array.isArray(children)
|
|
825
|
+
? children
|
|
826
|
+
: children && typeof children === "object" && "type" in children
|
|
827
|
+
? [children]
|
|
828
|
+
: [];
|
|
829
|
+
const found = findFirst(list, type);
|
|
830
|
+
if (found)
|
|
831
|
+
return found;
|
|
832
|
+
}
|
|
833
|
+
return undefined;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Slack requires a plain-text `text` fallback alongside `blocks`/`attachments`
|
|
837
|
+
* (used for notifications and a11y) — NOT a rendering of the card body. Return
|
|
838
|
+
* a concise one-line summary: the card's header (title) if present, else the
|
|
839
|
+
* first text encountered. Collapse whitespace and truncate to ~150 chars. This
|
|
840
|
+
* MUST stay short: it is the notification text, never a dump of the whole tree
|
|
841
|
+
* (which Slack would render as a duplicate "text wall" above the card).
|
|
842
|
+
*/
|
|
843
|
+
function fallbackText(ir) {
|
|
844
|
+
const header = findFirst(ir, "header");
|
|
845
|
+
const source = header ? collectNodeText(header) : firstText(ir);
|
|
846
|
+
const text = source.replace(/\s+/g, " ").trim();
|
|
847
|
+
if (!text)
|
|
848
|
+
return "…";
|
|
849
|
+
return text.length > 150 ? text.slice(0, 149) + "…" : text;
|
|
850
|
+
}
|
|
851
|
+
/** First descendant text node's value across the whole IR, or "". */
|
|
852
|
+
function firstText(ir) {
|
|
853
|
+
for (const node of ir) {
|
|
854
|
+
const t = collectNodeText(node);
|
|
855
|
+
if (t.trim())
|
|
856
|
+
return t;
|
|
857
|
+
}
|
|
858
|
+
return "";
|
|
859
|
+
}
|