@poolzin/pool-bot 2026.2.24 → 2026.2.26

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 (646) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/acp/client.js +207 -18
  3. package/dist/acp/event-mapper.js +87 -22
  4. package/dist/acp/meta.js +12 -6
  5. package/dist/acp/secret-file.js +22 -0
  6. package/dist/agents/agent-paths.js +8 -9
  7. package/dist/agents/agent-scope.js +17 -5
  8. package/dist/agents/auth-profiles/oauth.js +148 -64
  9. package/dist/agents/auth-profiles/session-override.js +13 -7
  10. package/dist/agents/bash-process-registry.test-helpers.js +29 -0
  11. package/dist/agents/bash-tools.exec-approval-request.js +20 -0
  12. package/dist/agents/bash-tools.exec-host-gateway.js +240 -0
  13. package/dist/agents/bash-tools.exec-host-node.js +235 -0
  14. package/dist/agents/bash-tools.exec-runtime.js +2 -25
  15. package/dist/agents/bash-tools.exec-types.js +1 -0
  16. package/dist/agents/bash-tools.process.js +224 -218
  17. package/dist/agents/bedrock-discovery.js +3 -1
  18. package/dist/agents/byteplus-models.js +97 -0
  19. package/dist/agents/chutes-oauth.js +1 -0
  20. package/dist/agents/cli-runner/helpers.js +4 -0
  21. package/dist/agents/compaction.js +41 -14
  22. package/dist/agents/content-blocks.js +16 -0
  23. package/dist/agents/doubao-models.js +121 -0
  24. package/dist/agents/failover-error.js +2 -0
  25. package/dist/agents/huggingface-models.js +5 -3
  26. package/dist/agents/live-model-filter.js +5 -0
  27. package/dist/agents/minimax-vlm.js +10 -8
  28. package/dist/agents/model-auth.js +6 -0
  29. package/dist/agents/model-catalog.js +3 -1
  30. package/dist/agents/model-fallback.js +96 -101
  31. package/dist/agents/model-selection.js +7 -1
  32. package/dist/agents/models-config.providers.js +364 -165
  33. package/dist/agents/ollama-stream.js +117 -4
  34. package/dist/agents/opencode-zen-models.js +22 -11
  35. package/dist/agents/pi-embedded-helpers/errors.js +55 -33
  36. package/dist/agents/pi-embedded-helpers/messaging-dedupe.js +10 -5
  37. package/dist/agents/pi-embedded-helpers/thinking.js +10 -5
  38. package/dist/agents/pi-embedded-helpers.js +1 -1
  39. package/dist/agents/pi-embedded-payloads.js +1 -0
  40. package/dist/agents/pi-embedded-runner/compact.js +29 -7
  41. package/dist/agents/pi-embedded-runner/extensions.js +28 -26
  42. package/dist/agents/pi-embedded-runner/google.js +20 -8
  43. package/dist/agents/pi-embedded-runner/run/attempt.js +95 -36
  44. package/dist/agents/pi-embedded-runner/run.js +71 -12
  45. package/dist/agents/pi-embedded-runner/run.overflow-compaction.fixture.js +34 -0
  46. package/dist/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.js +11 -2
  47. package/dist/agents/pi-embedded-runner/session-manager-cache.js +11 -7
  48. package/dist/agents/pi-embedded-runner/system-prompt.js +2 -0
  49. package/dist/agents/pi-embedded-runner/thinking.js +42 -0
  50. package/dist/agents/pi-embedded-runner/tool-name-allowlist.js +19 -0
  51. package/dist/agents/pi-embedded-runner/utils.js +7 -10
  52. package/dist/agents/pi-embedded-subscribe.handlers.lifecycle.js +45 -56
  53. package/dist/agents/pi-embedded-subscribe.handlers.tools.js +2 -2
  54. package/dist/agents/pi-embedded-subscribe.js +9 -4
  55. package/dist/agents/pi-embedded-subscribe.tools.js +68 -14
  56. package/dist/agents/pi-embedded-utils.js +3 -0
  57. package/dist/agents/pi-extensions/compaction-safeguard-runtime.js +4 -20
  58. package/dist/agents/pi-extensions/compaction-safeguard.js +75 -33
  59. package/dist/agents/pi-settings.js +40 -0
  60. package/dist/agents/pi-tools.policy.js +2 -1
  61. package/dist/agents/provider/config-loader.js +1 -1
  62. package/dist/agents/sandbox/browser.js +170 -33
  63. package/dist/agents/sandbox/config-hash.js +14 -27
  64. package/dist/agents/sandbox/config.js +21 -2
  65. package/dist/agents/sandbox/constants.js +2 -0
  66. package/dist/agents/sandbox/docker.js +16 -2
  67. package/dist/agents/sandbox/novnc-auth.js +62 -0
  68. package/dist/agents/sandbox/sanitize-env-vars.js +1 -1
  69. package/dist/agents/sandbox/shared.js +10 -6
  70. package/dist/agents/sandbox-paths.js +24 -11
  71. package/dist/agents/schema/clean-for-gemini.js +132 -85
  72. package/dist/agents/session-slug.js +10 -5
  73. package/dist/agents/session-tool-result-guard-wrapper.js +1 -0
  74. package/dist/agents/session-tool-result-guard.js +3 -1
  75. package/dist/agents/session-transcript-repair.js +40 -6
  76. package/dist/agents/skills/bundled-dir.js +19 -5
  77. package/dist/agents/skills/env-overrides.js +124 -43
  78. package/dist/agents/skills/frontmatter.js +6 -6
  79. package/dist/agents/skills/plugin-skills.js +14 -7
  80. package/dist/agents/skills/workspace.js +1 -0
  81. package/dist/agents/skills.test-helpers.js +13 -0
  82. package/dist/agents/stable-stringify.js +12 -0
  83. package/dist/agents/subagent-announce.js +251 -49
  84. package/dist/agents/subagent-lifecycle-events.js +19 -0
  85. package/dist/agents/subagent-registry-cleanup.js +31 -0
  86. package/dist/agents/subagent-registry-completion.js +68 -0
  87. package/dist/agents/subagent-registry-queries.js +117 -0
  88. package/dist/agents/subagent-registry-state.js +46 -0
  89. package/dist/agents/subagent-registry.js +252 -221
  90. package/dist/agents/subagent-registry.mocks.shared.js +12 -0
  91. package/dist/agents/subagent-registry.store.js +1 -0
  92. package/dist/agents/subagent-registry.types.js +1 -0
  93. package/dist/agents/subagent-spawn.js +195 -7
  94. package/dist/agents/system-prompt.js +22 -6
  95. package/dist/agents/test-helpers/assistant-message-fixtures.js +29 -0
  96. package/dist/agents/test-helpers/fast-coding-tools.js +1 -18
  97. package/dist/agents/test-helpers/fast-core-tools.js +1 -17
  98. package/dist/agents/test-helpers/pi-tools-sandbox-context.js +27 -0
  99. package/dist/agents/timeout.js +18 -6
  100. package/dist/agents/tool-call-id.js +1 -1
  101. package/dist/agents/tool-display-common.js +162 -29
  102. package/dist/agents/tool-images.js +82 -9
  103. package/dist/agents/tool-policy-shared.js +108 -0
  104. package/dist/agents/tool-policy.js +51 -26
  105. package/dist/agents/tools/browser-tool.js +160 -54
  106. package/dist/agents/tools/canvas-tool.js +27 -1
  107. package/dist/agents/tools/common.js +45 -0
  108. package/dist/agents/tools/cron-tool.test-helpers.js +12 -0
  109. package/dist/agents/tools/discord-actions-guild.js +4 -1
  110. package/dist/agents/tools/discord-actions-moderation-shared.js +27 -0
  111. package/dist/agents/tools/gateway-tool.js +3 -1
  112. package/dist/agents/tools/image-tool.js +214 -99
  113. package/dist/agents/tools/nodes-utils.js +1 -10
  114. package/dist/agents/tools/sessions-history-tool.js +140 -108
  115. package/dist/agents/tools/sessions-send-helpers.js +12 -6
  116. package/dist/agents/tools/sessions-spawn-tool.js +8 -2
  117. package/dist/agents/tools/subagents-tool.js +2 -1
  118. package/dist/agents/tools/whatsapp-actions.js +10 -2
  119. package/dist/agents/tools/whatsapp-target-auth.js +18 -0
  120. package/dist/agents/transcript-policy.js +22 -8
  121. package/dist/agents/venice-models.js +11 -3
  122. package/dist/agents/workspace.js +222 -46
  123. package/dist/auto-reply/commands-registry.data.js +51 -0
  124. package/dist/auto-reply/commands-registry.js +19 -21
  125. package/dist/auto-reply/fallback-state.js +114 -0
  126. package/dist/auto-reply/group-activation.js +10 -5
  127. package/dist/auto-reply/inbound-debounce.js +10 -5
  128. package/dist/auto-reply/model-runtime.js +68 -0
  129. package/dist/auto-reply/reply/abort.js +1 -1
  130. package/dist/auto-reply/reply/agent-runner-execution.js +40 -5
  131. package/dist/auto-reply/reply/agent-runner.js +165 -39
  132. package/dist/auto-reply/reply/bash-command.js +41 -39
  133. package/dist/auto-reply/reply/command-gates.js +25 -0
  134. package/dist/auto-reply/reply/commands-allowlist.js +111 -72
  135. package/dist/auto-reply/reply/commands-bash.js +6 -5
  136. package/dist/auto-reply/reply/commands-config.js +30 -28
  137. package/dist/auto-reply/reply/commands-core.js +2 -1
  138. package/dist/auto-reply/reply/commands-info.js +1 -0
  139. package/dist/auto-reply/reply/commands-models.js +65 -14
  140. package/dist/auto-reply/reply/commands-session.js +237 -82
  141. package/dist/auto-reply/reply/commands-setunset-standard.js +13 -0
  142. package/dist/auto-reply/reply/commands-setunset.js +45 -0
  143. package/dist/auto-reply/reply/commands-subagents/action-agents.js +44 -0
  144. package/dist/auto-reply/reply/commands-subagents/action-focus.js +64 -0
  145. package/dist/auto-reply/reply/commands-subagents/action-help.js +4 -0
  146. package/dist/auto-reply/reply/commands-subagents/action-info.js +45 -0
  147. package/dist/auto-reply/reply/commands-subagents/action-kill.js +60 -0
  148. package/dist/auto-reply/reply/commands-subagents/action-list.js +44 -0
  149. package/dist/auto-reply/reply/commands-subagents/action-log.js +29 -0
  150. package/dist/auto-reply/reply/commands-subagents/action-send.js +119 -0
  151. package/dist/auto-reply/reply/commands-subagents/action-spawn.js +52 -0
  152. package/dist/auto-reply/reply/commands-subagents/action-unfocus.js +30 -0
  153. package/dist/auto-reply/reply/commands-subagents/shared.js +303 -0
  154. package/dist/auto-reply/reply/commands-subagents.js +51 -587
  155. package/dist/auto-reply/reply/commands-tts.js +10 -5
  156. package/dist/auto-reply/reply/config-value.js +10 -5
  157. package/dist/auto-reply/reply/directive-handling.model-picker.js +12 -6
  158. package/dist/auto-reply/reply/directive-handling.persist.js +9 -21
  159. package/dist/auto-reply/reply/directive-handling.shared.js +24 -4
  160. package/dist/auto-reply/reply/followup-runner.js +1 -0
  161. package/dist/auto-reply/reply/get-reply-directives-utils.js +23 -14
  162. package/dist/auto-reply/reply/get-reply-directives.js +17 -28
  163. package/dist/auto-reply/reply/get-reply-inline-actions.js +1 -0
  164. package/dist/auto-reply/reply/get-reply.js +71 -12
  165. package/dist/auto-reply/reply/model-selection.js +80 -39
  166. package/dist/auto-reply/reply/queue/enqueue.js +10 -5
  167. package/dist/auto-reply/reply/queue/state.js +13 -12
  168. package/dist/auto-reply/reply/reply-payloads.js +67 -36
  169. package/dist/auto-reply/reply/reply-reference.js +9 -8
  170. package/dist/auto-reply/reply/route-reply.js +15 -8
  171. package/dist/auto-reply/reply/session-reset-prompt.js +1 -1
  172. package/dist/auto-reply/reply/session.js +22 -6
  173. package/dist/auto-reply/reply/strip-inbound-meta.js +147 -0
  174. package/dist/auto-reply/reply/subagents-utils.js +56 -30
  175. package/dist/auto-reply/reply/typing.js +46 -21
  176. package/dist/auto-reply/send-policy.js +14 -7
  177. package/dist/auto-reply/status.js +140 -16
  178. package/dist/auto-reply/templating.js +10 -5
  179. package/dist/auto-reply/thinking.js +7 -16
  180. package/dist/auto-reply/tokens.js +21 -5
  181. package/dist/browser/bridge-server.js +36 -20
  182. package/dist/browser/cdp.helpers.js +7 -14
  183. package/dist/browser/cdp.js +35 -15
  184. package/dist/browser/chrome.profile-decoration.js +7 -4
  185. package/dist/browser/config.js +30 -0
  186. package/dist/browser/extension-relay-auth.js +55 -0
  187. package/dist/browser/extension-relay.js +74 -29
  188. package/dist/browser/navigation-guard.js +39 -0
  189. package/dist/browser/paths.js +77 -0
  190. package/dist/browser/profiles.js +13 -8
  191. package/dist/browser/pw-ai-module.js +10 -5
  192. package/dist/browser/pw-session.js +76 -39
  193. package/dist/browser/pw-tools-core.interactions.js +14 -7
  194. package/dist/browser/pw-tools-core.state.js +12 -6
  195. package/dist/browser/routes/agent.act.js +431 -424
  196. package/dist/browser/routes/agent.shared.js +47 -3
  197. package/dist/browser/routes/agent.snapshot.js +122 -116
  198. package/dist/browser/routes/agent.storage.js +303 -297
  199. package/dist/browser/routes/tabs.js +154 -100
  200. package/dist/browser/server-context.js +7 -0
  201. package/dist/browser/server-lifecycle.js +37 -0
  202. package/dist/build-info.json +3 -3
  203. package/dist/channels/allow-from.js +26 -0
  204. package/dist/channels/allowlists/resolve-utils.js +43 -19
  205. package/dist/channels/channel-config.js +14 -7
  206. package/dist/channels/draft-stream-loop.js +7 -0
  207. package/dist/channels/model-overrides.js +82 -0
  208. package/dist/channels/plugins/account-action-gate.js +13 -0
  209. package/dist/channels/plugins/message-actions.js +10 -0
  210. package/dist/channels/plugins/normalize/imessage.js +14 -7
  211. package/dist/channels/plugins/normalize/slack.js +10 -5
  212. package/dist/channels/plugins/normalize/telegram.js +14 -7
  213. package/dist/channels/plugins/outbound/discord.js +80 -8
  214. package/dist/channels/plugins/outbound/signal.js +11 -11
  215. package/dist/channels/plugins/setup-helpers.js +10 -5
  216. package/dist/channels/sender-label.js +14 -7
  217. package/dist/channels/session.js +4 -2
  218. package/dist/channels/status-reactions.js +297 -0
  219. package/dist/channels/telegram/api.js +18 -0
  220. package/dist/cli/argv.js +84 -21
  221. package/dist/cli/banner.js +3 -2
  222. package/dist/cli/browser-cli-actions-input/register.files-downloads.js +65 -56
  223. package/dist/cli/cli-name.js +11 -11
  224. package/dist/cli/cli-utils.js +13 -3
  225. package/dist/cli/command-format.js +1 -1
  226. package/dist/cli/config-cli.js +1 -1
  227. package/dist/cli/daemon-cli/lifecycle-core.js +31 -19
  228. package/dist/cli/daemon-cli/lifecycle.js +64 -2
  229. package/dist/cli/daemon-cli/restart-health.js +126 -0
  230. package/dist/cli/daemon-cli/status.gather.js +9 -13
  231. package/dist/cli/daemon-cli/status.print.js +2 -10
  232. package/dist/cli/deps.js +27 -22
  233. package/dist/cli/exec-approvals-cli.js +92 -124
  234. package/dist/cli/gateway-cli/run-loop.js +23 -5
  235. package/dist/cli/memory-cli.js +158 -61
  236. package/dist/cli/node-cli/register.js +14 -5
  237. package/dist/cli/nodes-cli/register.push.js +63 -0
  238. package/dist/cli/nodes-media-utils.js +26 -0
  239. package/dist/cli/outbound-send-deps.js +2 -9
  240. package/dist/cli/outbound-send-mapping.js +11 -0
  241. package/dist/cli/pairing-cli.js +40 -14
  242. package/dist/cli/plugins-cli.js +250 -73
  243. package/dist/cli/ports.js +11 -10
  244. package/dist/cli/program/build-program.js +3 -1
  245. package/dist/cli/program/command-registry.js +214 -136
  246. package/dist/cli/program/command-tree.js +16 -0
  247. package/dist/cli/program/help.js +43 -12
  248. package/dist/cli/program/preaction.js +13 -9
  249. package/dist/cli/program/register.configure.js +3 -18
  250. package/dist/cli/program/register.maintenance.js +2 -2
  251. package/dist/cli/program/register.onboard.js +2 -0
  252. package/dist/cli/program/register.status-health-sessions.js +16 -17
  253. package/dist/cli/program/register.subclis.js +93 -52
  254. package/dist/cli/route.js +12 -8
  255. package/dist/cli/system-cli.js +36 -46
  256. package/dist/cli/test-runtime-capture.js +24 -0
  257. package/dist/cli/update-cli/shared.js +22 -9
  258. package/dist/cli/update-cli/update-command.js +89 -14
  259. package/dist/cli/update-cli/wizard.js +6 -12
  260. package/dist/commands/agent/run-context.js +18 -5
  261. package/dist/commands/agent/session-store.js +17 -4
  262. package/dist/commands/agent.js +185 -89
  263. package/dist/commands/agents.bindings.js +14 -7
  264. package/dist/commands/agents.commands.add.js +13 -9
  265. package/dist/commands/agents.commands.identity.js +12 -6
  266. package/dist/commands/agents.commands.list.js +11 -6
  267. package/dist/commands/agents.config.js +8 -10
  268. package/dist/commands/agents.providers.js +12 -6
  269. package/dist/commands/auth-choice-options.js +103 -75
  270. package/dist/commands/auth-choice.apply.byteplus.js +55 -0
  271. package/dist/commands/auth-choice.apply.js +4 -0
  272. package/dist/commands/auth-choice.apply.minimax.js +61 -13
  273. package/dist/commands/auth-choice.apply.openai.js +3 -1
  274. package/dist/commands/auth-choice.apply.volcengine.js +55 -0
  275. package/dist/commands/auth-choice.preferred-provider.js +2 -0
  276. package/dist/commands/channels/remove.js +13 -6
  277. package/dist/commands/channels/shared.js +4 -14
  278. package/dist/commands/channels.mock-harness.js +23 -0
  279. package/dist/commands/configure.commands.js +14 -0
  280. package/dist/commands/configure.gateway.js +2 -4
  281. package/dist/commands/configure.js +1 -1
  282. package/dist/commands/configure.shared.js +11 -0
  283. package/dist/commands/daemon-install-helpers.js +2 -2
  284. package/dist/commands/daemon-install-runtime-warning.js +11 -0
  285. package/dist/commands/dashboard.js +12 -10
  286. package/dist/commands/docs.js +14 -8
  287. package/dist/commands/doctor-config-flow.js +11 -9
  288. package/dist/commands/doctor-legacy-config.js +281 -0
  289. package/dist/commands/doctor-state-integrity.js +99 -23
  290. package/dist/commands/doctor-update.js +12 -9
  291. package/dist/commands/models/list.list-command.js +7 -5
  292. package/dist/commands/models/set-image.js +2 -21
  293. package/dist/commands/node-daemon-install-helpers.js +10 -8
  294. package/dist/commands/onboard-auth.config-minimax.js +54 -80
  295. package/dist/commands/onboard-auth.config-opencode.js +2 -18
  296. package/dist/commands/onboard-auth.credentials.js +90 -13
  297. package/dist/commands/onboard-auth.js +1 -1
  298. package/dist/commands/onboard-auth.models.js +6 -5
  299. package/dist/commands/onboard-hooks.js +1 -1
  300. package/dist/commands/onboard-non-interactive/api-keys.js +14 -7
  301. package/dist/commands/onboard-non-interactive/local/auth-choice.js +64 -49
  302. package/dist/commands/onboard-provider-auth-flags.js +14 -0
  303. package/dist/commands/onboard-remote.js +14 -7
  304. package/dist/commands/onboard.js +11 -13
  305. package/dist/commands/sandbox-display.js +6 -5
  306. package/dist/commands/sessions.test-helpers.js +61 -0
  307. package/dist/commands/status-all/diagnosis.js +14 -10
  308. package/dist/commands/status-all/format.js +1 -0
  309. package/dist/commands/status.gateway-probe.js +1 -16
  310. package/dist/commands/systemd-linger.js +12 -6
  311. package/dist/config/agent-limits.js +2 -0
  312. package/dist/config/commands.js +32 -15
  313. package/dist/config/config-paths.js +9 -11
  314. package/dist/config/config.js +1 -1
  315. package/dist/config/defaults.js +22 -2
  316. package/dist/config/discord-preview-streaming.js +104 -0
  317. package/dist/config/env-substitution.js +62 -34
  318. package/dist/config/env-vars.js +45 -7
  319. package/dist/config/includes.js +4 -0
  320. package/dist/config/io.js +656 -171
  321. package/dist/config/legacy.migrations.part-1.js +189 -78
  322. package/dist/config/legacy.shared.js +3 -1
  323. package/dist/config/merge-patch.js +54 -4
  324. package/dist/config/prototype-keys.js +4 -0
  325. package/dist/config/redact-snapshot.js +404 -76
  326. package/dist/config/schema.help.js +44 -7
  327. package/dist/config/schema.js +58 -570
  328. package/dist/config/schema.labels.js +38 -6
  329. package/dist/config/sessions/delivery-info.js +10 -3
  330. package/dist/config/sessions/main-session.js +10 -5
  331. package/dist/config/sessions/session-file.js +33 -0
  332. package/dist/config/sessions/session-key.js +10 -5
  333. package/dist/config/sessions/store.js +1 -1
  334. package/dist/config/sessions.js +1 -0
  335. package/dist/config/validation.js +140 -85
  336. package/dist/config/zod-schema.agent-runtime.js +11 -0
  337. package/dist/config/zod-schema.hooks.js +40 -11
  338. package/dist/config/zod-schema.installs.js +20 -0
  339. package/dist/config/zod-schema.js +156 -20
  340. package/dist/config/zod-schema.providers-core.js +78 -4
  341. package/dist/config/zod-schema.providers.js +6 -1
  342. package/dist/config/zod-schema.session.js +41 -2
  343. package/dist/cron/run-log.js +3 -0
  344. package/dist/cron/schedule.js +21 -10
  345. package/dist/cron/service/ops.js +35 -21
  346. package/dist/cron/service/timer.js +116 -16
  347. package/dist/cron/stagger.js +3 -1
  348. package/dist/daemon/cmd-argv.js +21 -0
  349. package/dist/daemon/cmd-set.js +58 -0
  350. package/dist/daemon/service-types.js +1 -0
  351. package/dist/discord/api.js +12 -6
  352. package/dist/discord/draft-chunking.js +22 -0
  353. package/dist/discord/draft-stream.js +124 -0
  354. package/dist/discord/monitor/agent-components.js +1 -1
  355. package/dist/discord/monitor/commands.js +5 -0
  356. package/dist/discord/monitor/exec-approvals.js +357 -162
  357. package/dist/discord/monitor/gateway-plugin.js +2 -1
  358. package/dist/discord/monitor/listeners.js +37 -27
  359. package/dist/discord/monitor/message-handler.js +4 -1
  360. package/dist/discord/monitor/message-handler.preflight.js +65 -8
  361. package/dist/discord/monitor/message-handler.process.js +246 -217
  362. package/dist/discord/monitor/message-utils.js +143 -6
  363. package/dist/discord/monitor/model-picker-preferences.js +143 -0
  364. package/dist/discord/monitor/model-picker.js +651 -0
  365. package/dist/discord/monitor/native-command.js +573 -16
  366. package/dist/discord/monitor/provider.allowlist.js +223 -0
  367. package/dist/discord/monitor/provider.js +275 -347
  368. package/dist/discord/monitor/provider.lifecycle.js +100 -0
  369. package/dist/discord/monitor/reply-delivery.js +123 -16
  370. package/dist/discord/monitor/thread-bindings.discord-api.js +215 -0
  371. package/dist/discord/monitor/thread-bindings.js +4 -0
  372. package/dist/discord/monitor/thread-bindings.lifecycle.js +177 -0
  373. package/dist/discord/monitor/thread-bindings.manager.js +423 -0
  374. package/dist/discord/monitor/thread-bindings.messages.js +55 -0
  375. package/dist/discord/monitor/thread-bindings.state.js +358 -0
  376. package/dist/discord/monitor/thread-bindings.types.js +6 -0
  377. package/dist/discord/resolve-users.js +33 -21
  378. package/dist/discord/send.channels.js +15 -0
  379. package/dist/discord/send.js +3 -2
  380. package/dist/discord/send.outbound.js +82 -26
  381. package/dist/discord/send.permissions.js +83 -30
  382. package/dist/discord/send.reactions.js +8 -4
  383. package/dist/discord/token.js +10 -5
  384. package/dist/discord/voice/command.js +263 -0
  385. package/dist/discord/voice/manager.js +531 -0
  386. package/dist/gateway/auth.js +72 -13
  387. package/dist/gateway/call.js +152 -83
  388. package/dist/gateway/canvas-capability.js +75 -0
  389. package/dist/gateway/client.js +28 -4
  390. package/dist/gateway/config-reload.js +3 -4
  391. package/dist/gateway/control-plane-audit.js +28 -0
  392. package/dist/gateway/control-plane-rate-limit.js +53 -0
  393. package/dist/gateway/control-ui.js +219 -96
  394. package/dist/gateway/events.js +1 -0
  395. package/dist/gateway/hooks-mapping.js +88 -38
  396. package/dist/gateway/hooks.js +109 -54
  397. package/dist/gateway/http-auth-helpers.js +3 -2
  398. package/dist/gateway/http-common.js +22 -0
  399. package/dist/gateway/http-endpoint-helpers.js +1 -0
  400. package/dist/gateway/method-scopes.js +169 -0
  401. package/dist/gateway/net.js +74 -9
  402. package/dist/gateway/node-invoke-system-run-approval.js +14 -35
  403. package/dist/gateway/node-registry.js +10 -5
  404. package/dist/gateway/openai-http.js +1 -0
  405. package/dist/gateway/openresponses-http.js +121 -110
  406. package/dist/gateway/origin-check.js +1 -18
  407. package/dist/gateway/probe-auth.js +2 -0
  408. package/dist/gateway/protocol/index.js +4 -2
  409. package/dist/gateway/protocol/schema/cron.js +1 -0
  410. package/dist/gateway/protocol/schema/devices.js +1 -0
  411. package/dist/gateway/protocol/schema/protocol-schemas.js +4 -1
  412. package/dist/gateway/protocol/schema/push.js +18 -0
  413. package/dist/gateway/protocol/schema/sessions.js +6 -0
  414. package/dist/gateway/protocol/schema.js +1 -0
  415. package/dist/gateway/role-policy.js +17 -0
  416. package/dist/gateway/server/ws-connection/connect-policy.js +37 -0
  417. package/dist/gateway/server/ws-connection/message-handler.js +175 -148
  418. package/dist/gateway/server-chat.js +83 -25
  419. package/dist/gateway/server-constants.js +10 -9
  420. package/dist/gateway/server-cron.js +1 -0
  421. package/dist/gateway/server-http.js +247 -54
  422. package/dist/gateway/server-maintenance.js +20 -5
  423. package/dist/gateway/server-methods/agent.js +162 -24
  424. package/dist/gateway/server-methods/chat.js +465 -130
  425. package/dist/gateway/server-methods/config.js +193 -152
  426. package/dist/gateway/server-methods/devices.js +17 -3
  427. package/dist/gateway/server-methods/models.js +11 -1
  428. package/dist/gateway/server-methods/nodes.helpers.js +12 -0
  429. package/dist/gateway/server-methods/nodes.js +251 -69
  430. package/dist/gateway/server-methods/push.js +53 -0
  431. package/dist/gateway/server-methods/sessions.js +64 -8
  432. package/dist/gateway/server-methods/usage.js +162 -75
  433. package/dist/gateway/server-node-events.js +29 -0
  434. package/dist/gateway/server-reload-handlers.js +2 -3
  435. package/dist/gateway/server-runtime-config.js +39 -13
  436. package/dist/gateway/server-runtime-state.js +2 -0
  437. package/dist/gateway/server-startup-memory.js +17 -11
  438. package/dist/gateway/server-ws-runtime.js +1 -0
  439. package/dist/gateway/server.impl.js +296 -139
  440. package/dist/gateway/session-preview.test-helpers.js +11 -0
  441. package/dist/gateway/session-utils.fs.js +32 -34
  442. package/dist/gateway/sessions-resolve.js +17 -5
  443. package/dist/gateway/startup-auth.js +126 -0
  444. package/dist/gateway/test-helpers.agent-results.js +15 -0
  445. package/dist/gateway/test-helpers.mocks.js +37 -14
  446. package/dist/gateway/test-helpers.openai-mock.js +14 -7
  447. package/dist/gateway/test-helpers.server.js +161 -77
  448. package/dist/gateway/tools-invoke-http.js +21 -10
  449. package/dist/hooks/bundled/bootstrap-extra-files/handler.js +3 -1
  450. package/dist/hooks/bundled/command-logger/handler.js +7 -2
  451. package/dist/hooks/bundled/session-memory/handler.js +170 -38
  452. package/dist/hooks/frontmatter.js +6 -6
  453. package/dist/hooks/gmail-watcher-lifecycle.js +23 -0
  454. package/dist/hooks/gmail-watcher.js +11 -6
  455. package/dist/hooks/internal-hooks.js +11 -1
  456. package/dist/hooks/llm-slug-generator.js +4 -1
  457. package/dist/hooks/workspace.js +47 -17
  458. package/dist/imessage/accounts.js +9 -20
  459. package/dist/imessage/monitor/inbound-processing.js +2 -1
  460. package/dist/infra/archive-path.js +49 -0
  461. package/dist/infra/archive.js +174 -73
  462. package/dist/infra/control-ui-assets.js +14 -6
  463. package/dist/infra/device-pairing.js +204 -144
  464. package/dist/infra/env.js +10 -5
  465. package/dist/infra/exec-approvals-allowlist.js +141 -70
  466. package/dist/infra/exec-approvals-analysis.js +78 -20
  467. package/dist/infra/exec-approvals.js +5 -17
  468. package/dist/infra/exec-safe-bin-policy.js +277 -0
  469. package/dist/infra/fixed-window-rate-limit.js +33 -0
  470. package/dist/infra/fs-safe.js +71 -39
  471. package/dist/infra/gateway-lock.js +6 -2
  472. package/dist/infra/git-root.js +61 -0
  473. package/dist/infra/heartbeat-active-hours.js +2 -2
  474. package/dist/infra/heartbeat-reason.js +40 -0
  475. package/dist/infra/heartbeat-runner.js +72 -32
  476. package/dist/infra/heartbeat-wake.js +6 -12
  477. package/dist/infra/host-env-security-policy.json +19 -0
  478. package/dist/infra/host-env-security.js +66 -0
  479. package/dist/infra/install-source-utils.js +91 -7
  480. package/dist/infra/net/ssrf.js +131 -38
  481. package/dist/infra/node-pairing.js +50 -105
  482. package/dist/infra/npm-integrity.js +45 -0
  483. package/dist/infra/npm-pack-install.js +40 -0
  484. package/dist/infra/outbound/bound-delivery-router.js +88 -0
  485. package/dist/infra/outbound/channel-adapters.js +20 -7
  486. package/dist/infra/outbound/channel-selection.js +12 -6
  487. package/dist/infra/outbound/envelope.js +1 -1
  488. package/dist/infra/outbound/format.js +12 -6
  489. package/dist/infra/outbound/message-action-runner.js +107 -327
  490. package/dist/infra/outbound/message.js +59 -36
  491. package/dist/infra/outbound/outbound-policy.js +52 -25
  492. package/dist/infra/outbound/outbound-send-service.js +58 -71
  493. package/dist/infra/outbound/payloads.js +14 -7
  494. package/dist/infra/outbound/session-binding-service.js +123 -0
  495. package/dist/infra/pairing-files.js +10 -0
  496. package/dist/infra/path-guards.js +25 -0
  497. package/dist/infra/plain-object.js +9 -0
  498. package/dist/infra/provider-usage.fetch.codex.js +7 -15
  499. package/dist/infra/provider-usage.fetch.gemini.js +14 -11
  500. package/dist/infra/provider-usage.fetch.shared.js +30 -1
  501. package/dist/infra/provider-usage.fetch.zai.js +10 -9
  502. package/dist/infra/push-apns.js +365 -0
  503. package/dist/infra/restart-sentinel.js +16 -1
  504. package/dist/infra/restart.js +229 -26
  505. package/dist/infra/retry-policy.js +4 -2
  506. package/dist/infra/retry.js +9 -5
  507. package/dist/infra/scp-host.js +54 -0
  508. package/dist/infra/session-cost-usage.js +107 -59
  509. package/dist/infra/session-maintenance-warning.js +3 -1
  510. package/dist/infra/shell-env.js +98 -34
  511. package/dist/infra/ssh-config.js +12 -6
  512. package/dist/infra/system-run-command.js +49 -4
  513. package/dist/infra/update-channels.js +10 -5
  514. package/dist/infra/update-startup.js +86 -9
  515. package/dist/line/accounts.js +5 -7
  516. package/dist/line/bot-access.js +8 -20
  517. package/dist/line/bot-handlers.js +3 -1
  518. package/dist/link-understanding/detect.js +15 -7
  519. package/dist/media/constants.js +15 -6
  520. package/dist/media/image-ops.js +7 -0
  521. package/dist/media/inbound-path-policy.js +114 -0
  522. package/dist/media/input-files.js +16 -0
  523. package/dist/media/local-roots.js +3 -2
  524. package/dist/media-understanding/apply.js +4 -1
  525. package/dist/media-understanding/concurrency.js +8 -20
  526. package/dist/memory/backend-config.js +45 -6
  527. package/dist/memory/embeddings.js +10 -4
  528. package/dist/memory/fs-utils.js +23 -0
  529. package/dist/memory/manager-search.js +12 -6
  530. package/dist/memory/manager-sync-ops.js +12 -2
  531. package/dist/memory/qmd-manager.js +466 -53
  532. package/dist/memory/query-expansion.js +167 -3
  533. package/dist/memory/status-format.js +10 -5
  534. package/dist/memory/sync-memory-files.js +1 -1
  535. package/dist/memory/test-manager.js +8 -0
  536. package/dist/node-host/invoke-system-run.js +281 -0
  537. package/dist/node-host/invoke.js +55 -337
  538. package/dist/pairing/pairing-store.js +22 -0
  539. package/dist/plugin-sdk/allow-from.js +1 -1
  540. package/dist/plugin-sdk/command-auth.js +3 -1
  541. package/dist/plugin-sdk/index.js +6 -3
  542. package/dist/plugin-sdk/temp-path.js +47 -0
  543. package/dist/plugin-sdk/webhook-targets.js +32 -0
  544. package/dist/plugins/bundled-dir.js +9 -6
  545. package/dist/plugins/discovery.js +217 -23
  546. package/dist/plugins/hook-runner-global.js +16 -0
  547. package/dist/plugins/hooks.js +50 -0
  548. package/dist/plugins/install.js +28 -16
  549. package/dist/plugins/loader.js +192 -26
  550. package/dist/plugins/logger.js +8 -0
  551. package/dist/plugins/manifest-registry.js +3 -0
  552. package/dist/plugins/path-safety.js +34 -0
  553. package/dist/plugins/registry.js +5 -2
  554. package/dist/plugins/runtime/index.js +271 -206
  555. package/dist/plugins/runtime.js +3 -17
  556. package/dist/plugins/update.js +78 -12
  557. package/dist/process/spawn-utils.js +14 -7
  558. package/dist/providers/github-copilot-models.js +4 -1
  559. package/dist/providers/github-copilot-token.js +11 -6
  560. package/dist/providers/qwen-portal-oauth.js +14 -6
  561. package/dist/routing/account-id.js +30 -0
  562. package/dist/routing/resolve-route.js +3 -7
  563. package/dist/routing/session-key.js +2 -16
  564. package/dist/security/audit-channel.js +100 -20
  565. package/dist/security/audit-extra.async.js +505 -179
  566. package/dist/security/audit-extra.js +12 -2
  567. package/dist/security/audit-extra.sync.js +421 -35
  568. package/dist/security/audit-fs.js +31 -13
  569. package/dist/security/audit.js +180 -370
  570. package/dist/security/dm-policy-shared.js +68 -0
  571. package/dist/security/external-content.js +46 -14
  572. package/dist/security/fix.js +49 -85
  573. package/dist/security/scan-paths.js +20 -0
  574. package/dist/security/secret-equal.js +3 -7
  575. package/dist/security/windows-acl.js +30 -15
  576. package/dist/shared/entry-status.js +6 -0
  577. package/dist/shared/frontmatter.js +5 -5
  578. package/dist/shared/node-list-parse.js +13 -0
  579. package/dist/shared/node-match.js +11 -4
  580. package/dist/shared/operator-scope-compat.js +42 -0
  581. package/dist/shared/text-chunking.js +29 -0
  582. package/dist/signal/accounts.js +7 -20
  583. package/dist/signal/monitor/event-handler.js +3 -1
  584. package/dist/slack/accounts.js +6 -19
  585. package/dist/slack/actions.js +11 -3
  586. package/dist/slack/blocks.test-helpers.js +31 -0
  587. package/dist/slack/monitor/auth.js +1 -1
  588. package/dist/slack/monitor/message-handler/dispatch.js +50 -29
  589. package/dist/slack/monitor/mrkdwn.js +8 -0
  590. package/dist/slack/monitor/replies.js +15 -7
  591. package/dist/slack/monitor/slash.js +22 -13
  592. package/dist/slack/resolve-channels.js +10 -5
  593. package/dist/slack/send.js +102 -12
  594. package/dist/slack/stream-mode.js +10 -0
  595. package/dist/slack/streaming.js +4 -2
  596. package/dist/telegram/accounts.js +19 -14
  597. package/dist/telegram/bot/helpers.js +3 -5
  598. package/dist/telegram/bot-access.js +35 -36
  599. package/dist/telegram/bot-handlers.js +120 -148
  600. package/dist/telegram/bot-message-context.js +68 -9
  601. package/dist/telegram/bot-message-dispatch.js +477 -210
  602. package/dist/telegram/bot-native-commands.js +16 -0
  603. package/dist/telegram/draft-stream.js +44 -8
  604. package/dist/telegram/inline-buttons.js +5 -15
  605. package/dist/telegram/monitor.js +11 -7
  606. package/dist/telegram/network-config.js +19 -7
  607. package/dist/telegram/reasoning-lane-coordinator.js +128 -0
  608. package/dist/telegram/send.js +3 -2
  609. package/dist/telegram/sent-message-cache.js +5 -6
  610. package/dist/telegram/status-reaction-variants.js +208 -0
  611. package/dist/telegram/sticker-cache.js +11 -9
  612. package/dist/terminal/prompt-select-styled.js +9 -0
  613. package/dist/terminal/theme.js +12 -12
  614. package/dist/test-utils/command-runner.js +6 -0
  615. package/dist/test-utils/internal-hook-event-payload.js +10 -0
  616. package/dist/test-utils/model-auth-mock.js +12 -0
  617. package/dist/test-utils/provider-usage-fetch.js +14 -0
  618. package/dist/test-utils/temp-home.js +33 -0
  619. package/dist/tts/tts.js +80 -567
  620. package/dist/tui/components/chat-log.js +50 -8
  621. package/dist/tui/theme/theme.js +10 -12
  622. package/dist/tui/tui-command-handlers.js +36 -27
  623. package/dist/tui/tui-event-handlers.js +122 -32
  624. package/dist/tui/tui-local-shell.js +16 -6
  625. package/dist/tui/tui.js +236 -48
  626. package/dist/utils/account-id.js +2 -4
  627. package/dist/utils/boolean.js +10 -5
  628. package/dist/utils/directive-tags.js +11 -0
  629. package/dist/utils/mask-api-key.js +10 -0
  630. package/dist/utils/queue-helpers.js +67 -12
  631. package/dist/utils/run-with-concurrency.js +39 -0
  632. package/dist/web/auto-reply/deliver-reply.js +8 -4
  633. package/dist/web/auto-reply/mentions.js +10 -5
  634. package/dist/web/auto-reply/monitor/group-members.js +14 -7
  635. package/dist/web/auto-reply/monitor/process-message.js +45 -24
  636. package/dist/web/inbound/access-control.js +5 -2
  637. package/dist/web/login-qr.js +12 -6
  638. package/dist/web/media.js +126 -15
  639. package/docs/tools/slash-commands.md +5 -1
  640. package/extensions/bluebubbles/src/monitor-processing.ts +580 -139
  641. package/extensions/bluebubbles/src/monitor.ts +208 -1950
  642. package/extensions/feishu/src/external-keys.ts +19 -0
  643. package/extensions/lobster/src/windows-spawn.ts +193 -0
  644. package/extensions/matrix/src/matrix/actions/limits.ts +6 -0
  645. package/extensions/mattermost/src/mattermost/reactions.test-helpers.ts +83 -0
  646. package/package.json +1 -1
