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.
@@ -0,0 +1,281 @@
1
+ import {
2
+ ActionRowBuilder,
3
+ AttachmentBuilder,
4
+ ButtonBuilder,
5
+ ButtonStyle,
6
+ ContainerBuilder,
7
+ LabelBuilder,
8
+ MessageFlags,
9
+ ModalBuilder,
10
+ StringSelectMenuBuilder,
11
+ TextDisplayBuilder,
12
+ TextInputBuilder,
13
+ TextInputStyle,
14
+ } from "discord.js";
15
+ import { messages } from "../i18n/index.ts";
16
+ import {
17
+ type OwnerQuestion,
18
+ QUESTIONNAIRE_LIMITS,
19
+ type Questionnaire,
20
+ type QuestionnaireAnswer,
21
+ } from "../interactions/questionnaire.ts";
22
+ import { CARD_PREFIX } from "./owner-card-rows.ts";
23
+ import type { CardPayload } from "./owner-card-types.ts";
24
+ import { ephemeralPanel, ownerPanels, plain } from "./owner-panel.ts";
25
+ import { clip } from "./utf16.ts";
26
+
27
+ /** What has been chosen and written on a questionnaire card so far; one per card, shared by all who may answer. */
28
+ export interface Draft {
29
+ choices: string[][];
30
+ texts: (string | undefined)[];
31
+ note?: string;
32
+ }
33
+
34
+ /** A card's questionnaire with its draft. */
35
+ export interface QuestionnaireForm {
36
+ questionnaire: Questionnaire;
37
+ draft: Draft;
38
+ }
39
+
40
+ export const newDraft = (questions: number): Draft => ({
41
+ choices: Array.from({ length: questions }, () => []),
42
+ texts: Array.from({ length: questions }, () => undefined),
43
+ });
44
+
45
+ const cut = (text: string, max: number) => clip(text, max, true);
46
+
47
+ const text = (content: string) => new TextDisplayBuilder().setContent(content);
48
+
49
+ /** Whether the question takes text of its own: it has no options, or allows an answer besides them. */
50
+ export const takesText = (question: OwnerQuestion) =>
51
+ question.allowOther || question.options.length === 0;
52
+
53
+ const given = (draft: Draft, i: number) =>
54
+ (draft.choices[i]?.length ?? 0) > 0 || draft.texts[i] !== undefined;
55
+
56
+ export const answeredCount = (draft: Draft) =>
57
+ draft.choices.filter((_, i) => given(draft, i)).length;
58
+
59
+ /** The draft as an answer: a blank question is undefined, a blank note is left out. */
60
+ export function answerOf(draft: Draft): QuestionnaireAnswer {
61
+ const note = draft.note?.trim();
62
+ return {
63
+ answers: draft.choices.map((choices, i) => {
64
+ const own = draft.texts[i]?.trim();
65
+ return choices.length > 0 || own
66
+ ? { choices: [...choices], ...(own ? { text: own } : {}) }
67
+ : undefined;
68
+ }),
69
+ ...(note ? { note } : {}),
70
+ };
71
+ }
72
+
73
+ /** One question's draft as a line: what is chosen and written, or that it is not answered. */
74
+ function lineOf(draft: Draft, i: number): string {
75
+ if (!given(draft, i)) return messages().cardUnanswered;
76
+ const own = draft.texts[i];
77
+ return messages().cardAnswered([
78
+ ...(draft.choices[i] ?? []).map(plain),
79
+ ...(own !== undefined ? [messages().cardQuote(plain(own))] : []),
80
+ ]);
81
+ }
82
+
83
+ /** A question's name: its header, else its text cut short. */
84
+ const nameOf = (question: OwnerQuestion) =>
85
+ plain(question.header ?? cut(question.question, 80));
86
+
87
+ function select(
88
+ id: string,
89
+ i: number,
90
+ question: OwnerQuestion,
91
+ draft: Draft,
92
+ disabled: boolean,
93
+ ) {
94
+ const chosen = draft.choices[i] ?? [];
95
+ return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
96
+ new StringSelectMenuBuilder()
97
+ .setCustomId(`${CARD_PREFIX}${id}:qpick:${i}`)
98
+ .setPlaceholder(
99
+ question.multi
100
+ ? messages().cardPickManyPlaceholder
101
+ : messages().cardPickOnePlaceholder,
102
+ )
103
+ .setMinValues(0)
104
+ .setMaxValues(question.multi ? question.options.length : 1)
105
+ .addOptions(
106
+ question.options.map((option, n) => ({
107
+ label: clip(option.label, 100),
108
+ value: String(n),
109
+ default: chosen.includes(option.label),
110
+ ...(option.description
111
+ ? { description: clip(option.description, 100) }
112
+ : {}),
113
+ })),
114
+ )
115
+ .setDisabled(disabled),
116
+ );
117
+ }
118
+
119
+ function buttons(id: string, form: QuestionnaireForm, disabled: boolean) {
120
+ const { questions } = form.questionnaire;
121
+ const row = new ActionRowBuilder<ButtonBuilder>();
122
+ const button = (action: string, label: string, style: ButtonStyle) =>
123
+ new ButtonBuilder()
124
+ .setCustomId(`${CARD_PREFIX}${id}:${action}`)
125
+ .setLabel(label)
126
+ .setStyle(style)
127
+ .setDisabled(disabled);
128
+ row.addComponents(
129
+ button("submit", messages().cardSubmitLabel, ButtonStyle.Primary),
130
+ button("notes", messages().cardWriteLabel, ButtonStyle.Secondary),
131
+ );
132
+ if (questions.some((q) => q.options.some((o) => o.preview !== undefined)))
133
+ row.addComponents(
134
+ button("previews", messages().cardPreviewsLabel, ButtonStyle.Secondary),
135
+ );
136
+ return row.components.length > 0 ? [row] : [];
137
+ }
138
+
139
+ /**
140
+ * A questionnaire card as one message. Discord lets a container hold ten children, so the
141
+ * heading, the four questions' texts and menus, and the button row fill it; the footer is a
142
+ * top-level text beneath it. Open, each question shows its text and menu; ended, its name and
143
+ * what was chosen, with the controls disabled.
144
+ */
145
+ export function questionnaireCard(args: {
146
+ id: string;
147
+ title: string;
148
+ /** The audience's note, shown with the heading; empty for none. */
149
+ note: string;
150
+ form: QuestionnaireForm;
151
+ disabled: boolean;
152
+ /** How the card ended, shown as its footer; ignored while it is open. */
153
+ footer: string;
154
+ }): CardPayload {
155
+ const { id, form, disabled } = args;
156
+ const { draft } = form;
157
+ const { questions } = form.questionnaire;
158
+ const container = new ContainerBuilder().addTextDisplayComponents(
159
+ text([`### ${args.title}`, ...(args.note ? [args.note] : [])].join("\n")),
160
+ );
161
+ for (const [i, question] of questions.entries()) {
162
+ const heading =
163
+ questions.length === 1
164
+ ? undefined
165
+ : `**${i + 1}${question.header ? ` · ${plain(question.header)}` : ""}**`;
166
+ const body = disabled
167
+ ? `${heading ?? `**${nameOf(question)}**`}\n${lineOf(draft, i)}`
168
+ : [heading, question.question].filter(Boolean).join("\n");
169
+ container.addTextDisplayComponents(text(body));
170
+ if (question.options.length > 0)
171
+ container.addActionRowComponents(
172
+ select(id, i, question, draft, disabled),
173
+ );
174
+ }
175
+ container.addActionRowComponents(...buttons(id, form, disabled));
176
+ const footer = disabled
177
+ ? args.footer
178
+ : messages().cardQuestionnaireFooter(
179
+ answeredCount(draft),
180
+ questions.length,
181
+ );
182
+ return {
183
+ components: [container, ...(footer ? [text(`-# ${footer}`)] : [])],
184
+ flags: MessageFlags.IsComponentsV2,
185
+ allowedMentions: { parse: [] },
186
+ };
187
+ }
188
+
189
+ /** The form for what the menus cannot take: each question's own answer, and the note. */
190
+ export function questionnaireModal(id: string, form: QuestionnaireForm) {
191
+ const { questions } = form.questionnaire;
192
+ const labelled = (
193
+ label: string,
194
+ field: string,
195
+ value: string | undefined,
196
+ description?: string,
197
+ ) => {
198
+ const input = new TextInputBuilder()
199
+ .setCustomId(field)
200
+ .setStyle(TextInputStyle.Paragraph)
201
+ .setRequired(false)
202
+ .setMaxLength(QUESTIONNAIRE_LIMITS.answerChars);
203
+ // Discord refuses an empty prefill and an empty description.
204
+ if (value) input.setValue(value);
205
+ const labelled = new LabelBuilder()
206
+ .setLabel(cut(label, 45))
207
+ .setTextInputComponent(input);
208
+ return description
209
+ ? labelled.setDescription(cut(description, 100))
210
+ : labelled;
211
+ };
212
+ const modal = new ModalBuilder()
213
+ .setCustomId(`${CARD_PREFIX}${id}:qtext`)
214
+ .setTitle(messages().cardModalTitle);
215
+ for (const [i, question] of questions.entries()) {
216
+ if (!takesText(question)) continue;
217
+ const label =
218
+ questions.length === 1
219
+ ? messages().cardModalField
220
+ : `${i + 1}. ${question.header ?? messages().cardQuestionName(i + 1)}`;
221
+ const field = labelled(
222
+ label,
223
+ `t${i}`,
224
+ form.draft.texts[i],
225
+ question.question,
226
+ );
227
+ modal.addLabelComponents(field);
228
+ }
229
+ return modal.addLabelComponents(
230
+ labelled(messages().cardNoteField, "note", form.draft.note),
231
+ );
232
+ }
233
+
234
+ /** The text fields a submitted form carries, by question index; `note` is the note. */
235
+ export function formFields(
236
+ form: QuestionnaireForm,
237
+ read: (field: string) => string,
238
+ ): { texts: Map<number, string>; note: string } {
239
+ const texts = new Map<number, string>();
240
+ for (const [i, question] of form.questionnaire.questions.entries())
241
+ if (takesText(question)) texts.set(i, read(`t${i}`));
242
+ return { texts, note: read("note") };
243
+ }
244
+
245
+ /** Every option's preview, question by question, as an ephemeral reply and follow-ups; a file when a preview holds a code fence. */
246
+ export function previewReplies(questionnaire: Questionnaire) {
247
+ const sections = questionnaire.questions.flatMap((question, i) => {
248
+ const shown = question.options.filter((o) => o.preview !== undefined);
249
+ if (shown.length === 0) return [];
250
+ return [
251
+ `**${i + 1}. ${nameOf(question)}**`,
252
+ ...shown.map(
253
+ (o) => `**${plain(o.label)}**\n\`\`\`\n${o.preview}\n\`\`\``,
254
+ ),
255
+ ];
256
+ });
257
+ const raw = questionnaire.questions
258
+ .flatMap((question, i) =>
259
+ question.options
260
+ .filter((o) => o.preview !== undefined)
261
+ .map((o) => `## ${i + 1}. ${o.label}\n\n${o.preview}`),
262
+ )
263
+ .join("\n\n");
264
+ if (raw.includes("```"))
265
+ return [
266
+ {
267
+ content: messages().cardPreviewsFile,
268
+ files: [
269
+ new AttachmentBuilder(Buffer.from(raw, "utf8"), {
270
+ name: "previews.md",
271
+ }),
272
+ ],
273
+ flags: MessageFlags.Ephemeral as const,
274
+ allowedMentions: { parse: [] as never[] },
275
+ },
276
+ ];
277
+ return ownerPanels({
278
+ title: messages().cardPreviewsLabel,
279
+ sections,
280
+ }).map(ephemeralPanel);
281
+ }
@@ -15,6 +15,7 @@ import {
15
15
  withdrawalNote,
16
16
  } from "../interactions/prompts.ts";
