pi-roundtable 0.1.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.
Files changed (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,183 @@
1
+ import {
2
+ ChannelType,
3
+ type Guild,
4
+ type GuildBasedChannel,
5
+ type GuildMember,
6
+ PermissionFlagsBits,
7
+ PermissionsBitField,
8
+ type Role,
9
+ } from "discord.js";
10
+ import { messages } from "../i18n/index.ts";
11
+ import { ChannelToolError } from "./channel-operations.ts";
12
+
13
+ /** What both the owner and the bot hold in the server for each server-level tool. */
14
+ export const SERVER_PERMISSIONS: Record<string, bigint[]> = {
15
+ discord_list_channels: [],
16
+ discord_list_roles: [],
17
+ discord_list_threads: [],
18
+ discord_find_members: [],
19
+ discord_create_channel: [PermissionFlagsBits.ManageChannels],
20
+ discord_create_role: [PermissionFlagsBits.ManageRoles],
21
+ discord_edit_role: [PermissionFlagsBits.ManageRoles],
22
+ discord_delete_role: [PermissionFlagsBits.ManageRoles],
23
+ discord_add_member_role: [PermissionFlagsBits.ManageRoles],
24
+ discord_remove_member_role: [PermissionFlagsBits.ManageRoles],
25
+ discord_set_nickname: [PermissionFlagsBits.ManageNicknames],
26
+ discord_timeout_member: [PermissionFlagsBits.ModerateMembers],
27
+ discord_kick_member: [PermissionFlagsBits.KickMembers],
28
+ discord_ban_member: [PermissionFlagsBits.BanMembers],
29
+ discord_unban_member: [PermissionFlagsBits.BanMembers],
30
+ };
31
+
32
+ export const CHANNEL_TYPES = {
33
+ text: ChannelType.GuildText,
34
+ voice: ChannelType.GuildVoice,
35
+ announcement: ChannelType.GuildAnnouncement,
36
+ category: ChannelType.GuildCategory,
37
+ } as const;
38
+
39
+ export const refuse = (code: string, detail: string) =>
40
+ new ChannelToolError(`${code}: ${detail}`);
41
+
42
+ /** The owner outranks a role when the owner's highest role is above it, or the owner owns the server. */
43
+ export function outranksRole(owner: GuildMember, role: Role): boolean {
44
+ return (
45
+ owner.guild.ownerId === owner.id ||
46
+ owner.roles.highest.comparePositionTo(role) > 0
47
+ );
48
+ }
49
+
50
+ export function outranksMember(
51
+ owner: GuildMember,
52
+ target: GuildMember,
53
+ ): boolean {
54
+ if (owner.guild.ownerId === owner.id) return target.id !== owner.id;
55
+ return (
56
+ target.id !== owner.guild.ownerId &&
57
+ owner.roles.highest.comparePositionTo(target.roles.highest) > 0
58
+ );
59
+ }
60
+
61
+ export function permissionFlags(names: unknown): bigint[] {
62
+ const list = (names ?? []) as string[];
63
+ for (const name of list) {
64
+ if (!Object.hasOwn(PermissionFlagsBits, name))
65
+ throw refuse("INVALID_PERMISSION", name);
66
+ }
67
+ return list.map(
68
+ (name) => PermissionFlagsBits[name as keyof typeof PermissionFlagsBits],
69
+ );
70
+ }
71
+
72
+ /** The owner's and the bot's standing in a server: who holds what, and who outranks whom. */
73
+ export class OwnerAccess {
74
+ readonly #ownerId: string;
75
+ readonly #ownerName: string;
76
+
77
+ constructor(ownerId: string, ownerName: string) {
78
+ this.#ownerId = ownerId;
79
+ this.#ownerName = ownerName;
80
+ }
81
+
82
+ /**
83
+ * The owner's and the bot's members, after checking both hold the permissions, in the
84
+ * channel when one is given and in the server otherwise.
85
+ */
86
+ async members(
87
+ guild: Guild,
88
+ needed: bigint[],
89
+ channel?: GuildBasedChannel,
90
+ ): Promise<{ owner: GuildMember; bot: GuildMember }> {
91
+ const owner = await guild.members
92
+ .fetch({ user: this.#ownerId, force: true })
93
+ .catch(() => undefined);
94
+ if (!owner)
95
+ throw refuse(
96
+ "OWNER_NOT_IN_SERVER",
97
+ `${this.#ownerName} is not in that server`,
98
+ );
99
+ const bot = await guild.members.fetchMe({ force: true });
100
+ const has = (member: GuildMember) =>
101
+ (channel ? channel.permissionsFor(member) : member.permissions).has(
102
+ needed,
103
+ );
104
+ const names = new PermissionsBitField(needed).toArray().join(", ");
105
+ if (!has(owner))
106
+ throw refuse(
107
+ "OWNER_PERMISSION_MISSING",
108
+ `${this.#ownerName} lacks ${names} there`,
109
+ );
110
+ if (!has(bot))
111
+ throw refuse(
112
+ "BOT_PERMISSION_MISSING",
113
+ messages().refuseAssistantLacks(names),
114
+ );
115
+ return { owner, bot };
116
+ }
117
+
118
+ holdsAll(owner: GuildMember, permissions: bigint[]): void {
119
+ if (!owner.permissions.has(permissions))
120
+ throw refuse(
121
+ "OWNER_PERMISSION_MISSING",
122
+ `${this.#ownerName} does not hold every permission the role would grant`,
123
+ );
124
+ }
125
+
126
+ outranks(owner: GuildMember, member: GuildMember): void {
127
+ if (!outranksMember(owner, member))
128
+ throw refuse(
129
+ "HIERARCHY",
130
+ `that member is not below ${this.#ownerName}'s highest role`,
131
+ );
132
+ }
133
+
134
+ async role(guild: Guild, owner: GuildMember, roleId: unknown) {
135
+ const role = await guild.roles.fetch(String(roleId)).catch(() => null);
136
+ if (!role) throw refuse("UNKNOWN_ROLE", String(roleId));
137
+ if (!outranksRole(owner, role))
138
+ throw refuse(
139
+ "HIERARCHY",
140
+ `that role is not below ${this.#ownerName}'s highest role`,
141
+ );
142
+ return role;
143
+ }
144
+
145
+ async member(guild: Guild, userId: unknown) {
146
+ const member = await guild.members
147
+ .fetch({ user: String(userId), force: true })
148
+ .catch(() => undefined);
149
+ if (!member) throw refuse("UNKNOWN_MEMBER", String(userId));
150
+ return member;
151
+ }
152
+
153
+ async category(guild: Guild, parentId: string) {
154
+ const parent = await guild.channels.fetch(parentId).catch(() => null);
155
+ if (parent?.type !== ChannelType.GuildCategory)
156
+ throw refuse("INVALID_CHANNEL", "parentId is not a category there");
157
+ return parent;
158
+ }
159
+ }
160
+
161
+ /** A member's server permissions; an administrator holds them all. */
162
+ export function permissionNames(member: GuildMember): string[] {
163
+ return member.permissions.has(PermissionFlagsBits.Administrator)
164
+ ? ["Administrator"]
165
+ : member.permissions.toArray();
166
+ }
167
+
168
+ export function roleOptions(
169
+ args: Record<string, unknown>,
170
+ permissions: bigint[] | undefined,
171
+ ) {
172
+ return {
173
+ ...(typeof args.name === "string" ? { name: args.name } : {}),
174
+ ...(typeof args.color === "string"
175
+ ? { colors: { primaryColor: args.color as `#${string}` } }
176
+ : {}),
177
+ ...(typeof args.hoist === "boolean" ? { hoist: args.hoist } : {}),
178
+ ...(typeof args.mentionable === "boolean"
179
+ ? { mentionable: args.mentionable }
180
+ : {}),
181
+ ...(permissions ? { permissions } : {}),
182
+ };
183
+ }
@@ -0,0 +1,160 @@
1
+ import {
2
+ type AnyThreadChannel,
3
+ ChannelType,
4
+ type Client,
5
+ type Guild,
6
+ type GuildMember,
7
+ PermissionFlagsBits,
8
+ } from "discord.js";
9
+ import { fetchManagedChannel, fetchOwnerChannel } from "./channel-executor.ts";
10
+ import { type OwnerAccess, refuse } from "./owner-discord-access.ts";
11
+
12
+ /** The owner's agent's thread operations, bounded by what both the owner and the bot may do. */
13
+ export class OwnerThreads {
14
+ readonly #client: Client;
15
+ readonly #access: OwnerAccess;
16
+ readonly #ownerId: string;
17
+ /** The audit-log reason of an action taken without one of its own. */
18
+ readonly #reason: string;
19
+
20
+ constructor(
21
+ client: Client,
22
+ access: OwnerAccess,
23
+ ownerId: string,
24
+ reason: string,
25
+ ) {
26
+ this.#client = client;
27
+ this.#access = access;
28
+ this.#ownerId = ownerId;
29
+ this.#reason = reason;
30
+ }
31
+
32
+ /**
33
+ * Threads both can see: a private thread only when the owner may manage threads there,
34
+ * since Discord shows it to its members alone otherwise.
35
+ */
36
+ async list(
37
+ guild: Guild,
38
+ owner: GuildMember,
39
+ bot: GuildMember,
40
+ args: Record<string, unknown>,
41
+ ) {
42
+ const parentId =
43
+ typeof args.parentId === "string" ? args.parentId : undefined;
44
+ let threads: AnyThreadChannel[];
45
+ if (args.archived === true) {
46
+ if (!parentId)
47
+ throw refuse("INVALID_ARGUMENT", "archived threads need parentId");
48
+ const parent = await fetchManagedChannel(this.#client, parentId);
49
+ if (parent.guildId !== guild.id)
50
+ throw refuse("INVALID_CHANNEL", "parentId is not in that server");
51
+ const fetched = await parent.threads.fetchArchived({ limit: 50 });
52
+ threads = [...fetched.threads.values()];
53
+ } else {
54
+ const fetched = await guild.channels.fetchActiveThreads();
55
+ threads = [...fetched.threads.values()].filter(
56
+ (t) => !parentId || t.parentId === parentId,
57
+ );
58
+ }
59
+ const sees = (member: GuildMember, thread: AnyThreadChannel) => {
60
+ const permissions = thread.permissionsFor(member);
61
+ return (
62
+ permissions.has(PermissionFlagsBits.ViewChannel) &&
63
+ (thread.type !== ChannelType.PrivateThread ||
64
+ permissions.has(PermissionFlagsBits.ManageThreads))
65
+ );
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
+ }));
79
+ }
80
+
81
+ async create(args: Record<string, unknown>) {
82
+ const parent = await fetchManagedChannel(
83
+ this.#client,
84
+ String(args.channelId),
85
+ );
86
+ const isPrivate = args.private === true;
87
+ if (isPrivate && (args.messageId || parent.type !== ChannelType.GuildText))
88
+ throw refuse(
89
+ "INVALID_ARGUMENT",
90
+ "a private thread starts on its own in a text channel",
91
+ );
92
+ await this.#access.members(
93
+ parent.guild,
94
+ [
95
+ PermissionFlagsBits.ViewChannel,
96
+ isPrivate
97
+ ? PermissionFlagsBits.CreatePrivateThreads
98
+ : PermissionFlagsBits.CreatePublicThreads,
99
+ PermissionFlagsBits.SendMessagesInThreads,
100
+ ],
101
+ parent,
102
+ );
103
+ const options = {
104
+ name: String(args.name),
105
+ ...(typeof args.autoArchiveMinutes === "number"
106
+ ? { autoArchiveDuration: args.autoArchiveMinutes }
107
+ : {}),
108
+ reason: typeof args.reason === "string" ? args.reason : this.#reason,
109
+ };
110
+ let thread: AnyThreadChannel;
111
+ if (typeof args.messageId === "string") {
112
+ const message = await parent.messages.fetch(args.messageId).catch(() => {
113
+ throw refuse("UNKNOWN_MESSAGE", String(args.messageId));
114
+ });
115
+ thread = await message.startThread(options);
116
+ } else if (isPrivate && parent.type === ChannelType.GuildText) {
117
+ thread = await parent.threads.create({
118
+ ...options,
119
+ type: ChannelType.PrivateThread,
120
+ });
121
+ await thread.members.add(this.#ownerId);
122
+ } else {
123
+ thread = await parent.threads.create(options);
124
+ }
125
+ return {
126
+ id: thread.id,
127
+ name: thread.name,
128
+ parentId: thread.parentId,
129
+ private: isPrivate,
130
+ };
131
+ }
132
+
133
+ async edit(args: Record<string, unknown>) {
134
+ const thread = await fetchOwnerChannel(this.#client, String(args.threadId));
135
+ if (!thread.isThread())
136
+ throw refuse("INVALID_CHANNEL", "threadId is not a thread");
137
+ await this.#access.members(
138
+ thread.guild,
139
+ [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageThreads],
140
+ thread,
141
+ );
142
+ await thread.edit({
143
+ ...(typeof args.name === "string" ? { name: args.name } : {}),
144
+ ...(typeof args.archived === "boolean"
145
+ ? { archived: args.archived }
146
+ : {}),
147
+ ...(typeof args.locked === "boolean" ? { locked: args.locked } : {}),
148
+ ...(typeof args.autoArchiveMinutes === "number"
149
+ ? { autoArchiveDuration: args.autoArchiveMinutes }
150
+ : {}),
151
+ reason: typeof args.reason === "string" ? args.reason : this.#reason,
152
+ });
153
+ return {
154
+ id: thread.id,
155
+ name: thread.name,
156
+ archived: thread.archived,
157
+ locked: thread.locked,
158
+ };
159
+ }
160
+ }
@@ -0,0 +1,325 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ChannelType, type Client, PermissionFlagsBits } from "discord.js";
3
+ import { DiscordOwnerOps } from "./owner-discord.ts";
4
+
5
+ const OWNER = "100";
6
+ const BOT = "200";
7
+ const {
8
+ KickMembers,
9
+ ManageNicknames,
10
+ ManageRoles,
11
+ Administrator,
12
+ ViewChannel,
13
+ ManageThreads,
14
+ CreatePrivateThreads,
15
+ SendMessagesInThreads,
16
+ } = PermissionFlagsBits;
17
+
18
+ class FakeRole {
19
+ constructor(
20
+ readonly id: string,
21
+ readonly position: number,
22
+ ) {}
23
+ comparePositionTo(other: FakeRole): number {
24
+ return this.position - other.position;
25
+ }
26
+ }
27
+
28
+ /** A member with a fixed permission set, a highest role, and recorded actions. */
29
+ class FakeMember {
30
+ actions: string[] = [];
31
+ readonly roles: {
32
+ highest: FakeRole;
33
+ add: (role: FakeRole) => Promise<void>;
34
+ };
35
+ readonly permissions: { has: (needed: bigint | bigint[]) => boolean };
36
+ constructor(
37
+ readonly id: string,
38
+ readonly guild: FakeGuild,
39
+ rank: number,
40
+ granted: bigint[],
41
+ ) {
42
+ this.roles = {
43
+ highest: new FakeRole(`r${id}`, rank),
44
+ add: async (role) => {
45
+ this.actions.push(`add ${role.id}`);
46
+ },
47
+ };
48
+ this.permissions = {
49
+ has: (needed) =>
50
+ granted.includes(Administrator) ||
51
+ [needed].flat().every((p) => granted.includes(p)),
52
+ };
53
+ }
54
+ async kick(): Promise<void> {
55
+ this.actions.push("kick");
56
+ }
57
+ async setNickname(nickname: string | null): Promise<void> {
58
+ this.actions.push(`nick ${nickname}`);
59
+ }
60
+ }
61
+
62
+ class FakeGuild {
63
+ readonly id = "1";
64
+ ownerId = "999";
65
+ readonly people = new Map<string, FakeMember>();
66
+ readonly roleList = new Map<string, FakeRole>();
67
+ readonly members = {
68
+ fetch: async ({ user }: { user: string }) => {
69
+ const member = this.people.get(user);
70
+ if (!member) throw new Error("Unknown Member");
71
+ return member;
72
+ },
73
+ fetchMe: async () => this.people.get(BOT),
74
+ };
75
+ readonly roles = {
76
+ fetch: async (id: string) => this.roleList.get(id) ?? null,
77
+ };
78
+ readonly threadList = new Map<string, FakeThread>();
79
+ readonly channels = {
80
+ fetchActiveThreads: async () => ({ threads: this.threadList }),
81
+ };
82
+ add(id: string, rank: number, granted: bigint[]): FakeMember {
83
+ const member = new FakeMember(id, this, rank, granted);
84
+ this.people.set(id, member);
85
+ return member;
86
+ }
87
+ }
88
+
89
+ /** A thread whose permissions are its members' server permissions. */
90
+ class FakeThread {
91
+ edits: object[] = [];
92
+ added: string[] = [];
93
+ readonly parentId = "50";
94
+ readonly guildId: string;
95
+ archived = false;
96
+ locked = false;
97
+ readonly messageCount = 0;
98
+ readonly createdAt = new Date(0);
99
+ readonly members = {
100
+ add: async (id: string) => {
101
+ this.added.push(id);
102
+ },
103
+ };
104
+ constructor(
105
+ readonly id: string,
106
+ readonly guild: FakeGuild,
107
+ readonly type: ChannelType,
108
+ readonly name = "t",
109
+ ) {
110
+ this.guildId = guild.id;
111
+ }
112
+ isThread() {
113
+ return true;
114
+ }
115
+ permissionsFor(member: FakeMember) {
116
+ return member.permissions;
117
+ }
118
+ async edit(options: object) {
119
+ this.edits.push(options);
120
+ }
121
+ }
122
+
123
+ /** A text channel whose new threads are recorded. */
124
+ class FakeTextChannel {
125
+ readonly id = "50";
126
+ readonly type = ChannelType.GuildText;
127
+ readonly guildId: string;
128
+ created: object[] = [];
129
+ readonly threads = {
130
+ create: async (options: { name: string; type?: ChannelType }) => {
131
+ this.created.push(options);
132
+ return new FakeThread(
133
+ "60",
134
+ this.guild,
135
+ options.type ?? ChannelType.PublicThread,
136
+ options.name,
137
+ );
138
+ },
139
+ };
140
+ constructor(readonly guild: FakeGuild) {
141
+ this.guildId = guild.id;
142
+ }
143
+ isThread() {
144
+ return false;
145
+ }
146
+ permissionsFor(member: FakeMember) {
147
+ return member.permissions;
148
+ }
149
+ }
150
+
151
+ function setup(ownerGrants: bigint[], botGrants: bigint[]) {
152
+ const guild = new FakeGuild();
153
+ const owner = guild.add(OWNER, 10, ownerGrants);
154
+ const bot = guild.add(BOT, 20, botGrants);
155
+ const guest = guild.add("300", 5, []);
156
+ const text = new FakeTextChannel(guild);
157
+ const channels = new Map<string, unknown>([["50", text]]);
158
+ const client = {
159
+ isReady: () => true,
160
+ channels: { fetch: async (id: string) => channels.get(id) ?? null },
161
+ guilds: {
162
+ fetch: async (id: string) => {
163
+ if (id !== guild.id) throw new Error("Unknown Guild");
164
+ return guild;
165
+ },
166
+ },
167
+ } as unknown as Client;
168
+ return {
169
+ ops: new DiscordOwnerOps(client, OWNER),
170
+ guild,
171
+ owner,
172
+ bot,
173
+ guest,
174
+ text,
175
+ channels,
176
+ };
177
+ }
178
+
179
+ const kick = (userId: string) => ({ guildId: "1", userId });
180
+
181
+ describe("DiscordOwnerOps", () => {
182
+ test("a kick runs when both hold the permission and the owner outranks the member", async () => {
183
+ const { ops, guest } = setup([KickMembers], [KickMembers]);
184
+ expect(await ops.run("discord_kick_member", kick("300"))).toEqual({
185
+ userId: "300",
186
+ kicked: true,
187
+ });
188
+ expect(guest.actions).toEqual(["kick"]);
189
+ });
190
+
191
+ test.each([
192
+ ["the owner lacks it", [], [KickMembers], "OWNER_PERMISSION_MISSING"],
193
+ ["the bot lacks it", [KickMembers], [], "BOT_PERMISSION_MISSING"],
194
+ ])(
195
+ "a kick is refused when %s",
196
+ async (_name, ownerGrants, botGrants, code) => {
197
+ const { ops, guest } = setup(ownerGrants, botGrants);
198
+ await expect(ops.run("discord_kick_member", kick("300"))).rejects.toThrow(
199
+ code,
200
+ );
201
+ expect(guest.actions).toEqual([]);
202
+ },
203
+ );
204
+
205
+ test("a member at or above the owner's highest role is refused unless the owner owns the server", async () => {
206
+ const { ops, guild, guest } = setup([KickMembers], [KickMembers]);
207
+ guest.roles.highest = new FakeRole("rtop", 10);
208
+ await expect(ops.run("discord_kick_member", kick("300"))).rejects.toThrow(
209
+ "HIERARCHY",
210
+ );
211
+ guild.ownerId = OWNER;
212
+ await ops.run("discord_kick_member", kick("300"));
213
+ expect(guest.actions).toEqual(["kick"]);
214
+ });
215
+
216
+ test("the owner must be in the server", async () => {
217
+ const { ops, guild } = setup([KickMembers], [KickMembers]);
218
+ guild.people.delete(OWNER);
219
+ await expect(ops.run("discord_kick_member", kick("300"))).rejects.toThrow(
220
+ "OWNER_NOT_IN_SERVER",
221
+ );
222
+ });
223
+
224
+ test("a role above the owner cannot be given", async () => {
225
+ const { ops, guild, guest } = setup([ManageRoles], [ManageRoles]);
226
+ guild.roleList.set("low", new FakeRole("low", 3));
227
+ guild.roleList.set("high", new FakeRole("high", 15));
228
+ const give = (roleId: string) =>
229
+ ops.run("discord_add_member_role", {
230
+ guildId: "1",
231
+ userId: "300",
232
+ roleId,
233
+ });
234
+ await give("low");
235
+ await expect(give("high")).rejects.toThrow("HIERARCHY");
236
+ expect(guest.actions).toEqual(["add low"]);
237
+ });
238
+
239
+ test("the owner may rename the assistant even though it ranks higher", async () => {
240
+ const { ops, bot } = setup([ManageNicknames], [ManageNicknames]);
241
+ await ops.run("discord_set_nickname", {
242
+ guildId: "1",
243
+ userId: BOT,
244
+ nickname: "Pika the Crow",
245
+ });
246
+ expect(bot.actions).toEqual(["nick Pika the Crow"]);
247
+ });
248
+ });
249
+
250
+ describe("threads", () => {
251
+ const threadMaker = [
252
+ ViewChannel,
253
+ CreatePrivateThreads,
254
+ SendMessagesInThreads,
255
+ ];
256
+
257
+ test("a private thread starts on its own and has the owner added", async () => {
258
+ const { ops, text } = setup(threadMaker, threadMaker);
259
+ expect(
260
+ await ops.run("discord_create_thread", {
261
+ channelId: "50",
262
+ name: "plans",
263
+ private: true,
264
+ }),
265
+ ).toEqual({ id: "60", name: "plans", parentId: "50", private: true });
266
+ expect(text.created).toMatchObject([
267
+ { name: "plans", type: ChannelType.PrivateThread },
268
+ ]);
269
+ });
270
+
271
+ test("a private thread from a message is refused before anything is made", async () => {
272
+ const { ops, text } = setup(threadMaker, threadMaker);
273
+ await expect(
274
+ ops.run("discord_create_thread", {
275
+ channelId: "50",
276
+ name: "x",
277
+ messageId: "70",
278
+ private: true,
279
+ }),
280
+ ).rejects.toThrow("INVALID_ARGUMENT");
281
+ expect(text.created).toEqual([]);
282
+ });
283
+
284
+ test("editing a thread needs Manage Threads from both", async () => {
285
+ const { ops, guild, channels } = setup([ViewChannel], [Administrator]);
286
+ const thread = new FakeThread("61", guild, ChannelType.PublicThread);
287
+ channels.set("61", thread);
288
+ await expect(
289
+ ops.run("discord_edit_thread", { threadId: "61", archived: true }),
290
+ ).rejects.toThrow("OWNER_PERMISSION_MISSING");
291
+ expect(thread.edits).toEqual([]);
292
+
293
+ const both = setup([ViewChannel, ManageThreads], [Administrator]);
294
+ const shared = new FakeThread("62", both.guild, ChannelType.PublicThread);
295
+ both.channels.set("62", shared);
296
+ await both.ops.run("discord_edit_thread", {
297
+ threadId: "62",
298
+ archived: true,
299
+ });
300
+ expect(shared.edits).toMatchObject([{ archived: true }]);
301
+ });
302
+
303
+ test("private threads are listed only when the owner may manage threads", async () => {
304
+ const list = async (ownerGrants: bigint[]) => {
305
+ const { ops, guild } = setup(ownerGrants, [Administrator]);
306
+ guild.threadList.set(
307
+ "63",
308
+ new FakeThread("63", guild, ChannelType.PublicThread, "open"),
309
+ );
310
+ guild.threadList.set(
311
+ "64",
312
+ new FakeThread("64", guild, ChannelType.PrivateThread, "secret"),
313
+ );
314
+ const threads = (await ops.run("discord_list_threads", {
315
+ guildId: "1",
316
+ })) as { name: string }[];
317
+ return threads.map((t) => t.name);
318
+ };
319
+ expect(await list([ViewChannel])).toEqual(["open"]);
320
+ expect(await list([ViewChannel, ManageThreads])).toEqual([
321
+ "open",
322
+ "secret",
323
+ ]);
324
+ });
325
+ });