@@ -1,254 +1,35 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import type { IncomingMessage, ServerResponse } from "node:http";
2
-
3
- import type { PoolbotConfig } from "poolbot/plugin-sdk";
3
+ import type { PoolBotConfig } from "poolbot/plugin-sdk";
4
4
  import {
5
- logAckFailure,
6
- logInboundDrop,
7
- logTypingFailure,
8
- resolveAckReaction,
9
- resolveControlCommandGate,
5
+ isRequestBodyLimitError,
6
+ readRequestBodyWithLimit,
7
+ registerWebhookTarget,
8
+ rejectNonPostWebhookRequest,
9
+ requestBodyErrorToText,
10
+ resolveSingleWebhookTarget,
11
+ resolveWebhookTargets,
10
12
  } from "poolbot/plugin-sdk";
11
- import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
12
- import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
13
- import { downloadBlueBubblesAttachment } from "./attachments.js";
14
- import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
15
- import { sendBlueBubblesMedia } from "./media-send.js";
16
- import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
17
- import type { ResolvedBlueBubblesAccount } from "./accounts.js";
18
- import { getBlueBubblesRuntime } from "./runtime.js";
19
- import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
13
+ import {
14
+ normalizeWebhookMessage,
15
+ normalizeWebhookReaction,
16
+ type NormalizedWebhookMessage,
17
+ } from "./monitor-normalize.js";
18
+ import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
19
+ import {
20
+ _resetBlueBubblesShortIdState,
21
+ resolveBlueBubblesMessageId,
22
+ } from "./monitor-reply-cache.js";
23
+ import {
24
+ DEFAULT_WEBHOOK_PATH,
25
+ normalizeWebhookPath,
26
+ resolveWebhookPathFromConfig,
27
+ type BlueBubblesCoreRuntime,
28
+ type BlueBubblesMonitorOptions,
29
+ type WebhookTarget,
30
+ } from "./monitor-shared.js";
20
31
  import { fetchBlueBubblesServerInfo } from "./probe.js";
