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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.20.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - The core model for asking several questions in one `ask_user` call (`pi-roundtable`): `Questionnaire` (one to four `OwnerQuestion`s), `QuestionnaireAnswer` (`answers` aligned with the questions, each undefined when left blank, and an optional `note`), `AskSupport` (`{ questions, previews, note }`, what a surface can show) and `SINGLE_QUESTION` (the 0.19 contract). `AskOption` gains `preview` and `OwnerQuestion` gains `header`. `Prompts` gains two optional members, `asks` and `questionnaire(title, questionnaire, signal?, wait?)`; a `Prompts` without them type-checks and behaves as before. `LateAnswer` gains `{ kind: "questionnaire"; answer: QuestionnaireAnswer }`, which only a `questionnaire` call produces, so a consumer that switches on `kind` exhaustively needs a case for it. `QUESTIONNAIRE_LIMITS` is the one table of limits; `validateQuestionnaire` returns the model-facing reason a set is refused; `checkAnswer` and `checkQuestionnaireAnswer` check an answer against its questions; `shapeFor` (`shapeFor(questionnaire, support)`) degrades a set to what a surface shows, folding previews into the question text as fenced blocks for a surface without previews and dropping headers for one that takes a single question.
13
+
14
+ - The Discord card for a whole `ask_user` set (`pi-roundtable`): `Prompts.asks` is `{ questions: 4, previews: true, note: true }` and `Prompts.questionnaire` posts one card with a menu per question that has options (it can be cleared again), **Submit**, **Write answers / note…** (a form with one field per question that takes text of its own, plus a note on the whole set; five fields at most), and, when an option has a preview, **Previews**. The draft is one per card and shared by everyone who may answer; each pick or edit restarts the grace period of a turn that waits on the card, and the one who presses Submit is reported as the answerer. Answers may be partial, but Submit with nothing chosen or written is refused. Previews opens an ephemeral reply for anyone who can see the card, never answers, and is sent as a file when a preview holds a code fence. A Submit after the turn stopped waiting starts a turn with the questionnaire answer. One question with previews is the single-question card with a Previews button, so it still answers on the first click. The card keeps within Discord's limits for four questions of 25 options each (ten container children, 40 components, 4,000 characters of text, five form fields, 45-character labels); `src/core/testing/discord-limits.ts` checks them. A restart abandons open cards as before. Every limit is counted in UTF-16 units, as Discord counts them, so an emoji or rare character weighs two, and the core's refusal of an over-long set (3,000 characters of questions and headers) counts them the same way; a card of emoji-heavy questions is refused with a reason instead of failing to post.
15
+
16
+ - The web chat shows a whole set on one card too, to clients that declared they can (see the `pi-roundtable-webchat` changelog); a person whose tabs have not is asked one question at a time.
17
+
18
+ - Questionnaire checks in the surface contract (`pi-roundtable/testing`): a surface that declares `asks` must settle a questionnaire as its person answers it (a blank question stays blank, the note comes back where `asks.note`), close it when the turn stops, and ignore another person's answer. `SurfaceContractSubject` and `SurfaceContractAnswerer` gain an optional `answerQuestionnaire(prompt, answer, questionnaire)`; without it the checks are skipped, and a subject that gives it must declare `asks`, or the contract fails it. The Discord cards and the web chat run them; the example surface stays on one question at a time.
19
+
20
+ ### Changed
21
+
22
+ - `ask_user` takes `questions`: one to four related questions in one call, each with an optional `header`, options that may carry a `description` and a markdown `preview`, `multi`, and `allow_other` (which defaults to true in this form; set it false for a closed choice). A call in the old form (`question`, `options`, `multi`, `allow_other` at the top level) and a `questions` or `options` value that arrives as a JSON string are folded into it before the schema is checked, so old sessions keep working; the old form keeps `allow_other` false by default. A set is checked first and a refused one (more than four questions, an option offered twice, a question asked twice, a text or preview over its limit, questions and headers together over 3,000 characters) posts no card and tells the model why. This refuses duplicate option labels and duplicate questions for every call, the old form included, which 0.19 accepted, and a whitespace-only question text is refused too. A surface without a questionnaire card is asked one question at a time, stopping at the first that is not answered and telling the model which questions it did not ask; previews are folded into the question text and headers are dropped there. A single question posts the same card and returns the same text as 0.19, previews or not, and so does its late answer. `PromptSlot` forwards `asks` and `questionnaire` to the prompts it wraps when they have them.
23
+
24
+ ### Fixed
25
+
26
+ - A single-question Discord card with 25 options and an answer of their own (`allow_other`) dropped the 25th option, because "Other…" took its place in the menu. The menu now keeps all 25 options and an "Answer…" button under it opens the form. A card with fewer options is unchanged.
27
+
8
28
  ## [0.19.2] - 2026-10-18
