heyhank 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -10
  3. package/bin/cli.ts +7 -7
  4. package/bin/ctl.ts +42 -42
  5. package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
  6. package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
  7. package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
  9. package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
  11. package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
  12. package/dist/assets/MediaPage-C48HTTrt.js +1 -0
  13. package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
  18. package/dist/assets/RunsPage-B9UOyO79.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
  20. package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
  21. package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
  22. package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
  24. package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
  28. package/dist/assets/index-BkjSoVgn.css +32 -0
  29. package/dist/assets/sw-register-C7NOHtIu.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/index.html +2 -2
  32. package/dist/sw.js +1 -1
  33. package/package.json +6 -1
  34. package/server/agent-executor.ts +37 -2
  35. package/server/agent-store.ts +3 -3
  36. package/server/agent-types.ts +11 -0
  37. package/server/assistant-store.ts +232 -6
  38. package/server/auth-manager.ts +9 -0
  39. package/server/cache-headers.ts +1 -1
  40. package/server/calendar-service.ts +10 -0
  41. package/server/ceo/document-store.ts +129 -0
  42. package/server/ceo/finance-store.ts +343 -0
  43. package/server/ceo/kpi-store.ts +208 -0
  44. package/server/ceo/memory-import.ts +277 -0
  45. package/server/ceo/news-store.ts +208 -0
  46. package/server/ceo/template-store.ts +134 -0
  47. package/server/ceo/time-tracking-store.ts +227 -0
  48. package/server/claude-auth-monitor.ts +128 -0
  49. package/server/claude-code-worker.ts +86 -0
  50. package/server/claude-session-discovery.ts +74 -1
  51. package/server/cli-launcher.ts +32 -10
  52. package/server/codex-adapter.ts +2 -2
  53. package/server/codex-ws-proxy.cjs +1 -1
  54. package/server/container-manager.ts +4 -4
  55. package/server/content-intelligence/content-engine.ts +1112 -0
  56. package/server/content-intelligence/platform-knowledge.ts +870 -0
  57. package/server/cron-store.ts +3 -3
  58. package/server/embedding-service.ts +49 -0
  59. package/server/event-bus-types.ts +13 -0
  60. package/server/federation/node-store.ts +5 -4
  61. package/server/fs-utils.ts +28 -1
  62. package/server/hank-notifications-store.ts +91 -0
  63. package/server/hank-tool-executor.ts +1835 -0
  64. package/server/hank-tools.ts +2107 -0
  65. package/server/image-pull-manager.ts +2 -2
  66. package/server/index.ts +25 -2
  67. package/server/llm-providers-streaming.ts +541 -0
  68. package/server/llm-providers.ts +12 -0
  69. package/server/marketplace.ts +249 -0
  70. package/server/mcp-registry.ts +158 -0
  71. package/server/memory-service.ts +296 -0
  72. package/server/obsidian-sync.ts +184 -0
  73. package/server/provider-manager.ts +5 -2
  74. package/server/provider-registry.ts +12 -0
  75. package/server/reminder-scheduler.ts +37 -1
  76. package/server/routes/agent-routes.ts +2 -1
  77. package/server/routes/assistant-routes.ts +198 -5
  78. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  79. package/server/routes/ceo-news-time-routes.ts +137 -0
  80. package/server/routes/ceo-routes.ts +99 -0
  81. package/server/routes/content-routes.ts +116 -0
  82. package/server/routes/email-routes.ts +147 -0
  83. package/server/routes/env-routes.ts +3 -3
  84. package/server/routes/fs-routes.ts +12 -9
  85. package/server/routes/hank-chat-routes.ts +592 -0
  86. package/server/routes/llm-routes.ts +12 -0
  87. package/server/routes/marketplace-routes.ts +63 -0
  88. package/server/routes/media-routes.ts +1 -1
  89. package/server/routes/memory-routes.ts +127 -0
  90. package/server/routes/platform-routes.ts +14 -675
  91. package/server/routes/sandbox-routes.ts +1 -1
  92. package/server/routes/settings-routes.ts +51 -1
  93. package/server/routes/socialmedia-routes.ts +152 -2
  94. package/server/routes/system-routes.ts +2 -2
  95. package/server/routes/team-routes.ts +71 -0
  96. package/server/routes/telephony-routes.ts +98 -18
  97. package/server/routes.ts +36 -9
  98. package/server/session-creation-service.ts +2 -2
  99. package/server/session-orchestrator.ts +54 -2
  100. package/server/session-types.ts +2 -0
  101. package/server/settings-manager.ts +50 -2
  102. package/server/skill-discovery.ts +68 -0
  103. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  104. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  105. package/server/socialmedia/manager.ts +234 -15
  106. package/server/socialmedia/store.ts +51 -1
  107. package/server/socialmedia/types.ts +35 -2
  108. package/server/socialview/browser-manager.ts +150 -0
  109. package/server/socialview/extractors.ts +1298 -0
  110. package/server/socialview/image-describe.ts +188 -0
  111. package/server/socialview/library.ts +119 -0
  112. package/server/socialview/poster.ts +276 -0
  113. package/server/socialview/routes.ts +371 -0
  114. package/server/socialview/style-analyzer.ts +187 -0
  115. package/server/socialview/style-profiles.ts +67 -0
  116. package/server/socialview/types.ts +166 -0
  117. package/server/socialview/vision.ts +127 -0
  118. package/server/socialview/vnc-manager.ts +110 -0
  119. package/server/style-injector.ts +135 -0
  120. package/server/team-service.ts +239 -0
  121. package/server/team-store.ts +75 -0
  122. package/server/team-types.ts +52 -0
  123. package/server/telephony/audio-bridge.ts +281 -35
  124. package/server/telephony/audio-recorder.ts +132 -0
  125. package/server/telephony/call-manager.ts +803 -104
  126. package/server/telephony/call-types.ts +67 -1
  127. package/server/telephony/esl-client.ts +319 -0
  128. package/server/telephony/freeswitch-sync.ts +155 -0
  129. package/server/telephony/phone-utils.ts +63 -0
  130. package/server/telephony/telephony-store.ts +9 -8
  131. package/server/url-validator.ts +82 -0
  132. package/server/vault-markdown.ts +317 -0
  133. package/server/vault-migration.ts +121 -0
  134. package/server/vault-store.ts +466 -0
  135. package/server/vault-watcher.ts +59 -0
  136. package/server/vector-store.ts +210 -0
  137. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  138. package/server/voice-pipeline/greeting-cache.ts +200 -0
  139. package/server/voice-pipeline/manager.ts +249 -0
  140. package/server/voice-pipeline/pipeline.ts +335 -0
  141. package/server/voice-pipeline/providers/index.ts +47 -0
  142. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  143. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  144. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  145. package/server/voice-pipeline/types.ts +247 -0
  146. package/server/ws-bridge-types.ts +6 -1
  147. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  148. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  149. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  150. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  151. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  152. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  153. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  154. package/dist/assets/index-C8M_PUmX.css +0 -32
  155. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  156. package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