21
-
22
- export type BlueBubblesRuntimeEnv = {
23
- log?: (message: string) => void;
24
- error?: (message: string) => void;
25
- };
26
-
27
- export type BlueBubblesMonitorOptions = {
28
- account: ResolvedBlueBubblesAccount;
29
- config: PoolbotConfig;
30
- runtime: BlueBubblesRuntimeEnv;
31
- abortSignal: AbortSignal;
32
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
33
- webhookPath?: string;
34
- };
35
-
36
- const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
37
- const DEFAULT_TEXT_LIMIT = 4000;
38
- const invalidAckReactions = new Set<string>();
39
-
40
- const REPLY_CACHE_MAX = 2000;
41
- const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
42
-
43
- type BlueBubblesReplyCacheEntry = {
44
- accountId: string;
45
- messageId: string;
46
- shortId: string;
47
- chatGuid?: string;
48
- chatIdentifier?: string;
49
- chatId?: number;
50
- senderLabel?: string;
51
- body?: string;
52
- timestamp: number;
53
- };
54
-
55
- // Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
56
- const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
57
-
58
- // Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
59
- const blueBubblesShortIdToUuid = new Map<string, string>();
60
- const blueBubblesUuidToShortId = new Map<string, string>();
61
- let blueBubblesShortIdCounter = 0;
62
-
63
- function trimOrUndefined(value?: string | null): string | undefined {
64
- const trimmed = value?.trim();
65
- return trimmed ? trimmed : undefined;
66
- }
67
-
68
- function generateShortId(): string {
69
- blueBubblesShortIdCounter += 1;
70
- return String(blueBubblesShortIdCounter);
71
- }
72
-
73
- function rememberBlueBubblesReplyCache(
74
- entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
75
- ): BlueBubblesReplyCacheEntry {
76
- const messageId = entry.messageId.trim();
77
- if (!messageId) {
78
- return { ...entry, shortId: "" };
79
- }
80
-
81
- // Check if we already have a short ID for this GUID
82
- let shortId = blueBubblesUuidToShortId.get(messageId);
83
- if (!shortId) {
84
- shortId = generateShortId();
85
- blueBubblesShortIdToUuid.set(shortId, messageId);
86
- blueBubblesUuidToShortId.set(messageId, shortId);
87
- }
88
-
89
- const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
90
-
91
- // Refresh insertion order.
92
- blueBubblesReplyCacheByMessageId.delete(messageId);
93
- blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
94
-
95
- // Opportunistic prune.
96
- const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
97
- for (const [key, value] of blueBubblesReplyCacheByMessageId) {
98
- if (value.timestamp < cutoff) {
99
- blueBubblesReplyCacheByMessageId.delete(key);
100
- // Clean up short ID mappings for expired entries
101
- if (value.shortId) {
102
- blueBubblesShortIdToUuid.delete(value.shortId);
103
- blueBubblesUuidToShortId.delete(key);
104
- }
105
- continue;
106
- }
107
- break;
108
- }
109
- while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
110
- const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
111
- if (!oldest) break;
112
- const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
113
- blueBubblesReplyCacheByMessageId.delete(oldest);
114
- // Clean up short ID mappings for evicted entries
115
- if (oldEntry?.shortId) {
116
- blueBubblesShortIdToUuid.delete(oldEntry.shortId);
117
- blueBubblesUuidToShortId.delete(oldest);
118
- }
119
- }
120
-
121
- return fullEntry;
122
- }
123
-
124
- /**
125
- * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
126
- * Returns the input unchanged if it's already a GUID or not found in the mapping.
127
- */
128
- export function resolveBlueBubblesMessageId(
129
- shortOrUuid: string,
130
- opts?: { requireKnownShortId?: boolean },
131
- ): string {
132
- const trimmed = shortOrUuid.trim();
133
- if (!trimmed) return trimmed;
134
-
135
- // If it looks like a short ID (numeric), try to resolve it
136
- if (/^\d+$/.test(trimmed)) {
137
- const uuid = blueBubblesShortIdToUuid.get(trimmed);
138
- if (uuid) return uuid;
139
- if (opts?.requireKnownShortId) {
140
- throw new Error(
141
- `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
142
- );
143
- }
144
- }
145
-
146
- // Return as-is (either already a UUID or not found)
147
- return trimmed;
148
- }
149
-
150
- /**
151
- * Resets the short ID state. Only use in tests.
152
- * @internal
153
- */
154
- export function _resetBlueBubblesShortIdState(): void {
155
- blueBubblesShortIdToUuid.clear();
156
- blueBubblesUuidToShortId.clear();
157
- blueBubblesReplyCacheByMessageId.clear();
158
- blueBubblesShortIdCounter = 0;
159
- }
160
-
161
- /**
162
- * Gets the short ID for a message GUID, if one exists.
163
- */
164
- function getShortIdForUuid(uuid: string): string | undefined {
165
- return blueBubblesUuidToShortId.get(uuid.trim());
166
- }
167
-
168
- function resolveReplyContextFromCache(params: {
169
- accountId: string;
170
- replyToId: string;
171
- chatGuid?: string;
172
- chatIdentifier?: string;
173
- chatId?: number;
174
- }): BlueBubblesReplyCacheEntry | null {
175
- const replyToId = params.replyToId.trim();
176
- if (!replyToId) return null;
177
-
178
- const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
179
- if (!cached) return null;
180
- if (cached.accountId !== params.accountId) return null;
181
-
182
- const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
183
- if (cached.timestamp < cutoff) {
184
- blueBubblesReplyCacheByMessageId.delete(replyToId);
185
- return null;
186
- }
187
-
188
- const chatGuid = trimOrUndefined(params.chatGuid);
189
- const chatIdentifier = trimOrUndefined(params.chatIdentifier);
190
- const cachedChatGuid = trimOrUndefined(cached.chatGuid);
191
- const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
192
- const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
193
- const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
194
-
195
- // Avoid cross-chat collisions if we have identifiers.
196
- if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
197
- if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) {
198
- return null;
199
- }
200
- if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
201
- return null;
202
- }
203
-
204
- return cached;
205
- }
206
-
207
- type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
208
-
209
- function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
210
- if (core.logging.shouldLogVerbose()) {
211
- runtime.log?.(`[bluebubbles] ${message}`);
212
- }
213
- }
214
-
215
- function logGroupAllowlistHint(params: {
216
- runtime: BlueBubblesRuntimeEnv;
217
- reason: string;
218
- entry: string | null;
219
- chatName?: string;
220
- accountId?: string;
221
- }): void {
222
- const log = params.runtime.log ?? console.log;
223
- const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
224
- const accountHint = params.accountId
225
- ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
226
- : "";
227
- if (params.entry) {
228
- log(
229
- `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
230
- `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
231
- );
232
- log(
233
- `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
234
- );
235
- return;
236
- }
237
- log(
238
- `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
239
- `channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
240
- `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
241
- );
242
- }
243
-
244
- type WebhookTarget = {
245
- account: ResolvedBlueBubblesAccount;
246
- config: PoolbotConfig;
247
- runtime: BlueBubblesRuntimeEnv;
248
- core: BlueBubblesCoreRuntime;
249
- path: string;
250
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
251
- };
32
+ import { getBlueBubblesRuntime } from "./runtime.js";
252
33
 
253
34
  /**
254
35
  * Entry type for debouncing inbound messages.
@@ -264,7 +45,7 @@ type BlueBubblesDebounceEntry = {
264
45
  * This helps combine URL text + link preview balloon messages that BlueBubbles
265
46
  * sends as separate webhook events when no explicit inbound debounce config exists.
266
47
  */
267
- const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
48
+ const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
268
49
 
269
50
  /**
270
51
  * Combines multiple debounced messages into a single message for processing.
@@ -284,13 +65,17 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
284
65
  // Combine text from all entries, filtering out duplicates and empty strings
285
66
  const seenTexts = new Set<string>();
286
67
  const textParts: string[] = [];
287
-
68
+
288
69
  for (const entry of entries) {
289
70
  const text = entry.message.text.trim();
290
- if (!text) continue;
71
+ if (!text) {
72
+ continue;
73
+ }
291
74
  // Skip duplicate text (URL might be in both text message and balloon)
292
75
  const normalizedText = text.toLowerCase();
293
- if (seenTexts.has(normalizedText)) continue;
76
+ if (seenTexts.has(normalizedText)) {
77
+ continue;
78
+ }
294
79
  seenTexts.add(normalizedText);
295
80
  textParts.push(text);
296
81
  }
@@ -330,23 +115,27 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
330
115
 
331
116
  const webhookTargets = new Map<string, WebhookTarget[]>();
332
117
 
118
+ type BlueBubblesDebouncer = {
119
+ enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
120
+ flushKey: (key: string) => Promise<void>;
121
+ };
122
+
333
123
  /**
334
124
  * Maps webhook targets to their inbound debouncers.
335
125
  * Each target gets its own debouncer keyed by a unique identifier.
336
126
  */
337
- const targetDebouncers = new Map<
338
- WebhookTarget,
339
- ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]>
340
- >();
127
+ const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
341
128
 
342
129
  function resolveBlueBubblesDebounceMs(
343
- config: PoolbotConfig,
130
+ config: PoolBotConfig,
344
131
  core: BlueBubblesCoreRuntime,
345
132
  ): number {
346
133
  const inbound = config.messages?.inbound;
347
134
  const hasExplicitDebounce =
348
135
  typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
349
- if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
136
+ if (!hasExplicitDebounce) {
137
+ return DEFAULT_INBOUND_DEBOUNCE_MS;
138
+ }
350
139
  return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
351
140
  }
352
141
 
@@ -355,7 +144,9 @@ function resolveBlueBubblesDebounceMs(
355
144
  */
356
145
  function getOrCreateDebouncer(target: WebhookTarget) {
357
146
  const existing = targetDebouncers.get(target);
358
- if (existing) return existing;
147
+ if (existing) {
148
+ return existing;
149
+ }
359
150
 
360
151
  const { account, config, runtime, core } = target;
361
152
 
@@ -363,7 +154,23 @@ function getOrCreateDebouncer(target: WebhookTarget) {
363
154
  debounceMs: resolveBlueBubblesDebounceMs(config, core),
364
155
  buildKey: (entry) => {
365
156
  const msg = entry.message;
366
- // Build key from account + chat + sender to coalesce messages from same source
157
+ // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
158
+ // same message (e.g., text-only then text+attachment).
159
+ //
160
+ // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
161
+ // messageId than the originating text. When present, key by associatedMessageGuid
162
+ // to keep text + balloon coalescing working.
163
+ const balloonBundleId = msg.balloonBundleId?.trim();
164
+ const associatedMessageGuid = msg.associatedMessageGuid?.trim();
165
+ if (balloonBundleId && associatedMessageGuid) {
166
+ return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
167
+ }
168
+
169
+ const messageId = msg.messageId?.trim();
170
+ if (messageId) {
171
+ return `bluebubbles:${account.accountId}:msg:${messageId}`;
172
+ }
173
+
367
174
  const chatKey =
368
175
  msg.chatGuid?.trim() ??
369
176
  msg.chatIdentifier?.trim() ??
@@ -372,21 +179,26 @@ function getOrCreateDebouncer(target: WebhookTarget) {
372
179
  },
373
180
  shouldDebounce: (entry) => {
374
181
  const msg = entry.message;
375
- // Skip debouncing for messages with attachments - process immediately
376
- if (msg.attachments && msg.attachments.length > 0) return false;
377
182
  // Skip debouncing for from-me messages (they're just cached, not processed)
378
- if (msg.fromMe) return false;
183
+ if (msg.fromMe) {
184
+ return false;
185
+ }
379
186
  // Skip debouncing for control commands - process immediately
380
- if (core.channel.text.hasControlCommand(msg.text, config)) return false;
381
- // Debounce normal text messages and URL balloon messages
187
+ if (core.channel.text.hasControlCommand(msg.text, config)) {
188
+ return false;
189
+ }
190
+ // Debounce all other messages to coalesce rapid-fire webhook events
191
+ // (e.g., text+image arriving as separate webhooks for the same messageId)
382
192
  return true;
383
193
  },
384
194
  onFlush: async (entries) => {
385
- if (entries.length === 0) return;
386
-
195
+ if (entries.length === 0) {
196
+ return;
197
+ }
198
+
387
199
  // Use target from first entry (all entries have same target due to key structure)
388
200
  const flushTarget = entries[0].target;
389
-
201
+
390
202
  if (entries.length === 1) {
391
203
  // Single message - process normally
392
204
  await processMessage(entries[0].message, flushTarget);
@@ -395,7 +207,7 @@ function getOrCreateDebouncer(target: WebhookTarget) {
395
207
 
396
208
  // Multiple messages - combine and process
397
209
  const combined = combineDebounceEntries(entries);
398
-
210
+
399
211
  if (core.logging.shouldLogVerbose()) {
400
212
  const count = entries.length;
401
213
  const preview = combined.text.slice(0, 50);
@@ -403,7 +215,7 @@ function getOrCreateDebouncer(target: WebhookTarget) {
403
215
  `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
404
216
  );
405
217
  }
406
-
218
+
407
219
  await processMessage(combined, flushTarget);
408
220
  },
