pi-roundtable 0.19.2 → 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.
@@ -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
- answeredLine,
25
- answerModal,
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, type PanelContent } from "./owner-panel.ts";
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
- title: card.title,
284
- sections,
285
- rows: card.rows(id, disabled),
286
- footer,
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 timer = wait
301
- ? setTimeout(() => {
302
- if (!this.#open.has(id)) return;
303
- late = true;
304
- signal?.removeEventListener("abort", abort);
305
- resolve(wait.pending);
306
- }, graceMs)
307
- : undefined;
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
- title: card.title,
312
- sections,
313
- rows: (disabled) => card.rows(id, disabled),
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
- content: PanelContent,
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
- closed(approved ? messages().cardApproved : messages().cardDeclined),
419
+ card.view(approved ? messages().cardApproved : messages().cardDeclined),
461
420
  );
462
421
  return true;
463
422
  }
464
- const question = card.question;
465
- if (!question) return true;
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
+ }
@@ -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(", ").slice(0, 300)}`,
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("、").slice(0, 300)}`,
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
+ }