17
17
  import { type ownerPanel, plain } from "./owner-panel.ts";
18
+ import { clip } from "./utf16.ts";
18
19
 
19
20
  /** The prefix of every card control's custom id. */
20
21
  export const CARD_PREFIX = "roundtable:card:";
@@ -41,39 +42,60 @@ export function askRows(
41
42
  .setDisabled(disabled),
42
43
  ),
43
44
  ];
44
- // Discord menus take 25 options; "Other…" takes the last place when the options fill it.
45
- const options = question.options
46
- .slice(0, question.allowOther ? 24 : 25)
47
- .map((option, i) => ({
48
- label: option.label.slice(0, 100),
45
+ // Discord menus take 25 options. With 24 or fewer, "Other…" is the last entry; with 25 it
46
+ // would push one out, so the menu keeps all of them and "Answer…" opens the form instead.
47
+ const roomForOther = question.allowOther && question.options.length < 25;
48
+ const options: { label: string; value: string; description?: string }[] =
49
+ question.options.map((option, i) => ({
50
+ label: clip(option.label, 100),
49
51
  value: String(i),
50
52
  ...(option.description
51
- ? { description: option.description.slice(0, 100) }
53
+ ? { description: clip(option.description, 100) }
52
54
  : {}),
53
55
  }));
54
- if (question.allowOther)
56
+ if (roomForOther)
55
57
  options.push({
56
58
  label: messages().cardOtherLabel,
57
59
  value: OTHER,
58
60
  description: messages().cardOtherDescription,
59
61
  });
62
+ const menu = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
63
+ new StringSelectMenuBuilder()
64
+ .setCustomId(`${CARD_PREFIX}${id}:pick`)
65
+ .setPlaceholder(
66
+ question.multi
67
+ ? messages().cardPickManyPlaceholder
68
+ : messages().cardPickOnePlaceholder,
69
+ )
70
+ .setMinValues(1)
71
+ .setMaxValues(question.multi ? options.length : 1)
72
+ .addOptions(options)
73
+ .setDisabled(disabled),
74
+ );
75
+ if (!question.allowOther || roomForOther) return [menu];
60
76
  return [
61
- new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
62
- new StringSelectMenuBuilder()
63
- .setCustomId(`${CARD_PREFIX}${id}:pick`)
64
- .setPlaceholder(
65
- question.multi
66
- ? messages().cardPickManyPlaceholder
67
- : messages().cardPickOnePlaceholder,
68
- )
69
- .setMinValues(1)
70
- .setMaxValues(question.multi ? options.length : 1)
71
- .addOptions(options)
77
+ menu,
78
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
79
+ new ButtonBuilder()
80
+ .setCustomId(`${CARD_PREFIX}${id}:write`)
81
+ .setLabel(messages().cardAnswerLabel)
82
+ .setStyle(ButtonStyle.Secondary)
72
83
  .setDisabled(disabled),
73
84
  ),
74
85
  ];
75
86
  }