409
221
  onError: (err) => {
@@ -422,868 +234,128 @@ function removeDebouncer(target: WebhookTarget): void {
422
234
  targetDebouncers.delete(target);
423
235
  }
424
236
 
425
- function normalizeWebhookPath(raw: string): string {
426
- const trimmed = raw.trim();
427
- if (!trimmed) return "/";
428
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
429
- if (withSlash.length > 1 && withSlash.endsWith("/")) {
430
- return withSlash.slice(0, -1);
431
- }
432
- return withSlash;
433
- }
434
-
435
237
  export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
436
- const key = normalizeWebhookPath(target.path);
437
- const normalizedTarget = { ...target, path: key };
438
- const existing = webhookTargets.get(key) ?? [];
439
- const next = [...existing, normalizedTarget];
440
- webhookTargets.set(key, next);
238
+ const registered = registerWebhookTarget(webhookTargets, target);
441
239
  return () => {
442
- const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
443
- if (updated.length > 0) {
444
- webhookTargets.set(key, updated);
445
- } else {
446
- webhookTargets.delete(key);
447
- }
240
+ registered.unregister();
448
241
  // Clean up debouncer when target is unregistered
449
- removeDebouncer(normalizedTarget);
242
+ removeDebouncer(registered.target);
450
243
  };
451
244
  }
452
245
 
453
- async function readJsonBody(req: IncomingMessage, maxBytes: number) {
454
- const chunks: Buffer[] = [];
455
- let total = 0;
456
- return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
457
- req.on("data", (chunk: Buffer) => {
458
- total += chunk.length;
459
- if (total > maxBytes) {
460
- resolve({ ok: false, error: "payload too large" });
461
- req.destroy();
462
- return;
463
- }
464
- chunks.push(chunk);
465
- });
466
- req.on("end", () => {
467
- try {
468
- const raw = Buffer.concat(chunks).toString("utf8");
469
- if (!raw.trim()) {
470
- resolve({ ok: false, error: "empty payload" });
471
- return;
472
- }
473
- try {
474
- resolve({ ok: true, value: JSON.parse(raw) as unknown });
475
- return;
476
- } catch {
477
- const params = new URLSearchParams(raw);
478
- const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
479
- if (payload) {
480
- resolve({ ok: true, value: JSON.parse(payload) as unknown });
481
- return;
482
- }
483
- throw new Error("invalid json");
484
- }
485
- } catch (err) {
486
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
487
- }
488
- });
489
- req.on("error", (err) => {
490
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
491
- });
492
- });
493
- }
494
-
495
- function asRecord(value: unknown): Record<string, unknown> | null {
496
- return value && typeof value === "object" && !Array.isArray(value)
497
- ? (value as Record<string, unknown>)
498
- : null;
499
- }
500
-
501
- function readString(record: Record<string, unknown> | null, key: string): string | undefined {
502
- if (!record) return undefined;
503
- const value = record[key];
504
- return typeof value === "string" ? value : undefined;
505
- }
246
+ type ReadBlueBubblesWebhookBodyResult =
247
+ | { ok: true; value: unknown }
248
+ | { ok: false; statusCode: number; error: string };
506
249
 
