experimental-ash 0.25.2 → 0.26.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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/bin/ash.d.ts +4 -4
  3. package/bin/ash.js +12 -8
  4. package/dist/docs/public/channels/README.md +26 -2
  5. package/dist/docs/public/channels/discord.md +159 -0
  6. package/dist/docs/public/channels/slack.md +14 -2
  7. package/dist/src/chunks/{compile-agent-C4OrJW6C.js → compile-agent-DrIyb818.js} +1 -1
  8. package/dist/src/chunks/{dev-authored-source-watcher-PwAxalwl.js → dev-authored-source-watcher-C1WUVv9F.js} +1 -1
  9. package/dist/src/chunks/{host-F-DkwYJK.js → host-CwAcCrg7.js} +2 -2
  10. package/dist/src/chunks/paths-CWZN-XRX.js +85 -0
  11. package/dist/src/cli/commands/channels.d.ts +15 -0
  12. package/dist/src/cli/commands/channels.js +9 -0
  13. package/dist/src/cli/commands/info.js +1 -1
  14. package/dist/src/cli/run.js +1 -1
  15. package/dist/src/evals/cli/eval.js +1 -1
  16. package/dist/src/internal/application/package.js +1 -1
  17. package/dist/src/internal/process/pnpm.d.ts +28 -0
  18. package/dist/src/internal/process/pnpm.js +50 -0
  19. package/dist/src/public/channels/discord/api.d.ts +99 -0
  20. package/dist/src/public/channels/discord/api.js +167 -0
  21. package/dist/src/public/channels/discord/defaults.d.ts +9 -0
  22. package/dist/src/public/channels/discord/defaults.js +74 -0
  23. package/dist/src/public/channels/discord/discordChannel.d.ts +132 -0
  24. package/dist/src/public/channels/discord/discordChannel.js +402 -0
  25. package/dist/src/public/channels/discord/hitl.d.ts +34 -0
  26. package/dist/src/public/channels/discord/hitl.js +194 -0
  27. package/dist/src/public/channels/discord/inbound.d.ts +97 -0
  28. package/dist/src/public/channels/discord/inbound.js +238 -0
  29. package/dist/src/public/channels/discord/index.d.ts +7 -0
  30. package/dist/src/public/channels/discord/index.js +6 -0
  31. package/dist/src/public/channels/discord/responses.d.ts +11 -0
  32. package/dist/src/public/channels/discord/responses.js +40 -0
  33. package/dist/src/public/channels/discord/verify.d.ts +38 -0
  34. package/dist/src/public/channels/discord/verify.js +72 -0
  35. package/dist/src/public/channels/discord/verifyInbound.d.ts +6 -0
  36. package/dist/src/public/channels/discord/verifyInbound.js +19 -0
  37. package/dist/src/public/channels/slack/constants.d.ts +7 -0
  38. package/dist/src/public/channels/slack/constants.js +7 -0
  39. package/dist/src/public/channels/slack/slackChannel.js +2 -1
  40. package/package.json +9 -3
  41. package/dist/src/chunks/paths-DnlVBqHu.js +0 -85
@@ -0,0 +1,402 @@
1
+ import { createLogger } from "#internal/logging.js";
2
+ import { callDiscordApi, createDiscordFollowupMessage, discordContinuationToken, editDiscordOriginalResponse, sendDiscordChannelMessage, splitDiscordMessageContent, triggerDiscordTypingIndicator, } from "#public/channels/discord/api.js";
3
+ import { defaultEvents, defaultOnCommand } from "#public/channels/discord/defaults.js";
4
+ import { deriveComponentInputResponses, deriveModalInputResponses, buildFreeformModalResponse, isDiscordFreeformComponent, } from "#public/channels/discord/hitl.js";
5
+ import { commandInteractionMessage, DISCORD_INTERACTION_RESPONSE_TYPE, DISCORD_INTERACTION_TYPE, parseDiscordInteraction, prependDiscordContext, } from "#public/channels/discord/inbound.js";
6
+ import { discordDeferredJson, discordJson, discordJsonBody, readMessageContent, } from "#public/channels/discord/responses.js";
7
+ import {} from "#public/channels/discord/verify.js";
8
+ import { verifyDiscordInbound } from "#public/channels/discord/verifyInbound.js";
9
+ import { parseJsonObject } from "#shared/json.js";
10
+ import { defineChannel, POST, } from "#public/definitions/defineChannel.js";
11
+ const log = createLogger("discord.channel");
12
+ /** Discord channel factory for HTTP Interactions and proactive channel messages. */
13
+ export function discordChannel(config = {}) {
14
+ const onCommand = config.onCommand ?? defaultOnCommand;
15
+ const mergedEvents = { ...defaultEvents, ...config.events };
16
+ return defineChannel({
17
+ kindHint: "discord",
18
+ state: initialDiscordState(),
19
+ context(state, session) {
20
+ return rebuildDiscordContext(state, session, config);
21
+ },
22
+ routes: [
23
+ POST(config.route ?? "/ash/v1/discord", async (req, { send, waitUntil }) => {
24
+ const body = await verifyDiscordInbound(req, config.credentials);
25
+ if (body === null)
26
+ return new Response("unauthorized", { status: 401 });
27
+ let raw;
28
+ try {
29
+ raw = parseJsonObject(JSON.parse(body));
30
+ }
31
+ catch (error) {
32
+ log.warn("inbound Discord body is not valid JSON", { error });
33
+ return discordJson({ content: "invalid request", ephemeral: true });
34
+ }
35
+ if (raw.type === DISCORD_INTERACTION_TYPE.PING) {
36
+ return discordJson({ type: DISCORD_INTERACTION_RESPONSE_TYPE.PONG });
37
+ }
38
+ const interaction = parseDiscordInteraction(raw);
39
+ if (interaction === null) {
40
+ return discordJson({ content: "Unsupported Discord interaction.", ephemeral: true });
41
+ }
42
+ return handleInteraction({
43
+ config,
44
+ interaction,
45
+ onCommand,
46
+ send,
47
+ waitUntil,
48
+ });
49
+ }),
50
+ ],
51
+ async receive(input, { send }) {
52
+ const receiveArgs = input.args;
53
+ const channelId = readString(receiveArgs.channelId);
54
+ if (!channelId) {
55
+ throw new Error("discordChannel().receive requires args.channelId.");
56
+ }
57
+ const requestedConversationId = readString(receiveArgs.conversationId);
58
+ const initialMessage = receiveArgs.initialMessage;
59
+ if (initialMessage !== undefined && requestedConversationId !== undefined) {
60
+ throw new Error("discordChannel().receive: `conversationId` and `initialMessage` are mutually exclusive.");
61
+ }
62
+ let conversationId = requestedConversationId ?? "";
63
+ let hasMessageAnchor = requestedConversationId !== undefined;
64
+ if (initialMessage !== undefined) {
65
+ const handle = buildDiscordHandle({
66
+ config,
67
+ state: {
68
+ ...initialDiscordState(),
69
+ channelId,
70
+ },
71
+ });
72
+ const posted = await handle.sendChannelMessage(initialMessage);
73
+ conversationId = posted.id;
74
+ hasMessageAnchor = posted.id.length > 0;
75
+ }
76
+ return send(input.message, {
77
+ auth: input.auth,
78
+ continuationToken: discordContinuationToken(channelId, conversationId),
79
+ state: {
80
+ applicationId: null,
81
+ channelId,
82
+ conversationId: conversationId || null,
83
+ guildId: null,
84
+ hasMessageAnchor,
85
+ initialResponseSent: true,
86
+ interactionToken: null,
87
+ },
88
+ });
89
+ },
90
+ events: mergedEvents,
91
+ });
92
+ }
93
+ function rebuildDiscordContext(state, session, config) {
94
+ return {
95
+ discord: buildDiscordHandle({ config, session, state }),
96
+ session,
97
+ state,
98
+ };
99
+ }
100
+ function buildDiscordHandle(input) {
101
+ const api = input.config.api;
102
+ const state = input.state;
103
+ const credentials = mergeCredentials(input.config.credentials, state);
104
+ function anchor(posted) {
105
+ if (!posted.id || state.hasMessageAnchor)
106
+ return;
107
+ state.conversationId = posted.id;
108
+ state.hasMessageAnchor = true;
109
+ if (state.channelId) {
110
+ input.session?.setContinuationToken(discordContinuationToken(state.channelId, posted.id));
111
+ }
112
+ }
113
+ async function sendViaChannel(message) {
114
+ const channelId = state.channelId ?? "";
115
+ if (!channelId)
116
+ throw new Error("discordChannel: missing channel id for outbound message.");
117
+ const posted = await sendDiscordChannelMessage({
118
+ apiBaseUrl: api?.apiBaseUrl,
119
+ body: normalizePostInput(message),
120
+ credentials,
121
+ fetch: api?.fetch,
122
+ channelId,
123
+ });
124
+ anchor(posted);
125
+ return posted;
126
+ }
127
+ async function editOriginal(message) {
128
+ const interactionToken = state.interactionToken ?? "";
129
+ if (!interactionToken) {
130
+ throw new Error("discordChannel: missing interaction token for original response edit.");
131
+ }
132
+ const posted = await editDiscordOriginalResponse({
133
+ apiBaseUrl: api?.apiBaseUrl,
134
+ body: normalizePostInput(message),
135
+ credentials,
136
+ fetch: api?.fetch,
137
+ interactionToken,
138
+ });
139
+ state.initialResponseSent = true;
140
+ anchor(posted);
141
+ return posted;
142
+ }
143
+ async function followup(message) {
144
+ const interactionToken = state.interactionToken ?? "";
145
+ if (!interactionToken) {
146
+ throw new Error("discordChannel: missing interaction token for followup message.");
147
+ }
148
+ const posted = await createDiscordFollowupMessage({
149
+ apiBaseUrl: api?.apiBaseUrl,
150
+ body: normalizePostInput(message),
151
+ credentials,
152
+ fetch: api?.fetch,
153
+ interactionToken,
154
+ });
155
+ anchor(posted);
156
+ return posted;
157
+ }
158
+ async function startTyping() {
159
+ const channelId = state.channelId ?? "";
160
+ if (!channelId)
161
+ return;
162
+ try {
163
+ await triggerDiscordTypingIndicator({
164
+ apiBaseUrl: api?.apiBaseUrl,
165
+ credentials,
166
+ fetch: api?.fetch,
167
+ channelId,
168
+ });
169
+ }
170
+ catch (error) {
171
+ log.debug("Discord typing indicator failed — swallowed", { error });
172
+ }
173
+ }
174
+ return {
175
+ applicationId: state.applicationId ?? undefined,
176
+ channelId: state.channelId ?? "",
177
+ conversationId: state.conversationId ?? "",
178
+ guildId: state.guildId ?? undefined,
179
+ interactionToken: state.interactionToken ?? undefined,
180
+ request(path, body, options) {
181
+ return callDiscordApi({
182
+ apiBaseUrl: api?.apiBaseUrl,
183
+ body,
184
+ botToken: options?.botAuth === true ? credentials.botToken : undefined,
185
+ fetch: api?.fetch,
186
+ method: options?.method,
187
+ path,
188
+ });
189
+ },
190
+ async post(message) {
191
+ const bodies = expandPostBodies(normalizePostInput(message));
192
+ let first;
193
+ for (const body of bodies) {
194
+ const posted = await postOne({
195
+ body,
196
+ editOriginal,
197
+ followup,
198
+ sendViaChannel,
199
+ state,
200
+ });
201
+ if (first === undefined)
202
+ first = posted;
203
+ }
204
+ return first ?? { id: "", raw: null };
205
+ },
206
+ editOriginalResponse: editOriginal,
207
+ followup,
208
+ sendChannelMessage: sendViaChannel,
209
+ startTyping,
210
+ };
211
+ }
212
+ async function postOne(input) {
213
+ if (input.state.interactionToken && input.state.applicationId) {
214
+ try {
215
+ if (!input.state.initialResponseSent) {
216
+ return await input.editOriginal(input.body);
217
+ }
218
+ return await input.followup(input.body);
219
+ }
220
+ catch (error) {
221
+ log.warn("Discord interaction-token delivery failed, falling back to channel message", {
222
+ error,
223
+ });
224
+ }
225
+ }
226
+ return input.sendViaChannel(input.body);
227
+ }
228
+ async function handleInteraction(input) {
229
+ if (input.interaction.type === DISCORD_INTERACTION_TYPE.APPLICATION_COMMAND) {
230
+ return handleCommandInteraction({
231
+ config: input.config,
232
+ interaction: input.interaction,
233
+ onCommand: input.onCommand,
234
+ send: input.send,
235
+ waitUntil: input.waitUntil,
236
+ });
237
+ }
238
+ if (input.interaction.type === DISCORD_INTERACTION_TYPE.MESSAGE_COMPONENT) {
239
+ return handleComponentInteraction({
240
+ interaction: input.interaction,
241
+ send: input.send,
242
+ waitUntil: input.waitUntil,
243
+ });
244
+ }
245
+ return handleModalSubmitInteraction({
246
+ interaction: input.interaction,
247
+ send: input.send,
248
+ waitUntil: input.waitUntil,
249
+ });
250
+ }
251
+ async function handleCommandInteraction(input) {
252
+ const state = stateFromInteraction(input.interaction, {
253
+ conversationId: input.interaction.id,
254
+ hasMessageAnchor: false,
255
+ initialResponseSent: false,
256
+ });
257
+ const ctx = {
258
+ discord: buildDiscordHandle({ config: input.config, state }),
259
+ };
260
+ let result;
261
+ try {
262
+ result = await input.onCommand(ctx, input.interaction);
263
+ }
264
+ catch (error) {
265
+ log.error("command handler failed", { error });
266
+ return discordJson({ content: "The Discord command handler failed.", ephemeral: true });
267
+ }
268
+ if (result === null || result === undefined) {
269
+ return discordJson({ content: "Command ignored.", ephemeral: true });
270
+ }
271
+ input.waitUntil(dispatchCommand({
272
+ interaction: input.interaction,
273
+ result,
274
+ send: input.send,
275
+ state,
276
+ }));
277
+ return discordDeferredJson(result.ephemeral === true);
278
+ }
279
+ function handleComponentInteraction(input) {
280
+ if (isDiscordFreeformComponent(input.interaction.customId)) {
281
+ const prompt = readMessageContent(input.interaction.raw);
282
+ return discordJsonBody(buildFreeformModalResponse({
283
+ customId: input.interaction.customId,
284
+ prompt,
285
+ }));
286
+ }
287
+ const inputResponses = deriveComponentInputResponses(input.interaction);
288
+ if (inputResponses.length > 0) {
289
+ input.waitUntil(dispatchInputResponses({
290
+ conversationId: input.interaction.messageId,
291
+ inputResponses,
292
+ interaction: input.interaction,
293
+ send: input.send,
294
+ }));
295
+ }
296
+ return discordJsonBody({ type: DISCORD_INTERACTION_RESPONSE_TYPE.DEFERRED_UPDATE_MESSAGE });
297
+ }
298
+ function handleModalSubmitInteraction(input) {
299
+ const inputResponses = deriveModalInputResponses(input.interaction);
300
+ if (inputResponses.length > 0) {
301
+ input.waitUntil(dispatchInputResponses({
302
+ conversationId: input.interaction.messageId ?? input.interaction.id,
303
+ inputResponses,
304
+ interaction: input.interaction,
305
+ send: input.send,
306
+ }));
307
+ }
308
+ return discordJson({ content: "Answer received.", ephemeral: true });
309
+ }
310
+ async function dispatchCommand(input) {
311
+ const message = commandInteractionMessage(input.interaction);
312
+ const turnMessage = prependDiscordContext(message, {
313
+ channelId: input.interaction.channelId,
314
+ commandName: input.interaction.commandName,
315
+ guildId: input.interaction.guildId,
316
+ interactionId: input.interaction.id,
317
+ userId: input.interaction.user.id,
318
+ username: input.interaction.user.username,
319
+ });
320
+ try {
321
+ await input.send({
322
+ message: turnMessage,
323
+ modelContext: input.result.modelContext,
324
+ }, {
325
+ auth: input.result.auth,
326
+ continuationToken: discordContinuationToken(input.interaction.channelId, input.interaction.id),
327
+ state: input.state,
328
+ });
329
+ }
330
+ catch (error) {
331
+ log.error("command delivery failed", { error });
332
+ }
333
+ }
334
+ async function dispatchInputResponses(input) {
335
+ try {
336
+ await input.send({ inputResponses: input.inputResponses }, {
337
+ auth: null,
338
+ continuationToken: discordContinuationToken(input.interaction.channelId, input.conversationId),
339
+ state: stateFromInteraction(input.interaction, {
340
+ conversationId: input.conversationId,
341
+ hasMessageAnchor: true,
342
+ initialResponseSent: true,
343
+ }),
344
+ });
345
+ }
346
+ catch (error) {
347
+ log.error("interaction response delivery failed", { error });
348
+ }
349
+ }
350
+ function stateFromInteraction(interaction, options) {
351
+ return {
352
+ applicationId: interaction.applicationId,
353
+ channelId: interaction.channelId,
354
+ conversationId: options.conversationId,
355
+ guildId: interaction.guildId ?? null,
356
+ hasMessageAnchor: options.hasMessageAnchor,
357
+ initialResponseSent: options.initialResponseSent,
358
+ interactionToken: interaction.token,
359
+ };
360
+ }
361
+ function initialDiscordState() {
362
+ return {
363
+ applicationId: null,
364
+ channelId: null,
365
+ conversationId: null,
366
+ guildId: null,
367
+ hasMessageAnchor: false,
368
+ initialResponseSent: false,
369
+ interactionToken: null,
370
+ };
371
+ }
372
+ function mergeCredentials(credentials, state) {
373
+ const merged = {
374
+ applicationId: state.applicationId ?? credentials?.applicationId,
375
+ botToken: credentials?.botToken,
376
+ publicKey: credentials?.publicKey,
377
+ webhookVerifier: credentials?.webhookVerifier,
378
+ };
379
+ return merged;
380
+ }
381
+ function normalizePostInput(message) {
382
+ if (typeof message === "string")
383
+ return { content: message };
384
+ return message;
385
+ }
386
+ function expandPostBodies(body) {
387
+ if (typeof body.content !== "string")
388
+ return [body];
389
+ const chunks = splitDiscordMessageContent(body.content);
390
+ return chunks.map((content, index) => {
391
+ if (index === 0) {
392
+ return { ...body, content };
393
+ }
394
+ return {
395
+ allowed_mentions: body.allowed_mentions,
396
+ content,
397
+ };
398
+ });
399
+ }
400
+ function readString(value) {
401
+ return typeof value === "string" && value.length > 0 ? value : undefined;
402
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Discord HITL component rendering + decode helpers.
3
+ *
4
+ * Discord components carry a `custom_id` with a 100-character cap. Ash
5
+ * encodes only the request id and, for buttons, the selected option id.
6
+ */
7
+ import { type DiscordComponentInteraction, type DiscordModalSubmitInteraction } from "#public/channels/discord/inbound.js";
8
+ import type { InputRequest, InputResponse } from "#runtime/input/types.js";
9
+ /** Discord component type values used by the native channel. */
10
+ export declare const DISCORD_COMPONENT_TYPE: {
11
+ readonly ACTION_ROW: 1;
12
+ readonly BUTTON: 2;
13
+ readonly STRING_SELECT: 3;
14
+ readonly TEXT_INPUT: 4;
15
+ };
16
+ /** Custom id prefix for selectable HITL controls. */
17
+ export declare const DISCORD_HITL_CUSTOM_ID_PREFIX = "ash_input:";
18
+ /** Custom id prefix for the button/modal freeform flow. */
19
+ export declare const DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX = "ash_input_freeform:";
20
+ /** Text-input id inside the freeform modal. */
21
+ export declare const DISCORD_HITL_FREEFORM_TEXT_INPUT_ID = "ash_freeform_text";
22
+ /** Renders one input request as Discord message components. */
23
+ export declare function renderInputRequestComponents(request: InputRequest): readonly Readonly<Record<string, unknown>>[];
24
+ /** Builds a Discord modal response for one freeform HITL request. */
25
+ export declare function buildFreeformModalResponse(input: {
26
+ readonly customId: string;
27
+ readonly prompt: string | undefined;
28
+ }): Record<string, unknown>;
29
+ /** Returns true when a component custom id starts the freeform modal flow. */
30
+ export declare function isDiscordFreeformComponent(customId: string): boolean;
31
+ /** Decodes one component interaction into HITL input responses. */
32
+ export declare function deriveComponentInputResponses(interaction: DiscordComponentInteraction): readonly InputResponse[];
33
+ /** Decodes one modal submission into HITL input responses. */
34
+ export declare function deriveModalInputResponses(interaction: DiscordModalSubmitInteraction): readonly InputResponse[];
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Discord HITL component rendering + decode helpers.
3
+ *
4
+ * Discord components carry a `custom_id` with a 100-character cap. Ash
5
+ * encodes only the request id and, for buttons, the selected option id.
6
+ */
7
+ import { DISCORD_INTERACTION_RESPONSE_TYPE, } from "#public/channels/discord/inbound.js";
8
+ /** Discord component type values used by the native channel. */
9
+ export const DISCORD_COMPONENT_TYPE = {
10
+ ACTION_ROW: 1,
11
+ BUTTON: 2,
12
+ STRING_SELECT: 3,
13
+ TEXT_INPUT: 4,
14
+ };
15
+ /** Custom id prefix for selectable HITL controls. */
16
+ export const DISCORD_HITL_CUSTOM_ID_PREFIX = "ash_input:";
17
+ /** Custom id prefix for the button/modal freeform flow. */
18
+ export const DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX = "ash_input_freeform:";
19
+ /** Text-input id inside the freeform modal. */
20
+ export const DISCORD_HITL_FREEFORM_TEXT_INPUT_ID = "ash_freeform_text";
21
+ const DISCORD_CUSTOM_ID_MAX_LENGTH = 100;
22
+ const DISCORD_LABEL_MAX_LENGTH = 80;
23
+ const DISCORD_SELECT_OPTION_TEXT_MAX_LENGTH = 100;
24
+ const DISCORD_MODAL_TITLE_MAX_LENGTH = 45;
25
+ const DISCORD_ACTION_ROW_LIMIT = 5;
26
+ const DISCORD_SELECT_OPTION_LIMIT = 25;
27
+ /** Renders one input request as Discord message components. */
28
+ export function renderInputRequestComponents(request) {
29
+ const options = request.options;
30
+ const acceptsFreeform = request.allowFreeform === true || !options || options.length === 0;
31
+ if (options && options.length > 0 && request.display === "select") {
32
+ return [
33
+ {
34
+ components: [
35
+ {
36
+ custom_id: encodeHitlCustomId(DISCORD_HITL_CUSTOM_ID_PREFIX, {
37
+ requestId: request.requestId,
38
+ }),
39
+ options: options.slice(0, DISCORD_SELECT_OPTION_LIMIT).map((option) => {
40
+ const result = {
41
+ label: truncate(option.label, DISCORD_SELECT_OPTION_TEXT_MAX_LENGTH),
42
+ value: truncate(option.id, DISCORD_SELECT_OPTION_TEXT_MAX_LENGTH),
43
+ };
44
+ if (option.description !== undefined) {
45
+ result.description = truncate(option.description, DISCORD_SELECT_OPTION_TEXT_MAX_LENGTH);
46
+ }
47
+ return result;
48
+ }),
49
+ placeholder: "Choose an option",
50
+ type: DISCORD_COMPONENT_TYPE.STRING_SELECT,
51
+ },
52
+ ],
53
+ type: DISCORD_COMPONENT_TYPE.ACTION_ROW,
54
+ },
55
+ ];
56
+ }
57
+ if (options && options.length > 0) {
58
+ return chunk(options.slice(0, DISCORD_ACTION_ROW_LIMIT * DISCORD_ACTION_ROW_LIMIT), 5).map((row) => ({
59
+ components: row.map((option) => ({
60
+ custom_id: encodeHitlCustomId(DISCORD_HITL_CUSTOM_ID_PREFIX, {
61
+ optionId: option.id,
62
+ requestId: request.requestId,
63
+ }),
64
+ label: truncate(option.label, DISCORD_LABEL_MAX_LENGTH),
65
+ style: toDiscordButtonStyle(option.style),
66
+ type: DISCORD_COMPONENT_TYPE.BUTTON,
67
+ })),
68
+ type: DISCORD_COMPONENT_TYPE.ACTION_ROW,
69
+ }));
70
+ }
71
+ if (acceptsFreeform) {
72
+ return [
73
+ {
74
+ components: [
75
+ {
76
+ custom_id: encodeHitlCustomId(DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX, {
77
+ requestId: request.requestId,
78
+ }),
79
+ label: "Type your answer",
80
+ style: 1,
81
+ type: DISCORD_COMPONENT_TYPE.BUTTON,
82
+ },
83
+ ],
84
+ type: DISCORD_COMPONENT_TYPE.ACTION_ROW,
85
+ },
86
+ ];
87
+ }
88
+ return [];
89
+ }
90
+ /** Builds a Discord modal response for one freeform HITL request. */
91
+ export function buildFreeformModalResponse(input) {
92
+ const payload = decodeHitlCustomId(input.customId, DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX);
93
+ if (!payload) {
94
+ throw new Error("discordChannel: freeform custom_id is malformed.");
95
+ }
96
+ return {
97
+ data: {
98
+ components: [
99
+ {
100
+ components: [
101
+ {
102
+ custom_id: DISCORD_HITL_FREEFORM_TEXT_INPUT_ID,
103
+ label: "Answer",
104
+ max_length: 4000,
105
+ min_length: 1,
106
+ placeholder: "Type your answer here...",
107
+ required: true,
108
+ style: 2,
109
+ type: DISCORD_COMPONENT_TYPE.TEXT_INPUT,
110
+ },
111
+ ],
112
+ type: DISCORD_COMPONENT_TYPE.ACTION_ROW,
113
+ },
114
+ ],
115
+ custom_id: encodeHitlCustomId(DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX, {
116
+ requestId: payload.requestId,
117
+ }),
118
+ title: truncate(input.prompt ?? "Your answer", DISCORD_MODAL_TITLE_MAX_LENGTH),
119
+ },
120
+ type: DISCORD_INTERACTION_RESPONSE_TYPE.MODAL,
121
+ };
122
+ }
123
+ /** Returns true when a component custom id starts the freeform modal flow. */
124
+ export function isDiscordFreeformComponent(customId) {
125
+ return decodeHitlCustomId(customId, DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX) !== null;
126
+ }
127
+ /** Decodes one component interaction into HITL input responses. */
128
+ export function deriveComponentInputResponses(interaction) {
129
+ const payload = decodeHitlCustomId(interaction.customId, DISCORD_HITL_CUSTOM_ID_PREFIX);
130
+ if (!payload)
131
+ return [];
132
+ if (payload.optionId !== undefined) {
133
+ return [{ optionId: payload.optionId, requestId: payload.requestId }];
134
+ }
135
+ const selected = interaction.values[0];
136
+ if (selected !== undefined) {
137
+ return [{ optionId: selected, requestId: payload.requestId }];
138
+ }
139
+ return [];
140
+ }
141
+ /** Decodes one modal submission into HITL input responses. */
142
+ export function deriveModalInputResponses(interaction) {
143
+ const payload = decodeHitlCustomId(interaction.customId, DISCORD_HITL_FREEFORM_CUSTOM_ID_PREFIX);
144
+ const text = interaction.textInputs[DISCORD_HITL_FREEFORM_TEXT_INPUT_ID];
145
+ if (!payload || text === undefined)
146
+ return [];
147
+ return [{ requestId: payload.requestId, text }];
148
+ }
149
+ function encodeHitlCustomId(prefix, payload) {
150
+ const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
151
+ const customId = `${prefix}${encoded}`;
152
+ if (customId.length > DISCORD_CUSTOM_ID_MAX_LENGTH) {
153
+ throw new Error("discordChannel: HITL custom_id exceeded Discord's 100-character limit.");
154
+ }
155
+ return customId;
156
+ }
157
+ function decodeHitlCustomId(customId, prefix) {
158
+ if (!customId.startsWith(prefix))
159
+ return null;
160
+ try {
161
+ const decoded = Buffer.from(customId.slice(prefix.length), "base64url").toString("utf8");
162
+ const parsed = JSON.parse(decoded);
163
+ if (typeof parsed.requestId !== "string" || parsed.requestId.length === 0)
164
+ return null;
165
+ const result = { requestId: parsed.requestId };
166
+ if (typeof parsed.optionId === "string") {
167
+ return { ...result, optionId: parsed.optionId };
168
+ }
169
+ return result;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ function toDiscordButtonStyle(style) {
176
+ if (style === "primary")
177
+ return 1;
178
+ if (style === "danger")
179
+ return 4;
180
+ return 2;
181
+ }
182
+ function truncate(value, maxLength) {
183
+ if (value.length <= maxLength)
184
+ return value;
185
+ const sliceLength = Math.max(0, maxLength - 3);
186
+ return `${value.slice(0, sliceLength).trimEnd()}...`;
187
+ }
188
+ function chunk(values, size) {
189
+ const result = [];
190
+ for (let index = 0; index < values.length; index += size) {
191
+ result.push(values.slice(index, index + size));
192
+ }
193
+ return result;
194
+ }