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,499 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { resolveMediaType, downloadAttachment, downloadMessageImages, sniffMediaType, MIN_PNG_BYTES, MIN_JPEG_BYTES, MIN_GIF_BYTES, MIN_WEBP_BYTES, } from './image-download.js';
3
+ // --- Helper buffers with valid magic bytes ---
4
+ /** PNG: 8-byte signature padded to >= MIN_PNG_BYTES */
5
+ function makePngBuffer(size = MIN_PNG_BYTES) {
6
+ const buf = Buffer.alloc(size);
7
+ Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).copy(buf);
8
+ return buf;
9
+ }
10
+ /** JPEG: FF D8 FF E0 padded to >= MIN_JPEG_BYTES */
11
+ function makeJpegBuffer(size = MIN_JPEG_BYTES) {
12
+ const buf = Buffer.alloc(size);
13
+ Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]).copy(buf);
14
+ return buf;
15
+ }
16
+ /** GIF89a padded to >= MIN_GIF_BYTES */
17
+ function makeGifBuffer(size = MIN_GIF_BYTES) {
18
+ const buf = Buffer.alloc(size);
19
+ Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]).copy(buf);
20
+ return buf;
21
+ }
22
+ /** WebP: RIFF....WEBP padded to >= MIN_WEBP_BYTES */
23
+ function makeWebpBuffer(size = MIN_WEBP_BYTES) {
24
+ const buf = Buffer.alloc(size);
25
+ Buffer.from([0x52, 0x49, 0x46, 0x46]).copy(buf, 0);
26
+ Buffer.from([0x57, 0x45, 0x42, 0x50]).copy(buf, 8);
27
+ return buf;
28
+ }
29
+ describe('resolveMediaType', () => {
30
+ it('returns MIME from contentType for PNG', () => {
31
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.png', contentType: 'image/png' })).toBe('image/png');
32
+ });
33
+ it('returns MIME from contentType for JPEG', () => {
34
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.jpg', contentType: 'image/jpeg' })).toBe('image/jpeg');
35
+ });
36
+ it('returns MIME from contentType for WebP', () => {
37
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.webp', contentType: 'image/webp' })).toBe('image/webp');
38
+ });
39
+ it('returns MIME from contentType for GIF', () => {
40
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.gif', contentType: 'image/gif' })).toBe('image/gif');
41
+ });
42
+ it('strips charset from contentType', () => {
43
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.png', contentType: 'image/png; charset=utf-8' })).toBe('image/png');
44
+ });
45
+ it('falls back to extension when contentType is missing', () => {
46
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.png', name: 'photo.png' })).toBe('image/png');
47
+ });
48
+ it('falls back to extension for jpg', () => {
49
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.jpg', name: 'photo.jpg' })).toBe('image/jpeg');
50
+ });
51
+ it('falls back to extension for jpeg', () => {
52
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.jpeg', name: 'photo.jpeg' })).toBe('image/jpeg');
53
+ });
54
+ it('returns null for unsupported contentType', () => {
55
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.pdf', contentType: 'application/pdf' })).toBeNull();
56
+ });
57
+ it('returns null for unsupported extension', () => {
58
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.pdf', name: 'doc.pdf' })).toBeNull();
59
+ });
60
+ it('returns null when no contentType or name', () => {
61
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a' })).toBeNull();
62
+ });
63
+ it('handles uppercase MIME types', () => {
64
+ expect(resolveMediaType({ url: 'https://cdn.discordapp.com/a.png', contentType: 'IMAGE/PNG' })).toBe('image/png');
65
+ });
66
+ });
67
+ describe('sniffMediaType', () => {
68
+ it('detects PNG from 8-byte signature', () => {
69
+ expect(sniffMediaType(makePngBuffer())).toBe('image/png');
70
+ });
71
+ it('detects JPEG from FF D8 FF (JFIF)', () => {
72
+ expect(sniffMediaType(makeJpegBuffer())).toBe('image/jpeg');
73
+ });
74
+ it('detects JPEG from FF D8 FF E1 (EXIF)', () => {
75
+ const buf = Buffer.alloc(20);
76
+ Buffer.from([0xFF, 0xD8, 0xFF, 0xE1]).copy(buf);
77
+ expect(sniffMediaType(buf)).toBe('image/jpeg');
78
+ });
79
+ it('detects GIF89a', () => {
80
+ expect(sniffMediaType(makeGifBuffer())).toBe('image/gif');
81
+ });
82
+ it('detects GIF87a', () => {
83
+ const buf = Buffer.alloc(26);
84
+ Buffer.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]).copy(buf);
85
+ expect(sniffMediaType(buf)).toBe('image/gif');
86
+ });
87
+ it('detects WebP from RIFF...WEBP', () => {
88
+ expect(sniffMediaType(makeWebpBuffer())).toBe('image/webp');
89
+ });
90
+ it('returns null for empty buffer', () => {
91
+ expect(sniffMediaType(Buffer.alloc(0))).toBeNull();
92
+ });
93
+ it('returns null for random bytes', () => {
94
+ expect(sniffMediaType(Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04]))).toBeNull();
95
+ });
96
+ it('returns null for 1-byte buffer', () => {
97
+ expect(sniffMediaType(Buffer.from([0xFF]))).toBeNull();
98
+ });
99
+ it('returns null for 2-byte buffer', () => {
100
+ expect(sniffMediaType(Buffer.from([0xFF, 0xD8]))).toBeNull();
101
+ });
102
+ it('returns null for 7-byte buffer matching PNG prefix', () => {
103
+ const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A]);
104
+ expect(sniffMediaType(buf)).toBeNull();
105
+ });
106
+ it('returns null for 5-byte buffer matching GIF prefix', () => {
107
+ const buf = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39]);
108
+ expect(sniffMediaType(buf)).toBeNull();
109
+ });
110
+ it('returns null for RIFF + WAVE (not WebP)', () => {
111
+ const buf = Buffer.alloc(12);
112
+ Buffer.from([0x52, 0x49, 0x46, 0x46]).copy(buf, 0);
113
+ Buffer.from([0x57, 0x41, 0x56, 0x45]).copy(buf, 8); // WAVE, not WEBP
114
+ expect(sniffMediaType(buf)).toBeNull();
115
+ });
116
+ it('returns null for 11-byte WebP-like buffer', () => {
117
+ const buf = Buffer.alloc(11);
118
+ Buffer.from([0x52, 0x49, 0x46, 0x46]).copy(buf, 0);
119
+ // Too short to check offset 8-11
120
+ expect(sniffMediaType(buf)).toBeNull();
121
+ });
122
+ it('detects format at exact minimum signature lengths', () => {
123
+ expect(sniffMediaType(makePngBuffer(8))).toBe('image/png');
124
+ expect(sniffMediaType(makeJpegBuffer(3))).toBe('image/jpeg');
125
+ expect(sniffMediaType(makeGifBuffer(6))).toBe('image/gif');
126
+ expect(sniffMediaType(makeWebpBuffer(12))).toBe('image/webp');
127
+ });
128
+ });
129
+ describe('downloadAttachment', () => {
130
+ const originalFetch = globalThis.fetch;
131
+ beforeEach(() => {
132
+ globalThis.fetch = vi.fn();
133
+ });
134
+ afterEach(() => {
135
+ globalThis.fetch = originalFetch;
136
+ });
137
+ it('downloads and base64-encodes a valid image', async () => {
138
+ const data = makePngBuffer();
139
+ globalThis.fetch.mockResolvedValue({
140
+ ok: true,
141
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
142
+ });
143
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/photo.png', name: 'photo.png', size: data.length }, 'image/png');
144
+ expect(result.ok).toBe(true);
145
+ if (result.ok) {
146
+ expect(result.image.mediaType).toBe('image/png');
147
+ expect(result.image.base64).toBe(data.toString('base64'));
148
+ }
149
+ });
150
+ it('rejects non-Discord-CDN URLs (SSRF protection)', async () => {
151
+ const result = await downloadAttachment({ url: 'https://evil.com/malicious.png', name: 'malicious.png' }, 'image/png');
152
+ expect(result.ok).toBe(false);
153
+ if (!result.ok) {
154
+ expect(result.error).toContain('blocked');
155
+ expect(result.error).not.toContain('evil.com'); // no raw URL
156
+ }
157
+ expect(globalThis.fetch).not.toHaveBeenCalled();
158
+ });
159
+ it('rejects HTTP URLs (non-HTTPS)', async () => {
160
+ const result = await downloadAttachment({ url: 'http://cdn.discordapp.com/photo.png', name: 'photo.png' }, 'image/png');
161
+ expect(result.ok).toBe(false);
162
+ if (!result.ok)
163
+ expect(result.error).toContain('blocked');
164
+ expect(globalThis.fetch).not.toHaveBeenCalled();
165
+ });
166
+ it('rejects oversized images from Discord metadata pre-check', async () => {
167
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/photo.png', name: 'photo.png', size: 25 * 1024 * 1024 }, 'image/png');
168
+ expect(result.ok).toBe(false);
169
+ if (!result.ok) {
170
+ expect(result.error).toContain('too large');
171
+ expect(result.error).toContain('max 20 MB');
172
+ }
173
+ expect(globalThis.fetch).not.toHaveBeenCalled();
174
+ });
175
+ it('rejects oversized images after download', async () => {
176
+ const bigBuf = Buffer.alloc(21 * 1024 * 1024); // 21 MB
177
+ globalThis.fetch.mockResolvedValue({
178
+ ok: true,
179
+ arrayBuffer: () => Promise.resolve(bigBuf.buffer.slice(bigBuf.byteOffset, bigBuf.byteOffset + bigBuf.byteLength)),
180
+ });
181
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/photo.png', name: 'photo.png', size: 100 }, // size lies
182
+ 'image/png');
183
+ expect(result.ok).toBe(false);
184
+ if (!result.ok)
185
+ expect(result.error).toContain('too large');
186
+ });
187
+ it('handles HTTP error responses', async () => {
188
+ globalThis.fetch.mockResolvedValue({ ok: false, status: 404 });
189
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/photo.png', name: 'photo.png' }, 'image/png');
190
+ expect(result.ok).toBe(false);
191
+ if (!result.ok)
192
+ expect(result.error).toContain('HTTP 404');
193
+ });
194
+ it('handles network errors', async () => {
195
+ globalThis.fetch.mockRejectedValue(new Error('ECONNREFUSED'));
196
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/photo.png', name: 'photo.png' }, 'image/png');
197
+ expect(result.ok).toBe(false);
198
+ if (!result.ok)
199
+ expect(result.error).toContain('download failed');
200
+ });
201
+ it('handles timeout', async () => {
202
+ const timeoutErr = new DOMException('signal timed out', 'TimeoutError');
203
+ globalThis.fetch.mockRejectedValue(timeoutErr);
204
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/photo.png', name: 'photo.png' }, 'image/png');
205
+ expect(result.ok).toBe(false);
206
+ if (!result.ok)
207
+ expect(result.error).toContain('timed out');
208
+ });
209
+ it('handles invalid URL', async () => {
210
+ const result = await downloadAttachment({ url: 'not-a-url', name: 'bad.png' }, 'image/png');
211
+ expect(result.ok).toBe(false);
212
+ if (!result.ok)
213
+ expect(result.error).toContain('invalid URL');
214
+ });
215
+ it('rejects redirected responses', async () => {
216
+ const redirectErr = new TypeError('fetch failed: redirect mode is set to error');
217
+ globalThis.fetch.mockRejectedValue(redirectErr);
218
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/photo.png', name: 'photo.png' }, 'image/png');
219
+ expect(result.ok).toBe(false);
220
+ if (!result.ok)
221
+ expect(result.error).toContain('blocked (unexpected redirect)');
222
+ });
223
+ it('rejects zero-byte image (unrecognized magic bytes)', async () => {
224
+ const data = Buffer.alloc(0);
225
+ globalThis.fetch.mockResolvedValue({
226
+ ok: true,
227
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
228
+ });
229
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/empty.png', name: 'empty.png', size: 0 }, 'image/png');
230
+ expect(result.ok).toBe(false);
231
+ if (!result.ok) {
232
+ expect(result.error).toContain('unsupported image format');
233
+ }
234
+ });
235
+ it('error messages are sanitized — no raw URLs', async () => {
236
+ globalThis.fetch.mockResolvedValue({ ok: false, status: 500 });
237
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/photo.png?token=secret', name: 'photo.png' }, 'image/png');
238
+ expect(result.ok).toBe(false);
239
+ if (!result.ok) {
240
+ expect(result.error).not.toContain('secret');
241
+ expect(result.error).not.toContain('https://');
242
+ }
243
+ });
244
+ it('overrides declared MIME when magic bytes differ', async () => {
245
+ // Declare image/webp but send JPEG bytes
246
+ const data = makeJpegBuffer();
247
+ globalThis.fetch.mockResolvedValue({
248
+ ok: true,
249
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
250
+ });
251
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/photo.webp', name: 'photo.webp', size: data.length }, 'image/webp');
252
+ expect(result.ok).toBe(true);
253
+ if (result.ok) {
254
+ expect(result.image.mediaType).toBe('image/jpeg');
255
+ }
256
+ });
257
+ it('rejects unsupported format (random bytes)', async () => {
258
+ const data = Buffer.alloc(64, 0x42);
259
+ globalThis.fetch.mockResolvedValue({
260
+ ok: true,
261
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
262
+ });
263
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/mystery.png', name: 'mystery.png', size: data.length }, 'image/png');
264
+ expect(result.ok).toBe(false);
265
+ if (!result.ok) {
266
+ expect(result.error).toContain('unsupported image format');
267
+ }
268
+ });
269
+ it('rejects truncated PNG (7 bytes, incomplete signature)', async () => {
270
+ const data = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A]);
271
+ globalThis.fetch.mockResolvedValue({
272
+ ok: true,
273
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
274
+ });
275
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/trunc.png', name: 'trunc.png', size: data.length }, 'image/png');
276
+ expect(result.ok).toBe(false);
277
+ if (!result.ok)
278
+ expect(result.error).toContain('unsupported image format');
279
+ });
280
+ it('rejects header-only JPEG (3 bytes, below MIN_JPEG_BYTES)', async () => {
281
+ const data = Buffer.from([0xFF, 0xD8, 0xFF]);
282
+ globalThis.fetch.mockResolvedValue({
283
+ ok: true,
284
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
285
+ });
286
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/tiny.jpg', name: 'tiny.jpg', size: data.length }, 'image/jpeg');
287
+ expect(result.ok).toBe(false);
288
+ if (!result.ok)
289
+ expect(result.error).toContain('image too small');
290
+ });
291
+ it('rejects header-only PNG (8 bytes, below MIN_PNG_BYTES)', async () => {
292
+ const data = makePngBuffer(8);
293
+ globalThis.fetch.mockResolvedValue({
294
+ ok: true,
295
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
296
+ });
297
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/tiny.png', name: 'tiny.png', size: data.length }, 'image/png');
298
+ expect(result.ok).toBe(false);
299
+ if (!result.ok)
300
+ expect(result.error).toContain('image too small');
301
+ });
302
+ it('accepts GIF at exactly MIN_GIF_BYTES', async () => {
303
+ const data = makeGifBuffer(MIN_GIF_BYTES);
304
+ globalThis.fetch.mockResolvedValue({
305
+ ok: true,
306
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
307
+ });
308
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/tiny.gif', name: 'tiny.gif', size: data.length }, 'image/gif');
309
+ expect(result.ok).toBe(true);
310
+ if (result.ok)
311
+ expect(result.image.mediaType).toBe('image/gif');
312
+ });
313
+ it('rejects GIF at MIN_GIF_BYTES - 1', async () => {
314
+ const data = makeGifBuffer(MIN_GIF_BYTES - 1);
315
+ globalThis.fetch.mockResolvedValue({
316
+ ok: true,
317
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
318
+ });
319
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/tiny.gif', name: 'tiny.gif', size: data.length }, 'image/gif');
320
+ expect(result.ok).toBe(false);
321
+ if (!result.ok)
322
+ expect(result.error).toContain('image too small');
323
+ });
324
+ it('accepts PNG at exactly MIN_PNG_BYTES', async () => {
325
+ const data = makePngBuffer(MIN_PNG_BYTES);
326
+ globalThis.fetch.mockResolvedValue({
327
+ ok: true,
328
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
329
+ });
330
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/min.png', name: 'min.png', size: data.length }, 'image/png');
331
+ expect(result.ok).toBe(true);
332
+ if (result.ok)
333
+ expect(result.image.mediaType).toBe('image/png');
334
+ });
335
+ it('rejects PNG at MIN_PNG_BYTES - 1', async () => {
336
+ const data = makePngBuffer(MIN_PNG_BYTES - 1);
337
+ globalThis.fetch.mockResolvedValue({
338
+ ok: true,
339
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
340
+ });
341
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/almost.png', name: 'almost.png', size: data.length }, 'image/png');
342
+ expect(result.ok).toBe(false);
343
+ if (!result.ok)
344
+ expect(result.error).toContain('image too small');
345
+ });
346
+ it('accepts WebP at exactly MIN_WEBP_BYTES', async () => {
347
+ const data = makeWebpBuffer(MIN_WEBP_BYTES);
348
+ globalThis.fetch.mockResolvedValue({
349
+ ok: true,
350
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
351
+ });
352
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/min.webp', name: 'min.webp', size: data.length }, 'image/webp');
353
+ expect(result.ok).toBe(true);
354
+ if (result.ok)
355
+ expect(result.image.mediaType).toBe('image/webp');
356
+ });
357
+ it('rejects WebP at MIN_WEBP_BYTES - 1', async () => {
358
+ const data = makeWebpBuffer(MIN_WEBP_BYTES - 1);
359
+ globalThis.fetch.mockResolvedValue({
360
+ ok: true,
361
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
362
+ });
363
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/almost.webp', name: 'almost.webp', size: data.length }, 'image/webp');
364
+ expect(result.ok).toBe(false);
365
+ if (!result.ok)
366
+ expect(result.error).toContain('image too small');
367
+ });
368
+ it('accepts JPEG at exactly MIN_JPEG_BYTES', async () => {
369
+ const data = makeJpegBuffer(MIN_JPEG_BYTES);
370
+ globalThis.fetch.mockResolvedValue({
371
+ ok: true,
372
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
373
+ });
374
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/min.jpg', name: 'min.jpg', size: data.length }, 'image/jpeg');
375
+ expect(result.ok).toBe(true);
376
+ if (result.ok)
377
+ expect(result.image.mediaType).toBe('image/jpeg');
378
+ });
379
+ it('rejects JPEG at MIN_JPEG_BYTES - 1', async () => {
380
+ const data = makeJpegBuffer(MIN_JPEG_BYTES - 1);
381
+ globalThis.fetch.mockResolvedValue({
382
+ ok: true,
383
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
384
+ });
385
+ const result = await downloadAttachment({ url: 'https://cdn.discordapp.com/attachments/123/456/almost.jpg', name: 'almost.jpg', size: data.length }, 'image/jpeg');
386
+ expect(result.ok).toBe(false);
387
+ if (!result.ok)
388
+ expect(result.error).toContain('image too small');
389
+ });
390
+ });
391
+ describe('downloadMessageImages', () => {
392
+ const originalFetch = globalThis.fetch;
393
+ beforeEach(() => {
394
+ globalThis.fetch = vi.fn();
395
+ });
396
+ afterEach(() => {
397
+ globalThis.fetch = originalFetch;
398
+ });
399
+ function makeAttachment(name, contentType, size) {
400
+ return { url: `https://cdn.discordapp.com/attachments/123/456/${name}`, name, contentType, size };
401
+ }
402
+ it('downloads multiple image attachments', async () => {
403
+ const pngData = makePngBuffer();
404
+ const jpegData = makeJpegBuffer();
405
+ globalThis.fetch
406
+ .mockResolvedValueOnce({
407
+ ok: true,
408
+ arrayBuffer: () => Promise.resolve(pngData.buffer.slice(pngData.byteOffset, pngData.byteOffset + pngData.byteLength)),
409
+ })
410
+ .mockResolvedValueOnce({
411
+ ok: true,
412
+ arrayBuffer: () => Promise.resolve(jpegData.buffer.slice(jpegData.byteOffset, jpegData.byteOffset + jpegData.byteLength)),
413
+ });
414
+ const result = await downloadMessageImages([
415
+ makeAttachment('a.png', 'image/png', 100),
416
+ makeAttachment('b.jpg', 'image/jpeg', 200),
417
+ ]);
418
+ expect(result.images).toHaveLength(2);
419
+ expect(result.errors).toHaveLength(0);
420
+ expect(result.images[0].mediaType).toBe('image/png');
421
+ expect(result.images[1].mediaType).toBe('image/jpeg');
422
+ });
423
+ it('filters out non-image attachments silently', async () => {
424
+ const pngData = makePngBuffer();
425
+ globalThis.fetch.mockResolvedValue({
426
+ ok: true,
427
+ arrayBuffer: () => Promise.resolve(pngData.buffer.slice(pngData.byteOffset, pngData.byteOffset + pngData.byteLength)),
428
+ });
429
+ const result = await downloadMessageImages([
430
+ makeAttachment('a.png', 'image/png', 100),
431
+ makeAttachment('doc.pdf', 'application/pdf', 500),
432
+ makeAttachment('b.jpg', 'image/jpeg', 200),
433
+ ]);
434
+ // Both images download, but magic bytes are PNG for both (single mock).
435
+ // The important assertion is that the PDF was filtered out.
436
+ expect(result.images).toHaveLength(2);
437
+ expect(result.errors).toHaveLength(0);
438
+ });
439
+ it('respects maxImages cap', async () => {
440
+ const data = makePngBuffer();
441
+ globalThis.fetch.mockResolvedValue({
442
+ ok: true,
443
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
444
+ });
445
+ const atts = Array.from({ length: 5 }, (_, i) => makeAttachment(`img${i}.png`, 'image/png', 100));
446
+ const result = await downloadMessageImages(atts, 2);
447
+ expect(result.images).toHaveLength(2);
448
+ });
449
+ it('stops downloading when total byte cap is exceeded', async () => {
450
+ const data = makePngBuffer();
451
+ globalThis.fetch.mockResolvedValue({
452
+ ok: true,
453
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
454
+ });
455
+ const result = await downloadMessageImages([
456
+ makeAttachment('a.png', 'image/png', 18 * 1024 * 1024), // 18 MB (under per-image 20 MB limit)
457
+ makeAttachment('b.png', 'image/png', 18 * 1024 * 1024), // 18 MB (total 36 MB, ok)
458
+ makeAttachment('c.png', 'image/png', 18 * 1024 * 1024), // 18 MB — total would be 54 MB, exceeds 50 MB cap
459
+ ]);
460
+ // First two images download, third is skipped.
461
+ expect(result.images).toHaveLength(2);
462
+ expect(result.errors).toHaveLength(1);
463
+ expect(result.errors[0]).toContain('total size limit');
464
+ });
465
+ it('returns empty for empty input', async () => {
466
+ const result = await downloadMessageImages([]);
467
+ expect(result.images).toHaveLength(0);
468
+ expect(result.errors).toHaveLength(0);
469
+ });
470
+ it('rejects single attachment exceeding total byte cap', async () => {
471
+ const data = makePngBuffer();
472
+ globalThis.fetch.mockResolvedValue({
473
+ ok: true,
474
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
475
+ });
476
+ const result = await downloadMessageImages([
477
+ makeAttachment('huge.png', 'image/png', 60 * 1024 * 1024), // 60 MB — exceeds 50 MB total cap
478
+ ]);
479
+ expect(result.images).toHaveLength(0);
480
+ expect(result.errors).toHaveLength(1);
481
+ expect(result.errors[0]).toContain('total size limit');
482
+ });
483
+ it('collects errors from individual failed downloads', async () => {
484
+ const data = makePngBuffer();
485
+ globalThis.fetch
486
+ .mockResolvedValueOnce({
487
+ ok: true,
488
+ arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)),
489
+ })
490
+ .mockResolvedValueOnce({ ok: false, status: 500 });
491
+ const result = await downloadMessageImages([
492
+ makeAttachment('good.png', 'image/png', 100),
493
+ makeAttachment('bad.png', 'image/png', 100),
494
+ ]);
495
+ expect(result.images).toHaveLength(1);
496
+ expect(result.errors).toHaveLength(1);
497
+ expect(result.errors[0]).toContain('HTTP 500');
498
+ });
499
+ });