9
29
 
10
30
  ### Changed
package/README.md CHANGED
@@ -201,7 +201,7 @@ When a conversation is started over in a server channel or thread, the assistant
201
201
  | text | Markdown renders except tables | What the model is told, in English, about how a reply renders in Discord; appended to the system prompt of every turn that replies there |
202
202
  | `false` | | Tell the model nothing |
203
203
 
204
- An approval card or an `ask_user` question holds its turn for `discord.cardGraceSeconds` (default `120`).
204
+ An approval card or an `ask_user` question (a set of up to four questions is one card) holds its turn for `discord.cardGraceSeconds` (default `120`).
205
205
  The files of a message in an agent's channel download within `discord.attachmentDownload.deadlineMs` (default 60,000) for the message and the one it replies to together, and a file that sends no byte for `discord.attachmentDownload.idleMs` (default 10,000) is given up; the turn goes on without a file it could not get.
206
206
  The copy button of an approval card keeps its text in `card-copies.json` under the data directory, the newest 100 for 30 days; `discord.cardCopies` (`{ keepDays?, keep? }`) changes that, and `false` keeps them in memory only.
207
207
  Answered within it, the turn goes on with the answer; otherwise the turn ends saying it is waiting, the card stays open with no deadline, and answering it later starts a new turn in that conversation with the answer, or with the result of the approved action.
