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,347 @@
1
+ import { ChannelType, EmbedBuilder } from 'discord.js';
2
+ import { parseCronDefinition } from './parser.js';
3
+ import { generateCronId, parseCronIdFromContent } from './run-stats.js';
4
+ import { detectCadence } from './cadence.js';
5
+ import { ensureStatusMessage } from './discord-sync.js';
6
+ function resolveForumChannel(client, nameOrId) {
7
+ // Try by ID first.
8
+ const byId = client.channels.cache.get(nameOrId);
9
+ if (byId && byId.type === ChannelType.GuildForum)
10
+ return byId;
11
+ // Try by name across all guilds.
12
+ for (const guild of client.guilds.cache.values()) {
13
+ const ch = guild.channels.cache.find((c) => c.type === ChannelType.GuildForum && c.name === nameOrId);
14
+ if (ch)
15
+ return ch;
16
+ }
17
+ return null;
18
+ }
19
+ function isSnowflakeId(v) {
20
+ return /^\d+$/.test(String(v ?? '').trim());
21
+ }
22
+ async function fetchStarterMessage(thread) {
23
+ try {
24
+ return await thread.fetchStarterMessage() ?? null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function isBotOwned(thread) {
31
+ const botUserId = thread.client?.user?.id ?? '';
32
+ return botUserId !== '' && thread.ownerId === botUserId;
33
+ }
34
+ async function rejectManualThread(thread, log) {
35
+ log?.info({ threadId: thread.id, name: thread.name, ownerId: thread.ownerId }, 'cron:forum rejected manual thread');
36
+ try {
37
+ await thread.send('Cron jobs must be created using the `cronCreate` bot command, not by manually creating forum threads.\n\n'
38
+ + 'Ask the bot to create a cron job for you, and it will set up this forum thread automatically.\n\n'
39
+ + 'This thread will be archived.');
40
+ }
41
+ catch { /* ignore */ }
42
+ try {
43
+ await thread.setArchived(true);
44
+ }
45
+ catch { /* ignore */ }
46
+ }
47
+ function scheduleEmbed(name, def, nextRun) {
48
+ return new EmbedBuilder()
49
+ .setColor(0x57f287)
50
+ .setTitle(`Cron Registered: ${name}`)
51
+ .addFields({ name: 'Schedule', value: `\`${def.schedule}\``, inline: true }, { name: 'Timezone', value: def.timezone, inline: true }, { name: 'Channel', value: `#${def.channel}`, inline: true }, { name: 'Prompt', value: def.prompt.slice(0, 1024) }, { name: 'Next Run', value: nextRun ? `<t:${Math.floor(nextRun.getTime() / 1000)}:F>` : 'N/A', inline: true })
52
+ .setTimestamp();
53
+ }
54
+ async function recoverCronId(thread, statsStore, log) {
55
+ // 1. Check stats store by threadId.
56
+ if (statsStore) {
57
+ const record = statsStore.getRecordByThreadId(thread.id);
58
+ if (record)
59
+ return record.cronId;
60
+ }
61
+ // 2. Look for bot status messages and recover by metadata index first.
62
+ // Keep content-token parse as legacy fallback.
63
+ try {
64
+ const messages = await thread.messages.fetch({ limit: 20 });
65
+ for (const msg of messages.values()) {
66
+ if (!msg.author.bot)
67
+ continue;
68
+ if (statsStore) {
69
+ const byStatusMessage = statsStore.getRecordByStatusMessageId(msg.id);
70
+ if (byStatusMessage && byStatusMessage.threadId === thread.id) {
71
+ return byStatusMessage.cronId;
72
+ }
73
+ }
74
+ const cronId = parseCronIdFromContent(msg.content);
75
+ if (cronId)
76
+ return cronId;
77
+ }
78
+ }
79
+ catch {
80
+ // Ignore fetch failures.
81
+ }
82
+ return null;
83
+ }
84
+ async function loadThreadAsCron(thread, guildId, scheduler, runtime, opts) {
85
+ const starter = await fetchStarterMessage(thread);
86
+ if (!starter?.content) {
87
+ opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum no starter message');
88
+ return false;
89
+ }
90
+ const starterAuthorId = starter?.author?.id ? String(starter.author.id) : '';
91
+ const botUserId = thread.client?.user?.id ?? '';
92
+ // SECURITY: Bot-authored threads are accepted because cronCreate (the only
93
+ // code path that creates bot-authored threads in the cron forum) already
94
+ // requires the requesting user to be in the Discord allowlist. If another
95
+ // code path ever creates bot-authored cron forum threads, it must enforce
96
+ // its own authorization check.
97
+ const isBotAuthored = botUserId !== '' && starterAuthorId === botUserId;
98
+ if (!starterAuthorId || (!opts.allowUserIds.has(starterAuthorId) && !isBotAuthored)) {
99
+ opts.log?.warn({ threadId: thread.id, name: thread.name, starterAuthorId }, 'cron:forum starter author not allowlisted');
100
+ scheduler.disable(thread.id);
101
+ try {
102
+ await thread.send('Cron not registered: the starter message author is not allowlisted for cron.');
103
+ }
104
+ catch {
105
+ // Ignore send failures.
106
+ }
107
+ return false;
108
+ }
109
+ const def = await parseCronDefinition(starter.content, runtime, { model: opts.cronModel, cwd: opts.cwd });
110
+ if (!def) {
111
+ opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum parse failed');
112
+ scheduler.disable(thread.id);
113
+ try {
114
+ await thread.send('Could not parse this cron definition. Please edit the starter message with a clearer schedule, timezone, target channel, and instruction.');
115
+ }
116
+ catch {
117
+ // Ignore send failures.
118
+ }
119
+ return false;
120
+ }
121
+ // Resolve or mint a stable cronId.
122
+ let cronId = await recoverCronId(thread, opts.statsStore, opts.log);
123
+ if (!cronId) {
124
+ cronId = generateCronId();
125
+ opts.log?.info({ threadId: thread.id, cronId }, 'cron:forum minted new cronId');
126
+ }
127
+ let job;
128
+ try {
129
+ job = scheduler.register(thread.id, thread.id, guildId, thread.name, def, cronId);
130
+ }
131
+ catch (err) {
132
+ opts.log?.error({ err, threadId: thread.id, schedule: def.schedule }, 'cron:forum invalid schedule');
133
+ scheduler.disable(thread.id);
134
+ try {
135
+ await thread.send(`Invalid cron schedule: \`${def.schedule}\`. Please edit the starter message with a valid schedule.`);
136
+ }
137
+ catch {
138
+ // Ignore send failures.
139
+ }
140
+ return false;
141
+ }
142
+ // Ensure stats record exists and set cadence.
143
+ if (opts.statsStore) {
144
+ try {
145
+ const cadence = detectCadence(def.schedule ?? '');
146
+ const existingRecord = opts.statsStore.getRecord(cronId);
147
+ await opts.statsStore.upsertRecord(cronId, thread.id, {
148
+ cadence,
149
+ // Preserve existing disabled state.
150
+ disabled: existingRecord?.disabled ?? false,
151
+ });
152
+ // Restore disabled state from stats.
153
+ if (existingRecord?.disabled) {
154
+ scheduler.disable(thread.id);
155
+ opts.log?.info({ threadId: thread.id, cronId }, 'cron:forum restored disabled state');
156
+ }
157
+ }
158
+ catch (err) {
159
+ opts.log?.warn({ err, threadId: thread.id }, 'cron:forum stats upsert failed');
160
+ }
161
+ }
162
+ // Only post confirmation on first registration (not re-parses) to keep threads clean.
163
+ if (opts.isNew) {
164
+ try {
165
+ await starter.react('\u2705');
166
+ }
167
+ catch {
168
+ // Ignore reaction failures.
169
+ }
170
+ try {
171
+ const nextRun = job.cron?.nextRun() ?? null;
172
+ await thread.send({ embeds: [scheduleEmbed(thread.name, def, nextRun)] });
173
+ }
174
+ catch {
175
+ // Ignore send failures.
176
+ }
177
+ // Create initial status message for new threads.
178
+ if (opts.statsStore) {
179
+ try {
180
+ const record = opts.statsStore.getRecord(cronId);
181
+ if (record) {
182
+ await ensureStatusMessage(thread.client, thread.id, cronId, record, opts.statsStore, { log: opts.log });
183
+ }
184
+ }
185
+ catch {
186
+ // Best-effort.
187
+ }
188
+ }
189
+ }
190
+ return true;
191
+ }
192
+ export async function initCronForum(opts) {
193
+ const { client, forumChannelNameOrId, allowUserIds, scheduler, runtime, cronModel, cwd, log, statsStore, pendingThreadIds } = opts;
194
+ if (!isSnowflakeId(forumChannelNameOrId)) {
195
+ log?.warn({ forumChannelNameOrId }, 'cron:forum name-based resolution is ambiguous; prefer a channel ID');
196
+ }
197
+ const forum = resolveForumChannel(client, forumChannelNameOrId);
198
+ if (!forum) {
199
+ log?.warn({ forumChannelNameOrId }, 'cron:forum channel not found, cron subsystem disabled');
200
+ return { forumId: '' };
201
+ }
202
+ log?.info({ forumId: forum.id, name: forum.name }, 'cron:forum resolved');
203
+ const guildId = forum.guildId;
204
+ // --- Initial load: fetch all active threads ---
205
+ const { threads: activeThreads } = await forum.threads.fetchActive();
206
+ let loaded = 0;
207
+ for (const thread of activeThreads.values()) {
208
+ if (thread.archived)
209
+ continue;
210
+ const ok = await loadThreadAsCron(thread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: false, allowUserIds, statsStore });
211
+ if (ok)
212
+ loaded++;
213
+ }
214
+ log?.info({ loaded, total: activeThreads.size }, 'cron:forum initial load complete');
215
+ // --- Live event listeners ---
216
+ const forumId = forum.id;
217
+ const reparseTimers = new Map();
218
+ const scheduleReparse = (thread, isNew) => {
219
+ const key = String(thread.id ?? '');
220
+ const existing = reparseTimers.get(key);
221
+ if (existing)
222
+ clearTimeout(existing);
223
+ reparseTimers.set(key, setTimeout(() => {
224
+ reparseTimers.delete(key);
225
+ void (async () => {
226
+ try {
227
+ await loadThreadAsCron(thread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew, allowUserIds, statsStore });
228
+ }
229
+ catch (err) {
230
+ log?.error?.({ err, threadId: key }, 'cron:forum reparse failed');
231
+ }
232
+ })();
233
+ }, 1000));
234
+ };
235
+ client.on('threadCreate', async (thread) => {
236
+ try {
237
+ if (thread.parentId !== forumId)
238
+ return;
239
+ // Skip threads created by cronCreate action to avoid double-handling.
240
+ // Check both the scheduler (already registered) and the pending set
241
+ // (registered soon — covers the window between threads.create and
242
+ // scheduler.register in the cronCreate flow).
243
+ if (scheduler.getJob(thread.id) || pendingThreadIds?.has(thread.id)) {
244
+ log?.info({ threadId: thread.id, name: thread.name }, 'cron:forum threadCreate skipped (already registered or pending)');
245
+ return;
246
+ }
247
+ // Reject manually-created threads (not created by the bot).
248
+ if (!isBotOwned(thread)) {
249
+ await rejectManualThread(thread, log);
250
+ return;
251
+ }
252
+ log?.info({ threadId: thread.id, name: thread.name }, 'cron:forum threadCreate');
253
+ // Small delay: Discord may not have the starter message ready immediately after thread creation.
254
+ await new Promise((r) => setTimeout(r, 2000));
255
+ const ok = await loadThreadAsCron(thread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: true, allowUserIds, statsStore });
256
+ if (ok)
257
+ opts.onCountChanged?.();
258
+ }
259
+ catch (err) {
260
+ log?.error({ err, threadId: thread.id }, 'cron:forum threadCreate handler failed');
261
+ }
262
+ });
263
+ client.on('threadDelete', (thread) => {
264
+ try {
265
+ if (thread.parentId !== forumId)
266
+ return;
267
+ log?.info({ threadId: thread.id, name: thread.name }, 'cron:forum threadDelete');
268
+ scheduler.unregister(thread.id);
269
+ opts.onCountChanged?.();
270
+ // Clean up stats.
271
+ if (statsStore) {
272
+ void statsStore.removeByThreadId(thread.id).catch(() => { });
273
+ }
274
+ }
275
+ catch (err) {
276
+ log?.error({ err, threadId: thread.id }, 'cron:forum threadDelete handler failed');
277
+ }
278
+ });
279
+ client.on('threadUpdate', async (oldThread, newThread) => {
280
+ try {
281
+ if (newThread.parentId !== forumId)
282
+ return;
283
+ // Archive state changed.
284
+ if (oldThread.archived !== newThread.archived) {
285
+ if (newThread.archived) {
286
+ log?.info({ threadId: newThread.id }, 'cron:forum thread archived, disabling');
287
+ scheduler.disable(newThread.id);
288
+ // Persist disabled state.
289
+ if (statsStore) {
290
+ const record = statsStore.getRecordByThreadId(newThread.id);
291
+ if (record) {
292
+ void statsStore.upsertRecord(record.cronId, newThread.id, { disabled: true }).catch(() => { });
293
+ }
294
+ }
295
+ }
296
+ else {
297
+ // Reject unarchived manual threads not already grandfathered into the scheduler.
298
+ if (!scheduler.getJob(newThread.id) && !isBotOwned(newThread)) {
299
+ await rejectManualThread(newThread, log);
300
+ return;
301
+ }
302
+ log?.info({ threadId: newThread.id }, 'cron:forum thread unarchived, re-loading');
303
+ await loadThreadAsCron(newThread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: false, allowUserIds, statsStore });
304
+ }
305
+ return;
306
+ }
307
+ // Name changed — update the job name (re-parse for good measure).
308
+ if (oldThread.name !== newThread.name) {
309
+ // Reject manual threads not already in the scheduler.
310
+ if (!scheduler.getJob(newThread.id) && !isBotOwned(newThread)) {
311
+ await rejectManualThread(newThread, log);
312
+ return;
313
+ }
314
+ log?.info({ threadId: newThread.id, oldName: oldThread.name, newName: newThread.name }, 'cron:forum thread name changed');
315
+ await loadThreadAsCron(newThread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: false, allowUserIds, statsStore });
316
+ }
317
+ }
318
+ catch (err) {
319
+ log?.error({ err, threadId: newThread.id }, 'cron:forum threadUpdate handler failed');
320
+ }
321
+ });
322
+ client.on('messageUpdate', async (_oldMsg, newMsg) => {
323
+ try {
324
+ // Check if this is the starter message of a tracked cron thread.
325
+ if (!newMsg?.channel || !newMsg?.id)
326
+ return;
327
+ const thread = newMsg.channel;
328
+ if (thread.parentId !== forumId)
329
+ return;
330
+ // Verify it's the starter message (first message in thread).
331
+ try {
332
+ const starter = await thread.fetchStarterMessage();
333
+ if (starter?.id !== newMsg.id)
334
+ return;
335
+ }
336
+ catch {
337
+ return;
338
+ }
339
+ log?.info({ threadId: thread.id, name: thread.name }, 'cron:forum starter message updated, re-parsing');
340
+ scheduleReparse(thread, false);
341
+ }
342
+ catch (err) {
343
+ log?.error({ err }, 'cron:forum messageUpdate handler failed');
344
+ }
345
+ });
346
+ return { forumId };
347
+ }