synabun 2.0.5 → 2.0.7

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 (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -1,8 +1,12 @@
1
1
  // Main-process side of the WhatsApp Link: forks host.js — the only process
2
2
  // that loads Baileys or reads the WhatsApp credentials — speaks protocol.js
3
- // over IPC, respawns it with backoff, and trips a crash breaker (host.state
4
- // 'held') when it keeps dying. mode 'inproc' (SYNABUN_WHATSAPP_HOST=inproc)
5
- // runs host-core inside this process instead: tests and a kill switch.
3
+ // over IPC, and respawns it whenever it dies, for as long as it is wanted:
4
+ // 1 / 5 / 15 / 60 / 300 s, never more than 300 s, the ladder starting over
5
+ // once a host ran for 10 minutes. host.state 'held' is only a host speaking
6
+ // another IPC protocol (an update installed while SynaBun ran): it is killed
7
+ // and stays down until start() again. mode 'inproc'
8
+ // (SYNABUN_WHATSAPP_HOST=inproc) runs host-core inside this process instead:
9
+ // tests and a kill switch.
6
10
  //
7
11
  // Same shape as lib/pty-host/manager.js and lib/desktop/manager.js, with two
8
12
  // deliberate differences: the child gets an ALLOWLISTED environment (no API
@@ -10,15 +14,19 @@
10
14
  // redacted before it reaches a log (never inherited).
11
15
  //
12
16
  // Every method resolves; none rejects. Failures are {ok:false, code, message}.
13
- // After a respawn the host receives the last configure, then — if the link
14
- // was running — connect({purpose:'run'}) again.
17
+ // The run intent (connect({purpose:'run'}), or a fresh link) is kept here
18
+ // before the request leaves, so a host that is still starting, or one that
19
+ // crashed with the request in flight, gets it anyway: every ready replays the
20
+ // last configure, then connect({purpose:'run'}) while the intent stands. Only
21
+ // NOT_LINKED, a terminal state or fatal event of the host (TERMINAL_CONN,
22
+ // DESIRE_KILLERS), stop(), disconnect() and logout() drop it.
15
23
 
16
24
  import { fork } from 'node:child_process';
17
25
  import { mkdirSync } from 'node:fs';
18
26
  import { fileURLToPath } from 'node:url';
19
- import { CONN, ERR, EV, LIMITS, OP, PROTOCOL_VERSION, pickPrefs, validateEvent } from './protocol.js';
27
+ import { ADDRESS_KEYS, CONN, ERR, EV, LIMITS, OP, PROTOCOL_VERSION, pickPrefs, validateEvent } from './protocol.js';
20
28
  import { resolveWhatsAppPaths } from './paths.js';
21
- import { redactWa } from './redact.js';
29
+ import { redactLogLine, redactWa } from './redact.js';
22
30
 
23
31
  const HOST_PATH = fileURLToPath(new URL('./host.js', import.meta.url));
24
32
 
@@ -77,7 +85,10 @@ export function hostExecArgv(parent = process.execArgv, { nodeVersion = process.
77
85
 
78
86
  const PUBLIC_EVENTS = new Set(['state', 'qr', 'pairing_code', 'linked', 'owner_bound', 'inbound', 'ignored', 'throttled', 'fatal']);
79
87
  const TERMINAL_CONN = new Set([CONN.LOGGED_OUT, CONN.REPLACED, CONN.FORBIDDEN, CONN.UNLINKED, CONN.LINK_EXPIRED, CONN.ERROR, CONN.LOCKED]);
80
- const DESIRE_KILLERS = new Set(['LOGGED_OUT', 'REPLACED', 'FORBIDDEN', 'SESSION_ERROR', 'CONFIRM_TIMEOUT', 'RECONNECT_LIMIT', 'LOCKED']);
88
+ // Fatal events that end the run intent: a logout (401, or a session WhatsApp calls invalid), an
89
+ // account refused while linking, an owner never confirmed, a session store held by another process.
90
+ // A saved session never stops on 440, 403 or a generic 500: the host retries those.
91
+ const DESIRE_KILLERS = new Set(['LOGGED_OUT', 'FORBIDDEN', 'CONFIRM_TIMEOUT', 'LOCKED']);
81
92
  const LINE_BURST = 60;
82
93
  const LINE_WINDOW_MS = 60_000;
83
94
 
@@ -114,11 +125,12 @@ export function createWhatsAppManager({
114
125
  log = () => {},
115
126
  now = () => Date.now(),
116
127
  respawnBackoffMs = [1000, 5000, 15000, 60000, 300000],
117
- crashLimit = 5,
118
- crashWindowMs = 15 * 60_000,
128
+ respawnMaxMs = 300_000, // no respawn waits longer, whatever the ladder says
119
129
  stableMs = 10 * 60_000,
120
130
  requestTimeoutMs = 15_000,
121
131
  sendTimeoutMs = 90_000,
132
+ // send_file: a read of up to 100 MiB and its upload. Past it the delivery is uncertain (never retried).
133
+ sendFileTimeoutMs = 120_000,
122
134
  readyTimeoutMs = 20_000,
123
135
  env = process.env,
124
136
  platform = process.platform,
@@ -135,7 +147,6 @@ export function createWhatsAppManager({
135
147
  let core = null;
136
148
  let token = null; // identity of the current host (child or inproc core)
137
149
  let ready = false;
138
- let hadReady = false;
139
150
  let hostState = 'stopped';
140
151
  let hostPid = null;
141
152
  let restarts = 0;
@@ -143,10 +154,10 @@ export function createWhatsAppManager({
143
154
  let heldReason = null;
144
155
  let launchError = null; // why the current launch failed before its ready event
145
156
  let configured = Promise.resolve(); // the configure replayed on ready
146
- let exitTimes = [];
147
157
  let failIdx = 0;
148
158
  let startedAt = 0;
149
159
  let respawnTimer = null;
160
+ let respawnAt = null; // when the next host starts, while hostState is 'backoff'
150
161
  let readyTimer = null;
151
162
  let stoppedByUser = true;
152
163
  let lastPrefs = null;
@@ -175,8 +186,11 @@ export function createWhatsAppManager({
175
186
  const conn = { ...connCache };
176
187
  if (hostState === 'held') conn.state = CONN.HELD;
177
188
  else if (hostState === 'stopped') conn.state = CONN.IDLE;
189
+ // host.retryInMs: how long until the next host starts. While the link is wanted, conn says
190
+ // "reconnecting" with the same wait (set when the respawn was scheduled, like the host's own retryInMs).
191
+ const retryInMs = hostState === 'backoff' && respawnAt !== null ? Math.max(0, respawnAt - now()) : null;
178
192
  return {
179
- host: { state: hostState, pid: hostPid, restarts, lastError },
193
+ host: { state: hostState, pid: hostPid, restarts, lastError, retryInMs },
180
194
  conn,
181
195
  runtime: { ...runtimeInfo },
182
196
  };
@@ -225,7 +239,7 @@ export function createWhatsAppManager({
225
239
  function request(op, fields = {}, { timeoutMs = requestTimeoutMs } = {}) {
226
240
  return new Promise((resolve) => {
227
241
  if (hostState === 'held') {
228
- resolve(fail(ERR.HELD, heldReason || lastError || 'the WhatsApp host is held after repeated crashes'));
242
+ resolve(fail(ERR.HELD, heldReason || lastError || 'the WhatsApp host speaks another protocol version'));
229
243
  return;
230
244
  }
231
245
  if (hostState === 'stopped') {
@@ -280,6 +294,7 @@ export function createWhatsAppManager({
280
294
 
281
295
  // ── Host → manager ──
282
296
 
297
+ /** An incompatible host (another IPC protocol): no respawn until start() again. */
283
298
  function hold(reason) {
284
299
  heldReason = reason;
285
300
  lastError = reason;
@@ -287,6 +302,7 @@ export function createWhatsAppManager({
287
302
  desired = null;
288
303
  clearTimeout(respawnTimer);
289
304
  respawnTimer = null;
305
+ respawnAt = null;
290
306
  failAll(ERR.HELD, reason);
291
307
  resolveReadyWaiters(false);
292
308
  say(`[whatsapp] host held: ${reason}`, 'warn');
@@ -315,19 +331,17 @@ export function createWhatsAppManager({
315
331
  ready = true;
316
332
  hostPid = m.pid;
317
333
  hostState = 'running';
334
+ respawnAt = null;
318
335
  runtimeInfo = { ...m.runtime };
319
336
  connCache = { ...m.conn };
320
- const replay = hadReady;
321
- hadReady = true;
337
+ // Every ready, the first one included: the configure first, then the run intent.
322
338
  configured = lastPrefs
323
339
  ? request(OP.CONFIGURE, { prefs: lastPrefs }).then((r) => { if (!r.ok) say(`[whatsapp] configure failed: ${r.message}`, 'warn'); })
324
340
  : Promise.resolve();
341
+ // A connect that waited for this host carries the intent itself.
342
+ const connectWaiting = queue.some((q) => q.op === OP.CONNECT && pending.has(q.reqId));
325
343
  flushQueue();
326
- if (replay && desired) {
327
- request(OP.CONNECT, { ...desired }).then((r) => {
328
- if (!r.ok) say(`[whatsapp] reconnect after a host restart failed: ${r.code}`, 'warn');
329
- });
330
- }
344
+ if (desired && !connectWaiting) resumeRun(source);
331
345
  resolveReadyWaiters(true);
332
346
  emitState();
333
347
  return;
@@ -348,7 +362,8 @@ export function createWhatsAppManager({
348
362
  emitState();
349
363
  return;
350
364
  case EV.LOG:
351
- say(`[whatsapp-host] ${redactWa(m.message)}`, m.level === 'error' ? 'error' : m.level === 'warn' ? 'warn' : 'info');
365
+ // send_file's audit line keeps its path legible (redact.js fileAuditLine checks its exact shape).
366
+ say(`[whatsapp-host] ${redactLogLine(m.message)}`, m.level === 'error' ? 'error' : m.level === 'warn' ? 'warn' : 'info');
352
367
  return;
353
368
  case EV.FATAL:
354
369
  if (DESIRE_KILLERS.has(m.code)) desired = null;
@@ -368,6 +383,19 @@ export function createWhatsAppManager({
368
383
  }
369
384
  }
370
385
 
386
+ /** After `configured`, connect the wanted link on the host that just became ready (`me`). */
387
+ function resumeRun(me) {
388
+ configured.then(() => {
389
+ if (token !== me || !ready || stoppedByUser || !desired) return;
390
+ const intent = desired;
391
+ request(OP.CONNECT, { ...intent }).then((r) => {
392
+ if (r.ok) return;
393
+ if (r.code === ERR.NOT_LINKED && desired === intent) desired = null;
394
+ say(`[whatsapp] connecting after the host started failed: ${r.code}`, 'warn');
395
+ });
396
+ });
397
+ }
398
+
371
399
  // ── Lifecycle ──
372
400
 
373
401
  function allowLine() {
@@ -404,6 +432,9 @@ export function createWhatsAppManager({
404
432
  function launch() {
405
433
  clearTimeout(respawnTimer);
406
434
  respawnTimer = null;
435
+ respawnAt = null;
436
+ // The respawn wait is over: a host starting now has no "next try" to show.
437
+ if (connCache.retryInMs !== null) connCache = { ...connCache, retryInMs: null };
407
438
  ready = false;
408
439
  hostPid = null;
409
440
  launchError = null;
@@ -492,21 +523,18 @@ export function createWhatsAppManager({
492
523
  emitState();
493
524
  return;
494
525
  }
526
+ // A crash is never final: respawn on the ladder, for as long as the host is wanted.
495
527
  const reason = `exited (code ${code}, signal ${signal})`;
496
528
  lastError = launchError || `the WhatsApp host ${reason}`;
497
529
  launchError = null;
498
530
  const t = now();
499
- exitTimes = exitTimes.filter((x) => t - x < crashWindowMs);
500
- exitTimes.push(t);
501
- if (exitTimes.length >= crashLimit) {
502
- hold(`the WhatsApp host crashed ${exitTimes.length} times in ${Math.round(crashWindowMs / 60_000)} minutes`);
503
- return;
504
- }
505
531
  if (startedAt && t - startedAt >= stableMs) failIdx = 0;
506
- const delay = respawnBackoffMs[Math.min(failIdx, respawnBackoffMs.length - 1)] ?? 0;
532
+ const delay = Math.min(respawnMaxMs, Math.max(0, respawnBackoffMs[Math.min(failIdx, respawnBackoffMs.length - 1)] ?? 0));
507
533
  failIdx += 1;
508
534
  restarts += 1;
509
535
  hostState = 'backoff';
536
+ respawnAt = t + delay;
537
+ if (desired) connCache = { ...connCache, retryInMs: delay };
510
538
  say(`[whatsapp] host ${reason}; restarting in ${delay} ms`, 'warn');
511
539
  emitState();
512
540
  respawnTimer = setTimeout(() => {
@@ -517,12 +545,16 @@ export function createWhatsAppManager({
517
545
 
518
546
  // ── Public API ──
519
547
 
520
- async function start(prefs) {
548
+ /**
549
+ * Start the host (at once, also while a respawn is pending). `resume: false` drops the run intent
550
+ * first: a host started to log out (Unlink) must not reconnect the link on its ready.
551
+ */
552
+ async function start(prefs, { resume = true } = {}) {
521
553
  if (prefs !== undefined) lastPrefs = { ...(lastPrefs || {}), ...pickPrefs(prefs) };
554
+ if (!resume) desired = null;
522
555
  stoppedByUser = false;
523
556
  if (hostState === 'held') {
524
557
  heldReason = null;
525
- exitTimes = [];
526
558
  failIdx = 0;
527
559
  hostState = 'stopped';
528
560
  }
@@ -544,8 +576,13 @@ export function createWhatsAppManager({
544
576
  const fields = { purpose };
545
577
  if (connMode) fields.mode = connMode;
546
578
  if (link) fields.link = { method: link.method === 'code' ? 'code' : 'qr', ...(link.phone ? { phone: String(link.phone) } : {}) };
579
+ // The intent before the request: a host that is not ready yet, or that dies with the request
580
+ // in flight, connects on its next ready. Only "nothing is linked" ends it here. A host that was
581
+ // stopped or is held gets no ready of its own: whatever starts it next asks for what it wants.
582
+ const intent = purpose === 'run' && hostState !== 'stopped' && hostState !== 'held' ? { purpose: 'run', ...(connMode ? { mode: connMode } : {}) } : null;
583
+ if (intent) desired = intent;
547
584
  const res = await request(OP.CONNECT, fields);
548
- if (res.ok && purpose === 'run') desired = { purpose: 'run', ...(connMode ? { mode: connMode } : {}) };
585
+ if (intent && !res.ok && res.code === ERR.NOT_LINKED && desired === intent) desired = null;
549
586
  return res;
550
587
  }
551
588
 
@@ -554,6 +591,7 @@ export function createWhatsAppManager({
554
591
  desired = null;
555
592
  clearTimeout(respawnTimer);
556
593
  respawnTimer = null;
594
+ respawnAt = null;
557
595
  clearTimeout(readyTimer);
558
596
  readyTimer = null;
559
597
  resolveReadyWaiters(false);
@@ -595,6 +633,7 @@ export function createWhatsAppManager({
595
633
  desired = null;
596
634
  clearTimeout(respawnTimer);
597
635
  respawnTimer = null;
636
+ respawnAt = null;
598
637
  clearTimeout(readyTimer);
599
638
  readyTimer = null;
600
639
  if (child) { try { child.kill('SIGTERM'); } catch {} }
@@ -623,6 +662,27 @@ export function createWhatsAppManager({
623
662
  if (typeof text !== 'string') return Promise.resolve(fail(ERR.BAD_REQUEST, 'text must be a string'));
624
663
  return request(OP.SEND, { text, ...(replyTo ? { replyTo } : {}) }, { timeoutMs: sendTimeoutMs });
625
664
  },
665
+ /**
666
+ * A local file to the owner (send_file): exactly one request, never retried here or anywhere,
667
+ * and never queued for a host that is not up yet (HOST_UNAVAILABLE: nothing was sent). A TIMEOUT,
668
+ * or a host that exits with the request in flight, may come after WhatsApp took the file: those
669
+ * answers carry `uncertain: true`. Only { path, caption?, as? } travel: an address key is
670
+ * ADDRESS_FORBIDDEN, any other key BAD_REQUEST.
671
+ */
672
+ sendFile: async (fields = {}) => {
673
+ if (!isPlainObject(fields)) return fail(ERR.BAD_REQUEST, 'send_file takes { path, caption?, as? }');
674
+ const address = ADDRESS_KEYS.find((key) => Object.hasOwn(fields, key));
675
+ if (address) return fail(ERR.ADDRESS_FORBIDDEN, `send_file never takes an address ('${address}')`);
676
+ const extra = Object.keys(fields).find((key) => !['path', 'caption', 'as'].includes(key));
677
+ if (extra) return fail(ERR.BAD_REQUEST, `send_file has an unknown field '${extra.slice(0, 40)}'`);
678
+ if (!ready) return fail(ERR.HOST_UNAVAILABLE, 'the WhatsApp host is not running: nothing was sent');
679
+ const out = { path: fields.path };
680
+ if (fields.caption !== undefined && fields.caption !== null) out.caption = fields.caption;
681
+ if (fields.as !== undefined && fields.as !== null) out.as = fields.as;
682
+ const result = await request(OP.SEND_FILE, out, { timeoutMs: sendFileTimeoutMs });
683
+ if (!result.ok && (result.code === ERR.TIMEOUT || result.code === ERR.HOST_UNAVAILABLE)) result.uncertain = true;
684
+ return result;
685
+ },
626
686
  react: (id, name) => request(OP.REACT, { id, reaction: name ?? null }, { timeoutMs: sendTimeoutMs }),
627
687
  presence: (state) => request(OP.PRESENCE, { state }),
628
688
  markRead: (ids) => request(OP.READ, { ids: Array.isArray(ids) ? ids.slice(0, LIMITS.MAX_READ_IDS) : [] }),
@@ -9,8 +9,16 @@
9
9
  // session to a second machine (that ends in connectionReplaced).
10
10
  // runtimeDir the Baileys runtime, installed on demand from the pinned
11
11
  // lockfile in ./connector (never in a SynaBun package.json).
12
+ // transcriptionDir voice notes (transcription.js): where the user puts
13
+ // whisper.cpp (bin/), FFmpeg (bin/) and the model, plus the
14
+ // private scratch folder of each note. A sibling of runtimeDir:
15
+ // outside every backup. SynaBun never downloads anything here.
12
16
  // stagingDir where the installer builds the next runtime before swapping.
13
17
  // logsDir SynaBun's shared log folder.
18
+ // dataHome DATA_HOME itself: a file the Assistant sends from WhatsApp is
19
+ // never taken from it or from waHome, save SynaBun's generated
20
+ // media (local-file.js protectedPlaces); SynaBun's backup
21
+ // archives are refused by name wherever the backup folder is.
14
22
  //
15
23
  // Pure: no filesystem access, so callers and tests can resolve any platform.
16
24
 
@@ -31,7 +39,7 @@ export function isInside(candidate, parent, platform = process.platform) {
31
39
  /**
32
40
  * @param {{dataHome: string, env?: Record<string,string|undefined>, platform?: string, extraBackupRoots?: string[]}} opts
33
41
  * `extraBackupRoots`: folders a backup copies besides BACKUP_DATA_DIRS (server.js' additional entries).
34
- * @returns {{waHome:string, defaultWaHome:string, authDir:string, runtimeDir:string, stagingDir:string, logsDir:string,
42
+ * @returns {{dataHome:string, waHome:string, defaultWaHome:string, authDir:string, runtimeDir:string, transcriptionDir:string, stagingDir:string, logsDir:string,
35
43
  * installLogPath:string, installLockPath:string, npmCacheDir:string, backupRoots:string[], warnings:string[]}}
36
44
  */
37
45
  export function resolveWhatsAppPaths({ dataHome, env = process.env, platform = process.platform, extraBackupRoots = [] } = {}) {
@@ -66,10 +74,12 @@ export function resolveWhatsAppPaths({ dataHome, env = process.env, platform = p
66
74
 
67
75
  const runtimeRoot = p.join(home, 'runtime');
68
76
  return {
77
+ dataHome: home,
69
78
  waHome,
70
79
  defaultWaHome,
71
80
  authDir: p.join(waHome, 'auth'),
72
81
  runtimeDir: p.join(runtimeRoot, 'whatsapp'),
82
+ transcriptionDir: p.join(runtimeRoot, 'whatsapp-transcription'),
73
83
  stagingDir: p.join(runtimeRoot, 'whatsapp.staging'),
74
84
  logsDir: p.join(dataDir, 'logs'),
75
85
  installLogPath: p.join(dataDir, 'whatsapp', 'install.log'),
@@ -9,13 +9,15 @@
9
9
  // 'inbound'|'ignored'|'throttled'|'fatal'|'log', ...}
10
10
  //
11
11
  // Addressing: no op that makes the host talk to WhatsApp carries an address.
12
- // send/react/presence/read always reach the bound owner (sendToOwner in
13
- // host-core.js is the only sendMessage call site) and validateOp rejects any
12
+ // send/send_file/react/presence/read always reach the bound owner (sendToOwner
13
+ // in host-core.js is the only sendMessage call site) and validateOp rejects any
14
14
  // address-shaped key on them. Events never carry a JID or a phone number: the
15
15
  // schemas below are closed, so a stray field fails validation instead of
16
16
  // leaking. The single exception is the claim_start reply, whose wa.me link
17
17
  // holds the dedicated account's own number for the local settings tab.
18
18
 
19
+ import path from 'node:path';
20
+
19
21
  export const PROTOCOL_VERSION = 1;
20
22
 
21
23
  export const OP = Object.freeze({
@@ -28,6 +30,8 @@ export const OP = Object.freeze({
28
30
  CLAIM_CANCEL: 'claim_cancel',
29
31
  OWNER_RESET: 'owner_reset',
30
32
  SEND: 'send',
33
+ // A local file to the owner as a WhatsApp attachment (local-file.js reads and checks it in the host).
34
+ SEND_FILE: 'send_file',
31
35
  REACT: 'react',
32
36
  PRESENCE: 'presence',
33
37
  READ: 'read',
@@ -52,7 +56,12 @@ export const EV = Object.freeze({
52
56
  LOG: 'log',
53
57
  });
54
58
 
55
- /** conn.state values. `held` is set by the manager (crash breaker), never by the host. */
59
+ /**
60
+ * conn.state values. `held` is set by the manager (a host speaking another
61
+ * IPC protocol: an update installed while SynaBun ran), never by the host.
62
+ * The host no longer stops in `replaced` or `forbidden` for a saved session
63
+ * (440 and 403 are retried: CONN_WARNINGS); only a link attempt still ends there.
64
+ */
56
65
  export const CONN = Object.freeze({
57
66
  IDLE: 'idle',
58
67
  UNLINKED: 'unlinked',
@@ -91,6 +100,10 @@ export const ERR = Object.freeze({
91
100
  PAUSED: 'PAUSED',
92
101
  THROTTLED: 'THROTTLED',
93
102
  TOO_LONG: 'TOO_LONG',
103
+ // send_file (local-file.js): a protected location, over the size limit, or not a readable regular file.
104
+ FILE_FORBIDDEN: 'FILE_FORBIDDEN',
105
+ FILE_TOO_LARGE: 'FILE_TOO_LARGE',
106
+ FILE_INVALID: 'FILE_INVALID',
94
107
  BAD_TARGET: 'BAD_TARGET',
95
108
  BAD_PHONE: 'BAD_PHONE',
96
109
  CLAIM_INACTIVE: 'CLAIM_INACTIVE',
@@ -103,15 +116,46 @@ export const ERR = Object.freeze({
103
116
  INTERNAL: 'INTERNAL',
104
117
  });
105
118
 
119
+ /**
120
+ * Disconnect codes a saved session keeps retrying at the longest delay while
121
+ * Settings shows a warning: 440 connectionReplaced (another copy uses the
122
+ * session) and 403 forbidden (the account is refused). The host picks the
123
+ * delay by this table (baileys-adapter.js classifyDisconnect), the service the
124
+ * warning, from conn.lastDisconnect.code.
125
+ */
126
+ export const CONN_WARNINGS = Object.freeze({ 440: 'replaced', 403: 'forbidden' });
127
+
106
128
  export const LIMITS = Object.freeze({
107
129
  MAX_TEXT: 3500,
108
130
  MAX_INBOUND_TEXT: 8192,
109
131
  MAX_IMAGE_BYTES: 5242880,
110
132
  MAX_IMAGES: 4,
111
133
  MAX_READ_IDS: 20,
134
+ // Voice notes (transcription.js): declared and actual size, declared and decoded duration.
135
+ MAX_AUDIO_BYTES: 20 * 1024 * 1024,
136
+ MAX_AUDIO_SECONDS: 600,
137
+ // send_file (local-file.js): the path and caption the op takes; a photo, video or audio file above
138
+ // MAX_FILE_MEDIA_BYTES goes as a document; nothing above MAX_FILE_BYTES is read.
139
+ MAX_FILE_PATH: 2048,
140
+ MAX_FILE_CAPTION: 1024,
141
+ MAX_FILE_MEDIA_BYTES: 16 * 1024 * 1024,
142
+ MAX_FILE_BYTES: 100 * 1024 * 1024,
112
143
  });
113
144
 
114
- /** Keys that name a WhatsApp recipient. None may appear on send/react/presence/read. */
145
+ /** send_file `as`: auto (by extension), or the form asked for (a native form must match the extension). */
146
+ export const FILE_SEND_AS = Object.freeze(['auto', 'document', 'image', 'video', 'audio']);
147
+
148
+ /**
149
+ * The text of a transcribed voice note starts with this marker, then the
150
+ * transcript (one line), then "\n" and the caption when there is one. The
151
+ * inbound message also carries `voice: true`: spoken words are an ordinary
152
+ * message, never a command, an ALLOW code or the answer to an approval.
153
+ */
154
+ export const VOICE_MARKER = '[Voice note transcript]';
155
+ /** transcriptionLanguage: 'auto' (whisper.cpp detects it) or a 2-3 letter lowercase language code. */
156
+ export const TRANSCRIPTION_LANGUAGE_RE = /^(?:auto|[a-z]{2,3})$/;
157
+
158
+ /** Keys that name a WhatsApp recipient. None may appear on send/send_file/react/presence/read. */
115
159
  export const ADDRESS_KEYS = Object.freeze(['jid', 'to', 'remoteJid', 'chat', 'number', 'participant', 'recipient']);
116
160
 
117
161
  /** Status reactions the bridge may set on an owner message (null clears it). */
@@ -127,7 +171,7 @@ export const MODES = Object.freeze(['self', 'dedicated']);
127
171
  export const SELF_TRIGGERS = Object.freeze(['all', 'prefix']);
128
172
  export const DEFAULT_PREFIX = 'sb';
129
173
 
130
- const ADDRESSLESS_OPS = new Set([OP.SEND, OP.REACT, OP.PRESENCE, OP.READ]);
174
+ const ADDRESSLESS_OPS = new Set([OP.SEND, OP.SEND_FILE, OP.REACT, OP.PRESENCE, OP.READ]);
131
175
  const MSG_ID_RE = /^[A-Za-z0-9._:+/=-]{1,128}$/;
132
176
  const PREFIX_RE = /^[^\s]{1,16}$/;
133
177
 
@@ -180,10 +224,21 @@ function checkObject(v, spec) {
180
224
 
181
225
  // ── Prefs (configure) ──
182
226
 
227
+ // A tool path the user set in Settings: '' (found by itself) or an absolute path. Whether the file
228
+ // exists and runs is checked where it is used (transcription.js), right before it runs.
229
+ const toolPath = () => (v, p) => (!p || v === undefined ? null
230
+ : typeof v !== 'string' || v.length > 4096 || /[\u0000-\u001f]/.test(v) || (v !== '' && !path.isAbsolute(v)) ? "must be '' or an absolute path" : null);
231
+
183
232
  const PREFS_SPEC = {
184
233
  mode: t.optOneOf(MODES),
185
234
  selfTrigger: t.optOneOf(SELF_TRIGGERS),
186
235
  prefix: (v, p) => (!p || v === undefined ? null : typeof v !== 'string' || !PREFIX_RE.test(v) ? 'must be 1-16 non-space characters' : null),
236
+ // Voice notes (transcription.js); a host that was never configured transcribes nothing.
237
+ transcriptionEnabled: t.optBool(),
238
+ transcriptionLanguage: (v, p) => (!p || v === undefined ? null : typeof v !== 'string' || !TRANSCRIPTION_LANGUAGE_RE.test(v) ? "must be 'auto' or a 2-3 letter language code" : null),
239
+ transcriptionEnginePath: toolPath(),
240
+ transcriptionModelPath: toolPath(),
241
+ transcriptionFfmpegPath: toolPath(),
187
242
  };
188
243
  export const PREF_KEYS = Object.freeze(Object.keys(PREFS_SPEC));
189
244
 
@@ -212,6 +267,7 @@ const OP_SPECS = {
212
267
  [OP.CLAIM_CANCEL]: {},
213
268
  [OP.OWNER_RESET]: {},
214
269
  [OP.SEND]: { text: t.str(LIMITS.MAX_TEXT * 4), replyTo: (v, p) => (!p || v === undefined || v === null ? null : typeof v !== 'string' || !MSG_ID_RE.test(v) ? 'must be a message id' : null) },
270
+ [OP.SEND_FILE]: { path: t.str(LIMITS.MAX_FILE_PATH), caption: t.optStr(LIMITS.MAX_FILE_CAPTION), as: t.optOneOf(FILE_SEND_AS) },
215
271
  [OP.REACT]: { id: t.msgId(), reaction: (v, p) => (!p ? 'is required' : v === null || Object.hasOwn(REACTIONS, v) ? null : `must be one of ${Object.keys(REACTIONS).join('|')} or null`) },
216
272
  [OP.PRESENCE]: { state: t.oneOf(['composing', 'paused']) },
217
273
  [OP.READ]: {
@@ -294,6 +350,10 @@ export const INBOUND_SPEC = {
294
350
  owner: (v, p) => (!p ? 'is required' : v === true ? null : 'must be true'),
295
351
  // Delivered after an offline spell (messages.upsert 'append'); `ts` says when it was sent.
296
352
  backlog: t.optBool(),
353
+ // A voice note transcribed on this computer: `text` is VOICE_MARKER + the transcript (+ "\n" + the caption).
354
+ voice: t.optBool(),
355
+ // …of an audio file, not a note recorded in WhatsApp: its words may be anyone's (the turn is capped like a picture's).
356
+ audioFile: t.optBool(),
297
357
  };
298
358
 
299
359
  const EVENT_SPECS = {
@@ -121,6 +121,69 @@ export function redactWa(value) {
121
121
  return text;
122
122
  }
123
123
 
124
+ /**
125
+ * A local file path for the send_file audit line and its Activity row: credentials, QR payloads,
126
+ * JIDs, codes and phone numbers are redacted as in redactWa, but not the key-shape heuristics
127
+ * (HEX_RE, B64_RE), which read most real paths (`/Users/<you>/App2/…`, a run id folder) as key
128
+ * material and would leave nothing of the path to audit. Paths only: never for message text.
129
+ */
130
+ export function redactFilePath(value) {
131
+ let text = typeof value === 'string' ? value : String(value ?? '');
132
+ if (!text) return '';
133
+ text = redactCredentials(text);
134
+ text = text.replace(QR_URL_RE, '[qr]').replace(QR_REF_RE, '[qr]');
135
+ text = text.replace(JID_RE, (_m, user, server) => tagFor(user, server));
136
+ text = text.replace(ALLOW_CODE_RE, (_m, word) => `${word} [code]`);
137
+ text = text.replace(CLAIM_CODE_RE, '[code]').replace(PAIRING_DASH_RE, '[code]');
138
+ text = text.replace(NUMBER_RE, (run) => (DATE_LIKE_RE.test(run) ? run : '[number]'));
139
+ return text.slice(0, 2048);
140
+ }
141
+
142
+ // A run of key-shaped characters inside a path, between separators, dashes, underscores, dots and spaces.
143
+ const PATH_KEY_RUN_RE = /[A-Za-z0-9+=]{24,}/g;
144
+
145
+ /**
146
+ * A path the Assistant asked for and SynaBun refused (its Activity row): redactFilePath, and any run of
147
+ * 24 or more hex or key-shaped characters between the path's separators, dashes, underscores, dots and
148
+ * spaces masked too, because the text came from the model and need not name a file at all. Ordinary
149
+ * names stay readable (their words are short); a real file's canonical path keeps redactFilePath alone.
150
+ */
151
+ export function redactRequestedPath(value) {
152
+ return redactFilePath(value).replace(PATH_KEY_RUN_RE, (run) => (/^[0-9a-fA-F]+$/.test(run) || looksLikeKey(run) ? '[key]' : run));
153
+ }
154
+
155
+ const FILE_AUDIT_PREFIX = '[whatsapp] file send ';
156
+ const FILE_AUDIT_TYPES = new Set(['document', 'image', 'video', 'audio']);
157
+ const MASKED_CHAT_RE = /^(?:••••|••\d{2}|••••\d{4}|linked id ••[A-Za-z0-9]{0,2})$/;
158
+
159
+ /**
160
+ * The send_file audit line (host-core.js noteFileSend), checked field by field: exactly
161
+ * { path, size, chat, type } with a masked chat and a known form, the path through
162
+ * redactFilePath. → the line, or null for anything else (which gets redactWa like every log line).
163
+ */
164
+ export function fileAuditLine(message) {
165
+ const text = typeof message === 'string' ? message : '';
166
+ if (!text.startsWith(FILE_AUDIT_PREFIX)) return null;
167
+ let entry;
168
+ try { entry = JSON.parse(text.slice(FILE_AUDIT_PREFIX.length)); } catch { return null; }
169
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
170
+ const keys = Object.keys(entry);
171
+ if (keys.length !== 4 || keys.join(',') !== 'path,size,chat,type') return null;
172
+ if (typeof entry.path !== 'string' || !Number.isSafeInteger(entry.size) || entry.size < 0 || typeof entry.chat !== 'string' || !MASKED_CHAT_RE.test(entry.chat) || !FILE_AUDIT_TYPES.has(entry.type)) return null;
173
+ return `${FILE_AUDIT_PREFIX}${JSON.stringify({ path: redactFilePath(entry.path), size: entry.size, chat: entry.chat, type: entry.type })}`;
174
+ }
175
+
176
+ /**
177
+ * One log line on its way to SynaBun's log (manager.js, service.js): redactWa, except a send_file
178
+ * audit line (after the host's "[whatsapp-host] " tag or not), which fileAuditLine checks and keeps.
179
+ */
180
+ export function redactLogLine(message) {
181
+ const text = typeof message === 'string' ? message : String(message ?? '');
182
+ const tag = text.startsWith('[whatsapp-host] ') ? '[whatsapp-host] ' : '';
183
+ const audit = fileAuditLine(text.slice(tag.length));
184
+ return audit ? `${tag}${audit}` : redactWa(text);
185
+ }
186
+
124
187
  /** A phone number for display: never more than the last four digits. */
125
188
  export function maskNumber(digits) {
126
189
  const d = String(digits ?? '').replace(/\D/g, '');