@rubytech/create-maxy-code 0.1.429 → 0.1.432

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 (77) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +69 -3
  3. package/payload/platform/plugins/docs/PLUGIN.md +1 -0
  4. package/payload/platform/plugins/docs/references/internals.md +2 -2
  5. package/payload/platform/plugins/docs/references/workflows.md +61 -0
  6. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.d.ts +8 -1
  7. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.d.ts.map +1 -1
  8. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.js +8 -1
  9. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.js.map +1 -1
  10. package/payload/platform/plugins/workflows/PLUGIN.md +29 -9
  11. package/payload/platform/plugins/workflows/mcp/dist/index.js +34 -0
  12. package/payload/platform/plugins/workflows/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts +2 -2
  14. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts.map +1 -1
  15. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.js.map +1 -1
  16. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts +25 -0
  17. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts.map +1 -1
  18. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js +14 -1
  19. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js.map +1 -1
  20. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts +25 -1
  21. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts.map +1 -1
  22. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js +79 -15
  23. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js.map +1 -1
  24. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts +2 -0
  25. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts.map +1 -1
  26. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js +4 -0
  27. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js.map +1 -1
  28. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts +3 -1
  29. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts.map +1 -1
  30. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js +8 -1
  31. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js.map +1 -1
  32. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +24 -3
  33. package/payload/platform/services/telegram-channel/dist/instructions.js +1 -1
  34. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  35. package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -5
  36. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  37. package/payload/platform/services/telegram-channel/dist/notification.js +9 -6
  38. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  39. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  40. package/payload/platform/services/telegram-channel/dist/server.js +15 -5
  41. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  42. package/payload/platform/services/webchat-channel/dist/instructions.js +1 -1
  43. package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
  44. package/payload/platform/services/webchat-channel/dist/notification.d.ts +12 -10
  45. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  46. package/payload/platform/services/webchat-channel/dist/notification.js +12 -11
  47. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  48. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  49. package/payload/platform/services/webchat-channel/dist/server.js +12 -1
  50. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  51. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +11 -6
  52. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  53. package/payload/platform/services/whatsapp-channel/dist/notification.js +11 -8
  54. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  55. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  56. package/payload/platform/services/whatsapp-channel/dist/server.js +57 -14
  57. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  58. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +26 -2
  59. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  60. package/payload/platform/services/whatsapp-channel/dist/targets.js +50 -2
  61. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  62. package/payload/server/public/assets/{AdminLoginScreens-C4AZT7cQ.js → AdminLoginScreens-BM45lL5G.js} +1 -1
  63. package/payload/server/public/assets/{admin-SaF1F4e-.js → admin-B8Eb9Wbj.js} +1 -1
  64. package/payload/server/public/assets/{audio-attachment-mime-BBW07eyG.js → audio-attachment-mime-Cx5d3Jls.js} +1 -1
  65. package/payload/server/public/assets/{chat-B9Qjt7C9.js → chat-LEEUAJOG.js} +1 -1
  66. package/payload/server/public/assets/data-CKojMQSz.js +1 -0
  67. package/payload/server/public/assets/{operator-CTJjI8te.js → operator-OwY_sWTH.js} +1 -1
  68. package/payload/server/public/assets/{page-CbI1wtef.js → page-B7nCws9K.js} +1 -1
  69. package/payload/server/public/assets/{page-JzxyFGle.js → page-C3L6-Ufk.js} +5 -5
  70. package/payload/server/public/assets/{public-DzeW3_eM.js → public-CSLXSSy5.js} +1 -1
  71. package/payload/server/public/chat.html +3 -3
  72. package/payload/server/public/data.html +3 -3
  73. package/payload/server/public/index.html +4 -4
  74. package/payload/server/public/operator.html +4 -4
  75. package/payload/server/public/public.html +3 -3
  76. package/payload/server/server.js +1647 -1470
  77. package/payload/server/public/assets/data-BKE6Sa-g.js +0 -1
@@ -1383,9 +1383,9 @@ var serveStatic = (options = { root: "" }) => {
1383
1383
  };
1384
1384
 
1385
1385
  // server/index.ts
1386
- import { readFileSync as readFileSync35, existsSync as existsSync33, watchFile } from "fs";
1386
+ import { readFileSync as readFileSync35, existsSync as existsSync34, watchFile } from "fs";
1387
1387
  import { spawn as spawn3 } from "child_process";
1388
- import { createHash as createHash6 } from "crypto";
1388
+ import { createHash as createHash7 } from "crypto";
1389
1389
  import { resolve as resolve33, join as join35, basename as basename13 } from "path";
1390
1390
  import { homedir as homedir3 } from "os";
1391
1391
  import { monitorEventLoopDelay } from "perf_hooks";
@@ -1763,7 +1763,7 @@ async function ensureAuth() {
1763
1763
  }
1764
1764
 
1765
1765
  // server/routes/health.ts
1766
- import { existsSync as existsSync4, readFileSync as readFileSync9 } from "fs";
1766
+ import { existsSync as existsSync5, readFileSync as readFileSync9 } from "fs";
1767
1767
  import { createConnection as createConnection2 } from "net";
1768
1768
 
1769
1769
  // app/lib/network.ts
