pi-roundtable 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +39 -11
  3. package/README.zh-TW.md +35 -14
  4. package/docs/plugins.md +258 -167
  5. package/examples/migrations.test.ts +1 -1
  6. package/examples/migrations.ts +2 -2
  7. package/package.json +7 -3
  8. package/src/cli/cli.ts +2 -0
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/project.ts +2 -0
  11. package/src/cli/runtime.ts +2 -2
  12. package/src/cli/templates.ts +10 -9
  13. package/src/core/agents/agent-prompt.ts +1 -2
  14. package/src/core/agents/agent-store.ts +1 -2
  15. package/src/core/agents/agent-team-fixture.ts +5 -3
  16. package/src/core/agents/fallback-avatar.ts +1 -0
  17. package/src/core/agents/group-messages.ts +1 -1
  18. package/src/core/agents/group-round.ts +10 -9
  19. package/src/core/agents/group-turns.ts +13 -8
  20. package/src/core/agents/team-layout.ts +13 -14
  21. package/src/core/agents/team-status.ts +8 -8
  22. package/src/core/agents/team-turns.ts +8 -9
  23. package/src/core/define-roundtable.ts +19 -19
  24. package/src/core/define.ts +10 -10
  25. package/src/core/discord/channel-executor.ts +20 -18
  26. package/src/core/discord/channel-operations.ts +7 -3
  27. package/src/core/discord/inbound-message.ts +13 -11
  28. package/src/core/discord/owner-cards.ts +3 -3
  29. package/src/core/discord/owner-discord-threads.ts +16 -12
  30. package/src/core/discord/owner-discord.ts +5 -4
  31. package/src/core/host.ts +1 -0
  32. package/src/core/log.ts +1 -0
  33. package/src/core/modules/schedules/scheduler.ts +3 -0
  34. package/src/core/modules/skills/skill-link.ts +13 -8
  35. package/src/core/modules/skills/skill-listing.ts +2 -4
  36. package/src/core/modules/skills/skill-registry.ts +3 -2
  37. package/src/core/modules/skills/skill-store.ts +3 -2
  38. package/src/core/registry/contributions.ts +1 -1
  39. package/src/core/routing/conversation-turns.ts +2 -2
  40. package/src/core/routing/settle-turn.ts +8 -0
  41. package/src/core/runtime/compaction-tiers.ts +9 -10
  42. package/src/core/runtime/turn-answer.ts +10 -12
  43. package/src/core/shared/session-messages.ts +4 -4