76
87
 
88
+ /** The Previews button of a single-question card: it shows the options' previews and answers nothing. */
89
+ export function previewsRow(id: string, disabled: boolean) {
90
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
91
+ new ButtonBuilder()
92
+ .setCustomId(`${CARD_PREFIX}${id}:previews`)
93
+ .setLabel(messages().cardPreviewsLabel)
94
+ .setStyle(ButtonStyle.Secondary)
95
+ .setDisabled(disabled),
96
+ );
97
+ }
98
+
77
99
  export function answerModal(id: string): ModalBuilder {
78
100
  return new ModalBuilder()
79
101
  .setCustomId(`${CARD_PREFIX}${id}:text`)
@@ -1,7 +1,13 @@
1
1
  import type { TextDisplayBuilder } from "discord.js";
2
- import type { ApprovedBy, OwnerQuestion } from "../interactions/prompts.ts";
2
+ import type {
3
+ ApprovedBy,
4
+ OwnerQuestion,
5
+ PromptWait,
6
+ } from "../interactions/prompts.ts";
7
+ import type { Questionnaire } from "../interactions/questionnaire.ts";
3
8
  import type { Audience } from "./card-audience.ts";
4
9
  import type { DiscordUser } from "./discord-owners.ts";
10
+ import type { QuestionnaireForm } from "./owner-card-questionnaire.ts";
5
11
  import type { Rows } from "./owner-card-rows.ts";
6
12
  import type { ownerPanel } from "./owner-panel.ts";
7
13
 
@@ -54,10 +60,12 @@ export interface Answerer {
54
60
 
55
61
  /** One open card: how it looks, and how it ends. */
56
62
  export interface OpenCard {
57
- title: string;
58
- sections: string[];
59
- /** The card's controls, disabled once it has ended. */
60
- rows(disabled: boolean): Rows;
63
+ /** The card as it looks now: open, or ended with `footer` and its controls disabled. */
64
+ view(footer: string, disabled?: boolean): CardPayload;
65
+ /** Restarts the grace period of a turn that waits on the card; a draft edit is someone at work on it. */
66
+ touch(): void;
67
+ /** Whether the card is still open: not answered, stopped or withdrawn. */
68
+ isOpen(): boolean;
61
69
  /**
62
70
  * Ends the card with its answer; the answering interaction shows the outcome. A card whose
63
71
  * turn stopped waiting starts a turn with the answer instead. False when the card had ended
@@ -65,6 +73,10 @@ export interface OpenCard {
65
73
  */
66
74
  end(value: unknown, by: Answerer): boolean;
67
75
  question?: OwnerQuestion;
76
+ /** A questionnaire card's questions and its shared draft. */
77
+ form?: QuestionnaireForm;
78
+ /** The questions whose previews the Previews button shows, on a card that is not a questionnaire card. */
79
+ previews?: Questionnaire;
68
80
  /** Options chosen together with "Other…", kept while its form is open. */
69
81
  picked?: string[];
70
82
  /** Posted in a thread, so it leads with the mention of who may answer. */
@@ -72,3 +84,40 @@ export interface OpenCard {
72
84
  /** Who may answer, and what to tell someone else who presses it. */
73
85
  audience: Audience;
74
86
  }
87
+
88
+ /** What `#post` needs to post one card; `T` is what the card resolves with. */
89
+ export interface CardSpec<T> {
90
+ audience: Audience;
91
+ title: string;
92
+ sections: string[];
93
+ /** The text behind the card's copy button, kept under the card's id. */
94
+ copy?: string;
95
+ /** Closes the card, also after its turn stopped waiting, when it aborts; see `ApprovalOptions.supersede`. */
96
+ supersede?: AbortSignal;
97
+ question?: OwnerQuestion;
98
+ form?: QuestionnaireForm;
99
+ previews?: Questionnaire;
100
+ /** Draws the card itself, as a questionnaire does; without it the card is the title, sections and rows. */
101
+ build?(id: string, footer: string, disabled: boolean): CardPayload;
102
+ rows?(id: string, disabled: boolean): Rows;
103
+ footer: string;
104
+ wait?: {
105
+ pending: T;
106
+ late(
107
+ value: unknown,
108
+ by: Answerer,
109
+ prompt: string,
110
+ ): ReturnType<PromptWait["late"]>;
111
+ };
112
+ /** Hears who approved the card, before it resolves approved in its turn. */
113
+ approved?(by: Answerer, prompt: string): void;
114
+ }
115
+
116
+ /** Posts a card and resolves with its answer; see `OwnerCards`. */
117
+ export type PostCard = <T>(
118
+ channelId: string,
119
+ signal: AbortSignal | undefined,
120
+ unposted: T,
121
+ cancelled: T,
122
+ card: CardSpec<T>,
123
+ ) => Promise<T>;
@@ -12,9 +12,12 @@ export const json = (payload: CardPayload | undefined) =>
12
12
  export function fakeChannel(thread = false) {
13
13
  const sent: CardPayload[] = [];
14
14
  const edits: CardPayload[] = [];
15
+ /** The index in `sent` of the card each edit changed. */
16
+ const edited: number[] = [];
15
17
  return {
16
18
  sent,
17
19
  edits,
20
+ edited,
18
21
  /** The id of the latest card sent. */
19
22
  cardId: () =>
20
23
  json(sent.at(-1)).match(new RegExp(`${CARD_PREFIX}([0-9a-f-]+):`))?.[1] ??
@@ -23,9 +26,11 @@ export function fakeChannel(thread = false) {
23
26
  thread,
24
27
  send: async (payload: CardPayload) => {
25
28
  sent.push(payload);
29
+ const index = sent.length - 1;
26
30
  return {
27
31
  edit: async (change: CardPayload) => {
28
32
  edits.push(change);
33
+ edited.push(index);
29
34
  },
30
35
  };
31
36
  },
@@ -44,6 +49,10 @@ export function press(
44
49
  roles?: string[];
45
50
  values?: string[];
46
51
  text?: string;
52
+ /** A form's fields by custom id; when given, a field not named reads empty. */
53
+ fields?: Record<string, string>;
54
+ /** Holds `deferUpdate` until it resolves, so the card can change while the press is in flight. */
55
+ gate?: Promise<void>;
47
56
  } = {},
48
57
  ) {
49
58
  const replies: string[] = [];
@@ -51,6 +60,10 @@ export function press(
51
60
  const replied: { files?: unknown[]; flags?: unknown }[] = [];
52
61
  const updates: CardPayload[] = [];
53
62
  const modals: unknown[] = [];
63
+ /** What the press answered after deferring: edits of the card, and follow-ups. */
64
+ const edited: CardPayload[] = [];
65
+ const followUps: { content?: string; flags?: unknown }[] = [];
66
+ const deferred: string[] = [];
54
67
  // SAFETY: the cards read only these members of an interaction.
55
68
  const interaction = {
56
69
  isButton: () => kind === "button",
@@ -66,7 +79,10 @@ export function press(
66
79
  roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },
67
80
  },
68
81
  values: options.values ?? [],
69
- fields: { getTextInputValue: () => options.text ?? "" },
82
+ fields: {
83
+ getTextInputValue: (name: string) =>
84
+ options.fields ? (options.fields[name] ?? "") : (options.text ?? ""),
85
+ },
70
86
  reply: async (answer: {
71
87
  content: string;
72
88
  files?: unknown[];
@@ -81,9 +97,27 @@ export function press(
81
97
  showModal: async (modal: unknown) => {
82
98
  modals.push(modal);
83
99
  },
84
- deferUpdate: async () => undefined,
100
+ deferUpdate: async () => {
101
+ await options.gate;
102
+ deferred.push("update");
103
+ },
104
+ editReply: async (payload: CardPayload) => {
105
+ edited.push(payload);
106
+ },
107
+ followUp: async (answer: { content?: string; flags?: unknown }) => {
108
+ followUps.push(answer);
109
+ },
85
110
  } as unknown as Interaction;
86
- return { interaction, replies, replied, updates, modals };
111
+ return {
112
+ interaction,
113
+ replies,
114
+ replied,
115
+ updates,
116
+ modals,
117
+ edited,
118
+ followUps,
119
+ deferred,
120
+ };
87
121
  }
88
122
 
89
123
  /** Lets a posted card settle. */