@memberjunction/messaging-adapters 0.0.1 → 5.18.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/README.md +231 -43
- package/dist/base/BaseMessagingAdapter.d.ts +428 -0
- package/dist/base/BaseMessagingAdapter.d.ts.map +1 -0
- package/dist/base/BaseMessagingAdapter.js +934 -0
- package/dist/base/BaseMessagingAdapter.js.map +1 -0
- package/dist/base/message-formatter.d.ts +70 -0
- package/dist/base/message-formatter.d.ts.map +1 -0
- package/dist/base/message-formatter.js +201 -0
- package/dist/base/message-formatter.js.map +1 -0
- package/dist/base/types.d.ts +211 -0
- package/dist/base/types.d.ts.map +1 -0
- package/dist/base/types.js +6 -0
- package/dist/base/types.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/slack/SlackAdapter.d.ts +141 -0
- package/dist/slack/SlackAdapter.d.ts.map +1 -0
- package/dist/slack/SlackAdapter.js +291 -0
- package/dist/slack/SlackAdapter.js.map +1 -0
- package/dist/slack/SlackMessagingExtension.d.ts +148 -0
- package/dist/slack/SlackMessagingExtension.d.ts.map +1 -0
- package/dist/slack/SlackMessagingExtension.js +433 -0
- package/dist/slack/SlackMessagingExtension.js.map +1 -0
- package/dist/slack/slack-block-builder.d.ts +133 -0
- package/dist/slack/slack-block-builder.d.ts.map +1 -0
- package/dist/slack/slack-block-builder.js +748 -0
- package/dist/slack/slack-block-builder.js.map +1 -0
- package/dist/slack/slack-formatter.d.ts +37 -0
- package/dist/slack/slack-formatter.d.ts.map +1 -0
- package/dist/slack/slack-formatter.js +116 -0
- package/dist/slack/slack-formatter.js.map +1 -0
- package/dist/slack/slack-interactivity.d.ts +38 -0
- package/dist/slack/slack-interactivity.d.ts.map +1 -0
- package/dist/slack/slack-interactivity.js +414 -0
- package/dist/slack/slack-interactivity.js.map +1 -0
- package/dist/slack/slack-routes.d.ts +35 -0
- package/dist/slack/slack-routes.d.ts.map +1 -0
- package/dist/slack/slack-routes.js +98 -0
- package/dist/slack/slack-routes.js.map +1 -0
- package/dist/teams/TeamsAdapter.d.ts +155 -0
- package/dist/teams/TeamsAdapter.d.ts.map +1 -0
- package/dist/teams/TeamsAdapter.js +383 -0
- package/dist/teams/TeamsAdapter.js.map +1 -0
- package/dist/teams/TeamsMessagingExtension.d.ts +75 -0
- package/dist/teams/TeamsMessagingExtension.d.ts.map +1 -0
- package/dist/teams/TeamsMessagingExtension.js +176 -0
- package/dist/teams/TeamsMessagingExtension.js.map +1 -0
- package/dist/teams/teams-card-builder.d.ts +94 -0
- package/dist/teams/teams-card-builder.d.ts.map +1 -0
- package/dist/teams/teams-card-builder.js +648 -0
- package/dist/teams/teams-card-builder.js.map +1 -0
- package/dist/teams/teams-formatter.d.ts +39 -0
- package/dist/teams/teams-formatter.d.ts.map +1 -0
- package/dist/teams/teams-formatter.js +107 -0
- package/dist/teams/teams-formatter.js.map +1 -0
- package/package.json +40 -7
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @memberjunction/messaging-adapters
|
|
3
|
+
* @description Rich Slack Block Kit builder functions for agent responses.
|
|
4
|
+
*
|
|
5
|
+
* Composes structured Block Kit layouts from agent execution results,
|
|
6
|
+
* including agent identity headers, artifact cards, action buttons,
|
|
7
|
+
* media blocks, and metadata footers.
|
|
8
|
+
*
|
|
9
|
+
* @see https://api.slack.com/reference/block-kit
|
|
10
|
+
*/
|
|
11
|
+
import { markdownToBlocks } from './slack-formatter.js';
|
|
12
|
+
/** Slack enforces a hard 50-block limit per message. */
|
|
13
|
+
const SLACK_MAX_BLOCKS = 50;
|
|
14
|
+
/**
|
|
15
|
+
* Approximate max payload size in bytes. Slack's actual limit is ~50KB,
|
|
16
|
+
* but we leave headroom for the API envelope (metadata, token, etc.).
|
|
17
|
+
*/
|
|
18
|
+
const SLACK_MAX_PAYLOAD_BYTES = 38_000;
|
|
19
|
+
// ─── Full Response Text Store ─────────────────────────────────────────────
|
|
20
|
+
// Stores full response text for retrieval by the "View Full" modal.
|
|
21
|
+
// This prevents content loss when blocks are truncated for payload size.
|
|
22
|
+
/** In-memory store for full response text, keyed by unique ID. Entries expire after 30 min. */
|
|
23
|
+
const fullResponseStore = new Map();
|
|
24
|
+
const FULL_RESPONSE_TTL_MS = 30 * 60 * 1000;
|
|
25
|
+
let fullResponseCounter = 0;
|
|
26
|
+
/**
|
|
27
|
+
* Store full response text and return a retrieval key.
|
|
28
|
+
*/
|
|
29
|
+
function storeFullResponseText(text) {
|
|
30
|
+
const key = `fr_${Date.now()}_${++fullResponseCounter}`;
|
|
31
|
+
fullResponseStore.set(key, { text, timestamp: Date.now() });
|
|
32
|
+
// Cleanup expired entries
|
|
33
|
+
for (const [k, v] of fullResponseStore) {
|
|
34
|
+
if (Date.now() - v.timestamp > FULL_RESPONSE_TTL_MS) {
|
|
35
|
+
fullResponseStore.delete(k);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Retrieve full response text by store key.
|
|
42
|
+
* Returns null if expired or not found.
|
|
43
|
+
*/
|
|
44
|
+
export function getFullResponseText(key) {
|
|
45
|
+
const entry = fullResponseStore.get(key);
|
|
46
|
+
if (!entry)
|
|
47
|
+
return null;
|
|
48
|
+
if (Date.now() - entry.timestamp > FULL_RESPONSE_TTL_MS) {
|
|
49
|
+
fullResponseStore.delete(key);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return entry.text;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the complete Block Kit layout for an agent response.
|
|
56
|
+
*
|
|
57
|
+
* Layout:
|
|
58
|
+
* ```
|
|
59
|
+
* [Agent Context Header] — agent avatar + name
|
|
60
|
+
* [Divider]
|
|
61
|
+
* [Text Content Blocks] — markdown → mrkdwn sections
|
|
62
|
+
* [Artifact Card] — if structured payload detected
|
|
63
|
+
* [Media Blocks] — if mediaOutputs present
|
|
64
|
+
* [Notification Blocks] — if automatic notification commands present
|
|
65
|
+
* [Divider]
|
|
66
|
+
* [Action Buttons] — if actionableCommands present
|
|
67
|
+
* [Metadata Footer] — timing and token info
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* Enforces Slack's 50-block limit. If exceeded, truncates text blocks
|
|
71
|
+
* and adds a truncation notice.
|
|
72
|
+
*/
|
|
73
|
+
export function buildRichResponse(result, agent, responseText, options) {
|
|
74
|
+
const blocks = [];
|
|
75
|
+
// Agent context header
|
|
76
|
+
blocks.push(buildAgentContextBlock(agent));
|
|
77
|
+
blocks.push(buildDivider());
|
|
78
|
+
// Mirror MJ Explorer: show the user-facing Message text (responseText) and let
|
|
79
|
+
// the Explorer deep-link provide access to the full payload/artifact. We do NOT
|
|
80
|
+
// mine agent payloads for content — that requires hardcoding agent-specific payload
|
|
81
|
+
// shapes and inevitably leaks internal LLM state (research plans, orchestration
|
|
82
|
+
// metadata, etc.) to the user. The data model itself tells us what's user-facing:
|
|
83
|
+
// agentRun.Message is the text, the artifact is the structured content.
|
|
84
|
+
blocks.push(...buildTextBlocks(responseText));
|
|
85
|
+
// Media blocks (images from agent)
|
|
86
|
+
if (result?.mediaOutputs && result.mediaOutputs.length > 0) {
|
|
87
|
+
blocks.push(...buildMediaBlocks(result.mediaOutputs.map((m) => mediaOutputToRecord(m))));
|
|
88
|
+
}
|
|
89
|
+
// Notification blocks from automatic commands
|
|
90
|
+
const notificationBlocks = buildNotificationBlocks(result?.automaticCommands);
|
|
91
|
+
if (notificationBlocks.length > 0) {
|
|
92
|
+
blocks.push(...notificationBlocks);
|
|
93
|
+
}
|
|
94
|
+
// Action buttons (if actionableCommands present)
|
|
95
|
+
const commands = result?.actionableCommands;
|
|
96
|
+
if (commands && commands.length > 0) {
|
|
97
|
+
blocks.push(buildDivider());
|
|
98
|
+
blocks.push(...buildActionButtons(commands, options?.explorerBaseURL));
|
|
99
|
+
}
|
|
100
|
+
// Response form (choice buttons for structured input)
|
|
101
|
+
if (result?.responseForm?.questions && result.responseForm.questions.length > 0) {
|
|
102
|
+
blocks.push(...buildResponseForm(result.responseForm));
|
|
103
|
+
}
|
|
104
|
+
// "Open in MJ Explorer" link — shown for all successful agent runs when ExplorerBaseURL is configured
|
|
105
|
+
const explorerLink = buildExplorerArtifactLink(result, options?.explorerBaseURL, options?.artifactId, options?.conversationId);
|
|
106
|
+
if (explorerLink) {
|
|
107
|
+
blocks.push(...explorerLink);
|
|
108
|
+
}
|
|
109
|
+
// Metadata footer
|
|
110
|
+
if (result?.agentRun) {
|
|
111
|
+
blocks.push(buildDivider());
|
|
112
|
+
blocks.push(buildMetadataFooter(result));
|
|
113
|
+
}
|
|
114
|
+
// Enforce 50-block limit (adds "View Full" button when truncating)
|
|
115
|
+
return enforceBlockLimit(blocks, responseText);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Build a context block showing the agent's avatar and name.
|
|
119
|
+
*/
|
|
120
|
+
export function buildAgentContextBlock(agent) {
|
|
121
|
+
const elements = [];
|
|
122
|
+
const agentName = agent.Name ?? 'Agent';
|
|
123
|
+
const logoURL = agent.LogoURL;
|
|
124
|
+
if (logoURL && typeof logoURL === 'string' && logoURL.startsWith('https://')) {
|
|
125
|
+
elements.push({
|
|
126
|
+
type: 'image',
|
|
127
|
+
image_url: logoURL,
|
|
128
|
+
alt_text: agentName,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
elements.push({
|
|
132
|
+
type: 'mrkdwn',
|
|
133
|
+
text: `*${agentName}*`,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
type: 'context',
|
|
137
|
+
elements,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Convert markdown response text to Block Kit text sections.
|
|
142
|
+
* Reuses the existing `markdownToBlocks` logic from `slack-formatter.ts`.
|
|
143
|
+
*/
|
|
144
|
+
export function buildTextBlocks(markdown) {
|
|
145
|
+
return markdownToBlocks(markdown);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Build a rich artifact card from a structured payload.
|
|
149
|
+
* Renders title, summary, source links, and an optional "View Full" button.
|
|
150
|
+
*/
|
|
151
|
+
export function buildArtifactCard(artifact) {
|
|
152
|
+
const blocks = [];
|
|
153
|
+
// Title section
|
|
154
|
+
if (artifact.Title) {
|
|
155
|
+
blocks.push({
|
|
156
|
+
type: 'header',
|
|
157
|
+
text: {
|
|
158
|
+
type: 'plain_text',
|
|
159
|
+
text: truncateToLength(artifact.Title, 150),
|
|
160
|
+
emoji: true,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// Summary / Body content — render as inline preview with "View Full" for long content
|
|
165
|
+
if (artifact.Summary) {
|
|
166
|
+
const PREVIEW_LIMIT = 1500;
|
|
167
|
+
const needsModal = artifact.Summary.length > PREVIEW_LIMIT;
|
|
168
|
+
const preview = needsModal
|
|
169
|
+
? artifact.Summary.substring(0, PREVIEW_LIMIT) + '\n\n_... content continues ..._'
|
|
170
|
+
: artifact.Summary;
|
|
171
|
+
// Render preview as markdown blocks
|
|
172
|
+
const previewBlocks = markdownToBlocks(preview);
|
|
173
|
+
blocks.push(...previewBlocks.slice(0, 10));
|
|
174
|
+
// "View Full Content" button for long content
|
|
175
|
+
if (needsModal) {
|
|
176
|
+
const storeKey = storeFullResponseText(artifact.Summary);
|
|
177
|
+
blocks.push({
|
|
178
|
+
type: 'actions',
|
|
179
|
+
elements: [
|
|
180
|
+
{
|
|
181
|
+
type: 'button',
|
|
182
|
+
text: { type: 'plain_text', text: 'View Full Content', emoji: true },
|
|
183
|
+
action_id: 'mj:view_full:artifact',
|
|
184
|
+
value: storeKey,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Sections
|
|
191
|
+
if (artifact.Sections && artifact.Sections.length > 0) {
|
|
192
|
+
for (const section of artifact.Sections.slice(0, 5)) {
|
|
193
|
+
blocks.push({
|
|
194
|
+
type: 'section',
|
|
195
|
+
text: {
|
|
196
|
+
type: 'mrkdwn',
|
|
197
|
+
text: `*${section.Heading}*\n${truncateToLength(section.Content, 2900)}`,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Sources
|
|
203
|
+
if (artifact.Sources && artifact.Sources.length > 0) {
|
|
204
|
+
const sourceLinks = artifact.Sources.slice(0, 10)
|
|
205
|
+
.map((s) => `<${s.URL}|${s.Title}>`)
|
|
206
|
+
.join(' · ');
|
|
207
|
+
blocks.push({
|
|
208
|
+
type: 'context',
|
|
209
|
+
elements: [
|
|
210
|
+
{
|
|
211
|
+
type: 'mrkdwn',
|
|
212
|
+
text: `📎 Sources: ${sourceLinks}`,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// "View Full" button if URL is available
|
|
218
|
+
if (artifact.URL) {
|
|
219
|
+
blocks.push({
|
|
220
|
+
type: 'actions',
|
|
221
|
+
elements: [
|
|
222
|
+
{
|
|
223
|
+
type: 'button',
|
|
224
|
+
text: { type: 'plain_text', text: 'View Full Report', emoji: true },
|
|
225
|
+
url: artifact.URL,
|
|
226
|
+
action_id: 'mj:view_artifact',
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return blocks;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Build action buttons from agent actionable commands.
|
|
235
|
+
*
|
|
236
|
+
* Handles both command types:
|
|
237
|
+
* - `open:url` → Slack URL button (opens external link)
|
|
238
|
+
* - `open:resource` → Deep-link button to MJ Explorer if `explorerBaseURL` is configured,
|
|
239
|
+
* otherwise rendered as an informational context block showing entity/resource info
|
|
240
|
+
*
|
|
241
|
+
* Returns an array of blocks (may include both action and context blocks).
|
|
242
|
+
*/
|
|
243
|
+
export function buildActionButtons(commands, explorerBaseURL) {
|
|
244
|
+
const blocks = [];
|
|
245
|
+
const buttons = [];
|
|
246
|
+
const resourceInfoItems = [];
|
|
247
|
+
for (const cmd of commands.slice(0, 5)) {
|
|
248
|
+
if (cmd.type === 'open:url' && 'url' in cmd) {
|
|
249
|
+
buttons.push(buildURLButton(cmd.label, cmd.url, buttons.length));
|
|
250
|
+
}
|
|
251
|
+
else if (cmd.type === 'open:resource') {
|
|
252
|
+
const resourceCmd = cmd;
|
|
253
|
+
const deepLink = buildExplorerDeepLink(resourceCmd, explorerBaseURL);
|
|
254
|
+
if (deepLink) {
|
|
255
|
+
buttons.push(buildURLButton(cmd.label, deepLink, buttons.length));
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
resourceInfoItems.push(formatResourceInfo(resourceCmd));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Add clickable buttons
|
|
263
|
+
if (buttons.length > 0) {
|
|
264
|
+
blocks.push({ type: 'actions', elements: buttons });
|
|
265
|
+
}
|
|
266
|
+
// Add resource info context for open:resource without deep-link
|
|
267
|
+
if (resourceInfoItems.length > 0) {
|
|
268
|
+
blocks.push({
|
|
269
|
+
type: 'context',
|
|
270
|
+
elements: resourceInfoItems.map(text => ({
|
|
271
|
+
type: 'mrkdwn',
|
|
272
|
+
text
|
|
273
|
+
}))
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return blocks;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Build a single Slack URL button.
|
|
280
|
+
*/
|
|
281
|
+
function buildURLButton(label, url, index) {
|
|
282
|
+
return {
|
|
283
|
+
type: 'button',
|
|
284
|
+
text: { type: 'plain_text', text: truncateToLength(label ?? `Link ${index + 1}`, 75), emoji: true },
|
|
285
|
+
action_id: `mj:action_${index}`,
|
|
286
|
+
url
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Build a deep link URL into MJ Explorer for an `open:resource` command.
|
|
291
|
+
* Returns null if no explorer base URL is configured.
|
|
292
|
+
*/
|
|
293
|
+
function buildExplorerDeepLink(cmd, explorerBaseURL) {
|
|
294
|
+
if (!explorerBaseURL)
|
|
295
|
+
return null;
|
|
296
|
+
const base = explorerBaseURL.replace(/\/+$/, '');
|
|
297
|
+
switch (cmd.resourceType) {
|
|
298
|
+
case 'Record':
|
|
299
|
+
if (cmd.entityName && cmd.resourceId) {
|
|
300
|
+
const entity = encodeURIComponent(cmd.entityName);
|
|
301
|
+
const id = encodeURIComponent(cmd.resourceId);
|
|
302
|
+
return `${base}/resource/record/${entity}/${id}`;
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
case 'Dashboard':
|
|
306
|
+
return `${base}/resource/dashboard/${encodeURIComponent(cmd.resourceId)}`;
|
|
307
|
+
case 'Report':
|
|
308
|
+
return `${base}/resource/report/${encodeURIComponent(cmd.resourceId)}`;
|
|
309
|
+
case 'View':
|
|
310
|
+
return `${base}/resource/view/${encodeURIComponent(cmd.resourceId)}`;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Format an `open:resource` command as descriptive text for a context block.
|
|
316
|
+
* Used when no MJ Explorer URL is configured for deep-linking.
|
|
317
|
+
*/
|
|
318
|
+
function formatResourceInfo(cmd) {
|
|
319
|
+
const label = cmd.label ?? 'Resource';
|
|
320
|
+
const typeIcon = RESOURCE_TYPE_ICONS[cmd.resourceType] ?? '';
|
|
321
|
+
const entityNote = cmd.entityName ? ` (${cmd.entityName})` : '';
|
|
322
|
+
return `${typeIcon} ${label}${entityNote} — _open in MJ Explorer_`;
|
|
323
|
+
}
|
|
324
|
+
/** Icons for resource types in context blocks. */
|
|
325
|
+
const RESOURCE_TYPE_ICONS = {
|
|
326
|
+
Record: ':page_facing_up:',
|
|
327
|
+
Dashboard: ':bar_chart:',
|
|
328
|
+
Report: ':clipboard:',
|
|
329
|
+
Form: ':pencil:',
|
|
330
|
+
View: ':mag:',
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Build MJ Explorer deep-link context blocks.
|
|
334
|
+
*
|
|
335
|
+
* - No artifact: single "Open conversation in MJ Explorer" link
|
|
336
|
+
* - With artifact: two links — conversation + artifact
|
|
337
|
+
* - Neither ID present: returns `null`
|
|
338
|
+
*/
|
|
339
|
+
function buildExplorerArtifactLink(_result, explorerBaseURL, artifactId, conversationId) {
|
|
340
|
+
if (!explorerBaseURL)
|
|
341
|
+
return null;
|
|
342
|
+
if (!conversationId && !artifactId)
|
|
343
|
+
return null;
|
|
344
|
+
const base = explorerBaseURL.replace(/\/+$/, '');
|
|
345
|
+
const links = [];
|
|
346
|
+
if (conversationId) {
|
|
347
|
+
const convoLink = `${base}/app/Chat/Conversations?conversationId=${encodeURIComponent(conversationId)}`;
|
|
348
|
+
links.push(`:speech_balloon: <${convoLink}|Open conversation in MJ Explorer>`);
|
|
349
|
+
}
|
|
350
|
+
if (artifactId) {
|
|
351
|
+
const artifactLink = `${base}/resource/artifact/${encodeURIComponent(artifactId)}`;
|
|
352
|
+
links.push(`:desktop_computer: <${artifactLink}|View full artifact in MJ Explorer>`);
|
|
353
|
+
}
|
|
354
|
+
return [
|
|
355
|
+
{
|
|
356
|
+
type: 'context',
|
|
357
|
+
elements: links.map(text => ({ type: 'mrkdwn', text }))
|
|
358
|
+
}
|
|
359
|
+
];
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Build notification blocks from automatic commands.
|
|
363
|
+
*
|
|
364
|
+
* Only handles `notification` type automatic commands — other types
|
|
365
|
+
* (like `refresh:data`) have no meaningful Slack representation.
|
|
366
|
+
*
|
|
367
|
+
* Renders notifications as styled context blocks with severity icons.
|
|
368
|
+
*/
|
|
369
|
+
export function buildNotificationBlocks(commands) {
|
|
370
|
+
if (!commands || commands.length === 0)
|
|
371
|
+
return [];
|
|
372
|
+
const blocks = [];
|
|
373
|
+
for (const cmd of commands) {
|
|
374
|
+
if (cmd.type !== 'notification')
|
|
375
|
+
continue;
|
|
376
|
+
const icon = NOTIFICATION_ICONS[cmd.severity ?? 'info'];
|
|
377
|
+
blocks.push({
|
|
378
|
+
type: 'context',
|
|
379
|
+
elements: [{
|
|
380
|
+
type: 'mrkdwn',
|
|
381
|
+
text: `${icon} ${cmd.message}`
|
|
382
|
+
}]
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return blocks;
|
|
386
|
+
}
|
|
387
|
+
/** Severity icons for notification automatic commands. */
|
|
388
|
+
const NOTIFICATION_ICONS = {
|
|
389
|
+
success: ':white_check_mark:',
|
|
390
|
+
info: ':information_source:',
|
|
391
|
+
warning: ':warning:',
|
|
392
|
+
error: ':x:',
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* Build image blocks from media outputs.
|
|
396
|
+
*/
|
|
397
|
+
export function buildMediaBlocks(mediaOutputs) {
|
|
398
|
+
return mediaOutputs
|
|
399
|
+
.filter((m) => typeof m.url === 'string' && m.url.startsWith('https://'))
|
|
400
|
+
.slice(0, 5)
|
|
401
|
+
.map((m) => ({
|
|
402
|
+
type: 'image',
|
|
403
|
+
image_url: m.url,
|
|
404
|
+
alt_text: m.title ?? m.alt ?? 'Agent output',
|
|
405
|
+
title: m.title ? { type: 'plain_text', text: truncateToLength(m.title, 200) } : undefined,
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Build a warning-styled error display block.
|
|
410
|
+
*/
|
|
411
|
+
export function buildErrorBlocks(errorMessage) {
|
|
412
|
+
return [
|
|
413
|
+
{
|
|
414
|
+
type: 'section',
|
|
415
|
+
text: {
|
|
416
|
+
type: 'mrkdwn',
|
|
417
|
+
text: `⚠️ ${errorMessage}`,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Build a metadata footer with timing and token information.
|
|
424
|
+
*/
|
|
425
|
+
export function buildMetadataFooter(result) {
|
|
426
|
+
const parts = [];
|
|
427
|
+
// Timing
|
|
428
|
+
const agentRun = result.agentRun;
|
|
429
|
+
if (agentRun) {
|
|
430
|
+
const startTime = agentRun.StartedAt;
|
|
431
|
+
const endTime = agentRun.CompletedAt;
|
|
432
|
+
if (startTime && endTime) {
|
|
433
|
+
const durationMs = new Date(endTime).getTime() - new Date(startTime).getTime();
|
|
434
|
+
const durationSec = (durationMs / 1000).toFixed(1);
|
|
435
|
+
parts.push(`Completed in ${durationSec}s`);
|
|
436
|
+
}
|
|
437
|
+
// Steps count
|
|
438
|
+
const stepCount = agentRun.Steps?.length ?? 0;
|
|
439
|
+
if (stepCount > 0) {
|
|
440
|
+
parts.push(`${stepCount} step${stepCount === 1 ? '' : 's'}`);
|
|
441
|
+
}
|
|
442
|
+
// Token usage
|
|
443
|
+
const tokens = agentRun.TotalTokensUsed;
|
|
444
|
+
if (tokens != null && tokens > 0) {
|
|
445
|
+
parts.push(`${tokens.toLocaleString()} tokens`);
|
|
446
|
+
}
|
|
447
|
+
// Cost (prefer rollup which includes sub-agents)
|
|
448
|
+
const cost = agentRun.TotalCostRollup ?? agentRun.TotalCost;
|
|
449
|
+
if (cost != null && cost > 0) {
|
|
450
|
+
parts.push(`$${cost.toFixed(cost < 0.01 ? 4 : 2)}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
type: 'context',
|
|
455
|
+
elements: [
|
|
456
|
+
{
|
|
457
|
+
type: 'mrkdwn',
|
|
458
|
+
text: parts.length > 0 ? parts.join(' · ') : 'Completed',
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Build a divider block.
|
|
465
|
+
*/
|
|
466
|
+
export function buildDivider() {
|
|
467
|
+
return { type: 'divider' };
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Convert a strongly-typed MediaOutput to a plain record for buildMediaBlocks.
|
|
471
|
+
*/
|
|
472
|
+
function mediaOutputToRecord(m) {
|
|
473
|
+
return {
|
|
474
|
+
url: m.url,
|
|
475
|
+
title: m.label ?? m.description,
|
|
476
|
+
alt: m.description ?? m.label ?? 'Agent output',
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Build response form blocks from an AgentResponseForm.
|
|
481
|
+
*
|
|
482
|
+
* All forms render as a summary + a single green button that opens a Slack modal.
|
|
483
|
+
* This is agent-agnostic and avoids partial submissions — the user fills out
|
|
484
|
+
* everything in the modal and submits once.
|
|
485
|
+
*/
|
|
486
|
+
export function buildResponseForm(form) {
|
|
487
|
+
const blocks = [];
|
|
488
|
+
// Form title
|
|
489
|
+
if (form.title) {
|
|
490
|
+
blocks.push({
|
|
491
|
+
type: 'section',
|
|
492
|
+
text: { type: 'mrkdwn', text: `*${form.title}*` },
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (form.description) {
|
|
496
|
+
blocks.push({
|
|
497
|
+
type: 'context',
|
|
498
|
+
elements: [{ type: 'mrkdwn', text: form.description }],
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
// Compact summary of fields
|
|
502
|
+
const fieldNames = form.questions.map((q) => q.label).join(', ');
|
|
503
|
+
blocks.push({
|
|
504
|
+
type: 'context',
|
|
505
|
+
elements: [{ type: 'mrkdwn', text: `_Fields: ${truncateToLength(fieldNames, 280)}_` }],
|
|
506
|
+
});
|
|
507
|
+
// Single green button opens the full modal
|
|
508
|
+
const formJson = JSON.stringify(form);
|
|
509
|
+
blocks.push({
|
|
510
|
+
type: 'actions',
|
|
511
|
+
elements: [
|
|
512
|
+
{
|
|
513
|
+
type: 'button',
|
|
514
|
+
text: { type: 'plain_text', text: form.submitLabel ?? 'Fill Out Form', emoji: true },
|
|
515
|
+
action_id: 'mj:form_modal:open',
|
|
516
|
+
value: formJson.length <= 2000 ? formJson : 'too_large',
|
|
517
|
+
style: 'primary',
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
});
|
|
521
|
+
return blocks;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Build a Slack modal view definition from an AgentResponseForm.
|
|
525
|
+
* Used when the form contains non-choice questions that need input fields.
|
|
526
|
+
*
|
|
527
|
+
* Slack modals support: plain_text_input, number_input, datepicker, checkboxes,
|
|
528
|
+
* radio_buttons, static_select, and multi_static_select.
|
|
529
|
+
*/
|
|
530
|
+
export function buildFormModal(form) {
|
|
531
|
+
const modalBlocks = [];
|
|
532
|
+
for (const question of form.questions) {
|
|
533
|
+
const element = buildModalInputElement(question);
|
|
534
|
+
if (element) {
|
|
535
|
+
modalBlocks.push({
|
|
536
|
+
type: 'input',
|
|
537
|
+
block_id: `mj_form_${question.id}`,
|
|
538
|
+
label: { type: 'plain_text', text: truncateToLength(question.label, 2000) },
|
|
539
|
+
element,
|
|
540
|
+
optional: !question.required,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
type: 'modal',
|
|
546
|
+
callback_id: 'mj:form_modal:submit',
|
|
547
|
+
title: { type: 'plain_text', text: truncateToLength(form.title ?? 'Form', 24) },
|
|
548
|
+
submit: { type: 'plain_text', text: form.submitLabel ?? 'Submit' },
|
|
549
|
+
close: { type: 'plain_text', text: 'Cancel' },
|
|
550
|
+
blocks: modalBlocks,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Build the appropriate Slack input element for a form question type.
|
|
555
|
+
*/
|
|
556
|
+
function buildModalInputElement(question) {
|
|
557
|
+
const qType = question.type;
|
|
558
|
+
switch (qType.type) {
|
|
559
|
+
case 'text':
|
|
560
|
+
case 'textarea':
|
|
561
|
+
case 'email': {
|
|
562
|
+
const textType = qType;
|
|
563
|
+
return {
|
|
564
|
+
type: 'plain_text_input',
|
|
565
|
+
action_id: `mj:form_field:${question.id}`,
|
|
566
|
+
multiline: textType.type === 'textarea',
|
|
567
|
+
...(textType.placeholder ? { placeholder: { type: 'plain_text', text: textType.placeholder } } : {}),
|
|
568
|
+
...(textType.maxLength ? { max_length: textType.maxLength } : {}),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
case 'number':
|
|
572
|
+
case 'currency':
|
|
573
|
+
return {
|
|
574
|
+
type: 'number_input',
|
|
575
|
+
action_id: `mj:form_field:${question.id}`,
|
|
576
|
+
is_decimal_allowed: qType.type === 'currency',
|
|
577
|
+
...(qType.min != null ? { min_value: String(qType.min) } : {}),
|
|
578
|
+
...(qType.max != null ? { max_value: String(qType.max) } : {}),
|
|
579
|
+
};
|
|
580
|
+
case 'date':
|
|
581
|
+
case 'datetime':
|
|
582
|
+
return {
|
|
583
|
+
type: 'datepicker',
|
|
584
|
+
action_id: `mj:form_field:${question.id}`,
|
|
585
|
+
};
|
|
586
|
+
case 'buttongroup':
|
|
587
|
+
case 'radio': {
|
|
588
|
+
const opts = qType.options;
|
|
589
|
+
return {
|
|
590
|
+
type: 'radio_buttons',
|
|
591
|
+
action_id: `mj:form_field:${question.id}`,
|
|
592
|
+
options: opts.slice(0, 10).map((opt) => ({
|
|
593
|
+
text: { type: 'plain_text', text: truncateToLength(String(opt.label), 75) },
|
|
594
|
+
value: String(opt.value),
|
|
595
|
+
})),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
case 'dropdown': {
|
|
599
|
+
const opts = qType.options;
|
|
600
|
+
return {
|
|
601
|
+
type: 'static_select',
|
|
602
|
+
action_id: `mj:form_field:${question.id}`,
|
|
603
|
+
options: opts.slice(0, 100).map((opt) => ({
|
|
604
|
+
text: { type: 'plain_text', text: truncateToLength(String(opt.label), 75) },
|
|
605
|
+
value: String(opt.value),
|
|
606
|
+
})),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
case 'checkbox': {
|
|
610
|
+
const opts = qType.options;
|
|
611
|
+
return {
|
|
612
|
+
type: 'checkboxes',
|
|
613
|
+
action_id: `mj:form_field:${question.id}`,
|
|
614
|
+
options: opts.slice(0, 10).map((opt) => ({
|
|
615
|
+
text: { type: 'plain_text', text: truncateToLength(String(opt.label), 75) },
|
|
616
|
+
value: String(opt.value),
|
|
617
|
+
})),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
default:
|
|
621
|
+
// Fallback for slider, time, date_range, etc. — use text input
|
|
622
|
+
return {
|
|
623
|
+
type: 'plain_text_input',
|
|
624
|
+
action_id: `mj:form_field:${question.id}`,
|
|
625
|
+
placeholder: { type: 'plain_text', text: `Enter ${question.label}` },
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Enforce Slack's block count (≤ 50) AND payload byte size (~38KB) limits.
|
|
631
|
+
*
|
|
632
|
+
* Strategy when over budget:
|
|
633
|
+
* 1. Trim block count to ≤ 50
|
|
634
|
+
* 2. If still over byte budget, progressively truncate long section text
|
|
635
|
+
* 3. If still over, remove tail blocks
|
|
636
|
+
* 4. Always reserve last 2 slots for truncation notice + "View Full" button
|
|
637
|
+
*
|
|
638
|
+
* @param blocks - The full set of blocks to potentially truncate.
|
|
639
|
+
* @param fullText - The full response text, stored for retrieval in the "View Full" modal.
|
|
640
|
+
*/
|
|
641
|
+
function enforceBlockLimit(blocks, fullText) {
|
|
642
|
+
// Store full text for later retrieval by the "View Full" modal
|
|
643
|
+
const storeKey = fullText ? storeFullResponseText(fullText) : undefined;
|
|
644
|
+
// Phase 1: block count enforcement
|
|
645
|
+
let result = blocks.length > SLACK_MAX_BLOCKS
|
|
646
|
+
? blocks.slice(0, SLACK_MAX_BLOCKS - 2)
|
|
647
|
+
: blocks;
|
|
648
|
+
// Phase 2: byte size enforcement
|
|
649
|
+
result = trimBlocksForPayloadSize(result, SLACK_MAX_PAYLOAD_BYTES);
|
|
650
|
+
// If we trimmed anything (block count or byte size), add notice + button
|
|
651
|
+
if (result.length < blocks.length || result !== blocks) {
|
|
652
|
+
// Check if we already added truncation elements (trimBlocksForPayloadSize may have)
|
|
653
|
+
const lastBlock = result[result.length - 1];
|
|
654
|
+
const hasNotice = lastBlock && lastBlock.type === 'actions' &&
|
|
655
|
+
Array.isArray(lastBlock.elements) &&
|
|
656
|
+
lastBlock.elements.some((e) => e.action_id === 'mj:view_full:response');
|
|
657
|
+
if (!hasNotice) {
|
|
658
|
+
result = appendTruncationNotice(result, storeKey);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Progressively trim blocks to fit within the byte budget.
|
|
665
|
+
* First truncates long text fields, then removes tail blocks.
|
|
666
|
+
*/
|
|
667
|
+
function trimBlocksForPayloadSize(blocks, maxBytes) {
|
|
668
|
+
let serialized = JSON.stringify(blocks);
|
|
669
|
+
if (serialized.length <= maxBytes)
|
|
670
|
+
return blocks;
|
|
671
|
+
// Pass 1: truncate section text to 500 chars
|
|
672
|
+
let trimmed = truncateBlockTexts(blocks, 500);
|
|
673
|
+
serialized = JSON.stringify(trimmed);
|
|
674
|
+
if (serialized.length <= maxBytes)
|
|
675
|
+
return trimmed;
|
|
676
|
+
// Pass 2: truncate section text to 200 chars
|
|
677
|
+
trimmed = truncateBlockTexts(blocks, 200);
|
|
678
|
+
serialized = JSON.stringify(trimmed);
|
|
679
|
+
if (serialized.length <= maxBytes)
|
|
680
|
+
return trimmed;
|
|
681
|
+
// Pass 3: remove blocks from the tail until under budget (reserve 2 for notice)
|
|
682
|
+
const reduced = [...trimmed];
|
|
683
|
+
while (reduced.length > 2 && JSON.stringify(reduced).length > maxBytes) {
|
|
684
|
+
reduced.splice(reduced.length - 1, 1);
|
|
685
|
+
}
|
|
686
|
+
return reduced;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Clone blocks with section text fields truncated to maxChars.
|
|
690
|
+
*/
|
|
691
|
+
function truncateBlockTexts(blocks, maxChars) {
|
|
692
|
+
return blocks.map((block) => {
|
|
693
|
+
if (block.type !== 'section')
|
|
694
|
+
return block;
|
|
695
|
+
const textObj = block.text;
|
|
696
|
+
if (!textObj || typeof textObj.text !== 'string')
|
|
697
|
+
return block;
|
|
698
|
+
if (textObj.text.length <= maxChars)
|
|
699
|
+
return block;
|
|
700
|
+
return {
|
|
701
|
+
...block,
|
|
702
|
+
text: {
|
|
703
|
+
...textObj,
|
|
704
|
+
text: textObj.text.substring(0, maxChars) + '...',
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Append truncation notice and "View Full Response" button to a block array.
|
|
711
|
+
*/
|
|
712
|
+
function appendTruncationNotice(blocks, storeKey) {
|
|
713
|
+
// Ensure room for 2 extra blocks
|
|
714
|
+
const trimmed = blocks.length > SLACK_MAX_BLOCKS - 2
|
|
715
|
+
? blocks.slice(0, SLACK_MAX_BLOCKS - 2)
|
|
716
|
+
: [...blocks];
|
|
717
|
+
trimmed.push({
|
|
718
|
+
type: 'context',
|
|
719
|
+
elements: [
|
|
720
|
+
{
|
|
721
|
+
type: 'mrkdwn',
|
|
722
|
+
text: '⋯ _Response truncated due to length._',
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
});
|
|
726
|
+
const buttonValue = storeKey ?? 'no_stored_text';
|
|
727
|
+
trimmed.push({
|
|
728
|
+
type: 'actions',
|
|
729
|
+
elements: [
|
|
730
|
+
{
|
|
731
|
+
type: 'button',
|
|
732
|
+
text: { type: 'plain_text', text: 'View Full Response', emoji: true },
|
|
733
|
+
action_id: 'mj:view_full:response',
|
|
734
|
+
value: buttonValue,
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
});
|
|
738
|
+
return trimmed;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Truncate a string to a given length with ellipsis.
|
|
742
|
+
*/
|
|
743
|
+
function truncateToLength(text, maxLength) {
|
|
744
|
+
if (text.length <= maxLength)
|
|
745
|
+
return text;
|
|
746
|
+
return text.substring(0, maxLength - 3) + '...';
|
|
747
|
+
}
|
|
748
|
+
//# sourceMappingURL=slack-block-builder.js.map
|