@@ -13,7 +13,7 @@ test.skipIf(!url)(
13
13
  try {
14
14
  for (const migration of visitCounter.migrations ?? []) {
15
15
  await migration.up(sql);
16
- await migration.up(sql); // Idempotent: the host runs it on every start.
16
+ await migration.up(sql); // Written to be idempotent, so a second run changes nothing.
17
17
  }
18
18
  const harness = await testPlugin(visitCounter, { database: sql });
19
19
  expect(await harness.runTool("visit_count", { place: "lab" })).toBe(
@@ -2,8 +2,8 @@ import { definePlugin, defineTool } from "pi-roundtable";
2
2
  import { Type } from "typebox";
3
3
 
4
4
  /**
5
- * Migrations create the plugin's tables before any setup runs, on every start, so they must be
6
- * idempotent. Their names and the tables are shared with every other plugin: prefix them.
5
+ * Migrations create the plugin's tables before any setup runs. Each one runs once and is recorded
6
+ * in a ledger. Table names are shared with every other plugin: prefix them.
7
7
  */
8
8
  export const visitCounter = definePlugin({
9
9
  name: "visit-counter",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,10 @@
34
34
  "type": "git",
35
35
  "url": "git+https://github.com/wayne930242/pi-roundtable.git"
36
36
  },
37
+ "homepage": "https://pi-roundtable.wayneh.tw",
38
+ "bugs": {
39
+ "url": "https://github.com/wayne930242/pi-roundtable/issues"
40
+ },
37
41
  "publishConfig": {
38
42
  "access": "public",
39
43
  "provenance": true
@@ -45,8 +49,8 @@
45
49
  },
46
50
  "dependencies": {
47
51
  "@babel/parser": "7.29.9",
48
- "@earendil-works/pi-ai": ">=0.99.2 <1",
49
- "@earendil-works/pi-coding-agent": ">=0.99.2 <1",
52
+ "@earendil-works/pi-ai": ">=0.99.2 <2",
53
+ "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
50
54
  "canvas": "3.2.3",
51
55
  "discord.js": "14.27.0",
52
56
  "pi-mcp-adapter": "4.0.0",
package/src/cli/cli.ts CHANGED
@@ -79,7 +79,9 @@ export function processEnvironment(): CliEnvironment {
79
79
  roundtable.listen();
80
80
  await roundtable.run();
81
81
  },
82
+ // pi-lens-ignore: no-console-except-error — the command line's printed output is its product
82
83
  out: (line) => console.log(line),
84
+ // pi-lens-ignore: no-console-except-error — the command line's printed output is its product
83
85
  err: (line) => console.error(line),
84
86
  };
85
87
  }
package/src/cli/main.ts CHANGED
@@ -7,6 +7,7 @@ runCli(process.argv.slice(2), processEnvironment()).then(
7
7
  process.exitCode = code;
8
8
  },
9
9
  (error: unknown) => {
10
+ // pi-lens-ignore: no-console-except-error — the entry point reports a failed boot on stderr; no logger exists yet
10
11
  console.error(error instanceof Error ? error.message : String(error));
11
12
  // A boot that failed has stopped what it started; nothing may keep the process up.
12
13
  process.exit(1);
@@ -89,6 +89,8 @@ export class Project {
89
89
  let value: unknown = raw.value;
90
90
  for (const key of path) {
91
91
  if (typeof value !== "object" || value === null) return undefined;
92
+ // A read only: nothing is assigned by key, and every caller passes literal keys.
93
+ // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop
92
94
  value = (value as Record<string, unknown>)[key];
93
95
  }
94
96
  return typeof value === "string" && value.trim() !== "" ? value : undefined;
@@ -26,7 +26,7 @@ export async function openModelRuntime(
26
26
  ): Promise<ModelRuntime> {
27
27
  process.env.PI_CODING_AGENT_DIR = agentDir;
28
28
  const authPath = join(agentDir, "auth.json");
29
- return await ModelRuntime.create(
29
+ return ModelRuntime.create(
30
30
  existsSync(authPath)
31
31
  ? { authPath, refreshOnCreate: false }
32
32
  : { credentials: emptyCredentials, refreshOnCreate: false },
@@ -47,7 +47,7 @@ export async function assemble(
47
47
  config: Parameters<typeof defineRoundtable>[0],
48
48
  agentDir: string,
49
49
  ): Promise<DefinedRoundtable> {
50
- return await defineRoundtable(config, {
50
+ return defineRoundtable(config, {
51
51
  modelRuntime: await openModelRuntime(agentDir),
52
52
  });
53
53
  }
@@ -114,15 +114,16 @@ export function renderProject(
114
114
  };
115
115
  const skeleton = filesUnder(dir)
116
116
  .map((file) => relative(dir, file))
117
- .filter(
118
- (path) =>
119
- !path.startsWith(`${PLUGIN_DIR}/`) &&
120
- !path.startsWith(`${OFFICIAL_DIR}/`),
121
- )
122
- .map((path) => ({
123
- path: targetOf(path),
124
- content: fill(readFileSync(join(dir, path), "utf8"), values),
125
- }));
117
+ .flatMap((path) =>
118
+ path.startsWith(`${PLUGIN_DIR}/`) || path.startsWith(`${OFFICIAL_DIR}/`)
119
+ ? []
120
+ : [
121
+ {
122
+ path: targetOf(path),
123
+ content: fill(readFileSync(join(dir, path), "utf8"), values),
124
+ },
125
+ ],
126
+ );
126
127
  return [...skeleton, ...renderPlugin(pluginNames("hello"), dir)];
127
128
  }
128
129
 
@@ -39,8 +39,7 @@ export function agentSystemPrompt(input: {
39
39
  ];
40
40
  if (group) {
41
41
  const others = group.members
42
- .filter((m) => m.name !== agent.name)
43
- .map((m) => `"${m.displayName}"`)
42
+ .flatMap((m) => (m.name === agent.name ? [] : [`"${m.displayName}"`]))
44
43
  .join(", ");
45
44
  parts.push(
46
45
  `You are now speaking in the group chat "${group.group.displayName}" with ${w.name} and ${others}. Each of your turns shows the group messages since your last turn. Speak only for yourself and add what your role brings; do not repeat what others said. To hand the next word to a member, mention them as @display name.`,
@@ -155,8 +155,7 @@ export class PgAgentStore implements AgentDirectory {
155
155
  #activeNames(): string {
156
156
  return (
157
157
  this.agents()
158
- .filter((a) => a.status === "active")
159
- .map((a) => a.name)
158
+ .flatMap((a) => (a.status === "active" ? [a.name] : []))
160
159
  .join(", ") || "none"
161
160
  );
162
161
  }
@@ -115,9 +115,11 @@ export class FakeChannels implements AgentChannels {
115
115
  this.webhooksRemoved.push(channelId);
116
116
  }
117
117
  texts(channelId: string) {
118
- return this.posts
119
- .filter((p) => p.channelId === channelId)
120
- .map((p) => `${p.post.name}: ${p.post.chunks.join("")}`);
118
+ return this.posts.flatMap((p) =>
119
+ p.channelId === channelId
120
+ ? [`${p.post.name}: ${p.post.chunks.join("")}`]
121
+ : [],
122
+ );
121
123
  }
122
124
  }
123
125
 
@@ -10,6 +10,7 @@ const BADGE = { x: 396, y: 396, radius: 64 };
10
10
  const hsl = (h: number, s: number, l: number): string =>
11
11
  `hsl(${Math.round(h) % 360},${s}%,${l}%)`;
12
12
 
13
+ // pi-lens-ignore: hardcoded-url — the SVG XML namespace, a fixed identifier rather than an address that is fetched
13
14
  const layer = (body: string): Buffer =>
14
15
  Buffer.from(
15
16
  `<svg xmlns="http://www.w3.org/2000/svg" width="${SIDE}" height="${SIDE}" viewBox="0 0 ${SIDE} ${SIDE}">${body}</svg>`,
@@ -43,7 +43,7 @@ export class GroupMessages {
43
43
  const rows: MessageRow[] = await this.#sql`
44
44
  SELECT id, author, author_name, text, created_at FROM agent_group_messages
45
45
  WHERE guild_id = ${this.#guild} AND group_name = ${group} ORDER BY id DESC LIMIT ${limit}`;
46
- return rows.reverse().map(toMessage);
46
+ return rows.toReversed().map(toMessage);
47
47
  }
48
48
 
49
49
  /** Group messages the member has not received yet, its own excluded; the newest `limit` are carried. */
@@ -15,20 +15,21 @@ const PROMPT_CHARS_FOR_SCORING = 1_200;
15
15
  const escapeRegExp = (value: string) =>
16
16
  value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
17
 
18
+ const mentions = (text: string, label: string) =>
19
+ // The label is an agent's name, escaped so every character of it matches literally.
20
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
21
+ new RegExp(`@${escapeRegExp(label)}(?![\\p{L}\\p{N}_-])`, "iu").test(text);
22
+
18
23
  /** Members a text names with `@display name` or `@name`, in group order. */
19
24
  export function mentionedMembers(
20
25
  text: string,
21
26
  members: readonly Agent[],
22
27
  ): string[] {
23
- return members
24
- .filter((member) =>
25
- [member.displayName, member.name].some((label) =>
26
- new RegExp(`@${escapeRegExp(label)}(?![\\p{L}\\p{N}_-])`, "iu").test(
27
- text,
28
- ),
29
- ),
30
- )
31
- .map((member) => member.name);
28
+ return members.flatMap((member) =>
29
+ [member.displayName, member.name].some((label) => mentions(text, label))
30
+ ? [member.name]
31
+ : [],
32
+ );
32
33
  }
33
34
 
34
35
  /**
@@ -12,7 +12,12 @@ import {
12
12
  RECENT_FOR_SCORING,
13
13
  } from "./group-round.ts";
14
14
  import { channelIdOf, groupScope, groupSessionKey } from "./team-keys.ts";
15
- import { BACKLOG_LIMIT, type Chain, type TurnHost } from "./team-turn-types.ts";
15
+ import {
16
+ BACKLOG_LIMIT,
17
+ type Chain,
18
+ type TurnExtra,
19
+ type TurnHost,
20
+ } from "./team-turn-types.ts";
16
21
 
17
22
  /** A group channel's rounds: scoring, then the chosen members answer one at a time. */
18
23
  export class GroupTurns {
@@ -99,19 +104,19 @@ export class GroupTurns {
99
104
  // Delivered once the session has the text, so a message never comes twice.
100
105
  if (backlog.lastId !== undefined)
101
106
  await store.advanceCursor(group.name, name, backlog.lastId);
107
+ // Attachments and a confirmation belong to a member's first turn of the round only.
108
+ const extra: TurnExtra = { interactive: true };
109
+ if (first) {
110
+ extra.attachments = attachments;
111
+ if (approvals.get(name)) extra.confirmed = true;
112
+ }
102
113
  const result = await this.#host.turn(
103
114
  member,
104
115
  groupScope(member, group),
105
116
  channel,
106
117
  groupTurnText(group, member, backlog),
107
118
  chain,
108
- first
109
- ? {
110
- attachments,
111
- interactive: true,
112
- ...(approvals.get(name) ? { confirmed: true } : {}),
113
- }
114
- : { interactive: true },
119
+ extra,
115
120
  );
116
121
  replies += 1;
117
122
  last = member;
@@ -54,17 +54,16 @@ export function layoutText(
54
54
  layout: readonly CategoryLayout[],
55
55
  store: TeamStore,
56
56
  ): string {
57
- const lines = layout
58
- .filter((c) => categoryKind(c.name))
59
- .map((c) => {
60
- const shown = c.channelIds.map(
61
- (id) =>
62
- store.agentByChannel(id)?.name ??
63
- store.groupByChannel(id)?.name ??
64
- `<#${id}>`,
65
- );
66
- return `- ${c.name}: ${shown.join(", ") || "empty"}`;
67
- });
57
+ const lines = layout.flatMap((c) => {
58
+ if (!categoryKind(c.name)) return [];
59
+ const shown = c.channelIds.map(
60
+ (id) =>
61
+ store.agentByChannel(id)?.name ??
62
+ store.groupByChannel(id)?.name ??
63
+ `<#${id}>`,
64
+ );
65
+ return [`- ${c.name}: ${shown.join(", ") || "empty"}`];
66
+ });
68
67
  return `Categories, in server order:\n${lines.join("\n") || "none"}`;
69
68
  }
70
69
 
@@ -122,9 +121,9 @@ export function planArrangement(
122
121
  channelIds: [...channelIds, ...(existing ? stay(existing) : [])],
123
122
  };
124
123
  });
125
- const rest = current
126
- .filter((c) => !ahead.some((a) => a.id === c.id))
127
- .map((c) => ({ ...c, channelIds: stay(c) }));
124
+ const rest = current.flatMap((c) =>
125
+ ahead.some((a) => a.id === c.id) ? [] : [{ ...c, channelIds: stay(c) }],
126
+ );
128
127
  const layout = [...ahead, ...rest];
129
128
  const remove = layout.flatMap((c) =>
130
129
  c.id && c.channelIds.length === 0 && categoryKind(c.name) ? [c.id] : [],
@@ -61,12 +61,11 @@ export async function teamStatus(ctx: TeamContext): Promise<TeamStatus> {
61
61
  schedules: home ? (await schedules.forChannel(home)).length : 0,
62
62
  });
63
63
  }
64
- const groups: GroupStatus[] = store
65
- .groups()
66
- .filter((group) => group.status === "active")
67
- .map((group) => {
68
- const lastActive = turns.lastGroupActive(group.name);
69
- return {
64
+ const groups: GroupStatus[] = store.groups().flatMap((group) => {
65
+ if (group.status !== "active") return [];
66
+ const lastActive = turns.lastGroupActive(group.name);
67
+ return [
68
+ {
70
69
  name: group.name,
71
70
  displayName: group.displayName,
72
71
  channelId: group.channelId,
@@ -74,7 +73,8 @@ export async function teamStatus(ctx: TeamContext): Promise<TeamStatus> {
74
73
  host: store.agent(group.host)?.displayName ?? group.host,
75
74
  busy: queue.size(discordKey(group.channelId)),
76
75
  ...(lastActive ? { lastActive } : {}),
77
- };
78
- });
76
+ },
77
+ ];
78
+ });
79
79
  return { agents, groups };
80
80
  }
@@ -9,7 +9,7 @@ import type { AgentTurnScope } from "../domain/ports.ts";
9
9
  import { messages } from "../i18n/index.ts";
10
10
  import { splitReply } from "../presentation/reply-splitter.ts";
11
11
  import { thinkingLine } from "../presentation/thinking-line.ts";
12
- import { settleTurn } from "../routing/settle-turn.ts";
12
+ import { endOf, settleTurn } from "../routing/settle-turn.ts";
13
13
  import type { Speaker } from "../speakers.ts";
14
14
  import { toolTiers } from "../tool-tiers.ts";
15
15
  import { AgentMessages } from "./agent-messages.ts";
@@ -31,6 +31,11 @@ import {
31
31
 
32
32
  export { MAX_CHAIN_MESSAGES, type TeamTurnsOptions };
33
33
 
34
+ /** What a failed turn posts: the stopped notice for a stop, the failure notice otherwise. */
35
+ function noticeOf(result: { stopped?: true }): string {
36
+ return result.stopped ? messages().stoppedNotice : messages().failureNotice;
37
+ }
38
+
34
39
  /**
35
40
  * The agent team's turns: owner messages, background turns, group rounds, and messages between
36
41
  * agents, each posted under the answering agent's name.
@@ -199,7 +204,7 @@ export class TeamTurns {
199
204
  }
200
205
  this.#options.events?.turnEnded({
201
206
  ...turn,
202
- result: result.ok ? "ok" : result.stopped ? "stopped" : "failed",
207
+ result: endOf(result),
203
208
  });
204
209
  try {
205
210
  if (!result.ok && !result.stopped) {
@@ -214,13 +219,7 @@ export class TeamTurns {
214
219
  : undefined;
215
220
  await this.#post(postTo, this.#current(agent), {
216
221
  ...(thinking ? { thinking } : {}),
217
- chunks: result.ok
218
- ? splitReply(result.text)
219
- : [
220
- result.stopped
221
- ? messages().stoppedNotice
222
- : messages().failureNotice,
223
- ],
222
+ chunks: result.ok ? splitReply(result.text) : [noticeOf(result)],
224
223
  });
225
224
  } catch (error) {
226
225
  logger.error({ agent: agent.name, err: error }, "agent reply not posted");
@@ -76,7 +76,24 @@ function judgeThrough(modelRuntime: ModelRuntime, ref: ModelRef): JudgeModel {
76
76
  throw new JudgeError(
77
77
  `the judge's model ${formatModelRef(ref)} is not available; log in to its provider or change judge.model`,
78
78
  );
79
- return await piJudgeModel(modelRuntime, model)(system, prompt);
79
+ return piJudgeModel(modelRuntime, model)(system, prompt);
80
+ };
81
+ }
82
+
83
+ /** The listener `config.http` names: its unix socket when it has one, else its TCP port. */
84
+ function publicListener(
85
+ http: ReturnType<typeof resolveConfig>["http"],
86
+ ): ListenerConfig {
87
+ if (http.socketPath)
88
+ return {
89
+ id: "public",
90
+ socketPath: http.socketPath,
91
+ ...(http.socketMode === undefined ? {} : { mode: http.socketMode }),
92
+ };
93
+ return {
94
+ id: "public",
95
+ port: http.port,
96
+ ...(http.hostname ? { hostname: http.hostname } : {}),
80
97
  };
81
98
  }
82
99
 
@@ -138,24 +155,7 @@ export async function defineRoundtable(
138
155
  },
139
156
  database: { url: config.databaseUrl },
140
157
  toolTiers: toolTiers(config.toolTiers),
141
- listeners: [
142
- config.http.socketPath
143
- ? {
144
- id: "public",
145
- socketPath: config.http.socketPath,
146
- ...(config.http.socketMode === undefined
147
- ? {}
148
- : { mode: config.http.socketMode }),
149
- }
150
- : {
151
- id: "public",
152
- port: config.http.port,
153
- ...(config.http.hostname
154
- ? { hostname: config.http.hostname }
155
- : {}),
156
- },
157
- ...(overrides.listeners ?? []),
158
- ],
158
+ listeners: [publicListener(config.http), ...(overrides.listeners ?? [])],
159
159
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
160
160
  apiKey: (provider) => registry.getApiKeyForProvider(provider),
161
161
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
@@ -120,20 +120,20 @@ export function defineTool<Schema extends TObject>(
120
120
  }),
121
121
  };
122
122
  const hold = spec.hold;
123
- return {
123
+ const contribution = {
124
124
  name,
125
125
  minTier: spec.minTier,
126
126
  agent: spec.agent ?? true,
127
127
  session,
128
- ...(hold
129
- ? {
130
- hold: {
131
- name: `tool:${name}`,
132
- describe: (tool, input) =>
133
- tool === name ? hold(input as Static<Schema>) : undefined,
134
- },
135
- }
136
- : {}),
128
+ };
129
+ if (!hold) return contribution;
130
+ return {
131
+ ...contribution,
132
+ hold: {
133
+ name: `tool:${name}`,
134
+ describe: (tool, input) =>
135
+ tool === name ? hold(input as Static<Schema>) : undefined,
136
+ },
137
137
  };
138
138
  }
139
139
 
@@ -90,13 +90,12 @@ export function operationPermissions(
90
90
  channel: OwnerChannel,
91
91
  ): bigint[] {
92
92
  const needed = OPERATION_PERMISSIONS[operation];
93
- return channel.isThread()
94
- ? needed.map((flag) =>
95
- flag === PermissionFlagsBits.SendMessages
96
- ? PermissionFlagsBits.SendMessagesInThreads
97
- : flag,
98
- )
99
- : [...needed];
93
+ if (!channel.isThread()) return [...needed];
94
+ return needed.map((flag) =>
95
+ flag === PermissionFlagsBits.SendMessages
96
+ ? PermissionFlagsBits.SendMessagesInThreads
97
+ : flag,
98
+ );
100
99
  }
101
100
 
102
101
  interface ChannelFile {
@@ -127,6 +126,16 @@ function serializeMessage(message: Message) {
127
126
  };
128
127
  }
129
128
 
129
+ function uploadOf(upload: ChannelFile) {
130
+ return {
131
+ attachment: Buffer.from(upload.dataBase64, "base64"),
132
+ name: upload.filename,
133
+ ...(upload.description === undefined
134
+ ? {}
135
+ : { description: upload.description }),
136
+ };
137
+ }
138
+
130
139
  function messagePayload(
131
140
  args: Record<string, unknown>,
132
141
  ): Pick<MessageCreateOptions, "content" | "allowedMentions" | "files"> {
@@ -134,17 +143,7 @@ function messagePayload(
134
143
  return {
135
144
  ...(args.content !== undefined ? { content: String(args.content) } : {}),
136
145
  allowedMentions: { parse: [] },
137
- ...(uploads
138
- ? {
139
- files: uploads.map((upload) => ({
140
- attachment: Buffer.from(upload.dataBase64, "base64"),
141
- name: upload.filename,
142
- ...(upload.description === undefined
143
- ? {}
144
- : { description: upload.description }),
145
- })),
146
- }
147
- : {}),
146
+ ...(uploads ? { files: uploads.map(uploadOf) } : {}),
148
147
  };
149
148
  }
150
149
 
@@ -393,6 +392,9 @@ export function discordChannelExecutor(
393
392
  "INVALID_CHANNEL: a thread takes its parent channel's permissions",
394
393
  );
395
394
  return editPermissions(channel, tool, args);
395
+ default:
396
+ // A message operation, handled below.
397
+ break;
396
398
  }
397
399
  const message = await channel.messages.fetch(String(args.messageId));
398
400
  if (tool === "discord_edit_message")
@@ -262,6 +262,12 @@ const DISCORD_EPOCH = 1420070400000;
262
262
  const BASE64 =
263
263
  /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
264
264
 
265
+ /** The `=` padding a base64 string ends with, which carries no bytes. */
266
+ function paddingOf(data: string): number {
267
+ if (data.endsWith("==")) return 2;
268
+ return data.endsWith("=") ? 1 : 0;
269
+ }
270
+
265
271
  /** Checks and fills defaults into a tool's arguments; throws ChannelToolError otherwise. */
266
272
  export function parseChannelTool(
267
273
  tool: string,
@@ -307,9 +313,7 @@ export function parseChannelTool(
307
313
  if (!BASE64.test(upload.dataBase64))
308
314
  throw new ChannelToolError("INVALID_CHANNEL_BASE64");
309
315
  const data = upload.dataBase64;
310
- bytes +=
311
- (data.length / 4) * 3 -
312
- (data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
316
+ bytes += (data.length / 4) * 3 - paddingOf(data);
313
317
  if (bytes > CHANNEL_UPLOAD_MAX_BYTES)
314
318
  throw new ChannelToolError("INVALID_CHANNEL_UPLOAD_SIZE");
315
319
  }
@@ -22,6 +22,16 @@ function attachmentRefs(
22
22
  }));
23
23
  }
24
24
 
25
+ function referenceOf(referenced: Message, mention: RegExp) {
26
+ return {
27
+ text: referenced.content.replace(mention, "").trim(),
28
+ attachments: attachmentRefs(referenced),
29
+ ...(referenced.webhookId
30
+ ? { webhookName: referenced.author.username }
31
+ : {}),
32
+ };
33
+ }
34
+
25
35
  /**
26
36
  * The facts of one Discord message, as the neutral `InboundMessage`; undefined for the bot's own
27
37
  * messages and before the client is ready. A forward reports the channel it came from as a
@@ -32,6 +42,8 @@ export async function toInbound(
32
42
  { botId, applicationId }: BotIdentity,
33
43
  ): Promise<InboundMessage | undefined> {
34
44
  if (!botId || message.author.id === botId) return undefined;
45
+ // botId is the bot's own Discord user id from the client, a snowflake, not message text.
46
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
35
47
  const mention = new RegExp(`<@!?${botId}>`, "g");
36
48
  const reference = message.reference;
37
49
  // A forward also has a reference, to a message elsewhere; its copy is the snapshot.
@@ -84,16 +96,6 @@ export async function toInbound(
84
96
  },
85
97
  }
86
98
  : {}),
87
- ...(referenced
88
- ? {
89
- reference: {
90
- text: referenced.content.replace(mention, "").trim(),
91
- attachments: attachmentRefs(referenced),
92
- ...(referenced.webhookId
93
- ? { webhookName: referenced.author.username }
94
- : {}),
95
- },
96
- }
97
- : {}),
99
+ ...(referenced ? { reference: referenceOf(referenced, mention) } : {}),
98
100
  };
99
101
  }
@@ -362,9 +362,9 @@ export class OwnerCards implements InteractionModule {
362
362
  }
363
363
  if (interaction.isStringSelectMenu() && action === "pick") {
364
364
  const values = interaction.values;
365
- const choices = question.options
366
- .filter((_option, i) => values.includes(String(i)))
367
- .map((option) => option.label);
365
+ const choices = question.options.flatMap((option, i) =>
366
+ values.includes(String(i)) ? [option.label] : [],
367
+ );
368
368
  if (values.includes(OTHER)) {
369
369
  card.picked = choices;
370
370
  await interaction.showModal(answerModal(id));
@@ -64,18 +64,22 @@ export class OwnerThreads {
64
64
  permissions.has(PermissionFlagsBits.ManageThreads))
65
65
  );
66
66
  };
67
- return threads
68
- .filter((t) => sees(owner, t) && sees(bot, t))
69
- .map((t) => ({
70
- id: t.id,
71
- name: t.name,
72
- parentId: t.parentId,
73
- private: t.type === ChannelType.PrivateThread,
74
- archived: t.archived,
75
- locked: t.locked,
76
- messageCount: t.messageCount,
77
- createdAt: t.createdAt?.toISOString() ?? null,
78
- }));
67
+ return threads.flatMap((t) =>
68
+ sees(owner, t) && sees(bot, t)
69
+ ? [
70
+ {
71
+ id: t.id,
72
+ name: t.name,
73
+ parentId: t.parentId,
74
+ private: t.type === ChannelType.PrivateThread,
75
+ archived: t.archived,
76
+ locked: t.locked,
77
+ messageCount: t.messageCount,
78
+ createdAt: t.createdAt?.toISOString() ?? null,
79
+ },
80
+ ]
81
+ : [],
82
+ );
79
83
  }
80
84
 
81
85
  async create(args: Record<string, unknown>) {