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,401 @@
1
+ import { ChannelType, AttachmentBuilder } from 'discord.js';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { resolveChannel, fmtTime, findChannelRaw, describeChannelType } from './action-utils.js';
5
+ import { NO_MENTIONS } from './allowed-mentions.js';
6
+ const MESSAGING_TYPE_MAP = {
7
+ sendMessage: true, react: true, unreact: true, readMessages: true, fetchMessage: true,
8
+ editMessage: true, deleteMessage: true, bulkDelete: true, crosspost: true, threadCreate: true,
9
+ pinMessage: true, unpinMessage: true, listPins: true, sendFile: true,
10
+ };
11
+ export const MESSAGING_ACTION_TYPES = new Set(Object.keys(MESSAGING_TYPE_MAP));
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+ const DISCORD_MAX_CONTENT = 2000;
16
+ const SENDFILE_MAX_BYTES = 25 * 1024 * 1024; // 25 MB — Discord standard upload limit
17
+ const SENDFILE_ALLOWED_EXTENSIONS = new Set([
18
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf',
19
+ ]);
20
+ // ---------------------------------------------------------------------------
21
+ // Executor
22
+ // ---------------------------------------------------------------------------
23
+ export async function executeMessagingAction(action, ctx) {
24
+ const { guild } = ctx;
25
+ switch (action.type) {
26
+ case 'sendMessage': {
27
+ if (typeof action.channel !== 'string' || !action.channel.trim()) {
28
+ return { ok: false, error: 'sendMessage requires a non-empty channel name or ID' };
29
+ }
30
+ // Silent suppression: if the AI targets the same channel the user
31
+ // message came from, the response is already being posted as a reply —
32
+ // swallow the spurious action to avoid duplicating the response.
33
+ // Only applies to message-handler contexts (messageId is set); cron
34
+ // executions intentionally send to their target channel.
35
+ if (ctx.messageId) {
36
+ const raw = findChannelRaw(guild, action.channel);
37
+ if (raw && raw.id === ctx.channelId) {
38
+ return { ok: true, summary: 'Suppressed: response is already posted as a reply to this channel' };
39
+ }
40
+ // Also suppress when targeting the parent forum from within a task thread.
41
+ if (ctx.threadParentId && raw && raw.id === ctx.threadParentId && raw.type === ChannelType.GuildForum) {
42
+ return { ok: true, summary: 'Suppressed: response is already posted to this thread' };
43
+ }
44
+ }
45
+ if (typeof action.content !== 'string' || !action.content.trim()) {
46
+ return { ok: false, error: 'sendMessage requires non-empty string content' };
47
+ }
48
+ if (action.content.length > DISCORD_MAX_CONTENT) {
49
+ return { ok: false, error: `Content exceeds Discord's ${DISCORD_MAX_CONTENT} character limit (got ${action.content.length})` };
50
+ }
51
+ const channel = resolveChannel(guild, action.channel);
52
+ if (!channel) {
53
+ const raw = findChannelRaw(guild, action.channel);
54
+ if (raw) {
55
+ const kind = describeChannelType(raw);
56
+ const hint = kind === 'forum' ? ' Use threadCreate to post in forum channels.' : '';
57
+ return { ok: false, error: `Channel "${action.channel}" is a ${kind} channel and cannot receive messages directly.${hint}` };
58
+ }
59
+ return { ok: false, error: `Channel "${action.channel}" not found — it may have been deleted or archived. If this was a task thread, use taskShow with the task ID instead.` };
60
+ }
61
+ const opts = { content: action.content, allowedMentions: NO_MENTIONS };
62
+ if (action.replyTo) {
63
+ opts.reply = { messageReference: action.replyTo };
64
+ }
65
+ await channel.send(opts);
66
+ return { ok: true, summary: `Sent message to #${channel.name}` };
67
+ }
68
+ case 'react': {
69
+ if (!action.channelId?.trim())
70
+ return { ok: false, error: 'react requires a non-empty channelId' };
71
+ if (!action.messageId?.trim())
72
+ return { ok: false, error: 'react requires a non-empty messageId' };
73
+ if (!action.emoji?.trim())
74
+ return { ok: false, error: 'react requires a non-empty emoji' };
75
+ const channel = guild.channels.cache.get(action.channelId);
76
+ if (!channel || !('messages' in channel))
77
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
78
+ const message = await channel.messages.fetch(action.messageId);
79
+ await message.react(action.emoji);
80
+ return { ok: true, summary: `Reacted with ${action.emoji}` };
81
+ }
82
+ case 'unreact': {
83
+ if (!action.channelId?.trim())
84
+ return { ok: false, error: 'unreact requires a non-empty channelId' };
85
+ if (!action.messageId?.trim())
86
+ return { ok: false, error: 'unreact requires a non-empty messageId' };
87
+ if (!action.emoji?.trim())
88
+ return { ok: false, error: 'unreact requires a non-empty emoji' };
89
+ const channel = guild.channels.cache.get(action.channelId);
90
+ if (!channel || !('messages' in channel))
91
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
92
+ const message = await channel.messages.fetch(action.messageId);
93
+ const reaction = message.reactions.resolve(action.emoji);
94
+ if (!reaction)
95
+ return { ok: false, error: `Reaction "${action.emoji}" not found on message` };
96
+ await reaction.users.remove(ctx.client.user.id);
97
+ return { ok: true, summary: `Removed reaction ${action.emoji}` };
98
+ }
99
+ case 'readMessages': {
100
+ const channel = resolveChannel(guild, action.channel);
101
+ if (!channel) {
102
+ const raw = findChannelRaw(guild, action.channel);
103
+ if (raw) {
104
+ const kind = describeChannelType(raw);
105
+ return { ok: false, error: `Channel "${action.channel}" is a ${kind} channel and cannot be read directly. Use readMessages with a thread ID instead.` };
106
+ }
107
+ return { ok: false, error: `Channel "${action.channel}" not found — it may have been deleted or archived. If this was a task thread, use taskShow with the task ID instead.` };
108
+ }
109
+ const limit = Math.min(Math.max(1, action.limit ?? 10), 20);
110
+ const opts = { limit };
111
+ if (action.before)
112
+ opts.before = action.before;
113
+ const messages = await channel.messages.fetch(opts);
114
+ const sorted = [...messages.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp);
115
+ if (sorted.length === 0) {
116
+ return { ok: true, summary: `No messages found in #${channel.name}` };
117
+ }
118
+ const lines = sorted.map((m) => {
119
+ const author = m.author?.username ?? 'Unknown';
120
+ const time = fmtTime(m.createdAt);
121
+ const text = (m.content || '(no text)').slice(0, 200);
122
+ return `[${author}] ${text} (${time}, id:${m.id})`;
123
+ });
124
+ return { ok: true, summary: `Messages in #${channel.name}:\n${lines.join('\n')}` };
125
+ }
126
+ case 'fetchMessage': {
127
+ if (!action.channelId?.trim())
128
+ return { ok: false, error: 'fetchMessage requires a non-empty channelId' };
129
+ if (!action.messageId?.trim())
130
+ return { ok: false, error: 'fetchMessage requires a non-empty messageId' };
131
+ const channel = guild.channels.cache.get(action.channelId);
132
+ if (!channel || !('messages' in channel))
133
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
134
+ const message = await channel.messages.fetch(action.messageId);
135
+ const author = message.author?.username ?? 'Unknown';
136
+ const time = fmtTime(message.createdAt);
137
+ const text = (message.content || '(no text)').slice(0, 500);
138
+ return { ok: true, summary: `[${author}]: ${text} (${time}, #${channel.name}, id:${message.id})` };
139
+ }
140
+ case 'editMessage': {
141
+ if (!action.channelId?.trim())
142
+ return { ok: false, error: 'editMessage requires a non-empty channelId' };
143
+ if (!action.messageId?.trim())
144
+ return { ok: false, error: 'editMessage requires a non-empty messageId' };
145
+ if (typeof action.content !== 'string' || !action.content.trim()) {
146
+ return { ok: false, error: 'editMessage requires non-empty string content' };
147
+ }
148
+ if (action.content.length > DISCORD_MAX_CONTENT) {
149
+ return { ok: false, error: `Content exceeds Discord's ${DISCORD_MAX_CONTENT} character limit (got ${action.content.length})` };
150
+ }
151
+ const channel = guild.channels.cache.get(action.channelId);
152
+ if (!channel || !('messages' in channel))
153
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
154
+ const message = await channel.messages.fetch(action.messageId);
155
+ await message.edit({ content: action.content, allowedMentions: NO_MENTIONS });
156
+ return { ok: true, summary: `Edited message in #${channel.name}` };
157
+ }
158
+ case 'deleteMessage': {
159
+ if (!action.channelId?.trim())
160
+ return { ok: false, error: 'deleteMessage requires a non-empty channelId' };
161
+ if (!action.messageId?.trim())
162
+ return { ok: false, error: 'deleteMessage requires a non-empty messageId' };
163
+ const channel = guild.channels.cache.get(action.channelId);
164
+ if (!channel || !('messages' in channel))
165
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
166
+ const message = await channel.messages.fetch(action.messageId);
167
+ await message.delete();
168
+ return { ok: true, summary: `Deleted message in #${channel.name}` };
169
+ }
170
+ case 'bulkDelete': {
171
+ const count = action.count;
172
+ if (!Number.isInteger(count) || count < 2 || count > 100) {
173
+ return { ok: false, error: 'bulkDelete count must be an integer between 2 and 100' };
174
+ }
175
+ const channel = guild.channels.cache.get(action.channelId);
176
+ if (!channel || !('bulkDelete' in channel)) {
177
+ return { ok: false, error: `Channel "${action.channelId}" not found or does not support bulk delete` };
178
+ }
179
+ const deleted = await channel.bulkDelete(count, true);
180
+ return { ok: true, summary: `Bulk deleted ${deleted.size} messages in #${channel.name}` };
181
+ }
182
+ case 'crosspost': {
183
+ if (!action.channelId?.trim())
184
+ return { ok: false, error: 'crosspost requires a non-empty channelId' };
185
+ if (!action.messageId?.trim())
186
+ return { ok: false, error: 'crosspost requires a non-empty messageId' };
187
+ const channel = guild.channels.cache.get(action.channelId);
188
+ if (!channel || !('messages' in channel))
189
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
190
+ if (channel.type !== ChannelType.GuildAnnouncement) {
191
+ return { ok: false, error: `Channel #${channel.name} is not an announcement channel` };
192
+ }
193
+ const message = await channel.messages.fetch(action.messageId);
194
+ await message.crosspost();
195
+ return { ok: true, summary: `Published message to followers of #${channel.name}` };
196
+ }
197
+ case 'threadCreate': {
198
+ const channel = guild.channels.cache.get(action.channelId);
199
+ if (!channel)
200
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
201
+ if (action.messageId && 'messages' in channel) {
202
+ const message = await channel.messages.fetch(action.messageId);
203
+ const thread = await message.startThread({
204
+ name: action.name,
205
+ autoArchiveDuration: action.autoArchiveMinutes ?? 1440,
206
+ });
207
+ return { ok: true, summary: `Created thread "${thread.name}" from message in #${channel.name}` };
208
+ }
209
+ if ('threads' in channel) {
210
+ const thread = await channel.threads.create({
211
+ name: action.name,
212
+ autoArchiveDuration: action.autoArchiveMinutes ?? 1440,
213
+ });
214
+ return { ok: true, summary: `Created thread "${thread.name}" in #${channel.name}` };
215
+ }
216
+ return { ok: false, error: `Channel "${action.channelId}" does not support threads` };
217
+ }
218
+ case 'pinMessage': {
219
+ if (!action.channelId?.trim())
220
+ return { ok: false, error: 'pinMessage requires a non-empty channelId' };
221
+ if (!action.messageId?.trim())
222
+ return { ok: false, error: 'pinMessage requires a non-empty messageId' };
223
+ const channel = guild.channels.cache.get(action.channelId);
224
+ if (!channel || !('messages' in channel))
225
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
226
+ const message = await channel.messages.fetch(action.messageId);
227
+ await message.pin();
228
+ return { ok: true, summary: `Pinned message in #${channel.name}` };
229
+ }
230
+ case 'unpinMessage': {
231
+ if (!action.channelId?.trim())
232
+ return { ok: false, error: 'unpinMessage requires a non-empty channelId' };
233
+ if (!action.messageId?.trim())
234
+ return { ok: false, error: 'unpinMessage requires a non-empty messageId' };
235
+ const channel = guild.channels.cache.get(action.channelId);
236
+ if (!channel || !('messages' in channel))
237
+ return { ok: false, error: `Channel "${action.channelId}" not found` };
238
+ const message = await channel.messages.fetch(action.messageId);
239
+ await message.unpin();
240
+ return { ok: true, summary: `Unpinned message in #${channel.name}` };
241
+ }
242
+ case 'listPins': {
243
+ const channel = resolveChannel(guild, action.channel);
244
+ if (!channel) {
245
+ const raw = findChannelRaw(guild, action.channel);
246
+ if (raw) {
247
+ const kind = describeChannelType(raw);
248
+ return { ok: false, error: `Channel "${action.channel}" is a ${kind} channel. Use individual thread IDs to list pins.` };
249
+ }
250
+ return { ok: false, error: `Channel "${action.channel}" not found — it may have been deleted or archived. If this was a task thread, use taskShow with the task ID instead.` };
251
+ }
252
+ const pinned = await channel.messages.fetchPinned();
253
+ if (pinned.size === 0) {
254
+ return { ok: true, summary: `No pinned messages in #${channel.name}` };
255
+ }
256
+ const lines = [...pinned.values()].map((m) => {
257
+ const author = m.author?.username ?? 'Unknown';
258
+ const text = (m.content || '(no text)').slice(0, 200);
259
+ return `[${author}] ${text} (id:${m.id})`;
260
+ });
261
+ return { ok: true, summary: `Pinned messages in #${channel.name}:\n${lines.join('\n')}` };
262
+ }
263
+ case 'sendFile': {
264
+ if (typeof action.channel !== 'string' || !action.channel.trim()) {
265
+ return { ok: false, error: 'sendFile requires a non-empty channel name or ID' };
266
+ }
267
+ if (typeof action.filePath !== 'string' || !action.filePath.trim()) {
268
+ return { ok: false, error: 'sendFile requires a non-empty filePath' };
269
+ }
270
+ const ext = path.extname(action.filePath).toLowerCase().slice(1);
271
+ if (!SENDFILE_ALLOWED_EXTENSIONS.has(ext)) {
272
+ return { ok: false, error: `File extension ".${ext}" is not allowed. Allowed extensions: ${[...SENDFILE_ALLOWED_EXTENSIONS].join(', ')}` };
273
+ }
274
+ if (action.content && action.content.length > DISCORD_MAX_CONTENT) {
275
+ return { ok: false, error: `Content exceeds Discord's ${DISCORD_MAX_CONTENT} character limit (got ${action.content.length})` };
276
+ }
277
+ let fileBuffer;
278
+ try {
279
+ const stat = await fs.stat(action.filePath);
280
+ if (stat.size > SENDFILE_MAX_BYTES) {
281
+ return { ok: false, error: `File exceeds the ${SENDFILE_MAX_BYTES / (1024 * 1024)} MB size limit (${stat.size} bytes)` };
282
+ }
283
+ fileBuffer = await fs.readFile(action.filePath);
284
+ }
285
+ catch (err) {
286
+ if (err.code === 'ENOENT') {
287
+ return { ok: false, error: `File not found: ${action.filePath}` };
288
+ }
289
+ throw err;
290
+ }
291
+ const channel = resolveChannel(guild, action.channel);
292
+ if (!channel) {
293
+ const raw = findChannelRaw(guild, action.channel);
294
+ if (raw) {
295
+ const kind = describeChannelType(raw);
296
+ return { ok: false, error: `Channel "${action.channel}" is a ${kind} channel and cannot receive files directly.` };
297
+ }
298
+ return { ok: false, error: `Channel "${action.channel}" not found — it may have been deleted or archived.` };
299
+ }
300
+ const attachment = new AttachmentBuilder(fileBuffer, { name: path.basename(action.filePath) });
301
+ const opts = { files: [attachment], allowedMentions: NO_MENTIONS };
302
+ if (action.content)
303
+ opts.content = action.content;
304
+ await channel.send(opts);
305
+ return { ok: true, summary: `Sent file "${path.basename(action.filePath)}" to #${channel.name}` };
306
+ }
307
+ }
308
+ }
309
+ // ---------------------------------------------------------------------------
310
+ // Prompt section
311
+ // ---------------------------------------------------------------------------
312
+ export function messagingActionsPromptSection() {
313
+ return `### Messaging
314
+
315
+ **sendMessage** — Send a message to a channel:
316
+ \`\`\`
317
+ <discord-action>{"type":"sendMessage","channel":"#general","content":"Hello world!","replyTo":"message-id"}</discord-action>
318
+ \`\`\`
319
+ - \`channel\` (required): Channel name (with or without #) or channel ID.
320
+ - \`content\` (required): Message text.
321
+ - \`replyTo\` (optional): Message ID to reply to.
322
+ - **Important:** Do NOT use sendMessage to reply to the current conversation — your response text is automatically posted as a reply. Only use sendMessage to post in a *different* channel.
323
+ - Forum channels do NOT support sendMessage. To post in a forum, use \`threadCreate\` instead.
324
+
325
+ **sendFile** — Send a local file as a Discord attachment:
326
+ \`\`\`
327
+ <discord-action>{"type":"sendFile","channel":"#general","filePath":"/tmp/screenshot.png","content":"Here is the screenshot"}</discord-action>
328
+ \`\`\`
329
+ - \`channel\` (required): Channel name (with or without #) or channel ID.
330
+ - \`filePath\` (required): Absolute path to the local file to upload.
331
+ - \`content\` (optional): Caption text to accompany the file.
332
+ - Allowed extensions: png, jpg, jpeg, gif, webp, pdf.
333
+ - Maximum file size: 25 MB.
334
+ - Unlike sendMessage, sendFile is never suppressed when targeting the current channel — the file is not auto-posted as a reply.
335
+
336
+ **react** — Add a reaction to a message:
337
+ \`\`\`
338
+ <discord-action>{"type":"react","channelId":"123","messageId":"456","emoji":"👍"}</discord-action>
339
+ \`\`\`
340
+
341
+ **unreact** — Remove the bot's reaction from a message:
342
+ \`\`\`
343
+ <discord-action>{"type":"unreact","channelId":"123","messageId":"456","emoji":"👍"}</discord-action>
344
+ \`\`\`
345
+
346
+ **readMessages** — Read recent messages from a channel:
347
+ \`\`\`
348
+ <discord-action>{"type":"readMessages","channel":"#general","limit":10,"before":"message-id"}</discord-action>
349
+ \`\`\`
350
+ - \`channel\` (required): Channel name or ID.
351
+ - \`limit\` (optional): 1–20, default 10.
352
+ - \`before\` (optional): Message ID to fetch messages before.
353
+
354
+ **fetchMessage** — Fetch a single message by ID:
355
+ \`\`\`
356
+ <discord-action>{"type":"fetchMessage","channelId":"123","messageId":"456"}</discord-action>
357
+ \`\`\`
358
+
359
+ **editMessage** — Edit a bot message:
360
+ \`\`\`
361
+ <discord-action>{"type":"editMessage","channelId":"123","messageId":"456","content":"Updated text"}</discord-action>
362
+ \`\`\`
363
+
364
+ **deleteMessage** — Delete a message (destructive — confirm with user first):
365
+ \`\`\`
366
+ <discord-action>{"type":"deleteMessage","channelId":"123","messageId":"456"}</discord-action>
367
+ \`\`\`
368
+
369
+ **bulkDelete** — Delete multiple recent messages at once (destructive — confirm with user first):
370
+ \`\`\`
371
+ <discord-action>{"type":"bulkDelete","channelId":"123","count":10}</discord-action>
372
+ \`\`\`
373
+ - \`channelId\` (required): Channel ID.
374
+ - \`count\` (required): Number of messages to delete (2–100). Messages older than 14 days are skipped.
375
+
376
+ **crosspost** — Publish a message in an announcement channel to all following servers:
377
+ \`\`\`
378
+ <discord-action>{"type":"crosspost","channelId":"123","messageId":"456"}</discord-action>
379
+ \`\`\`
380
+ - Only works in announcement channels. The message will be pushed to all servers following the channel.
381
+
382
+ **threadCreate** — Create a thread:
383
+ \`\`\`
384
+ <discord-action>{"type":"threadCreate","channelId":"123","name":"Discussion","messageId":"456"}</discord-action>
385
+ \`\`\`
386
+ - \`channelId\` (required): Parent channel ID.
387
+ - \`name\` (required): Thread name.
388
+ - \`messageId\` (optional): Start thread from this message. If omitted, creates a standalone thread.
389
+ - \`autoArchiveMinutes\` (optional): Auto-archive after N minutes (60, 1440, 4320, 10080). Default: 1440.
390
+
391
+ **pinMessage** / **unpinMessage** — Pin or unpin a message:
392
+ \`\`\`
393
+ <discord-action>{"type":"pinMessage","channelId":"123","messageId":"456"}</discord-action>
394
+ <discord-action>{"type":"unpinMessage","channelId":"123","messageId":"456"}</discord-action>
395
+ \`\`\`
396
+
397
+ **listPins** — List pinned messages in a channel:
398
+ \`\`\`
399
+ <discord-action>{"type":"listPins","channel":"#general"}</discord-action>
400
+ \`\`\``;
401
+ }