@@ -4,8 +4,160 @@
4
4
  // Auth: raw API key in Authorization header (no Bearer prefix).
5
5
  // API key found at: Settings → Developers → Public API
6
6
 
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import sharp from "sharp";
7
10
  import type { SocialMediaAdapter } from "../adapter.js";
8
11
  import type { SocialProfile, CreatePostInput, PostAnalytics, AccountAnalytics, SocialComment, SocialPlatform } from "../types.js";
12
+ import { getSettings as getAppSettings } from "../../settings-manager.js";
13
+ import { HEYHANK_HOME } from "../../paths.js";
14
+
15
+ const MIME_BY_EXT: Record<string, string> = {
16
+ png: "image/png",
17
+ jpg: "image/jpeg",
18
+ jpeg: "image/jpeg",
19
+ gif: "image/gif",
20
+ webp: "image/webp",
21
+ mp4: "video/mp4",
22
+ mov: "video/quicktime",
23
+ webm: "video/webm",
24
+ };
25
+
26
+ /**
27
+ * Detect the true MIME type from the file's magic bytes. Some generators
28
+ * (e.g. Google's nanobanana MCP) save JPEG content under a `.png` filename,
29
+ * which causes IG/X/Meta to reject the post because the declared content
30
+ * type doesn't match the actual bytes. Always trust the bytes over the name.
31
+ * Returns null if no known signature matches — caller falls back to extension.
32
+ */
33
+ function detectMimeFromBytes(buf: Uint8Array): string | null {
34
+ if (buf.length < 12) return null;
35
+ // JPEG: FF D8 FF
36
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
37
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
38
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
39
+ // GIF: 47 49 46 38
40
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return "image/gif";
41
+ // WebP: RIFF....WEBP
42
+ if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
43
+ && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return "image/webp";
44
+ // MP4 (ISO BMFF): bytes 4-7 = "ftyp"
45
+ if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return "video/mp4";
46
+ return null;
47
+ }
48
+
49
+ // Instagram feed aspect ratio bounds (width / height).
50
+ // Spec is [0.8, 1.91] but we leave a safety margin to avoid rejections at
51
+ // the exact boundary (Instagram occasionally rounds and rejects 0.800).
52
+ const IG_ASPECT_MIN = 0.81;
53
+ const IG_ASPECT_MAX = 1.90;
54
+
55
+ // Canonical IG sizes — pad/resize to one of these so IG always accepts the
56
+ // container creation request.
57
+ const IG_PORTRAIT_W = 1080;
58
+ const IG_PORTRAIT_H = 1350; // 4:5
59
+ const IG_SQUARE = 1080;
60
+ const IG_LANDSCAPE_W = 1080;
61
+ const IG_LANDSCAPE_H = 566; // ~1.91:1
62
+
63
+ /**
64
+ * Normalize an image so Instagram's Graph API will accept it.
65
+ *
66
+ * Instagram returns the misleading error "Media fetch failed, please try
67
+ * again" (error code 2207052) for several distinct reasons, all of which
68
+ * we hit in practice:
69
+ * 1. Aspect ratio outside [0.8, 1.91] (NB2 default 896×1200 = 0.747).
70
+ * 2. JPEG written without a JFIF/Exif APP marker (Sharp's default
71
+ * output via `.jpeg()` after `.extend()`).
72
+ * 3. Non-sRGB color space.
73
+ *
74
+ * To eliminate all three at once, we always re-encode through Sharp into
75
+ * a canonical IG size (1080×1350 portrait, 1080×1080 square, 1080×566
76
+ * landscape) as sRGB JPEG with explicit metadata. Padded background is
77
+ * white. This is only invoked when Instagram is among the target
78
+ * platforms, so other platforms still see the user's original image.
79
+ */
80
+ async function normalizeForInstagram(
81
+ buf: Buffer,
82
+ inMime: string,
83
+ ): Promise<{ buffer: Buffer; mime: string; changed: boolean }> {
84
+ if (!inMime.startsWith("image/")) return { buffer: buf, mime: inMime, changed: false };
85
+ // GIFs: IG doesn't accept GIF in feed, and Sharp would lose animation
86
+ // frames anyway. Pass through untouched.
87
+ if (inMime === "image/gif") return { buffer: buf, mime: inMime, changed: false };
88
+
89
+ const meta = await sharp(buf).metadata();
90
+ const w = meta.width ?? 0;
91
+ const h = meta.height ?? 0;
92
+ if (!w || !h) return { buffer: buf, mime: inMime, changed: false };
93
+
94
+ const aspect = w / h;
95
+
96
+ // Choose the closest canonical canvas based on the original aspect.
97
+ let targetW: number;
98
+ let targetH: number;
99
+ if (aspect < IG_ASPECT_MIN || aspect < 0.95) {
100
+ targetW = IG_PORTRAIT_W;
101
+ targetH = IG_PORTRAIT_H;
102
+ } else if (aspect > IG_ASPECT_MAX || aspect > 1.5) {
103
+ targetW = IG_LANDSCAPE_W;
104
+ targetH = IG_LANDSCAPE_H;
105
+ } else {
106
+ targetW = IG_SQUARE;
107
+ targetH = IG_SQUARE;
108
+ }
109
+
110
+ // `fit: contain` resizes the original to fit inside the target canvas
111
+ // and pads the rest with the background color (white). No cropping.
112
+ const out = await sharp(buf)
113
+ .resize({
114
+ width: targetW,
115
+ height: targetH,
116
+ fit: "contain",
117
+ background: { r: 255, g: 255, b: 255, alpha: 1 },
118
+ })
119
+ .toColorspace("srgb")
120
+ .jpeg({
121
+ quality: 90,
122
+ chromaSubsampling: "4:2:0",
123
+ // `force: true` guarantees JPEG output even if input was PNG/WebP.
124
+ force: true,
125
+ })
126
+ .withMetadata({ density: 72 })
127
+ .toBuffer();
128
+
129
+ return { buffer: out, mime: "image/jpeg", changed: true };
130
+ }
131
+
132
+ function extForMime(mime: string): string {
133
+ switch (mime) {
134
+ case "image/jpeg": return "jpg";
135
+ case "image/png": return "png";
136
+ case "image/gif": return "gif";
137
+ case "image/webp": return "webp";
138
+ case "video/mp4": return "mp4";
139
+ case "video/quicktime": return "mov";
140
+ case "video/webm": return "webm";
141
+ default: return "bin";
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Detect URLs that point to HeyHank's own media route. Works for both
147
+ * absolute URLs (any host, e.g. the publicUrl) and relative paths.
148
+ * Returns the bare filename if matched, else null.
149
+ */
150
+ function extractLocalMediaFilename(url: string): string | null {
151
+ try {
152
+ // Normalize: strip protocol+host if present
153
+ const withoutHost = url.replace(/^https?:\/\/[^/]+/i, "");
154
+ const match = withoutHost.match(/^\/api\/media\/file\/([^/?#]+)/);
155
+ if (!match) return null;
156
+ return basename(decodeURIComponent(match[1]));
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
9
161
 
10
162
  const HOSTED_API = "https://api.postiz.com";
11
163
 
@@ -125,42 +277,167 @@ export class PostizAdapter implements SocialMediaAdapter {
125
277
  return { id: null, status: "failed", backendData: { error: "No connected integrations match the selected platforms" } };
126
278
  }
127
279
 
128
- // Upload media from URLs if present
280
+ // Upload media. Two paths:
281
+ // 1. Local HeyHank media (/api/media/file/<filename>): read from disk
282
+ // and upload as multipart — avoids Postiz hitting our Basic-Auth
283
+ // protected HTTP endpoint (which returns 500 / auth challenge).
284
+ // 2. Other URLs: use Postiz /upload-from-url so Postiz fetches directly.
285
+ const publicUrl = getAppSettings().publicUrl?.replace(/\/+$/, "") ?? "";
129
286
  const mediaItems: Array<{ id: string; path: string }> = [];
130
- if (input.mediaUrls?.length) {
131
- for (const mediaUrl of input.mediaUrls) {
287
+ const mediaUploadErrors: string[] = [];
288
+
289
+ for (const mediaUrl of input.mediaUrls ?? []) {
290
+ if (!mediaUrl) continue;
291
+
292
+ // ── Path 1: local media file ──────────────────────────────────────
293
+ const localFilename = extractLocalMediaFilename(mediaUrl);
294
+ if (localFilename) {
295
+ const localPath = join(HEYHANK_HOME, "media", localFilename);
296
+ if (!existsSync(localPath)) {
297
+ mediaUploadErrors.push(`Local media file not found: ${localFilename}`);
298
+ continue;
299
+ }
132
300
  try {
133
- const uploadRes = await fetch(this.url("/upload-from-url"), {
301
+ const rawBuf = readFileSync(localPath);
302
+ const extFromName = localFilename.split(".").pop()?.toLowerCase() ?? "";
303
+ // Prefer magic-byte detection over extension. Some image generators
304
+ // (e.g. nanobanana MCP) save JPEG content with a `.png` name, which
305
+ // makes IG reject the post ("Media fetch failed") and X fail with
306
+ // "bad_body" because the declared vs actual format disagree.
307
+ const sniffedMime = detectMimeFromBytes(rawBuf);
308
+ const baseMime = sniffedMime ?? MIME_BY_EXT[extFromName] ?? "application/octet-stream";
309
+
310
+ // If Instagram is one of the targets, ensure the image aspect ratio
311
+ // is within IG's accepted range. NB2 generates 896×1200 (0.747:1)
312
+ // by default which IG rejects as "Media fetch failed". Pad to 4:5.
313
+ let fileBuf: Buffer = rawBuf;
314
+ let mime = baseMime;
315
+ let normalized = false;
316
+ if (input.platforms.includes("instagram")) {
317
+ const norm = await normalizeForInstagram(rawBuf, baseMime);
318
+ fileBuf = norm.buffer;
319
+ mime = norm.mime;
320
+ normalized = norm.changed;
321
+ }
322
+
323
+ // If the on-disk extension disagrees with the sniffed (or
324
+ // re-encoded) content, rename the upload filename to the correct
325
+ // extension so that Postiz / downstream CDNs serve it with a
326
+ // matching Content-Type.
327
+ const finalSniff = detectMimeFromBytes(fileBuf) ?? mime;
328
+ const correctExt = finalSniff ? extForMime(finalSniff) : extFromName;
329
+ const stem = localFilename.replace(/\.[^.]+$/, "");
330
+ const uploadName = normalized
331
+ ? `${stem}_ig${correctExt ? `.${correctExt}` : ""}`
332
+ : correctExt && correctExt !== extFromName
333
+ ? `${stem}.${correctExt}`
334
+ : localFilename;
335
+ const form = new FormData();
336
+ form.append("file", new Blob([new Uint8Array(fileBuf)], { type: mime }), uploadName);
337
+
338
+ // Do NOT set Content-Type — fetch/Blob sets the multipart boundary.
339
+ const uploadRes = await fetch(this.url("/upload"), {
134
340
  method: "POST",
135
- headers: this.headers(),
136
- body: JSON.stringify({ url: mediaUrl }),
341
+ headers: { Authorization: this.apiKey },
342
+ body: form,
137
343
  });
138
344
  if (uploadRes.ok) {
139
345
  const uploadData = await uploadRes.json();
140
346
  if (uploadData.id && uploadData.path) {
141
347
  mediaItems.push({ id: uploadData.id, path: uploadData.path });
348
+ } else {
349
+ mediaUploadErrors.push(`Upload of "${localFilename}" returned unexpected payload`);
142
350
  }
351
+ } else {
352
+ const errText = await uploadRes.text().catch(() => "");
353
+ mediaUploadErrors.push(`Upload of "${localFilename}" failed: ${uploadRes.status} ${errText || uploadRes.statusText}`);
354
+ }
355
+ } catch (err: any) {
356
+ mediaUploadErrors.push(`Upload of "${localFilename}" threw: ${err?.message ?? "unknown"}`);
357
+ }
358
+ continue;
359
+ }
360
+
361
+ // ── Path 2: external URL via upload-from-url ──────────────────────
362
+ let absoluteUrl: string | null = null;
363
+ if (/^https?:\/\//i.test(mediaUrl)) {
364
+ absoluteUrl = mediaUrl;
365
+ } else if (mediaUrl.startsWith("/")) {
366
+ absoluteUrl = publicUrl ? `${publicUrl}${mediaUrl}` : null;
367
+ } else {
368
+ absoluteUrl = mediaUrl;
369
+ }
370
+ if (!absoluteUrl) {
371
+ mediaUploadErrors.push(`Cannot resolve media URL "${mediaUrl}" — publicUrl not configured`);
372
+ continue;
373
+ }
374
+ try {
375
+ const uploadRes = await fetch(this.url("/upload-from-url"), {
376
+ method: "POST",
377
+ headers: this.headers(),
378
+ body: JSON.stringify({ url: absoluteUrl }),
379
+ });
380
+ if (uploadRes.ok) {
381
+ const uploadData = await uploadRes.json();
382
+ if (uploadData.id && uploadData.path) {
383
+ mediaItems.push({ id: uploadData.id, path: uploadData.path });
384
+ } else {
385
+ mediaUploadErrors.push(`Upload of "${absoluteUrl}" returned unexpected payload`);
143
386
  }
144
- } catch {
145
- // Skip failed uploads
387
+ } else {
388
+ const errText = await uploadRes.text().catch(() => "");
389
+ mediaUploadErrors.push(`Upload of "${absoluteUrl}" failed: ${uploadRes.status} ${errText || uploadRes.statusText}`);
146
390
  }
391
+ } catch (err: any) {
392
+ mediaUploadErrors.push(`Upload of "${absoluteUrl}" threw: ${err?.message ?? "unknown"}`);
147
393
  }
148
394
  }
149
395
 
150
- // Build post payload per Postiz API
396
+ // If any media was requested but NONE uploaded successfully, fail the post
397
+ // rather than silently publishing text-only — user expected the image.
398
+ if (input.mediaUrls?.length && mediaItems.length === 0) {
399
+ return {
400
+ id: null,
401
+ status: "failed",
402
+ backendData: { error: "Media upload failed", details: mediaUploadErrors },
403
+ };
404
+ }
405
+
406
+ // Platform-specific default settings required by Postiz.
407
+ // These mirror the validation rules Postiz applies per integration:
408
+ // - Instagram/Facebook: post_type ("post" | "story")
409
+ // - X (Twitter): who_can_reply_post ("everyone" | "following" | "mentionedUsers" | "subscribers" | "verified")
410
+ const SETTINGS_DEFAULTS: Record<string, Record<string, unknown>> = {
411
+ instagram: { post_type: "post" },
412
+ facebook: { post_type: "post" },
413
+ x: { who_can_reply_post: "everyone" },
414
+ };
415
+
416
+ // Build post payload per Postiz API.
417
+ // Postiz treats `value` as a thread — a second entry becomes the
418
+ // first comment on IG/Facebook or a thread reply on X/Threads/LinkedIn.
419
+ const firstCommentText = input.firstComment?.trim();
420
+ const valueEntries: Array<{ content: string; image: Array<{ id: string; path: string }> }> = [
421
+ { content: input.text, image: mediaItems },
422
+ ];
423
+ if (firstCommentText) {
424
+ valueEntries.push({ content: firstCommentText, image: [] });
425
+ }
426
+
151
427
  const postEntries = matchedIntegrations.map((ig) => ({
152
428
  integration: { id: ig.id },
153
- value: [{
154
- content: input.text,
155
- image: mediaItems,
156
- }],
157
- settings: { __type: ig.identifier },
429
+ value: valueEntries,
430
+ settings: {
431
+ __type: ig.identifier,
432
+ ...(SETTINGS_DEFAULTS[ig.identifier] ?? {}),
433
+ },
158
434
  }));
159
435
 
160
436
  const body: Record<string, unknown> = {
161
437
  type: input.scheduledAt ? "schedule" : "now",
162
438
  date: input.scheduledAt || new Date().toISOString(),
163
439
  shortLink: false,
440
+ tags: [],
164
441
  posts: postEntries,
165
442
  };
166
443
 
@@ -12,12 +12,47 @@ import type {
12
12
  SocialComment,
13
13
  SocialProfile,
14
14
  ListPostsOpts,
15
+ SocialPlatform,
15
16
  } from "./types.js";
16
17
  import * as store from "./store.js";
18
+ import { BrowserAdapter } from "./adapters/browser-adapter.js";
17
19
 
18
20
  let cachedAdapter: SocialMediaAdapter | null = null;
19
21
  let cachedBackendKey: string | null = null;
20
22
 
23
+ // Singleton BrowserAdapter — only one persistent Playwright session per platform,
24
+ // so there's no per-config variation to cache.
25
+ let browserAdapterSingleton: BrowserAdapter | null = null;
26
+
27
+ function getBrowserAdapter(): BrowserAdapter {
28
+ if (!browserAdapterSingleton) browserAdapterSingleton = new BrowserAdapter();
29
+ return browserAdapterSingleton;
30
+ }
31
+
32
+ /** Platforms routed through the browser (rather than the primary backend). */
33
+ function browserPlatformsFrom(settings: SocialMediaSettings): SocialPlatform[] {
34
+ return settings.browserPlatforms ?? [];
35
+ }
36
+
37
+ /**
38
+ * Coerce `platforms` input to a clean SocialPlatform[]. The Content Agent has
39
+ * occasionally sent `[{id, name}]` objects (from a Postiz integration listing)
40
+ * instead of plain strings, which corrupted drafts and crashed the UI.
41
+ */
42
+ function coercePlatforms(input: unknown): SocialPlatform[] {
43
+ if (!Array.isArray(input)) return [];
44
+ const out: SocialPlatform[] = [];
45
+ for (const p of input) {
46
+ if (typeof p === "string" && p) { out.push(p as SocialPlatform); continue; }
47
+ if (p && typeof p === "object") {
48
+ const o = p as { name?: unknown; platform?: unknown };
49
+ if (typeof o.name === "string" && o.name) { out.push(o.name as SocialPlatform); continue; }
50
+ if (typeof o.platform === "string" && o.platform) { out.push(o.platform as SocialPlatform); continue; }
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
21
56
  export async function getAdapter(settings?: SocialMediaSettings): Promise<SocialMediaAdapter> {
22
57
  const s = settings ?? store.getSettings();
23
58
  if (!s.backend) {
@@ -43,11 +78,6 @@ export async function getAdapter(settings?: SocialMediaSettings): Promise<Social
43
78
  adapter = new PostizAdapter({ url: config.url ?? "", apiKey: config.apiKey });
44
79
  break;
45
80
  }
46
- case "ayrshare": {
47
- const { AyrshareAdapter } = await import("./adapters/ayrshare-adapter.js");
48
- adapter = new AyrshareAdapter({ apiKey: config.apiKey });
49
- break;
50
- }
51
81
  case "buffer": {
52
82
  const { BufferAdapter } = await import("./adapters/buffer-adapter.js");
53
83
  adapter = new BufferAdapter({ apiKey: config.apiKey });
@@ -80,6 +110,9 @@ export async function createPost(input: CreatePostInput): Promise<SocialPost> {
80
110
  const settings = store.getSettings();
81
111
  const now = new Date().toISOString();
82
112
 
113
+ // Defensive: agents have occasionally sent platforms as object arrays.
114
+ input = { ...input, platforms: coercePlatforms(input.platforms) };
115
+
83
116
  const post: SocialPost = {
84
117
  id: randomUUID(),
85
118
  text: input.text,
@@ -104,17 +137,115 @@ export async function createPost(input: CreatePostInput): Promise<SocialPost> {
104
137
  return post;
105
138
  }
106
139
 
107
- try {
108
- const adapter = await getAdapter(settings);
109
- const result = await adapter.createPost(input);
110
- post.backendPostId = result.id;
111
- post.status = result.status as SocialPost["status"];
112
- post.backendData = result.backendData;
113
- } catch (err: any) {
114
- post.status = "failed";
115
- post.backendData = { error: err?.message };
140
+ // ── Partition platforms between Browser and primary backend ────────────────
141
+ const browserSet = new Set(browserPlatformsFrom(settings));
142
+ const browserPlatforms = input.platforms.filter((p) => browserSet.has(p));
143
+ const primaryPlatforms = input.platforms.filter((p) => !browserSet.has(p));
144
+
145
+ type GroupResult = {
146
+ group: "browser" | "primary";
147
+ ok: boolean;
148
+ id: string | null;
149
+ status: string;
150
+ backendData?: unknown;
151
+ error?: string;
152
+ };
153
+ const groupResults: GroupResult[] = [];
154
+
155
+ // Primary backend group
156
+ if (primaryPlatforms.length > 0) {
157
+ try {
158
+ const adapter = await getAdapter(settings);
159
+ const result = await adapter.createPost({ ...input, platforms: primaryPlatforms });
160
+ const ok = result.status === "published" || result.status === "scheduled";
161
+ groupResults.push({
162
+ group: "primary",
163
+ ok,
164
+ id: result.id,
165
+ status: result.status,
166
+ backendData: result.backendData,
167
+ });
168
+ } catch (err: any) {
169
+ groupResults.push({
170
+ group: "primary",
171
+ ok: false,
172
+ id: null,
173
+ status: "failed",
174
+ error: err?.message ?? "primary backend failed",
175
+ });
176
+ }
177
+ }
178
+
179
+ // Browser group (X / TikTok)
180
+ if (browserPlatforms.length > 0) {
181
+ try {
182
+ const adapter = getBrowserAdapter();
183
+ adapter.setTargetPlatforms(browserPlatforms);
184
+ const result = await adapter.createPost({ ...input, platforms: browserPlatforms });
185
+ const ok = result.status === "published" || result.status === "partial";
186
+ groupResults.push({
187
+ group: "browser",
188
+ ok,
189
+ id: result.id,
190
+ status: result.status,
191
+ backendData: result.backendData,
192
+ });
193
+ } catch (err: any) {
194
+ groupResults.push({
195
+ group: "browser",
196
+ ok: false,
197
+ id: null,
198
+ status: "failed",
199
+ error: err?.message ?? "browser adapter failed",
200
+ });
201
+ }
116
202
  }
117
203
 
204
+ // ── Merge group results into a single SocialPost ───────────────────────────
205
+ const anyOk = groupResults.some((g) => g.ok);
206
+ const anyFail = groupResults.some((g) => !g.ok);
207
+ // A primary-group result of "partial" also indicates a mixed outcome.
208
+ const primaryGroupPartial = groupResults.find((g) => g.group === "primary")?.status === "partial";
209
+ const browserGroupPartial = groupResults.find((g) => g.group === "browser")?.status === "partial";
210
+
211
+ let finalStatus: SocialPost["status"];
212
+ if (groupResults.length === 0) {
213
+ finalStatus = "failed";
214
+ } else if (anyOk && (anyFail || primaryGroupPartial || browserGroupPartial)) {
215
+ finalStatus = "partial";
216
+ } else if (anyOk) {
217
+ // Could be "scheduled" if the primary group was scheduled and there was no browser group.
218
+ const only = groupResults[0];
219
+ finalStatus = (only?.status === "scheduled" ? "scheduled" : "published");
220
+ } else {
221
+ finalStatus = "failed";
222
+ }
223
+
224
+ // Build a structured backendPostId-ish payload in backendData; keep the flat
225
+ // string field for backwards-compat (first available ID).
226
+ const primaryRes = groupResults.find((g) => g.group === "primary");
227
+ const browserRes = groupResults.find((g) => g.group === "browser");
228
+ post.backendPostId = primaryRes?.id ?? browserRes?.id ?? null;
229
+ post.backendData = {
230
+ postiz: primaryRes
231
+ ? {
232
+ status: primaryRes.status,
233
+ id: primaryRes.id,
234
+ data: primaryRes.backendData,
235
+ error: primaryRes.error,
236
+ }
237
+ : undefined,
238
+ browser: browserRes
239
+ ? {
240
+ status: browserRes.status,
241
+ id: browserRes.id,
242
+ data: browserRes.backendData,
243
+ error: browserRes.error,
244
+ }
245
+ : undefined,
246
+ };
247
+ post.status = finalStatus;
248
+
118
249
  post.updatedAt = new Date().toISOString();
119
250
  store.savePost(post);
120
251
  return post;
@@ -128,6 +259,44 @@ export function getPost(id: string): SocialPost | null {
128
259
  return store.getPost(id);
129
260
  }
130
261
 
262
+ /**
263
+ * Move an already-published or scheduled post back to draft state.
264
+ *
265
+ * Best-effort deletes the post from the configured backend (Postiz/Buffer)
266
+ * so it disappears from the platform queue / live feed, then resets the
267
+ * local record to status="draft" with no backend reference. The user can
268
+ * subsequently edit and re-publish via the normal draft flow.
269
+ *
270
+ * Note: this is destructive on the backend side — once the post is live on
271
+ * Instagram/LinkedIn/etc., deleting from Postiz removes it from those
272
+ * platforms (where the integration supports deletion). The local content
273
+ * (text, media, scheduledAt) is preserved.
274
+ */
275
+ export async function moveToDraft(id: string): Promise<SocialPost> {
276
+ const post = store.getPost(id);
277
+ if (!post) throw new Error("Post not found");
278
+ if (post.status === "draft") return post;
279
+
280
+ // Best-effort backend delete — don't block the local downgrade if the
281
+ // backend has already cleaned up or the integration doesn't support it.
282
+ if (post.backendPostId) {
283
+ try {
284
+ const adapter = await getAdapter();
285
+ await adapter.deletePost(post.backendPostId);
286
+ } catch (err: any) {
287
+ console.error(`[socialmedia] moveToDraft: backend delete failed for ${post.backendPostId}:`, err?.message ?? err);
288
+ }
289
+ }
290
+
291
+ post.status = "draft";
292
+ post.backendId = null;
293
+ post.backendPostId = null;
294
+ post.backendData = undefined;
295
+ post.updatedAt = new Date().toISOString();
296
+ store.savePost(post);
297
+ return post;
298
+ }
299
+
131
300
  export async function deletePost(id: string): Promise<boolean> {
132
301
  const post = store.getPost(id);
133
302
  if (!post) return false;
@@ -145,6 +314,35 @@ export async function deletePost(id: string): Promise<boolean> {
145
314
  return store.deleteLocalPost(id);
146
315
  }
147
316
 
317
+ /**
318
+ * Archive or unarchive a post. Archiving hides the post from the default
319
+ * Queue view without removing it; `previousStatus` is preserved in
320
+ * `backendData._preArchiveStatus` so unarchiving restores the prior state.
321
+ */
322
+ export async function setArchived(id: string, archived: boolean): Promise<SocialPost> {
323
+ const post = store.getPost(id);
324
+ if (!post) throw new Error("Post not found");
325
+
326
+ if (archived) {
327
+ if (post.status === "archived") return post;
328
+ const data = (post.backendData as Record<string, unknown> | undefined) ?? {};
329
+ post.backendData = { ...data, _preArchiveStatus: post.status };
330
+ post.status = "archived";
331
+ } else {
332
+ const data = (post.backendData as Record<string, unknown> | undefined) ?? {};
333
+ const prev = data._preArchiveStatus as SocialPost["status"] | undefined;
334
+ post.status = prev ?? "published";
335
+ if ("_preArchiveStatus" in data) {
336
+ const { _preArchiveStatus: _, ...rest } = data;
337
+ post.backendData = rest;
338
+ }
339
+ }
340
+
341
+ post.updatedAt = new Date().toISOString();
342
+ store.savePost(post);
343
+ return post;
344
+ }
345
+
148
346
  export async function getPostAnalytics(id: string): Promise<PostAnalytics> {
149
347
  const post = store.getPost(id);
150
348
  if (!post?.backendPostId) {
@@ -177,10 +375,11 @@ export async function replyToComment(postId: string, commentId: string | null, t
177
375
 
178
376
  export async function createDraft(input: CreatePostInput & { createdBy?: "user" | "gemini" | "agent" }): Promise<SocialPost> {
179
377
  const now = new Date().toISOString();
378
+ const platforms = coercePlatforms(input.platforms);
180
379
  const post: SocialPost = {
181
380
  id: randomUUID(),
182
381
  text: input.text,
183
- platforms: input.platforms,
382
+ platforms,
184
383
  scheduledAt: input.scheduledAt ?? null,
185
384
  mediaUrls: input.mediaUrls ?? [],
186
385
  status: "draft",
@@ -198,6 +397,26 @@ export async function createDraft(input: CreatePostInput & { createdBy?: "user"
198
397
  return post;
199
398
  }
200
399
 
400
+ export async function updateDraft(id: string, updates: { text?: string; platforms?: string[]; scheduledAt?: string }): Promise<SocialPost> {
401
+ const post = store.getPost(id);
402
+ if (!post) throw new Error("Post not found");
403
+ if (post.status !== "draft") throw new Error("Post is not a draft");
404
+
405
+ if (updates.text !== undefined) post.text = updates.text;
406
+ if (updates.platforms !== undefined) post.platforms = coercePlatforms(updates.platforms);
407
+ if (updates.scheduledAt !== undefined) post.scheduledAt = updates.scheduledAt || null;
408
+ post.updatedAt = new Date().toISOString();
409
+ store.savePost(post);
410
+ return post;
411
+ }
412
+
413
+ export async function deleteDraft(id: string): Promise<boolean> {
414
+ const post = store.getPost(id);
415
+ if (!post) return false;
416
+ if (post.status !== "draft") throw new Error("Post is not a draft");
417
+ return store.deleteLocalPost(id);
418
+ }
419
+
201
420
  export async function publishDraft(id: string): Promise<SocialPost> {
202
421
  const post = store.getPost(id);
203
422
  if (!post) throw new Error("Post not found");