@@ -3494,7 +3494,7 @@ function readAllConversations(platformAccountId) {
3494
3494
  var TAG7 = "[whatsapp-persist]";
3495
3495
  var sessionWriteLocks = /* @__PURE__ */ new Map();
3496
3496
  async function persistWhatsAppMessage(input) {
3497
- if (!input.body || !input.msgKeyId) return null;
3497
+ if (!input.body && !input.attachmentId || !input.msgKeyId) return null;
3498
3498
  const messageId = `whatsapp-live:${input.accountId}:${input.remoteJid}:${input.msgKeyId}`;
3499
3499
  const canonicalRemoteJid = input.canonicalRemoteJid;
3500
3500
  const keyResolved = canonicalRemoteJid !== input.remoteJid ? "lid2pn" : "none";
@@ -3564,6 +3564,7 @@ async function persistWhatsAppMessage(input) {
3564
3564
  quotedId: input.quoted?.id ?? null,
3565
3565
  quotedSender: input.quoted?.sender ?? null,
3566
3566
  scope,
3567
+ ...input.attachmentId ? { attachmentId: input.attachmentId } : {},
3567
3568
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
3568
3569
  };
3569
3570
  const altDedupeRemoteJids = canonicalRemoteJid !== input.remoteJid ? [input.remoteJid] : [];
@@ -3781,554 +3782,945 @@ function isCustomerTurnSuppressed(role, managedService) {
3781
3782
  }
3782
3783
 
3783
3784
  // app/lib/whatsapp/inbound/media.ts
3784
- import { randomUUID as randomUUID3 } from "crypto";
3785
- import { writeFile, mkdir } from "fs/promises";
3786
- import { join as join5 } from "path";
3785
+ import { randomUUID as randomUUID4, createHash } from "crypto";
3786
+ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
3787
+ import { join as join6 } from "path";
3787
3788
  import {
3788
3789
  downloadMediaMessage,
3789
3790
  downloadContentFromMessage,
3790
3791
  normalizeMessageContent as normalizeMessageContent2
3791
3792
  } from "@whiskeysockets/baileys";
3792
- var TAG9 = "[whatsapp:media]";
3793
- var MEDIA_DIR = "/tmp/maxy-media";
3794
- function mimeToExt(mimetype) {
3795
- const map = {
3796
- "image/jpeg": "jpg",
3797
- "image/png": "png",
3798
- "image/webp": "webp",
3799
- "image/gif": "gif",
3800
- "audio/ogg; codecs=opus": "ogg",
3801
- "audio/mpeg": "mp3",
3802
- "audio/mp4": "m4a",
3803
- "video/mp4": "mp4",
3804
- "application/pdf": "pdf",
3805
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
3806
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx"
3807
- };
3808
- return map[mimetype] ?? mimetype.split("/").pop()?.replace(/[^a-z0-9]/gi, "") ?? "bin";
3809
- }
3810
- function getDownloadableContent(message) {
3811
- const msg = message;
3812
- if (msg.imageMessage) return { downloadable: msg.imageMessage, mediaType: "image" };
3813
- if (msg.videoMessage) return { downloadable: msg.videoMessage, mediaType: "video" };
3814
- if (msg.audioMessage) return { downloadable: msg.audioMessage, mediaType: "audio" };
3815
- if (msg.documentMessage) return { downloadable: msg.documentMessage, mediaType: "document" };
3816
- if (msg.stickerMessage) return { downloadable: msg.stickerMessage, mediaType: "sticker" };
3817
- return void 0;
3793
+
3794
+ // app/lib/attachments.ts
3795
+ import { randomUUID as randomUUID3 } from "crypto";
3796
+ import { mkdir, writeFile } from "fs/promises";
3797
+ import { realpathSync as realpathSync2, existsSync as existsSync4 } from "fs";
3798
+ import { resolve as resolve6, extname } from "path";
3799
+
3800
+ // app/lib/data-path.ts
3801
+ import { realpathSync } from "fs";
3802
+ import { resolve as resolve5, normalize, sep as sep2, relative, basename, join as join5 } from "path";
3803
+ var PLATFORM_ROOT3 = process.env.MAXY_PLATFORM_ROOT ?? resolve5(process.cwd(), "../platform");
3804
+ var DATA_ROOT = resolve5(PLATFORM_ROOT3, "..", "data");
3805
+ var CLAUDE_UPLOADS_ROOT = process.env.CLAUDE_CONFIG_DIR ? resolve5(process.env.CLAUDE_CONFIG_DIR, "uploads") : null;
3806
+ var ACCOUNT_PARTITION_DIRS = /* @__PURE__ */ new Set(["accounts"]);
3807
+ var ACCOUNT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3808
+ function crossesForeignAccountPartition(relPath, accountId) {
3809
+ const segs = relPath.split("/").filter(Boolean);
3810
+ if (segs.length < 2) return false;
3811
+ if (!ACCOUNT_PARTITION_DIRS.has(segs[0])) return false;
3812
+ const owner = segs[1];
3813
+ if (!ACCOUNT_UUID_RE.test(owner)) return false;
3814
+ return owner !== accountId;
3818
3815
  }
3819
- async function streamToBuffer(stream2) {
3820
- const chunks = [];
3821
- for await (const chunk of stream2) {
3822
- chunks.push(Buffer.from(chunk));
3823
- }
3824
- return Buffer.concat(chunks);
3816
+ function isWithinOwnAccountPartition(relPath, accountId) {
3817
+ const segs = relPath.split("/").filter(Boolean);
3818
+ return segs.length >= 2 && segs[0] === "accounts" && segs[1] === accountId;
3825
3819
  }
3826
- async function downloadInboundMedia(msg, sock, opts) {
3827
- const rawMessage = msg.message;
3828
- if (!rawMessage) return void 0;
3829
- const content = normalizeMessageContent2(rawMessage);
3830
- if (!content) return void 0;
3831
- const inner = content;
3832
- const mimetype = inner.imageMessage?.mimetype ?? inner.videoMessage?.mimetype ?? inner.audioMessage?.mimetype ?? inner.documentMessage?.mimetype ?? inner.stickerMessage?.mimetype ?? void 0;
3833
- if (!inner.imageMessage && !inner.videoMessage && !inner.audioMessage && !inner.documentMessage && !inner.stickerMessage) {
3834
- return void 0;
3820
+ function resolveUnderRoot(raw, rootAbs, rootLabel) {
3821
+ const cleaned = normalize("/" + (raw ?? "").replace(/\\/g, "/")).replace(/^\/+/, "");
3822
+ const absolute = resolve5(rootAbs, cleaned);
3823
+ let rootReal;
3824
+ try {
3825
+ rootReal = realpathSync(rootAbs);
3826
+ } catch (err) {
3827
+ return {
3828
+ ok: false,
3829
+ status: 500,
3830
+ error: `${rootLabel} not accessible: ${err instanceof Error ? err.message : String(err)}`
3831
+ };
3835
3832
  }
3836
- const maxBytes = opts?.maxBytes ?? 50 * 1024 * 1024;
3833
+ let resolvedReal;
3837
3834
  try {
3838
- let buffer = await downloadMediaMessage(
3839
- msg,
3840
- "buffer",
3841
- {},
3842
- {
3843
- reuploadRequest: sock.updateMediaMessage,
3844
- logger: sock.logger
3845
- }
3846
- );
3847
- if (!buffer || buffer.length === 0) {
3848
- console.error(`${TAG9} primary download returned empty, trying direct fallback`);
3849
- const downloadable = getDownloadableContent(content);
3850
- if (downloadable) {
3851
- try {
3852
- const stream2 = await downloadContentFromMessage(downloadable.downloadable, downloadable.mediaType);
3853
- buffer = await streamToBuffer(stream2);
3854
- } catch (fallbackErr) {
3855
- console.error(`${TAG9} direct download fallback failed: ${String(fallbackErr)}`);
3856
- }
3835
+ resolvedReal = realpathSync(absolute);
3836
+ } catch (err) {
3837
+ const code = err.code;
3838
+ if (code === "ENOENT") {
3839
+ if (absolute !== rootReal && !absolute.startsWith(rootReal + sep2)) {
3840
+ return { ok: false, status: 403, error: `Path escapes ${rootLabel}`, resolved: absolute };
3857
3841
  }
3842
+ return { ok: false, status: 404, error: "Not found" };
3858
3843
  }
3859
- if (!buffer || buffer.length === 0) {
3860
- console.error(`${TAG9} download failed: empty buffer for ${mimetype ?? "unknown"}`);
3861
- return void 0;
3862
- }
3863
- if (buffer.length > maxBytes) {
3864
- const sizeMB = (buffer.length / (1024 * 1024)).toFixed(1);
3865
- const limitMB = (maxBytes / (1024 * 1024)).toFixed(0);
3866
- console.error(`${TAG9} media too large type=${mimetype ?? "unknown"} size=${sizeMB}MB limit=${limitMB}MB`);
3867
- return void 0;
3868
- }
3869
- await mkdir(MEDIA_DIR, { recursive: true });
3870
- const ext = mimeToExt(mimetype ?? "application/octet-stream");
3871
- const filename = `${randomUUID3()}.${ext}`;
3872
- const filePath = join5(MEDIA_DIR, filename);
3873
- await writeFile(filePath, buffer);
3874
- const sizeKB = (buffer.length / 1024).toFixed(0);
3875
- console.error(`${TAG9} media downloaded type=${mimetype ?? "unknown"} size=${sizeKB}KB path=${filePath}`);
3876
3844
  return {
3877
- path: filePath,
3878
- mimetype: mimetype ?? "application/octet-stream",
3879
- size: buffer.length
3845
+ ok: false,
3846
+ status: 500,
3847
+ error: err instanceof Error ? err.message : String(err)
3880
3848
  };
3881
- } catch (err) {
3882
- console.error(`${TAG9} media download failed type=${mimetype ?? "unknown"} error=${String(err)}`);
3883
- return void 0;
3884
3849
  }
3850
+ if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + sep2)) {
3851
+ return { ok: false, status: 403, error: `Path escapes ${rootLabel}`, resolved: resolvedReal };
3852
+ }
3853
+ const relPath = relative(rootReal, resolvedReal) || ".";
3854
+ return { ok: true, absolute: resolvedReal, dataRootReal: rootReal, relative: relPath };
3855
+ }
3856
+ function resolveDataPath(raw) {
3857
+ return resolveUnderRoot(raw, DATA_ROOT, "DATA_ROOT");
3858
+ }
3859
+ function resolveClaudeUploadsPath(raw) {
3860
+ if (!CLAUDE_UPLOADS_ROOT) {
3861
+ return { ok: false, status: 404, error: "Not found" };
3862
+ }
3863
+ return resolveUnderRoot(raw, CLAUDE_UPLOADS_ROOT, "CLAUDE_UPLOADS_ROOT");
3864
+ }
3865
+ function scratchpadTmpBase() {
3866
+ return process.env.MAXY_SCRATCHPAD_BASE ?? "/tmp";
3867
+ }
3868
+ function sessionScratchpadRoot(projectDir, sessionId) {
3869
+ const uid = typeof process.geteuid === "function" ? process.geteuid() : 0;
3870
+ return join5(scratchpadTmpBase(), `claude-${uid}`, basename(projectDir), sessionId, "scratchpad");
3871
+ }
3872
+ function resolveScratchpadPath(raw, scratchpadRootAbs) {
3873
+ return resolveUnderRoot(raw, scratchpadRootAbs, "scratchpad");
3885
3874
  }
3886
3875
 
3887
- // app/lib/whatsapp/inbound/debounce.ts
3888
- var TAG10 = "[whatsapp:debounce]";
3889
- var STT_TAG = "[whatsapp:stt-await]";
3890
- function createInboundDebouncer(opts) {
3891
- const { debounceMs, buildKey, onFlush, onError } = opts;
3892
- const awaitTimeoutMs = opts.awaitTimeoutMs ?? 3e4;
3893
- const pending = /* @__PURE__ */ new Map();
3894
- const pendingAwaits = /* @__PURE__ */ new Map();
3895
- let destroyed = false;
3896
- async function flushKey(key) {
3897
- const awaiting = pendingAwaits.get(key);
3898
- if (awaiting) {
3899
- const start = Date.now();
3900
- try {
3901
- await Promise.race([
3902
- awaiting,
3903
- new Promise(
3904
- (_, reject) => setTimeout(() => reject(new Error("stt-await-timeout")), awaitTimeoutMs)
3905
- )
3906
- ]);
3907
- console.error(`${STT_TAG} resolved key=${key} waited_ms=${Date.now() - start}`);
3908
- } catch {
3909
- console.error(`${STT_TAG} timeout key=${key} waited_ms=${Date.now() - start}`);
3910
- }
3911
- pendingAwaits.delete(key);
3912
- }
3913
- const batch = pending.get(key);
3914
- if (!batch) return;
3915
- clearTimeout(batch.timer);
3916
- pending.delete(key);
3917
- const batchSize = batch.entries.length;
3918
- try {
3919
- console.error(`${TAG10} debounce flush key=${key} batchSize=${batchSize}`);
3920
- const result = onFlush(batch.entries);
3921
- if (result && typeof result.catch === "function") {
3922
- result.catch(onError);
3923
- }
3924
- } catch (err) {
3925
- onError(err);
3876
+ // app/lib/doc-to-text.ts
3877
+ import { unzipSync, strFromU8 } from "fflate";
3878
+ var DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
3879
+ var ODT = "application/vnd.oasis.opendocument.text";
3880
+ var CONVERTIBLE_DOC_MIME = /* @__PURE__ */ new Set([
3881
+ DOCX,
3882
+ ODT,
3883
+ "text/rtf",
3884
+ "application/rtf"
3885
+ ]);
3886
+ function bareMime(mimeType) {
3887
+ return mimeType.split(";")[0].trim().toLowerCase();
3888
+ }
3889
+ function unescapeXml(s) {
3890
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
3891
+ }
3892
+ function unzipEntry(buffer, entry) {
3893
+ const files = unzipSync(new Uint8Array(buffer));
3894
+ const bytes = files[entry];
3895
+ if (!bytes) throw new Error(`zip entry not found: ${entry}`);
3896
+ return strFromU8(bytes);
3897
+ }
3898
+ function docxToText(buffer) {
3899
+ const xml = unzipEntry(buffer, "word/document.xml");
3900
+ const paras = [];
3901
+ for (const p of xml.split("</w:p>")) {
3902
+ const withBreaks = p.replace(/<w:tab\b[^>]*\/?>/g, " ").replace(/<w:br\b[^>]*\/?>/g, "\n");
3903
+ let line = "";
3904
+ for (const m of withBreaks.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)) {
3905
+ line += unescapeXml(m[1]);
3926
3906
  }
3907
+ if (line.length > 0) paras.push(line);
3927
3908
  }
3928
- async function flush() {
3929
- const keys = Array.from(pending.keys());
3930
- await Promise.all(keys.map((key) => flushKey(key)));
3931
- }
3932
- function enqueue(msg) {
3933
- if (destroyed) return;
3934
- if (debounceMs <= 0) {
3935
- try {
3936
- const result = onFlush([msg]);
3937
- if (result && typeof result.catch === "function") {
3938
- result.catch(onError);
3909
+ return paras.join("\n");
3910
+ }
3911
+ function odtToText(buffer) {
3912
+ const xml = unzipEntry(buffer, "content.xml");
3913
+ const body = xml.replace(/^[\s\S]*?<office:text\b[^>]*>/, "").replace(/<\/office:text>[\s\S]*$/, "");
3914
+ const withBreaks = body.replace(/<text:line-break\b[^>]*\/?>/g, "\n").replace(/<text:tab\b[^>]*\/?>/g, " ").replace(/<\/text:(?:p|h)>/g, "\n");
3915
+ const stripped = withBreaks.replace(/<[^>]+>/g, "");
3916
+ return unescapeXml(stripped).replace(/\n{3,}/g, "\n\n").trim();
3917
+ }
3918
+ var RTF_DROP_DEST = /^\\(?:\*|fonttbl|colortbl|stylesheet|listtable|info|pict)\b/;
3919
+ function stripRtfDestinations(s) {
3920
+ let out = "";
3921
+ for (let i = 0; i < s.length; i++) {
3922
+ const ch = s[i];
3923
+ if (ch === "\\" && i + 1 < s.length) {
3924
+ out += ch + s[i + 1];
3925
+ i++;
3926
+ continue;
3927
+ }
3928
+ if (ch === "{" && RTF_DROP_DEST.test(s.slice(i + 1))) {
3929
+ let depth = 1;
3930
+ let j = i + 1;
3931
+ for (; j < s.length && depth > 0; j++) {
3932
+ if (s[j] === "\\") {
3933
+ j++;
3934
+ continue;
3939
3935
  }
3940
- } catch (err) {
3941
- onError(err);
3936
+ if (s[j] === "{") depth++;
3937
+ else if (s[j] === "}") depth--;
3942
3938
  }
3943
- return;
3939
+ i = j - 1;
3940
+ continue;
3944
3941
  }
3945
- const key = buildKey(msg);
3946
- if (!key) {
3947
- try {
3948
- const result = onFlush([msg]);
3949
- if (result && typeof result.catch === "function") {
3950
- result.catch(onError);
3951
- }
3952
- } catch (err) {
3953
- onError(err);
3954
- }
3955
- return;
3956
- }
3957
- const existing = pending.get(key);
3958
- if (existing) {
3959
- existing.entries.push(msg);
3960
- clearTimeout(existing.timer);
3961
- existing.timer = setTimeout(() => {
3962
- flushKey(key).catch(onError);
3963
- }, debounceMs);
3964
- } else {
3965
- const timer2 = setTimeout(() => {
3966
- flushKey(key).catch(onError);
3967
- }, debounceMs);
3968
- pending.set(key, { entries: [msg], timer: timer2 });
3969
- }
3970
- }
3971
- function registerPending(key, promise) {
3972
- if (destroyed) return;
3973
- const existing = pendingAwaits.get(key);
3974
- if (existing) {
3975
- pendingAwaits.set(key, Promise.all([existing, promise]).then(() => {
3976
- }));
3977
- } else {
3978
- pendingAwaits.set(key, promise);
3979
- }
3980
- console.error(`${STT_TAG} pending key=${key}`);
3981
- }
3982
- async function destroy() {
3983
- if (destroyed) return;
3984
- destroyed = true;
3985
- await flush();
3942
+ out += ch;
3986
3943
  }
3987
- return { enqueue, flush, destroy, registerPending };
3944
+ return out;
3988
3945
  }
3989
-
3990
- // app/lib/whatsapp/inbound/combine-batch.ts
3991
- function combineInboundBatch(entries) {
3992
- const last = entries[entries.length - 1];
3993
- const combinedText = entries.map((e) => e.text).filter(Boolean).join("\n");
3994
- const media = entries.flatMap((e) => e.media);
3995
- return { ...last, text: combinedText, media };
3946
+ function rtfToText(rtf) {
3947
+ let s = stripRtfDestinations(rtf);
3948
+ s = s.replace(/\\par[d]?\b/g, "\n").replace(/\\line\b/g, "\n").replace(/\\tab\b/g, " ");
3949
+ s = s.replace(/\\'([0-9a-fA-F]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)));
3950
+ s = s.replace(/\\[a-zA-Z]+-?\d* ?/g, "");
3951
+ s = s.replace(/\\([{}\\])/g, "$1").replace(/[{}]/g, "");
3952
+ return s.replace(/[ \t]{2,}/g, " ").replace(/\n{3,}/g, "\n\n").trim();
3953
+ }
3954
+ function docToText(buffer, mimeType) {
3955
+ const mime = bareMime(mimeType);
3956
+ if (mime === DOCX) return docxToText(buffer);
3957
+ if (mime === ODT) return odtToText(buffer);
3958
+ if (mime === "text/rtf" || mime === "application/rtf") return rtfToText(buffer.toString("latin1"));
3959
+ return null;
3996
3960
  }
3997
3961
 
3998
- // app/lib/whatsapp/opening-hours.ts
3999
- var TAG11 = "[whatsapp:hours]";
4000
- async function isBusinessOpen(accountId) {
4001
- try {
4002
- const timezone = await resolveTimezone(accountId);
4003
- const now = /* @__PURE__ */ new Date();
4004
- const dayOfWeek = getDayOfWeek(now, timezone);
4005
- const previousDayOfWeek = getPreviousDayOfWeek(now, timezone);
4006
- const currentTime = getCurrentTime(now, timezone);
4007
- const session = getSession();
4008
- try {
4009
- const result = await session.run(
4010
- `MATCH (b:LocalBusiness {accountId: $accountId})
4011
- OPTIONAL MATCH (b)-[:HAS_HOURS]->(hours:OpeningHoursSpecification)
4012
- WHERE hours.dayOfWeek IN [$dayOfWeek, $previousDayOfWeek]
4013
- RETURN hours.dayOfWeek AS dayOfWeek, hours.opens AS opens, hours.closes AS closes`,
4014
- { accountId, dayOfWeek, previousDayOfWeek }
4015
- );
4016
- if (result.records.length === 0) {
4017
- console.error(`${TAG11} [${accountId}] business hours check: no opening hours configured, treating as open`);
4018
- return { open: true, reason: "no opening hours configured" };
4019
- }
4020
- const specs = result.records.filter((r) => r.get("opens") != null && r.get("closes") != null).map((r) => ({
4021
- day: String(r.get("dayOfWeek")).trim(),
4022
- opens: String(r.get("opens")).trim(),
4023
- closes: String(r.get("closes")).trim()
4024
- }));
4025
- if (specs.length === 0) {
4026
- console.error(`${TAG11} [${accountId}] business hours check: no opening hours configured, treating as open`);
4027
- return { open: true, reason: "no opening hours configured" };
4028
- }
4029
- for (const spec of specs) {
4030
- if (!spec.opens || !spec.closes || spec.opens === spec.closes) continue;
4031
- const isYesterday = spec.day === previousDayOfWeek;
4032
- if (isYesterday) {
4033
- if (spec.opens > spec.closes && currentTime < spec.closes) {
4034
- const hoursStr = `${spec.opens}-${spec.closes} (${spec.day})`;
4035
- console.error(
4036
- `${TAG11} [${accountId}] business hours check: open (day=${dayOfWeek}, time=${currentTime}, hours=${hoursStr})`
4037
- );
4038
- return {
4039
- open: true,
4040
- reason: "within business hours",
4041
- dayOfWeek,
4042
- currentTime,
4043
- matchedHours: hoursStr
4044
- };
4045
- }
4046
- } else if (isTimeInRange(currentTime, spec.opens, spec.closes)) {
4047
- const hoursStr = `${spec.opens}-${spec.closes}`;
4048
- console.error(
4049
- `${TAG11} [${accountId}] business hours check: open (day=${dayOfWeek}, time=${currentTime}, hours=${hoursStr})`
4050
- );
4051
- return {
4052
- open: true,
4053
- reason: "within business hours",
4054
- dayOfWeek,
4055
- currentTime,
4056
- matchedHours: hoursStr
4057
- };
4058
- }
4059
- }
4060
- const todaySpecs = specs.filter((s) => s.day === dayOfWeek);
4061
- const allHours = todaySpecs.map((s) => `${s.opens}-${s.closes}`).join(", ") || "none today";
4062
- console.error(
4063
- `${TAG11} [${accountId}] business hours check: closed (day=${dayOfWeek}, time=${currentTime}, hours=${allHours})`
4064
- );
4065
- return {
4066
- open: false,
4067
- reason: "outside business hours",
4068
- dayOfWeek,
4069
- currentTime,
4070
- matchedHours: allHours
4071
- };
4072
- } finally {
4073
- await session.close();
4074
- }
4075
- } catch (err) {
4076
- console.error(
4077
- `${TAG11} [${accountId}] business hours check failed, treating as open: ${err instanceof Error ? err.message : String(err)}`
3962
+ // app/lib/attachments.ts
3963
+ function uploadsDirFor(accountId, subPath, attachmentId) {
3964
+ return subPath ? resolve6(DATA_ROOT, "accounts", accountId, "uploads", subPath, attachmentId) : resolve6(DATA_ROOT, "accounts", accountId, "uploads", attachmentId);
3965
+ }
3966
+ var SUPPORTED_MIME_TYPES = /* @__PURE__ */ new Set([
3967
+ "image/jpeg",
3968
+ "image/png",
3969
+ "image/gif",
3970
+ "image/webp",
3971
+ "application/pdf",
3972
+ "text/plain",
3973
+ "text/markdown",
3974
+ "text/csv",
3975
+ "text/html",
3976
+ "text/calendar",
3977
+ "application/zip",
3978
+ "application/x-zip-compressed",
3979
+ // Task 1347 — Word/OpenDocument/RTF text documents. The bytes are converted to
3980
+ // a readable-text sibling at ingestion (writeAttachment doc-to-text.ts)
3981
+ // because the on-device Read tool cannot parse the binary containers. rtf is
3982
+ // reported by browsers as either text/rtf or application/rtf.
3983
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
3984
+ "application/vnd.oasis.opendocument.text",
3985
+ "text/rtf",
3986
+ "application/rtf",
3987
+ // Task 1240 — voice recordings persist as playable clips in the admin bubble.
3988
+ // These match AUDIO_MIME_TYPES in app/lib/stt/audio-mime.ts exactly.
3989
+ "audio/ogg",
3990
+ "audio/opus",
3991
+ "audio/webm",
3992
+ "audio/mp4",
3993
+ "audio/x-m4a",
3994
+ "audio/mpeg",
3995
+ "audio/wav",
3996
+ "video/mp4"
3997
+ ]);
3998
+ var MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
3999
+ var MAX_FILES_PER_MESSAGE = 5;
4000
+ var MAX_DATA_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
4001
+ var MAX_ZIP_UNCOMPRESSED_BYTES = 100 * 1024 * 1024;
4002
+ var EXTRACTED_TEXT_SUFFIX = ".extracted.txt";
4003
+ function assertSupportedMime(mimeType) {
4004
+ const bare = mimeType.split(";")[0].trim().toLowerCase();
4005
+ if (!SUPPORTED_MIME_TYPES.has(bare)) {
4006
+ throw new Error(
4007
+ `Unsupported file type: "${mimeType}". Supported types: ${[...SUPPORTED_MIME_TYPES].join(", ")}.`
4078
4008
  );
4079
- return { open: true, reason: "hours check failed (treating as open)" };
4080
4009
  }
4081
4010
  }
4082
- async function resolveTimezone(accountId) {
4083
- try {
4084
- const tz = await getUserTimezone(accountId);
4085
- if (tz) {
4086
- Intl.DateTimeFormat("en-US", { timeZone: tz });
4087
- return tz;
4088
- }
4089
- } catch {
4011
+ function isSupportedMime(mimeType) {
4012
+ return SUPPORTED_MIME_TYPES.has(mimeType.split(";")[0].trim().toLowerCase());
4013
+ }
4014
+ async function storeMediaBuffer(accountId, attachmentId, filename, mimeType, buffer) {
4015
+ assertSupportedMime(mimeType);
4016
+ if (buffer.byteLength > MAX_FILE_SIZE_BYTES) {
4017
+ throw new Error(
4018
+ `Media exceeds the 50 MB limit (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB).`
4019
+ );
4090
4020
  }
4091
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
4021
+ return writeAttachment(accountId, "", filename, mimeType, buffer.byteLength, buffer, attachmentId, true);
4092
4022
  }
4093
- function getDayOfWeek(date, timezone) {
4094
- return new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: timezone }).format(date);
4023
+ function attachmentExists(accountId, attachmentId) {
4024
+ return existsSync4(resolve6(uploadsDirFor(accountId, "", attachmentId), `${attachmentId}.meta.json`));
4095
4025
  }
4096
- function getPreviousDayOfWeek(date, timezone) {
4097
- const yesterday = new Date(date.getTime() - 24 * 60 * 60 * 1e3);
4098
- return getDayOfWeek(yesterday, timezone);
4026
+ async function writeAttachment(accountId, subPath, filename, mimeType, sizeBytes, buffer, explicitId, skipDocToText = false) {
4027
+ const attachmentId = explicitId ?? randomUUID3();
4028
+ const dir = uploadsDirFor(accountId, subPath, attachmentId);
4029
+ await mkdir(dir, { recursive: true });
4030
+ const ext = extname(filename) || "";
4031
+ const storagePath = resolve6(dir, `${attachmentId}${ext}`);
4032
+ const metaPath = resolve6(dir, `${attachmentId}.meta.json`);
4033
+ const meta = {
4034
+ attachmentId,
4035
+ scope: subPath || accountId,
4036
+ filename,
4037
+ mimeType,
4038
+ sizeBytes,
4039
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
4040
+ };
4041
+ await writeFile(storagePath, buffer);
4042
+ await writeFile(metaPath, JSON.stringify(meta, null, 2));
4043
+ let readableTextPath;
4044
+ if (!skipDocToText && CONVERTIBLE_DOC_MIME.has(mimeType.split(";")[0].trim().toLowerCase())) {
4045
+ const text = docToText(buffer, mimeType);
4046
+ if (text) {
4047
+ readableTextPath = resolve6(dir, `${attachmentId}${EXTRACTED_TEXT_SUFFIX}`);
4048
+ await writeFile(readableTextPath, text);
4049
+ }
4050
+ }
4051
+ return { attachmentId, filename, storagePath, metaPath, mimeType, sizeBytes, readableTextPath };
4099
4052
  }
4100
- function getCurrentTime(date, timezone) {
4101
- const parts = new Intl.DateTimeFormat("en-US", {
4102
- hour: "2-digit",
4103
- minute: "2-digit",
4104
- hour12: false,
4105
- timeZone: timezone
4106
- }).formatToParts(date);
4107
- const hour = parts.find((p) => p.type === "hour")?.value ?? "00";
4108
- const minute = parts.find((p) => p.type === "minute")?.value ?? "00";
4109
- return `${hour}:${minute}`;
4053
+ async function storeAttachment(accountId, file) {
4054
+ assertSupportedMime(file.type);
4055
+ if (file.size > MAX_FILE_SIZE_BYTES) {
4056
+ throw new Error(
4057
+ `File "${file.name}" exceeds the 50 MB limit (${(file.size / 1024 / 1024).toFixed(1)} MB).`
4058
+ );
4059
+ }
4060
+ const buffer = Buffer.from(await file.arrayBuffer());
4061
+ return writeAttachment(accountId, "", file.name, file.type, file.size, buffer);
4110
4062
  }
4111
- function isTimeInRange(time, opens, closes) {
4112
- if (opens <= closes) {
4113
- return time >= opens && time < closes;
4063
+ async function storePublicAttachment(accountId, sessionId, file, buffer) {
4064
+ assertSupportedMime(file.type);
4065
+ if (file.size > MAX_FILE_SIZE_BYTES) {
4066
+ throw new Error(
4067
+ `File "${file.name}" exceeds the 50 MB limit (${(file.size / 1024 / 1024).toFixed(1)} MB).`
4068
+ );
4114
4069
  }
4115
- return time >= opens || time < closes;
4070
+ return writeAttachment(accountId, `public/${sessionId}`, file.name, file.type, file.size, buffer);
4116
4071
  }
4117
-
4118
- // app/lib/stt/index.ts
4119
- import { execFile } from "child_process";
4120
- import { unlink, stat } from "fs/promises";
4121
- import { promisify } from "util";
4122
- var execFileAsync = promisify(execFile);
4123
- var TAG12 = "[stt]";
4124
- var WHISPER_BINARY = process.env.WHISPER_BINARY ?? "/opt/whisper.cpp/build/bin/whisper-cli";
4125
- var WHISPER_MODEL = process.env.WHISPER_MODEL ?? "/opt/whisper.cpp/models/ggml-base.bin";
4126
- var WHISPER_TIMEOUT_MS = 20 * 60 * 1e3;
4127
- var SPECIAL_TOKEN_RE = /\[_[A-Z]+(?:_\d+)?_?\]/g;
4128
- function stripSpecialTokens(input) {
4129
- const matches = input.match(SPECIAL_TOKEN_RE);
4130
- const count = matches ? matches.length : 0;
4131
- if (!count) return { text: input, count: 0 };
4132
- const text = input.replace(SPECIAL_TOKEN_RE, "").replace(/[ \t]{2,}/g, " ").trim();
4133
- return { text, count };
4072
+ var MIME_BY_EXT = {
4073
+ ".pdf": "application/pdf",
4074
+ ".png": "image/png",
4075
+ ".jpg": "image/jpeg",
4076
+ ".jpeg": "image/jpeg",
4077
+ ".gif": "image/gif",
4078
+ ".webp": "image/webp",
4079
+ ".svg": "image/svg+xml",
4080
+ ".html": "text/html",
4081
+ ".htm": "text/html",
4082
+ ".css": "text/css",
4083
+ ".js": "application/javascript",
4084
+ ".json": "application/json",
4085
+ ".csv": "text/csv",
4086
+ ".txt": "text/plain",
4087
+ ".md": "text/markdown",
4088
+ ".xml": "text/xml",
4089
+ ".zip": "application/zip",
4090
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4091
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4092
+ ".odt": "application/vnd.oasis.opendocument.text",
4093
+ ".rtf": "text/rtf",
4094
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4095
+ ".ics": "text/calendar"
4096
+ };
4097
+ function detectMimeType(filePath) {
4098
+ const ext = extname(filePath).toLowerCase();
4099
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
4134
4100
  }
4135
- async function transcribe(audioPath, mimetype) {
4136
- const startMs = Date.now();
4137
- let audioBytes = 0;
4138
- try {
4139
- const s = await stat(audioPath);
4140
- audioBytes = s.size;
4141
- } catch {
4142
- console.error(`${TAG12} failed: file not readable path=${audioPath}`);
4143
- return void 0;
4101
+ function validateFilePathInAccount(filePath, accountDir) {
4102
+ const resolved = realpathSync2(filePath);
4103
+ const accountResolved = realpathSync2(accountDir);
4104
+ if (!resolved.startsWith(accountResolved + "/")) {
4105
+ throw new Error(`File path is outside the account directory`);
4144
4106
  }
4145
- console.error(
4146
- `${TAG12} start provider=whisper-local audio_bytes=${audioBytes} mimetype=${mimetype} path=${audioPath}`
4107
+ return resolved;
4108
+ }
4109
+ async function storeComponentArtefact(accountId, attachmentId, mimeType, content, filename) {
4110
+ const buffer = Buffer.from(content, "utf-8");
4111
+ if (buffer.byteLength > MAX_FILE_SIZE_BYTES) {
4112
+ throw new Error(
4113
+ `Component artefact exceeds the 50 MB limit (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB).`
4114
+ );
4115
+ }
4116
+ const dir = uploadsDirFor(accountId, "", attachmentId);
4117
+ await mkdir(dir, { recursive: true });
4118
+ const ext = extname(filename) || (mimeType === "text/html" ? ".html" : ".md");
4119
+ const storagePath = resolve6(dir, `${attachmentId}${ext}`);
4120
+ const metaPath = resolve6(dir, `${attachmentId}.meta.json`);
4121
+ const meta = {
4122
+ attachmentId,
4123
+ scope: accountId,
4124
+ filename,
4125
+ mimeType,
4126
+ sizeBytes: buffer.byteLength,
4127
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
4128
+ };
4129
+ await writeFile(storagePath, buffer);
4130
+ await writeFile(metaPath, JSON.stringify(meta, null, 2));
4131
+ return { attachmentId, filename, storagePath, metaPath, mimeType, sizeBytes: buffer.byteLength };
4132
+ }
4133
+
4134
+ // app/lib/whatsapp/inbound/media.ts
4135
+ var TAG9 = "[whatsapp:media]";
4136
+ var MEDIA_DIR = "/tmp/maxy-media";
4137
+ function deterministicAttachmentId(seed) {
4138
+ const h = createHash("sha256").update(seed).digest("hex");
4139
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
4140
+ }
4141
+ var CACHE_RETAIN_MS = 5e3;
4142
+ var inflightDownloads = /* @__PURE__ */ new Map();
4143
+ function downloadInboundMediaBufferCached(msg, sock, opts) {
4144
+ const key = msg.key.id ? `${msg.key.remoteJid ?? ""}:${msg.key.id}` : null;
4145
+ if (!key) return downloadInboundMediaBuffer(msg, sock, opts);
4146
+ const existing = inflightDownloads.get(key);
4147
+ if (existing) return existing;
4148
+ const p = downloadInboundMediaBuffer(msg, sock, opts);
4149
+ inflightDownloads.set(key, p);
4150
+ void p.then(
4151
+ (res) => {
4152
+ if (res === void 0) {
4153
+ inflightDownloads.delete(key);
4154
+ } else {
4155
+ const t = setTimeout(() => inflightDownloads.delete(key), CACHE_RETAIN_MS);
4156
+ t.unref?.();
4157
+ }
4158
+ },
4159
+ () => inflightDownloads.delete(key)
4160
+ // downloadInboundMediaBuffer never rejects, but be safe
4147
4161
  );
4148
- const wavPath = audioPath.replace(/\.[^.]+$/, "") + ".wav";
4149
- try {
4150
- await execFileAsync("ffmpeg", [
4151
- "-i",
4152
- audioPath,
4153
- "-ar",
4154
- "16000",
4155
- "-ac",
4156
- "1",
4157
- "-c:a",
4158
- "pcm_s16le",
4159
- "-y",
4160
- // overwrite without prompting
4161
- wavPath
4162
- ], { timeout: 3e4 });
4163
- } catch (err) {
4164
- const reason = err instanceof Error ? err.message : String(err);
4165
- console.error(`${TAG12} failed: ffmpeg conversion error=${reason}`);
4162
+ return p;
4163
+ }
4164
+ function mimeToExt(mimetype) {
4165
+ const map = {
4166
+ "image/jpeg": "jpg",
4167
+ "image/png": "png",
4168
+ "image/webp": "webp",
4169
+ "image/gif": "gif",
4170
+ "audio/ogg; codecs=opus": "ogg",
4171
+ "audio/mpeg": "mp3",
4172
+ "audio/mp4": "m4a",
4173
+ "video/mp4": "mp4",
4174
+ "application/pdf": "pdf",
4175
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
4176
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx"
4177
+ };
4178
+ return map[mimetype] ?? mimetype.split("/").pop()?.replace(/[^a-z0-9]/gi, "") ?? "bin";
4179
+ }
4180
+ function getDownloadableContent(message) {
4181
+ const msg = message;
4182
+ if (msg.imageMessage) return { downloadable: msg.imageMessage, mediaType: "image" };
4183
+ if (msg.videoMessage) return { downloadable: msg.videoMessage, mediaType: "video" };
4184
+ if (msg.audioMessage) return { downloadable: msg.audioMessage, mediaType: "audio" };
4185
+ if (msg.documentMessage) return { downloadable: msg.documentMessage, mediaType: "document" };
4186
+ if (msg.stickerMessage) return { downloadable: msg.stickerMessage, mediaType: "sticker" };
4187
+ return void 0;
4188
+ }
4189
+ async function streamToBuffer(stream2) {
4190
+ const chunks = [];
4191
+ for await (const chunk of stream2) {
4192
+ chunks.push(Buffer.from(chunk));
4193
+ }
4194
+ return Buffer.concat(chunks);
4195
+ }
4196
+ async function downloadInboundMediaBuffer(msg, sock, opts) {
4197
+ const rawMessage = msg.message;
4198
+ if (!rawMessage) return void 0;
4199
+ const content = normalizeMessageContent2(rawMessage);
4200
+ if (!content) return void 0;
4201
+ const inner = content;
4202
+ const mimetype = inner.imageMessage?.mimetype ?? inner.videoMessage?.mimetype ?? inner.audioMessage?.mimetype ?? inner.documentMessage?.mimetype ?? inner.stickerMessage?.mimetype ?? void 0;
4203
+ if (!inner.imageMessage && !inner.videoMessage && !inner.audioMessage && !inner.documentMessage && !inner.stickerMessage) {
4166
4204
  return void 0;
4167
4205
  }
4206
+ const maxBytes = opts?.maxBytes ?? 50 * 1024 * 1024;
4168
4207
  try {
4169
- const { stdout, stderr } = await execFileAsync(WHISPER_BINARY, [
4170
- "-m",
4171
- WHISPER_MODEL,
4172
- "-f",
4173
- wavPath,
4174
- "--no-timestamps",
4175
- "-l",
4176
- "auto"
4177
- ], { timeout: WHISPER_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
4178
- const durationMs = Date.now() - startMs;
4179
- const { text, count: stripped } = stripSpecialTokens(stdout.trim());
4180
- if (!text) {
4181
- console.error(`${TAG12} failed: whisper returned empty output duration_ms=${durationMs}`);
4208
+ let buffer = await downloadMediaMessage(
4209
+ msg,
4210
+ "buffer",
4211
+ {},
4212
+ {
4213
+ reuploadRequest: sock.updateMediaMessage,
4214
+ logger: sock.logger
4215
+ }
4216
+ );
4217
+ if (!buffer || buffer.length === 0) {
4218
+ console.error(`${TAG9} primary download returned empty, trying direct fallback`);
4219
+ const downloadable = getDownloadableContent(content);
4220
+ if (downloadable) {
4221
+ try {
4222
+ const stream2 = await downloadContentFromMessage(downloadable.downloadable, downloadable.mediaType);
4223
+ buffer = await streamToBuffer(stream2);
4224
+ } catch (fallbackErr) {
4225
+ console.error(`${TAG9} direct download fallback failed: ${String(fallbackErr)}`);
4226
+ }
4227
+ }
4228
+ }
4229
+ if (!buffer || buffer.length === 0) {
4230
+ console.error(`${TAG9} download failed: empty buffer for ${mimetype ?? "unknown"}`);
4182
4231
  return void 0;
4183
4232
  }
4184
- const langMatch = stderr.match(/auto-detected language:\s*(\w+)/);
4185
- const language = langMatch?.[1] ?? "unknown";
4186
- const words = text.split(/\s+/).filter(Boolean).length;
4187
- console.error(
4188
- `${TAG12} done provider=whisper-local duration_ms=${durationMs} words=${words} stripped=${stripped} lang=${language}`
4189
- );
4190
- return { text, language, durationMs };
4233
+ if (buffer.length > maxBytes) {
4234
+ const sizeMB = (buffer.length / (1024 * 1024)).toFixed(1);
4235
+ const limitMB = (maxBytes / (1024 * 1024)).toFixed(0);
4236
+ console.error(`${TAG9} media too large type=${mimetype ?? "unknown"} size=${sizeMB}MB limit=${limitMB}MB`);
4237
+ return void 0;
4238
+ }
4239
+ return {
4240
+ buffer,
4241
+ mimetype: mimetype ?? "application/octet-stream",
4242
+ filename: inner.documentMessage?.fileName ?? void 0
4243
+ };
4191
4244
  } catch (err) {
4192
- const durationMs = Date.now() - startMs;
4193
- const reason = err instanceof Error ? err.message : String(err);
4194
- console.error(`${TAG12} failed provider=whisper-local duration_ms=${durationMs} error=${reason}`);
4245
+ console.error(`${TAG9} media download failed type=${mimetype ?? "unknown"} error=${String(err)}`);
4195
4246
  return void 0;
4196
- } finally {
4197
- unlink(wavPath).catch(() => {
4198
- });
4199
- }
4200
- }
4201
-
4202
- // app/lib/whatsapp/reply-error.ts
4203
- var WaReplyError = class extends Error {
4204
- terminal;
4205
- constructor(message, terminal) {
4206
- super(message);
4207
- this.name = "WaReplyError";
4208
- this.terminal = terminal;
4209
- }
4210
- };
4211
-
4212
- // app/lib/whatsapp/manager.ts
4213
- var TAG13 = "[whatsapp:manager]";
4214
- var MAX_RECONNECT_ATTEMPTS = 10;
4215
- var MESSAGE_STORE_MAX = 500;
4216
- var connections = /* @__PURE__ */ new Map();
4217
- var configDir = null;
4218
- var whatsAppConfig = {};
4219
- var onInboundMessage = null;
4220
- var initialized = false;
4221
- var messageStore = /* @__PURE__ */ new Map();
4222
- function messageStoreKey(accountId, remoteJid) {
4223
- return `${accountId}:${remoteJid}`;
4224
- }
4225
- function storeMessage(storeKey, entry) {
4226
- let entries = messageStore.get(storeKey);
4227
- if (!entries) {
4228
- entries = [];
4229
- messageStore.set(storeKey, entries);
4230
- }
4231
- entries.push(entry);
4232
- if (entries.length > MESSAGE_STORE_MAX) {
4233
- const trimmed = entries.length - MESSAGE_STORE_MAX;
4234
- entries.splice(0, trimmed);
4235
- console.error(`${TAG13} message store trimmed for ${storeKey}: ${trimmed} oldest messages removed`);
4236
4247
  }
4237
4248
  }
4238
- function deriveCacheKey(input) {
4239
- if (input.isOwnerMirror || input.agentType === "admin") {
4240
- return `whatsapp:${input.accountId}`;
4241
- }
4242
- if (input.isGroup) {
4243
- if (!input.groupJid) {
4244
- throw new Error(
4245
- `deriveCacheKey: isGroup=true requires groupJid (accountId=${input.accountId}, senderPhone=${input.senderPhone})`
4246
- );
4249
+ async function downloadInboundMedia(msg, sock, opts) {
4250
+ const res = await downloadInboundMediaBufferCached(msg, sock, opts);
4251
+ if (!res) return void 0;
4252
+ await mkdir2(MEDIA_DIR, { recursive: true });
4253
+ const ext = mimeToExt(res.mimetype);
4254
+ const filePath = join6(MEDIA_DIR, `${randomUUID4()}.${ext}`);
4255
+ await writeFile2(filePath, res.buffer);
4256
+ const sizeKB = (res.buffer.length / 1024).toFixed(0);
4257
+ console.error(`${TAG9} media downloaded type=${res.mimetype} size=${sizeKB}KB path=${filePath}`);
4258
+ return { path: filePath, mimetype: res.mimetype, size: res.buffer.length };
4259
+ }
4260
+ async function storeInboundMediaServable(msg, sock, accountId, seed, opts) {
4261
+ const attachmentId = deterministicAttachmentId(seed);
4262
+ try {
4263
+ if (attachmentExists(accountId, attachmentId)) return attachmentId;
4264
+ const res = await downloadInboundMediaBufferCached(msg, sock, opts);
4265
+ if (!res) {
4266
+ console.error(`${TAG9} servable-store skip reason=no-buffer id=${attachmentId}`);
4267
+ return null;
4247
4268
  }
4248
- return `whatsapp:${input.accountId}:group:${input.groupJid}`;
4269
+ const filename = res.filename ?? `${attachmentId}.${mimeToExt(res.mimetype)}`;
4270
+ await storeMediaBuffer(accountId, attachmentId, filename, res.mimetype, res.buffer);
4271
+ console.error(`${TAG9} servable-store ok id=${attachmentId} mime=${res.mimetype} bytes=${res.buffer.length}`);
4272
+ return attachmentId;
4273
+ } catch (err) {
4274
+ console.error(`${TAG9} servable-store FAIL id=${attachmentId} reason=${String(err).slice(0, 120)}`);
4275
+ return null;
4249
4276
  }
4250
- return `whatsapp:${input.accountId}:${input.senderPhone}`;
4251
- }
4252
- function buildSelfChatDispatch(input) {
4253
- const senderPhone = input.selfPhone ?? "owner";
4254
- return {
4255
- accountId: input.accountId,
4256
- agentType: "admin",
4257
- senderPhone,
4258
- text: input.text,
4259
- isGroup: false,
4260
- reply: input.reply,
4261
- composing: input.composing,
4262
- cacheKey: deriveCacheKey({
4263
- agentType: "admin",
4264
- accountId: input.accountId,
4265
- senderPhone,
4266
- isGroup: false
4267
- }),
4268
- media: [],
4269
- // Self-chat is the owner: the session scopes to the house account (Task 1383).
4270
- effectiveAccountId: input.accountId
4271
- };
4272
4277
  }
4273
- async function init(opts) {
4274
- if (initialized) {
4275
- console.error(`${TAG13} already initialized`);
4276
- return;
4278
+
4279
+ // app/lib/whatsapp/inbound/debounce.ts
4280
+ var TAG10 = "[whatsapp:debounce]";
4281
+ var STT_TAG = "[whatsapp:stt-await]";
4282
+ function createInboundDebouncer(opts) {
4283
+ const { debounceMs, buildKey, onFlush, onError } = opts;
4284
+ const awaitTimeoutMs = opts.awaitTimeoutMs ?? 3e4;
4285
+ const pending = /* @__PURE__ */ new Map();
4286
+ const pendingAwaits = /* @__PURE__ */ new Map();
4287
+ let destroyed = false;
4288
+ async function flushKey(key) {
4289
+ const awaiting = pendingAwaits.get(key);
4290
+ if (awaiting) {
4291
+ const start = Date.now();
4292
+ try {
4293
+ await Promise.race([
4294
+ awaiting,
4295
+ new Promise(
4296
+ (_, reject) => setTimeout(() => reject(new Error("stt-await-timeout")), awaitTimeoutMs)
4297
+ )
4298
+ ]);
4299
+ console.error(`${STT_TAG} resolved key=${key} waited_ms=${Date.now() - start}`);
4300
+ } catch {
4301
+ console.error(`${STT_TAG} timeout key=${key} waited_ms=${Date.now() - start}`);
4302
+ }
4303
+ pendingAwaits.delete(key);
4304
+ }
4305
+ const batch = pending.get(key);
4306
+ if (!batch) return;
4307
+ clearTimeout(batch.timer);
4308
+ pending.delete(key);
4309
+ const batchSize = batch.entries.length;
4310
+ try {
4311
+ console.error(`${TAG10} debounce flush key=${key} batchSize=${batchSize}`);
4312
+ const result = onFlush(batch.entries);
4313
+ if (result && typeof result.catch === "function") {
4314
+ result.catch(onError);
4315
+ }
4316
+ } catch (err) {
4317
+ onError(err);
4318
+ }
4277
4319
  }
4278
- configDir = opts.configDir;
4279
- onInboundMessage = opts.onMessage;
4280
- loadConfig(opts.accountConfig);
4281
- const accountIds = listCredentialAccountIds(configDir);
4282
- if (accountIds.length === 0) {
4283
- console.error(`${TAG13} init: no stored WhatsApp credentials found`);
4284
- initialized = true;
4285
- return;
4320
+ async function flush() {
4321
+ const keys = Array.from(pending.keys());
4322
+ await Promise.all(keys.map((key) => flushKey(key)));
4286
4323
  }
4287
- console.error(`${TAG13} init: found ${accountIds.length} credentialed account(s): ${accountIds.join(", ")}`);
4288
- initialized = true;
4289
- for (const accountId of accountIds) {
4290
- const accountCfg = whatsAppConfig.accounts?.[accountId];
4291
- if (accountCfg?.enabled === false) {
4292
- console.error(`${TAG13} skipping disabled account=${accountId}`);
4293
- continue;
4324
+ function enqueue(msg) {
4325
+ if (destroyed) return;
4326
+ if (debounceMs <= 0) {
4327
+ try {
4328
+ const result = onFlush([msg]);
4329
+ if (result && typeof result.catch === "function") {
4330
+ result.catch(onError);
4331
+ }
4332
+ } catch (err) {
4333
+ onError(err);
4334
+ }
4335
+ return;
4336
+ }
4337
+ const key = buildKey(msg);
4338
+ if (!key) {
4339
+ try {
4340
+ const result = onFlush([msg]);
4341
+ if (result && typeof result.catch === "function") {
4342
+ result.catch(onError);
4343
+ }
4344
+ } catch (err) {
4345
+ onError(err);
4346
+ }
4347
+ return;
4348
+ }
4349
+ const existing = pending.get(key);
4350
+ if (existing) {
4351
+ existing.entries.push(msg);
4352
+ clearTimeout(existing.timer);
4353
+ existing.timer = setTimeout(() => {
4354
+ flushKey(key).catch(onError);
4355
+ }, debounceMs);
4356
+ } else {
4357
+ const timer2 = setTimeout(() => {
4358
+ flushKey(key).catch(onError);
4359
+ }, debounceMs);
4360
+ pending.set(key, { entries: [msg], timer: timer2 });
4294
4361
  }
4295
- startConnection(accountId).catch((err) => {
4296
- console.error(`${TAG13} failed to auto-start account=${accountId}: ${formatError(err)}`);
4297
- });
4298
4362
  }
4299
- }
4300
- async function startConnection(accountId) {
4301
- if (!configDir) throw new Error("WhatsApp manager not initialized");
4302
- const authDir = resolveAuthDir(configDir, accountId);
4303
- const hasAuth = await authExists(authDir);
4304
- if (!hasAuth) {
4305
- console.error(`${TAG13} no credentials for account=${accountId}`);
4306
- return;
4363
+ function registerPending(key, promise) {
4364
+ if (destroyed) return;
4365
+ const existing = pendingAwaits.get(key);
4366
+ if (existing) {
4367
+ pendingAwaits.set(key, Promise.all([existing, promise]).then(() => {
4368
+ }));
4369
+ } else {
4370
+ pendingAwaits.set(key, promise);
4371
+ }
4372
+ console.error(`${STT_TAG} pending key=${key}`);
4307
4373
  }
4308
- let platformAccountId;
4374
+ async function destroy() {
4375
+ if (destroyed) return;
4376
+ destroyed = true;
4377
+ await flush();
4378
+ }
4379
+ return { enqueue, flush, destroy, registerPending };
4380
+ }
4381
+
4382
+ // app/lib/whatsapp/inbound/combine-batch.ts
4383
+ function combineInboundBatch(entries) {
4384
+ const last = entries[entries.length - 1];
4385
+ const combinedText = entries.map((e) => e.text).filter(Boolean).join("\n");
4386
+ const media = entries.flatMap((e) => e.media);
4387
+ return { ...last, text: combinedText, media };
4388
+ }
4389
+
4390
+ // app/lib/whatsapp/opening-hours.ts
4391
+ var TAG11 = "[whatsapp:hours]";
4392
+ async function isBusinessOpen(accountId) {
4309
4393
  try {
4310
- platformAccountId = resolvePlatformAccountId();
4311
- console.error(
4312
- `[whatsapp-persist] resolved-account-id waname=${accountId} uuid=${platformAccountId}`
4313
- );
4394
+ const timezone = await resolveTimezone(accountId);
4395
+ const now = /* @__PURE__ */ new Date();
4396
+ const dayOfWeek = getDayOfWeek(now, timezone);
4397
+ const previousDayOfWeek = getPreviousDayOfWeek(now, timezone);
4398
+ const currentTime = getCurrentTime(now, timezone);
4399
+ const session = getSession();
4400
+ try {
4401
+ const result = await session.run(
4402
+ `MATCH (b:LocalBusiness {accountId: $accountId})
4403
+ OPTIONAL MATCH (b)-[:HAS_HOURS]->(hours:OpeningHoursSpecification)
4404
+ WHERE hours.dayOfWeek IN [$dayOfWeek, $previousDayOfWeek]
4405
+ RETURN hours.dayOfWeek AS dayOfWeek, hours.opens AS opens, hours.closes AS closes`,
4406
+ { accountId, dayOfWeek, previousDayOfWeek }
4407
+ );
4408
+ if (result.records.length === 0) {
4409
+ console.error(`${TAG11} [${accountId}] business hours check: no opening hours configured, treating as open`);
4410
+ return { open: true, reason: "no opening hours configured" };
4411
+ }
4412
+ const specs = result.records.filter((r) => r.get("opens") != null && r.get("closes") != null).map((r) => ({
4413
+ day: String(r.get("dayOfWeek")).trim(),
4414
+ opens: String(r.get("opens")).trim(),
4415
+ closes: String(r.get("closes")).trim()
4416
+ }));
4417
+ if (specs.length === 0) {
4418
+ console.error(`${TAG11} [${accountId}] business hours check: no opening hours configured, treating as open`);
4419
+ return { open: true, reason: "no opening hours configured" };
4420
+ }
4421
+ for (const spec of specs) {
4422
+ if (!spec.opens || !spec.closes || spec.opens === spec.closes) continue;
4423
+ const isYesterday = spec.day === previousDayOfWeek;
4424
+ if (isYesterday) {
4425
+ if (spec.opens > spec.closes && currentTime < spec.closes) {
4426
+ const hoursStr = `${spec.opens}-${spec.closes} (${spec.day})`;
4427
+ console.error(
4428
+ `${TAG11} [${accountId}] business hours check: open (day=${dayOfWeek}, time=${currentTime}, hours=${hoursStr})`
4429
+ );
4430
+ return {
4431
+ open: true,
4432
+ reason: "within business hours",
4433
+ dayOfWeek,
4434
+ currentTime,
4435
+ matchedHours: hoursStr
4436
+ };
4437
+ }
4438
+ } else if (isTimeInRange(currentTime, spec.opens, spec.closes)) {
4439
+ const hoursStr = `${spec.opens}-${spec.closes}`;
4440
+ console.error(
4441
+ `${TAG11} [${accountId}] business hours check: open (day=${dayOfWeek}, time=${currentTime}, hours=${hoursStr})`
4442
+ );
4443
+ return {
4444
+ open: true,
4445
+ reason: "within business hours",
4446
+ dayOfWeek,
4447
+ currentTime,
4448
+ matchedHours: hoursStr
4449
+ };
4450
+ }
4451
+ }
4452
+ const todaySpecs = specs.filter((s) => s.day === dayOfWeek);
4453
+ const allHours = todaySpecs.map((s) => `${s.opens}-${s.closes}`).join(", ") || "none today";
4454
+ console.error(
4455
+ `${TAG11} [${accountId}] business hours check: closed (day=${dayOfWeek}, time=${currentTime}, hours=${allHours})`
4456
+ );
4457
+ return {
4458
+ open: false,
4459
+ reason: "outside business hours",
4460
+ dayOfWeek,
4461
+ currentTime,
4462
+ matchedHours: allHours
4463
+ };
4464
+ } finally {
4465
+ await session.close();
4466
+ }
4314
4467
  } catch (err) {
4315
- const reason = err instanceof Error ? err.message : String(err);
4316
4468
  console.error(
4317
- `[whatsapp-persist] resolved-account-id FAIL waname=${accountId} reason=${reason}`
4469
+ `${TAG11} [${accountId}] business hours check failed, treating as open: ${err instanceof Error ? err.message : String(err)}`
4318
4470
  );
4319
- throw err;
4471
+ return { open: true, reason: "hours check failed (treating as open)" };
4320
4472
  }
4321
- await stopConnection(accountId);
4322
- const conn = {
4323
- accountId,
4324
- platformAccountId,
4325
- accountName: whatsAppConfig.accounts?.[accountId]?.name,
4326
- authDir,
4327
- sock: null,
4328
- connected: false,
4329
- selfPhone: null,
4330
- selfJid: null,
4331
- selfLid: null,
4473
+ }
4474
+ async function resolveTimezone(accountId) {
4475
+ try {
4476
+ const tz = await getUserTimezone(accountId);
4477
+ if (tz) {
4478
+ Intl.DateTimeFormat("en-US", { timeZone: tz });
4479
+ return tz;
4480
+ }
4481
+ } catch {
4482
+ }
4483
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
4484
+ }
4485
+ function getDayOfWeek(date, timezone) {
4486
+ return new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: timezone }).format(date);
4487
+ }
4488
+ function getPreviousDayOfWeek(date, timezone) {
4489
+ const yesterday = new Date(date.getTime() - 24 * 60 * 60 * 1e3);
4490
+ return getDayOfWeek(yesterday, timezone);
4491
+ }
4492
+ function getCurrentTime(date, timezone) {
4493
+ const parts = new Intl.DateTimeFormat("en-US", {
4494
+ hour: "2-digit",
4495
+ minute: "2-digit",
4496
+ hour12: false,
4497
+ timeZone: timezone
4498
+ }).formatToParts(date);
4499
+ const hour = parts.find((p) => p.type === "hour")?.value ?? "00";
4500
+ const minute = parts.find((p) => p.type === "minute")?.value ?? "00";
4501
+ return `${hour}:${minute}`;
4502
+ }
4503
+ function isTimeInRange(time, opens, closes) {
4504
+ if (opens <= closes) {
4505
+ return time >= opens && time < closes;
4506
+ }
4507
+ return time >= opens || time < closes;
4508
+ }
4509
+
4510
+ // app/lib/stt/index.ts
4511
+ import { execFile } from "child_process";
4512
+ import { unlink, stat } from "fs/promises";
4513
+ import { promisify } from "util";
4514
+ var execFileAsync = promisify(execFile);
4515
+ var TAG12 = "[stt]";
4516
+ var WHISPER_BINARY = process.env.WHISPER_BINARY ?? "/opt/whisper.cpp/build/bin/whisper-cli";
4517
+ var WHISPER_MODEL = process.env.WHISPER_MODEL ?? "/opt/whisper.cpp/models/ggml-base.bin";
4518
+ var WHISPER_TIMEOUT_MS = 20 * 60 * 1e3;
4519
+ var SPECIAL_TOKEN_RE = /\[_[A-Z]+(?:_\d+)?_?\]/g;
4520
+ function stripSpecialTokens(input) {
4521
+ const matches = input.match(SPECIAL_TOKEN_RE);
4522
+ const count = matches ? matches.length : 0;
4523
+ if (!count) return { text: input, count: 0 };
4524
+ const text = input.replace(SPECIAL_TOKEN_RE, "").replace(/[ \t]{2,}/g, " ").trim();
4525
+ return { text, count };
4526
+ }
4527
+ async function transcribe(audioPath, mimetype) {
4528
+ const startMs = Date.now();
4529
+ let audioBytes = 0;
4530
+ try {
4531
+ const s = await stat(audioPath);
4532
+ audioBytes = s.size;
4533
+ } catch {
4534
+ console.error(`${TAG12} failed: file not readable path=${audioPath}`);
4535
+ return void 0;
4536
+ }
4537
+ console.error(
4538
+ `${TAG12} start provider=whisper-local audio_bytes=${audioBytes} mimetype=${mimetype} path=${audioPath}`
4539
+ );
4540
+ const wavPath = audioPath.replace(/\.[^.]+$/, "") + ".wav";
4541
+ try {
4542
+ await execFileAsync("ffmpeg", [
4543
+ "-i",
4544
+ audioPath,
4545
+ "-ar",
4546
+ "16000",
4547
+ "-ac",
4548
+ "1",
4549
+ "-c:a",
4550
+ "pcm_s16le",
4551
+ "-y",
4552
+ // overwrite without prompting
4553
+ wavPath
4554
+ ], { timeout: 3e4 });
4555
+ } catch (err) {
4556
+ const reason = err instanceof Error ? err.message : String(err);
4557
+ console.error(`${TAG12} failed: ffmpeg conversion error=${reason}`);
4558
+ return void 0;
4559
+ }
4560
+ try {
4561
+ const { stdout, stderr } = await execFileAsync(WHISPER_BINARY, [
4562
+ "-m",
4563
+ WHISPER_MODEL,
4564
+ "-f",
4565
+ wavPath,
4566
+ "--no-timestamps",
4567
+ "-l",
4568
+ "auto"
4569
+ ], { timeout: WHISPER_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
4570
+ const durationMs = Date.now() - startMs;
4571
+ const { text, count: stripped } = stripSpecialTokens(stdout.trim());
4572
+ if (!text) {
4573
+ console.error(`${TAG12} failed: whisper returned empty output duration_ms=${durationMs}`);
4574
+ return void 0;
4575
+ }
4576
+ const langMatch = stderr.match(/auto-detected language:\s*(\w+)/);
4577
+ const language = langMatch?.[1] ?? "unknown";
4578
+ const words = text.split(/\s+/).filter(Boolean).length;
4579
+ console.error(
4580
+ `${TAG12} done provider=whisper-local duration_ms=${durationMs} words=${words} stripped=${stripped} lang=${language}`
4581
+ );
4582
+ return { text, language, durationMs };
4583
+ } catch (err) {
4584
+ const durationMs = Date.now() - startMs;
4585
+ const reason = err instanceof Error ? err.message : String(err);
4586
+ console.error(`${TAG12} failed provider=whisper-local duration_ms=${durationMs} error=${reason}`);
4587
+ return void 0;
4588
+ } finally {
4589
+ unlink(wavPath).catch(() => {
4590
+ });
4591
+ }
4592
+ }
4593
+
4594
+ // app/lib/whatsapp/reply-error.ts
4595
+ var WaReplyError = class extends Error {
4596
+ terminal;
4597
+ constructor(message, terminal) {
4598
+ super(message);
4599
+ this.name = "WaReplyError";
4600
+ this.terminal = terminal;
4601
+ }
4602
+ };
4603
+
4604
+ // app/lib/whatsapp/manager.ts
4605
+ var TAG13 = "[whatsapp:manager]";
4606
+ var MAX_RECONNECT_ATTEMPTS = 10;
4607
+ var MESSAGE_STORE_MAX = 500;
4608
+ var connections = /* @__PURE__ */ new Map();
4609
+ var configDir = null;
4610
+ var whatsAppConfig = {};
4611
+ var onInboundMessage = null;
4612
+ var initialized = false;
4613
+ var messageStore = /* @__PURE__ */ new Map();
4614
+ function messageStoreKey(accountId, remoteJid) {
4615
+ return `${accountId}:${remoteJid}`;
4616
+ }
4617
+ function storeMessage(storeKey, entry) {
4618
+ let entries = messageStore.get(storeKey);
4619
+ if (!entries) {
4620
+ entries = [];
4621
+ messageStore.set(storeKey, entries);
4622
+ }
4623
+ entries.push(entry);
4624
+ if (entries.length > MESSAGE_STORE_MAX) {
4625
+ const trimmed = entries.length - MESSAGE_STORE_MAX;
4626
+ entries.splice(0, trimmed);
4627
+ console.error(`${TAG13} message store trimmed for ${storeKey}: ${trimmed} oldest messages removed`);
4628
+ }
4629
+ }
4630
+ function deriveCacheKey(input) {
4631
+ if (input.isOwnerMirror || input.agentType === "admin") {
4632
+ return `whatsapp:${input.accountId}`;
4633
+ }
4634
+ if (input.isGroup) {
4635
+ if (!input.groupJid) {
4636
+ throw new Error(
4637
+ `deriveCacheKey: isGroup=true requires groupJid (accountId=${input.accountId}, senderPhone=${input.senderPhone})`
4638
+ );
4639
+ }
4640
+ return `whatsapp:${input.accountId}:group:${input.groupJid}`;
4641
+ }
4642
+ return `whatsapp:${input.accountId}:${input.senderPhone}`;
4643
+ }
4644
+ function buildSelfChatDispatch(input) {
4645
+ const senderPhone = input.selfPhone ?? "owner";
4646
+ return {
4647
+ accountId: input.accountId,
4648
+ agentType: "admin",
4649
+ senderPhone,
4650
+ text: input.text,
4651
+ isGroup: false,
4652
+ reply: input.reply,
4653
+ composing: input.composing,
4654
+ cacheKey: deriveCacheKey({
4655
+ agentType: "admin",
4656
+ accountId: input.accountId,
4657
+ senderPhone,
4658
+ isGroup: false
4659
+ }),
4660
+ media: [],
4661
+ // Self-chat is the owner: the session scopes to the house account (Task 1383).
4662
+ effectiveAccountId: input.accountId
4663
+ };
4664
+ }
4665
+ async function init(opts) {
4666
+ if (initialized) {
4667
+ console.error(`${TAG13} already initialized`);
4668
+ return;
4669
+ }
4670
+ configDir = opts.configDir;
4671
+ onInboundMessage = opts.onMessage;
4672
+ loadConfig(opts.accountConfig);
4673
+ const accountIds = listCredentialAccountIds(configDir);
4674
+ if (accountIds.length === 0) {
4675
+ console.error(`${TAG13} init: no stored WhatsApp credentials found`);
4676
+ initialized = true;
4677
+ return;
4678
+ }
4679
+ console.error(`${TAG13} init: found ${accountIds.length} credentialed account(s): ${accountIds.join(", ")}`);
4680
+ initialized = true;
4681
+ for (const accountId of accountIds) {
4682
+ const accountCfg = whatsAppConfig.accounts?.[accountId];
4683
+ if (accountCfg?.enabled === false) {
4684
+ console.error(`${TAG13} skipping disabled account=${accountId}`);
4685
+ continue;
4686
+ }
4687
+ startConnection(accountId).catch((err) => {
4688
+ console.error(`${TAG13} failed to auto-start account=${accountId}: ${formatError(err)}`);
4689
+ });
4690
+ }
4691
+ }
4692
+ async function startConnection(accountId) {
4693
+ if (!configDir) throw new Error("WhatsApp manager not initialized");
4694
+ const authDir = resolveAuthDir(configDir, accountId);
4695
+ const hasAuth = await authExists(authDir);
4696
+ if (!hasAuth) {
4697
+ console.error(`${TAG13} no credentials for account=${accountId}`);
4698
+ return;
4699
+ }
4700
+ let platformAccountId;
4701
+ try {
4702
+ platformAccountId = resolvePlatformAccountId();
4703
+ console.error(
4704
+ `[whatsapp-persist] resolved-account-id waname=${accountId} uuid=${platformAccountId}`
4705
+ );
4706
+ } catch (err) {
4707
+ const reason = err instanceof Error ? err.message : String(err);
4708
+ console.error(
4709
+ `[whatsapp-persist] resolved-account-id FAIL waname=${accountId} reason=${reason}`
4710
+ );
4711
+ throw err;
4712
+ }
4713
+ await stopConnection(accountId);
4714
+ const conn = {
4715
+ accountId,
4716
+ platformAccountId,
4717
+ accountName: whatsAppConfig.accounts?.[accountId]?.name,
4718
+ authDir,
4719
+ sock: null,
4720
+ connected: false,
4721
+ selfPhone: null,
4722
+ selfJid: null,
4723
+ selfLid: null,
4332
4724
  reconnectAttempts: 0,
4333
4725
  abortController: new AbortController(),
4334
4726
  debouncer: null,
@@ -4375,9 +4767,12 @@ function getStatus() {
4375
4767
  function getSocket(accountId) {
4376
4768
  return connections.get(accountId)?.sock ?? null;
4377
4769
  }
4770
+ function findConnectionByKey(conns, accountId) {
4771
+ return conns.find((c) => c.accountId === accountId) ?? conns.find((c) => c.platformAccountId === accountId);
4772
+ }
4378
4773
  function selectConnection(conns, accountId) {
4379
4774
  const presentKeys = conns.map((c) => c.accountId);
4380
- const conn = conns.find((c) => c.accountId === accountId) ?? conns.find((c) => c.platformAccountId === accountId);
4775
+ const conn = findConnectionByKey(conns, accountId);
4381
4776
  if (!conn) return { ok: false, reason: "key-miss", presentKeys };
4382
4777
  if (!conn.sock) return { ok: false, reason: "disconnected", presentKeys };
4383
4778
  return { ok: true, sock: conn.sock, accountId: conn.accountId };
@@ -4385,6 +4780,13 @@ function selectConnection(conns, accountId) {
4385
4780
  function resolveSocket(accountId) {
4386
4781
  return selectConnection(Array.from(connections.values()), accountId);
4387
4782
  }
4783
+ function selectStoreAccountKey(conns, accountId) {
4784
+ const conn = findConnectionByKey(conns, accountId);
4785
+ return conn ? conn.platformAccountId : accountId;
4786
+ }
4787
+ function resolveReadStoreKey(accountId) {
4788
+ return selectStoreAccountKey(Array.from(connections.values()), accountId);
4789
+ }
4388
4790
  function listConnectionPlatformAccountIds() {
4389
4791
  return [...connections.values()].map((conn) => conn.platformAccountId);
4390
4792
  }
@@ -4703,21 +5105,24 @@ function monitorInbound(conn) {
4703
5105
  if (!remoteJid || remoteJid === "status@broadcast") continue;
4704
5106
  if (!msg.message) continue;
4705
5107
  const extracted = extractMessage(msg);
4706
- if (extracted.text) {
5108
+ const mediaServable = !!(extracted.mediaType && extracted.mimetype && isSupportedMime(extracted.mimetype));
5109
+ if (extracted.text || mediaServable) {
4707
5110
  const isGroup = isGroupJid(remoteJid);
4708
5111
  const senderJid = isGroup ? msg.key.participant ?? remoteJid : remoteJid;
4709
5112
  const senderPhone = await resolveJidToE164(senderJid, conn.lidMapping) ?? senderJid;
4710
5113
  const ts = msg.messageTimestamp ? Number(msg.messageTimestamp) : Math.floor(Date.now() / 1e3);
4711
5114
  const storeKey = messageStoreKey(conn.platformAccountId, remoteJid);
4712
- storeMessage(storeKey, {
4713
- id: msg.key.id ?? `${remoteJid}-${ts}`,
4714
- sender: senderPhone,
4715
- senderName: msg.pushName ?? void 0,
4716
- body: extracted.text,
4717
- timestamp: ts,
4718
- fromMe: Boolean(msg.key.fromMe),
4719
- quoted: extracted.quotedMessage ? { id: extracted.quotedMessage.id, body: extracted.quotedMessage.text, sender: extracted.quotedMessage.sender } : void 0
4720
- });
5115
+ if (extracted.text) {
5116
+ storeMessage(storeKey, {
5117
+ id: msg.key.id ?? `${remoteJid}-${ts}`,
5118
+ sender: senderPhone,
5119
+ senderName: msg.pushName ?? void 0,
5120
+ body: extracted.text,
5121
+ timestamp: ts,
5122
+ fromMe: Boolean(msg.key.fromMe),
5123
+ quoted: extracted.quotedMessage ? { id: extracted.quotedMessage.id, body: extracted.quotedMessage.text, sender: extracted.quotedMessage.sender } : void 0
5124
+ });
5125
+ }
4721
5126
  const entries = messageStore.get(storeKey);
4722
5127
  console.error(
4723
5128
  `${TAG13} stored message ${msg.key.id ?? "?"} for ${remoteJid} (type: ${upsert.type}, store size: ${entries?.length ?? 0}/${MESSAGE_STORE_MAX}) account=${conn.platformAccountId}`
@@ -4755,6 +5160,17 @@ function monitorInbound(conn) {
4755
5160
  remoteJid,
4756
5161
  conn.lidMapping
4757
5162
  );
5163
+ let mediaAttachmentId;
5164
+ if (mediaServable && !fromMe && conn.sock && upsert.type === "notify") {
5165
+ const maxMb = whatsAppConfig.mediaMaxMb ?? 50;
5166
+ mediaAttachmentId = await storeInboundMediaServable(
5167
+ msg,
5168
+ conn.sock,
5169
+ conn.platformAccountId,
5170
+ `whatsapp-live:${conn.accountId}:${canonicalRemoteJid}:${msg.key.id}`,
5171
+ { maxBytes: maxMb * 1024 * 1024 }
5172
+ ) ?? void 0;
5173
+ }
4758
5174
  await persistWhatsAppMessage({
4759
5175
  accountId: conn.accountId,
4760
5176
  platformAccountId: conn.platformAccountId,
@@ -4766,7 +5182,13 @@ function monitorInbound(conn) {
4766
5182
  fromMe,
4767
5183
  senderPhone,
4768
5184
  selfPhone: conn.selfPhone,
4769
- body: extracted.text,
5185
+ // A stored media message persists the human caption (or empty) plus
5186
+ // the attachment id — the reader shows the inline media, never the
5187
+ // synthetic `[document: …]` / `[sticker]` line. If the store failed
5188
+ // (no id), fall back to the extracted text so a document/sticker
5189
+ // still shows its placeholder rather than a blank bubble.
5190
+ body: mediaAttachmentId ? extracted.caption ?? "" : extracted.text,
5191
+ attachmentId: mediaAttachmentId,
4770
5192
  timestamp: ts,
4771
5193
  pushName: msg.pushName ?? void 0,
4772
5194
  quoted: extracted.quotedMessage ? {
@@ -4884,868 +5306,543 @@ async function handleInboundMessage(conn, msg) {
4884
5306
  agentType: "admin",
4885
5307
  accountId: conn.accountId,
4886
5308
  senderPhone: senderPhone2,
4887
- isGroup: isGroup2,
4888
- groupJid: isGroup2 ? remoteJid : void 0,
4889
- isOwnerMirror: true
4890
- }),
4891
- media: [],
4892
- isOwnerMirror: true
4893
- });
4894
- return;
4895
- }
4896
- const extracted = extractMessage(msg);
4897
- if (!extracted.text && !extracted.mediaType) {
4898
- console.error(`${TAG13} drop: no text or media account=${conn.accountId} from=${remoteJid}`);
4899
- return;
4900
- }
4901
- let mediaResult;
4902
- if (extracted.mediaType && conn.sock) {
4903
- const maxMb = whatsAppConfig.mediaMaxMb ?? 50;
4904
- mediaResult = await downloadInboundMedia(msg, conn.sock, {
4905
- maxBytes: maxMb * 1024 * 1024
4906
- });
4907
- if (!mediaResult) {
4908
- console.error(`${TAG13} media download returned undefined account=${conn.accountId} type=${extracted.mediaType} from=${remoteJid}`);
4909
- }
4910
- }
4911
- const isGroup = isGroupJid(remoteJid);
4912
- const senderJid = isGroup ? msg.key.participant ?? remoteJid : remoteJid;
4913
- const senderPhone = await resolveJidToE164(senderJid, conn.lidMapping) ?? senderJid;
4914
- const lidCapture = lidCaptureFor(senderJid, senderPhone);
4915
- if (lidCapture) recordLidMapping(lidCapture.numericLid, lidCapture.e164);
4916
- const selfPhone = conn.selfPhone ?? "";
4917
- if (extracted.mediaType === "audio" && mediaResult?.path) {
4918
- const conversationKey = isGroup ? remoteJid : senderPhone;
4919
- const debounceKey = `${conn.accountId}:${conversationKey}:${senderPhone}`;
4920
- let resolvePending;
4921
- const sttPending = new Promise((resolve34) => {
4922
- resolvePending = resolve34;
4923
- });
4924
- if (conn.debouncer) conn.debouncer.registerPending(debounceKey, sttPending);
4925
- try {
4926
- const sttResult = await transcribe(mediaResult.path, mediaResult.mimetype);
4927
- if (sttResult?.text) {
4928
- extracted.text = sttResult.text;
4929
- }
4930
- } finally {
4931
- resolvePending();
4932
- }
4933
- }
4934
- let accessResult;
4935
- if (isGroup) {
4936
- accessResult = checkGroupAccess({ groupJid: remoteJid });
4937
- } else {
4938
- accessResult = await resolveDmAccess({
4939
- senderPhone,
4940
- selfPhone,
4941
- config: whatsAppConfig,
4942
- accountConfig: whatsAppConfig.accounts?.[conn.accountId],
4943
- accountId: conn.accountId,
4944
- isValidAccount: (id) => isValidAccountId(id),
4945
- resolvePersonByPhone
4946
- });
4947
- }
4948
- console.error(
4949
- `${TAG13} inbound account=${conn.accountId} from=${senderPhone} group=${isGroup} access=${accessResult.allowed ? "allowed" : "blocked"}(${accessResult.reason}) agent=${accessResult.agentType}` + (extracted.mediaType ? ` media=${extracted.mediaType}` : "") + (mediaResult ? ` mediaPath=${mediaResult.path}` : "") + (extracted.quotedMessage ? ` replyTo=${extracted.quotedMessage.id}` : "")
4950
- );
4951
- if (!accessResult.allowed && accessResult.reason === "account-manager-unresolved") {
4952
- console.error(
4953
- `[whatsapp-native] op=account-manager-reject senderId=${senderPhone} reason=unresolved-effective-account`
4954
- );
4955
- }
4956
- if (accessResult.allowed && accessResult.reason === "account-manager" && accessResult.effectiveAccountId === conn.accountId) {
4957
- console.error(
4958
- `[whatsapp-native] op=escalation-tripwire senderId=${senderPhone} reason=account-manager-routed-to-house effectiveAccount=${accessResult.effectiveAccountId}`
4959
- );
4960
- return;
4961
- }
4962
- if (!accessResult.allowed) return;
4963
- if (isCustomerTurnSuppressed(accessResult.agentType, isHouseManagedService())) {
4964
- console.error(
4965
- `[channel-house] op=inbound from=${senderPhone} accountId=${conn.platformAccountId} autoReply=suppressed`
4966
- );
4967
- return;
4968
- }
4969
- const sendReceipts = whatsAppConfig.accounts?.[conn.accountId]?.sendReadReceipts ?? whatsAppConfig.sendReadReceipts ?? true;
4970
- if (sendReceipts && msg.key.id) {
4971
- sendReadReceipt(conn.sock, remoteJid, [msg.key.id], isGroup ? senderJid : void 0);
4972
- }
4973
- const reply = async (text) => {
4974
- if (isGroupJid(remoteJid)) {
4975
- console.error(`${TAG13} op=group-reply-blocked jid=${remoteJid}`);
4976
- throw new WaReplyError("group reply blocked \u2014 observe-only", false);
4977
- }
4978
- const currentSock = conn.sock;
4979
- if (!currentSock) {
4980
- const reason = conn.terminalReason;
4981
- throw new WaReplyError(reason ?? "WhatsApp disconnected \u2014 cannot reply", Boolean(reason));
4982
- }
4983
- await sendTextMessage(currentSock, remoteJid, text, { accountId: conn.accountId });
4984
- };
4985
- const composing = async () => {
4986
- const currentSock = conn.sock;
4987
- if (currentSock) await sendComposing(currentSock, remoteJid);
4988
- };
4989
- const cacheKey = deriveCacheKey({
4990
- agentType: accessResult.agentType,
4991
- accountId: conn.accountId,
4992
- senderPhone,
4993
- isGroup,
4994
- groupJid: isGroup ? remoteJid : void 0
4995
- });
4996
- conn.lastMessageAt = Date.now();
4997
- const payload = {
4998
- accountId: conn.accountId,
4999
- agentType: accessResult.agentType,
5000
- passive: accessResult.passive === true,
5001
- personId: accessResult.personId ?? null,
5002
- senderPhone,
5003
- text: extracted.text,
5004
- isGroup,
5005
- groupJid: isGroup ? remoteJid : void 0,
5006
- reply,
5007
- composing,
5008
- cacheKey,
5009
- media: mediaResult?.path ? [{ path: mediaResult.path, type: extracted.mediaType, mimetype: mediaResult.mimetype }] : [],
5010
- replyContext: extracted.quotedMessage,
5011
- // Task 1383 — the gate's single resolution feeds the spawn (house or the
5012
- // bound sub-account); the router never re-resolves.
5013
- effectiveAccountId: accessResult.effectiveAccountId
5014
- };
5015
- if (accessResult.agentType === "public") {
5016
- const hoursResult = await isBusinessOpen(conn.accountId);
5017
- if (!hoursResult.open) {
5018
- console.error(`${TAG13} [${conn.accountId}] dispatch skipped: business closed`);
5019
- const afterHoursMessage = whatsAppConfig.accounts?.[conn.accountId]?.afterHoursMessage ?? whatsAppConfig.afterHoursMessage;
5020
- if (afterHoursMessage) {
5021
- try {
5022
- await reply(afterHoursMessage);
5023
- console.error(`${TAG13} [${conn.accountId}] after-hours auto-reply sent to ${remoteJid}`);
5024
- } catch (err) {
5025
- console.error(
5026
- `${TAG13} [${conn.accountId}] after-hours auto-reply failed: ${err instanceof Error ? err.message : String(err)}`
5027
- );
5028
- }
5029
- }
5030
- return;
5031
- }
5032
- }
5033
- if (conn.debouncer) {
5034
- conn.debouncer.enqueue(payload);
5035
- } else {
5036
- onInboundMessage(payload);
5037
- }
5038
- }
5039
-
5040
- // app/lib/vnc.ts
5041
- import { spawnSync, execFileSync } from "child_process";
5042
- import { createConnection } from "net";
5043
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
5044
- import { resolve as resolve5 } from "path";
5045
- var PLATFORM_ROOT3 = process.env.MAXY_PLATFORM_ROOT ?? resolve5(process.cwd(), "..");
5046
- var VNC_SCRIPT = resolve5(PLATFORM_ROOT3, "scripts/vnc.sh");
5047
- var displayMode = process.env.DISPLAY_MODE ?? "virtual";
5048
- if (displayMode === "native") {
5049
- console.log(`[vnc] DISPLAY_MODE=native \u2014 local requests use desktop display, remote requests use VNC`);
5050
- }
5051
- function resolveBrowserTransport(req, remoteAddress) {
5052
- if (displayMode !== "native") return "vnc";
5053
- const xff = req.headers.get("x-forwarded-for") ?? void 0;
5054
- const client = resolveClientIp(remoteAddress ?? "127.0.0.1", xff);
5055
- const transport = client.kind !== "external" && !xff ? "native" : "vnc";
5056
- if (remoteAddress && remoteAddress !== "127.0.0.1" && remoteAddress !== "::1") {
5057
- const oldClient = resolveClientIp("127.0.0.1", xff);
5058
- const oldTransport = oldClient.kind !== "external" && !xff ? "native" : "vnc";
5059
- if (transport !== oldTransport) {
5060
- console.log(`[resolve-transport] remoteAddress="${remoteAddress}" transport="${transport}" (was: "${oldTransport}" with hardcoded loopback)`);
5061
- }
5062
- }
5063
- return transport;
5064
- }
5065
- var currentCdpDisplay = null;
5066
- function discoverNativeDisplay() {
5067
- const fallback = { sessionType: "x11", display: ":0", waylandDisplay: "" };
5068
- try {
5069
- const listResult = spawnSync("loginctl", ["list-sessions", "--no-legend"], {
5070
- timeout: 5e3,
5071
- encoding: "utf8",
5072
- stdio: ["ignore", "pipe", "pipe"]
5073
- });
5074
- if (listResult.status !== 0 || !listResult.stdout.trim()) {
5075
- vncLog("native-display-discovery", { action: "fallback", reason: "no-sessions" });
5076
- return fallback;
5077
- }
5078
- const sessionId = listResult.stdout.trim().split("\n")[0].trim().split(/\s+/)[0];
5079
- const typeResult = spawnSync("loginctl", ["show-session", sessionId, "-p", "Type", "--value"], {
5080
- timeout: 5e3,
5081
- encoding: "utf8",
5082
- stdio: ["ignore", "pipe", "pipe"]
5083
- });
5084
- const displayResult = spawnSync("loginctl", ["show-session", sessionId, "-p", "Display", "--value"], {
5085
- timeout: 5e3,
5086
- encoding: "utf8",
5087
- stdio: ["ignore", "pipe", "pipe"]
5088
- });
5089
- const sessionType = typeResult.stdout?.trim() || "";
5090
- const sessionDisplay = displayResult.stdout?.trim() || ":0";
5091
- if (sessionType === "wayland") {
5092
- let waylandDisplay = "wayland-0";
5093
- const leaderResult = spawnSync("loginctl", ["show-session", sessionId, "-p", "Leader", "--value"], {
5094
- timeout: 5e3,
5095
- encoding: "utf8",
5096
- stdio: ["ignore", "pipe", "pipe"]
5097
- });
5098
- const leaderPid = leaderResult.stdout?.trim();
5099
- if (leaderPid) {
5100
- try {
5101
- const environ = readFileSync8(`/proc/${leaderPid}/environ`, "utf8");
5102
- const match = environ.split("\0").find((e) => e.startsWith("WAYLAND_DISPLAY="));
5103
- if (match) waylandDisplay = match.split("=")[1];
5104
- } catch {
5105
- }
5106
- }
5107
- const info2 = { sessionType: "wayland", display: sessionDisplay, waylandDisplay };
5108
- vncLog("native-display-discovery", {
5109
- action: "discovered",
5110
- session_type: "wayland",
5111
- display: sessionDisplay,
5112
- wayland_display: waylandDisplay
5113
- });
5114
- return info2;
5115
- }
5116
- const info = { sessionType: "x11", display: sessionDisplay, waylandDisplay: "" };
5117
- vncLog("native-display-discovery", {
5118
- action: "discovered",
5119
- session_type: sessionType || "unknown (defaulting to x11)",
5120
- display: sessionDisplay
5121
- });
5122
- return info;
5123
- } catch (err) {
5124
- vncLog("native-display-discovery", { action: "fallback", reason: "error", err: err.message });
5125
- return fallback;
5126
- }
5127
- }
5128
- function sleep(ms) {
5129
- return new Promise((r) => setTimeout(r, ms));
5130
- }
5131
- async function waitForPort(port2, timeoutMs = 12e3) {
5132
- const deadline = Date.now() + timeoutMs;
5133
- while (Date.now() < deadline) {
5134
- const ready = await new Promise((res) => {
5135
- const socket = createConnection(port2, "127.0.0.1");
5136
- socket.setTimeout(500);
5137
- socket.once("connect", () => {
5138
- socket.destroy();
5139
- res(true);
5140
- });
5141
- socket.once("error", () => {
5142
- socket.destroy();
5143
- res(false);
5144
- });
5145
- socket.once("timeout", () => {
5146
- socket.destroy();
5147
- res(false);
5148
- });
5149
- });
5150
- if (ready) return true;
5151
- await sleep(300);
5152
- }
5153
- return false;
5154
- }
5155
- function ensureLogDir() {
5156
- mkdirSync2(LOG_DIR, { recursive: true });
5157
- }
5158
- function logPath(name) {
5159
- return resolve5(LOG_DIR, `${name}.log`);
5160
- }
5161
- async function ensureVnc() {
5162
- if (process.platform !== "linux") {
5163
- vncLog("ensure-vnc", { action: "skipped", reason: "platform-not-linux", platform: process.platform });
5164
- return false;
5165
- }
5166
- const up = await waitForPort(RFB_PORT, 1e3);
5167
- if (up) return true;
5168
- vncLog("ensure-vnc", { action: "restart", reason: "x-down", rfb_port: RFB_PORT });
5169
- try {
5170
- execFileSync("bash", [VNC_SCRIPT, "start"], { timeout: 2e4 });
5171
- } catch (err) {
5172
- vncLog("ensure-vnc", { action: "restart-failed", err: err.message });
5173
- return false;
5309
+ isGroup: isGroup2,
5310
+ groupJid: isGroup2 ? remoteJid : void 0,
5311
+ isOwnerMirror: true
5312
+ }),
5313
+ media: [],
5314
+ isOwnerMirror: true
5315
+ });
5316
+ return;
5174
5317
  }
5175
- const ok = await waitForPort(RFB_PORT, 12e3);
5176
- vncLog("ensure-vnc", { action: "restart-complete", rfb_port: RFB_PORT, ok });
5177
- return ok;
5178
- }
5179
- async function ensureCdp(transport = "vnc") {
5180
- if (process.platform !== "linux") {
5181
- vncLog("ensure-cdp", { action: "skipped", reason: "platform-not-linux", platform: process.platform });
5182
- return false;
5318
+ const extracted = extractMessage(msg);
5319
+ if (!extracted.text && !extracted.mediaType) {
5320
+ console.error(`${TAG13} drop: no text or media account=${conn.accountId} from=${remoteJid}`);
5321
+ return;
5183
5322
  }
5184
- const targetSentinel = transport === "native" ? "native" : VNC_DISPLAY;
5185
- if (await waitForPort(CDP_PORT, 1e3)) {
5186
- if (currentCdpDisplay !== null && currentCdpDisplay !== targetSentinel) {
5187
- console.error(`[ensureCdp] Display switch: ${currentCdpDisplay} \u2192 ${targetSentinel}`);
5188
- vncLog("ensure-cdp", {
5189
- action: "display-switch",
5190
- transport,
5191
- from_display: currentCdpDisplay,
5192
- to_display: targetSentinel
5193
- });
5194
- killChromium();
5195
- await sleep(500);
5196
- } else {
5197
- return true;
5323
+ let mediaResult;
5324
+ if (extracted.mediaType && conn.sock) {
5325
+ const maxMb = whatsAppConfig.mediaMaxMb ?? 50;
5326
+ mediaResult = await downloadInboundMedia(msg, conn.sock, {
5327
+ maxBytes: maxMb * 1024 * 1024
5328
+ });
5329
+ if (!mediaResult) {
5330
+ console.error(`${TAG13} media download returned undefined account=${conn.accountId} type=${extracted.mediaType} from=${remoteJid}`);
5198
5331
  }
5199
5332
  }
5200
- const vncCommand = transport === "native" ? "start-chrome-native" : "start-chrome";
5201
- if (transport === "vnc") {
5202
- const xAlive = await waitForPort(RFB_PORT, 1e3);
5203
- if (!xAlive) {
5204
- console.error(`[ensureCdp] X server down on rfb=${RFB_PORT} \u2014 escalating to full VNC restart`);
5205
- vncLog("ensure-cdp", { action: "escalate-vnc-restart", reason: "x-down", transport, rfb_port: RFB_PORT });
5206
- const vncOk = await ensureVnc();
5207
- if (!vncOk) {
5208
- console.error("[ensureCdp] Full VNC restart failed \u2014 browser-specialist degraded");
5209
- vncLog("ensure-cdp", { action: "degraded", reason: "vnc-restart-failed", transport });
5210
- return false;
5333
+ const isGroup = isGroupJid(remoteJid);
5334
+ const senderJid = isGroup ? msg.key.participant ?? remoteJid : remoteJid;
5335
+ const senderPhone = await resolveJidToE164(senderJid, conn.lidMapping) ?? senderJid;
5336
+ const lidCapture = lidCaptureFor(senderJid, senderPhone);
5337
+ if (lidCapture) recordLidMapping(lidCapture.numericLid, lidCapture.e164);
5338
+ const selfPhone = conn.selfPhone ?? "";
5339
+ if (extracted.mediaType === "audio" && mediaResult?.path) {
5340
+ const conversationKey = isGroup ? remoteJid : senderPhone;
5341
+ const debounceKey = `${conn.accountId}:${conversationKey}:${senderPhone}`;
5342
+ let resolvePending;
5343
+ const sttPending = new Promise((resolve34) => {
5344
+ resolvePending = resolve34;
5345
+ });
5346
+ if (conn.debouncer) conn.debouncer.registerPending(debounceKey, sttPending);
5347
+ try {
5348
+ const sttResult = await transcribe(mediaResult.path, mediaResult.mimetype);
5349
+ if (sttResult?.text) {
5350
+ extracted.text = sttResult.text;
5211
5351
  }
5212
- const cdpOk2 = await waitForPort(CDP_PORT, 12e3);
5213
- if (cdpOk2) currentCdpDisplay = targetSentinel;
5214
- vncLog("ensure-cdp", { action: "post-vnc-restart", transport, cdp_port: CDP_PORT, ok: cdpOk2 });
5215
- return cdpOk2;
5352
+ } finally {
5353
+ resolvePending();
5216
5354
  }
5217
5355
  }
5218
- if (transport === "native") {
5219
- const nativeInfo = discoverNativeDisplay();
5220
- vncLog("ensure-cdp", {
5221
- action: "native-display-resolved",
5222
- transport,
5223
- session_type: nativeInfo.sessionType,
5224
- display: nativeInfo.display,
5225
- wayland_display: nativeInfo.waylandDisplay || void 0
5356
+ let accessResult;
5357
+ if (isGroup) {
5358
+ accessResult = checkGroupAccess({ groupJid: remoteJid });
5359
+ } else {
5360
+ accessResult = await resolveDmAccess({
5361
+ senderPhone,
5362
+ selfPhone,
5363
+ config: whatsAppConfig,
5364
+ accountConfig: whatsAppConfig.accounts?.[conn.accountId],
5365
+ accountId: conn.accountId,
5366
+ isValidAccount: (id) => isValidAccountId(id),
5367
+ resolvePersonByPhone
5226
5368
  });
5227
5369
  }
5228
- console.error(`[ensureCdp] Restarting Chrome (${vncCommand}) for transport=${transport}`);
5229
- vncLog("ensure-cdp", { action: `restart-${vncCommand}`, reason: "cdp-dead", transport });
5230
- try {
5231
- execFileSync("bash", [VNC_SCRIPT, vncCommand], { timeout: 2e4 });
5232
- } catch (err) {
5233
- vncLog("ensure-cdp", { action: "restart-chrome-failed", transport, err: err.message });
5234
- return false;
5370
+ console.error(
5371
+ `${TAG13} inbound account=${conn.accountId} from=${senderPhone} group=${isGroup} access=${accessResult.allowed ? "allowed" : "blocked"}(${accessResult.reason}) agent=${accessResult.agentType}` + (extracted.mediaType ? ` media=${extracted.mediaType}` : "") + (mediaResult ? ` mediaPath=${mediaResult.path}` : "") + (extracted.quotedMessage ? ` replyTo=${extracted.quotedMessage.id}` : "")
5372
+ );
5373
+ if (!accessResult.allowed && accessResult.reason === "account-manager-unresolved") {
5374
+ console.error(
5375
+ `[whatsapp-native] op=account-manager-reject senderId=${senderPhone} reason=unresolved-effective-account`
5376
+ );
5235
5377
  }
5236
- const cdpOk = await waitForPort(CDP_PORT, 12e3);
5237
- if (cdpOk) currentCdpDisplay = targetSentinel;
5238
- vncLog("ensure-cdp", { action: "restart-chrome-complete", transport, sentinel: targetSentinel, cdp_port: CDP_PORT, ok: cdpOk });
5239
- return cdpOk;
5240
- }
5241
- function killChromium() {
5242
- spawnSync("pkill", ["-f", "chromium"], { stdio: "pipe" });
5243
- }
5244
- function writeChromiumWrapper() {
5245
- mkdirSync2(BIN_DIR, { recursive: true });
5246
- const wrapperPath = resolve5(BIN_DIR, "chromium");
5247
- writeFileSync3(wrapperPath, `#!/bin/bash
5248
- LOG="${LOG_DIR}/chromium.log"
5249
- echo "==== [$(date)] chromium wrapper ====" >> "$LOG"
5250
- echo " DISPLAY=$DISPLAY WAYLAND=$WAYLAND_DISPLAY XDG_SESSION_TYPE=$XDG_SESSION_TYPE" >> "$LOG"
5251
- echo " args: $*" >> "$LOG"
5252
-
5253
- # Check the brand's virtual display is up (Tasks 553 + 924 \u2014 per-brand
5254
- # display + per-brand CDP port).
5255
- if DISPLAY=${VNC_DISPLAY} xdpyinfo >/dev/null 2>&1; then
5256
- echo " xdpyinfo ${VNC_DISPLAY} OK" >> "$LOG"
5257
- else
5258
- echo " xdpyinfo ${VNC_DISPLAY} FAILED \u2014 display not ready" >> "$LOG"
5259
- fi
5260
-
5261
- # read the install-time-resolved non-snap Chromium binary path.
5262
- # The installer (ensureNonSnapChromium + writeChromiumBinaryPathFile) writes
5263
- # this file with the absolute path to either /usr/bin/chromium (Pi Bookworm:
5264
- # real .deb) or /usr/bin/google-chrome-stable (Ubuntu Noble laptop: snap
5265
- # replaced). Refusing the legacy probe loop closes the silent-fallback path
5266
- # where /snap/bin/chromium would re-introduce the AppArmor SingletonLock
5267
- # denial that triggered.
5268
- PATH_FILE="${PLATFORM_ROOT3}/config/chromium-binary.path"
5269
- if [ ! -r "$PATH_FILE" ]; then
5270
- echo " ERROR: $PATH_FILE missing \u2014 re-run installer" >> "$LOG"
5271
- exit 1
5272
- fi
5273
- BIN="$(head -n1 "$PATH_FILE" | tr -d '[:space:]')"
5274
- if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
5275
- echo " ERROR: $PATH_FILE empty or non-executable: $BIN" >> "$LOG"
5276
- exit 1
5277
- fi
5278
- RESOLVED="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
5279
- case ":$(echo "$RESOLVED" | tr '/' ':'):" in
5280
- *:snap:*)
5281
- echo " ERROR: $BIN resolves to snap-confined $RESOLVED \u2014 re-run installer" >> "$LOG"
5282
- exit 1
5283
- ;;
5284
- esac
5285
- echo " launching $BIN (realpath=$RESOLVED)" >> "$LOG"
5286
-
5287
- # nohup detaches chromium from this wrapper's process group so it outlives
5288
- # claude auth login exiting (e.g. on timeout). --user-data-dir is brand-scoped
5289
- # so a wrapper-spawned chromium attaches to the same per-brand
5290
- # profile that vnc.sh's start_chrome targets \u2014 no shared cookies across brands.
5291
- nohup "$BIN" \\
5292
- --user-data-dir=${CHROMIUM_PROFILE_DIR} \\
5293
- --no-sandbox \\
5294
- --test-type \\
5295
- --disable-dev-shm-usage \\
5296
- --disable-gpu \\
5297
- --password-store=basic \\
5298
- --use-mock-keychain \\
5299
- --ozone-platform=x11 \\
5300
- --no-first-run \\
5301
- --no-default-browser-check \\
5302
- --disable-session-crashed-bubble \\
5303
- --hide-crash-restore-bubble \\
5304
- --noerrdialogs \\
5305
- --window-size=1280,800 \\
5306
- --window-position=0,0 \\
5307
- "$@" >> "$LOG" 2>&1 &
5308
- echo " chromium PID: $!" >> "$LOG"
5309
- `, { mode: 493 });
5310
- return wrapperPath;
5311
- }
5312
- function buildX11Env(chromiumWrapperPath, transport = "vnc") {
5313
- if (transport === "native") {
5314
- const nativeInfo = discoverNativeDisplay();
5315
- const env = {
5316
- ...process.env,
5317
- DISPLAY: nativeInfo.display,
5318
- BROWSER: chromiumWrapperPath,
5319
- PATH: `${BIN_DIR}:${process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}`
5320
- };
5321
- if (nativeInfo.sessionType === "wayland") {
5322
- env.WAYLAND_DISPLAY = nativeInfo.waylandDisplay;
5323
- } else {
5324
- env.WAYLAND_DISPLAY = "";
5325
- }
5326
- vncLog("build-x11-env", {
5327
- transport,
5328
- session_type: nativeInfo.sessionType,
5329
- display: nativeInfo.display,
5330
- wayland_display: env.WAYLAND_DISPLAY || void 0
5331
- });
5332
- return env;
5378
+ if (accessResult.allowed && accessResult.reason === "account-manager" && accessResult.effectiveAccountId === conn.accountId) {
5379
+ console.error(
5380
+ `[whatsapp-native] op=escalation-tripwire senderId=${senderPhone} reason=account-manager-routed-to-house effectiveAccount=${accessResult.effectiveAccountId}`
5381
+ );
5382
+ return;
5333
5383
  }
5334
- return {
5335
- ...process.env,
5336
- DISPLAY: VNC_DISPLAY,
5337
- WAYLAND_DISPLAY: "",
5338
- XDG_SESSION_TYPE: "x11",
5339
- BROWSER: chromiumWrapperPath,
5340
- PATH: `${BIN_DIR}:${process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}`
5341
- };
5342
- }
5343
-
5344
- // server/routes/health.ts
5345
- function checkPort(port2, timeoutMs = 500) {
5346
- return new Promise((resolve34) => {
5347
- const socket = createConnection2(port2, "127.0.0.1");
5348
- socket.setTimeout(timeoutMs);
5349
- socket.once("connect", () => {
5350
- socket.destroy();
5351
- resolve34(true);
5352
- });
5353
- socket.once("error", () => {
5354
- socket.destroy();
5355
- resolve34(false);
5356
- });
5357
- socket.once("timeout", () => {
5358
- socket.destroy();
5359
- resolve34(false);
5360
- });
5384
+ if (!accessResult.allowed) return;
5385
+ if (isCustomerTurnSuppressed(accessResult.agentType, isHouseManagedService())) {
5386
+ console.error(
5387
+ `[channel-house] op=inbound from=${senderPhone} accountId=${conn.platformAccountId} autoReply=suppressed`
5388
+ );
5389
+ return;
5390
+ }
5391
+ const sendReceipts = whatsAppConfig.accounts?.[conn.accountId]?.sendReadReceipts ?? whatsAppConfig.sendReadReceipts ?? true;
5392
+ if (sendReceipts && msg.key.id) {
5393
+ sendReadReceipt(conn.sock, remoteJid, [msg.key.id], isGroup ? senderJid : void 0);
5394
+ }
5395
+ const reply = async (text) => {
5396
+ if (isGroupJid(remoteJid)) {
5397
+ console.error(`${TAG13} op=group-reply-blocked jid=${remoteJid}`);
5398
+ throw new WaReplyError("group reply blocked \u2014 observe-only", false);
5399
+ }
5400
+ const currentSock = conn.sock;
5401
+ if (!currentSock) {
5402
+ const reason = conn.terminalReason;
5403
+ throw new WaReplyError(reason ?? "WhatsApp disconnected \u2014 cannot reply", Boolean(reason));
5404
+ }
5405
+ await sendTextMessage(currentSock, remoteJid, text, { accountId: conn.accountId });
5406
+ };
5407
+ const composing = async () => {
5408
+ const currentSock = conn.sock;
5409
+ if (currentSock) await sendComposing(currentSock, remoteJid);
5410
+ };
5411
+ const cacheKey = deriveCacheKey({
5412
+ agentType: accessResult.agentType,
5413
+ accountId: conn.accountId,
5414
+ senderPhone,
5415
+ isGroup,
5416
+ groupJid: isGroup ? remoteJid : void 0
5361
5417
  });
5362
- }
5363
- var app = new Hono();
5364
- app.get("/", async (c) => {
5365
- const browserTransport = resolveBrowserTransport(c.req.raw, c.env?.incoming?.socket?.remoteAddress);
5366
- let pinConfigured = false;
5367
- try {
5368
- if (existsSync4(USERS_FILE)) {
5369
- const raw = readFileSync9(USERS_FILE, "utf-8").trim();
5370
- if (raw) {
5371
- const users = JSON.parse(raw);
5372
- pinConfigured = Array.isArray(users) && users.length > 0;
5418
+ conn.lastMessageAt = Date.now();
5419
+ const payload = {
5420
+ accountId: conn.accountId,
5421
+ agentType: accessResult.agentType,
5422
+ passive: accessResult.passive === true,
5423
+ personId: accessResult.personId ?? null,
5424
+ senderPhone,
5425
+ text: extracted.text,
5426
+ isGroup,
5427
+ groupJid: isGroup ? remoteJid : void 0,
5428
+ reply,
5429
+ composing,
5430
+ cacheKey,
5431
+ media: mediaResult?.path ? [{ path: mediaResult.path, type: extracted.mediaType, mimetype: mediaResult.mimetype }] : [],
5432
+ replyContext: extracted.quotedMessage,
5433
+ // Task 1383 — the gate's single resolution feeds the spawn (house or the
5434
+ // bound sub-account); the router never re-resolves.
5435
+ effectiveAccountId: accessResult.effectiveAccountId
5436
+ };
5437
+ if (accessResult.agentType === "public") {
5438
+ const hoursResult = await isBusinessOpen(conn.accountId);
5439
+ if (!hoursResult.open) {
5440
+ console.error(`${TAG13} [${conn.accountId}] dispatch skipped: business closed`);
5441
+ const afterHoursMessage = whatsAppConfig.accounts?.[conn.accountId]?.afterHoursMessage ?? whatsAppConfig.afterHoursMessage;
5442
+ if (afterHoursMessage) {
5443
+ try {
5444
+ await reply(afterHoursMessage);
5445
+ console.error(`${TAG13} [${conn.accountId}] after-hours auto-reply sent to ${remoteJid}`);
5446
+ } catch (err) {
5447
+ console.error(
5448
+ `${TAG13} [${conn.accountId}] after-hours auto-reply failed: ${err instanceof Error ? err.message : String(err)}`
5449
+ );
5450
+ }
5373
5451
  }
5452
+ return;
5374
5453
  }
5375
- } catch (err) {
5376
- console.error(`[health] users.json corrupt \u2014 reporting pin_configured=false: ${err instanceof Error ? err.message : String(err)}`);
5377
- }
5378
- let authHealth;
5379
- try {
5380
- authHealth = await ensureAuth();
5381
- } catch {
5382
- authHealth = { status: "missing" };
5383
5454
  }
5384
- const claudeAuthenticated = authHealth.status === "ok" || authHealth.status === "expiring";
5385
- const vncRunning = await checkPort(WEBSOCKIFY_PORT);
5386
- let whatsappAccounts = [];
5387
- try {
5388
- whatsappAccounts = getStatus().map((a) => ({
5389
- accountId: a.accountId,
5390
- accountName: a.accountName ?? null,
5391
- connected: a.connected,
5392
- selfPhone: a.selfPhone ?? null,
5393
- reconnectAttempts: a.reconnectAttempts,
5394
- lastError: a.lastError ?? null,
5395
- sessionStuckReason: a.sessionStuckReason ?? null,
5396
- terminalReason: a.terminalReason ?? null
5397
- }));
5398
- } catch (err) {
5399
- console.error(`[health] failed to read WhatsApp status: ${err instanceof Error ? err.message : String(err)}`);
5455
+ if (conn.debouncer) {
5456
+ conn.debouncer.enqueue(payload);
5457
+ } else {
5458
+ onInboundMessage(payload);
5400
5459
  }
5401
- const whatsappAnyConnected = whatsappAccounts.some((a) => a.connected);
5402
- const whatsappAnyStuck = whatsappAccounts.some((a) => Boolean(a.sessionStuckReason));
5403
- const whatsappAnyTerminal = whatsappAccounts.some((a) => Boolean(a.terminalReason));
5404
- const account = resolveAccount();
5405
- const missingPlugins = findMissingPlugins(account?.config.enabledPlugins);
5406
- return c.json({
5407
- pin_configured: pinConfigured,
5408
- claude_authenticated: claudeAuthenticated,
5409
- vnc_running: vncRunning,
5410
- browser_transport: browserTransport,
5411
- auth_status: authHealth.status,
5412
- auth_expires_at: authHealth.expiresAt ?? null,
5413
- server_ip: getLanIp(),
5414
- missingPlugins,
5415
- whatsapp: {
5416
- any_connected: whatsappAnyConnected,
5417
- any_stuck: whatsappAnyStuck,
5418
- any_terminal: whatsappAnyTerminal,
5419
- accounts: whatsappAccounts
5420
- }
5421
- });
5422
- });
5423
- var health_default = app;
5424
-
5425
- // app/lib/attachments.ts
5426
- import { randomUUID as randomUUID4 } from "crypto";
5427
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
5428
- import { realpathSync as realpathSync2 } from "fs";
5429
- import { resolve as resolve7, extname } from "path";
5430
-
5431
- // app/lib/data-path.ts
5432
- import { realpathSync } from "fs";
5433
- import { resolve as resolve6, normalize, sep as sep2, relative, basename, join as join6 } from "path";
5434
- var PLATFORM_ROOT4 = process.env.MAXY_PLATFORM_ROOT ?? resolve6(process.cwd(), "../platform");
5435
- var DATA_ROOT = resolve6(PLATFORM_ROOT4, "..", "data");
5436
- var CLAUDE_UPLOADS_ROOT = process.env.CLAUDE_CONFIG_DIR ? resolve6(process.env.CLAUDE_CONFIG_DIR, "uploads") : null;
5437
- var ACCOUNT_PARTITION_DIRS = /* @__PURE__ */ new Set(["accounts"]);
5438
- var ACCOUNT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5439
- function crossesForeignAccountPartition(relPath, accountId) {
5440
- const segs = relPath.split("/").filter(Boolean);
5441
- if (segs.length < 2) return false;
5442
- if (!ACCOUNT_PARTITION_DIRS.has(segs[0])) return false;
5443
- const owner = segs[1];
5444
- if (!ACCOUNT_UUID_RE.test(owner)) return false;
5445
- return owner !== accountId;
5446
5460
  }
5447
- function isWithinOwnAccountPartition(relPath, accountId) {
5448
- const segs = relPath.split("/").filter(Boolean);
5449
- return segs.length >= 2 && segs[0] === "accounts" && segs[1] === accountId;
5461
+
5462
+ // app/lib/vnc.ts
5463
+ import { spawnSync, execFileSync } from "child_process";
5464
+ import { createConnection } from "net";
5465
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
5466
+ import { resolve as resolve7 } from "path";
5467
+ var PLATFORM_ROOT4 = process.env.MAXY_PLATFORM_ROOT ?? resolve7(process.cwd(), "..");
5468
+ var VNC_SCRIPT = resolve7(PLATFORM_ROOT4, "scripts/vnc.sh");
5469
+ var displayMode = process.env.DISPLAY_MODE ?? "virtual";
5470
+ if (displayMode === "native") {
5471
+ console.log(`[vnc] DISPLAY_MODE=native \u2014 local requests use desktop display, remote requests use VNC`);
5450
5472
  }
5451
- function resolveUnderRoot(raw, rootAbs, rootLabel) {
5452
- const cleaned = normalize("/" + (raw ?? "").replace(/\\/g, "/")).replace(/^\/+/, "");
5453
- const absolute = resolve6(rootAbs, cleaned);
5454
- let rootReal;
5455
- try {
5456
- rootReal = realpathSync(rootAbs);
5457
- } catch (err) {
5458
- return {
5459
- ok: false,
5460
- status: 500,
5461
- error: `${rootLabel} not accessible: ${err instanceof Error ? err.message : String(err)}`
5462
- };
5473
+ function resolveBrowserTransport(req, remoteAddress) {
5474
+ if (displayMode !== "native") return "vnc";
5475
+ const xff = req.headers.get("x-forwarded-for") ?? void 0;
5476
+ const client = resolveClientIp(remoteAddress ?? "127.0.0.1", xff);
5477
+ const transport = client.kind !== "external" && !xff ? "native" : "vnc";
5478
+ if (remoteAddress && remoteAddress !== "127.0.0.1" && remoteAddress !== "::1") {
5479
+ const oldClient = resolveClientIp("127.0.0.1", xff);
5480
+ const oldTransport = oldClient.kind !== "external" && !xff ? "native" : "vnc";
5481
+ if (transport !== oldTransport) {
5482
+ console.log(`[resolve-transport] remoteAddress="${remoteAddress}" transport="${transport}" (was: "${oldTransport}" with hardcoded loopback)`);
5483
+ }
5463
5484
  }
5464
- let resolvedReal;
5485
+ return transport;
5486
+ }
5487
+ var currentCdpDisplay = null;
5488
+ function discoverNativeDisplay() {
5489
+ const fallback = { sessionType: "x11", display: ":0", waylandDisplay: "" };
5465
5490
  try {
5466
- resolvedReal = realpathSync(absolute);
5467
- } catch (err) {
5468
- const code = err.code;
5469
- if (code === "ENOENT") {
5470
- if (absolute !== rootReal && !absolute.startsWith(rootReal + sep2)) {
5471
- return { ok: false, status: 403, error: `Path escapes ${rootLabel}`, resolved: absolute };
5491
+ const listResult = spawnSync("loginctl", ["list-sessions", "--no-legend"], {
5492
+ timeout: 5e3,
5493
+ encoding: "utf8",
5494
+ stdio: ["ignore", "pipe", "pipe"]
5495
+ });
5496
+ if (listResult.status !== 0 || !listResult.stdout.trim()) {
5497
+ vncLog("native-display-discovery", { action: "fallback", reason: "no-sessions" });
5498
+ return fallback;
5499
+ }
5500
+ const sessionId = listResult.stdout.trim().split("\n")[0].trim().split(/\s+/)[0];
5501
+ const typeResult = spawnSync("loginctl", ["show-session", sessionId, "-p", "Type", "--value"], {
5502
+ timeout: 5e3,
5503
+ encoding: "utf8",
5504
+ stdio: ["ignore", "pipe", "pipe"]
5505
+ });
5506
+ const displayResult = spawnSync("loginctl", ["show-session", sessionId, "-p", "Display", "--value"], {
5507
+ timeout: 5e3,
5508
+ encoding: "utf8",
5509
+ stdio: ["ignore", "pipe", "pipe"]
5510
+ });
5511
+ const sessionType = typeResult.stdout?.trim() || "";
5512
+ const sessionDisplay = displayResult.stdout?.trim() || ":0";
5513
+ if (sessionType === "wayland") {
5514
+ let waylandDisplay = "wayland-0";
5515
+ const leaderResult = spawnSync("loginctl", ["show-session", sessionId, "-p", "Leader", "--value"], {
5516
+ timeout: 5e3,
5517
+ encoding: "utf8",
5518
+ stdio: ["ignore", "pipe", "pipe"]
5519
+ });
5520
+ const leaderPid = leaderResult.stdout?.trim();
5521
+ if (leaderPid) {
5522
+ try {
5523
+ const environ = readFileSync8(`/proc/${leaderPid}/environ`, "utf8");
5524
+ const match = environ.split("\0").find((e) => e.startsWith("WAYLAND_DISPLAY="));
5525
+ if (match) waylandDisplay = match.split("=")[1];
5526
+ } catch {
5527
+ }
5472
5528
  }
5473
- return { ok: false, status: 404, error: "Not found" };
5529
+ const info2 = { sessionType: "wayland", display: sessionDisplay, waylandDisplay };
5530
+ vncLog("native-display-discovery", {
5531
+ action: "discovered",
5532
+ session_type: "wayland",
5533
+ display: sessionDisplay,
5534
+ wayland_display: waylandDisplay
5535
+ });
5536
+ return info2;
5474
5537
  }
5475
- return {
5476
- ok: false,
5477
- status: 500,
5478
- error: err instanceof Error ? err.message : String(err)
5479
- };
5480
- }
5481
- if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + sep2)) {
5482
- return { ok: false, status: 403, error: `Path escapes ${rootLabel}`, resolved: resolvedReal };
5538
+ const info = { sessionType: "x11", display: sessionDisplay, waylandDisplay: "" };
5539
+ vncLog("native-display-discovery", {
5540
+ action: "discovered",
5541
+ session_type: sessionType || "unknown (defaulting to x11)",
5542
+ display: sessionDisplay
5543
+ });
5544
+ return info;
5545
+ } catch (err) {
5546
+ vncLog("native-display-discovery", { action: "fallback", reason: "error", err: err.message });
5547
+ return fallback;
5483
5548
  }
5484
- const relPath = relative(rootReal, resolvedReal) || ".";
5485
- return { ok: true, absolute: resolvedReal, dataRootReal: rootReal, relative: relPath };
5486
5549
  }
5487
- function resolveDataPath(raw) {
5488
- return resolveUnderRoot(raw, DATA_ROOT, "DATA_ROOT");
5550
+ function sleep(ms) {
5551
+ return new Promise((r) => setTimeout(r, ms));
5489
5552
  }
5490
- function resolveClaudeUploadsPath(raw) {
5491
- if (!CLAUDE_UPLOADS_ROOT) {
5492
- return { ok: false, status: 404, error: "Not found" };
5553
+ async function waitForPort(port2, timeoutMs = 12e3) {
5554
+ const deadline = Date.now() + timeoutMs;
5555
+ while (Date.now() < deadline) {
5556
+ const ready = await new Promise((res) => {
5557
+ const socket = createConnection(port2, "127.0.0.1");
5558
+ socket.setTimeout(500);
5559
+ socket.once("connect", () => {
5560
+ socket.destroy();
5561
+ res(true);
5562
+ });
5563
+ socket.once("error", () => {
5564
+ socket.destroy();
5565
+ res(false);
5566
+ });
5567
+ socket.once("timeout", () => {
5568
+ socket.destroy();
5569
+ res(false);
5570
+ });
5571
+ });
5572
+ if (ready) return true;
5573
+ await sleep(300);
5493
5574
  }
5494
- return resolveUnderRoot(raw, CLAUDE_UPLOADS_ROOT, "CLAUDE_UPLOADS_ROOT");
5495
- }
5496
- function scratchpadTmpBase() {
5497
- return process.env.MAXY_SCRATCHPAD_BASE ?? "/tmp";
5498
- }
5499
- function sessionScratchpadRoot(projectDir, sessionId) {
5500
- const uid = typeof process.geteuid === "function" ? process.geteuid() : 0;
5501
- return join6(scratchpadTmpBase(), `claude-${uid}`, basename(projectDir), sessionId, "scratchpad");
5502
- }
5503
- function resolveScratchpadPath(raw, scratchpadRootAbs) {
5504
- return resolveUnderRoot(raw, scratchpadRootAbs, "scratchpad");
5505
- }
5506
-
5507
- // app/lib/doc-to-text.ts
5508
- import { unzipSync, strFromU8 } from "fflate";
5509
- var DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
5510
- var ODT = "application/vnd.oasis.opendocument.text";
5511
- var CONVERTIBLE_DOC_MIME = /* @__PURE__ */ new Set([
5512
- DOCX,
5513
- ODT,
5514
- "text/rtf",
5515
- "application/rtf"
5516
- ]);
5517
- function bareMime(mimeType) {
5518
- return mimeType.split(";")[0].trim().toLowerCase();
5575
+ return false;
5519
5576
  }
5520
- function unescapeXml(s) {
5521
- return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5577
+ function ensureLogDir() {
5578
+ mkdirSync2(LOG_DIR, { recursive: true });
5522
5579
  }
5523
- function unzipEntry(buffer, entry) {
5524
- const files = unzipSync(new Uint8Array(buffer));
5525
- const bytes = files[entry];
5526
- if (!bytes) throw new Error(`zip entry not found: ${entry}`);
5527
- return strFromU8(bytes);
5580
+ function logPath(name) {
5581
+ return resolve7(LOG_DIR, `${name}.log`);
5528
5582
  }
5529
- function docxToText(buffer) {
5530
- const xml = unzipEntry(buffer, "word/document.xml");
5531
- const paras = [];
5532
- for (const p of xml.split("</w:p>")) {
5533
- const withBreaks = p.replace(/<w:tab\b[^>]*\/?>/g, " ").replace(/<w:br\b[^>]*\/?>/g, "\n");
5534
- let line = "";
5535
- for (const m of withBreaks.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)) {
5536
- line += unescapeXml(m[1]);
5537
- }
5538
- if (line.length > 0) paras.push(line);
5583
+ async function ensureVnc() {
5584
+ if (process.platform !== "linux") {
5585
+ vncLog("ensure-vnc", { action: "skipped", reason: "platform-not-linux", platform: process.platform });
5586
+ return false;
5539
5587
  }
5540
- return paras.join("\n");
5541
- }
5542
- function odtToText(buffer) {
5543
- const xml = unzipEntry(buffer, "content.xml");
5544
- const body = xml.replace(/^[\s\S]*?<office:text\b[^>]*>/, "").replace(/<\/office:text>[\s\S]*$/, "");
5545
- const withBreaks = body.replace(/<text:line-break\b[^>]*\/?>/g, "\n").replace(/<text:tab\b[^>]*\/?>/g, " ").replace(/<\/text:(?:p|h)>/g, "\n");
5546
- const stripped = withBreaks.replace(/<[^>]+>/g, "");
5547
- return unescapeXml(stripped).replace(/\n{3,}/g, "\n\n").trim();
5588
+ const up = await waitForPort(RFB_PORT, 1e3);
5589
+ if (up) return true;
5590
+ vncLog("ensure-vnc", { action: "restart", reason: "x-down", rfb_port: RFB_PORT });
5591
+ try {
5592
+ execFileSync("bash", [VNC_SCRIPT, "start"], { timeout: 2e4 });
5593
+ } catch (err) {
5594
+ vncLog("ensure-vnc", { action: "restart-failed", err: err.message });
5595
+ return false;
5596
+ }
5597
+ const ok = await waitForPort(RFB_PORT, 12e3);
5598
+ vncLog("ensure-vnc", { action: "restart-complete", rfb_port: RFB_PORT, ok });
5599
+ return ok;
5548
5600
  }
5549
- var RTF_DROP_DEST = /^\\(?:\*|fonttbl|colortbl|stylesheet|listtable|info|pict)\b/;
5550
- function stripRtfDestinations(s) {
5551
- let out = "";
5552
- for (let i = 0; i < s.length; i++) {
5553
- const ch = s[i];
5554
- if (ch === "\\" && i + 1 < s.length) {
5555
- out += ch + s[i + 1];
5556
- i++;
5557
- continue;
5601
+ async function ensureCdp(transport = "vnc") {
5602
+ if (process.platform !== "linux") {
5603
+ vncLog("ensure-cdp", { action: "skipped", reason: "platform-not-linux", platform: process.platform });
5604
+ return false;
5605
+ }
5606
+ const targetSentinel = transport === "native" ? "native" : VNC_DISPLAY;
5607
+ if (await waitForPort(CDP_PORT, 1e3)) {
5608
+ if (currentCdpDisplay !== null && currentCdpDisplay !== targetSentinel) {
5609
+ console.error(`[ensureCdp] Display switch: ${currentCdpDisplay} \u2192 ${targetSentinel}`);
5610
+ vncLog("ensure-cdp", {
5611
+ action: "display-switch",
5612
+ transport,
5613
+ from_display: currentCdpDisplay,
5614
+ to_display: targetSentinel
5615
+ });
5616
+ killChromium();
5617
+ await sleep(500);
5618
+ } else {
5619
+ return true;
5558
5620
  }
5559
- if (ch === "{" && RTF_DROP_DEST.test(s.slice(i + 1))) {
5560
- let depth = 1;
5561
- let j = i + 1;
5562
- for (; j < s.length && depth > 0; j++) {
5563
- if (s[j] === "\\") {
5564
- j++;
5565
- continue;
5566
- }
5567
- if (s[j] === "{") depth++;
5568
- else if (s[j] === "}") depth--;
5621
+ }
5622
+ const vncCommand = transport === "native" ? "start-chrome-native" : "start-chrome";
5623
+ if (transport === "vnc") {
5624
+ const xAlive = await waitForPort(RFB_PORT, 1e3);
5625
+ if (!xAlive) {
5626
+ console.error(`[ensureCdp] X server down on rfb=${RFB_PORT} \u2014 escalating to full VNC restart`);
5627
+ vncLog("ensure-cdp", { action: "escalate-vnc-restart", reason: "x-down", transport, rfb_port: RFB_PORT });
5628
+ const vncOk = await ensureVnc();
5629
+ if (!vncOk) {
5630
+ console.error("[ensureCdp] Full VNC restart failed \u2014 browser-specialist degraded");
5631
+ vncLog("ensure-cdp", { action: "degraded", reason: "vnc-restart-failed", transport });
5632
+ return false;
5569
5633
  }
5570
- i = j - 1;
5571
- continue;
5634
+ const cdpOk2 = await waitForPort(CDP_PORT, 12e3);
5635
+ if (cdpOk2) currentCdpDisplay = targetSentinel;
5636
+ vncLog("ensure-cdp", { action: "post-vnc-restart", transport, cdp_port: CDP_PORT, ok: cdpOk2 });
5637
+ return cdpOk2;
5572
5638
  }
5573
- out += ch;
5574
5639
  }
5575
- return out;
5576
- }
5577
- function rtfToText(rtf) {
5578
- let s = stripRtfDestinations(rtf);
5579
- s = s.replace(/\\par[d]?\b/g, "\n").replace(/\\line\b/g, "\n").replace(/\\tab\b/g, " ");
5580
- s = s.replace(/\\'([0-9a-fA-F]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)));
5581
- s = s.replace(/\\[a-zA-Z]+-?\d* ?/g, "");
5582
- s = s.replace(/\\([{}\\])/g, "$1").replace(/[{}]/g, "");
5583
- return s.replace(/[ \t]{2,}/g, " ").replace(/\n{3,}/g, "\n\n").trim();
5640
+ if (transport === "native") {
5641
+ const nativeInfo = discoverNativeDisplay();
5642
+ vncLog("ensure-cdp", {
5643
+ action: "native-display-resolved",
5644
+ transport,
5645
+ session_type: nativeInfo.sessionType,
5646
+ display: nativeInfo.display,
5647
+ wayland_display: nativeInfo.waylandDisplay || void 0
5648
+ });
5649
+ }
5650
+ console.error(`[ensureCdp] Restarting Chrome (${vncCommand}) for transport=${transport}`);
5651
+ vncLog("ensure-cdp", { action: `restart-${vncCommand}`, reason: "cdp-dead", transport });
5652
+ try {
5653
+ execFileSync("bash", [VNC_SCRIPT, vncCommand], { timeout: 2e4 });
5654
+ } catch (err) {
5655
+ vncLog("ensure-cdp", { action: "restart-chrome-failed", transport, err: err.message });
5656
+ return false;
5657
+ }
5658
+ const cdpOk = await waitForPort(CDP_PORT, 12e3);
5659
+ if (cdpOk) currentCdpDisplay = targetSentinel;
5660
+ vncLog("ensure-cdp", { action: "restart-chrome-complete", transport, sentinel: targetSentinel, cdp_port: CDP_PORT, ok: cdpOk });
5661
+ return cdpOk;
5584
5662
  }
5585
- function docToText(buffer, mimeType) {
5586
- const mime = bareMime(mimeType);
5587
- if (mime === DOCX) return docxToText(buffer);
5588
- if (mime === ODT) return odtToText(buffer);
5589
- if (mime === "text/rtf" || mime === "application/rtf") return rtfToText(buffer.toString("latin1"));
5590
- return null;
5663
+ function killChromium() {
5664
+ spawnSync("pkill", ["-f", "chromium"], { stdio: "pipe" });
5591
5665
  }
5666
+ function writeChromiumWrapper() {
5667
+ mkdirSync2(BIN_DIR, { recursive: true });
5668
+ const wrapperPath = resolve7(BIN_DIR, "chromium");
5669
+ writeFileSync3(wrapperPath, `#!/bin/bash
5670
+ LOG="${LOG_DIR}/chromium.log"
5671
+ echo "==== [$(date)] chromium wrapper ====" >> "$LOG"
5672
+ echo " DISPLAY=$DISPLAY WAYLAND=$WAYLAND_DISPLAY XDG_SESSION_TYPE=$XDG_SESSION_TYPE" >> "$LOG"
5673
+ echo " args: $*" >> "$LOG"
5592
5674
 
5593
- // app/lib/attachments.ts
5594
- function uploadsDirFor(accountId, subPath, attachmentId) {
5595
- return subPath ? resolve7(DATA_ROOT, "accounts", accountId, "uploads", subPath, attachmentId) : resolve7(DATA_ROOT, "accounts", accountId, "uploads", attachmentId);
5596
- }
5597
- var SUPPORTED_MIME_TYPES = /* @__PURE__ */ new Set([
5598
- "image/jpeg",
5599
- "image/png",
5600
- "image/gif",
5601
- "image/webp",
5602
- "application/pdf",
5603
- "text/plain",
5604
- "text/markdown",
5605
- "text/csv",
5606
- "text/html",
5607
- "text/calendar",
5608
- "application/zip",
5609
- "application/x-zip-compressed",
5610
- // Task 1347 Word/OpenDocument/RTF text documents. The bytes are converted to
5611
- // a readable-text sibling at ingestion (writeAttachment → doc-to-text.ts)
5612
- // because the on-device Read tool cannot parse the binary containers. rtf is
5613
- // reported by browsers as either text/rtf or application/rtf.
5614
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5615
- "application/vnd.oasis.opendocument.text",
5616
- "text/rtf",
5617
- "application/rtf",
5618
- // Task 1240 voice recordings persist as playable clips in the admin bubble.
5619
- // These match AUDIO_MIME_TYPES in app/lib/stt/audio-mime.ts exactly.
5620
- "audio/ogg",
5621
- "audio/opus",
5622
- "audio/webm",
5623
- "audio/mp4",
5624
- "audio/x-m4a",
5625
- "audio/mpeg",
5626
- "audio/wav",
5627
- "video/mp4"
5628
- ]);
5629
- var MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
5630
- var MAX_FILES_PER_MESSAGE = 5;
5631
- var MAX_DATA_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
5632
- var MAX_ZIP_UNCOMPRESSED_BYTES = 100 * 1024 * 1024;
5633
- var EXTRACTED_TEXT_SUFFIX = ".extracted.txt";
5634
- function assertSupportedMime(mimeType) {
5635
- const bare = mimeType.split(";")[0].trim().toLowerCase();
5636
- if (!SUPPORTED_MIME_TYPES.has(bare)) {
5637
- throw new Error(
5638
- `Unsupported file type: "${mimeType}". Supported types: ${[...SUPPORTED_MIME_TYPES].join(", ")}.`
5639
- );
5640
- }
5675
+ # Check the brand's virtual display is up (Tasks 553 + 924 \u2014 per-brand
5676
+ # display + per-brand CDP port).
5677
+ if DISPLAY=${VNC_DISPLAY} xdpyinfo >/dev/null 2>&1; then
5678
+ echo " xdpyinfo ${VNC_DISPLAY} OK" >> "$LOG"
5679
+ else
5680
+ echo " xdpyinfo ${VNC_DISPLAY} FAILED \u2014 display not ready" >> "$LOG"
5681
+ fi
5682
+
5683
+ # read the install-time-resolved non-snap Chromium binary path.
5684
+ # The installer (ensureNonSnapChromium + writeChromiumBinaryPathFile) writes
5685
+ # this file with the absolute path to either /usr/bin/chromium (Pi Bookworm:
5686
+ # real .deb) or /usr/bin/google-chrome-stable (Ubuntu Noble laptop: snap
5687
+ # replaced). Refusing the legacy probe loop closes the silent-fallback path
5688
+ # where /snap/bin/chromium would re-introduce the AppArmor SingletonLock
5689
+ # denial that triggered.
5690
+ PATH_FILE="${PLATFORM_ROOT4}/config/chromium-binary.path"
5691
+ if [ ! -r "$PATH_FILE" ]; then
5692
+ echo " ERROR: $PATH_FILE missing \u2014 re-run installer" >> "$LOG"
5693
+ exit 1
5694
+ fi
5695
+ BIN="$(head -n1 "$PATH_FILE" | tr -d '[:space:]')"
5696
+ if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
5697
+ echo " ERROR: $PATH_FILE empty or non-executable: $BIN" >> "$LOG"
5698
+ exit 1
5699
+ fi
5700
+ RESOLVED="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
5701
+ case ":$(echo "$RESOLVED" | tr '/' ':'):" in
5702
+ *:snap:*)
5703
+ echo " ERROR: $BIN resolves to snap-confined $RESOLVED \u2014 re-run installer" >> "$LOG"
5704
+ exit 1
5705
+ ;;
5706
+ esac
5707
+ echo " launching $BIN (realpath=$RESOLVED)" >> "$LOG"
5708
+
5709
+ # nohup detaches chromium from this wrapper's process group so it outlives
5710
+ # claude auth login exiting (e.g. on timeout). --user-data-dir is brand-scoped
5711
+ # so a wrapper-spawned chromium attaches to the same per-brand
5712
+ # profile that vnc.sh's start_chrome targets \u2014 no shared cookies across brands.
5713
+ nohup "$BIN" \\
5714
+ --user-data-dir=${CHROMIUM_PROFILE_DIR} \\
5715
+ --no-sandbox \\
5716
+ --test-type \\
5717
+ --disable-dev-shm-usage \\
5718
+ --disable-gpu \\
5719
+ --password-store=basic \\
5720
+ --use-mock-keychain \\
5721
+ --ozone-platform=x11 \\
5722
+ --no-first-run \\
5723
+ --no-default-browser-check \\
5724
+ --disable-session-crashed-bubble \\
5725
+ --hide-crash-restore-bubble \\
5726
+ --noerrdialogs \\
5727
+ --window-size=1280,800 \\
5728
+ --window-position=0,0 \\
5729
+ "$@" >> "$LOG" 2>&1 &
5730
+ echo " chromium PID: $!" >> "$LOG"
5731
+ `, { mode: 493 });
5732
+ return wrapperPath;
5641
5733
  }
5642
- async function writeAttachment(accountId, subPath, filename, mimeType, sizeBytes, buffer) {
5643
- const attachmentId = randomUUID4();
5644
- const dir = uploadsDirFor(accountId, subPath, attachmentId);
5645
- await mkdir2(dir, { recursive: true });
5646
- const ext = extname(filename) || "";
5647
- const storagePath = resolve7(dir, `${attachmentId}${ext}`);
5648
- const metaPath = resolve7(dir, `${attachmentId}.meta.json`);
5649
- const meta = {
5650
- attachmentId,
5651
- scope: subPath || accountId,
5652
- filename,
5653
- mimeType,
5654
- sizeBytes,
5655
- storedAt: (/* @__PURE__ */ new Date()).toISOString()
5656
- };
5657
- await writeFile2(storagePath, buffer);
5658
- await writeFile2(metaPath, JSON.stringify(meta, null, 2));
5659
- let readableTextPath;
5660
- if (CONVERTIBLE_DOC_MIME.has(mimeType.split(";")[0].trim().toLowerCase())) {
5661
- const text = docToText(buffer, mimeType);
5662
- if (text) {
5663
- readableTextPath = resolve7(dir, `${attachmentId}${EXTRACTED_TEXT_SUFFIX}`);
5664
- await writeFile2(readableTextPath, text);
5734
+ function buildX11Env(chromiumWrapperPath, transport = "vnc") {
5735
+ if (transport === "native") {
5736
+ const nativeInfo = discoverNativeDisplay();
5737
+ const env = {
5738
+ ...process.env,
5739
+ DISPLAY: nativeInfo.display,
5740
+ BROWSER: chromiumWrapperPath,
5741
+ PATH: `${BIN_DIR}:${process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}`
5742
+ };
5743
+ if (nativeInfo.sessionType === "wayland") {
5744
+ env.WAYLAND_DISPLAY = nativeInfo.waylandDisplay;
5745
+ } else {
5746
+ env.WAYLAND_DISPLAY = "";
5665
5747
  }
5748
+ vncLog("build-x11-env", {
5749
+ transport,
5750
+ session_type: nativeInfo.sessionType,
5751
+ display: nativeInfo.display,
5752
+ wayland_display: env.WAYLAND_DISPLAY || void 0
5753
+ });
5754
+ return env;
5666
5755
  }
5667
- return { attachmentId, filename, storagePath, metaPath, mimeType, sizeBytes, readableTextPath };
5756
+ return {
5757
+ ...process.env,
5758
+ DISPLAY: VNC_DISPLAY,
5759
+ WAYLAND_DISPLAY: "",
5760
+ XDG_SESSION_TYPE: "x11",
5761
+ BROWSER: chromiumWrapperPath,
5762
+ PATH: `${BIN_DIR}:${process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}`
5763
+ };
5668
5764
  }
5669
- async function storeAttachment(accountId, file) {
5670
- assertSupportedMime(file.type);
5671
- if (file.size > MAX_FILE_SIZE_BYTES) {
5672
- throw new Error(
5673
- `File "${file.name}" exceeds the 50 MB limit (${(file.size / 1024 / 1024).toFixed(1)} MB).`
5674
- );
5675
- }
5676
- const buffer = Buffer.from(await file.arrayBuffer());
5677
- return writeAttachment(accountId, "", file.name, file.type, file.size, buffer);
5765
+
5766
+ // server/routes/health.ts
5767
+ function checkPort(port2, timeoutMs = 500) {
5768
+ return new Promise((resolve34) => {
5769
+ const socket = createConnection2(port2, "127.0.0.1");
5770
+ socket.setTimeout(timeoutMs);
5771
+ socket.once("connect", () => {
5772
+ socket.destroy();
5773
+ resolve34(true);
5774
+ });
5775
+ socket.once("error", () => {
5776
+ socket.destroy();
5777
+ resolve34(false);
5778
+ });
5779
+ socket.once("timeout", () => {
5780
+ socket.destroy();
5781
+ resolve34(false);
5782
+ });
5783
+ });
5678
5784
  }
5679
- async function storePublicAttachment(accountId, sessionId, file, buffer) {
5680
- assertSupportedMime(file.type);
5681
- if (file.size > MAX_FILE_SIZE_BYTES) {
5682
- throw new Error(
5683
- `File "${file.name}" exceeds the 50 MB limit (${(file.size / 1024 / 1024).toFixed(1)} MB).`
5684
- );
5785
+ var app = new Hono();
5786
+ app.get("/", async (c) => {
5787
+ const browserTransport = resolveBrowserTransport(c.req.raw, c.env?.incoming?.socket?.remoteAddress);
5788
+ let pinConfigured = false;
5789
+ try {
5790
+ if (existsSync5(USERS_FILE)) {
5791
+ const raw = readFileSync9(USERS_FILE, "utf-8").trim();
5792
+ if (raw) {
5793
+ const users = JSON.parse(raw);
5794
+ pinConfigured = Array.isArray(users) && users.length > 0;
5795
+ }
5796
+ }
5797
+ } catch (err) {
5798
+ console.error(`[health] users.json corrupt \u2014 reporting pin_configured=false: ${err instanceof Error ? err.message : String(err)}`);
5685
5799
  }
5686
- return writeAttachment(accountId, `public/${sessionId}`, file.name, file.type, file.size, buffer);
5687
- }
5688
- var MIME_BY_EXT = {
5689
- ".pdf": "application/pdf",
5690
- ".png": "image/png",
5691
- ".jpg": "image/jpeg",
5692
- ".jpeg": "image/jpeg",
5693
- ".gif": "image/gif",
5694
- ".webp": "image/webp",
5695
- ".svg": "image/svg+xml",
5696
- ".html": "text/html",
5697
- ".htm": "text/html",
5698
- ".css": "text/css",
5699
- ".js": "application/javascript",
5700
- ".json": "application/json",
5701
- ".csv": "text/csv",
5702
- ".txt": "text/plain",
5703
- ".md": "text/markdown",
5704
- ".xml": "text/xml",
5705
- ".zip": "application/zip",
5706
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5707
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5708
- ".odt": "application/vnd.oasis.opendocument.text",
5709
- ".rtf": "text/rtf",
5710
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
5711
- ".ics": "text/calendar"
5712
- };
5713
- function detectMimeType(filePath) {
5714
- const ext = extname(filePath).toLowerCase();
5715
- return MIME_BY_EXT[ext] ?? "application/octet-stream";
5716
- }
5717
- function validateFilePathInAccount(filePath, accountDir) {
5718
- const resolved = realpathSync2(filePath);
5719
- const accountResolved = realpathSync2(accountDir);
5720
- if (!resolved.startsWith(accountResolved + "/")) {
5721
- throw new Error(`File path is outside the account directory`);
5800
+ let authHealth;
5801
+ try {
5802
+ authHealth = await ensureAuth();
5803
+ } catch {
5804
+ authHealth = { status: "missing" };
5722
5805
  }
5723
- return resolved;
5724
- }
5725
- async function storeComponentArtefact(accountId, attachmentId, mimeType, content, filename) {
5726
- const buffer = Buffer.from(content, "utf-8");
5727
- if (buffer.byteLength > MAX_FILE_SIZE_BYTES) {
5728
- throw new Error(
5729
- `Component artefact exceeds the 50 MB limit (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB).`
5730
- );
5806
+ const claudeAuthenticated = authHealth.status === "ok" || authHealth.status === "expiring";
5807
+ const vncRunning = await checkPort(WEBSOCKIFY_PORT);
5808
+ let whatsappAccounts = [];
5809
+ try {
5810
+ whatsappAccounts = getStatus().map((a) => ({
5811
+ accountId: a.accountId,
5812
+ accountName: a.accountName ?? null,
5813
+ connected: a.connected,
5814
+ selfPhone: a.selfPhone ?? null,
5815
+ reconnectAttempts: a.reconnectAttempts,
5816
+ lastError: a.lastError ?? null,
5817
+ sessionStuckReason: a.sessionStuckReason ?? null,
5818
+ terminalReason: a.terminalReason ?? null
5819
+ }));
5820
+ } catch (err) {
5821
+ console.error(`[health] failed to read WhatsApp status: ${err instanceof Error ? err.message : String(err)}`);
5731
5822
  }
5732
- const dir = uploadsDirFor(accountId, "", attachmentId);
5733
- await mkdir2(dir, { recursive: true });
5734
- const ext = extname(filename) || (mimeType === "text/html" ? ".html" : ".md");
5735
- const storagePath = resolve7(dir, `${attachmentId}${ext}`);
5736
- const metaPath = resolve7(dir, `${attachmentId}.meta.json`);
5737
- const meta = {
5738
- attachmentId,
5739
- scope: accountId,
5740
- filename,
5741
- mimeType,
5742
- sizeBytes: buffer.byteLength,
5743
- storedAt: (/* @__PURE__ */ new Date()).toISOString()
5744
- };
5745
- await writeFile2(storagePath, buffer);
5746
- await writeFile2(metaPath, JSON.stringify(meta, null, 2));
5747
- return { attachmentId, filename, storagePath, metaPath, mimeType, sizeBytes: buffer.byteLength };
5748
- }
5823
+ const whatsappAnyConnected = whatsappAccounts.some((a) => a.connected);
5824
+ const whatsappAnyStuck = whatsappAccounts.some((a) => Boolean(a.sessionStuckReason));
5825
+ const whatsappAnyTerminal = whatsappAccounts.some((a) => Boolean(a.terminalReason));
5826
+ const account = resolveAccount();
5827
+ const missingPlugins = findMissingPlugins(account?.config.enabledPlugins);
5828
+ return c.json({
5829
+ pin_configured: pinConfigured,
5830
+ claude_authenticated: claudeAuthenticated,
5831
+ vnc_running: vncRunning,
5832
+ browser_transport: browserTransport,
5833
+ auth_status: authHealth.status,
5834
+ auth_expires_at: authHealth.expiresAt ?? null,
5835
+ server_ip: getLanIp(),
5836
+ missingPlugins,
5837
+ whatsapp: {
5838
+ any_connected: whatsappAnyConnected,
5839
+ any_stuck: whatsappAnyStuck,
5840
+ any_terminal: whatsappAnyTerminal,
5841
+ accounts: whatsappAccounts
5842
+ }
5843
+ });
5844
+ });
5845
+ var health_default = app;
5749
5846
 
5750
5847
  // app/lib/stt/voice-note.ts
5751
5848
  import { writeFile as writeFile3, mkdtemp, rm } from "fs/promises";
@@ -6204,10 +6301,10 @@ ${result.result.text}` : result.result.text;
6204
6301
  }
6205
6302
 
6206
6303
  // app/lib/channel-pty-bridge/admin-session-id.ts
6207
- import { createHash } from "crypto";
6304
+ import { createHash as createHash2 } from "crypto";
6208
6305
  function adminSessionIdFor(accountId, senderId, personId) {
6209
6306
  const subject = personId && personId.length > 0 ? `person:${personId}` : senderId;
6210
- const b = createHash("sha256").update(`${accountId}:admin:${subject}`).digest().subarray(0, 16);
6307
+ const b = createHash2("sha256").update(`${accountId}:admin:${subject}`).digest().subarray(0, 16);
6211
6308
  const v = Buffer.from(b);
6212
6309
  v[6] = v[6] & 15 | 64;
6213
6310
  v[8] = v[8] & 63 | 128;
@@ -6363,13 +6460,13 @@ async function sendTelegramText(botToken, chatId, text) {
6363
6460
 
6364
6461
  // server/routes/whatsapp.ts
6365
6462
  import { join as join10, resolve as resolve10 } from "path";
6366
- import { readdirSync as readdirSync4, readFileSync as readFileSync11, existsSync as existsSync6 } from "fs";
6463
+ import { readdirSync as readdirSync4, readFileSync as readFileSync11, existsSync as existsSync7 } from "fs";
6367
6464
 
6368
6465
  // app/lib/whatsapp/login.ts
6369
6466
  import { randomUUID as randomUUID6 } from "crypto";
6370
6467
 
6371
6468
  // app/lib/whatsapp/config-persist.ts
6372
- import { readFileSync as readFileSync10, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
6469
+ import { readFileSync as readFileSync10, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
6373
6470
  import { resolve as resolve8, join as join8, dirname as dirname2, basename as basename2 } from "path";
6374
6471
  var TAG17 = "[whatsapp:config]";
6375
6472
  function configPath(accountDir) {
@@ -6377,7 +6474,7 @@ function configPath(accountDir) {
6377
6474
  }
6378
6475
  function readConfig(accountDir) {
6379
6476
  const path2 = configPath(accountDir);
6380
- if (!existsSync5(path2)) throw new Error(`account.json not found at ${path2}`);
6477
+ if (!existsSync6(path2)) throw new Error(`account.json not found at ${path2}`);
6381
6478
  return JSON.parse(readFileSync10(path2, "utf-8"));
6382
6479
  }
6383
6480
  function writeConfig(accountDir, config) {
@@ -6676,7 +6773,7 @@ function setPublicAgent(accountDir, slug) {
6676
6773
  return { ok: false, error: "Agent slug cannot be empty." };
6677
6774
  }
6678
6775
  const agentConfigPath = join8(accountDir, "agents", trimmed, "config.json");
6679
- if (!existsSync5(agentConfigPath)) {
6776
+ if (!existsSync6(agentConfigPath)) {
6680
6777
  return { ok: false, error: `Agent "${trimmed}" not found \u2014 no config.json at ${agentConfigPath}. Check the agent slug and try again.` };
6681
6778
  }
6682
6779
  try {
@@ -7638,13 +7735,13 @@ app2.post("/config", async (c) => {
7638
7735
  case "list-public-agents": {
7639
7736
  const agentsDir = resolve10(account.accountDir, "agents");
7640
7737
  const agents = [];
7641
- if (existsSync6(agentsDir)) {
7738
+ if (existsSync7(agentsDir)) {
7642
7739
  try {
7643
7740
  const entries = readdirSync4(agentsDir, { withFileTypes: true });
7644
7741
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
7645
7742
  if (!entry.isDirectory() || entry.name === "admin") continue;
7646
7743
  const configPath2 = resolve10(agentsDir, entry.name, "config.json");
7647
- if (!existsSync6(configPath2)) continue;
7744
+ if (!existsSync7(configPath2)) continue;
7648
7745
  try {
7649
7746
  const config = JSON.parse(readFileSync11(configPath2, "utf-8"));
7650
7747
  agents.push({ slug: entry.name, displayName: config.displayName ?? entry.name });
@@ -7788,9 +7885,10 @@ app2.get("/activity", (c) => {
7788
7885
  });
7789
7886
  app2.get("/conversations", (c) => {
7790
7887
  try {
7791
- const accountId = validateAccountId(c.req.query("accountId") ?? null);
7792
- const denial = callerScopeDenial(c, accountId);
7888
+ const requestedAccountId = validateAccountId(c.req.query("accountId") ?? null);
7889
+ const denial = callerScopeDenial(c, requestedAccountId);
7793
7890
  if (denial) return denial;
7891
+ const accountId = resolveReadStoreKey(requestedAccountId);
7794
7892
  const jids = listConversationJids(accountId);
7795
7893
  const conversations = jids.map((jid) => {
7796
7894
  const messages = getMessages(accountId, jid);
@@ -7803,7 +7901,7 @@ app2.get("/conversations", (c) => {
7803
7901
  };
7804
7902
  });
7805
7903
  conversations.sort((a, b) => (b.lastMessageTimestamp ?? 0) - (a.lastMessageTimestamp ?? 0));
7806
- console.error(`${TAG21} conversations account=${accountId} storeKeyFound=${jids.length > 0} count=${conversations.length}`);
7904
+ console.error(`${TAG21} conversations requested=${requestedAccountId} account=${accountId} storeKeyFound=${jids.length > 0} count=${conversations.length}`);
7807
7905
  return c.json({ conversations });
7808
7906
  } catch (err) {
7809
7907
  console.error(`${TAG21} conversations error: ${String(err)}`);
@@ -7812,9 +7910,10 @@ app2.get("/conversations", (c) => {
7812
7910
  });
7813
7911
  app2.get("/messages", (c) => {
7814
7912
  try {
7815
- const accountId = validateAccountId(c.req.query("accountId") ?? null);
7816
- const denial = callerScopeDenial(c, accountId);
7913
+ const requestedAccountId = validateAccountId(c.req.query("accountId") ?? null);
7914
+ const denial = callerScopeDenial(c, requestedAccountId);
7817
7915
  if (denial) return denial;
7916
+ const accountId = resolveReadStoreKey(requestedAccountId);
7818
7917
  const jid = c.req.query("jid");
7819
7918
  if (!jid) {
7820
7919
  return c.json({ error: "Missing required parameter: jid" }, 400);
@@ -7824,7 +7923,7 @@ app2.get("/messages", (c) => {
7824
7923
  const effectiveLimit = limit && !Number.isNaN(limit) && limit > 0 ? limit : void 0;
7825
7924
  const messages = getMessages(accountId, jid, effectiveLimit);
7826
7925
  const storeKeyFound = listConversationJids(accountId).length > 0;
7827
- console.error(`${TAG21} messages account=${accountId} jid=${jid} storeKeyFound=${storeKeyFound} limit=${effectiveLimit ?? "all"} returned=${messages.length}`);
7926
+ console.error(`${TAG21} messages requested=${requestedAccountId} account=${accountId} jid=${jid} storeKeyFound=${storeKeyFound} limit=${effectiveLimit ?? "all"} returned=${messages.length}`);
7828
7927
  return c.json({ messages });
7829
7928
  } catch (err) {
7830
7929
  console.error(`${TAG21} messages error: ${String(err)}`);
@@ -7959,7 +8058,7 @@ app2.get("/group-info", async (c) => {
7959
8058
  var whatsapp_default = app2;
7960
8059
 
7961
8060
  // server/routes/whatsapp-reader.ts
7962
- import { readFileSync as readFileSync15, watch, statSync as statSync5, openSync, readSync, closeSync, existsSync as existsSync8, readdirSync as readdirSync8, realpathSync as realpathSync4 } from "fs";
8061
+ import { readFileSync as readFileSync15, watch, statSync as statSync5, openSync, readSync, closeSync, existsSync as existsSync9, readdirSync as readdirSync8, realpathSync as realpathSync4 } from "fs";
7963
8062
  import { basename as basename5, dirname as dirname4, isAbsolute, join as join14, relative as relative2, resolve as resolve12, sep as sep3 } from "path";
7964
8063
 
7965
8064
  // server/routes/admin/sidebar-sessions.ts
@@ -8319,14 +8418,14 @@ app3.get("/", requireAdminSession, async (c) => {
8319
8418
  var sidebar_sessions_default = app3;
8320
8419
 
8321
8420
  // app/lib/admin-identity/pin-validator.ts
8322
- import { createHash as createHash2 } from "crypto";
8323
- import { existsSync as existsSync7, readFileSync as readFileSync13, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
8421
+ import { createHash as createHash3 } from "crypto";
8422
+ import { existsSync as existsSync8, readFileSync as readFileSync13, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
8324
8423
  import { join as join12 } from "path";
8325
8424
  function hashPin(pin) {
8326
- return createHash2("sha256").update(pin).digest("hex");
8425
+ return createHash3("sha256").update(pin).digest("hex");
8327
8426
  }
8328
8427
  function readUsersFile(usersFilePath) {
8329
- if (!existsSync7(usersFilePath)) return null;
8428
+ if (!existsSync8(usersFilePath)) return null;
8330
8429
  const raw = readFileSync13(usersFilePath, "utf-8").trim();
8331
8430
  if (!raw) return [];
8332
8431
  return JSON.parse(raw);
@@ -8481,14 +8580,14 @@ function joinTextBlocks(content) {
8481
8580
  }
8482
8581
  return parts.join("");
8483
8582
  }
8484
- function assistantTurns(content, ts) {
8583
+ function assistantTurns(content, ts, promoteFinal = false) {
8485
8584
  if (!Array.isArray(content)) return [];
8486
8585
  const out = [];
8487
8586
  for (const block of content) {
8488
8587
  if (!block || typeof block !== "object") continue;
8489
8588
  const b = block;
8490
8589
  if (b.type === "text" && typeof b.text === "string") {
8491
- out.push({ kind: "agent-text", text: b.text, ts });
8590
+ out.push(promoteFinal && b.text.trim().length > 0 ? { kind: "agent-webchat-final", text: b.text, ts } : { kind: "agent-text", text: b.text, ts });
8492
8591
  } else if (b.type === "tool_use" && (b.name === WA_REPLY_TOOL || b.name === WEBCHAT_REPLY_TOOL || b.name === CHANNEL_REPLY_TOOL)) {
8493
8592
  const text = b.input?.text;
8494
8593
  out.push({ kind: "agent-reply", text: typeof text === "string" ? text : "", ts });
@@ -8539,7 +8638,7 @@ function renderOrSuppressChannelInbound(u, ts, out, queuedPendingSuppress) {
8539
8638
  out.push(operatorInboundTurn(u, ts));
8540
8639
  }
8541
8640
  }
8542
- function parseTranscript(lines, queuedPendingSuppress = /* @__PURE__ */ new Map()) {
8641
+ function parseTranscript(lines, queuedPendingSuppress = /* @__PURE__ */ new Map(), turnCtx = {}) {
8543
8642
  const out = [];
8544
8643
  for (const line of lines) {
8545
8644
  if (!line || line[0] !== "{") continue;
@@ -8562,23 +8661,31 @@ function parseTranscript(lines, queuedPendingSuppress = /* @__PURE__ */ new Map(
8562
8661
  out.push({ kind: "agent-error", code, raw, ts });
8563
8662
  continue;
8564
8663
  }
8565
- out.push(...assistantTurns(msg?.content, ts));
8664
+ const isEnd = msg?.stop_reason === "end_turn";
8665
+ out.push(...assistantTurns(msg?.content, ts, isEnd && turnCtx.lastChannelSource === "webchat"));
8566
8666
  continue;
8567
8667
  }
8568
8668
  if (row.type === "queue-operation" && row.operation === "enqueue") {
8569
8669
  const content = asString(row.content);
8570
- if (content !== null && CHANNEL_WRAPPER2.test(content) && !isScheduleSource(content)) {
8670
+ if (content !== null && CHANNEL_WRAPPER2.test(content)) {
8571
8671
  const u = unwrapChannel(content);
8572
- out.push(operatorInboundTurn(u, ts));
8573
- queuedPendingSuppress.set(u.text, (queuedPendingSuppress.get(u.text) ?? 0) + 1);
8672
+ turnCtx.lastChannelSource = u.source;
8673
+ if (!isScheduleSource(content)) {
8674
+ out.push(operatorInboundTurn(u, ts));
8675
+ queuedPendingSuppress.set(u.text, (queuedPendingSuppress.get(u.text) ?? 0) + 1);
8676
+ }
8574
8677
  }
8575
8678
  continue;
8576
8679
  }
8577
8680
  if (row.type === "attachment") {
8578
8681
  const att = row.attachment;
8579
8682
  const isChannelQueued = att?.type === "queued_command" && att.isMeta === true && typeof att.origin === "object" && att.origin !== null && att.origin.kind === "channel" && typeof att.prompt === "string";
8580
- if (isChannelQueued && !isScheduleSource(att.prompt)) {
8581
- renderOrSuppressChannelInbound(unwrapChannel(att.prompt), ts, out, queuedPendingSuppress);
8683
+ if (isChannelQueued) {
8684
+ const u = unwrapChannel(att.prompt);
8685
+ turnCtx.lastChannelSource = u.source;
8686
+ if (!isScheduleSource(att.prompt)) {
8687
+ renderOrSuppressChannelInbound(u, ts, out, queuedPendingSuppress);
8688
+ }
8582
8689
  }
8583
8690
  continue;
8584
8691
  }
@@ -8603,8 +8710,10 @@ function parseTranscript(lines, queuedPendingSuppress = /* @__PURE__ */ new Map(
8603
8710
  // enqueue row AND this row both render and every channel message doubles.
8604
8711
  CHANNEL_WRAPPER2.test(text);
8605
8712
  if (isChannel) {
8713
+ const u = unwrapChannel(text);
8714
+ turnCtx.lastChannelSource = u.source;
8606
8715
  if (!isScheduleSource(text)) {
8607
- renderOrSuppressChannelInbound(unwrapChannel(text), ts, out, queuedPendingSuppress);
8716
+ renderOrSuppressChannelInbound(u, ts, out, queuedPendingSuppress);
8608
8717
  }
8609
8718
  } else if (row.isMeta === true) {
8610
8719
  out.push({ kind: "harness-internal", text, ts });
@@ -9027,7 +9136,7 @@ data: ${JSON.stringify(turn)}
9027
9136
  const sessionKey = c.req.query("session_key") ?? "";
9028
9137
  const subagentsDir = resolve12(join14(projectDir, sessionId, "subagents"));
9029
9138
  console.log(
9030
- `[webchat-activity] op=watch-subagents key=${sessionKey} dir=${subagentsDir} exists=${existsSync8(subagentsDir)}`
9139
+ `[webchat-activity] op=watch-subagents key=${sessionKey} dir=${subagentsDir} exists=${existsSync9(subagentsDir)}`
9031
9140
  );
9032
9141
  const actx = makeActivityCtx({
9033
9142
  subagentsDir,
@@ -9044,6 +9153,7 @@ data: ${JSON.stringify(obj)}
9044
9153
  log: (line) => console.log(line)
9045
9154
  });
9046
9155
  const queuedPendingSuppress = /* @__PURE__ */ new Map();
9156
+ const turnCtx = {};
9047
9157
  const drain = () => {
9048
9158
  try {
9049
9159
  const { buf, end } = readFrom(jsonlPath, offset);
@@ -9051,7 +9161,7 @@ data: ${JSON.stringify(obj)}
9051
9161
  const { lines, nextOffset } = splitNewLines(buf, offset);
9052
9162
  offset = nextOffset;
9053
9163
  let n = 0;
9054
- for (const turn of parseTranscript(lines, queuedPendingSuppress)) {
9164
+ for (const turn of parseTranscript(lines, queuedPendingSuppress, turnCtx)) {
9055
9165
  send(controller, enrichAdminAttachments(enrichDownloads(turn, scratchpadRoot)), offset);
9056
9166
  actx.lastParentTurns.push(turn);
9057
9167
  if (turn.kind === "agent-error") {
@@ -9071,7 +9181,7 @@ data: ${JSON.stringify(obj)}
9071
9181
  const { buf, end } = readFrom(jsonlPath, 0);
9072
9182
  offset = end;
9073
9183
  let n = 0;
9074
- for (const turn of parseTranscript(buf.toString("utf8").split("\n"), queuedPendingSuppress)) {
9184
+ for (const turn of parseTranscript(buf.toString("utf8").split("\n"), queuedPendingSuppress, turnCtx)) {
9075
9185
  send(controller, enrichAdminAttachments(enrichDownloads(turn, scratchpadRoot)), offset);
9076
9186
  actx.lastParentTurns.push(turn);
9077
9187
  if (turn.kind === "agent-error") {
@@ -9133,7 +9243,7 @@ app4.get("/directives", requireAdminSession, (c) => {
9133
9243
  const sessionId = c.req.query("sessionId") ?? "";
9134
9244
  if (!SESSION_ID_RE2.test(sessionId)) return c.json({ error: "bad session reference" }, 400);
9135
9245
  const dir = directiveStoreDir(sessionId);
9136
- if (!dir || !existsSync8(dir)) return c.json({ entries: [] });
9246
+ if (!dir || !existsSync9(dir)) return c.json({ entries: [] });
9137
9247
  let names;
9138
9248
  try {
9139
9249
  names = readdirSync8(dir).filter((n) => DIRECTIVE_NAME_RE.test(n));
@@ -9191,14 +9301,22 @@ app4.get("/store-stream", requireAdminSession, (c) => {
9191
9301
  const connId = `${remoteJid.slice(0, 12)}-${process.hrtime.bigint().toString(36).slice(-6)}`;
9192
9302
  const startedAt = Date.now();
9193
9303
  const lastEventId = c.req.header("Last-Event-ID");
9194
- const toTurn = (r) => ({
9195
- kind: "whatsapp-persisted",
9196
- text: r.body,
9197
- ts: r.dateSent,
9198
- fromMe: r.fromMe,
9199
- senderName: r.senderName,
9200
- origin: r.origin
9201
- });
9304
+ const uploadsBase = resolve12(DATA_ROOT, "accounts", accountId, "uploads");
9305
+ const toTurn = (r) => {
9306
+ const turn = {
9307
+ kind: "whatsapp-persisted",
9308
+ text: r.body,
9309
+ ts: r.dateSent,
9310
+ fromMe: r.fromMe,
9311
+ senderName: r.senderName,
9312
+ origin: r.origin
9313
+ };
9314
+ if (r.attachmentId) {
9315
+ const meta = readAttachmentMeta(join14(uploadsBase, r.attachmentId));
9316
+ if (meta) return { ...turn, attachments: [meta] };
9317
+ }
9318
+ return turn;
9319
+ };
9202
9320
  const readable = new ReadableStream({
9203
9321
  start(controller) {
9204
9322
  const send = (turn, id) => {
@@ -9382,7 +9500,7 @@ app4.post("/reply", requireAdminSession, async (c) => {
9382
9500
  return c.json({ error: `WhatsApp account "${accountId}" is registered but its socket is disconnected.` }, 503);
9383
9501
  }
9384
9502
  console.log(`${MANUAL_TAG} op=socket sendId=${sendId} accountId=${accountId} result=ok socketAccountId=${res.accountId}`);
9385
- const record = (waMessageId, body) => {
9503
+ const record = (waMessageId, body, attachmentId) => {
9386
9504
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
9387
9505
  const rec = {
9388
9506
  messageId: `whatsapp-live:${res.accountId}:${remoteJid}:${waMessageId}`,
@@ -9398,11 +9516,15 @@ app4.post("/reply", requireAdminSession, async (c) => {
9398
9516
  quotedSender: null,
9399
9517
  scope: template.scope,
9400
9518
  origin: "operator-manual",
9401
- createdAt: nowIso
9519
+ createdAt: nowIso,
9520
+ // Task 1536 — a media send carries the servable attachment reference (the
9521
+ // file already lives in the account's uploads store via storeAttachment),
9522
+ // so the store reader renders it inline instead of a `[sent …]` text line.
9523
+ ...attachmentId ? { attachmentId } : {}
9402
9524
  };
9403
9525
  appendMessage(accountId, rec);
9404
9526
  const present = readConversation(accountId, remoteJid).some((r) => r.messageId === rec.messageId);
9405
- console.log(`${MANUAL_TAG} op=recorded sendId=${sendId} waMessageId=${waMessageId} present=${present}`);
9527
+ console.log(`${MANUAL_TAG} op=recorded sendId=${sendId} waMessageId=${waMessageId} present=${present} attachment=${attachmentId ? "yes" : "no"}`);
9406
9528
  };
9407
9529
  let sent = 0;
9408
9530
  if (text) {
@@ -9440,7 +9562,7 @@ app4.post("/reply", requireAdminSession, async (c) => {
9440
9562
  mid = r.messageId;
9441
9563
  }
9442
9564
  console.log(`${MANUAL_TAG} op=sent sendId=${sendId} kind=${kind ?? "document"} idx=${i} waMessageId=${mid ?? ""}`);
9443
- if (mid) record(mid, `[sent ${stored.filename}]`);
9565
+ if (mid) record(mid, "", stored.attachmentId);
9444
9566
  sent++;
9445
9567
  }
9446
9568
  exit("delivered");
@@ -9751,7 +9873,7 @@ var public_reader_default = app5;
9751
9873
  // server/routes/webchat.ts
9752
9874
  import { basename as basename7, dirname as dirname6 } from "path";
9753
9875
  import { join as join18 } from "path";
9754
- import { existsSync as existsSync10, readdirSync as readdirSync11, readFileSync as readFileSync19, renameSync as renameSync4, statSync as statSync7, writeFileSync as writeFileSync7 } from "fs";
9876
+ import { existsSync as existsSync11, readdirSync as readdirSync11, readFileSync as readFileSync19, renameSync as renameSync4, statSync as statSync7, writeFileSync as writeFileSync7 } from "fs";
9755
9877
 
9756
9878
  // server/canonical-webchat-override.ts
9757
9879
  import { readFileSync as readFileSync17, writeFileSync as writeFileSync5, renameSync as renameSync2 } from "fs";
@@ -9838,7 +9960,7 @@ function isNewSessionEffortLevel(value) {
9838
9960
  }
9839
9961
 
9840
9962
  // ../services/claude-session-manager/src/sidecar-store.ts
9841
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readdirSync as readdirSync10, readFileSync as readFileSync18, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "fs";
9963
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3, readdirSync as readdirSync10, readFileSync as readFileSync18, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "fs";
9842
9964
  import { join as join17 } from "path";
9843
9965
  function escapeRegExp(s) {
9844
9966
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -9859,7 +9981,7 @@ function createSidecarStore(config) {
9859
9981
  return { ok: true };
9860
9982
  } catch (error) {
9861
9983
  try {
9862
- if (existsSync9(tmp)) unlinkSync2(tmp);
9984
+ if (existsSync10(tmp)) unlinkSync2(tmp);
9863
9985
  } catch {
9864
9986
  }
9865
9987
  return { ok: false, error };
@@ -9915,7 +10037,7 @@ function createSidecarStore(config) {
9915
10037
  function clear(sessionsDir, id) {
9916
10038
  const path2 = pathFor(sessionsDir, id);
9917
10039
  try {
9918
- if (existsSync9(path2)) {
10040
+ if (existsSync10(path2)) {
9919
10041
  unlinkSync2(path2);
9920
10042
  return { ok: true, removed: true };
9921
10043
  }
@@ -9983,7 +10105,7 @@ function locateSidecar(sessionId) {
9983
10105
  }
9984
10106
  for (const slug of slugs) {
9985
10107
  const metaPath = join18(projectsRoot, slug, `${sessionId}.meta.json`);
9986
- if (existsSync10(metaPath)) {
10108
+ if (existsSync11(metaPath)) {
9987
10109
  const meta = readSidecarMeta(metaPath);
9988
10110
  return { channel: meta.channel, adminUserId: meta.adminUserId, accountId: meta.accountId };
9989
10111
  }
@@ -10578,7 +10700,7 @@ ${note}` : note;
10578
10700
  import { resolve as resolve14 } from "path";
10579
10701
 
10580
10702
  // app/lib/claude-agent/specialist-roster.ts
10581
- import { existsSync as existsSync11, readFileSync as readFileSync20, readdirSync as readdirSync12 } from "fs";
10703
+ import { existsSync as existsSync12, readFileSync as readFileSync20, readdirSync as readdirSync12 } from "fs";
10582
10704
  import { join as join19 } from "path";
10583
10705
  function field(fm, name) {
10584
10706
  const m = fm.match(new RegExp(`^${name}:\\s*(.*?)\\r?$`, "m"));
@@ -10590,7 +10712,7 @@ function field(fm, name) {
10590
10712
  return value || null;
10591
10713
  }
10592
10714
  function readSpecialistRoster(specialistsDir) {
10593
- if (!existsSync11(specialistsDir)) return { specialists: [], skipped: [] };
10715
+ if (!existsSync12(specialistsDir)) return { specialists: [], skipped: [] };
10594
10716
  const entries = readdirSync12(specialistsDir);
10595
10717
  const specialists = [];
10596
10718
  const skipped = [];
@@ -10716,7 +10838,7 @@ var webchat_greeting_default = app6;
10716
10838
  // server/routes/telegram.ts
10717
10839
  import { homedir } from "os";
10718
10840
  import { resolve as resolve15, join as join20 } from "path";
10719
- import { existsSync as existsSync12, readFileSync as readFileSync21 } from "fs";
10841
+ import { existsSync as existsSync13, readFileSync as readFileSync21 } from "fs";
10720
10842
 
10721
10843
  // app/lib/telegram/gateway/instance.ts
10722
10844
  var instance = null;
@@ -10776,7 +10898,7 @@ var TAG23 = "[telegram-inbound]";
10776
10898
  function configDirName() {
10777
10899
  const platformRoot3 = process.env.MAXY_PLATFORM_ROOT ?? resolve15(process.cwd(), "..");
10778
10900
  const brandPath = join20(platformRoot3, "config", "brand.json");
10779
- if (existsSync12(brandPath)) {
10901
+ if (existsSync13(brandPath)) {
10780
10902
  try {
10781
10903
  return JSON.parse(readFileSync21(brandPath, "utf-8")).configDir ?? ".maxy";
10782
10904
  } catch {
@@ -10797,7 +10919,7 @@ app7.post("/", async (c) => {
10797
10919
  return c.json({ ok: false }, 400);
10798
10920
  }
10799
10921
  const sp = secretPath(botType);
10800
- if (!existsSync12(sp)) {
10922
+ if (!existsSync13(sp)) {
10801
10923
  console.error(`${TAG23} op=reject reason=no-secret-file botType=${botType}`);
10802
10924
  return c.json({ ok: false }, 401);
10803
10925
  }
@@ -10865,7 +10987,7 @@ var telegram_default = app7;
10865
10987
 
10866
10988
  // server/routes/quickbooks.ts
10867
10989
  import { join as join21 } from "path";
10868
- import { existsSync as existsSync13, readFileSync as readFileSync22, writeFileSync as writeFileSync8, mkdirSync as mkdirSync4, rmSync, renameSync as renameSync5 } from "fs";
10990
+ import { existsSync as existsSync14, readFileSync as readFileSync22, writeFileSync as writeFileSync8, mkdirSync as mkdirSync4, rmSync, renameSync as renameSync5 } from "fs";
10869
10991
  var TAG24 = "[quickbooks]";
10870
10992
  var INTUIT_TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer";
10871
10993
  var STATE_RE = /^[A-Za-z0-9_-]+$/;
@@ -10878,7 +11000,7 @@ function storePath(accountDir) {
10878
11000
  async function completeConsent(args) {
10879
11001
  const { accountDir, code, realmId } = args;
10880
11002
  const state = args.state;
10881
- if (!state || !STATE_RE.test(state) || !existsSync13(pendingPath(accountDir, state))) {
11003
+ if (!state || !STATE_RE.test(state) || !existsSync14(pendingPath(accountDir, state))) {
10882
11004
  console.error(`${TAG24} op=callback state=${state ?? ""} stateValid=false realmId=${realmId ?? ""}`);
10883
11005
  return { ok: false, status: 400, message: "Invalid or unknown authorization state.", stateValid: false };
10884
11006
  }
@@ -10905,7 +11027,7 @@ async function completeConsent(args) {
10905
11027
  if (!code || !realmId) {
10906
11028
  return { ok: false, status: 400, message: "Missing code or realmId in the callback.", stateValid: true };
10907
11029
  }
10908
- if (!existsSync13(storePath(accountDir))) {
11030
+ if (!existsSync14(storePath(accountDir))) {
10909
11031
  return { ok: false, status: 500, message: "No QuickBooks credentials are stored for this account.", stateValid: true };
10910
11032
  }
10911
11033
  const store2 = JSON.parse(readFileSync22(storePath(accountDir), "utf-8"));
@@ -10973,8 +11095,8 @@ var quickbooks_default = app8;
10973
11095
 
10974
11096
  // server/routes/onboarding.ts
10975
11097
  import { spawn, spawnSync as spawnSync2, execFileSync as execFileSync3 } from "child_process";
10976
- import { openSync as openSync3, closeSync as closeSync3, writeFileSync as writeFileSync10, writeSync, existsSync as existsSync15, readFileSync as readFileSync24, unlinkSync as unlinkSync3, renameSync as renameSync7 } from "fs";
10977
- import { createHash as createHash3, randomUUID as randomUUID7 } from "crypto";
11098
+ import { openSync as openSync3, closeSync as closeSync3, writeFileSync as writeFileSync10, writeSync, existsSync as existsSync16, readFileSync as readFileSync24, unlinkSync as unlinkSync3, renameSync as renameSync7 } from "fs";
11099
+ import { createHash as createHash4, randomUUID as randomUUID7 } from "crypto";
10978
11100
 
10979
11101
  // app/lib/claude-spawn-env.ts
10980
11102
  function claudeBin() {
@@ -11111,7 +11233,7 @@ function readHostCredsBlob(service) {
11111
11233
  }
11112
11234
 
11113
11235
  // ../lib/admins-write/src/index.ts
11114
- import { existsSync as existsSync14, readFileSync as readFileSync23, writeFileSync as writeFileSync9, renameSync as renameSync6, mkdirSync as mkdirSync5, readdirSync as readdirSync13, statSync as statSync8, appendFileSync as appendFileSync2 } from "fs";
11236
+ import { existsSync as existsSync15, readFileSync as readFileSync23, writeFileSync as writeFileSync9, renameSync as renameSync6, mkdirSync as mkdirSync5, readdirSync as readdirSync13, statSync as statSync8, appendFileSync as appendFileSync2 } from "fs";
11115
11237
  import { dirname as dirname7, join as join22 } from "path";
11116
11238
  function id8(value) {
11117
11239
  return value.slice(0, 8);
@@ -11152,7 +11274,7 @@ function writeAdminEntry(input) {
11152
11274
  const result = { usersJsonResult: "noop", accountJsonResult: "noop" };
11153
11275
  try {
11154
11276
  let users = [];
11155
- if (existsSync14(input.usersFile)) {
11277
+ if (existsSync15(input.usersFile)) {
11156
11278
  const raw = readFileSync23(input.usersFile, "utf-8").trim();
11157
11279
  if (raw) {
11158
11280
  const parsed = JSON.parse(raw);
@@ -11185,7 +11307,7 @@ function writeAdminEntry(input) {
11185
11307
  }
11186
11308
  try {
11187
11309
  const accountJsonPath = join22(input.accountDir, "account.json");
11188
- if (!existsSync14(accountJsonPath)) {
11310
+ if (!existsSync15(accountJsonPath)) {
11189
11311
  throw new Error(`account.json not found at ${accountJsonPath}`);
11190
11312
  }
11191
11313
  const config = JSON.parse(readFileSync23(accountJsonPath, "utf-8"));
@@ -11208,7 +11330,7 @@ function writeAdminEntry(input) {
11208
11330
  function computeAdminStoreDivergence(input) {
11209
11331
  const result = { divergences: 0, accountWithoutUsers: [], usersWithoutAccount: [], errors: [] };
11210
11332
  const usersUserIds = /* @__PURE__ */ new Set();
11211
- if (existsSync14(input.usersFile)) {
11333
+ if (existsSync15(input.usersFile)) {
11212
11334
  try {
11213
11335
  const raw = readFileSync23(input.usersFile, "utf-8").trim();
11214
11336
  if (raw) {
@@ -11222,7 +11344,7 @@ function computeAdminStoreDivergence(input) {
11222
11344
  }
11223
11345
  }
11224
11346
  const accountUserIds = /* @__PURE__ */ new Set();
11225
- if (existsSync14(input.accountsDir)) {
11347
+ if (existsSync15(input.accountsDir)) {
11226
11348
  let entries;
11227
11349
  try {
11228
11350
  entries = readdirSync13(input.accountsDir);
@@ -11239,7 +11361,7 @@ function computeAdminStoreDivergence(input) {
11239
11361
  continue;
11240
11362
  }
11241
11363
  const accountJsonPath = join22(accountDir, "account.json");
11242
- if (!existsSync14(accountJsonPath)) continue;
11364
+ if (!existsSync15(accountJsonPath)) continue;
11243
11365
  let admins = [];
11244
11366
  try {
11245
11367
  const config = JSON.parse(readFileSync23(accountJsonPath, "utf-8"));
@@ -11287,10 +11409,10 @@ function checkAdminAuthInvariant(input) {
11287
11409
 
11288
11410
  // server/routes/onboarding.ts
11289
11411
  function hashPin2(pin) {
11290
- return createHash3("sha256").update(pin).digest("hex");
11412
+ return createHash4("sha256").update(pin).digest("hex");
11291
11413
  }
11292
11414
  function readUsersFile2() {
11293
- if (!existsSync15(USERS_FILE)) return null;
11415
+ if (!existsSync16(USERS_FILE)) return null;
11294
11416
  const raw = readFileSync24(USERS_FILE, "utf-8").trim();
11295
11417
  if (!raw) return [];
11296
11418
  return JSON.parse(raw);
@@ -11350,7 +11472,7 @@ app9.post("/claude-auth", async (c) => {
11350
11472
  } catch (err) {
11351
11473
  logoutError = err instanceof Error ? err.message : String(err);
11352
11474
  }
11353
- const credentialsPresent = existsSync15(CLAUDE_CREDENTIALS_FILE);
11475
+ const credentialsPresent = existsSync16(CLAUDE_CREDENTIALS_FILE);
11354
11476
  const ranMs = Date.now() - start;
11355
11477
  console.log(`[onboarding] op=claude-logout credentialsPresent=${credentialsPresent} ranMs=${ranMs} logoutError=${logoutError ? JSON.stringify(logoutError.slice(0, 120)) : "none"}`);
11356
11478
  return c.json({ logged_out: !credentialsPresent });
@@ -11499,7 +11621,7 @@ app9.post("/set-pin", async (c) => {
11499
11621
  console.log(`[set-pin] wrote users.json + account.json admins: userId=${userId.slice(0, 8)}\u2026 role=owner`);
11500
11622
  if (process.platform === "linux") {
11501
11623
  let installOwner = null;
11502
- if (existsSync15(INSTALL_OWNER_FILE)) {
11624
+ if (existsSync16(INSTALL_OWNER_FILE)) {
11503
11625
  try {
11504
11626
  const raw = readFileSync24(INSTALL_OWNER_FILE, "utf-8").trim();
11505
11627
  if (raw.length > 0) installOwner = raw;
@@ -11647,7 +11769,7 @@ app9.put("/set-pin", requireAdminSession, async (c) => {
11647
11769
  console.log(`[set-pin] changed PIN in place: userId=${sessionUserId.slice(0, 8)}\u2026`);
11648
11770
  if (process.platform === "linux") {
11649
11771
  let installOwner = null;
11650
- if (existsSync15(INSTALL_OWNER_FILE)) {
11772
+ if (existsSync16(INSTALL_OWNER_FILE)) {
11651
11773
  try {
11652
11774
  const raw = readFileSync24(INSTALL_OWNER_FILE, "utf-8").trim();
11653
11775
  if (raw.length > 0) installOwner = raw;
@@ -11681,7 +11803,7 @@ ${body.newPin}
11681
11803
  var onboarding_default = app9;
11682
11804
 
11683
11805
  // server/routes/client-error.ts
11684
- import { appendFileSync as appendFileSync3, existsSync as existsSync16, renameSync as renameSync8, statSync as statSync9 } from "fs";
11806
+ import { appendFileSync as appendFileSync3, existsSync as existsSync17, renameSync as renameSync8, statSync as statSync9 } from "fs";
11685
11807
  import { join as join24 } from "path";
11686
11808
  var CLIENT_ERRORS_LOG = join24(LOG_DIR, "client-errors.log");
11687
11809
  var MAX_LOG_SIZE = 10 * 1024 * 1024;
@@ -11731,7 +11853,7 @@ function stackHead(stack) {
11731
11853
  }
11732
11854
  function rotateIfNeeded() {
11733
11855
  try {
11734
- if (!existsSync16(CLIENT_ERRORS_LOG)) return;
11856
+ if (!existsSync17(CLIENT_ERRORS_LOG)) return;
11735
11857
  const stats = statSync9(CLIENT_ERRORS_LOG);
11736
11858
  if (stats.size < MAX_LOG_SIZE) return;
11737
11859
  renameSync8(CLIENT_ERRORS_LOG, CLIENT_ERRORS_LOG + ".1");
@@ -12051,18 +12173,18 @@ app12.get("/", requireAdminSession, async (c) => {
12051
12173
  var accounts_default = app12;
12052
12174
 
12053
12175
  // server/routes/admin/logs.ts
12054
- import { existsSync as existsSync18, readdirSync as readdirSync14, readFileSync as readFileSync25, statSync as statSync10 } from "fs";
12176
+ import { existsSync as existsSync19, readdirSync as readdirSync14, readFileSync as readFileSync25, statSync as statSync10 } from "fs";
12055
12177
  import { resolve as resolve16, basename as basename8 } from "path";
12056
12178
 
12057
12179
  // app/lib/logs-read-resolve.ts
12058
- import { existsSync as existsSync17 } from "fs";
12180
+ import { existsSync as existsSync18 } from "fs";
12059
12181
  import { join as join25 } from "path";
12060
12182
  function resolveSessionLogPaths(filename, logDirs) {
12061
12183
  const tried = [filename];
12062
12184
  const hits = [];
12063
12185
  for (const dir of logDirs) {
12064
12186
  const fullPath = join25(dir, filename);
12065
- if (existsSync17(fullPath)) {
12187
+ if (existsSync18(fullPath)) {
12066
12188
  hits.push({ path: fullPath, dir });
12067
12189
  }
12068
12190
  }
@@ -12190,7 +12312,7 @@ app13.get("/", async (c) => {
12190
12312
  const seen = /* @__PURE__ */ new Set();
12191
12313
  const logs = {};
12192
12314
  for (const dir of logDirs) {
12193
- if (!existsSync18(dir)) continue;
12315
+ if (!existsSync19(dir)) continue;
12194
12316
  let files;
12195
12317
  try {
12196
12318
  files = readdirSync14(dir).filter((f) => f.endsWith(".log"));
@@ -12242,7 +12364,7 @@ var claude_info_default = app14;
12242
12364
 
12243
12365
  // server/routes/admin/attachment.ts
12244
12366
  import { readFile as readFile2, readdir } from "fs/promises";
12245
- import { existsSync as existsSync19 } from "fs";
12367
+ import { existsSync as existsSync20 } from "fs";
12246
12368
  import { resolve as resolve17 } from "path";
12247
12369
  var app15 = new Hono();
12248
12370
  var ATTACHMENT_ID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -12265,12 +12387,12 @@ app15.get("/:attachmentId", requireAdminSession, async (c) => {
12265
12387
  }
12266
12388
  let accountId = pinnedAccountId;
12267
12389
  let dir = uploadsDirFor2(accountId, attachmentId);
12268
- let found = existsSync19(dir);
12390
+ let found = existsSync20(dir);
12269
12391
  if (!found) {
12270
12392
  for (const candidate of listValidAccounts()) {
12271
12393
  if (candidate.accountId === pinnedAccountId) continue;
12272
12394
  const candidateDir = uploadsDirFor2(candidate.accountId, attachmentId);
12273
- if (existsSync19(candidateDir)) {
12395
+ if (existsSync20(candidateDir)) {
12274
12396
  accountId = candidate.accountId;
12275
12397
  dir = candidateDir;
12276
12398
  found = true;
@@ -12285,7 +12407,7 @@ app15.get("/:attachmentId", requireAdminSession, async (c) => {
12285
12407
  return new Response("Not found", { status: 404 });
12286
12408
  }
12287
12409
  const metaPath = resolve17(dir, `${attachmentId}.meta.json`);
12288
- if (!existsSync19(metaPath)) {
12410
+ if (!existsSync20(metaPath)) {
12289
12411
  return new Response("Not found", { status: 404 });
12290
12412
  }
12291
12413
  let meta;
@@ -12313,13 +12435,13 @@ var attachment_default = app15;
12313
12435
 
12314
12436
  // server/routes/admin/agents.ts
12315
12437
  import { resolve as resolve18 } from "path";
12316
- import { readdirSync as readdirSync15, readFileSync as readFileSync26, existsSync as existsSync20, rmSync as rmSync2 } from "fs";
12438
+ import { readdirSync as readdirSync15, readFileSync as readFileSync26, existsSync as existsSync21, rmSync as rmSync2 } from "fs";
12317
12439
  var app16 = new Hono();
12318
12440
  app16.get("/", (c) => {
12319
12441
  const account = resolveAccount();
12320
12442
  if (!account) return c.json({ agents: [] });
12321
12443
  const agentsDir = resolve18(account.accountDir, "agents");
12322
- if (!existsSync20(agentsDir)) return c.json({ agents: [] });
12444
+ if (!existsSync21(agentsDir)) return c.json({ agents: [] });
12323
12445
  const agents = [];
12324
12446
  try {
12325
12447
  const entries = readdirSync15(agentsDir, { withFileTypes: true });
@@ -12327,7 +12449,7 @@ app16.get("/", (c) => {
12327
12449
  if (!entry.isDirectory()) continue;
12328
12450
  if (entry.name === "admin") continue;
12329
12451
  const configPath2 = resolve18(agentsDir, entry.name, "config.json");
12330
- if (!existsSync20(configPath2)) continue;
12452
+ if (!existsSync21(configPath2)) continue;
12331
12453
  try {
12332
12454
  const config = JSON.parse(readFileSync26(configPath2, "utf-8"));
12333
12455
  agents.push({
@@ -12356,7 +12478,7 @@ app16.delete("/:slug", async (c) => {
12356
12478
  return c.json({ error: "Invalid agent slug" }, 400);
12357
12479
  }
12358
12480
  const agentDir = resolve18(account.accountDir, "agents", slug);
12359
- if (!existsSync20(agentDir)) {
12481
+ if (!existsSync21(agentDir)) {
12360
12482
  return c.json({ error: "Agent not found" }, 404);
12361
12483
  }
12362
12484
  try {
@@ -12386,7 +12508,7 @@ app16.post("/:slug/project", async (c) => {
12386
12508
  return c.json({ error: "Invalid agent slug" }, 400);
12387
12509
  }
12388
12510
  const agentDir = resolve18(account.accountDir, "agents", slug);
12389
- if (!existsSync20(agentDir)) {
12511
+ if (!existsSync21(agentDir)) {
12390
12512
  return c.json({ error: "Agent not found on disk" }, 404);
12391
12513
  }
12392
12514
  try {
@@ -12402,7 +12524,7 @@ var agents_default = app16;
12402
12524
  // server/routes/admin/sessions.ts
12403
12525
  import crypto2 from "crypto";
12404
12526
  import { resolve as resolvePath } from "path";
12405
- import { existsSync as existsSync21, mkdirSync as mkdirSync6, createWriteStream } from "fs";
12527
+ import { existsSync as existsSync22, mkdirSync as mkdirSync6, createWriteStream } from "fs";
12406
12528
 
12407
12529
  // ../lib/admin-conversation-purge/src/index.ts
12408
12530
  async function purgeAdminConversationJsonls(args) {
@@ -12562,7 +12684,7 @@ function validateAndShapeAttachments(raws, conversationAccountId, sessionId, mes
12562
12684
  let reason = null;
12563
12685
  if (!a.attachmentId || !a.filename || !a.mimeType || !a.storagePath) reason = "schema-fail";
12564
12686
  else if (a.accountId !== conversationAccountId) reason = "account-mismatch";
12565
- else if (!existsSync21(a.storagePath)) reason = "missing-file";
12687
+ else if (!existsSync22(a.storagePath)) reason = "missing-file";
12566
12688
  if (reason) {
12567
12689
  invalid++;
12568
12690
  try {
@@ -13088,7 +13210,7 @@ app17.put("/:id/label", requireAdminSession, async (c) => {
13088
13210
  var sessions_default = app17;
13089
13211
 
13090
13212
  // app/lib/claude-agent/spawn-context.ts
13091
- import { existsSync as existsSync22, readFileSync as readFileSync27, readdirSync as readdirSync16, statSync as statSync11 } from "fs";
13213
+ import { existsSync as existsSync23, readFileSync as readFileSync27, readdirSync as readdirSync16, statSync as statSync11 } from "fs";
13092
13214
  import { dirname as dirname9, resolve as resolve19, join as join26 } from "path";
13093
13215
  async function resolveOwnerProfileBlock(accountId, userId) {
13094
13216
  if (!userId) return { ok: false, reason: "missing-user-id" };
@@ -13135,7 +13257,7 @@ function extractPluginToolDescriptions(pluginDir) {
13135
13257
  ];
13136
13258
  let raw = null;
13137
13259
  for (const path2 of candidates) {
13138
- if (!existsSync22(path2)) continue;
13260
+ if (!existsSync23(path2)) continue;
13139
13261
  try {
13140
13262
  raw = readFileSync27(path2, "utf-8");
13141
13263
  break;
@@ -13187,7 +13309,7 @@ function parseSkillPathsFromFrontmatter(fm) {
13187
13309
  function listPluginDirs() {
13188
13310
  const out = /* @__PURE__ */ new Map();
13189
13311
  const platformPlugins = resolve19(PLATFORM_ROOT, "plugins");
13190
- if (existsSync22(platformPlugins)) {
13312
+ if (existsSync23(platformPlugins)) {
13191
13313
  for (const name of readdirSync16(platformPlugins)) {
13192
13314
  const dir = join26(platformPlugins, name);
13193
13315
  try {
@@ -13197,10 +13319,10 @@ function listPluginDirs() {
13197
13319
  }
13198
13320
  }
13199
13321
  const premiumRoot = resolve19(dirname9(PLATFORM_ROOT), "premium-plugins");
13200
- if (existsSync22(premiumRoot)) {
13322
+ if (existsSync23(premiumRoot)) {
13201
13323
  for (const bundle of readdirSync16(premiumRoot)) {
13202
13324
  const bundlePlugins = join26(premiumRoot, bundle, "plugins");
13203
- if (!existsSync22(bundlePlugins)) continue;
13325
+ if (!existsSync23(bundlePlugins)) continue;
13204
13326
  try {
13205
13327
  for (const name of readdirSync16(bundlePlugins)) {
13206
13328
  const dir = join26(bundlePlugins, name);
@@ -13217,7 +13339,7 @@ function listPluginDirs() {
13217
13339
  }
13218
13340
  function readEnabledPlugins(accountId) {
13219
13341
  const accountFile = resolve19(PLATFORM_ROOT, "..", "data/accounts", accountId, "account.json");
13220
- if (!existsSync22(accountFile)) return /* @__PURE__ */ new Set();
13342
+ if (!existsSync23(accountFile)) return /* @__PURE__ */ new Set();
13221
13343
  try {
13222
13344
  const parsed = JSON.parse(readFileSync27(accountFile, "utf-8"));
13223
13345
  return new Set(
@@ -13228,7 +13350,7 @@ function readEnabledPlugins(accountId) {
13228
13350
  }
13229
13351
  }
13230
13352
  function readFrontmatter(path2) {
13231
- if (!existsSync22(path2)) return null;
13353
+ if (!existsSync23(path2)) return null;
13232
13354
  let raw;
13233
13355
  try {
13234
13356
  raw = readFileSync27(path2, "utf-8");
@@ -13263,7 +13385,7 @@ ${fm}
13263
13385
  `plugin-manifest-tool-coverage plugin=${name} tools=${tools.length} with-desc=${withDesc}`
13264
13386
  );
13265
13387
  if (toolNames.length > 0 && descMap.size === 0) {
13266
- const hasSource = existsSync22(join26(dir, "mcp/dist/index.js")) || existsSync22(join26(dir, "mcp/src/index.ts"));
13388
+ const hasSource = existsSync23(join26(dir, "mcp/dist/index.js")) || existsSync23(join26(dir, "mcp/src/index.ts"));
13267
13389
  if (hasSource) {
13268
13390
  console.warn(
13269
13391
  `[spawn-context] WARN plugin=${name} mcp source present but tool-description regex matched zero entries \u2014 SDK upgrade may have changed the registration shape`
@@ -13290,7 +13412,7 @@ ${skillFm}
13290
13412
  }
13291
13413
  function computeSpecialistDomains(accountId) {
13292
13414
  const specialistsDir = resolve19(PLATFORM_ROOT, "..", "data/accounts", accountId, "specialists", "agents");
13293
- if (!existsSync22(specialistsDir)) return [];
13415
+ if (!existsSync23(specialistsDir)) return [];
13294
13416
  let entries;
13295
13417
  try {
13296
13418
  entries = readdirSync16(specialistsDir);
@@ -13336,7 +13458,7 @@ ${fm}
13336
13458
  }
13337
13459
  function computeDormantPlugins(accountId) {
13338
13460
  const accountFile = resolve19(PLATFORM_ROOT, "..", "data/accounts", accountId, "account.json");
13339
- if (!existsSync22(accountFile)) return [];
13461
+ if (!existsSync23(accountFile)) return [];
13340
13462
  let enabled;
13341
13463
  try {
13342
13464
  const raw = readFileSync27(accountFile, "utf-8");
@@ -13351,7 +13473,7 @@ function computeDormantPlugins(accountId) {
13351
13473
  return [];
13352
13474
  }
13353
13475
  const pluginsDir = resolve19(PLATFORM_ROOT, "plugins");
13354
- if (!existsSync22(pluginsDir)) return [];
13476
+ if (!existsSync23(pluginsDir)) return [];
13355
13477
  let entries;
13356
13478
  try {
13357
13479
  entries = readdirSync16(pluginsDir);
@@ -13362,7 +13484,7 @@ function computeDormantPlugins(accountId) {
13362
13484
  for (const name of entries) {
13363
13485
  if (enabled.has(name)) continue;
13364
13486
  const manifestPath = resolve19(pluginsDir, name, "PLUGIN.md");
13365
- if (!existsSync22(manifestPath)) continue;
13487
+ if (!existsSync23(manifestPath)) continue;
13366
13488
  let manifest;
13367
13489
  try {
13368
13490
  manifest = readFileSync27(manifestPath, "utf-8");
@@ -13379,11 +13501,11 @@ function computeDormantPlugins(accountId) {
13379
13501
  }
13380
13502
 
13381
13503
  // server/routes/admin/claude-sessions.ts
13382
- import { existsSync as existsSync23, readFileSync as readFileSync28 } from "fs";
13504
+ import { existsSync as existsSync24, readFileSync as readFileSync28 } from "fs";
13383
13505
  import { resolve as resolve20 } from "path";
13384
13506
  function readTunnelState(brandConfigDir) {
13385
13507
  const statePath = `${process.env.HOME ?? ""}/${brandConfigDir}/cloudflared/tunnel.state`;
13386
- if (!existsSync23(statePath)) return null;
13508
+ if (!existsSync24(statePath)) return null;
13387
13509
  try {
13388
13510
  const parsed = JSON.parse(readFileSync28(statePath, "utf-8"));
13389
13511
  const tunnelId = typeof parsed.tunnelId === "string" ? parsed.tunnelId : null;
@@ -13641,7 +13763,7 @@ app20.post("/", async (c) => {
13641
13763
  var events_default = app20;
13642
13764
 
13643
13765
  // server/routes/admin/files.ts
13644
- import { createReadStream as createReadStream2, createWriteStream as createWriteStream2, existsSync as existsSync24 } from "fs";
13766
+ import { createReadStream as createReadStream2, createWriteStream as createWriteStream2, existsSync as existsSync25 } from "fs";
13645
13767
  import { readdir as readdir3, readFile as readFile4, stat as stat4, mkdir as mkdir3, unlink as unlink2, rename } from "fs/promises";
13646
13768
  import { realpathSync as realpathSync5 } from "fs";
13647
13769
  import { randomUUID as randomUUID9 } from "crypto";
@@ -15004,7 +15126,7 @@ app21.post("/upload", requireAdminSession, async (c) => {
15004
15126
  });
15005
15127
  await unlink2(tmpPath).catch(() => {
15006
15128
  });
15007
- const tempRemoved = !existsSync24(tmpPath);
15129
+ const tempRemoved = !existsSync25(tmpPath);
15008
15130
  console.error(`[data-upload] op=cleanup uid=${uid} tempRemoved=${tempRemoved}`);
15009
15131
  };
15010
15132
  const reader = body.getReader();
@@ -15078,7 +15200,7 @@ async function discardChunkUpload(uploadId, state) {
15078
15200
  });
15079
15201
  await unlink2(state.tmpPath).catch(() => {
15080
15202
  });
15081
- const tempRemoved = !existsSync24(state.tmpPath);
15203
+ const tempRemoved = !existsSync25(state.tmpPath);
15082
15204
  console.error(`[data-upload] op=cleanup uid=${uploadId} tempRemoved=${tempRemoved}`);
15083
15205
  }
15084
15206
  async function writeChunkBody(body, out, alreadyReceived, ceiling) {
@@ -15488,7 +15610,7 @@ function applyBacklinkBoost(hits) {
15488
15610
  }
15489
15611
 
15490
15612
  // ../lib/graph-search/src/dedup.ts
15491
- import { createHash as createHash4 } from "crypto";
15613
+ import { createHash as createHash5 } from "crypto";
15492
15614
  var DEFAULT_LAYERS = [
15493
15615
  "nodeId",
15494
15616
  "slug",
@@ -15515,7 +15637,7 @@ function readContentHash(props) {
15515
15637
  const content = props["content"];
15516
15638
  const source = typeof compiled === "string" && compiled.length > 0 ? compiled : typeof content === "string" && content.length > 0 ? content : null;
15517
15639
  if (source === null) return null;
15518
- return createHash4("sha256").update(source).digest("hex").slice(0, 16);
15640
+ return createHash5("sha256").update(source).digest("hex").slice(0, 16);
15519
15641
  }
15520
15642
  function dedup(hits, layers = DEFAULT_LAYERS) {
15521
15643
  const sorted = [...hits].sort((a, b) => b.score - a.score);
@@ -17535,7 +17657,7 @@ var graph_default_view_default = app27;
17535
17657
  // server/routes/admin/sidebar-artefacts.ts
17536
17658
  import { readdir as readdir4, stat as stat5 } from "fs/promises";
17537
17659
  import { resolve as resolve23, relative as relative5, isAbsolute as isAbsolute2, sep as sep7, basename as basename11 } from "path";
17538
- import { existsSync as existsSync25 } from "fs";
17660
+ import { existsSync as existsSync26 } from "fs";
17539
17661
  var LIMIT = 50;
17540
17662
  var ADMIN_AGENT_FILES = ["IDENTITY.md", "SOUL.md", "KNOWLEDGE.md"];
17541
17663
  function toDataRootRelPath(absPath) {
@@ -17642,7 +17764,7 @@ async function fetchAgentTemplateRows(accountDir) {
17642
17764
  async function unionSpecialistFilenames(overrideDir, bundledDir) {
17643
17765
  const names = /* @__PURE__ */ new Set();
17644
17766
  for (const dir of [overrideDir, bundledDir]) {
17645
- if (!existsSync25(dir)) continue;
17767
+ if (!existsSync26(dir)) continue;
17646
17768
  try {
17647
17769
  const entries = await readdir4(dir);
17648
17770
  for (const entry of entries) {
@@ -17657,7 +17779,7 @@ async function unionSpecialistFilenames(overrideDir, bundledDir) {
17657
17779
  }
17658
17780
  async function readAgentTemplateRow(inp) {
17659
17781
  let chosenPath = null;
17660
- if (existsSync25(inp.overridePath)) {
17782
+ if (existsSync26(inp.overridePath)) {
17661
17783
  try {
17662
17784
  validateFilePathInAccount(inp.overridePath, inp.overrideRoot);
17663
17785
  chosenPath = inp.overridePath;
@@ -17668,7 +17790,7 @@ async function readAgentTemplateRow(inp) {
17668
17790
  );
17669
17791
  return null;
17670
17792
  }
17671
- } else if (existsSync25(inp.bundledPath)) {
17793
+ } else if (existsSync26(inp.bundledPath)) {
17672
17794
  if (!isWithin(inp.bundledPath, inp.bundledRoot)) {
17673
17795
  console.error(
17674
17796
  `[admin/sidebar-artefacts] agent-template-read-failed agent=${inp.displayName} kind=${inp.logName} error="bundled path outside PLATFORM_ROOT"`
@@ -18271,12 +18393,12 @@ app36.get("/", async (c) => {
18271
18393
  var system_stats_default = app36;
18272
18394
 
18273
18395
  // server/routes/admin/health.ts
18274
- import { existsSync as existsSync26, readFileSync as readFileSync29 } from "fs";
18396
+ import { existsSync as existsSync27, readFileSync as readFileSync29 } from "fs";
18275
18397
  import { resolve as resolve24, join as join29 } from "path";
18276
18398
  var PLATFORM_ROOT6 = process.env.MAXY_PLATFORM_ROOT ?? resolve24(process.cwd(), "..");
18277
18399
  var brandHostname = "maxy";
18278
18400
  var brandJsonPath = join29(PLATFORM_ROOT6, "config", "brand.json");
18279
- if (existsSync26(brandJsonPath)) {
18401
+ if (existsSync27(brandJsonPath)) {
18280
18402
  try {
18281
18403
  const brand = JSON.parse(readFileSync29(brandJsonPath, "utf-8"));
18282
18404
  if (brand.hostname) brandHostname = brand.hostname;
@@ -18287,7 +18409,7 @@ var VERSION_FILE = resolve24(PLATFORM_ROOT6, `config/.${brandHostname}-version`)
18287
18409
  var PROCESS_STARTED_AT = (/* @__PURE__ */ new Date()).toISOString();
18288
18410
  var PROBE_TIMEOUT_MS = 1e3;
18289
18411
  function readVersion() {
18290
- if (!existsSync26(VERSION_FILE)) return "unknown";
18412
+ if (!existsSync27(VERSION_FILE)) return "unknown";
18291
18413
  return readFileSync29(VERSION_FILE, "utf-8").trim() || "unknown";
18292
18414
  }
18293
18415
  async function probeConversationDb() {
@@ -19064,7 +19186,7 @@ app44.post("/launch", requireAdminSession, async (c) => {
19064
19186
  var browser_default = app44;
19065
19187
 
19066
19188
  // server/routes/admin/calendar.ts
19067
- import { existsSync as existsSync27 } from "fs";
19189
+ import { existsSync as existsSync28 } from "fs";
19068
19190
  import { join as join31 } from "path";
19069
19191
 
19070
19192
  // server/lib/calendar-ics.ts
@@ -19536,7 +19658,7 @@ app45.get("/booking-link", requireAdminSession, (c) => {
19536
19658
  console.error('[calendar] op=booking-link-fail err="no account configured"');
19537
19659
  return c.json({ bookingDomain: null });
19538
19660
  }
19539
- if (!existsSync27(join31(account.accountDir, AVAILABILITY_FILENAME))) {
19661
+ if (!existsSync28(join31(account.accountDir, AVAILABILITY_FILENAME))) {
19540
19662
  console.log("[calendar] op=booking-link bookingDomain=none source=availability-config");
19541
19663
  return c.json({ bookingDomain: null });
19542
19664
  }
@@ -20084,7 +20206,7 @@ app53.route("/request-magic-link", request_magic_link_default);
20084
20206
  var access_default = app53;
20085
20207
 
20086
20208
  // server/routes/sites.ts
20087
- import { existsSync as existsSync28, readFileSync as readFileSync31, realpathSync as realpathSync6, statSync as statSync12 } from "fs";
20209
+ import { existsSync as existsSync29, readFileSync as readFileSync31, realpathSync as realpathSync6, statSync as statSync12 } from "fs";
20088
20210
  import { resolve as resolve26 } from "path";
20089
20211
  var SAFE_SEG_RE = /^[a-z0-9_][a-z0-9_.-]{0,99}$/i;
20090
20212
  var MIME = {
@@ -20150,7 +20272,7 @@ app54.get("/:rel{.*}", (c) => {
20150
20272
  }
20151
20273
  let stat8;
20152
20274
  try {
20153
- stat8 = existsSync28(filePath) ? statSync12(filePath) : null;
20275
+ stat8 = existsSync29(filePath) ? statSync12(filePath) : null;
20154
20276
  } catch {
20155
20277
  stat8 = null;
20156
20278
  }
@@ -20169,7 +20291,7 @@ app54.get("/:rel{.*}", (c) => {
20169
20291
  console.error(`[sites] path-traversal-rejected path=${reqPath} reason=escape status=403`);
20170
20292
  return c.text("Forbidden", 403);
20171
20293
  }
20172
- if (!existsSync28(filePath)) {
20294
+ if (!existsSync29(filePath)) {
20173
20295
  console.error(`[sites] not-found path=${reqPath} status=404`);
20174
20296
  return c.text("Not found", 404);
20175
20297
  }
@@ -20300,7 +20422,7 @@ var VISITOR_COOKIE_NAME = "mxy_v";
20300
20422
  var VISITOR_COOKIE_MAX_AGE_SECONDS = Math.floor(DEFAULT_TTL_MS / 1e3);
20301
20423
 
20302
20424
  // app/lib/brand-config.ts
20303
- import { existsSync as existsSync29, readFileSync as readFileSync33 } from "fs";
20425
+ import { existsSync as existsSync30, readFileSync as readFileSync33 } from "fs";
20304
20426
  import { join as join32 } from "path";
20305
20427
  var cached = null;
20306
20428
  var cachedAttempted = false;
@@ -20310,7 +20432,7 @@ function readBrandConfig() {
20310
20432
  const platformRoot3 = process.env.MAXY_PLATFORM_ROOT;
20311
20433
  if (!platformRoot3) return null;
20312
20434
  const brandPath = join32(platformRoot3, "config", "brand.json");
20313
- if (!existsSync29(brandPath)) return null;
20435
+ if (!existsSync30(brandPath)) return null;
20314
20436
  try {
20315
20437
  cached = JSON.parse(readFileSync33(brandPath, "utf-8"));
20316
20438
  return cached;
@@ -20801,7 +20923,7 @@ var visitor_event_default = app57;
20801
20923
 
20802
20924
  // server/routes/session.ts
20803
20925
  import { resolve as resolve27 } from "path";
20804
- import { existsSync as existsSync30, writeFileSync as writeFileSync12, mkdirSync as mkdirSync8 } from "fs";
20926
+ import { existsSync as existsSync31, writeFileSync as writeFileSync12, mkdirSync as mkdirSync8 } from "fs";
20805
20927
  var UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20806
20928
  function writeBrandingCache(accountId, agentSlug, branding) {
20807
20929
  try {
@@ -20885,7 +21007,7 @@ app58.post("/", async (c) => {
20885
21007
  let agentConfig = null;
20886
21008
  if (account) {
20887
21009
  const agentDir = resolve27(account.accountDir, "agents", agentSlug);
20888
- if (!existsSync30(agentDir) || !existsSync30(resolve27(agentDir, "config.json"))) {
21010
+ if (!existsSync31(agentDir) || !existsSync31(resolve27(agentDir, "config.json"))) {
20889
21011
  return c.json({ error: "Agent not found" }, 404);
20890
21012
  }
20891
21013
  agentConfig = resolveAgentConfig(account.accountDir, agentSlug);
@@ -21180,9 +21302,17 @@ function startTimeEntryCensus(openSession, opts = {}) {
21180
21302
  const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
21181
21303
  const horizonMs = opts.horizonMs ?? DEFAULT_HORIZON_MS;
21182
21304
  const tick = async () => {
21183
- const session = openSession();
21305
+ let session;
21306
+ try {
21307
+ session = openSession();
21308
+ } catch (err) {
21309
+ console.error(`${TAG39} open-session-failed error="${err instanceof Error ? err.message : String(err)}"`);
21310
+ return;
21311
+ }
21184
21312
  try {
21185
21313
  await runTimeEntryCensusOnce(session, Date.now(), horizonMs);
21314
+ } catch (err) {
21315
+ console.error(`${TAG39} tick-failed error="${err instanceof Error ? err.message : String(err)}"`);
21186
21316
  } finally {
21187
21317
  try {
21188
21318
  await session.close();
@@ -21545,7 +21675,7 @@ async function migrateUploads(opts = {}) {
21545
21675
 
21546
21676
  // app/lib/migrate-admin-webchat-sidecars.ts
21547
21677
  import { readdir as readdir6, readFile as readFile6, rename as rename3, writeFile as writeFile4, unlink as unlink3 } from "fs/promises";
21548
- import { existsSync as existsSync31 } from "fs";
21678
+ import { existsSync as existsSync32 } from "fs";
21549
21679
  import { join as join33 } from "path";
21550
21680
  var ADMIN_ROLE2 = "admin";
21551
21681
  var WEBCHAT_CHANNEL2 = "webchat";
@@ -21575,7 +21705,7 @@ async function writeRaw(path2, obj) {
21575
21705
  await rename3(tmp, path2);
21576
21706
  } catch (err) {
21577
21707
  try {
21578
- if (existsSync31(tmp)) await unlink3(tmp);
21708
+ if (existsSync32(tmp)) await unlink3(tmp);
21579
21709
  } catch {
21580
21710
  }
21581
21711
  throw err;
@@ -22073,7 +22203,24 @@ function createWaChannelRoutes(deps) {
22073
22203
  if (typeof senderId !== "string" || typeof sessionId !== "string" || typeof replied !== "boolean") {
22074
22204
  return c.json({ error: "senderId, sessionId and replied required" }, 400);
22075
22205
  }
22206
+ const interactive = body?.interactive === true;
22207
+ const finalText = typeof body?.finalText === "string" ? body.finalText : "";
22076
22208
  const sid = sessionId.slice(0, 8);
22209
+ const deliver = interactive && finalText.trim().length > 0;
22210
+ if (deliver) {
22211
+ const bytes = Buffer.byteLength(finalText, "utf8");
22212
+ try {
22213
+ await deps.sendOutbound(senderId, finalText);
22214
+ } catch (err) {
22215
+ const message = err instanceof Error ? err.message : String(err);
22216
+ console.error(
22217
+ `[whatsapp-native] op=turn-strand-anomaly senderId=${senderId} sessionId=${sid} interactive=yes bytes=${bytes} delivered=undelivered error=${message}`
22218
+ );
22219
+ return c.json({ ok: true, delivered: "undelivered" });
22220
+ }
22221
+ console.error(`[whatsapp-native] op=turn-end sessionId=${sid} replied=${replied ? "yes" : "no"} delivered=fallback bytes=${bytes}`);
22222
+ return c.json({ ok: true, delivered: "fallback" });
22223
+ }
22077
22224
  if (replied) {
22078
22225
  console.error(`[whatsapp-native] op=turn-end sessionId=${sid} replied=yes delivered=reply`);
22079
22226
  return c.json({ ok: true, delivered: "reply" });
@@ -22194,6 +22341,11 @@ var WaGateway = class {
22194
22341
  console.error(
22195
22342
  `[whatsapp-native] op=inbound senderId=${input.senderId} accountId=${input.accountId} bytes=${bytes} ${mediaField} source=${source} delivery=${willDeliverNow ? "immediate" : "queued"}`
22196
22343
  );
22344
+ if (source === "schedule" && (hadSubscriber || this.spawning.has(input.senderId))) {
22345
+ console.error(
22346
+ `[whatsapp-native] op=coalesced-onto-live-session eventId=${input.scheduleProvenance?.eventId ?? "-"} senderId=${input.senderId} droppedSeat=${input.scheduledSessionId ?? "-"} reason=${hadSubscriber ? "live-subscriber" : "spawn-in-flight"}`
22347
+ );
22348
+ }
22197
22349
  if (!hadSubscriber && !this.spawning.has(input.senderId)) {
22198
22350
  this.spawning.add(input.senderId);
22199
22351
  console.error(`[whatsapp-native] op=spawn-trigger senderId=${input.senderId}`);
@@ -22500,17 +22652,20 @@ function createWebchatChannelRoutes(deps) {
22500
22652
  if (typeof key !== "string" || typeof sessionId !== "string" || typeof replied !== "boolean") {
22501
22653
  return c.json({ error: "key, sessionId and replied required" }, 400);
22502
22654
  }
22655
+ const interactive = body?.interactive === true;
22656
+ const finalBytes = typeof body?.finalBytes === "number" && Number.isFinite(body.finalBytes) ? body.finalBytes : 0;
22503
22657
  const k = key.slice(0, 8);
22504
22658
  const sid = sessionId.slice(0, 8);
22659
+ if (interactive && finalBytes > 0) {
22660
+ console.error(`[webchat-native] op=turn-end channel=webchat key=${k} sessionId=${sid} replied=${replied ? "yes" : "no"} delivered=fallback bytes=${finalBytes}`);
22661
+ return c.json({ ok: true, delivered: "fallback" });
22662
+ }
22505
22663
  if (replied) {
22664
+ console.error(`[webchat-native] op=turn-end channel=webchat key=${k} sessionId=${sid} delivered=reply`);
22506
22665
  return c.json({ ok: true, delivered: "reply" });
22507
22666
  }
22508
- const finalBytes = typeof body?.finalBytes === "number" && Number.isFinite(body.finalBytes) ? body.finalBytes : 0;
22509
- if (finalBytes <= 0) {
22510
- return c.json({ ok: true, delivered: "none" });
22511
- }
22512
- console.error(`[webchat-native] op=turn-undelivered channel=webchat key=${k} sessionId=${sid} bytes=${finalBytes}`);
22513
- return c.json({ ok: true, delivered: "undelivered" });
22667
+ console.error(`[webchat-native] op=turn-end channel=webchat key=${k} sessionId=${sid} delivered=none`);
22668
+ return c.json({ ok: true, delivered: "none" });
22514
22669
  });
22515
22670
  return app61;
22516
22671
  }
@@ -23443,7 +23598,24 @@ function createTelegramChannelRoutes(deps) {
23443
23598
  if (typeof key !== "string" || typeof sessionId !== "string" || typeof replied !== "boolean") {
23444
23599
  return c.json({ error: "key, sessionId and replied required" }, 400);
23445
23600
  }
23601
+ const interactive = body?.interactive === true;
23602
+ const finalText = typeof body?.finalText === "string" ? body.finalText : "";
23446
23603
  const sid = sessionId.slice(0, 8);
23604
+ const deliver = interactive && finalText.trim().length > 0;
23605
+ if (deliver) {
23606
+ const bytes = Buffer.byteLength(finalText, "utf8");
23607
+ try {
23608
+ await deps.sendOutbound(key, finalText);
23609
+ } catch (err) {
23610
+ const message = err instanceof Error ? err.message : String(err);
23611
+ console.error(
23612
+ `[telegram-native] op=turn-strand-anomaly key=${key} sessionId=${sid} interactive=yes bytes=${bytes} delivered=undelivered error=${message}`
23613
+ );
23614
+ return c.json({ ok: true, delivered: "undelivered" });
23615
+ }
23616
+ console.error(`[telegram-native] op=turn-end key=${key} sessionId=${sid} replied=${replied ? "yes" : "no"} delivered=fallback bytes=${bytes}`);
23617
+ return c.json({ ok: true, delivered: "fallback" });
23618
+ }
23447
23619
  if (replied) {
23448
23620
  console.error(`[telegram-native] op=turn-end key=${key} sessionId=${sid} replied=yes delivered=reply`);
23449
23621
  return c.json({ ok: true, delivered: "reply" });
@@ -23524,6 +23696,11 @@ var TelegramGateway = class {
23524
23696
  console.error(
23525
23697
  `[telegram-native] op=inbound key=${input.senderId} accountId=${input.accountId} botType=${input.role} bytes=${bytes} source=${source} delivery=${willDeliverNow ? "immediate" : "queued"}`
23526
23698
  );
23699
+ if (source === "schedule" && (hadSubscriber || this.spawning.has(input.senderId))) {
23700
+ console.error(
23701
+ `[telegram-native] op=coalesced-onto-live-session eventId=${input.scheduleProvenance?.eventId ?? "-"} senderId=${input.senderId} droppedSeat=${input.scheduledSessionId ?? "-"} reason=${hadSubscriber ? "live-subscriber" : "spawn-in-flight"}`
23702
+ );
23703
+ }
23527
23704
  if (!hadSubscriber && !this.spawning.has(input.senderId)) {
23528
23705
  this.spawning.add(input.senderId);
23529
23706
  console.error(`[telegram-native] op=spawn-trigger key=${input.senderId} role=${input.role}`);
@@ -23977,8 +24154,8 @@ function broadcastAdminShutdown(reason) {
23977
24154
  }
23978
24155
 
23979
24156
  // ../lib/entitlement/src/index.ts
23980
- import { createPublicKey, createHash as createHash5, verify as cryptoVerify } from "crypto";
23981
- import { existsSync as existsSync32, readFileSync as readFileSync34, statSync as statSync13 } from "fs";
24157
+ import { createPublicKey, createHash as createHash6, verify as cryptoVerify } from "crypto";
24158
+ import { existsSync as existsSync33, readFileSync as readFileSync34, statSync as statSync13 } from "fs";
23982
24159
  import { resolve as resolve32 } from "path";
23983
24160
 
23984
24161
  // ../lib/entitlement/src/canonicalize.ts
@@ -24027,7 +24204,7 @@ function resolveEntitlement(brand, account) {
24027
24204
  return logResolved(implicitTrust(account), null);
24028
24205
  }
24029
24206
  const entitlementPath = resolve32(brand.configDir, "entitlement.json");
24030
- if (!existsSync32(entitlementPath)) {
24207
+ if (!existsSync33(entitlementPath)) {
24031
24208
  return logResolved(anonymousFallback("missing"), { reason: "missing" });
24032
24209
  }
24033
24210
  const stat8 = statSync13(entitlementPath);
@@ -24048,7 +24225,7 @@ function verifyAndResolve(brand, entitlementPath, account) {
24048
24225
  reason: "pubkey-missing"
24049
24226
  });
24050
24227
  }
24051
- const pubkeyHash = createHash5("sha256").update(pubkeyPem).digest("hex");
24228
+ const pubkeyHash = createHash6("sha256").update(pubkeyPem).digest("hex");
24052
24229
  if (pubkeyHash !== PUBKEY_SHA256) {
24053
24230
  return logResolved(anonymousFallback("pubkey-tamper"), {
24054
24231
  reason: "pubkey-tamper"
@@ -24219,10 +24396,10 @@ function clientFrom(c) {
24219
24396
  var PLATFORM_ROOT8 = process.env.MAXY_PLATFORM_ROOT || "";
24220
24397
  var BRAND_JSON_PATH = PLATFORM_ROOT8 ? join35(PLATFORM_ROOT8, "config", "brand.json") : "";
24221
24398
  var BRAND = { productName: "Maxy", hostname: "maxy", configDir: ".maxy", marketingUrl: "https://getmaxy.com" };
24222
- if (BRAND_JSON_PATH && !existsSync33(BRAND_JSON_PATH)) {
24399
+ if (BRAND_JSON_PATH && !existsSync34(BRAND_JSON_PATH)) {
24223
24400
  console.error(`[brand] WARNING: brand.json not found at ${BRAND_JSON_PATH} \u2014 using Maxy defaults`);
24224
24401
  }
24225
- if (BRAND_JSON_PATH && existsSync33(BRAND_JSON_PATH)) {
24402
+ if (BRAND_JSON_PATH && existsSync34(BRAND_JSON_PATH)) {
24226
24403
  try {
24227
24404
  const parsed = JSON.parse(readFileSync35(BRAND_JSON_PATH, "utf-8"));
24228
24405
  BRAND = { ...BRAND, ...parsed };
@@ -24259,7 +24436,7 @@ var brandLoginOpts = {
24259
24436
  var ALIAS_DOMAINS_PATH = join35(homedir3(), BRAND.configDir, "alias-domains.json");
24260
24437
  function loadAliasDomains() {
24261
24438
  try {
24262
- if (!existsSync33(ALIAS_DOMAINS_PATH)) return null;
24439
+ if (!existsSync34(ALIAS_DOMAINS_PATH)) return null;
24263
24440
  const parsed = JSON.parse(readFileSync35(ALIAS_DOMAINS_PATH, "utf-8"));
24264
24441
  if (!Array.isArray(parsed)) {
24265
24442
  console.error("[alias-domains] malformed alias-domains.json \u2014 expected array");
@@ -24344,7 +24521,7 @@ var waGateway = new WaGateway({
24344
24521
  },
24345
24522
  ensureChannelSession: async ({ accountId, effectiveAccountId, senderId, role, personId, gatewayUrl, serverPath, passive, text, msgId, sessionIdOverride }) => {
24346
24523
  if (passive) {
24347
- const phoneHash = createHash6("sha256").update(senderId).digest("hex").slice(0, 12);
24524
+ const phoneHash = createHash7("sha256").update(senderId).digest("hex").slice(0, 12);
24348
24525
  const prompt = (text ?? "").trim();
24349
24526
  if (prompt.length === 0) {
24350
24527
  console.error(`[wa-passive] op=dropped account=${effectiveAccountId} phoneHash=${phoneHash} msgId=${msgId ?? "none"} reason=empty-text`);
@@ -24941,7 +25118,7 @@ app60.get("/agent-assets/:slug/:filename", (c) => {
24941
25118
  console.error(`[agent-assets] path-traversal-rejected slug=${slug} file=${filename}`);
24942
25119
  return c.text("Forbidden", 403);
24943
25120
  }
24944
- if (!existsSync33(filePath)) {
25121
+ if (!existsSync34(filePath)) {
24945
25122
  console.error(`[agent-assets] serve slug=${slug} file=${filename} status=404`);
24946
25123
  return c.text("Not found", 404);
24947
25124
  }
@@ -24971,7 +25148,7 @@ app60.get("/generated/:filename", (c) => {
24971
25148
  console.error(`[generated] serve file=${filename} status=403`);
24972
25149
  return c.text("Forbidden", 403);
24973
25150
  }
24974
- if (!existsSync33(filePath)) {
25151
+ if (!existsSync34(filePath)) {
24975
25152
  console.error(`[generated] serve file=${filename} status=404`);
24976
25153
  return c.text("Not found", 404);
24977
25154
  }
@@ -24991,7 +25168,7 @@ app60.route("/v", visitor_consent_default);
24991
25168
  var htmlCache = /* @__PURE__ */ new Map();
24992
25169
  var brandLogoPath = "/brand/maxy-monochrome.png";
24993
25170
  var brandIconPath = "/brand/maxy-monochrome.png";
24994
- if (BRAND_JSON_PATH && existsSync33(BRAND_JSON_PATH)) {
25171
+ if (BRAND_JSON_PATH && existsSync34(BRAND_JSON_PATH)) {
24995
25172
  try {
24996
25173
  const fullBrand = JSON.parse(readFileSync35(BRAND_JSON_PATH, "utf-8"));
24997
25174
  if (fullBrand.assets?.logo) brandLogoPath = `/brand/${fullBrand.assets.logo}`;
@@ -25020,7 +25197,7 @@ var brandScript = `<script>window.__BRAND__=${JSON.stringify({
25020
25197
  var brandThemeColor = BRAND.defaultColors?.primary ?? "#000000";
25021
25198
  var brandBackgroundColor = BRAND.defaultColors?.background ?? "#ffffff";
25022
25199
  var brandAppIconsExist = [brandAppIcon192Path, brandAppIcon512Path, brandMaskableIconPath].every(
25023
- (p) => existsSync33(resolve33(process.cwd(), "public", p.replace(/^\//, "")))
25200
+ (p) => existsSync34(resolve33(process.cwd(), "public", p.replace(/^\//, "")))
25024
25201
  );
25025
25202
  var SW_SOURCE = (() => {
25026
25203
  try {
@@ -25033,7 +25210,7 @@ function readInstalledVersion() {
25033
25210
  try {
25034
25211
  if (!PLATFORM_ROOT8) return "unknown";
25035
25212
  const versionFile = join35(PLATFORM_ROOT8, "config", `.${BRAND.hostname}-version`);
25036
- if (!existsSync33(versionFile)) return "unknown";
25213
+ if (!existsSync34(versionFile)) return "unknown";
25037
25214
  const content = readFileSync35(versionFile, "utf-8").trim();
25038
25215
  return content || "unknown";
25039
25216
  } catch {
@@ -25097,7 +25274,7 @@ function loadBrandingCache(agentSlug) {
25097
25274
  const accountId = getDefaultAccountId();
25098
25275
  if (!accountId) return null;
25099
25276
  const cachePath = join35(configDir2, "branding-cache", accountId, `${agentSlug}.json`);
25100
- if (!existsSync33(cachePath)) return null;
25277
+ if (!existsSync34(cachePath)) return null;
25101
25278
  return JSON.parse(readFileSync35(cachePath, "utf-8"));
25102
25279
  } catch {
25103
25280
  return null;
@@ -25324,7 +25501,7 @@ console.log(`${BRAND.productName} listening on http://${hostname}:${port}`);
25324
25501
  const reconcileScript = resolve33(reconcilePlatformRoot, "plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js");
25325
25502
  const RECONCILE_INTERVAL_MS = 12e4;
25326
25503
  const runReconcile = () => {
25327
- if (!existsSync33(reconcileScript)) return;
25504
+ if (!existsSync34(reconcileScript)) return;
25328
25505
  try {
25329
25506
  const child = spawn3(process.execPath, [reconcileScript], {
25330
25507
  env: { ...process.env, PLATFORM_ROOT: reconcilePlatformRoot },
@@ -25348,7 +25525,7 @@ startTimeEntryCensus(() => getSession());
25348
25525
  const auditLogDir = process.env.LOG_DIR ?? LOG_DIR;
25349
25526
  const CONNECTOR_AUDIT_INTERVAL_MS = 3e5;
25350
25527
  const runConnectorAudit = () => {
25351
- if (!existsSync33(auditScript)) return;
25528
+ if (!existsSync34(auditScript)) return;
25352
25529
  try {
25353
25530
  const child = spawn3(process.execPath, [auditScript], {
25354
25531
  env: { ...process.env, PLATFORM_ROOT: auditPlatformRoot, LOG_DIR: auditLogDir },
@@ -25458,7 +25635,7 @@ try {
25458
25635
  var ADMINUSER_RECONCILE_INTERVAL_MS = 60 * 60 * 1e3;
25459
25636
  async function runAdminUserReconcileTick() {
25460
25637
  try {
25461
- if (!existsSync33(USERS_FILE)) {
25638
+ if (!existsSync34(USERS_FILE)) {
25462
25639
  console.error("[adminuser-self-heal] skip reason=no-users-file");
25463
25640
  return;
25464
25641
  }
@@ -25491,7 +25668,7 @@ var adminUserReconcileTimer = setInterval(() => {
25491
25668
  if (typeof adminUserReconcileTimer.unref === "function") adminUserReconcileTimer.unref();
25492
25669
  var USERS_RECONCILE_INTERVAL_MS = 60 * 60 * 1e3;
25493
25670
  function countUsersRows() {
25494
- if (!existsSync33(USERS_FILE)) return 0;
25671
+ if (!existsSync34(USERS_FILE)) return 0;
25495
25672
  const raw = readFileSync35(USERS_FILE, "utf-8").trim();
25496
25673
  if (!raw) return 0;
25497
25674
  const users = JSON.parse(raw);