discoclaw 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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,301 @@
1
+ import { GuildScheduledEventEntityType, GuildScheduledEventPrivacyLevel } from 'discord.js';
2
+ import { resolveChannel, fmtTime } from './action-utils.js';
3
+ const GUILD_TYPE_MAP = {
4
+ memberInfo: true, roleInfo: true, roleAdd: true, roleRemove: true,
5
+ searchMessages: true, eventList: true, eventCreate: true, eventEdit: true, eventDelete: true,
6
+ };
7
+ export const GUILD_ACTION_TYPES = new Set(Object.keys(GUILD_TYPE_MAP));
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ // Discord epoch: 2015-01-01T00:00:00.000Z
12
+ const DISCORD_EPOCH = 1420070400000n;
13
+ /**
14
+ * Convert an ISO date string or raw snowflake ID to a Discord snowflake string.
15
+ * Returns null on invalid input.
16
+ */
17
+ export function isoToSnowflake(input) {
18
+ const trimmed = input.trim();
19
+ if (!trimmed)
20
+ return null;
21
+ // Already a snowflake (numeric string, 17-20 digits)?
22
+ if (/^\d{17,20}$/.test(trimmed))
23
+ return trimmed;
24
+ // Try ISO date parse.
25
+ const ms = Date.parse(trimmed);
26
+ if (isNaN(ms))
27
+ return null;
28
+ if (BigInt(ms) < DISCORD_EPOCH)
29
+ return null;
30
+ const snowflake = (BigInt(ms) - DISCORD_EPOCH) << 22n;
31
+ return snowflake.toString();
32
+ }
33
+ function resolveRole(guild, ref) {
34
+ // Try by ID.
35
+ const byId = guild.roles.cache.get(ref);
36
+ if (byId)
37
+ return byId;
38
+ // Try by name (case-insensitive).
39
+ return guild.roles.cache.find((r) => r.name.toLowerCase() === ref.toLowerCase());
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Executor
43
+ // ---------------------------------------------------------------------------
44
+ export async function executeGuildAction(action, ctx) {
45
+ const { guild } = ctx;
46
+ switch (action.type) {
47
+ case 'memberInfo': {
48
+ const member = await guild.members.fetch(action.userId).catch(() => null);
49
+ if (!member)
50
+ return { ok: false, error: `Member "${action.userId}" not found` };
51
+ const info = [
52
+ `Username: ${member.user.username}`,
53
+ `Display: ${member.displayName}`,
54
+ `ID: ${member.id}`,
55
+ `Joined: ${member.joinedAt ? fmtTime(member.joinedAt) : 'unknown'}`,
56
+ `Roles: ${member.roles.cache.filter((r) => r.name !== '@everyone').map((r) => r.name).join(', ') || '(none)'}`,
57
+ ];
58
+ if (member.user.bot)
59
+ info.push('Bot: yes');
60
+ return { ok: true, summary: info.join('\n') };
61
+ }
62
+ case 'roleInfo': {
63
+ const roles = [...guild.roles.cache.values()]
64
+ .filter((r) => r.name !== '@everyone')
65
+ .sort((a, b) => b.position - a.position);
66
+ if (roles.length === 0) {
67
+ return { ok: true, summary: 'No custom roles' };
68
+ }
69
+ const lines = roles.map((r) => {
70
+ const members = r.members?.size ?? '?';
71
+ return `${r.name} (id:${r.id}, ${members} members)`;
72
+ });
73
+ return { ok: true, summary: `Roles:\n${lines.join('\n')}` };
74
+ }
75
+ case 'roleAdd': {
76
+ const member = await guild.members.fetch(action.userId).catch(() => null);
77
+ if (!member)
78
+ return { ok: false, error: `Member "${action.userId}" not found` };
79
+ const role = resolveRole(guild, action.role);
80
+ if (!role)
81
+ return { ok: false, error: `Role "${action.role}" not found` };
82
+ await member.roles.add(role.id);
83
+ return { ok: true, summary: `Added role "${role.name}" to ${member.displayName}` };
84
+ }
85
+ case 'roleRemove': {
86
+ const member = await guild.members.fetch(action.userId).catch(() => null);
87
+ if (!member)
88
+ return { ok: false, error: `Member "${action.userId}" not found` };
89
+ const role = resolveRole(guild, action.role);
90
+ if (!role)
91
+ return { ok: false, error: `Role "${action.role}" not found` };
92
+ await member.roles.remove(role.id);
93
+ return { ok: true, summary: `Removed role "${role.name}" from ${member.displayName}` };
94
+ }
95
+ case 'searchMessages': {
96
+ const channel = action.channel
97
+ ? resolveChannel(guild, action.channel)
98
+ : guild.channels.cache.get(ctx.channelId);
99
+ if (!channel || !('messages' in channel)) {
100
+ return { ok: false, error: `Channel not found` };
101
+ }
102
+ const limit = Math.min(Math.max(1, action.limit ?? 25), 50);
103
+ const maxPages = Math.min(Math.max(1, action.maxPages ?? 5), 10);
104
+ const beforeSnowflake = action.before ? isoToSnowflake(action.before) : null;
105
+ const afterSnowflake = action.after ? isoToSnowflake(action.after) : null;
106
+ const query = action.query.toLowerCase();
107
+ const matches = [];
108
+ let cursor = beforeSnowflake ?? undefined;
109
+ let totalScanned = 0;
110
+ let hitAfterBound = false;
111
+ for (let page = 0; page < maxPages; page++) {
112
+ const fetchOpts = { limit: 100 };
113
+ if (cursor)
114
+ fetchOpts.before = cursor;
115
+ const batch = await channel.messages.fetch(fetchOpts);
116
+ const msgs = [...batch.values()];
117
+ if (msgs.length === 0)
118
+ break;
119
+ for (const m of msgs) {
120
+ // If we've passed the after boundary, stop entirely.
121
+ if (afterSnowflake && BigInt(m.id) <= BigInt(afterSnowflake)) {
122
+ hitAfterBound = true;
123
+ break;
124
+ }
125
+ totalScanned++;
126
+ if (m.content?.toLowerCase().includes(query) && matches.length < limit) {
127
+ matches.push(m);
128
+ }
129
+ }
130
+ // Update cursor to oldest message in this batch.
131
+ cursor = msgs[msgs.length - 1].id;
132
+ if (hitAfterBound)
133
+ break;
134
+ if (matches.length >= limit)
135
+ break;
136
+ if (msgs.length < 100)
137
+ break; // End of channel.
138
+ }
139
+ if (matches.length === 0) {
140
+ return { ok: true, summary: `No messages matching "${action.query}" in #${channel.name} (scanned ${totalScanned} messages)` };
141
+ }
142
+ const lines = matches.map((m) => {
143
+ const author = m.author?.username ?? 'Unknown';
144
+ const ts = m.createdAt ? fmtTime(m.createdAt) : '';
145
+ const text = (m.content || '').slice(0, 150);
146
+ return `[${ts}] [${author}] ${text} (id:${m.id})`;
147
+ });
148
+ return { ok: true, summary: `Search results for "${action.query}" in #${channel.name} (${matches.length} found, ${totalScanned} scanned):\n${lines.join('\n')}` };
149
+ }
150
+ case 'eventList': {
151
+ const events = await guild.scheduledEvents.fetch();
152
+ if (events.size === 0) {
153
+ return { ok: true, summary: 'No scheduled events' };
154
+ }
155
+ const lines = [...events.values()].map((e) => {
156
+ const start = e.scheduledStartAt ? fmtTime(e.scheduledStartAt) : 'TBD';
157
+ return `${e.name} (id:${e.id}) — ${start}${e.description ? ` — ${e.description.slice(0, 80)}` : ''}`;
158
+ });
159
+ return { ok: true, summary: `Scheduled events:\n${lines.join('\n')}` };
160
+ }
161
+ case 'eventCreate': {
162
+ const startTime = new Date(action.startTime);
163
+ if (isNaN(startTime.getTime())) {
164
+ return { ok: false, error: `Invalid startTime: "${action.startTime}"` };
165
+ }
166
+ const opts = {
167
+ name: action.name,
168
+ scheduledStartTime: startTime.toISOString(),
169
+ privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly,
170
+ description: action.description,
171
+ };
172
+ if (action.endTime) {
173
+ const endTime = new Date(action.endTime);
174
+ if (!isNaN(endTime.getTime())) {
175
+ opts.scheduledEndTime = endTime.toISOString();
176
+ }
177
+ }
178
+ if (action.location) {
179
+ opts.entityType = GuildScheduledEventEntityType.External;
180
+ opts.entityMetadata = { location: action.location };
181
+ if (!opts.scheduledEndTime) {
182
+ // External events require an end time. Default to +1 hour.
183
+ opts.scheduledEndTime = new Date(startTime.getTime() + 3600_000).toISOString();
184
+ }
185
+ }
186
+ else if (action.channelId) {
187
+ opts.entityType = GuildScheduledEventEntityType.Voice;
188
+ opts.channel = action.channelId;
189
+ }
190
+ else {
191
+ opts.entityType = GuildScheduledEventEntityType.External;
192
+ opts.entityMetadata = { location: 'TBD' };
193
+ if (!opts.scheduledEndTime) {
194
+ opts.scheduledEndTime = new Date(startTime.getTime() + 3600_000).toISOString();
195
+ }
196
+ }
197
+ const event = await guild.scheduledEvents.create(opts);
198
+ return { ok: true, summary: `Created event "${event.name}"` };
199
+ }
200
+ case 'eventEdit': {
201
+ const { eventId, name, startTime, endTime, description, location } = action;
202
+ if (!name && !startTime && !endTime && description === undefined && !location) {
203
+ return { ok: false, error: 'eventEdit requires at least one field to update' };
204
+ }
205
+ const edits = {};
206
+ if (name)
207
+ edits.name = name;
208
+ if (description !== undefined)
209
+ edits.description = description;
210
+ if (location)
211
+ edits.entityMetadata = { location };
212
+ if (startTime) {
213
+ const d = new Date(startTime);
214
+ if (isNaN(d.getTime()))
215
+ return { ok: false, error: `Invalid startTime: "${startTime}"` };
216
+ edits.scheduledStartTime = d.toISOString();
217
+ }
218
+ if (endTime) {
219
+ const d = new Date(endTime);
220
+ if (isNaN(d.getTime()))
221
+ return { ok: false, error: `Invalid endTime: "${endTime}"` };
222
+ edits.scheduledEndTime = d.toISOString();
223
+ }
224
+ const event = await guild.scheduledEvents.edit(eventId, edits);
225
+ return { ok: true, summary: `Edited event "${event.name}"` };
226
+ }
227
+ case 'eventDelete': {
228
+ const event = await guild.scheduledEvents.fetch(action.eventId).catch(() => null);
229
+ const name = event?.name ?? action.eventId;
230
+ await guild.scheduledEvents.delete(action.eventId);
231
+ return { ok: true, summary: `Deleted event "${name}"` };
232
+ }
233
+ }
234
+ }
235
+ // ---------------------------------------------------------------------------
236
+ // Prompt section
237
+ // ---------------------------------------------------------------------------
238
+ export function guildActionsPromptSection() {
239
+ return `### Guild Info & Management
240
+
241
+ **memberInfo** — Get info about a server member:
242
+ \`\`\`
243
+ <discord-action>{"type":"memberInfo","userId":"123456789"}</discord-action>
244
+ \`\`\`
245
+
246
+ **roleInfo** — List all roles in the server:
247
+ \`\`\`
248
+ <discord-action>{"type":"roleInfo"}</discord-action>
249
+ \`\`\`
250
+
251
+ **roleAdd** / **roleRemove** — Add or remove a role from a member:
252
+ \`\`\`
253
+ <discord-action>{"type":"roleAdd","userId":"123","role":"Moderator"}</discord-action>
254
+ <discord-action>{"type":"roleRemove","userId":"123","role":"Moderator"}</discord-action>
255
+ \`\`\`
256
+ - \`role\`: Role name or ID.
257
+
258
+ **searchMessages** — Search messages in a channel (paginated, client-side filter):
259
+ \`\`\`
260
+ <discord-action>{"type":"searchMessages","query":"keyword","channel":"#general","limit":10}</discord-action>
261
+ \`\`\`
262
+ - \`query\` (required): Text to search for (case-insensitive substring match).
263
+ - \`channel\` (optional): Channel to search; defaults to current channel.
264
+ - \`limit\` (optional): Max results (1–50, default 25).
265
+ - \`before\` (optional): Message ID or ISO date — only search messages before this point.
266
+ - \`after\` (optional): Message ID or ISO date — stop scanning at this point.
267
+ - \`maxPages\` (optional): Pages of 100 messages to scan (1–10, default 5 = 500 messages).
268
+
269
+ **eventList** — List scheduled events:
270
+ \`\`\`
271
+ <discord-action>{"type":"eventList"}</discord-action>
272
+ \`\`\`
273
+
274
+ **eventCreate** — Create a scheduled event:
275
+ \`\`\`
276
+ <discord-action>{"type":"eventCreate","name":"Team Meeting","startTime":"2025-02-01T15:00:00Z","description":"Weekly sync","location":"Zoom"}</discord-action>
277
+ \`\`\`
278
+ - \`name\` (required): Event name.
279
+ - \`startTime\` (required): ISO 8601 datetime.
280
+ - \`endTime\` (optional): ISO 8601 datetime.
281
+ - \`description\` (optional): Event description.
282
+ - \`channelId\` (optional): Voice channel ID for voice events.
283
+ - \`location\` (optional): External location (creates an external event).
284
+
285
+ **eventEdit** — Edit a scheduled event:
286
+ \`\`\`
287
+ <discord-action>{"type":"eventEdit","eventId":"123","name":"New Name","startTime":"2025-03-01T10:00:00Z"}</discord-action>
288
+ \`\`\`
289
+ - \`eventId\` (required): Event ID (from eventList).
290
+ - \`name\` (optional): New event name.
291
+ - \`startTime\` (optional): New ISO 8601 start time.
292
+ - \`endTime\` (optional): New ISO 8601 end time.
293
+ - \`description\` (optional): New description.
294
+ - \`location\` (optional): New external location.
295
+ At least one field besides eventId is required.
296
+
297
+ **eventDelete** — Delete a scheduled event (destructive — confirm with user first):
298
+ \`\`\`
299
+ <discord-action>{"type":"eventDelete","eventId":"123"}</discord-action>
300
+ \`\`\``;
301
+ }
@@ -0,0 +1,386 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import { executeGuildAction, isoToSnowflake } from './actions-guild.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeMockMember(overrides = {}) {
8
+ const roles = new Map();
9
+ for (const r of (overrides.roles ?? [])) {
10
+ roles.set(r.id, r);
11
+ }
12
+ return {
13
+ id: overrides.id ?? 'user1',
14
+ displayName: overrides.displayName ?? 'TestUser',
15
+ user: {
16
+ username: overrides.username ?? 'testuser',
17
+ bot: overrides.bot ?? false,
18
+ },
19
+ joinedAt: overrides.joinedAt ?? new Date('2024-01-01T00:00:00Z'),
20
+ roles: {
21
+ cache: {
22
+ filter: (fn) => {
23
+ const filtered = [...roles.values()].filter(fn);
24
+ return { map: (mapFn) => filtered.map(mapFn) };
25
+ },
26
+ values: () => roles.values(),
27
+ },
28
+ add: vi.fn(async () => { }),
29
+ remove: vi.fn(async () => { }),
30
+ },
31
+ timeout: vi.fn(async () => { }),
32
+ kick: vi.fn(async () => { }),
33
+ ban: vi.fn(async () => { }),
34
+ };
35
+ }
36
+ function makeCtx(overrides = {}) {
37
+ const channels = new Map();
38
+ for (const ch of (overrides.channels ?? [])) {
39
+ channels.set(ch.id, ch);
40
+ }
41
+ const roles = new Map();
42
+ for (const r of (overrides.roles ?? [])) {
43
+ roles.set(r.id, r);
44
+ }
45
+ const members = new Map();
46
+ for (const m of (overrides.members ?? [])) {
47
+ members.set(m.id, m);
48
+ }
49
+ return {
50
+ guild: {
51
+ channels: {
52
+ cache: {
53
+ get: (id) => channels.get(id),
54
+ find: (fn) => {
55
+ for (const ch of channels.values()) {
56
+ if (fn(ch))
57
+ return ch;
58
+ }
59
+ return undefined;
60
+ },
61
+ values: () => channels.values(),
62
+ },
63
+ },
64
+ members: {
65
+ fetch: vi.fn(async (id) => {
66
+ const m = members.get(id);
67
+ if (!m)
68
+ throw new Error('not found');
69
+ return m;
70
+ }),
71
+ },
72
+ roles: {
73
+ cache: {
74
+ get: (id) => roles.get(id),
75
+ find: (fn) => {
76
+ for (const r of roles.values()) {
77
+ if (fn(r))
78
+ return r;
79
+ }
80
+ return undefined;
81
+ },
82
+ values: () => roles.values(),
83
+ },
84
+ },
85
+ scheduledEvents: {
86
+ fetch: vi.fn(async (idOrUndefined) => {
87
+ if (typeof idOrUndefined === 'string') {
88
+ const ev = overrides.events?.get(idOrUndefined);
89
+ if (!ev)
90
+ throw new Error('not found');
91
+ return ev;
92
+ }
93
+ return overrides.events ?? new Map();
94
+ }),
95
+ create: vi.fn(async (opts) => ({ name: opts.name })),
96
+ edit: vi.fn(async (_id, opts) => ({ name: opts.name ?? 'Edited Event' })),
97
+ delete: vi.fn(async () => { }),
98
+ },
99
+ },
100
+ client: {},
101
+ channelId: 'ch1',
102
+ messageId: 'msg1',
103
+ };
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Tests
107
+ // ---------------------------------------------------------------------------
108
+ describe('memberInfo', () => {
109
+ it('returns member info', async () => {
110
+ const member = makeMockMember({
111
+ id: 'u1',
112
+ username: 'alice',
113
+ displayName: 'Alice',
114
+ roles: [{ id: 'r1', name: 'Admin' }],
115
+ });
116
+ const ctx = makeCtx({ members: [member] });
117
+ const result = await executeGuildAction({ type: 'memberInfo', userId: 'u1' }, ctx);
118
+ expect(result.ok).toBe(true);
119
+ const summary = result.summary;
120
+ expect(summary).toContain('Username: alice');
121
+ expect(summary).toContain('Display: Alice');
122
+ expect(summary).toContain('Admin');
123
+ });
124
+ it('fails when member not found', async () => {
125
+ const ctx = makeCtx({});
126
+ const result = await executeGuildAction({ type: 'memberInfo', userId: 'nonexistent' }, ctx);
127
+ expect(result).toEqual({ ok: false, error: 'Member "nonexistent" not found' });
128
+ });
129
+ });
130
+ describe('roleInfo', () => {
131
+ it('lists roles sorted by position', async () => {
132
+ const roles = [
133
+ { id: 'r1', name: 'Admin', position: 2, members: { size: 3 } },
134
+ { id: 'r2', name: 'Member', position: 1, members: { size: 10 } },
135
+ ];
136
+ const ctx = makeCtx({ roles });
137
+ const result = await executeGuildAction({ type: 'roleInfo' }, ctx);
138
+ expect(result.ok).toBe(true);
139
+ const summary = result.summary;
140
+ expect(summary).toContain('Admin');
141
+ expect(summary).toContain('Member');
142
+ // Admin should come first (higher position).
143
+ expect(summary.indexOf('Admin')).toBeLessThan(summary.indexOf('Member'));
144
+ });
145
+ });
146
+ describe('roleAdd / roleRemove', () => {
147
+ it('adds a role by name', async () => {
148
+ const member = makeMockMember({ id: 'u1', displayName: 'Alice' });
149
+ const role = { id: 'r1', name: 'Moderator', position: 1 };
150
+ const ctx = makeCtx({ members: [member], roles: [role] });
151
+ const result = await executeGuildAction({ type: 'roleAdd', userId: 'u1', role: 'Moderator' }, ctx);
152
+ expect(result).toEqual({ ok: true, summary: 'Added role "Moderator" to Alice' });
153
+ expect(member.roles.add).toHaveBeenCalledWith('r1');
154
+ });
155
+ it('removes a role by name', async () => {
156
+ const member = makeMockMember({ id: 'u1', displayName: 'Alice' });
157
+ const role = { id: 'r1', name: 'Moderator', position: 1 };
158
+ const ctx = makeCtx({ members: [member], roles: [role] });
159
+ const result = await executeGuildAction({ type: 'roleRemove', userId: 'u1', role: 'Moderator' }, ctx);
160
+ expect(result).toEqual({ ok: true, summary: 'Removed role "Moderator" from Alice' });
161
+ expect(member.roles.remove).toHaveBeenCalledWith('r1');
162
+ });
163
+ it('fails when role not found', async () => {
164
+ const member = makeMockMember({ id: 'u1' });
165
+ const ctx = makeCtx({ members: [member] });
166
+ const result = await executeGuildAction({ type: 'roleAdd', userId: 'u1', role: 'Nonexistent' }, ctx);
167
+ expect(result).toEqual({ ok: false, error: 'Role "Nonexistent" not found' });
168
+ });
169
+ });
170
+ describe('isoToSnowflake', () => {
171
+ it('passes through raw snowflake IDs', () => {
172
+ expect(isoToSnowflake('123456789012345678')).toBe('123456789012345678');
173
+ });
174
+ it('converts ISO date to snowflake', () => {
175
+ const result = isoToSnowflake('2025-01-01T00:00:00Z');
176
+ expect(result).not.toBeNull();
177
+ // Should be a large numeric string
178
+ expect(/^\d+$/.test(result)).toBe(true);
179
+ });
180
+ it('returns null for invalid input', () => {
181
+ expect(isoToSnowflake('not-a-date')).toBeNull();
182
+ expect(isoToSnowflake('')).toBeNull();
183
+ });
184
+ it('returns null for pre-epoch dates', () => {
185
+ expect(isoToSnowflake('2014-01-01')).toBeNull();
186
+ expect(isoToSnowflake('1970-01-01T00:00:00Z')).toBeNull();
187
+ });
188
+ });
189
+ describe('searchMessages', () => {
190
+ /** Helper: create a channel with a paginated messages.fetch mock. */
191
+ function makeChannel(pages) {
192
+ let callIdx = 0;
193
+ return {
194
+ id: 'ch1',
195
+ name: 'general',
196
+ type: ChannelType.GuildText,
197
+ messages: {
198
+ fetch: vi.fn(async () => {
199
+ const page = pages[callIdx] ?? [];
200
+ callIdx++;
201
+ const map = new Map(page.map((m) => [m.id, m]));
202
+ return map;
203
+ }),
204
+ },
205
+ };
206
+ }
207
+ it('finds matching messages across pages', async () => {
208
+ // Page 1: exactly 100 messages so pagination continues.
209
+ const page1 = Array.from({ length: 100 }, (_, i) => ({
210
+ id: String(10200 - i),
211
+ content: i === 5 ? 'Hello world' : 'noise',
212
+ author: { username: i === 5 ? 'bob' : 'filler' },
213
+ }));
214
+ const page2 = [
215
+ { id: '10050', content: 'Hello again', author: { username: 'carol' } },
216
+ ];
217
+ const ch = makeChannel([page1, page2]);
218
+ const ctx = makeCtx({ channels: [ch] });
219
+ const result = await executeGuildAction({ type: 'searchMessages', query: 'hello', channel: '#general' }, ctx);
220
+ expect(result.ok).toBe(true);
221
+ const summary = result.summary;
222
+ expect(summary).toContain('[bob]');
223
+ expect(summary).toContain('[carol]');
224
+ expect(summary).toContain('2 found');
225
+ });
226
+ it('stops scanning when channel is exhausted', async () => {
227
+ // Single partial page (< 100 messages).
228
+ const page1 = [
229
+ { id: '50', content: 'Match here', author: { username: 'alice' } },
230
+ ];
231
+ const ch = makeChannel([page1]);
232
+ const ctx = makeCtx({ channels: [ch] });
233
+ const result = await executeGuildAction({ type: 'searchMessages', query: 'match', channel: '#general' }, ctx);
234
+ expect(result.ok).toBe(true);
235
+ expect(ch.messages.fetch).toHaveBeenCalledTimes(1);
236
+ });
237
+ it('stops scanning when after boundary is hit mid-page', async () => {
238
+ // Use snowflake-length IDs. after boundary = message ID 15000000000000000150.
239
+ const page1 = [
240
+ { id: '15000000000000000200', content: 'Match A', author: { username: 'alice' } },
241
+ { id: '15000000000000000180', content: 'Match B', author: { username: 'bob' } },
242
+ { id: '15000000000000000160', content: 'Match C', author: { username: 'carol' } },
243
+ { id: '15000000000000000140', content: 'Match D past boundary', author: { username: 'dave' } },
244
+ ];
245
+ const ch = makeChannel([page1]);
246
+ const ctx = makeCtx({ channels: [ch] });
247
+ const result = await executeGuildAction({ type: 'searchMessages', query: 'match', channel: '#general', after: '15000000000000000150' }, ctx);
248
+ expect(result.ok).toBe(true);
249
+ const summary = result.summary;
250
+ // Only A, B, C should match (IDs > boundary).
251
+ expect(summary).toContain('3 found');
252
+ expect(summary).not.toContain('dave');
253
+ });
254
+ it('respects maxPages cap', async () => {
255
+ // 100 messages per page, maxPages = 1 → only 1 fetch call.
256
+ const fullPage = Array.from({ length: 100 }, (_, i) => ({
257
+ id: String(10000 - i),
258
+ content: i === 50 ? 'target' : 'noise',
259
+ author: { username: 'user' },
260
+ }));
261
+ const ch = makeChannel([fullPage, fullPage]);
262
+ const ctx = makeCtx({ channels: [ch] });
263
+ const result = await executeGuildAction({ type: 'searchMessages', query: 'target', channel: '#general', maxPages: 1 }, ctx);
264
+ expect(result.ok).toBe(true);
265
+ expect(ch.messages.fetch).toHaveBeenCalledTimes(1);
266
+ });
267
+ it('returns no-match message with scan count', async () => {
268
+ const page1 = [
269
+ { id: '200', content: 'nothing relevant', author: { username: 'alice' } },
270
+ ];
271
+ const ch = makeChannel([page1]);
272
+ const ctx = makeCtx({ channels: [ch] });
273
+ const result = await executeGuildAction({ type: 'searchMessages', query: 'zzzzz', channel: '#general' }, ctx);
274
+ expect(result.ok).toBe(true);
275
+ expect(result.summary).toContain('No messages matching');
276
+ expect(result.summary).toContain('scanned 1');
277
+ });
278
+ });
279
+ describe('searchMessages — empty channel', () => {
280
+ function makeChannel(pages) {
281
+ let callIdx = 0;
282
+ return {
283
+ id: 'ch1',
284
+ name: 'general',
285
+ type: ChannelType.GuildText,
286
+ messages: {
287
+ fetch: vi.fn(async () => {
288
+ const page = pages[callIdx] ?? [];
289
+ callIdx++;
290
+ return new Map(page.map((m) => [m.id, m]));
291
+ }),
292
+ },
293
+ };
294
+ }
295
+ it('returns no-match when channel has zero messages', async () => {
296
+ const ch = makeChannel([[]]);
297
+ const ctx = makeCtx({ channels: [ch] });
298
+ const result = await executeGuildAction({ type: 'searchMessages', query: 'anything', channel: '#general' }, ctx);
299
+ expect(result.ok).toBe(true);
300
+ expect(result.summary).toContain('No messages matching');
301
+ expect(ch.messages.fetch).toHaveBeenCalledTimes(1);
302
+ });
303
+ });
304
+ describe('eventList', () => {
305
+ it('lists events', async () => {
306
+ const events = new Map([
307
+ ['e1', { id: 'e1', name: 'Team Meeting', scheduledStartAt: new Date('2025-02-01T15:00:00Z'), description: 'Weekly sync' }],
308
+ ]);
309
+ const ctx = makeCtx({ events });
310
+ const result = await executeGuildAction({ type: 'eventList' }, ctx);
311
+ expect(result.ok).toBe(true);
312
+ expect(result.summary).toContain('Team Meeting (id:e1)');
313
+ });
314
+ it('shows empty message when no events', async () => {
315
+ const ctx = makeCtx({ events: new Map() });
316
+ const result = await executeGuildAction({ type: 'eventList' }, ctx);
317
+ expect(result).toEqual({ ok: true, summary: 'No scheduled events' });
318
+ });
319
+ });
320
+ describe('eventCreate', () => {
321
+ it('creates an external event with location', async () => {
322
+ const ctx = makeCtx({});
323
+ const result = await executeGuildAction({
324
+ type: 'eventCreate',
325
+ name: 'Offsite',
326
+ startTime: '2025-03-01T10:00:00Z',
327
+ location: 'Conference Room A',
328
+ }, ctx);
329
+ expect(result).toEqual({ ok: true, summary: 'Created event "Offsite"' });
330
+ expect(ctx.guild.scheduledEvents.create).toHaveBeenCalled();
331
+ });
332
+ it('fails with invalid startTime', async () => {
333
+ const ctx = makeCtx({});
334
+ const result = await executeGuildAction({ type: 'eventCreate', name: 'Bad', startTime: 'not-a-date' }, ctx);
335
+ expect(result).toEqual({ ok: false, error: 'Invalid startTime: "not-a-date"' });
336
+ });
337
+ });
338
+ describe('eventEdit', () => {
339
+ it('edits an event name', async () => {
340
+ const ctx = makeCtx({});
341
+ const result = await executeGuildAction({ type: 'eventEdit', eventId: 'e1', name: 'New Name' }, ctx);
342
+ expect(result).toEqual({ ok: true, summary: 'Edited event "New Name"' });
343
+ expect(ctx.guild.scheduledEvents.edit).toHaveBeenCalledWith('e1', { name: 'New Name' });
344
+ });
345
+ it('edits multiple fields', async () => {
346
+ const ctx = makeCtx({});
347
+ const result = await executeGuildAction({ type: 'eventEdit', eventId: 'e1', name: 'Updated', description: 'New desc', startTime: '2025-06-01T10:00:00Z' }, ctx);
348
+ expect(result.ok).toBe(true);
349
+ const call = ctx.guild.scheduledEvents.edit.mock.calls[0];
350
+ expect(call[0]).toBe('e1');
351
+ expect(call[1]).toMatchObject({ name: 'Updated', description: 'New desc' });
352
+ expect(call[1].scheduledStartTime).toBeDefined();
353
+ });
354
+ it('fails when no fields provided', async () => {
355
+ const ctx = makeCtx({});
356
+ const result = await executeGuildAction({ type: 'eventEdit', eventId: 'e1' }, ctx);
357
+ expect(result).toEqual({ ok: false, error: 'eventEdit requires at least one field to update' });
358
+ });
359
+ it('fails with invalid startTime', async () => {
360
+ const ctx = makeCtx({});
361
+ const result = await executeGuildAction({ type: 'eventEdit', eventId: 'e1', startTime: 'nope' }, ctx);
362
+ expect(result).toEqual({ ok: false, error: 'Invalid startTime: "nope"' });
363
+ });
364
+ it('fails with invalid endTime', async () => {
365
+ const ctx = makeCtx({});
366
+ const result = await executeGuildAction({ type: 'eventEdit', eventId: 'e1', endTime: 'bad' }, ctx);
367
+ expect(result).toEqual({ ok: false, error: 'Invalid endTime: "bad"' });
368
+ });
369
+ });
370
+ describe('eventDelete', () => {
371
+ it('deletes an event and shows its name', async () => {
372
+ const events = new Map([
373
+ ['e1', { id: 'e1', name: 'Team Meeting' }],
374
+ ]);
375
+ const ctx = makeCtx({ events });
376
+ const result = await executeGuildAction({ type: 'eventDelete', eventId: 'e1' }, ctx);
377
+ expect(result).toEqual({ ok: true, summary: 'Deleted event "Team Meeting"' });
378
+ expect(ctx.guild.scheduledEvents.delete).toHaveBeenCalledWith('e1');
379
+ });
380
+ it('falls back to eventId when event not found for name', async () => {
381
+ const ctx = makeCtx({});
382
+ const result = await executeGuildAction({ type: 'eventDelete', eventId: 'unknown-id' }, ctx);
383
+ expect(result).toEqual({ ok: true, summary: 'Deleted event "unknown-id"' });
384
+ expect(ctx.guild.scheduledEvents.delete).toHaveBeenCalledWith('unknown-id');
385
+ });
386
+ });