507
- function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
508
- if (!record) return undefined;
509
- const value = record[key];
510
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
511
- }
512
-
513
- function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
514
- if (!record) return undefined;
515
- const value = record[key];
516
- return typeof value === "boolean" ? value : undefined;
517
- }
518
-
519
- function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
520
- const raw = message["attachments"];
521
- if (!Array.isArray(raw)) return [];
522
- const out: BlueBubblesAttachment[] = [];
523
- for (const entry of raw) {
524
- const record = asRecord(entry);
525
- if (!record) continue;
526
- out.push({
527
- guid: readString(record, "guid"),
528
- uti: readString(record, "uti"),
529
- mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
530
- transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
531
- totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
532
- height: readNumberLike(record, "height"),
533
- width: readNumberLike(record, "width"),
534
- originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
535
- });
536
- }
537
- return out;
538
- }
539
-
540
- function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
541
- if (attachments.length === 0) return "";
542
- const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
543
- const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
544
- const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
545
- const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
546
- const tag = allImages
547
- ? "<media:image>"
548
- : allVideos
549
- ? "<media:video>"
550
- : allAudio
551
- ? "<media:audio>"
552
- : "<media:attachment>";
553
- const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
554
- const suffix = attachments.length === 1 ? label : `${label}s`;
555
- return `${tag} (${attachments.length} ${suffix})`;
556
- }
557
-
558
- function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
559
- const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
560
- if (attachmentPlaceholder) return attachmentPlaceholder;
561
- if (message.balloonBundleId) return "<media:sticker>";
562
- return "";
563
- }
564
-
565
- // Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
566
- function formatReplyTag(message: {
567
- replyToId?: string;
568
- replyToShortId?: string;
569
- }): string | null {
570
- // Prefer short ID
571
- const rawId = message.replyToShortId || message.replyToId;
572
- if (!rawId) return null;
573
- return `[[reply_to:${rawId}]]`;
574
- }
575
-
576
- function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
577
- if (!record) return undefined;
578
- const value = record[key];
579
- if (typeof value === "number" && Number.isFinite(value)) return value;
580
- if (typeof value === "string") {
581
- const parsed = Number.parseFloat(value);
582
- if (Number.isFinite(parsed)) return parsed;
250
+ function parseBlueBubblesWebhookPayload(
251
+ rawBody: string,
252
+ ): { ok: true; value: unknown } | { ok: false; error: string } {
253
+ const trimmed = rawBody.trim();
254
+ if (!trimmed) {
255
+ return { ok: false, error: "empty payload" };
583
256
  }
584
- return undefined;
585
- }
586
-
587
- function extractReplyMetadata(message: Record<string, unknown>): {
588
- replyToId?: string;
589
- replyToBody?: string;
590
- replyToSender?: string;
591
- } {
592
- const replyRaw =
593
- message["replyTo"] ??
594
- message["reply_to"] ??
595
- message["replyToMessage"] ??
596
- message["reply_to_message"] ??
597
- message["repliedMessage"] ??
598
- message["quotedMessage"] ??
599
- message["associatedMessage"] ??
600
- message["reply"];
601
- const replyRecord = asRecord(replyRaw);
602
- const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
603
- const replySenderRaw =
604
- readString(replyHandle, "address") ??
605
- readString(replyHandle, "handle") ??
606
- readString(replyHandle, "id") ??
607
- readString(replyRecord, "senderId") ??
608
- readString(replyRecord, "sender") ??
609
- readString(replyRecord, "from");
610
- const normalizedSender = replySenderRaw
611
- ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
612
- : undefined;
613
-
614
- const replyToBody =
615
- readString(replyRecord, "text") ??
616
- readString(replyRecord, "body") ??
617
- readString(replyRecord, "message") ??
618
- readString(replyRecord, "subject") ??
619
- undefined;
620
-
621
- const directReplyId =
622
- readString(message, "replyToMessageGuid") ??
623
- readString(message, "replyToGuid") ??
624
- readString(message, "replyGuid") ??
625
- readString(message, "selectedMessageGuid") ??
626
- readString(message, "selectedMessageId") ??
627
- readString(message, "replyToMessageId") ??
628
- readString(message, "replyId") ??
629
- readString(replyRecord, "guid") ??
630
- readString(replyRecord, "id") ??
631
- readString(replyRecord, "messageId");
632
-
633
- const associatedType =
634
- readNumberLike(message, "associatedMessageType") ??
635
- readNumberLike(message, "associated_message_type");
636
- const associatedGuid =
637
- readString(message, "associatedMessageGuid") ??
638
- readString(message, "associated_message_guid") ??
639
- readString(message, "associatedMessageId");
640
- const isReactionAssociation =
641
- typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
642
-
643
- const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
644
- const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
645
- const messageGuid = readString(message, "guid");
646
- const fallbackReplyId =
647
- !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
648
- ? threadOriginatorGuid
649
- : undefined;
650
-
651
- return {
652
- replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
653
- replyToBody: replyToBody?.trim() || undefined,
654
- replyToSender: normalizedSender || undefined,
655
- };
656
- }
657
-
658
- function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
659
- const chats = message["chats"];
660
- if (!Array.isArray(chats) || chats.length === 0) return null;
661
- const first = chats[0];
662
- return asRecord(first);
663
- }
664
-
665
- function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
666
- if (typeof entry === "string" || typeof entry === "number") {
667
- const raw = String(entry).trim();
668
- if (!raw) return null;
669
- const normalized = normalizeBlueBubblesHandle(raw) || raw;
670
- return normalized ? { id: normalized } : null;
257
+ try {
258
+ return { ok: true, value: JSON.parse(trimmed) as unknown };
259
+ } catch {
260
+ const params = new URLSearchParams(rawBody);
261
+ const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
262
+ if (!payload) {
263
+ return { ok: false, error: "invalid json" };
264
+ }
265
+ try {
266
+ return { ok: true, value: JSON.parse(payload) as unknown };
267
+ } catch (error) {
268
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
269
+ }
671
270
  }
672
- const record = asRecord(entry);
673
- if (!record) return null;
674
- const nestedHandle =
675
- asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
676
- const idRaw =
677
- readString(record, "address") ??
678
- readString(record, "handle") ??
679
- readString(record, "id") ??
680
- readString(record, "phoneNumber") ??
681
- readString(record, "phone_number") ??
682
- readString(record, "email") ??
683
- readString(nestedHandle, "address") ??
684
- readString(nestedHandle, "handle") ??
685
- readString(nestedHandle, "id");
686
- const nameRaw =
687
- readString(record, "displayName") ??
688
- readString(record, "name") ??
689
- readString(record, "title") ??
690
- readString(nestedHandle, "displayName") ??
691
- readString(nestedHandle, "name");
692
- const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
693
- if (!normalizedId) return null;
694
- const name = nameRaw?.trim() || undefined;
695
- return { id: normalizedId, name };
696
271
  }
697
272
 
698
- function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
699
- if (!Array.isArray(raw) || raw.length === 0) return [];
700
- const seen = new Set<string>();
701
- const output: BlueBubblesParticipant[] = [];
702
- for (const entry of raw) {
703
- const normalized = normalizeParticipantEntry(entry);
704
- if (!normalized?.id) continue;
705
- const key = normalized.id.toLowerCase();
706
- if (seen.has(key)) continue;
707
- seen.add(key);
708
- output.push(normalized);
273
+ async function readBlueBubblesWebhookBody(
274
+ req: IncomingMessage,
275
+ maxBytes: number,
276
+ ): Promise<ReadBlueBubblesWebhookBodyResult> {
277
+ try {
278
+ const rawBody = await readRequestBodyWithLimit(req, {
279
+ maxBytes,
280
+ timeoutMs: 30_000,
281
+ });
282
+ const parsed = parseBlueBubblesWebhookPayload(rawBody);
283
+ if (!parsed.ok) {
284
+ return { ok: false, statusCode: 400, error: parsed.error };
285
+ }
286
+ return parsed;
287
+ } catch (error) {
288
+ if (isRequestBodyLimitError(error)) {
289
+ return {
290
+ ok: false,
291
+ statusCode: error.statusCode,
292
+ error: requestBodyErrorToText(error.code),
293
+ };
294
+ }
295
+ return {
296
+ ok: false,
297
+ statusCode: 400,
298
+ error: error instanceof Error ? error.message : String(error),
299
+ };
709
300
  }
710
- return output;
711
301
  }
712
302
 
713
- function formatGroupMembers(params: {
714
- participants?: BlueBubblesParticipant[];
715
- fallback?: BlueBubblesParticipant;
716
- }): string | undefined {
717
- const seen = new Set<string>();
718
- const ordered: BlueBubblesParticipant[] = [];
719
- for (const entry of params.participants ?? []) {
720
- if (!entry?.id) continue;
721
- const key = entry.id.toLowerCase();
722
- if (seen.has(key)) continue;
723
- seen.add(key);
724
- ordered.push(entry);
725
- }
726
- if (ordered.length === 0 && params.fallback?.id) {
727
- ordered.push(params.fallback);
728
- }
729
- if (ordered.length === 0) return undefined;
730
- return ordered
731
- .map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id))
732
- .join(", ");
303
+ function asRecord(value: unknown): Record<string, unknown> | null {
304
+ return value && typeof value === "object" && !Array.isArray(value)
305
+ ? (value as Record<string, unknown>)
306
+ : null;
733
307
  }
734
308
 
735
- function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
736
- const guid = chatGuid?.trim();
737
- if (!guid) return undefined;
738
- const parts = guid.split(";");
739
- if (parts.length >= 3) {
740
- if (parts[1] === "+") return true;
741
- if (parts[1] === "-") return false;
309
+ function maskSecret(value: string): string {
310
+ if (value.length <= 6) {
311
+ return "***";
742
312
  }
743
- if (guid.includes(";+;")) return true;
744
- if (guid.includes(";-;")) return false;
745
- return undefined;
746
- }
747
-
748
- function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
749
- const guid = chatGuid?.trim();
750
- if (!guid) return undefined;
751
- const parts = guid.split(";");
752
- if (parts.length < 3) return undefined;
753
- const identifier = parts[2]?.trim();
754
- return identifier || undefined;
755
- }
756
-
757
- function formatGroupAllowlistEntry(params: {
758
- chatGuid?: string;
759
- chatId?: number;
760
- chatIdentifier?: string;
761
- }): string | null {
762
- const guid = params.chatGuid?.trim();
763
- if (guid) return `chat_guid:${guid}`;
764
- const chatId = params.chatId;
765
- if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
766
- const identifier = params.chatIdentifier?.trim();
767
- if (identifier) return `chat_identifier:${identifier}`;
768
- return null;
769
- }
770
-
771
- type BlueBubblesParticipant = {
772
- id: string;
773
- name?: string;
774
- };
775
-
776
- type NormalizedWebhookMessage = {
777
- text: string;
778
- senderId: string;
779
- senderName?: string;
780
- messageId?: string;
781
- timestamp?: number;
782
- isGroup: boolean;
783
- chatId?: number;
784
- chatGuid?: string;
785
- chatIdentifier?: string;
786
- chatName?: string;
787
- fromMe?: boolean;
788
- attachments?: BlueBubblesAttachment[];
789
- balloonBundleId?: string;
790
- associatedMessageGuid?: string;
791
- associatedMessageType?: number;
792
- associatedMessageEmoji?: string;
793
- isTapback?: boolean;
794
- participants?: BlueBubblesParticipant[];
795
- replyToId?: string;
796
- replyToBody?: string;
797
- replyToSender?: string;
798
- };
799
-
800
- type NormalizedWebhookReaction = {
801
- action: "added" | "removed";
802
- emoji: string;
803
- senderId: string;
804
- senderName?: string;
805
- messageId: string;
806
- timestamp?: number;
807
- isGroup: boolean;
808
- chatId?: number;
809
- chatGuid?: string;
810
- chatIdentifier?: string;
811
- chatName?: string;
812
- fromMe?: boolean;
813
- };
814
-
815
- const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([
816
- [2000, { emoji: "❤️", action: "added" }],
817
- [2001, { emoji: "👍", action: "added" }],
818
- [2002, { emoji: "👎", action: "added" }],
819
- [2003, { emoji: "😂", action: "added" }],
820
- [2004, { emoji: "‼️", action: "added" }],
821
- [2005, { emoji: "❓", action: "added" }],
822
- [3000, { emoji: "❤️", action: "removed" }],
823
- [3001, { emoji: "👍", action: "removed" }],
824
- [3002, { emoji: "👎", action: "removed" }],
825
- [3003, { emoji: "😂", action: "removed" }],
826
- [3004, { emoji: "‼️", action: "removed" }],
827
- [3005, { emoji: "❓", action: "removed" }],
828
- ]);
829
-
830
- // Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
831
- const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
832
- ["loved", { emoji: "❤️", action: "added" }],
833
- ["liked", { emoji: "👍", action: "added" }],
834
- ["disliked", { emoji: "👎", action: "added" }],
835
- ["laughed at", { emoji: "😂", action: "added" }],
836
- ["emphasized", { emoji: "‼️", action: "added" }],
837
- ["questioned", { emoji: "❓", action: "added" }],
838
- // Removal patterns (e.g., "Removed a heart from")
839
- ["removed a heart from", { emoji: "❤️", action: "removed" }],
840
- ["removed a like from", { emoji: "👍", action: "removed" }],
841
- ["removed a dislike from", { emoji: "👎", action: "removed" }],
842
- ["removed a laugh from", { emoji: "😂", action: "removed" }],
843
- ["removed an emphasis from", { emoji: "‼️", action: "removed" }],
844
- ["removed a question from", { emoji: "❓", action: "removed" }],
845
- ]);
846
-
847
- const TAPBACK_EMOJI_REGEX =
848
- /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
849
-
850
- function extractFirstEmoji(text: string): string | null {
851
- const match = text.match(TAPBACK_EMOJI_REGEX);
852
- return match ? match[0] : null;
853
- }
854
-
855
- function extractQuotedTapbackText(text: string): string | null {
856
- const match = text.match(/[“"]([^”"]+)[”"]/s);
857
- return match ? match[1] : null;
858
- }
859
-
860
- function isTapbackAssociatedType(type: number | undefined): boolean {
861
- return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
862
- }
863
-
864
- function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
865
- if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
866
- if (type >= 3000 && type < 4000) return "removed";
867
- if (type >= 2000 && type < 3000) return "added";
868
- return undefined;
869
- }
870
-
871
- function resolveTapbackContext(message: NormalizedWebhookMessage): {
872
- emojiHint?: string;
873
- actionHint?: "added" | "removed";
874
- replyToId?: string;
875
- } | null {
876
- const associatedType = message.associatedMessageType;
877
- const hasTapbackType = isTapbackAssociatedType(associatedType);
878
- const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
879
- if (!hasTapbackType && !hasTapbackMarker) return null;
880
- const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
881
- const actionHint = resolveTapbackActionHint(associatedType);
882
- const emojiHint =
883
- message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
884
- return { emojiHint, actionHint, replyToId };
313
+ return `${value.slice(0, 2)}***${value.slice(-2)}`;
885
314
  }
886
315
 