package/README.zh-TW.md CHANGED
@@ -184,7 +184,7 @@ export default {
184
184
  | `foreignWebhook` | 無 | `(webhookId) => boolean \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是助理自己的;回傳 `"skip"` 則把該 webhook 的貼文當作不存在,不納入也不截斷範圍(只能用程式碼設定) |
185
185
  | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`:替這一輪抓取保留訊息中的幾張圖片,最多 4 張 |
186
186
 
187
- 核准卡和 `ask_user` 問題會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
187
+ 核准卡和 `ask_user` 問題(一次最多四題的一組問題放在同一張卡片上)會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
188
188
  agent 頻道裡訊息的附件,整則訊息(連同它回覆的那則)要在 `discord.attachmentDownload.deadlineMs`(預設 60,000)毫秒內下載完;單一檔案連續 `discord.attachmentDownload.idleMs`(預設 10,000)毫秒沒收到任何位元組就放棄,這一輪會略過收不到的檔案繼續。
189
189
  核准卡「複製」按鈕的文字存在資料目錄的 `card-copies.json`,預設留最新 100 張、30 天;`discord.cardCopies`(`{ keepDays?, keep? }`)可以調整,設成 `false` 則只留在記憶體。
190
190
  在這段時間內回答,這一輪就帶著答案繼續;沒回答的話,這一輪會說它在等你,然後結束,卡片繼續開著、沒有期限,之後再回答就會在同一個對話開新的一輪,帶上你的答案,或核准後那個動作的結果。
package/docs/plugins.md CHANGED
@@ -2608,7 +2608,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2608
2608
  | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
2609
2609
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
2610
2610
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
2611
- | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`; without a scope, the owners | the action is held until a message approves it |
2611
+ | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`, and, where the surface declares `asks`, `questionnaire` ([several questions on one card](#several-questions-on-one-card-questionnaire-and-asksupport)); without a scope, the owners | the action is held until a message approves it |
2612
2612
  | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
2613
2613
  | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2614
2614
 
@@ -2668,7 +2668,7 @@ Because anyone who can see the card can copy the text, it holds nothing they may
2668
2668
 
2669
2669
  #### A prompt that outlives its turn: `PromptWait`
2670
2670
 
2671
- `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
2671
+ `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, `{ kind: "question", answer, by }`, or `{ kind: "questionnaire", answer: QuestionnaireAnswer, by }`, `by` naming who answered; only a `questionnaire` call can produce the last) and returns the text of the turn that tells the conversation, or undefined for none.
2672
2672
  Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
2673
2673
  Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
2674
2674
  A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
@@ -2682,6 +2682,25 @@ On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the
2682
2682
  A card answered late is edited to show the answer with its controls disabled, as one answered in time is; a second press finds it answered.
2683
2683
  Open cards live in memory: after a restart, pressing one replies, to the presser alone, that it is no longer valid and to ask again.
2684
2684
 
2685
+ #### Several questions on one card: `questionnaire` and `AskSupport`
2686
+
2687
+ `ask_user` takes one to four questions in one call (`questions`; a whitespace-only question is refused), each with an optional short `header`, options with an optional `description` and markdown `preview`, `multi`, and `allow_other` (true by default in this form, so every question takes a written answer too), and the person may add a note on the whole set. The older single-question arguments (`question`, `options`, `multi`, `allow_other`) still work and read as one question with `allow_other` false; double-encoded `questions` and `options` values are decoded. A call that breaks `QUESTIONNAIRE_LIMITS` (the table of every limit: four questions, a header of 30 characters, 25 options, 100 characters for a label or description, 1,500 characters for a lone question and 3,000 for the questions and headers of a set, 1,200 characters per preview and 10,000 in all, 1,000 for an answer of their own or the note) is refused to the model with the reason and posts no card. `validateQuestionnaire` gives the reason. Since 0.20 the same option label twice in a question, or the same question twice in a call, is refused for every call; 0.19 accepted both.
2688
+
2689
+ A surface says what it can show with `Prompts.asks` (`AskSupport`: `{ questions, previews, note }`) and, when it declares it, implements `Prompts.questionnaire(title, questionnaire, signal?, wait?)`, which resolves with a `QuestionnaireAnswer` (`answers`, one per question in order and `undefined` for one left blank, and `note`), `"pending"`, or `undefined` as `ask` does. Both members are optional: a `Prompts` without them is the 0.19 contract (`SINGLE_QUESTION`: one question, no previews, no note) and keeps working unchanged. The runtime gives `questionnaire` a set already shaped for `asks` (`shapeFor`), so a surface renders only what it declared:
2690
+
2691
+ | The surface lacks | What the core does |
2692
+ |---|---|
2693
+ | room for the questions (`asks.questions`, or no `asks` at all) | asks them one at a time through `ask` and stops at the first one that does not come back answered; the model is told which were not asked |
2694
+ | `previews` | appends each preview to its question as `**label**` and a fenced block (a longer fence when the preview holds one), cut to the question limit with `…` |
2695
+ | a `header` (a surface that takes one question) | drops it; the tool description already asks that each question read on its own |
2696
+ | `note` | shows no note control, and the answer carries no `note` |
2697
+
2698
+ One question without previews always goes through `ask` and returns the same text as 0.19. `checkAnswer` and `checkQuestionnaireAnswer` check that an answer is one the questions allow (offered labels only, one unless `multi`, text of their own only where `allowOther`); a surface that takes answers from a network uses them rather than its own rules. `describeSurfaceContract` runs the questionnaire checks (answered, blank question kept blank, closed on stop, a stranger's answer ignored) for a subject that gives `answerQuestionnaire(prompt, answer, questionnaire)`; a subject that gives it must declare `asks`, and the contract fails it otherwise.
2699
+
2700
+ On Discord the whole set is one card (`asks` is four questions, previews and a note): a menu per question with its options (an option can be cleared again, and the menus show the draft), `Submit`, a button that opens one form with a field per question that takes written text plus the note, and, when an option has a preview, a `Previews` button that replies ephemerally to whoever presses it and answers nothing. A short footer outside the container says how many questions are answered. The draft is one per card, shared by everyone who may answer and visible to everyone who sees the card; the last write wins and the submitter is reported. Presses are checked against the same audience as any question each time (menus, form and Submit). `Submit` needs at least one answer or the note, and a partial answer is allowed. Every limit Discord enforces is counted in UTF-16 units, so an emoji weighs two: the core's refusal of an over-long set counts them too. With a `wait`, each draft edit restarts the grace period, and a `Submit` after the turn stopped waiting starts the late turn with a `questionnaire` `LateAnswer`. A single question keeps its 0.19 card (a menu answers at once; "Other…" opens the form, or, with 25 options plus `allow_other`, an `Answer…` button does), with a `Previews` button when an option has a preview. Open cards live in memory, as before.
2701
+
2702
+ On the web chat the support is the least of the person's connections that declared theirs (`prompt_features`), so the assistant only sends a set a tab can show, and a person whose tabs all lack it is asked one question at a time. A questionnaire that no connection can show expires after 60 seconds: it settles unanswered, the model is told, and if it asks again it gets the one-at-a-time fallback. See the web chat package's README for the frames.
2703
+
2685
2704
  #### Interim text: what a turn writes before its final answer
2686
2705
 
2687
2706
  A model often writes text in an assistant message that then calls tools, such as a proposal before it asks `ask_user` "go with this version?".
@@ -3405,6 +3424,7 @@ The contract starts the surface, calls the subject's `join()`, and stops the sur
3405
3424
  It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3406
3425
  A check for a part the surface does not offer passes.
3407
3426
  The contract has no check for `ApprovalDetails.copy`: a surface that shows it keeps it informational, so using it never resolves, approves or declines the prompt, whoever uses it.
3427
+ A subject that gives `answerQuestionnaire` (and a `stranger` that gives its own) must declare `asks` and also keeps the questionnaire checks.
3408
3428
  `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
3409
3429
 
3410
3430
  ## What happens when the bot starts and stops
@@ -3665,6 +3685,7 @@ Import from the entries listed below; source area files are internal.
3665
3685
  | `ApprovalOptions` | `pi-roundtable` | type |
3666
3686
  | `ApprovedBy` | `pi-roundtable` | type |
3667
3687
  | `AskOption` | `pi-roundtable` | type |
3688
+ | `AskSupport` | `pi-roundtable` | type |
3668
3689
  | `AttachmentFailure` | `pi-roundtable` | type |
3669
3690
  | `AttachmentPort` | `pi-roundtable` | type |
3670
3691
  | `AttachmentRef` | `pi-roundtable` | type |
@@ -3793,6 +3814,9 @@ Import from the entries listed below; source area files are internal.
3793
3814
  | `Provisioning` | `pi-roundtable` | type |
3794
3815
  | `ProvisioningRule` | `pi-roundtable` | type |
3795
3816
  | `QueuePort` | `pi-roundtable` | type |
3817
+ | `QUESTIONNAIRE_LIMITS` | `pi-roundtable` | value |
3818
+ | `Questionnaire` | `pi-roundtable` | type |
3819
+ | `QuestionnaireAnswer` | `pi-roundtable` | type |
3796
3820
  | `Recurrence` | `pi-roundtable` | type |
3797
3821
  | `ReferenceImage` | `pi-roundtable` | type |
3798
3822
  | `ResolvedProviders` | `pi-roundtable` | type |
@@ -3825,6 +3849,7 @@ Import from the entries listed below; source area files are internal.
3825
3849
  | `PrecheckTool` | `pi-roundtable` | type |
3826
3850
  | `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
3827
3851
  | `SKILLS` | `pi-roundtable` | value |
3852
+ | `SINGLE_QUESTION` | `pi-roundtable` | value |
3828
3853
  | `Schedule` | `pi-roundtable` | type |
3829
3854
  | `ScheduleChange` | `pi-roundtable` | type |
3830
3855
  | `ScheduleError` | `pi-roundtable` | value |
@@ -3886,6 +3911,7 @@ Import from the entries listed below; source area files are internal.
3886
3911
  | `Weekday` | `pi-roundtable` | type |
3887
3912
  | `attachReplyFile` | `pi-roundtable` | value |
3888
3913
  | `promptScope` | `pi-roundtable` | value |
3914
+ | `validateQuestionnaire` | `pi-roundtable` | value |
3889
3915
  | `withReplyFiles` | `pi-roundtable` | value |
3890
3916
  | `withdrawalNote` | `pi-roundtable` | value |
3891
3917
  | `prepareImageBytes` | `pi-roundtable/kit` | value |
@@ -3895,12 +3921,15 @@ Import from the entries listed below; source area files are internal.
3895
3921
  | `SquarePngOptions` | `pi-roundtable/kit` | type |
3896
3922
  | `scrubDiagnostic` | `pi-roundtable/kit` | value |
3897
3923
  | `channelKey` | `pi-roundtable` | value |
3924
+ | `checkAnswer` | `pi-roundtable` | value |
3925
+ | `checkQuestionnaireAnswer` | `pi-roundtable` | value |
3898
3926
  | `definePlugin` | `pi-roundtable` | value |
3899
3927
  | `defineRoundtable` | `pi-roundtable` | value |
3900
3928
  | `defineTool` | `pi-roundtable` | value |
3901
3929
  | `migrateDatabase` | `pi-roundtable` | value |
3902
3930
  | `parseChannelKey` | `pi-roundtable` | value |
3903
3931
  | `serviceKey` | `pi-roundtable` | value |
3932
+ | `shapeFor` | `pi-roundtable` | value |
3904
3933
  | `FakeDiscord` | `pi-roundtable/testing` | type |
3905
3934
  | `FakeThreadHost` | `pi-roundtable/testing` | type |
3906
3935
  | `OWNER_SPEAKER` | `pi-roundtable/testing` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "src",
27
27
  "!src/**/*.test.ts",
28
28
  "!src/core/testing/file-size.ts",
29
+ "!src/core/testing/discord-limits.ts",
29
30
  "!src/core/testing/prompt-capture*.ts",
30
31
  "!src/core/testing/discord-api-emulator*.ts",
31
32
  "!src/core/discord/poster/poster-contract*.ts",
@@ -0,0 +1,180 @@
1
+ import { type Interaction, MessageFlags } from "discord.js";
2
+ import { messages } from "../i18n/index.ts";
3
+ import {
4
+ checkQuestionnaireAnswer,
5
+ type QuestionnaireAnswer,
6
+ } from "../interactions/questionnaire.ts";
7
+ import {
8
+ answerOf,
9
+ formFields,
10
+ previewReplies,
11
+ questionnaireModal,
12
+ } from "./owner-card-questionnaire.ts";
13
+ import {
14
+ answeredLine,
15
+ answerModal,
16
+ OTHER,
17
+ TEXT_FIELD,
18
+ } from "./owner-card-rows.ts";
19
+ import type { Answerer, OpenCard } from "./owner-card-types.ts";
20
+
21
+ const ephemeral = (content: string) => ({
22
+ content,
23
+ flags: MessageFlags.Ephemeral as const,
24
+ });
25
+
26
+ /** The single-question card's controls: a menu pick, "Write answer…", and the answer form. */
27
+ export async function answerSingle(
28
+ card: OpenCard,
29
+ id: string,
30
+ action: string,
31
+ interaction: Interaction,
32
+ by: Answerer,
33
+ ): Promise<void> {
34
+ const question = card.question;
35
+ if (!question) return;
36
+ if (interaction.isButton() && action === "write") {
37
+ card.picked = [];
38
+ await interaction.showModal(answerModal(id));
39
+ return;
40
+ }
41
+ if (interaction.isStringSelectMenu() && action === "pick") {
42
+ const values = interaction.values;
43
+ const choices = question.options.flatMap((option, i) =>
44
+ values.includes(String(i)) ? [option.label] : [],
45
+ );
46
+ if (values.includes(OTHER)) {
47
+ card.picked = choices;
48
+ await interaction.showModal(answerModal(id));
49
+ return;
50
+ }
51
+ const answer = { choices };
52
+ card.end(answer, by);
53
+ await interaction.update(card.view(answeredLine(answer)));
54
+ return;
55
+ }
56
+ if (interaction.isModalSubmit() && action === "text") {
57
+ const answer = {
58
+ choices: card.picked ?? [],
59
+ text: interaction.fields.getTextInputValue(TEXT_FIELD),
60
+ };
61
+ card.end(answer, by);
62
+ if (interaction.isFromMessage())
63
+ await interaction.update(card.view(answeredLine(answer)));
64
+ else await interaction.deferUpdate();
65
+ }
66
+ }
67
+
68
+ /** Previews answer nothing, so anyone who sees the card may open them. */
69
+ export async function showPreviews(
70
+ card: OpenCard,
71
+ interaction: Interaction,
72
+ ): Promise<void> {
73
+ const source = card.form?.questionnaire ?? card.previews;
74
+ if (!interaction.isButton() || !source) return;
75
+ const [first, ...rest] = previewReplies(source);
76
+ if (first) await interaction.reply(first);
77
+ for (const page of rest) await interaction.followUp(page);
78
+ }
79
+
80
+ /** Ends the card with `answer`; false when another press or the plugin ended it first. */
81
+ function finish(
82
+ card: OpenCard,
83
+ answer: QuestionnaireAnswer,
84
+ by: Answerer,
85
+ ): boolean {
86
+ const draft = card.form?.draft;
87
+ if (draft) {
88
+ for (const [i, a] of answer.answers.entries()) {
89
+ draft.choices[i] = [...(a?.choices ?? [])];
90
+ draft.texts[i] = a?.text;
91
+ }
92
+ if (answer.note !== undefined) draft.note = answer.note;
93
+ }
94
+ return card.end(answer, by);
95
+ }
96
+
97
+ /** The note an ended card shows as its footer: the draft's note, when it has one. */
98
+ const endedFooter = (card: OpenCard) => {
99
+ const note = card.form?.draft.note?.trim();
100
+ return note ? messages().cardNoteLine(note) : "";
101
+ };
102
+
103
+ /**
104
+ * A menu pick, Submit or the submitted form of a questionnaire card. The press is deferred first
105
+ * (the audience lookup may take a while), then the audience and the card are checked: a refusal
106
+ * or a closed card changes nothing, and a refused pick re-draws the card so their menu resets.
107
+ */
108
+ export async function answerDeferred(
109
+ card: OpenCard,
110
+ action: string,
111
+ index: number,
112
+ interaction: Interaction,
113
+ by: Answerer,
114
+ ): Promise<void> {
115
+ const form = card.form;
116
+ if (!form) return;
117
+ const picks = interaction.isStringSelectMenu() && action === "qpick";
118
+ const submits = interaction.isButton() && action === "submit";
119
+ const writes = interaction.isModalSubmit() && action === "qtext";
120
+ if (!picks && !submits && !writes) return;
121
+ await interaction.deferUpdate();
122
+ const fromMessage =
123
+ !interaction.isModalSubmit() || interaction.isFromMessage();
124
+ if (!(await card.audience.allows(by.user))) {
125
+ await interaction.followUp(ephemeral(card.audience.refusal));
126
+ if (picks && card.isOpen())
127
+ await interaction.editReply(card.view("", false));
128
+ return;
129
+ }
130
+ // Stopped, withdrawn or answered meanwhile: the closed card must not be drawn open again.
131
+ if (!card.isOpen()) {
132
+ await interaction.followUp(ephemeral(messages().cardInactive));
133
+ return;
134
+ }
135
+ const { questions } = form.questionnaire;
136
+ if (picks) {
137
+ const question = questions[index];
138
+ if (!question) return;
139
+ form.draft.choices[index] = question.options.flatMap((option, n) =>
140
+ interaction.values.includes(String(n)) ? [option.label] : [],
141
+ );
142
+ } else if (writes) {
143
+ const { texts, note } = formFields(form, (field) =>
144
+ interaction.fields.getTextInputValue(field),
145
+ );
146
+ for (const [i, value] of texts)
147
+ form.draft.texts[i] = value.trim() || undefined;
148
+ form.draft.note = note.trim() || undefined;
149
+ }
150
+ if (!submits) {
151
+ card.touch();
152
+ if (fromMessage) await interaction.editReply(card.view("", false));
153
+ return;
154
+ }
155
+ const answer = answerOf(form.draft);
156
+ if (!checkQuestionnaireAnswer(form.questionnaire, answer)) {
157
+ await interaction.followUp(ephemeral(messages().cardNothingYet));
158
+ return;
159
+ }
160
+ if (!finish(card, answer, by)) {
161
+ await interaction.followUp(ephemeral(messages().cardInactive));
162
+ return;
163
+ }
164
+ await interaction.editReply(card.view(endedFooter(card)));
165
+ }
166
+
167
+ /** "Write answers / note…": opens the form, which a modal cannot defer; the audience was checked. */
168
+ export async function answerForm(
169
+ card: OpenCard,
170
+ id: string,
171
+ action: string,
172
+ interaction: Interaction,
173
+ ): Promise<void> {
174
+ if (!card.form || !interaction.isButton() || action !== "notes") return;
175
+ if (!card.isOpen()) {
176
+ await interaction.reply(ephemeral(messages().cardInactive));
177
+ return;
178
+ }
179
+ await interaction.showModal(questionnaireModal(id, card.form));
180
+ }
@@ -0,0 +1,149 @@
1
+ import { messages } from "../i18n/index.ts";
2
+ import type {
3
+ OwnerAnswer,
4
+ PromptScope,
5
+ Prompts,
6
+ } from "../interactions/prompts.ts";
7
+ import type {
8
+ AskSupport,
9
+ QuestionnaireAnswer,
10
+ } from "../interactions/questionnaire.ts";
11
+ import type { CardAudiences } from "./card-audience.ts";
12
+ import type { QuestionnaireForm } from "./owner-card-questionnaire.ts";
13
+ import { newDraft, questionnaireCard } from "./owner-card-questionnaire.ts";
14
+ import { askRows, previewsRow } from "./owner-card-rows.ts";
15
+ import type { PostCard } from "./owner-card-types.ts";
16
+
17
+ /** What the Discord card shows of a questionnaire: four questions, previews, and a note. */
18
+ export const DISCORD_ASKS: AskSupport = {
19
+ questions: 4,
20
+ previews: true,
21
+ note: true,
22
+ };
23
+
24
+ /** A channel's question cards: one question on its own card, or up to four on one. */
25
+ export function askPrompts(args: {
26
+ channelId: string;
27
+ scope: PromptScope | undefined;
28
+ audiences: CardAudiences;
29
+ post: PostCard;
30
+ }): Pick<Prompts, "ask" | "asks" | "questionnaire"> {
31
+ const { channelId, scope, audiences, post } = args;
32
+ return {
33
+ asks: DISCORD_ASKS,
34
+ ask: async (title, question, signal, wait) => {
35
+ const audience = await audiences.question(scope);
36
+ if (!audience) return undefined;
37
+ return post<OwnerAnswer | "pending" | undefined>(
38
+ channelId,
39
+ signal,
40
+ undefined,
41
+ undefined,
42
+ {
43
+ audience,
44
+ ...(wait
45
+ ? {
46
+ wait: {
47
+ pending: "pending",
48
+ late: (value, by) =>
49
+ wait.late({
50
+ kind: "question",
51
+ answer: value as OwnerAnswer,
52
+ by: by.user.name,
53
+ }),
54
+ },
55
+ }
56
+ : {}),
57
+ title,
58
+ sections: [question.question],
59
+ question,
60
+ rows: (id, disabled) => askRows(id, question, disabled),
61
+ footer: messages().cardQuestionFooter,
62
+ },
63
+ );
64
+ },
65
+ questionnaire: async (title, questionnaire, signal, wait) => {
66
+ const audience = await audiences.question(scope);
67
+ if (!audience) return undefined;
68
+ const [only] = questionnaire.questions;
69
+ if (only && questionnaire.questions.length === 1) {
70
+ // One question is the 0.19 card, answered by one click, with a Previews button.
71
+ const answer = await post<OwnerAnswer | "pending" | undefined>(
72
+ channelId,
73
+ signal,
74
+ undefined,
75
+ undefined,
76
+ {
77
+ audience,
78
+ ...(wait
79
+ ? {
80
+ wait: {
81
+ pending: "pending",
82
+ late: (value, by) =>
83
+ wait.late({
84
+ kind: "questionnaire",
85
+ answer: { answers: [value as OwnerAnswer] },
86
+ by: by.user.name,
87
+ }),
88
+ },
89
+ }
90
+ : {}),
91
+ title,
92
+ sections: [only.question],
93
+ question: only,
94
+ previews: questionnaire,
95
+ rows: (id, disabled) => [
96
+ ...askRows(id, only, disabled),
97
+ ...(only.options.some((o) => o.preview !== undefined)
98
+ ? [previewsRow(id, disabled)]
99
+ : []),
100
+ ],
101
+ footer: messages().cardQuestionFooter,
102
+ },
103
+ );
104
+ return answer === "pending" || answer === undefined
105
+ ? answer
106
+ : { answers: [answer] };
107
+ }
108
+ const form: QuestionnaireForm = {
109
+ questionnaire,
110
+ draft: newDraft(questionnaire.questions.length),
111
+ };
112
+ return post<QuestionnaireAnswer | "pending" | undefined>(
113
+ channelId,
114
+ signal,
115
+ undefined,
116
+ undefined,
117
+ {
118
+ audience,
119
+ ...(wait
120
+ ? {
121
+ wait: {
122
+ pending: "pending",
123
+ late: (value, by) =>
124
+ wait.late({
125
+ kind: "questionnaire",
126
+ answer: value as QuestionnaireAnswer,
127
+ by: by.user.name,
128
+ }),
129
+ },
130
+ }
131
+ : {}),
132
+ title,
133
+ sections: [],
134
+ form,
135
+ build: (id, footer, disabled) =>
136
+ questionnaireCard({
137
+ id,
138
+ title,
139
+ note: audience.note,
140
+ form,
141
+ disabled,
142
+ footer,
143
+ }),
144
+ footer: "",
145
+ },
146
+ );
147
+ },
148
+ };
149
+ }