@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.
Files changed (58) hide show
  1. package/README.md +231 -43
  2. package/dist/base/BaseMessagingAdapter.d.ts +428 -0
  3. package/dist/base/BaseMessagingAdapter.d.ts.map +1 -0
  4. package/dist/base/BaseMessagingAdapter.js +934 -0
  5. package/dist/base/BaseMessagingAdapter.js.map +1 -0
  6. package/dist/base/message-formatter.d.ts +70 -0
  7. package/dist/base/message-formatter.d.ts.map +1 -0
  8. package/dist/base/message-formatter.js +201 -0
  9. package/dist/base/message-formatter.js.map +1 -0
  10. package/dist/base/types.d.ts +211 -0
  11. package/dist/base/types.d.ts.map +1 -0
  12. package/dist/base/types.js +6 -0
  13. package/dist/base/types.js.map +1 -0
  14. package/dist/index.d.ts +72 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +76 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/slack/SlackAdapter.d.ts +141 -0
  19. package/dist/slack/SlackAdapter.d.ts.map +1 -0
  20. package/dist/slack/SlackAdapter.js +291 -0
  21. package/dist/slack/SlackAdapter.js.map +1 -0
  22. package/dist/slack/SlackMessagingExtension.d.ts +148 -0
  23. package/dist/slack/SlackMessagingExtension.d.ts.map +1 -0
  24. package/dist/slack/SlackMessagingExtension.js +433 -0
  25. package/dist/slack/SlackMessagingExtension.js.map +1 -0
  26. package/dist/slack/slack-block-builder.d.ts +133 -0
  27. package/dist/slack/slack-block-builder.d.ts.map +1 -0
  28. package/dist/slack/slack-block-builder.js +748 -0
  29. package/dist/slack/slack-block-builder.js.map +1 -0
  30. package/dist/slack/slack-formatter.d.ts +37 -0
  31. package/dist/slack/slack-formatter.d.ts.map +1 -0
  32. package/dist/slack/slack-formatter.js +116 -0
  33. package/dist/slack/slack-formatter.js.map +1 -0
  34. package/dist/slack/slack-interactivity.d.ts +38 -0
  35. package/dist/slack/slack-interactivity.d.ts.map +1 -0
  36. package/dist/slack/slack-interactivity.js +414 -0
  37. package/dist/slack/slack-interactivity.js.map +1 -0
  38. package/dist/slack/slack-routes.d.ts +35 -0
  39. package/dist/slack/slack-routes.d.ts.map +1 -0
  40. package/dist/slack/slack-routes.js +98 -0
  41. package/dist/slack/slack-routes.js.map +1 -0
  42. package/dist/teams/TeamsAdapter.d.ts +155 -0
  43. package/dist/teams/TeamsAdapter.d.ts.map +1 -0
  44. package/dist/teams/TeamsAdapter.js +383 -0
  45. package/dist/teams/TeamsAdapter.js.map +1 -0
  46. package/dist/teams/TeamsMessagingExtension.d.ts +75 -0
  47. package/dist/teams/TeamsMessagingExtension.d.ts.map +1 -0
  48. package/dist/teams/TeamsMessagingExtension.js +176 -0
  49. package/dist/teams/TeamsMessagingExtension.js.map +1 -0
  50. package/dist/teams/teams-card-builder.d.ts +94 -0
  51. package/dist/teams/teams-card-builder.d.ts.map +1 -0
  52. package/dist/teams/teams-card-builder.js +648 -0
  53. package/dist/teams/teams-card-builder.js.map +1 -0
  54. package/dist/teams/teams-formatter.d.ts +39 -0
  55. package/dist/teams/teams-formatter.d.ts.map +1 -0
  56. package/dist/teams/teams-formatter.js +107 -0
  57. package/dist/teams/teams-formatter.js.map +1 -0
  58. 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