heyhank 0.1.0 → 0.3.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.
- package/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-DqjDAcIw.js} +3 -3
- package/dist/assets/AssistantPage-C50CQFSB.js +2 -0
- package/dist/assets/BusinessPage-AY70tf1k.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-Dt7LLuRr.js} +1 -1
- package/dist/assets/HelpPage-tlGx7fQF.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-B4XOuHXu.js} +1 -1
- package/dist/assets/JarvisHUD-BDvuRd0I.js +120 -0
- package/dist/assets/MediaPage-CofV9Rd-.js +1 -0
- package/dist/assets/MemoryPage-Cj7FeqmJ.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-B9kXAlH1.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-Cka-pRkP.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-BqhQgfYj.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-VveKc9uX.js} +2 -2
- package/dist/assets/RunsPage-DXVEk0AZ.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-DACcwfDF.js} +1 -1
- package/dist/assets/SettingsPage-jfuQh8Tu.js +51 -0
- package/dist/assets/SkillsMarketplace-DrigiApe.js +1 -0
- package/dist/assets/SocialMediaPage-DOh3IPe8.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DLhJWATT.js} +1 -1
- package/dist/assets/TelephonyPage-9C4C3_ot.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-ChX-8Wu7.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/index-C6Q5UQHD.js +229 -0
- package/dist/assets/index-ZxGXgiV3.css +32 -0
- package/dist/assets/sw-register-BBYuk-kw.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist/{workbox-d2a0910a.js → workbox-080c8b91.js} +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +102 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/execution-store.ts +54 -1
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +44 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/index-CEqZnThB.js +0 -204
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +0 -2
- 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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
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.
|
|
136
|
-
body:
|
|
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
|
-
}
|
|
145
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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");
|