887
- // Detects tapback text patterns like 'Loved "message"' and converts to structured format
888
- function parseTapbackText(params: {
889
- text: string;
890
- emojiHint?: string;
891
- actionHint?: "added" | "removed";
892
- requireQuoted?: boolean;
893
- }): {
894
- emoji: string;
895
- action: "added" | "removed";
896
- quotedText: string;
897
- } | null {
898
- const trimmed = params.text.trim();
899
- const lower = trimmed.toLowerCase();
900
- if (!trimmed) return null;
901
-
902
- for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
903
- if (lower.startsWith(pattern)) {
904
- // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
905
- const afterPattern = trimmed.slice(pattern.length).trim();
906
- if (params.requireQuoted) {
907
- const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
908
- if (!strictMatch) return null;
909
- return { emoji, action, quotedText: strictMatch[1] };
910
- }
911
- const quotedText =
912
- extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
913
- return { emoji, action, quotedText };
914
- }
915
- }
916
-
917
- if (lower.startsWith("reacted")) {
918
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
919
- if (!emoji) return null;
920
- const quotedText = extractQuotedTapbackText(trimmed);
921
- if (params.requireQuoted && !quotedText) return null;
922
- const fallback = trimmed.slice("reacted".length).trim();
923
- return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
316
+ function normalizeAuthToken(raw: string): string {
317
+ const value = raw.trim();
318
+ if (!value) {
319
+ return "";
924
320
  }
925
-
926
- if (lower.startsWith("removed")) {
927
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
928
- if (!emoji) return null;
929
- const quotedText = extractQuotedTapbackText(trimmed);
930
- if (params.requireQuoted && !quotedText) return null;
931
- const fallback = trimmed.slice("removed".length).trim();
932
- return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
321
+ if (value.toLowerCase().startsWith("bearer ")) {
322
+ return value.slice("bearer ".length).trim();
933
323
  }
934
- return null;
935
- }
936
-
937
- function maskSecret(value: string): string {
938
- if (value.length <= 6) return "***";
939
- return `${value.slice(0, 2)}***${value.slice(-2)}`;
324
+ return value;
940
325
  }
941
326
 
942
- function resolveBlueBubblesAckReaction(params: {
943
- cfg: PoolbotConfig;
944
- agentId: string;
945
- core: BlueBubblesCoreRuntime;
946
- runtime: BlueBubblesRuntimeEnv;
947
- }): string | null {
948
- const raw = resolveAckReaction(params.cfg, params.agentId).trim();
949
- if (!raw) return null;
950
- try {
951
- normalizeBlueBubblesReactionInput(raw);
952
- return raw;
953
- } catch {
954
- const key = raw.toLowerCase();
955
- if (!invalidAckReactions.has(key)) {
956
- invalidAckReactions.add(key);
957
- logVerbose(
958
- params.core,
959
- params.runtime,
960
- `ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
961
- );
962
- }
963
- return null;
327
+ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
328
+ const a = normalizeAuthToken(aRaw);
329
+ const b = normalizeAuthToken(bRaw);
330
+ if (!a || !b) {
331
+ return false;
964
332
  }
965
- }
966
-
967
- function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
968
- const dataRaw = payload.data ?? payload.payload ?? payload.event;
969
- const data =
970
- asRecord(dataRaw) ??
971
- (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
972
- const messageRaw = payload.message ?? data?.message ?? data;
973
- const message =
974
- asRecord(messageRaw) ??
975
- (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
976
- if (!message) return null;
977
- return message;
978
- }
979
-
980
- function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWebhookMessage | null {
981
- const message = extractMessagePayload(payload);
982
- if (!message) return null;
983
-
984
- const text =
985
- readString(message, "text") ??
986
- readString(message, "body") ??
987
- readString(message, "subject") ??
988
- "";
989
-
990
- const handleValue = message.handle ?? message.sender;
991
- const handle =
992
- asRecord(handleValue) ??
993
- (typeof handleValue === "string" ? { address: handleValue } : null);
994
- const senderId =
995
- readString(handle, "address") ??
996
- readString(handle, "handle") ??
997
- readString(handle, "id") ??
998
- readString(message, "senderId") ??
999
- readString(message, "sender") ??
1000
- readString(message, "from") ??
1001
- "";
1002
-
1003
- const senderName =
1004
- readString(handle, "displayName") ??
1005
- readString(handle, "name") ??
1006
- readString(message, "senderName") ??
1007
- undefined;
1008
-
1009
- const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
1010
- const chatFromList = readFirstChatRecord(message);
1011
- const chatGuid =
1012
- readString(message, "chatGuid") ??
1013
- readString(message, "chat_guid") ??
1014
- readString(chat, "chatGuid") ??
1015
- readString(chat, "chat_guid") ??
1016
- readString(chat, "guid") ??
1017
- readString(chatFromList, "chatGuid") ??
1018
- readString(chatFromList, "chat_guid") ??
1019
- readString(chatFromList, "guid");
1020
- const chatIdentifier =
1021
- readString(message, "chatIdentifier") ??
1022
- readString(message, "chat_identifier") ??
1023
- readString(chat, "chatIdentifier") ??
1024
- readString(chat, "chat_identifier") ??
1025
- readString(chat, "identifier") ??
1026
- readString(chatFromList, "chatIdentifier") ??
1027
- readString(chatFromList, "chat_identifier") ??
1028
- readString(chatFromList, "identifier") ??
1029
- extractChatIdentifierFromChatGuid(chatGuid);
1030
- const chatId =
1031
- readNumberLike(message, "chatId") ??
1032
- readNumberLike(message, "chat_id") ??
1033
- readNumberLike(chat, "chatId") ??
1034
- readNumberLike(chat, "chat_id") ??
1035
- readNumberLike(chat, "id") ??
1036
- readNumberLike(chatFromList, "chatId") ??
1037
- readNumberLike(chatFromList, "chat_id") ??
1038
- readNumberLike(chatFromList, "id");
1039
- const chatName =
1040
- readString(message, "chatName") ??
1041
- readString(chat, "displayName") ??
1042
- readString(chat, "name") ??
1043
- readString(chatFromList, "displayName") ??
1044
- readString(chatFromList, "name") ??
1045
- undefined;
1046
-
1047
- const chatParticipants = chat ? chat["participants"] : undefined;
1048
- const messageParticipants = message["participants"];
1049
- const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
1050
- const participants = Array.isArray(chatParticipants)
1051
- ? chatParticipants
1052
- : Array.isArray(messageParticipants)
1053
- ? messageParticipants
1054
- : Array.isArray(chatsParticipants)
1055
- ? chatsParticipants
1056
- : [];
1057
- const normalizedParticipants = normalizeParticipantList(participants);
1058
- const participantsCount = participants.length;
1059
- const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
1060
- const explicitIsGroup =
1061
- readBoolean(message, "isGroup") ??
1062
- readBoolean(message, "is_group") ??
1063
- readBoolean(chat, "isGroup") ??
1064
- readBoolean(message, "group");
1065
- const isGroup =
1066
- typeof groupFromChatGuid === "boolean"
1067
- ? groupFromChatGuid
1068
- : explicitIsGroup ?? (participantsCount > 2 ? true : false);
1069
-
1070
- const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
1071
- const messageId =
1072
- readString(message, "guid") ??
1073
- readString(message, "id") ??
1074
- readString(message, "messageId") ??
1075
- undefined;
1076
- const balloonBundleId = readString(message, "balloonBundleId");
1077
- const associatedMessageGuid =
1078
- readString(message, "associatedMessageGuid") ??
1079
- readString(message, "associated_message_guid") ??
1080
- readString(message, "associatedMessageId") ??
1081
- undefined;
1082
- const associatedMessageType =
1083
- readNumberLike(message, "associatedMessageType") ??
1084
- readNumberLike(message, "associated_message_type");
1085
- const associatedMessageEmoji =
1086
- readString(message, "associatedMessageEmoji") ??
1087
- readString(message, "associated_message_emoji") ??
1088
- readString(message, "reactionEmoji") ??
1089
- readString(message, "reaction_emoji") ??
1090
- undefined;
1091
- const isTapback =
1092
- readBoolean(message, "isTapback") ??
1093
- readBoolean(message, "is_tapback") ??
1094
- readBoolean(message, "tapback") ??
1095
- undefined;
1096
-
1097
- const timestampRaw =
1098
- readNumber(message, "date") ??
1099
- readNumber(message, "dateCreated") ??
1100
- readNumber(message, "timestamp");
1101
- const timestamp =
1102
- typeof timestampRaw === "number"
1103
- ? timestampRaw > 1_000_000_000_000
1104
- ? timestampRaw
1105
- : timestampRaw * 1000
1106
- : undefined;
1107
-
1108
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
1109
- if (!normalizedSender) return null;
1110
- const replyMetadata = extractReplyMetadata(message);
1111
-
1112
- return {
1113
- text,
1114
- senderId: normalizedSender,
1115
- senderName,
1116
- messageId,
1117
- timestamp,
1118
- isGroup,
1119
- chatId,
1120
- chatGuid,
1121
- chatIdentifier,
1122
- chatName,
1123
- fromMe,
1124
- attachments: extractAttachments(message),
1125
- balloonBundleId,
1126
- associatedMessageGuid,
1127
- associatedMessageType,
1128
- associatedMessageEmoji,
1129
- isTapback,
1130
- participants: normalizedParticipants,
1131
- replyToId: replyMetadata.replyToId,
1132
- replyToBody: replyMetadata.replyToBody,
1133
- replyToSender: replyMetadata.replyToSender,
1134
- };
1135
- }
1136
-
1137
- function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedWebhookReaction | null {
1138
- const message = extractMessagePayload(payload);
1139
- if (!message) return null;
1140
-
1141
- const associatedGuid =
1142
- readString(message, "associatedMessageGuid") ??
1143
- readString(message, "associated_message_guid") ??
1144
- readString(message, "associatedMessageId");
1145
- const associatedType =
1146
- readNumberLike(message, "associatedMessageType") ??
1147
- readNumberLike(message, "associated_message_type");
1148
- if (!associatedGuid || associatedType === undefined) return null;
1149
-
1150
- const mapping = REACTION_TYPE_MAP.get(associatedType);
1151
- const associatedEmoji =
1152
- readString(message, "associatedMessageEmoji") ??
1153
- readString(message, "associated_message_emoji") ??
1154
- readString(message, "reactionEmoji") ??
1155
- readString(message, "reaction_emoji");
1156
- const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
1157
- const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
1158
-
1159
- const handleValue = message.handle ?? message.sender;
1160
- const handle =
1161
- asRecord(handleValue) ??
1162
- (typeof handleValue === "string" ? { address: handleValue } : null);
1163
- const senderId =
1164
- readString(handle, "address") ??
1165
- readString(handle, "handle") ??
1166
- readString(handle, "id") ??
1167
- readString(message, "senderId") ??
1168
- readString(message, "sender") ??
1169
- readString(message, "from") ??
1170
- "";
1171
- const senderName =
1172
- readString(handle, "displayName") ??
1173
- readString(handle, "name") ??
1174
- readString(message, "senderName") ??
1175
- undefined;
1176
-
1177
- const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
1178
- const chatFromList = readFirstChatRecord(message);
1179
- const chatGuid =
1180
- readString(message, "chatGuid") ??
1181
- readString(message, "chat_guid") ??
1182
- readString(chat, "chatGuid") ??
1183
- readString(chat, "chat_guid") ??
1184
- readString(chat, "guid") ??
1185
- readString(chatFromList, "chatGuid") ??
1186
- readString(chatFromList, "chat_guid") ??
1187
- readString(chatFromList, "guid");
1188
- const chatIdentifier =
1189
- readString(message, "chatIdentifier") ??
1190
- readString(message, "chat_identifier") ??
1191
- readString(chat, "chatIdentifier") ??
1192
- readString(chat, "chat_identifier") ??
1193
- readString(chat, "identifier") ??
1194
- readString(chatFromList, "chatIdentifier") ??
1195
- readString(chatFromList, "chat_identifier") ??
1196
- readString(chatFromList, "identifier") ??
1197
- extractChatIdentifierFromChatGuid(chatGuid);
1198
- const chatId =
1199
- readNumberLike(message, "chatId") ??
1200
- readNumberLike(message, "chat_id") ??
1201
- readNumberLike(chat, "chatId") ??
1202
- readNumberLike(chat, "chat_id") ??
1203
- readNumberLike(chat, "id") ??
1204
- readNumberLike(chatFromList, "chatId") ??
1205
- readNumberLike(chatFromList, "chat_id") ??
1206
- readNumberLike(chatFromList, "id");
1207
- const chatName =
1208
- readString(message, "chatName") ??
1209
- readString(chat, "displayName") ??
1210
- readString(chat, "name") ??
1211
- readString(chatFromList, "displayName") ??
1212
- readString(chatFromList, "name") ??
1213
- undefined;
1214
-
1215
- const chatParticipants = chat ? chat["participants"] : undefined;
1216
- const messageParticipants = message["participants"];
1217
- const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
1218
- const participants = Array.isArray(chatParticipants)
1219
- ? chatParticipants
1220
- : Array.isArray(messageParticipants)
1221
- ? messageParticipants
1222
- : Array.isArray(chatsParticipants)
1223
- ? chatsParticipants
1224
- : [];
1225
- const participantsCount = participants.length;
1226
- const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
1227
- const explicitIsGroup =
1228
- readBoolean(message, "isGroup") ??
1229
- readBoolean(message, "is_group") ??
1230
- readBoolean(chat, "isGroup") ??
1231
- readBoolean(message, "group");
1232
- const isGroup =
1233
- typeof groupFromChatGuid === "boolean"
1234
- ? groupFromChatGuid
1235
- : explicitIsGroup ?? (participantsCount > 2 ? true : false);
1236
-
1237
- const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
1238
- const timestampRaw =
1239
- readNumberLike(message, "date") ??
1240
- readNumberLike(message, "dateCreated") ??
1241
- readNumberLike(message, "timestamp");
1242
- const timestamp =
1243
- typeof timestampRaw === "number"
1244
- ? timestampRaw > 1_000_000_000_000
1245
- ? timestampRaw
1246
- : timestampRaw * 1000
1247
- : undefined;
1248
-
1249
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
1250
- if (!normalizedSender) return null;
1251
-
1252
- return {
1253
- action,
1254
- emoji,
1255
- senderId: normalizedSender,
1256
- senderName,
1257
- messageId: associatedGuid,
1258
- timestamp,
1259
- isGroup,
1260
- chatId,
1261
- chatGuid,
1262
- chatIdentifier,
1263
- chatName,
1264
- fromMe,
1265
- };
333
+ const bufA = Buffer.from(a, "utf8");
334
+ const bufB = Buffer.from(b, "utf8");
335
+ if (bufA.length !== bufB.length) {
336
+ return false;
337
+ }
338
+ return timingSafeEqual(bufA, bufB);
1266
339
  }
1267
340
 
1268
341
  export async function handleBlueBubblesWebhookRequest(
1269
342
  req: IncomingMessage,
1270
343
  res: ServerResponse,
1271
344
  ): Promise<boolean> {
345
+ const resolved = resolveWebhookTargets(req, webhookTargets);
346
+ if (!resolved) {
347
+ return false;
348
+ }
349
+ const { path, targets } = resolved;
1272
350
  const url = new URL(req.url ?? "/", "http://localhost");
1273
- const path = normalizeWebhookPath(url.pathname);
1274
- const targets = webhookTargets.get(path);
1275
- if (!targets || targets.length === 0) return false;
1276
351
 
1277
- if (req.method !== "POST") {
1278
- res.statusCode = 405;
1279
- res.setHeader("Allow", "POST");
1280
- res.end("Method Not Allowed");
352
+ if (rejectNonPostWebhookRequest(req, res)) {
1281
353
  return true;
1282
354
  }
1283
355
 
1284
- const body = await readJsonBody(req, 1024 * 1024);
356
+ const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
1285
357
  if (!body.ok) {
1286
- res.statusCode = body.error === "payload too large" ? 413 : 400;
358
+ res.statusCode = body.statusCode;
1287
359
  res.end(body.error ?? "invalid payload");
1288
360
  console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
1289
361
  return true;
@@ -1340,26 +412,19 @@ export async function handleBlueBubblesWebhookRequest(
1340
412
  return true;
1341
413
  }
1342
414
 
1343
- const matching = targets.filter((target) => {
1344
- const token = target.account.config.password?.trim();
1345
- if (!token) return true;
1346
- const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
1347
- const headerToken =
1348
- req.headers["x-guid"] ??
1349
- req.headers["x-password"] ??
1350
- req.headers["x-bluebubbles-guid"] ??
1351
- req.headers["authorization"];
1352
- const guid =
1353
- (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
1354
- if (guid && guid.trim() === token) return true;
1355
- const remote = req.socket?.remoteAddress ?? "";
1356
- if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
1357
- return true;
1358
- }
1359
- return false;
415
+ const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
416
+ const headerToken =
417
+ req.headers["x-guid"] ??
418
+ req.headers["x-password"] ??
419
+ req.headers["x-bluebubbles-guid"] ??
420
+ req.headers["authorization"];
421
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
422
+ const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
423
+ const token = target.account.config.password?.trim() ?? "";
424
+ return safeEqualSecret(guid, token);
1360
425
  });
1361
426
 
1362
- if (matching.length === 0) {
427
+ if (matchedTarget.kind === "none") {
1363
428
  res.statusCode = 401;
1364
429
  res.end("unauthorized");
1365
430
  console.warn(
@@ -1368,24 +433,30 @@ export async function handleBlueBubblesWebhookRequest(
1368
433
  return true;
1369
434
  }
1370
435
 
1371
- for (const target of matching) {
1372
- target.statusSink?.({ lastInboundAt: Date.now() });
1373
- if (reaction) {
1374
- processReaction(reaction, target).catch((err) => {
1375
- target.runtime.error?.(
1376
- `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
1377
- );
1378
- });
1379
- } else if (message) {
1380
- // Route messages through debouncer to coalesce rapid-fire events
1381
- // (e.g., text message + URL balloon arriving as separate webhooks)
1382
- const debouncer = getOrCreateDebouncer(target);
1383
- debouncer.enqueue({ message, target }).catch((err) => {
1384
- target.runtime.error?.(
1385
- `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
1386
- );
1387
- });
1388
- }
436
+ if (matchedTarget.kind === "ambiguous") {
437
+ res.statusCode = 401;
438
+ res.end("ambiguous webhook target");
439
+ console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
440
+ return true;
441
+ }
442
+
443
+ const target = matchedTarget.target;
444
+ target.statusSink?.({ lastInboundAt: Date.now() });
445
+ if (reaction) {
446
+ processReaction(reaction, target).catch((err) => {
447
+ target.runtime.error?.(
448
+ `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
449
+ );
450
+ });
451
+ } else if (message) {
452
+ // Route messages through debouncer to coalesce rapid-fire events
453
+ // (e.g., text message + URL balloon arriving as separate webhooks)
454
+ const debouncer = getOrCreateDebouncer(target);
455
+ debouncer.enqueue({ message, target }).catch((err) => {
456
+ target.runtime.error?.(
457
+ `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
458
+ );
459
+ });
1389
460
  }
1390
461
 
1391
462
  res.statusCode = 200;
@@ -1410,820 +481,6 @@ export async function handleBlueBubblesWebhookRequest(
1410
481
  return true;
1411
482
  }
1412
483
 
1413
- async function processMessage(
1414
- message: NormalizedWebhookMessage,
1415
- target: WebhookTarget,
1416
- ): Promise<void> {
1417
- const { account, config, runtime, core, statusSink } = target;
1418
-
1419
- const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
1420
- const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
1421
-
1422
- const text = message.text.trim();
1423
- const attachments = message.attachments ?? [];
1424
- const placeholder = buildMessagePlaceholder(message);
1425
- // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
1426
- // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
1427
- const tapbackContext = resolveTapbackContext(message);
1428
- const tapbackParsed = parseTapbackText({
1429
- text,
1430
- emojiHint: tapbackContext?.emojiHint,
1431
- actionHint: tapbackContext?.actionHint,
1432
- requireQuoted: !tapbackContext,
1433
- });
1434
- const isTapbackMessage = Boolean(tapbackParsed);
1435
- const rawBody = tapbackParsed
1436
- ? tapbackParsed.action === "removed"
1437
- ? `removed ${tapbackParsed.emoji} reaction`
1438
- : `reacted with ${tapbackParsed.emoji}`
1439
- : text || placeholder;
1440
-
1441
- const cacheMessageId = message.messageId?.trim();
1442
- let messageShortId: string | undefined;
1443
- const cacheInboundMessage = () => {
1444
- if (!cacheMessageId) return;
1445
- const cacheEntry = rememberBlueBubblesReplyCache({
1446
- accountId: account.accountId,
1447
- messageId: cacheMessageId,
1448
- chatGuid: message.chatGuid,
1449
- chatIdentifier: message.chatIdentifier,
1450
- chatId: message.chatId,
1451
- senderLabel: message.fromMe ? "me" : message.senderId,
1452
- body: rawBody,
1453
- timestamp: message.timestamp ?? Date.now(),
1454
- });
1455
- messageShortId = cacheEntry.shortId;
1456
- };
1457
-
1458
- if (message.fromMe) {
1459
- // Cache from-me messages so reply context can resolve sender/body.
1460
- cacheInboundMessage();
1461
- return;
1462
- }
1463
-
1464
- if (!rawBody) {
1465
- logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
1466
- return;
1467
- }
1468
- logVerbose(
1469
- core,
1470
- runtime,
1471
- `msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
1472
- );
1473
-
1474
- const dmPolicy = account.config.dmPolicy ?? "pairing";
1475
- const groupPolicy = account.config.groupPolicy ?? "allowlist";
1476
- const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
1477
- const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
1478
- const storeAllowFrom = await core.channel.pairing
1479
- .readAllowFromStore("bluebubbles")
1480
- .catch(() => []);
1481
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
1482
- .map((entry) => String(entry).trim())
1483
- .filter(Boolean);
1484
- const effectiveGroupAllowFrom = [
1485
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
1486
- ...storeAllowFrom,
1487
- ]
1488
- .map((entry) => String(entry).trim())
1489
- .filter(Boolean);
1490
- const groupAllowEntry = formatGroupAllowlistEntry({
1491
- chatGuid: message.chatGuid,
1492
- chatId: message.chatId ?? undefined,
1493
- chatIdentifier: message.chatIdentifier ?? undefined,
1494
- });
1495
- const groupName = message.chatName?.trim() || undefined;
1496
-
1497
- if (isGroup) {
1498
- if (groupPolicy === "disabled") {
1499
- logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
1500
- logGroupAllowlistHint({
1501
- runtime,
1502
- reason: "groupPolicy=disabled",
1503
- entry: groupAllowEntry,
1504
- chatName: groupName,
1505
- accountId: account.accountId,
1506
- });
1507
- return;
1508
- }
1509
- if (groupPolicy === "allowlist") {
1510
- if (effectiveGroupAllowFrom.length === 0) {
1511
- logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
1512
- logGroupAllowlistHint({
1513
- runtime,
1514
- reason: "groupPolicy=allowlist (empty allowlist)",
1515
- entry: groupAllowEntry,
1516
- chatName: groupName,
1517
- accountId: account.accountId,
1518
- });
1519
- return;
1520
- }
1521
- const allowed = isAllowedBlueBubblesSender({
1522
- allowFrom: effectiveGroupAllowFrom,
1523
- sender: message.senderId,
1524
- chatId: message.chatId ?? undefined,
1525
- chatGuid: message.chatGuid ?? undefined,
1526
- chatIdentifier: message.chatIdentifier ?? undefined,
1527
- });
1528
- if (!allowed) {
1529
- logVerbose(
1530
- core,
1531
- runtime,
1532
- `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
1533
- );
1534
- logVerbose(
1535
- core,
1536
- runtime,
1537
- `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
1538
- );
1539
- logGroupAllowlistHint({
1540
- runtime,
1541
- reason: "groupPolicy=allowlist (not allowlisted)",
1542
- entry: groupAllowEntry,
1543
- chatName: groupName,
1544
- accountId: account.accountId,
1545
- });
1546
- return;
1547
- }
1548
- }
1549
- } else {
1550
- if (dmPolicy === "disabled") {
1551
- logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
1552
- logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
1553
- return;
1554
- }
1555
- if (dmPolicy !== "open") {
1556
- const allowed = isAllowedBlueBubblesSender({
1557
- allowFrom: effectiveAllowFrom,
1558
- sender: message.senderId,
1559
- chatId: message.chatId ?? undefined,
1560
- chatGuid: message.chatGuid ?? undefined,
1561
- chatIdentifier: message.chatIdentifier ?? undefined,
1562
- });
1563
- if (!allowed) {
1564
- if (dmPolicy === "pairing") {
1565
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
1566
- channel: "bluebubbles",
1567
- id: message.senderId,
1568
- meta: { name: message.senderName },
1569
- });
1570
- runtime.log?.(
1571
- `[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
1572
- );
1573
- if (created) {
1574
- logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
1575
- try {
1576
- await sendMessageBlueBubbles(
1577
- message.senderId,
1578
- core.channel.pairing.buildPairingReply({
1579
- channel: "bluebubbles",
1580
- idLine: `Your BlueBubbles sender id: ${message.senderId}`,
1581
- code,
1582
- }),
1583
- { cfg: config, accountId: account.accountId },
1584
- );
1585
- statusSink?.({ lastOutboundAt: Date.now() });
1586
- } catch (err) {
1587
- logVerbose(
1588
- core,
1589
- runtime,
1590
- `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
1591
- );
1592
- runtime.error?.(
1593
- `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
1594
- );
1595
- }
1596
- }
1597
- } else {
1598
- logVerbose(
1599
- core,
1600
- runtime,
1601
- `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
1602
- );
1603
- logVerbose(
1604
- core,
1605
- runtime,
1606
- `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
1607
- );
1608
- }
1609
- return;
1610
- }
1611
- }
1612
- }
1613
-
1614
- const chatId = message.chatId ?? undefined;
1615
- const chatGuid = message.chatGuid ?? undefined;
1616
- const chatIdentifier = message.chatIdentifier ?? undefined;
1617
- const peerId = isGroup
1618
- ? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
1619
- : message.senderId;
1620
-
1621
- const route = core.channel.routing.resolveAgentRoute({
1622
- cfg: config,
1623
- channel: "bluebubbles",
1624
- accountId: account.accountId,
1625
- peer: {
1626
- kind: isGroup ? "group" : "dm",
1627
- id: peerId,
1628
- },
1629
- });
1630
-
1631
- // Mention gating for group chats (parity with iMessage/WhatsApp)
1632
- const messageText = text;
1633
- const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
1634
- const wasMentioned = isGroup
1635
- ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
1636
- : true;
1637
- const canDetectMention = mentionRegexes.length > 0;
1638
- const requireMention = core.channel.groups.resolveRequireMention({
1639
- cfg: config,
1640
- channel: "bluebubbles",
1641
- groupId: peerId,
1642
- accountId: account.accountId,
1643
- });
1644
-
1645
- // Command gating (parity with iMessage/WhatsApp)
1646
- const useAccessGroups = config.commands?.useAccessGroups !== false;
1647
- const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
1648
- const ownerAllowedForCommands =
1649
- effectiveAllowFrom.length > 0
1650
- ? isAllowedBlueBubblesSender({
1651
- allowFrom: effectiveAllowFrom,
1652
- sender: message.senderId,
1653
- chatId: message.chatId ?? undefined,
1654
- chatGuid: message.chatGuid ?? undefined,
1655
- chatIdentifier: message.chatIdentifier ?? undefined,
1656
- })
1657
- : false;
1658
- const groupAllowedForCommands =
1659
- effectiveGroupAllowFrom.length > 0
1660
- ? isAllowedBlueBubblesSender({
1661
- allowFrom: effectiveGroupAllowFrom,
1662
- sender: message.senderId,
1663
- chatId: message.chatId ?? undefined,
1664
- chatGuid: message.chatGuid ?? undefined,
1665
- chatIdentifier: message.chatIdentifier ?? undefined,
1666
- })
1667
- : false;
1668
- const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
1669
- const commandGate = resolveControlCommandGate({
1670
- useAccessGroups,
1671
- authorizers: [
1672
- { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
1673
- { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
1674
- ],
1675
- allowTextCommands: true,
1676
- hasControlCommand: hasControlCmd,
1677
- });
1678
- const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
1679
-
1680
- // Block control commands from unauthorized senders in groups
1681
- if (isGroup && commandGate.shouldBlock) {
1682
- logInboundDrop({
1683
- log: (msg) => logVerbose(core, runtime, msg),
1684
- channel: "bluebubbles",
1685
- reason: "control command (unauthorized)",
1686
- target: message.senderId,
1687
- });
1688
- return;
1689
- }
1690
-
1691
- // Allow control commands to bypass mention gating when authorized (parity with iMessage)
1692
- const shouldBypassMention =
1693
- isGroup &&
1694
- requireMention &&
1695
- !wasMentioned &&
1696
- commandAuthorized &&
1697
- hasControlCmd;
1698
- const effectiveWasMentioned = wasMentioned || shouldBypassMention;
1699
-
1700
- // Skip group messages that require mention but weren't mentioned
1701
- if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
1702
- logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
1703
- return;
1704
- }
1705
-
1706
- // Cache allowed inbound messages so later replies can resolve sender/body without
1707
- // surfacing dropped content (allowlist/mention/command gating).
1708
- cacheInboundMessage();
1709
-
1710
- const baseUrl = account.config.serverUrl?.trim();
1711
- const password = account.config.password?.trim();
1712
- const maxBytes =
1713
- account.config.mediaMaxMb && account.config.mediaMaxMb > 0
1714
- ? account.config.mediaMaxMb * 1024 * 1024
1715
- : 8 * 1024 * 1024;
1716
-
1717
- let mediaUrls: string[] = [];
1718
- let mediaPaths: string[] = [];
1719
- let mediaTypes: string[] = [];
1720
- if (attachments.length > 0) {
1721
- if (!baseUrl || !password) {
1722
- logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
1723
- } else {
1724
- for (const attachment of attachments) {
1725
- if (!attachment.guid) continue;
1726
- if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
1727
- logVerbose(
1728
- core,
1729
- runtime,
1730
- `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
1731
- );
1732
- continue;
1733
- }
1734
- try {
1735
- const downloaded = await downloadBlueBubblesAttachment(attachment, {
1736
- cfg: config,
1737
- accountId: account.accountId,
1738
- maxBytes,
1739
- });
1740
- const saved = await core.channel.media.saveMediaBuffer(
1741
- downloaded.buffer,
1742
- downloaded.contentType,
1743
- "inbound",
1744
- maxBytes,
1745
- );
1746
- mediaPaths.push(saved.path);
1747
- mediaUrls.push(saved.path);
1748
- if (saved.contentType) {
1749
- mediaTypes.push(saved.contentType);
1750
- }
1751
- } catch (err) {
1752
- logVerbose(
1753
- core,
1754
- runtime,
1755
- `attachment download failed guid=${attachment.guid} err=${String(err)}`,
1756
- );
1757
- }
1758
- }
1759
- }
1760
- }
1761
- let replyToId = message.replyToId;
1762
- let replyToBody = message.replyToBody;
1763
- let replyToSender = message.replyToSender;
1764
- let replyToShortId: string | undefined;
1765
-
1766
- if (isTapbackMessage && tapbackContext?.replyToId) {
1767
- replyToId = tapbackContext.replyToId;
1768
- }
1769
-
1770
- if (replyToId) {
1771
- const cached = resolveReplyContextFromCache({
1772
- accountId: account.accountId,
1773
- replyToId,
1774
- chatGuid: message.chatGuid,
1775
- chatIdentifier: message.chatIdentifier,
1776
- chatId: message.chatId,
1777
- });
1778
- if (cached) {
1779
- if (!replyToBody && cached.body) replyToBody = cached.body;
1780
- if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
1781
- replyToShortId = cached.shortId;
1782
- if (core.logging.shouldLogVerbose()) {
1783
- const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
1784
- logVerbose(
1785
- core,
1786
- runtime,
1787
- `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
1788
- );
1789
- }
1790
- }
1791
- }
1792
-
1793
- // If no cached short ID, try to get one from the UUID directly
1794
- if (replyToId && !replyToShortId) {
1795
- replyToShortId = getShortIdForUuid(replyToId);
1796
- }
1797
-
1798
- // Use inline [[reply_to:N]] tag format
1799
- // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
1800
- // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
1801
- const replyTag = formatReplyTag({ replyToId, replyToShortId });
1802
- const baseBody = replyTag
1803
- ? isTapbackMessage
1804
- ? `${rawBody} ${replyTag}`
1805
- : `${replyTag} ${rawBody}`
1806
- : rawBody;
1807
- const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
1808
- const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
1809
- const groupMembers = isGroup
1810
- ? formatGroupMembers({
1811
- participants: message.participants,
1812
- fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
1813
- })
1814
- : undefined;
1815
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
1816
- agentId: route.agentId,
1817
- });
1818
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
1819
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
1820
- storePath,
1821
- sessionKey: route.sessionKey,
1822
- });
1823
- const body = core.channel.reply.formatAgentEnvelope({
1824
- channel: "BlueBubbles",
1825
- from: fromLabel,
1826
- timestamp: message.timestamp,
1827
- previousTimestamp,
1828
- envelope: envelopeOptions,
1829
- body: baseBody,
1830
- });
1831
- let chatGuidForActions = chatGuid;
1832
- if (!chatGuidForActions && baseUrl && password) {
1833
- const target =
1834
- isGroup && (chatId || chatIdentifier)
1835
- ? chatId
1836
- ? ({ kind: "chat_id", chatId } as const)
1837
- : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
1838
- : ({ kind: "handle", address: message.senderId } as const);
1839
- if (target.kind !== "chat_identifier" || target.chatIdentifier) {
1840
- chatGuidForActions =
1841
- (await resolveChatGuidForTarget({
1842
- baseUrl,
1843
- password,
1844
- target,
1845
- })) ?? undefined;
1846
- }
1847
- }
1848
-
1849
- const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
1850
- const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
1851
- const ackReactionValue = resolveBlueBubblesAckReaction({
1852
- cfg: config,
1853
- agentId: route.agentId,
1854
- core,
1855
- runtime,
1856
- });
1857
- const shouldAckReaction = () =>
1858
- Boolean(
1859
- ackReactionValue &&
1860
- core.channel.reactions.shouldAckReaction({
1861
- scope: ackReactionScope,
1862
- isDirect: !isGroup,
1863
- isGroup,
1864
- isMentionableGroup: isGroup,
1865
- requireMention: Boolean(requireMention),
1866
- canDetectMention,
1867
- effectiveWasMentioned,
1868
- shouldBypassMention,
1869
- }),
1870
- );
1871
- const ackMessageId = message.messageId?.trim() || "";
1872
- const ackReactionPromise =
1873
- shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
1874
- ? sendBlueBubblesReaction({
1875
- chatGuid: chatGuidForActions,
1876
- messageGuid: ackMessageId,
1877
- emoji: ackReactionValue,
1878
- opts: { cfg: config, accountId: account.accountId },
1879
- }).then(
1880
- () => true,
1881
- (err) => {
1882
- logVerbose(
1883
- core,
1884
- runtime,
1885
- `ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
1886
- );
1887
- return false;
1888
- },
1889
- )
1890
- : null;
1891
-
1892
- // Respect sendReadReceipts config (parity with WhatsApp)
1893
- const sendReadReceipts = account.config.sendReadReceipts !== false;
1894
- if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
1895
- try {
1896
- await markBlueBubblesChatRead(chatGuidForActions, {
1897
- cfg: config,
1898
- accountId: account.accountId,
1899
- });
1900
- logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
1901
- } catch (err) {
1902
- runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
1903
- }
1904
- } else if (!sendReadReceipts) {
1905
- logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
1906
- } else {
1907
- logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
1908
- }
1909
-
1910
- const outboundTarget = isGroup
1911
- ? formatBlueBubblesChatTarget({
1912
- chatId,
1913
- chatGuid: chatGuidForActions ?? chatGuid,
1914
- chatIdentifier,
1915
- }) || peerId
1916
- : chatGuidForActions
1917
- ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
1918
- : message.senderId;
1919
-
1920
- const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
1921
- const trimmed = messageId?.trim();
1922
- if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
1923
- // Cache outbound message to get short ID
1924
- const cacheEntry = rememberBlueBubblesReplyCache({
1925
- accountId: account.accountId,
1926
- messageId: trimmed,
1927
- chatGuid: chatGuidForActions ?? chatGuid,
1928
- chatIdentifier,
1929
- chatId,
1930
- senderLabel: "me",
1931
- body: snippet ?? "",
1932
- timestamp: Date.now(),
1933
- });
1934
- const displayId = cacheEntry.shortId || trimmed;
1935
- const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
1936
- core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
1937
- sessionKey: route.sessionKey,
1938
- contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
1939
- });
1940
- };
1941
-
1942
- const ctxPayload = {
1943
- Body: body,
1944
- BodyForAgent: body,
1945
- RawBody: rawBody,
1946
- CommandBody: rawBody,
1947
- BodyForCommands: rawBody,
1948
- MediaUrl: mediaUrls[0],
1949
- MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
1950
- MediaPath: mediaPaths[0],
1951
- MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
1952
- MediaType: mediaTypes[0],
1953
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
1954
- From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
1955
- To: `bluebubbles:${outboundTarget}`,
1956
- SessionKey: route.sessionKey,
1957
- AccountId: route.accountId,
1958
- ChatType: isGroup ? "group" : "direct",
1959
- ConversationLabel: fromLabel,
1960
- // Use short ID for token savings (agent can use this to reference the message)
1961
- ReplyToId: replyToShortId || replyToId,
1962
- ReplyToIdFull: replyToId,
1963
- ReplyToBody: replyToBody,
1964
- ReplyToSender: replyToSender,
1965
- GroupSubject: groupSubject,
1966
- GroupMembers: groupMembers,
1967
- SenderName: message.senderName || undefined,
1968
- SenderId: message.senderId,
1969
- Provider: "bluebubbles",
1970
- Surface: "bluebubbles",
1971
- // Use short ID for token savings (agent can use this to reference the message)
1972
- MessageSid: messageShortId || message.messageId,
1973
- MessageSidFull: message.messageId,
1974
- Timestamp: message.timestamp,
1975
- OriginatingChannel: "bluebubbles",
1976
- OriginatingTo: `bluebubbles:${outboundTarget}`,
1977
- WasMentioned: effectiveWasMentioned,
1978
- CommandAuthorized: commandAuthorized,
1979
- };
1980
-
1981
- let sentMessage = false;
1982
- try {
1983
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1984
- ctx: ctxPayload,
1985
- cfg: config,
1986
- dispatcherOptions: {
1987
- deliver: async (payload) => {
1988
- const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
1989
- // Resolve short ID (e.g., "5") to full UUID
1990
- const replyToMessageGuid = rawReplyToId
1991
- ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
1992
- : "";
1993
- const mediaList = payload.mediaUrls?.length
1994
- ? payload.mediaUrls
1995
- : payload.mediaUrl
1996
- ? [payload.mediaUrl]
1997
- : [];
1998
- if (mediaList.length > 0) {
1999
- const tableMode = core.channel.text.resolveMarkdownTableMode({
2000
- cfg: config,
2001
- channel: "bluebubbles",
2002
- accountId: account.accountId,
2003
- });
2004
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
2005
- let first = true;
2006
- for (const mediaUrl of mediaList) {
2007
- const caption = first ? text : undefined;
2008
- first = false;
2009
- const result = await sendBlueBubblesMedia({
2010
- cfg: config,
2011
- to: outboundTarget,
2012
- mediaUrl,
2013
- caption: caption ?? undefined,
2014
- replyToId: replyToMessageGuid || null,
2015
- accountId: account.accountId,
2016
- });
2017
- const cachedBody = (caption ?? "").trim() || "<media:attachment>";
2018
- maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
2019
- sentMessage = true;
2020
- statusSink?.({ lastOutboundAt: Date.now() });
2021
- }
2022
- return;
2023
- }
2024
-
2025
- const textLimit =
2026
- account.config.textChunkLimit && account.config.textChunkLimit > 0
2027
- ? account.config.textChunkLimit
2028
- : DEFAULT_TEXT_LIMIT;
2029
- const chunkMode = account.config.chunkMode ?? "length";
2030
- const tableMode = core.channel.text.resolveMarkdownTableMode({
2031
- cfg: config,
2032
- channel: "bluebubbles",
2033
- accountId: account.accountId,
2034
- });
2035
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
2036
- const chunks =
2037
- chunkMode === "newline"
2038
- ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
2039
- : core.channel.text.chunkMarkdownText(text, textLimit);
2040
- if (!chunks.length && text) chunks.push(text);
2041
- if (!chunks.length) return;
2042
- for (let i = 0; i < chunks.length; i++) {
2043
- const chunk = chunks[i];
2044
- const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
2045
- cfg: config,
2046
- accountId: account.accountId,
2047
- replyToMessageGuid: replyToMessageGuid || undefined,
2048
- });
2049
- maybeEnqueueOutboundMessageId(result.messageId, chunk);
2050
- sentMessage = true;
2051
- statusSink?.({ lastOutboundAt: Date.now() });
2052
- // In newline mode, restart typing after each chunk if more chunks remain
2053
- // Small delay allows the Apple API to finish clearing the typing state from message send
2054
- if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
2055
- await new Promise((r) => setTimeout(r, 150));
2056
- sendBlueBubblesTyping(chatGuidForActions, true, {
2057
- cfg: config,
2058
- accountId: account.accountId,
2059
- }).catch(() => {
2060
- // Ignore typing errors
2061
- });
2062
- }
2063
- }
2064
- },
2065
- onReplyStart: async () => {
2066
- if (!chatGuidForActions) return;
2067
- if (!baseUrl || !password) return;
2068
- logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
2069
- try {
2070
- await sendBlueBubblesTyping(chatGuidForActions, true, {
2071
- cfg: config,
2072
- accountId: account.accountId,
2073
- });
2074
- } catch (err) {
2075
- runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
2076
- }
2077
- },
2078
- onIdle: async () => {
2079
- if (!chatGuidForActions) return;
2080
- if (!baseUrl || !password) return;
2081
- try {
2082
- await sendBlueBubblesTyping(chatGuidForActions, false, {
2083
- cfg: config,
2084
- accountId: account.accountId,
2085
- });
2086
- } catch (err) {
2087
- logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
2088
- }
2089
- },
2090
- onError: (err, info) => {
2091
- runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
2092
- },
2093
- },
2094
- replyOptions: {
2095
- disableBlockStreaming:
2096
- typeof account.config.blockStreaming === "boolean"
2097
- ? !account.config.blockStreaming
2098
- : undefined,
2099
- },
2100
- });
2101
- } finally {
2102
- if (sentMessage && chatGuidForActions && ackMessageId) {
2103
- core.channel.reactions.removeAckReactionAfterReply({
2104
- removeAfterReply: removeAckAfterReply,
2105
- ackReactionPromise,
2106
- ackReactionValue: ackReactionValue ?? null,
2107
- remove: () =>
2108
- sendBlueBubblesReaction({
2109
- chatGuid: chatGuidForActions,
2110
- messageGuid: ackMessageId,
2111
- emoji: ackReactionValue ?? "",
2112
- remove: true,
2113
- opts: { cfg: config, accountId: account.accountId },
2114
- }),
2115
- onError: (err) => {
2116
- logAckFailure({
2117
- log: (msg) => logVerbose(core, runtime, msg),
2118
- channel: "bluebubbles",
2119
- target: `${chatGuidForActions}/${ackMessageId}`,
2120
- error: err,
2121
- });
2122
- },
2123
- });
2124
- }
2125
- if (chatGuidForActions && baseUrl && password && !sentMessage) {
2126
- // Stop typing indicator when no message was sent (e.g., NO_REPLY)
2127
- sendBlueBubblesTyping(chatGuidForActions, false, {
2128
- cfg: config,
2129
- accountId: account.accountId,
2130
- }).catch((err) => {
2131
- logTypingFailure({
2132
- log: (msg) => logVerbose(core, runtime, msg),
2133
- channel: "bluebubbles",
2134
- action: "stop",
2135
- target: chatGuidForActions,
2136
- error: err,
2137
- });
2138
- });
2139
- }
2140
- }
2141
- }
2142
-
2143
- async function processReaction(
2144
- reaction: NormalizedWebhookReaction,
2145
- target: WebhookTarget,
2146
- ): Promise<void> {
2147
- const { account, config, runtime, core } = target;
2148
- if (reaction.fromMe) return;
2149
-
2150
- const dmPolicy = account.config.dmPolicy ?? "pairing";
2151
- const groupPolicy = account.config.groupPolicy ?? "allowlist";
2152
- const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
2153
- const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
2154
- const storeAllowFrom = await core.channel.pairing
2155
- .readAllowFromStore("bluebubbles")
2156
- .catch(() => []);
2157
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
2158
- .map((entry) => String(entry).trim())
2159
- .filter(Boolean);
2160
- const effectiveGroupAllowFrom = [
2161
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
2162
- ...storeAllowFrom,
2163
- ]
2164
- .map((entry) => String(entry).trim())
2165
- .filter(Boolean);
2166
-
2167
- if (reaction.isGroup) {
2168
- if (groupPolicy === "disabled") return;
2169
- if (groupPolicy === "allowlist") {
2170
- if (effectiveGroupAllowFrom.length === 0) return;
2171
- const allowed = isAllowedBlueBubblesSender({
2172
- allowFrom: effectiveGroupAllowFrom,
2173
- sender: reaction.senderId,
2174
- chatId: reaction.chatId ?? undefined,
2175
- chatGuid: reaction.chatGuid ?? undefined,
2176
- chatIdentifier: reaction.chatIdentifier ?? undefined,
2177
- });
2178
- if (!allowed) return;
2179
- }
2180
- } else {
2181
- if (dmPolicy === "disabled") return;
2182
- if (dmPolicy !== "open") {
2183
- const allowed = isAllowedBlueBubblesSender({
2184
- allowFrom: effectiveAllowFrom,
2185
- sender: reaction.senderId,
2186
- chatId: reaction.chatId ?? undefined,
2187
- chatGuid: reaction.chatGuid ?? undefined,
2188
- chatIdentifier: reaction.chatIdentifier ?? undefined,
2189
- });
2190
- if (!allowed) return;
2191
- }
2192
- }
2193
-
2194
- const chatId = reaction.chatId ?? undefined;
2195
- const chatGuid = reaction.chatGuid ?? undefined;
2196
- const chatIdentifier = reaction.chatIdentifier ?? undefined;
2197
- const peerId = reaction.isGroup
2198
- ? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
2199
- : reaction.senderId;
2200
-
2201
- const route = core.channel.routing.resolveAgentRoute({
2202
- cfg: config,
2203
- channel: "bluebubbles",
2204
- accountId: account.accountId,
2205
- peer: {
2206
- kind: reaction.isGroup ? "group" : "dm",
2207
- id: peerId,
2208
- },
2209
- });
2210
-
2211
- const senderLabel = reaction.senderName || reaction.senderId;
2212
- const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
2213
- // Use short ID for token savings
2214
- const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
2215
- // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
2216
- const text =
2217
- reaction.action === "removed"
2218
- ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
2219
- : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
2220
- core.system.enqueueSystemEvent(text, {
2221
- sessionKey: route.sessionKey,
2222
- contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
2223
- });
2224
- logVerbose(core, runtime, `reaction event enqueued: ${text}`);
2225
- }
2226
-
2227
484
  export async function monitorBlueBubblesProvider(
2228
485
  options: BlueBubblesMonitorOptions,
2229
486
  ): Promise<void> {
@@ -2241,6 +498,11 @@ export async function monitorBlueBubblesProvider(
2241
498
  if (serverInfo?.os_version) {
2242
499
  runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
2243
500
  }
501
+ if (typeof serverInfo?.private_api === "boolean") {
502
+ runtime.log?.(
503
+ `[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`,
504
+ );
505
+ }
2244
506
 
2245
507
  const unregister = registerBlueBubblesWebhookTarget({
2246
508
  account,
@@ -2269,8 +531,4 @@ export async function monitorBlueBubblesProvider(
2269
531
  });
2270
532
  }
2271
533
 
2272
- export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
2273
- const raw = config?.webhookPath?.trim();
2274
- if (raw) return normalizeWebhookPath(raw);
2275
- return DEFAULT_WEBHOOK_PATH;
2276
- }
534
+ export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig };