pi-roundtable 0.19.1 → 0.20.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/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/docs/plugins.md +32 -3
- package/package.json +2 -1
- package/src/core/agents/agent-prompt.ts +1 -1
- package/src/core/discord/owner-card-answers.ts +180 -0
- package/src/core/discord/owner-card-asks.ts +149 -0
- package/src/core/discord/owner-card-questionnaire.ts +281 -0
- package/src/core/discord/owner-card-rows.ts +40 -18
- package/src/core/discord/owner-card-types.ts +54 -5
- package/src/core/discord/owner-cards-fixture.ts +37 -3
- package/src/core/discord/owner-cards.ts +61 -133
- package/src/core/discord/utf16.ts +11 -0
- package/src/core/i18n/cards.ts +32 -2
- package/src/core/interactions/prompts.ts +33 -22
- package/src/core/interactions/questionnaire.ts +213 -0
- package/src/core/runtime/ask-questions.ts +93 -0
- package/src/core/runtime/extensions/ask-user-arguments.ts +52 -0
- package/src/core/runtime/extensions/ask-user-text.ts +146 -0
- package/src/core/runtime/extensions/ask-user.ts +87 -81
- package/src/core/runtime/prompt-slot.ts +21 -1
- package/src/core/testing/surface-contract-questionnaire.ts +192 -0
- package/src/core/testing/surface-contract-support.ts +22 -0
- package/src/core/testing/surface-contract.ts +21 -19
- package/src/index.ts +11 -0
|
@@ -5,30 +5,24 @@ import type { ChannelKey } from "../domain/conversation.ts";
|
|
|
5
5
|
import { messages } from "../i18n/index.ts";
|
|
6
6
|
import type {
|
|
7
7
|
Approval,
|
|
8
|
-
OwnerAnswer,
|
|
9
|
-
OwnerQuestion,
|
|
10
8
|
PromptScope,
|
|
11
9
|
Prompts,
|
|
12
|
-
PromptWait,
|
|
13
10
|
} from "../interactions/prompts.ts";
|
|
14
11
|
import type { Logger } from "../log.ts";
|
|
15
|
-
import {
|
|
16
|
-
type Audience,
|
|
17
|
-
type CardAudienceOptions,
|
|
18
|
-
CardAudiences,
|
|
19
|
-
} from "./card-audience.ts";
|
|
12
|
+
import { type CardAudienceOptions, CardAudiences } from "./card-audience.ts";
|
|
20
13
|
import { CardCopies, copyReply, offeredCopy } from "./card-copies.ts";
|
|
21
14
|
import { DiscordOwners, discordUser } from "./discord-owners.ts";
|
|
22
15
|
import type { InteractionModule } from "./interaction-module.ts";
|
|
23
16
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
answerDeferred,
|
|
18
|
+
answerForm,
|
|
19
|
+
answerSingle,
|
|
20
|
+
showPreviews,
|
|
21
|
+
} from "./owner-card-answers.ts";
|
|
22
|
+
import { askPrompts } from "./owner-card-asks.ts";
|
|
23
|
+
import {
|
|
26
24
|
approvalRows,
|
|
27
|
-
askRows,
|
|
28
25
|
CARD_PREFIX,
|
|
29
|
-
OTHER,
|
|
30
|
-
type Rows,
|
|
31
|
-
TEXT_FIELD,
|
|
32
26
|
withdrawnFooter,
|
|
33
27
|
} from "./owner-card-rows.ts";
|
|
34
28
|
import type {
|
|
@@ -36,10 +30,11 @@ import type {
|
|
|
36
30
|
CardChannel,
|
|
37
31
|
CardMessage,
|
|
38
32
|
CardPayload,
|
|
33
|
+
CardSpec,
|
|
39
34
|
LateTurn,
|
|
40
35
|
OpenCard,
|
|
41
36
|
} from "./owner-card-types.ts";
|
|
42
|
-
import { ownerPanel
|
|
37
|
+
import { ownerPanel } from "./owner-panel.ts";
|
|
43
38
|
|
|
44
39
|
export type {
|
|
45
40
|
CardChannel,
|
|
@@ -47,6 +42,7 @@ export type {
|
|
|
47
42
|
LateTurn,
|
|
48
43
|
} from "./owner-card-types.ts";
|
|
49
44
|
export { CARD_PREFIX };
|
|
45
|
+
|
|
50
46
|
/** How long a turn that can be resumed waits on its card before it goes on without the answer. */
|
|
51
47
|
export const CARD_GRACE_MS = 2 * 60_000;
|
|
52
48
|
|
|
@@ -120,6 +116,12 @@ export class OwnerCards implements InteractionModule {
|
|
|
120
116
|
prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
|
|
121
117
|
const channelId = parseChannelKey(channel).id;
|
|
122
118
|
return {
|
|
119
|
+
...askPrompts({
|
|
120
|
+
channelId,
|
|
121
|
+
scope,
|
|
122
|
+
audiences: this.#audiences,
|
|
123
|
+
post: (c, s, u, x, card) => this.#post(c, s, u, x, card),
|
|
124
|
+
}),
|
|
123
125
|
confirm: async (
|
|
124
126
|
title,
|
|
125
127
|
message,
|
|
@@ -177,37 +179,6 @@ export class OwnerCards implements InteractionModule {
|
|
|
177
179
|
},
|
|
178
180
|
);
|
|
179
181
|
},
|
|
180
|
-
ask: async (title, question, signal, wait) => {
|
|
181
|
-
const audience = await this.#audiences.question(scope);
|
|
182
|
-
if (!audience) return undefined;
|
|
183
|
-
return this.#post<OwnerAnswer | "pending" | undefined>(
|
|
184
|
-
channelId,
|
|
185
|
-
signal,
|
|
186
|
-
undefined,
|
|
187
|
-
undefined,
|
|
188
|
-
{
|
|
189
|
-
audience,
|
|
190
|
-
...(wait
|
|
191
|
-
? {
|
|
192
|
-
wait: {
|
|
193
|
-
pending: "pending",
|
|
194
|
-
late: (value, by) =>
|
|
195
|
-
wait.late({
|
|
196
|
-
kind: "question",
|
|
197
|
-
answer: value as OwnerAnswer,
|
|
198
|
-
by: by.user.name,
|
|
199
|
-
}),
|
|
200
|
-
},
|
|
201
|
-
}
|
|
202
|
-
: {}),
|
|
203
|
-
title,
|
|
204
|
-
sections: [question.question],
|
|
205
|
-
question,
|
|
206
|
-
rows: (id, disabled) => askRows(id, question, disabled),
|
|
207
|
-
footer: messages().cardQuestionFooter,
|
|
208
|
-
},
|
|
209
|
-
);
|
|
210
|
-
},
|
|
211
182
|
};
|
|
212
183
|
}
|
|
213
184
|
|
|
@@ -220,28 +191,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
220
191
|
signal: AbortSignal | undefined,
|
|
221
192
|
unposted: T,
|
|
222
193
|
cancelled: T,
|
|
223
|
-
card:
|
|
224
|
-
audience: Audience;
|
|
225
|
-
title: string;
|
|
226
|
-
sections: string[];
|
|
227
|
-
/** The text behind the card's copy button, kept under the card's id. */
|
|
228
|
-
copy?: string;
|
|
229
|
-
/** Closes the card, also after its turn stopped waiting, when it aborts; see `ApprovalOptions.supersede`. */
|
|
230
|
-
supersede?: AbortSignal;
|
|
231
|
-
question?: OwnerQuestion;
|
|
232
|
-
rows(id: string, disabled: boolean): Rows;
|
|
233
|
-
footer: string;
|
|
234
|
-
wait?: {
|
|
235
|
-
pending: T;
|
|
236
|
-
late(
|
|
237
|
-
value: unknown,
|
|
238
|
-
by: Answerer,
|
|
239
|
-
prompt: string,
|
|
240
|
-
): ReturnType<PromptWait["late"]>;
|
|
241
|
-
};
|
|
242
|
-
/** Hears who approved the card, before it resolves approved in its turn. */
|
|
243
|
-
approved?(by: Answerer, prompt: string): void;
|
|
244
|
-
},
|
|
194
|
+
card: CardSpec<T>,
|
|
245
195
|
): Promise<T> {
|
|
246
196
|
const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
|
|
247
197
|
if (signal?.aborted || card.supersede?.aborted)
|
|
@@ -279,12 +229,13 @@ export class OwnerCards implements InteractionModule {
|
|
|
279
229
|
};
|
|
280
230
|
const view = (footer: string, disabled = true) =>
|
|
281
231
|
this.#panel(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
232
|
+
card.build?.(id, footer, disabled) ??
|
|
233
|
+
ownerPanel({
|
|
234
|
+
title: card.title,
|
|
235
|
+
sections,
|
|
236
|
+
rows: card.rows?.(id, disabled) ?? [],
|
|
237
|
+
footer,
|
|
238
|
+
}),
|
|
288
239
|
open.mention === true,
|
|
289
240
|
card.audience.mentions,
|
|
290
241
|
);
|
|
@@ -297,20 +248,26 @@ export class OwnerCards implements InteractionModule {
|
|
|
297
248
|
close(withdrawnFooter(card.supersede));
|
|
298
249
|
if (!late) resolve(cancelled);
|
|
299
250
|
};
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
251
|
+
const arm = () =>
|
|
252
|
+
wait
|
|
253
|
+
? setTimeout(() => {
|
|
254
|
+
if (!this.#open.has(id)) return;
|
|
255
|
+
late = true;
|
|
256
|
+
signal?.removeEventListener("abort", abort);
|
|
257
|
+
resolve(wait.pending);
|
|
258
|
+
}, graceMs)
|
|
259
|
+
: undefined;
|
|
260
|
+
let timer = arm();
|
|
308
261
|
signal?.addEventListener("abort", abort, { once: true });
|
|
309
262
|
card.supersede?.addEventListener("abort", withdraw, { once: true });
|
|
310
263
|
const open: OpenCard = {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
264
|
+
view,
|
|
265
|
+
isOpen: () => this.#open.has(id),
|
|
266
|
+
touch: () => {
|
|
267
|
+
if (late || !this.#open.has(id)) return;
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
timer = arm();
|
|
270
|
+
},
|
|
314
271
|
audience: card.audience,
|
|
315
272
|
end: (value, by) => {
|
|
316
273
|
// Withdrawn or answered already: this answer changes nothing.
|
|
@@ -336,6 +293,8 @@ export class OwnerCards implements InteractionModule {
|
|
|
336
293
|
return true;
|
|
337
294
|
},
|
|
338
295
|
...(card.question ? { question: card.question } : {}),
|
|
296
|
+
...(card.form ? { form: card.form } : {}),
|
|
297
|
+
...(card.previews ? { previews: card.previews } : {}),
|
|
339
298
|
};
|
|
340
299
|
this.#open.set(id, open);
|
|
341
300
|
this.#options
|
|
@@ -358,11 +317,10 @@ export class OwnerCards implements InteractionModule {
|
|
|
358
317
|
|
|
359
318
|
/** The card's panel; in a thread it leads with the mention of who may answer. */
|
|
360
319
|
#panel(
|
|
361
|
-
|
|
320
|
+
panel: CardPayload,
|
|
362
321
|
mention: boolean,
|
|
363
322
|
userIds: readonly string[],
|
|
364
323
|
): CardPayload {
|
|
365
|
-
const panel = ownerPanel(content);
|
|
366
324
|
if (!mention) return panel;
|
|
367
325
|
return {
|
|
368
326
|
...panel,
|
|
@@ -386,7 +344,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
386
344
|
!interaction.customId.startsWith(CARD_PREFIX)
|
|
387
345
|
)
|
|
388
346
|
return false;
|
|
389
|
-
const [id = "", action = ""] = interaction.customId
|
|
347
|
+
const [id = "", action = "", index = ""] = interaction.customId
|
|
390
348
|
.slice(CARD_PREFIX.length)
|
|
391
349
|
.split(":");
|
|
392
350
|
// Copying answers nothing: anyone who sees the card may copy, as often as they like, also
|
|
@@ -408,6 +366,10 @@ export class OwnerCards implements InteractionModule {
|
|
|
408
366
|
});
|
|
409
367
|
return true;
|
|
410
368
|
}
|
|
369
|
+
if (action === "previews") {
|
|
370
|
+
await showPreviews(card, interaction);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
411
373
|
const user = discordUser(interaction);
|
|
412
374
|
const by: Answerer = {
|
|
413
375
|
user,
|
|
@@ -415,6 +377,14 @@ export class OwnerCards implements InteractionModule {
|
|
|
415
377
|
...(interaction.guildId ? { guildId: interaction.guildId } : {}),
|
|
416
378
|
isDirect: !interaction.inGuild(),
|
|
417
379
|
};
|
|
380
|
+
// A menu pick or Submit is deferred before the audience is looked up, which may take a while.
|
|
381
|
+
if (
|
|
382
|
+
card.form &&
|
|
383
|
+
(action === "qpick" || action === "submit" || action === "qtext")
|
|
384
|
+
) {
|
|
385
|
+
await answerDeferred(card, action, Number(index), interaction, by);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
418
388
|
if (!(await card.audience.allows(user))) {
|
|
419
389
|
await interaction.reply({
|
|
420
390
|
content: card.audience.refusal,
|
|
@@ -422,17 +392,6 @@ export class OwnerCards implements InteractionModule {
|
|
|
422
392
|
});
|
|
423
393
|
return true;
|
|
424
394
|
}
|
|
425
|
-
const closed = (outcome: string) =>
|
|
426
|
-
this.#panel(
|
|
427
|
-
{
|
|
428
|
-
title: card.title,
|
|
429
|
-
sections: card.sections,
|
|
430
|
-
rows: card.rows(true),
|
|
431
|
-
footer: outcome,
|
|
432
|
-
},
|
|
433
|
-
card.mention === true,
|
|
434
|
-
card.audience.mentions,
|
|
435
|
-
);
|
|
436
395
|
if (interaction.isButton() && (action === "yes" || action === "no")) {
|
|
437
396
|
const approved = action === "yes";
|
|
438
397
|
const approver = approved
|
|
@@ -457,43 +416,12 @@ export class OwnerCards implements InteractionModule {
|
|
|
457
416
|
return true;
|
|
458
417
|
}
|
|
459
418
|
await interaction.update(
|
|
460
|
-
|
|
419
|
+
card.view(approved ? messages().cardApproved : messages().cardDeclined),
|
|
461
420
|
);
|
|
462
421
|
return true;
|
|
463
422
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (interaction.isButton() && action === "write") {
|
|
467
|
-
card.picked = [];
|
|
468
|
-
await interaction.showModal(answerModal(id));
|
|
469
|
-
return true;
|
|
470
|
-
}
|
|
471
|
-
if (interaction.isStringSelectMenu() && action === "pick") {
|
|
472
|
-
const values = interaction.values;
|
|
473
|
-
const choices = question.options.flatMap((option, i) =>
|
|
474
|
-
values.includes(String(i)) ? [option.label] : [],
|
|
475
|
-
);
|
|
476
|
-
if (values.includes(OTHER)) {
|
|
477
|
-
card.picked = choices;
|
|
478
|
-
await interaction.showModal(answerModal(id));
|
|
479
|
-
return true;
|
|
480
|
-
}
|
|
481
|
-
const answer = { choices };
|
|
482
|
-
card.end(answer, by);
|
|
483
|
-
await interaction.update(closed(answeredLine(answer)));
|
|
484
|
-
return true;
|
|
485
|
-
}
|
|
486
|
-
if (interaction.isModalSubmit() && action === "text") {
|
|
487
|
-
const answer = {
|
|
488
|
-
choices: card.picked ?? [],
|
|
489
|
-
text: interaction.fields.getTextInputValue(TEXT_FIELD),
|
|
490
|
-
};
|
|
491
|
-
card.end(answer, by);
|
|
492
|
-
if (interaction.isFromMessage())
|
|
493
|
-
await interaction.update(closed(answeredLine(answer)));
|
|
494
|
-
else await interaction.deferUpdate();
|
|
495
|
-
return true;
|
|
496
|
-
}
|
|
423
|
+
if (card.form) await answerForm(card, id, action, interaction);
|
|
424
|
+
else await answerSingle(card, id, action, interaction, by);
|
|
497
425
|
return true;
|
|
498
426
|
}
|
|
499
427
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `text` cut to at most `max` UTF-16 units, the count discord.js validates, without splitting a
|
|
3
|
+
* surrogate pair; with `ellipsis` the cut ends in `…` inside the limit.
|
|
4
|
+
*/
|
|
5
|
+
export function clip(text: string, max: number, ellipsis = false): string {
|
|
6
|
+
if (text.length <= max) return text;
|
|
7
|
+
let end = ellipsis ? max - 1 : max;
|
|
8
|
+
const last = text.charCodeAt(end - 1);
|
|
9
|
+
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
|
10
|
+
return ellipsis ? `${text.slice(0, end)}…` : text.slice(0, end);
|
|
11
|
+
}
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { CatalogContext } from "./types.ts";
|
|
2
2
|
|
|
3
|
+
/** `text` cut to at most 300 UTF-16 units without splitting a surrogate pair. */
|
|
4
|
+
function clip(text: string, max: number): string {
|
|
5
|
+
if (text.length <= max) return text;
|
|
6
|
+
const last = text.charCodeAt(max - 1);
|
|
7
|
+
return text.slice(0, last >= 0xd800 && last <= 0xdbff ? max - 1 : max);
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
/** The owner's approval and question cards. */
|
|
4
11
|
export function cardsEn(ctx: CatalogContext) {
|
|
5
12
|
return {
|
|
@@ -36,7 +43,19 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
36
43
|
cardModalField: "Your answer",
|
|
37
44
|
cardQuote: (text: string) => `“${text}”`,
|
|
38
45
|
cardAnswered: (parts: readonly string[]) =>
|
|
39
|
-
`Answered: ${parts.join(", ")
|
|
46
|
+
`Answered: ${clip(parts.join(", "), 300)}`,
|
|
47
|
+
cardPreviewsFile:
|
|
48
|
+
"The previews contain code blocks, so they are attached as a file.",
|
|
49
|
+
cardSubmitLabel: "Submit",
|
|
50
|
+
cardWriteLabel: "Write answers / note…",
|
|
51
|
+
cardPreviewsLabel: "Previews",
|
|
52
|
+
cardQuestionName: (n: number) => `Question ${n}`,
|
|
53
|
+
cardNoteField: "Note on all answers",
|
|
54
|
+
cardNothingYet: "Choose or write at least one answer first.",
|
|
55
|
+
cardQuestionnaireFooter: (answered: number, total: number) =>
|
|
56
|
+
`${answered} of ${total} answered; submit when you are ready.`,
|
|
57
|
+
cardUnanswered: "not answered",
|
|
58
|
+
cardNoteLine: (note: string) => `Note: ${note}`,
|
|
40
59
|
};
|
|
41
60
|
}
|
|
42
61
|
|
|
@@ -71,6 +90,17 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
71
90
|
cardModalField: "你的回答",
|
|
72
91
|
cardQuote: (text: string) => `「${text}」`,
|
|
73
92
|
cardAnswered: (parts: readonly string[]) =>
|
|
74
|
-
`已回答:${parts.join("、")
|
|
93
|
+
`已回答:${clip(parts.join("、"), 300)}`,
|
|
94
|
+
cardPreviewsFile: "預覽裡有程式碼區塊,所以用檔案附上。",
|
|
95
|
+
cardSubmitLabel: "送出",
|
|
96
|
+
cardWriteLabel: "寫答案/備註…",
|
|
97
|
+
cardPreviewsLabel: "預覽",
|
|
98
|
+
cardQuestionName: (n: number) => `問題 ${n}`,
|
|
99
|
+
cardNoteField: "給全部答案的備註",
|
|
100
|
+
cardNothingYet: "請先選或寫至少一個答案。",
|
|
101
|
+
cardQuestionnaireFooter: (answered: number, total: number) =>
|
|
102
|
+
`已回答 ${answered}/${total} 題;準備好再送出。`,
|
|
103
|
+
cardUnanswered: "未回答",
|
|
104
|
+
cardNoteLine: (note: string) => `備註:${note}`,
|
|
75
105
|
};
|
|
76
106
|
}
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import type { ConversationVisibility } from "../conversations/conversation-registry.ts";
|
|
2
2
|
import type { TurnRequest } from "../domain/ports.ts";
|
|
3
3
|
import type { Speaker, Tier } from "../speakers.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AskSupport,
|
|
6
|
+
OwnerAnswer,
|
|
7
|
+
OwnerQuestion,
|
|
8
|
+
Questionnaire,
|
|
9
|
+
QuestionnaireAnswer,
|
|
10
|
+
} from "./questionnaire.ts";
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
AskOption,
|
|
14
|
+
AskSupport,
|
|
15
|
+
OwnerAnswer,
|
|
16
|
+
OwnerQuestion,
|
|
17
|
+
Questionnaire,
|
|
18
|
+
QuestionnaireAnswer,
|
|
19
|
+
} from "./questionnaire.ts";
|
|
4
20
|
|
|
5
21
|
/**
|
|
6
22
|
* How a prompt ended: answered yes or no; `expired` when the card was shown and no one answered
|
|
@@ -135,6 +151,7 @@ export type LateAnswer = { by: string } & (
|
|
|
135
151
|
prompt?: string;
|
|
136
152
|
}
|
|
137
153
|
| { kind: "question"; answer: OwnerAnswer }
|
|
154
|
+
| { kind: "questionnaire"; answer: QuestionnaireAnswer }
|
|
138
155
|
);
|
|
139
156
|
|
|
140
157
|
/**
|
|
@@ -148,28 +165,6 @@ export interface PromptWait {
|
|
|
148
165
|
late(answer: LateAnswer): string | undefined;
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
export interface AskOption {
|
|
152
|
-
label: string;
|
|
153
|
-
description?: string;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface OwnerQuestion {
|
|
157
|
-
question: string;
|
|
158
|
-
/** Up to 25; none means a free-text answer. */
|
|
159
|
-
options: readonly AskOption[];
|
|
160
|
-
/** More than one option may be chosen. */
|
|
161
|
-
multi: boolean;
|
|
162
|
-
/** They may write an answer of their own besides the options. */
|
|
163
|
-
allowOther: boolean;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export interface OwnerAnswer {
|
|
167
|
-
/** The chosen options' labels, in the order they were offered. */
|
|
168
|
-
choices: string[];
|
|
169
|
-
/** What they wrote themselves, if anything. */
|
|
170
|
-
text?: string;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
168
|
/**
|
|
174
169
|
* Who may answer a running turn's prompts: the person whose turn asks, and, in a conversation
|
|
175
170
|
* others share, the owners. The runtime makes it from the turn's speaker and the conversation's
|
|
@@ -235,6 +230,22 @@ export interface Prompts {
|
|
|
235
230
|
signal?: AbortSignal,
|
|
236
231
|
wait?: PromptWait,
|
|
237
232
|
): Promise<OwnerAnswer | "pending" | undefined>;
|
|
233
|
+
/**
|
|
234
|
+
* What `questionnaire` can show; absent means the 0.19 contract (one question, no previews,
|
|
235
|
+
* no note), and `ask` is the only way to ask.
|
|
236
|
+
*/
|
|
237
|
+
asks?: AskSupport;
|
|
238
|
+
/**
|
|
239
|
+
* Asks several questions on one card, shaped for `asks` (see `shapeFor`); the answer is
|
|
240
|
+
* index-aligned with the questions. Results are as for `ask`; a late answer is a
|
|
241
|
+
* `questionnaire` `LateAnswer`. Present only on a surface that declares `asks`.
|
|
242
|
+
*/
|
|
243
|
+
questionnaire?(
|
|
244
|
+
title: string,
|
|
245
|
+
questionnaire: Questionnaire,
|
|
246
|
+
signal?: AbortSignal,
|
|
247
|
+
wait?: PromptWait,
|
|
248
|
+
): Promise<QuestionnaireAnswer | "pending" | undefined>;
|
|
238
249
|
}
|
|
239
250
|
|
|
240
251
|
/** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** One choice a question offers. */
|
|
2
|
+
export interface AskOption {
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
/** A markdown mock-up or snippet shown beside the option, where the surface can; see `AskSupport.previews`. */
|
|
6
|
+
preview?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OwnerQuestion {
|
|
10
|
+
question: string;
|
|
11
|
+
/** A short name for the question in a set (see `QUESTIONNAIRE_LIMITS.headerChars`); surfaces that take one question drop it. */
|
|
12
|
+
header?: string;
|
|
13
|
+
/** Up to 25; none means a free-text answer. */
|
|
14
|
+
options: readonly AskOption[];
|
|
15
|
+
/** More than one option may be chosen. */
|
|
16
|
+
multi: boolean;
|
|
17
|
+
/** They may write an answer of their own besides the options. */
|
|
18
|
+
allowOther: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OwnerAnswer {
|
|
22
|
+
/** The chosen options' labels, in the order they were offered. */
|
|
23
|
+
choices: string[];
|
|
24
|
+
/** What they wrote themselves, if anything. */
|
|
25
|
+
text?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One to four related questions asked on a single card. */
|
|
29
|
+
export interface Questionnaire {
|
|
30
|
+
questions: readonly OwnerQuestion[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The answer to a `Questionnaire`: `answers[i]` is for `questions[i]`, undefined when left blank. */
|
|
34
|
+
export interface QuestionnaireAnswer {
|
|
35
|
+
answers: readonly (OwnerAnswer | undefined)[];
|
|
36
|
+
/** A note on the whole set, where the surface takes one. */
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a surface can show of a questionnaire. A `Prompts` without `asks` is the 0.19 contract:
|
|
42
|
+
* one question, no previews, no note.
|
|
43
|
+
*/
|
|
44
|
+
export interface AskSupport {
|
|
45
|
+
/** The most questions one card holds (1 to 4). */
|
|
46
|
+
questions: number;
|
|
47
|
+
/** Options may carry a `preview`. */
|
|
48
|
+
previews: boolean;
|
|
49
|
+
/** The card takes a note on the whole set. */
|
|
50
|
+
note: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const SINGLE_QUESTION: AskSupport = {
|
|
54
|
+
questions: 1,
|
|
55
|
+
previews: false,
|
|
56
|
+
note: false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Every limit of a questionnaire, one table for the tool schema, the validation and the tests. */
|
|
60
|
+
export const QUESTIONNAIRE_LIMITS = {
|
|
61
|
+
questions: 4,
|
|
62
|
+
/** A question's text, alone in a call. */
|
|
63
|
+
questionChars: 1500,
|
|
64
|
+
/** Question texts and headers together when a call holds several questions. */
|
|
65
|
+
setChars: 3000,
|
|
66
|
+
headerChars: 30,
|
|
67
|
+
options: 25,
|
|
68
|
+
labelChars: 100,
|
|
69
|
+
descriptionChars: 100,
|
|
70
|
+
/** One option's preview. */
|
|
71
|
+
previewChars: 1200,
|
|
72
|
+
/** All previews of a call together. */
|
|
73
|
+
previewTotalChars: 10000,
|
|
74
|
+
/** Their own answer to one question, and the note. */
|
|
75
|
+
answerChars: 1000,
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
const L = QUESTIONNAIRE_LIMITS;
|
|
79
|
+
|
|
80
|
+
const pointsOf = (text: string) => Array.from(text).length;
|
|
81
|
+
const units = (text: string) => Math.max(pointsOf(text), text.length);
|
|
82
|
+
|
|
83
|
+
function duplicate(values: readonly string[]): string | undefined {
|
|
84
|
+
return values.find((v, i) => values.indexOf(v) !== i);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The model-facing reason a questionnaire is refused, or undefined when it is sound. */
|
|
88
|
+
export function validateQuestionnaire(q: Questionnaire): string | undefined {
|
|
89
|
+
const n = q.questions.length;
|
|
90
|
+
if (n < 1 || n > L.questions)
|
|
91
|
+
return `Ask between 1 and ${L.questions} questions in one call (got ${n}).`;
|
|
92
|
+
const many = n > 1;
|
|
93
|
+
let setChars = 0;
|
|
94
|
+
let previews = 0;
|
|
95
|
+
for (const [i, question] of q.questions.entries()) {
|
|
96
|
+
const at = many ? `Question ${i + 1}: ` : "";
|
|
97
|
+
const text = question.question.trim();
|
|
98
|
+
if (text === "") return `${at}the question is empty.`;
|
|
99
|
+
if (!many && pointsOf(question.question) > L.questionChars)
|
|
100
|
+
return `${at}the question is over ${L.questionChars} characters.`;
|
|
101
|
+
if (
|
|
102
|
+
question.header !== undefined &&
|
|
103
|
+
pointsOf(question.header) > L.headerChars
|
|
104
|
+
)
|
|
105
|
+
return `${at}the header is over ${L.headerChars} characters.`;
|
|
106
|
+
// Discord counts UTF-16 units, so an emoji or rare character weighs two here.
|
|
107
|
+
setChars += units(question.question) + units(question.header ?? "");
|
|
108
|
+
if (question.options.length > L.options)
|
|
109
|
+
return `${at}offer at most ${L.options} options.`;
|
|
110
|
+
for (const option of question.options) {
|
|
111
|
+
if (option.label.trim() === "") return `${at}an option has no label.`;
|
|
112
|
+
if (pointsOf(option.label) > L.labelChars)
|
|
113
|
+
return `${at}an option label is over ${L.labelChars} characters.`;
|
|
114
|
+
if (pointsOf(option.description ?? "") > L.descriptionChars)
|
|
115
|
+
return `${at}an option description is over ${L.descriptionChars} characters.`;
|
|
116
|
+
const preview = pointsOf(option.preview ?? "");
|
|
117
|
+
if (preview > L.previewChars)
|
|
118
|
+
return `${at}the preview of "${option.label}" is over ${L.previewChars} characters.`;
|
|
119
|
+
previews += preview;
|
|
120
|
+
}
|
|
121
|
+
const same = duplicate(question.options.map((o) => o.label));
|
|
122
|
+
if (same !== undefined)
|
|
123
|
+
return `${at}the option "${same}" is offered twice.`;
|
|
124
|
+
}
|
|
125
|
+
if (many && setChars > L.setChars)
|
|
126
|
+
return `The questions and headers together are over ${L.setChars} characters (an emoji or rare character counts as two); shorten them or ask fewer.`;
|
|
127
|
+
if (previews > L.previewTotalChars)
|
|
128
|
+
return `The previews together are over ${L.previewTotalChars} characters.`;
|
|
129
|
+
const twice = duplicate(q.questions.map((x) => x.question.trim()));
|
|
130
|
+
if (twice !== undefined) return `The question "${twice}" is asked twice.`;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether an answer is one the question allows: offered labels, one unless multi, own text only where allowed. */
|
|
135
|
+
export function checkAnswer(q: OwnerQuestion, a: OwnerAnswer): boolean {
|
|
136
|
+
const labels = q.options.map((option) => option.label);
|
|
137
|
+
const text = a.text?.trim();
|
|
138
|
+
if (!a.choices.every((choice) => labels.includes(choice))) return false;
|
|
139
|
+
if (new Set(a.choices).size !== a.choices.length) return false;
|
|
140
|
+
if (!q.multi && a.choices.length > 1) return false;
|
|
141
|
+
if (labels.length === 0) return a.choices.length === 0 && Boolean(text);
|
|
142
|
+
if (text && !q.allowOther) return false;
|
|
143
|
+
return a.choices.length > 0 || Boolean(text);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether an answer fits the set: aligned with the questions, each given answer allowed, something given. */
|
|
147
|
+
export function checkQuestionnaireAnswer(
|
|
148
|
+
q: Questionnaire,
|
|
149
|
+
a: QuestionnaireAnswer,
|
|
150
|
+
): boolean {
|
|
151
|
+
if (a.answers.length !== q.questions.length) return false;
|
|
152
|
+
const note = a.note?.trim();
|
|
153
|
+
if (a.note !== undefined && pointsOf(a.note) > L.answerChars) return false;
|
|
154
|
+
let given = Boolean(note);
|
|
155
|
+
for (const [i, answer] of a.answers.entries()) {
|
|
156
|
+
const question = q.questions[i];
|
|
157
|
+
if (answer === undefined || !question) continue;
|
|
158
|
+
if (!checkAnswer(question, answer)) return false;
|
|
159
|
+
if (answer.text !== undefined && pointsOf(answer.text) > L.answerChars)
|
|
160
|
+
return false;
|
|
161
|
+
given = true;
|
|
162
|
+
}
|
|
163
|
+
return given;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `text` cut to `max` characters with a closing `…`. */
|
|
167
|
+
function cut(text: string, max: number): string {
|
|
168
|
+
const chars = Array.from(text);
|
|
169
|
+
return chars.length <= max ? text : `${chars.slice(0, max - 1).join("")}…`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A fenced block for `body`, its fence longer than any backtick run inside. */
|
|
173
|
+
function fenced(body: string): string {
|
|
174
|
+
const longest = Math.max(
|
|
175
|
+
0,
|
|
176
|
+
...(body.match(/`+/g) ?? []).map((run) => run.length),
|
|
177
|
+
);
|
|
178
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
179
|
+
return `${fence}\n${body}\n${fence}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function foldPreviews(question: OwnerQuestion): OwnerQuestion {
|
|
183
|
+
if (!question.options.some((o) => o.preview)) return question;
|
|
184
|
+
const shown = question.options
|
|
185
|
+
.filter((o) => o.preview)
|
|
186
|
+
.map((o) => `**${o.label}**\n${fenced(o.preview ?? "")}`);
|
|
187
|
+
return {
|
|
188
|
+
...question,
|
|
189
|
+
question: cut(
|
|
190
|
+
`${question.question}\n\n${shown.join("\n\n")}`,
|
|
191
|
+
L.questionChars,
|
|
192
|
+
),
|
|
193
|
+
options: question.options.map(({ preview: _preview, ...rest }) => rest),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The questionnaire a surface with `support` can show; the same questions, in the same order.
|
|
199
|
+
* A surface without previews gets each preview appended to its question as a fenced block, cut
|
|
200
|
+
* to the question limit. A surface that takes one question drops the headers. Questions beyond
|
|
201
|
+
* `support.questions` are not removed here: the runtime asks a surface that takes fewer one
|
|
202
|
+
* question at a time.
|
|
203
|
+
*/
|
|
204
|
+
export function shapeFor(q: Questionnaire, support: AskSupport): Questionnaire {
|
|
205
|
+
return {
|
|
206
|
+
questions: q.questions.map((question) => {
|
|
207
|
+
const shaped = support.previews ? question : foldPreviews(question);
|
|
208
|
+
if (support.questions > 1) return shaped;
|
|
209
|
+
const { header: _header, ...rest } = shaped;
|
|
210
|
+
return rest;
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|