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,738 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import * as fsMod from 'node:fs/promises';
4
+ import { executeMessagingAction } from './actions-messaging.js';
5
+ vi.mock('node:fs/promises', () => ({
6
+ stat: vi.fn(),
7
+ readFile: vi.fn(),
8
+ }));
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+ function makeMockChannel(overrides = {}) {
13
+ const messages = new Map();
14
+ return {
15
+ id: overrides.id ?? 'ch1',
16
+ name: overrides.name ?? 'general',
17
+ type: overrides.type ?? ChannelType.GuildText,
18
+ send: vi.fn(async () => ({ id: 'sent-1' })),
19
+ messages: {
20
+ fetch: vi.fn(async (arg) => {
21
+ if (typeof arg === 'string') {
22
+ const m = messages.get(arg);
23
+ if (!m)
24
+ throw new Error('Unknown message');
25
+ return m;
26
+ }
27
+ // Return a collection-like map for bulk fetch.
28
+ return overrides.fetchedMessages ?? new Map();
29
+ }),
30
+ fetchPinned: vi.fn(async () => overrides.pinnedMessages ?? new Map()),
31
+ },
32
+ threads: {
33
+ create: vi.fn(async (opts) => ({ name: opts.name, id: 'thread-1' })),
34
+ },
35
+ ...(overrides.extraProps ?? {}),
36
+ };
37
+ }
38
+ function makeMockMessage(id, overrides = {}) {
39
+ const { author: authorName, ...rest } = overrides;
40
+ return {
41
+ id,
42
+ content: rest.content ?? 'Hello',
43
+ author: { username: authorName ?? 'testuser' },
44
+ createdAt: new Date('2025-01-15T12:00:00Z'),
45
+ createdTimestamp: new Date('2025-01-15T12:00:00Z').getTime(),
46
+ react: vi.fn(async () => { }),
47
+ edit: vi.fn(async () => { }),
48
+ delete: vi.fn(async () => { }),
49
+ pin: vi.fn(async () => { }),
50
+ unpin: vi.fn(async () => { }),
51
+ startThread: vi.fn(async (opts) => ({ name: opts.name, id: 'thread-from-msg' })),
52
+ ...rest,
53
+ };
54
+ }
55
+ function makeCtx(channels) {
56
+ const cache = new Map();
57
+ for (const ch of channels)
58
+ cache.set(ch.id, ch);
59
+ return {
60
+ guild: {
61
+ channels: {
62
+ cache: {
63
+ get: (id) => cache.get(id),
64
+ find: (fn) => {
65
+ for (const ch of cache.values()) {
66
+ if (fn(ch))
67
+ return ch;
68
+ }
69
+ return undefined;
70
+ },
71
+ values: () => cache.values(),
72
+ },
73
+ },
74
+ },
75
+ client: {},
76
+ channelId: 'origin-ch',
77
+ messageId: 'msg1',
78
+ };
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Tests
82
+ // ---------------------------------------------------------------------------
83
+ describe('sendMessage', () => {
84
+ it('sends a message to a resolved channel', async () => {
85
+ const ch = makeMockChannel({ name: 'general' });
86
+ const ctx = makeCtx([ch]);
87
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#general', content: 'Hello!' }, ctx);
88
+ expect(result).toEqual({ ok: true, summary: 'Sent message to #general' });
89
+ expect(ch.send).toHaveBeenCalledWith({ content: 'Hello!', allowedMentions: { parse: [] } });
90
+ });
91
+ it('sends a reply when replyTo is set', async () => {
92
+ const ch = makeMockChannel({ name: 'general' });
93
+ const ctx = makeCtx([ch]);
94
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'general', content: 'Reply!', replyTo: 'msg-123' }, ctx);
95
+ expect(result.ok).toBe(true);
96
+ expect(ch.send).toHaveBeenCalledWith({
97
+ content: 'Reply!',
98
+ allowedMentions: { parse: [] },
99
+ reply: { messageReference: 'msg-123' },
100
+ });
101
+ });
102
+ it('fails when channel not found', async () => {
103
+ const ctx = makeCtx([]);
104
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#nonexistent', content: 'Hi' }, ctx);
105
+ expect(result).toEqual({ ok: false, error: 'Channel "#nonexistent" not found — it may have been deleted or archived. If this was a task thread, use taskShow with the task ID instead.' });
106
+ });
107
+ it('rejects content exceeding 2000 chars', async () => {
108
+ const ch = makeMockChannel({ name: 'general' });
109
+ const ctx = makeCtx([ch]);
110
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#general', content: 'x'.repeat(2001) }, ctx);
111
+ expect(result.ok).toBe(false);
112
+ expect(result.error).toContain('2000 character limit');
113
+ expect(ch.send).not.toHaveBeenCalled();
114
+ });
115
+ it('rejects empty content', async () => {
116
+ const ch = makeMockChannel({ name: 'general' });
117
+ const ctx = makeCtx([ch]);
118
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#general', content: ' ' }, ctx);
119
+ expect(result.ok).toBe(false);
120
+ expect(result.error).toContain('non-empty string');
121
+ });
122
+ it('returns descriptive error when targeting a forum channel by ID', async () => {
123
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
124
+ const ctx = makeCtx([forum]);
125
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'forum1', content: 'Hello' }, ctx);
126
+ expect(result.ok).toBe(false);
127
+ expect(result.error).toContain('forum channel');
128
+ expect(result.error).toContain('threadCreate');
129
+ expect(result.error).not.toContain('not found');
130
+ });
131
+ it('returns descriptive error when targeting a forum channel by name', async () => {
132
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
133
+ const ctx = makeCtx([forum]);
134
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'beads', content: 'Hello' }, ctx);
135
+ expect(result.ok).toBe(false);
136
+ expect(result.error).toContain('forum channel');
137
+ expect(result.error).not.toContain('not found');
138
+ });
139
+ // Test 1: sendMessage to forum by ID — suppressed when ctx.threadParentId matches
140
+ it('silently suppresses sendMessage to parent forum by ID when threadParentId matches', async () => {
141
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
142
+ const ctx = makeCtx([forum]);
143
+ ctx.channelId = 'thread1';
144
+ ctx.threadParentId = 'forum1';
145
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'forum1', content: 'Hello' }, ctx);
146
+ expect(result.ok).toBe(true);
147
+ expect(result.summary).toBe('Suppressed: response is already posted to this thread');
148
+ expect(forum.send).not.toHaveBeenCalled();
149
+ });
150
+ // Test 2: sendMessage to forum by name — suppressed
151
+ it('silently suppresses sendMessage to parent forum by name when threadParentId matches', async () => {
152
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
153
+ const ctx = makeCtx([forum]);
154
+ ctx.channelId = 'thread1';
155
+ ctx.threadParentId = 'forum1';
156
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'beads', content: 'Hello' }, ctx);
157
+ expect(result.ok).toBe(true);
158
+ expect(result.summary).toBe('Suppressed: response is already posted to this thread');
159
+ });
160
+ // Test 3: sendMessage to non-forum — works normally when threadParentId set
161
+ it('sends to non-forum channel normally even when threadParentId is set', async () => {
162
+ const ch = makeMockChannel({ id: 'ch2', name: 'general', type: ChannelType.GuildText });
163
+ const ctx = makeCtx([ch]);
164
+ ctx.threadParentId = 'forum1';
165
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#general', content: 'Hello!' }, ctx);
166
+ expect(result).toEqual({ ok: true, summary: 'Sent message to #general' });
167
+ expect(ch.send).toHaveBeenCalled();
168
+ });
169
+ // Test 4: sendMessage to forum — errors when threadParentId not set
170
+ it('errors when targeting a forum channel without threadParentId', async () => {
171
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
172
+ const ctx = makeCtx([forum]);
173
+ // No threadParentId set (undefined)
174
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'forum1', content: 'Hello' }, ctx);
175
+ expect(result.ok).toBe(false);
176
+ expect(result.error).toContain('forum channel');
177
+ expect(result.error).toContain('threadCreate');
178
+ });
179
+ // Test 5: sendMessage to *different* forum — errors
180
+ it('errors when targeting a different forum channel (threadParentId does not match)', async () => {
181
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
182
+ const ctx = makeCtx([forum]);
183
+ ctx.channelId = 'thread1';
184
+ ctx.threadParentId = 'other-forum-id';
185
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'forum1', content: 'Hello' }, ctx);
186
+ expect(result.ok).toBe(false);
187
+ expect(result.error).toContain('forum channel');
188
+ });
189
+ // Test 6: sendMessage with empty content to parent forum — suppressed (not content error)
190
+ it('suppresses sendMessage with empty content targeting parent forum', async () => {
191
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
192
+ const ctx = makeCtx([forum]);
193
+ ctx.threadParentId = 'forum1';
194
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'forum1', content: '' }, ctx);
195
+ expect(result.ok).toBe(true);
196
+ expect(result.summary).toBe('Suppressed: response is already posted to this thread');
197
+ });
198
+ // Test 7: sendMessage with unresolvable channel ref when threadParentId set
199
+ it('returns not-found error for unresolvable channel even when threadParentId set', async () => {
200
+ const ctx = makeCtx([]);
201
+ ctx.threadParentId = 'forum1';
202
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'nonexistent', content: 'Hello' }, ctx);
203
+ expect(result).toEqual({ ok: false, error: 'Channel "nonexistent" not found — it may have been deleted or archived. If this was a task thread, use taskShow with the task ID instead.' });
204
+ });
205
+ // Test 8: sendMessage with empty action.channel — returns channel validation error
206
+ it('returns channel validation error for empty channel string', async () => {
207
+ const ctx = makeCtx([]);
208
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '', content: 'Hello' }, ctx);
209
+ expect(result).toEqual({ ok: false, error: 'sendMessage requires a non-empty channel name or ID' });
210
+ });
211
+ // Test 9: sendMessage when threadParentId is "" — guard inert
212
+ it('does not suppress when threadParentId is empty string', async () => {
213
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
214
+ const ctx = makeCtx([forum]);
215
+ ctx.threadParentId = '';
216
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'forum1', content: 'Hello' }, ctx);
217
+ expect(result.ok).toBe(false);
218
+ expect(result.error).toContain('forum channel');
219
+ });
220
+ // Test 10: sendMessage with non-string action.channel — returns channel validation error
221
+ it.each([
222
+ ['number', 123],
223
+ ['undefined', undefined],
224
+ ['null', null],
225
+ ['object', {}],
226
+ ])('returns channel validation error for non-string channel (%s)', async (_label, channel) => {
227
+ const ctx = makeCtx([]);
228
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: channel, content: 'Hello' }, ctx);
229
+ expect(result).toEqual({ ok: false, error: 'sendMessage requires a non-empty channel name or ID' });
230
+ });
231
+ // Test 11: sendMessage targeting forum by name with duplicate-named non-forum channel
232
+ it('does not suppress when channel name resolves to a non-forum channel', async () => {
233
+ const textCh = makeMockChannel({ id: 'ch-text', name: 'beads', type: ChannelType.GuildText });
234
+ const ctx = makeCtx([textCh]);
235
+ ctx.threadParentId = 'forum1';
236
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'beads', content: 'Hello!' }, ctx);
237
+ // The text channel named 'beads' is resolved by resolveChannel, not suppressed
238
+ expect(result.ok).toBe(true);
239
+ expect(result.summary).toBe('Sent message to #beads');
240
+ expect(textCh.send).toHaveBeenCalled();
241
+ });
242
+ // Test 12: sendMessage with whitespace-only action.channel — returns channel validation error
243
+ it('returns channel validation error for whitespace-only channel', async () => {
244
+ const ctx = makeCtx([]);
245
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: ' ', content: 'Hello' }, ctx);
246
+ expect(result).toEqual({ ok: false, error: 'sendMessage requires a non-empty channel name or ID' });
247
+ });
248
+ it('returns descriptive error when targeting a voice channel by ID', async () => {
249
+ const voice = makeMockChannel({ id: 'v1', name: 'voice', type: ChannelType.GuildVoice });
250
+ const ctx = makeCtx([voice]);
251
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'v1', content: 'Hello' }, ctx);
252
+ expect(result.ok).toBe(false);
253
+ expect(result.error).toContain('voice channel');
254
+ expect(result.error).not.toContain('not found');
255
+ });
256
+ it('suppresses sendMessage targeting the same channel as the incoming message', async () => {
257
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
258
+ const ctx = makeCtx([ch]);
259
+ ctx.channelId = 'ch1'; // Same as target channel
260
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#general', content: 'Hello!' }, ctx);
261
+ expect(result.ok).toBe(true);
262
+ expect(result.summary).toContain('Suppressed');
263
+ expect(ch.send).not.toHaveBeenCalled();
264
+ });
265
+ it('suppresses sendMessage targeting the same channel by ID', async () => {
266
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
267
+ const ctx = makeCtx([ch]);
268
+ ctx.channelId = 'ch1';
269
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: 'ch1', content: 'Hello!' }, ctx);
270
+ expect(result.ok).toBe(true);
271
+ expect(result.summary).toContain('Suppressed');
272
+ expect(ch.send).not.toHaveBeenCalled();
273
+ });
274
+ it('allows sendMessage to same channel when messageId is empty (cron path)', async () => {
275
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
276
+ const ctx = makeCtx([ch]);
277
+ ctx.channelId = 'ch1';
278
+ ctx.messageId = ''; // Cron executor sets empty messageId
279
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#general', content: 'Hello!' }, ctx);
280
+ expect(result.ok).toBe(true);
281
+ expect(result.summary).toBe('Sent message to #general');
282
+ expect(ch.send).toHaveBeenCalled();
283
+ });
284
+ it('allows sendMessage to a different channel', async () => {
285
+ const ch = makeMockChannel({ id: 'ch2', name: 'other' });
286
+ const ctx = makeCtx([ch]);
287
+ ctx.channelId = 'ch1'; // Different from target
288
+ const result = await executeMessagingAction({ type: 'sendMessage', channel: '#other', content: 'Hello!' }, ctx);
289
+ expect(result.ok).toBe(true);
290
+ expect(result.summary).toBe('Sent message to #other');
291
+ expect(ch.send).toHaveBeenCalled();
292
+ });
293
+ });
294
+ describe('react', () => {
295
+ it('adds a reaction to a message', async () => {
296
+ const msg = makeMockMessage('msg1');
297
+ const ch = makeMockChannel({ id: 'ch1' });
298
+ ch.messages.fetch = vi.fn(async () => msg);
299
+ const ctx = makeCtx([ch]);
300
+ const result = await executeMessagingAction({ type: 'react', channelId: 'ch1', messageId: 'msg1', emoji: '👍' }, ctx);
301
+ expect(result).toEqual({ ok: true, summary: 'Reacted with 👍' });
302
+ expect(msg.react).toHaveBeenCalledWith('👍');
303
+ });
304
+ it('rejects empty channelId', async () => {
305
+ const ctx = makeCtx([]);
306
+ const result = await executeMessagingAction({ type: 'react', channelId: '', messageId: 'msg1', emoji: '👍' }, ctx);
307
+ expect(result).toEqual({ ok: false, error: 'react requires a non-empty channelId' });
308
+ });
309
+ it('rejects empty messageId', async () => {
310
+ const ctx = makeCtx([]);
311
+ const result = await executeMessagingAction({ type: 'react', channelId: 'ch1', messageId: '', emoji: '👍' }, ctx);
312
+ expect(result).toEqual({ ok: false, error: 'react requires a non-empty messageId' });
313
+ });
314
+ it('rejects empty emoji', async () => {
315
+ const ctx = makeCtx([]);
316
+ const result = await executeMessagingAction({ type: 'react', channelId: 'ch1', messageId: 'msg1', emoji: '' }, ctx);
317
+ expect(result).toEqual({ ok: false, error: 'react requires a non-empty emoji' });
318
+ });
319
+ });
320
+ describe('unreact', () => {
321
+ it('removes bot reaction from a message', async () => {
322
+ const removeFn = vi.fn(async () => { });
323
+ const msg = makeMockMessage('msg1');
324
+ msg.reactions = {
325
+ resolve: vi.fn((emoji) => ({
326
+ users: { remove: removeFn },
327
+ })),
328
+ };
329
+ const ch = makeMockChannel({ id: 'ch1' });
330
+ ch.messages.fetch = vi.fn(async () => msg);
331
+ const ctx = makeCtx([ch]);
332
+ ctx.client = { user: { id: 'bot-user-id' } };
333
+ const result = await executeMessagingAction({ type: 'unreact', channelId: 'ch1', messageId: 'msg1', emoji: '👍' }, ctx);
334
+ expect(result).toEqual({ ok: true, summary: 'Removed reaction 👍' });
335
+ expect(msg.reactions.resolve).toHaveBeenCalledWith('👍');
336
+ expect(removeFn).toHaveBeenCalledWith('bot-user-id');
337
+ });
338
+ it('rejects empty channelId', async () => {
339
+ const ctx = makeCtx([]);
340
+ const result = await executeMessagingAction({ type: 'unreact', channelId: '', messageId: 'msg1', emoji: '👍' }, ctx);
341
+ expect(result).toEqual({ ok: false, error: 'unreact requires a non-empty channelId' });
342
+ });
343
+ it('rejects empty messageId', async () => {
344
+ const ctx = makeCtx([]);
345
+ const result = await executeMessagingAction({ type: 'unreact', channelId: 'ch1', messageId: '', emoji: '👍' }, ctx);
346
+ expect(result).toEqual({ ok: false, error: 'unreact requires a non-empty messageId' });
347
+ });
348
+ it('rejects empty emoji', async () => {
349
+ const ctx = makeCtx([]);
350
+ const result = await executeMessagingAction({ type: 'unreact', channelId: 'ch1', messageId: 'msg1', emoji: '' }, ctx);
351
+ expect(result).toEqual({ ok: false, error: 'unreact requires a non-empty emoji' });
352
+ });
353
+ it('fails when reaction not found on message', async () => {
354
+ const msg = makeMockMessage('msg1');
355
+ msg.reactions = {
356
+ resolve: vi.fn(() => null),
357
+ };
358
+ const ch = makeMockChannel({ id: 'ch1' });
359
+ ch.messages.fetch = vi.fn(async () => msg);
360
+ const ctx = makeCtx([ch]);
361
+ const result = await executeMessagingAction({ type: 'unreact', channelId: 'ch1', messageId: 'msg1', emoji: '🔥' }, ctx);
362
+ expect(result).toEqual({ ok: false, error: 'Reaction "🔥" not found on message' });
363
+ });
364
+ });
365
+ describe('readMessages', () => {
366
+ it('reads and formats messages', async () => {
367
+ const msg1 = makeMockMessage('m1', { content: 'First', author: 'alice' });
368
+ const msg2 = makeMockMessage('m2', { content: 'Second', author: 'bob' });
369
+ const fetchedMessages = new Map([['m1', msg1], ['m2', msg2]]);
370
+ const ch = makeMockChannel({ name: 'general', fetchedMessages });
371
+ const ctx = makeCtx([ch]);
372
+ const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
373
+ expect(result.ok).toBe(true);
374
+ const summary = result.summary;
375
+ expect(summary).toContain('[alice] First');
376
+ expect(summary).toContain('[bob] Second');
377
+ });
378
+ it('returns descriptive error when targeting a forum channel by ID', async () => {
379
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
380
+ const ctx = makeCtx([forum]);
381
+ const result = await executeMessagingAction({ type: 'readMessages', channel: 'forum1', limit: 5 }, ctx);
382
+ expect(result.ok).toBe(false);
383
+ expect(result.error).toContain('forum channel');
384
+ expect(result.error).not.toContain('not found');
385
+ });
386
+ it('returns descriptive error when targeting a forum channel by name', async () => {
387
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
388
+ const ctx = makeCtx([forum]);
389
+ const result = await executeMessagingAction({ type: 'readMessages', channel: 'beads', limit: 5 }, ctx);
390
+ expect(result.ok).toBe(false);
391
+ expect(result.error).toContain('forum channel');
392
+ expect(result.error).not.toContain('not found');
393
+ });
394
+ it('clamps limit to 20', async () => {
395
+ const ch = makeMockChannel({ name: 'general', fetchedMessages: new Map() });
396
+ const ctx = makeCtx([ch]);
397
+ await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 50 }, ctx);
398
+ expect(ch.messages.fetch).toHaveBeenCalledWith({ limit: 20 });
399
+ });
400
+ });
401
+ describe('fetchMessage', () => {
402
+ it('fetches and formats a single message', async () => {
403
+ const msg = makeMockMessage('msg1', { content: 'Fetched message', author: 'alice' });
404
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
405
+ ch.messages.fetch = vi.fn(async () => msg);
406
+ const ctx = makeCtx([ch]);
407
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
408
+ expect(result.ok).toBe(true);
409
+ expect(result.summary).toContain('[alice]: Fetched message');
410
+ expect(result.summary).toContain('#general');
411
+ });
412
+ it('rejects empty channelId', async () => {
413
+ const ctx = makeCtx([]);
414
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: '', messageId: 'msg1' }, ctx);
415
+ expect(result).toEqual({ ok: false, error: 'fetchMessage requires a non-empty channelId' });
416
+ });
417
+ it('rejects empty messageId', async () => {
418
+ const ctx = makeCtx([]);
419
+ const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: '' }, ctx);
420
+ expect(result).toEqual({ ok: false, error: 'fetchMessage requires a non-empty messageId' });
421
+ });
422
+ });
423
+ describe('editMessage', () => {
424
+ it('edits a message', async () => {
425
+ const msg = makeMockMessage('msg1');
426
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
427
+ ch.messages.fetch = vi.fn(async () => msg);
428
+ const ctx = makeCtx([ch]);
429
+ const result = await executeMessagingAction({ type: 'editMessage', channelId: 'ch1', messageId: 'msg1', content: 'Updated' }, ctx);
430
+ expect(result).toEqual({ ok: true, summary: 'Edited message in #general' });
431
+ expect(msg.edit).toHaveBeenCalledWith({ content: 'Updated', allowedMentions: { parse: [] } });
432
+ });
433
+ it('rejects content exceeding 2000 chars', async () => {
434
+ const ctx = makeCtx([]);
435
+ const result = await executeMessagingAction({ type: 'editMessage', channelId: 'ch1', messageId: 'msg1', content: 'x'.repeat(2001) }, ctx);
436
+ expect(result.ok).toBe(false);
437
+ expect(result.error).toContain('2000 character limit');
438
+ });
439
+ it('rejects empty channelId', async () => {
440
+ const ctx = makeCtx([]);
441
+ const result = await executeMessagingAction({ type: 'editMessage', channelId: '', messageId: 'msg1', content: 'Updated' }, ctx);
442
+ expect(result).toEqual({ ok: false, error: 'editMessage requires a non-empty channelId' });
443
+ });
444
+ it('rejects empty messageId', async () => {
445
+ const ctx = makeCtx([]);
446
+ const result = await executeMessagingAction({ type: 'editMessage', channelId: 'ch1', messageId: '', content: 'Updated' }, ctx);
447
+ expect(result).toEqual({ ok: false, error: 'editMessage requires a non-empty messageId' });
448
+ });
449
+ });
450
+ describe('deleteMessage', () => {
451
+ it('deletes a message', async () => {
452
+ const msg = makeMockMessage('msg1');
453
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
454
+ ch.messages.fetch = vi.fn(async () => msg);
455
+ const ctx = makeCtx([ch]);
456
+ const result = await executeMessagingAction({ type: 'deleteMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
457
+ expect(result).toEqual({ ok: true, summary: 'Deleted message in #general' });
458
+ expect(msg.delete).toHaveBeenCalled();
459
+ });
460
+ it('rejects empty channelId', async () => {
461
+ const ctx = makeCtx([]);
462
+ const result = await executeMessagingAction({ type: 'deleteMessage', channelId: '', messageId: 'msg1' }, ctx);
463
+ expect(result).toEqual({ ok: false, error: 'deleteMessage requires a non-empty channelId' });
464
+ });
465
+ it('rejects empty messageId', async () => {
466
+ const ctx = makeCtx([]);
467
+ const result = await executeMessagingAction({ type: 'deleteMessage', channelId: 'ch1', messageId: '' }, ctx);
468
+ expect(result).toEqual({ ok: false, error: 'deleteMessage requires a non-empty messageId' });
469
+ });
470
+ });
471
+ describe('bulkDelete', () => {
472
+ it('bulk deletes messages', async () => {
473
+ const deleted = new Map([['m1', {}], ['m2', {}], ['m3', {}]]);
474
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
475
+ ch.bulkDelete = vi.fn(async () => deleted);
476
+ const ctx = makeCtx([ch]);
477
+ const result = await executeMessagingAction({ type: 'bulkDelete', channelId: 'ch1', count: 10 }, ctx);
478
+ expect(result).toEqual({ ok: true, summary: 'Bulk deleted 3 messages in #general' });
479
+ expect(ch.bulkDelete).toHaveBeenCalledWith(10, true);
480
+ });
481
+ it('rejects count below 2', async () => {
482
+ const ctx = makeCtx([]);
483
+ const result = await executeMessagingAction({ type: 'bulkDelete', channelId: 'ch1', count: 1 }, ctx);
484
+ expect(result).toEqual({ ok: false, error: 'bulkDelete count must be an integer between 2 and 100' });
485
+ });
486
+ it('rejects count above 100', async () => {
487
+ const ctx = makeCtx([]);
488
+ const result = await executeMessagingAction({ type: 'bulkDelete', channelId: 'ch1', count: 101 }, ctx);
489
+ expect(result).toEqual({ ok: false, error: 'bulkDelete count must be an integer between 2 and 100' });
490
+ });
491
+ it('rejects non-integer count', async () => {
492
+ const ctx = makeCtx([]);
493
+ const result = await executeMessagingAction({ type: 'bulkDelete', channelId: 'ch1', count: 5.5 }, ctx);
494
+ expect(result).toEqual({ ok: false, error: 'bulkDelete count must be an integer between 2 and 100' });
495
+ });
496
+ it('fails when channel not found', async () => {
497
+ const ctx = makeCtx([]);
498
+ const result = await executeMessagingAction({ type: 'bulkDelete', channelId: 'nope', count: 5 }, ctx);
499
+ expect(result.ok).toBe(false);
500
+ expect(result.error).toContain('not found');
501
+ });
502
+ });
503
+ describe('crosspost', () => {
504
+ it('crossposts a message in an announcement channel', async () => {
505
+ const msg = makeMockMessage('msg1');
506
+ msg.crosspost = vi.fn(async () => { });
507
+ const ch = makeMockChannel({ id: 'ch1', name: 'announcements', type: ChannelType.GuildAnnouncement });
508
+ ch.messages.fetch = vi.fn(async () => msg);
509
+ const ctx = makeCtx([ch]);
510
+ const result = await executeMessagingAction({ type: 'crosspost', channelId: 'ch1', messageId: 'msg1' }, ctx);
511
+ expect(result).toEqual({ ok: true, summary: 'Published message to followers of #announcements' });
512
+ expect(msg.crosspost).toHaveBeenCalled();
513
+ });
514
+ it('fails when channel is not an announcement channel', async () => {
515
+ const ch = makeMockChannel({ id: 'ch1', name: 'general', type: ChannelType.GuildText });
516
+ const ctx = makeCtx([ch]);
517
+ const result = await executeMessagingAction({ type: 'crosspost', channelId: 'ch1', messageId: 'msg1' }, ctx);
518
+ expect(result).toEqual({ ok: false, error: 'Channel #general is not an announcement channel' });
519
+ });
520
+ it('fails when channel not found', async () => {
521
+ const ctx = makeCtx([]);
522
+ const result = await executeMessagingAction({ type: 'crosspost', channelId: 'nope', messageId: 'msg1' }, ctx);
523
+ expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
524
+ });
525
+ it('rejects empty channelId', async () => {
526
+ const ctx = makeCtx([]);
527
+ const result = await executeMessagingAction({ type: 'crosspost', channelId: '', messageId: 'msg1' }, ctx);
528
+ expect(result).toEqual({ ok: false, error: 'crosspost requires a non-empty channelId' });
529
+ });
530
+ it('rejects empty messageId', async () => {
531
+ const ctx = makeCtx([]);
532
+ const result = await executeMessagingAction({ type: 'crosspost', channelId: 'ch1', messageId: '' }, ctx);
533
+ expect(result).toEqual({ ok: false, error: 'crosspost requires a non-empty messageId' });
534
+ });
535
+ });
536
+ describe('threadCreate', () => {
537
+ it('creates a thread from a message', async () => {
538
+ const msg = makeMockMessage('msg1');
539
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
540
+ ch.messages.fetch = vi.fn(async () => msg);
541
+ const ctx = makeCtx([ch]);
542
+ const result = await executeMessagingAction({ type: 'threadCreate', channelId: 'ch1', name: 'Discussion', messageId: 'msg1' }, ctx);
543
+ expect(result.ok).toBe(true);
544
+ expect(result.summary).toContain('Discussion');
545
+ expect(msg.startThread).toHaveBeenCalledWith({ name: 'Discussion', autoArchiveDuration: 1440 });
546
+ });
547
+ it('creates a standalone thread', async () => {
548
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
549
+ const ctx = makeCtx([ch]);
550
+ const result = await executeMessagingAction({ type: 'threadCreate', channelId: 'ch1', name: 'New Thread' }, ctx);
551
+ expect(result.ok).toBe(true);
552
+ expect(result.summary).toContain('New Thread');
553
+ expect(ch.threads.create).toHaveBeenCalledWith({ name: 'New Thread', autoArchiveDuration: 1440 });
554
+ });
555
+ });
556
+ describe('pinMessage / unpinMessage', () => {
557
+ it('pins a message', async () => {
558
+ const msg = makeMockMessage('msg1');
559
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
560
+ ch.messages.fetch = vi.fn(async () => msg);
561
+ const ctx = makeCtx([ch]);
562
+ const result = await executeMessagingAction({ type: 'pinMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
563
+ expect(result).toEqual({ ok: true, summary: 'Pinned message in #general' });
564
+ expect(msg.pin).toHaveBeenCalled();
565
+ });
566
+ it('unpins a message', async () => {
567
+ const msg = makeMockMessage('msg1');
568
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
569
+ ch.messages.fetch = vi.fn(async () => msg);
570
+ const ctx = makeCtx([ch]);
571
+ const result = await executeMessagingAction({ type: 'unpinMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
572
+ expect(result).toEqual({ ok: true, summary: 'Unpinned message in #general' });
573
+ expect(msg.unpin).toHaveBeenCalled();
574
+ });
575
+ it('rejects empty channelId for pinMessage', async () => {
576
+ const ctx = makeCtx([]);
577
+ const result = await executeMessagingAction({ type: 'pinMessage', channelId: '', messageId: 'msg1' }, ctx);
578
+ expect(result).toEqual({ ok: false, error: 'pinMessage requires a non-empty channelId' });
579
+ });
580
+ it('rejects empty messageId for pinMessage', async () => {
581
+ const ctx = makeCtx([]);
582
+ const result = await executeMessagingAction({ type: 'pinMessage', channelId: 'ch1', messageId: '' }, ctx);
583
+ expect(result).toEqual({ ok: false, error: 'pinMessage requires a non-empty messageId' });
584
+ });
585
+ it('rejects empty channelId for unpinMessage', async () => {
586
+ const ctx = makeCtx([]);
587
+ const result = await executeMessagingAction({ type: 'unpinMessage', channelId: '', messageId: 'msg1' }, ctx);
588
+ expect(result).toEqual({ ok: false, error: 'unpinMessage requires a non-empty channelId' });
589
+ });
590
+ it('rejects empty messageId for unpinMessage', async () => {
591
+ const ctx = makeCtx([]);
592
+ const result = await executeMessagingAction({ type: 'unpinMessage', channelId: 'ch1', messageId: '' }, ctx);
593
+ expect(result).toEqual({ ok: false, error: 'unpinMessage requires a non-empty messageId' });
594
+ });
595
+ });
596
+ describe('listPins', () => {
597
+ it('lists pinned messages', async () => {
598
+ const pinned = new Map([
599
+ ['p1', { id: 'p1', content: 'Important', author: { username: 'alice' } }],
600
+ ]);
601
+ const ch = makeMockChannel({ name: 'general', pinnedMessages: pinned });
602
+ const ctx = makeCtx([ch]);
603
+ const result = await executeMessagingAction({ type: 'listPins', channel: '#general' }, ctx);
604
+ expect(result.ok).toBe(true);
605
+ expect(result.summary).toContain('[alice] Important');
606
+ });
607
+ it('returns descriptive error when targeting a forum channel by ID', async () => {
608
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
609
+ const ctx = makeCtx([forum]);
610
+ const result = await executeMessagingAction({ type: 'listPins', channel: 'forum1' }, ctx);
611
+ expect(result.ok).toBe(false);
612
+ expect(result.error).toContain('forum channel');
613
+ expect(result.error).not.toContain('not found');
614
+ });
615
+ it('returns descriptive error when targeting a forum channel by name', async () => {
616
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
617
+ const ctx = makeCtx([forum]);
618
+ const result = await executeMessagingAction({ type: 'listPins', channel: 'beads' }, ctx);
619
+ expect(result.ok).toBe(false);
620
+ expect(result.error).toContain('forum channel');
621
+ expect(result.error).not.toContain('not found');
622
+ });
623
+ it('returns empty message when no pins', async () => {
624
+ const ch = makeMockChannel({ name: 'general' });
625
+ const ctx = makeCtx([ch]);
626
+ const result = await executeMessagingAction({ type: 'listPins', channel: '#general' }, ctx);
627
+ expect(result).toEqual({ ok: true, summary: 'No pinned messages in #general' });
628
+ });
629
+ });
630
+ describe('sendFile', () => {
631
+ beforeEach(() => {
632
+ vi.mocked(fsMod.stat).mockResolvedValue({ size: 1000 });
633
+ vi.mocked(fsMod.readFile).mockResolvedValue(Buffer.from('fake image data'));
634
+ });
635
+ it('sends a file to a channel', async () => {
636
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
637
+ const ctx = makeCtx([ch]);
638
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/screenshot.png' }, ctx);
639
+ expect(result).toEqual({ ok: true, summary: 'Sent file "screenshot.png" to #general' });
640
+ expect(ch.send).toHaveBeenCalledWith(expect.objectContaining({ files: expect.arrayContaining([expect.anything()]), allowedMentions: { parse: [] } }));
641
+ });
642
+ it('rejects empty filePath', async () => {
643
+ const ctx = makeCtx([]);
644
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '' }, ctx);
645
+ expect(result).toEqual({ ok: false, error: 'sendFile requires a non-empty filePath' });
646
+ });
647
+ it('rejects whitespace-only filePath', async () => {
648
+ const ctx = makeCtx([]);
649
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: ' ' }, ctx);
650
+ expect(result.ok).toBe(false);
651
+ expect(result.error).toContain('non-empty filePath');
652
+ });
653
+ it('returns error when file not found on disk', async () => {
654
+ vi.mocked(fsMod.stat).mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
655
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
656
+ const ctx = makeCtx([ch]);
657
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/screenshot.png' }, ctx);
658
+ expect(result.ok).toBe(false);
659
+ expect(result.error).toContain('not found');
660
+ expect(ch.send).not.toHaveBeenCalled();
661
+ });
662
+ it('returns error when file exceeds size limit', async () => {
663
+ vi.mocked(fsMod.stat).mockResolvedValue({ size: 26 * 1024 * 1024 }); // 26 MB > 25 MB limit
664
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
665
+ const ctx = makeCtx([ch]);
666
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/big.png' }, ctx);
667
+ expect(result.ok).toBe(false);
668
+ expect(result.error).toContain('size limit');
669
+ expect(ch.send).not.toHaveBeenCalled();
670
+ });
671
+ it('returns error for disallowed file extension', async () => {
672
+ const ctx = makeCtx([]);
673
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/script.exe' }, ctx);
674
+ expect(result.ok).toBe(false);
675
+ expect(result.error).toContain('.exe');
676
+ // fs should not be touched — extension check happens first
677
+ expect(fsMod.stat).not.toHaveBeenCalled();
678
+ });
679
+ it('includes optional content caption when provided', async () => {
680
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
681
+ const ctx = makeCtx([ch]);
682
+ await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/screenshot.png', content: 'Here is the screenshot' }, ctx);
683
+ expect(ch.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Here is the screenshot', files: expect.anything() }));
684
+ });
685
+ it('rejects caption exceeding 2000 chars', async () => {
686
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
687
+ const ctx = makeCtx([ch]);
688
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/screenshot.png', content: 'x'.repeat(2001) }, ctx);
689
+ expect(result.ok).toBe(false);
690
+ expect(result.error).toContain('2000 character limit');
691
+ expect(ch.send).not.toHaveBeenCalled();
692
+ // Caption validation must happen before any file I/O
693
+ expect(fsMod.stat).not.toHaveBeenCalled();
694
+ });
695
+ it('omits content key when content is not provided', async () => {
696
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
697
+ const ctx = makeCtx([ch]);
698
+ await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/screenshot.png' }, ctx);
699
+ const callArg = ch.send.mock.calls[0][0];
700
+ expect(callArg).not.toHaveProperty('content');
701
+ });
702
+ it('returns error when channel not found', async () => {
703
+ const ctx = makeCtx([]);
704
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#nonexistent', filePath: '/tmp/screenshot.png' }, ctx);
705
+ expect(result.ok).toBe(false);
706
+ expect(result.error).toContain('not found');
707
+ });
708
+ it('does NOT suppress when targeting the same channel (unlike sendMessage)', async () => {
709
+ const ch = makeMockChannel({ id: 'ch1', name: 'general' });
710
+ const ctx = makeCtx([ch]);
711
+ ctx.channelId = 'ch1'; // Same as target — would be suppressed for sendMessage
712
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '#general', filePath: '/tmp/screenshot.png' }, ctx);
713
+ expect(result.ok).toBe(true);
714
+ expect(result.summary).not.toContain('Suppressed');
715
+ expect(ch.send).toHaveBeenCalled();
716
+ });
717
+ it('rejects empty channel', async () => {
718
+ const ctx = makeCtx([]);
719
+ const result = await executeMessagingAction({ type: 'sendFile', channel: '', filePath: '/tmp/screenshot.png' }, ctx);
720
+ expect(result).toEqual({ ok: false, error: 'sendFile requires a non-empty channel name or ID' });
721
+ });
722
+ it('returns descriptive error when targeting a forum channel by ID', async () => {
723
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
724
+ const ctx = makeCtx([forum]);
725
+ const result = await executeMessagingAction({ type: 'sendFile', channel: 'forum1', filePath: '/tmp/screenshot.png' }, ctx);
726
+ expect(result.ok).toBe(false);
727
+ expect(result.error).toContain('forum channel');
728
+ expect(result.error).not.toContain('not found');
729
+ });
730
+ it('returns descriptive error when targeting a forum channel by name', async () => {
731
+ const forum = makeMockChannel({ id: 'forum1', name: 'beads', type: ChannelType.GuildForum });
732
+ const ctx = makeCtx([forum]);
733
+ const result = await executeMessagingAction({ type: 'sendFile', channel: 'beads', filePath: '/tmp/screenshot.png' }, ctx);
734
+ expect(result.ok).toBe(false);
735
+ expect(result.error).toContain('forum channel');
736
+ expect(result.error).not.toContain('not found');
737
+ });
738
+ });