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.
- 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-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -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/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 +2 -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/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ─── Image Description (via Claude Code Subscription) ───────────────────────
|
|
2
|
+
// Downloads remote post images to ~/.heyhank/socialview/media/<postId>.<ext>
|
|
3
|
+
// and feeds them to Claude Code's Read tool to produce a textual description.
|
|
4
|
+
// Used to backfill `media[].description` for posts whose visual signal would
|
|
5
|
+
// otherwise be missing from the persona analysis.
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { HEYHANK_HOME } from "../paths.js";
|
|
11
|
+
import { savePost } from "./library.js";
|
|
12
|
+
import type { LibraryPost } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const MEDIA_ROOT = join(HEYHANK_HOME, "socialview", "media");
|
|
15
|
+
|
|
16
|
+
const DESCRIBE_PROMPT = `Beschreibe dieses Social-Media-Post-Bild für einen Content-Agent (3-5 prägnante Bullets, unter 120 Wörter total):
|
|
17
|
+
- Motiv & Komposition (was ist im Bild, wie ist es arrangiert)
|
|
18
|
+
- Farbpalette & Stimmung (warm/kühl, gesättigt/gedämpft, dominante Farben)
|
|
19
|
+
- Text-Overlays falls vorhanden (wörtlich zitieren)
|
|
20
|
+
- Stil (flatlay, portrait, candid, stock-ish, UGC, studio, meme, infographic)
|
|
21
|
+
- Produktionsqualität-Signale (natürliches Licht, Tiefenschärfe, Markenkonsistenz)
|
|
22
|
+
|
|
23
|
+
Nur die Bullets, kein Vorwort.`;
|
|
24
|
+
|
|
25
|
+
/** Download a remote image to local disk. Returns the local path or null on failure. */
|
|
26
|
+
async function downloadImage(url: string, postId: string, idx: number): Promise<string | null> {
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(MEDIA_ROOT, { recursive: true });
|
|
29
|
+
const safeId = postId.replace(/[^a-z0-9._-]/gi, "_");
|
|
30
|
+
const ext = guessExtension(url);
|
|
31
|
+
const localPath = join(MEDIA_ROOT, `${safeId}-${idx}.${ext}`);
|
|
32
|
+
|
|
33
|
+
if (existsSync(localPath) && statSync(localPath).size > 0) {
|
|
34
|
+
return localPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
38
|
+
if (!res.ok) return null;
|
|
39
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
40
|
+
if (buf.length === 0) return null;
|
|
41
|
+
writeFileSync(localPath, buf);
|
|
42
|
+
return localPath;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function guessExtension(url: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
51
|
+
if (path.endsWith(".png")) return "png";
|
|
52
|
+
if (path.endsWith(".webp")) return "webp";
|
|
53
|
+
if (path.endsWith(".gif")) return "gif";
|
|
54
|
+
return "jpg";
|
|
55
|
+
} catch {
|
|
56
|
+
return "jpg";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Spawn `claude -p` with Read enabled, point it at the image, capture stdout. */
|
|
61
|
+
function describeImageViaClaudeCode(imagePath: string, timeoutMs = 90_000): Promise<string> {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const childEnv: Record<string, string | undefined> = { ...process.env, NO_COLOR: "1" };
|
|
64
|
+
delete childEnv.CLAUDECODE;
|
|
65
|
+
delete childEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
66
|
+
|
|
67
|
+
// Pre-approve Read via the settings glob pattern (Read(*)). The plain
|
|
68
|
+
// `--tools "Read"` enables Read but still demands interactive permission,
|
|
69
|
+
// which blocks under root (where --dangerously-skip-permissions is denied).
|
|
70
|
+
const args = [
|
|
71
|
+
"-p",
|
|
72
|
+
"--allowedTools", "Read(*)",
|
|
73
|
+
"--add-dir", dirname(imagePath),
|
|
74
|
+
"--no-session-persistence",
|
|
75
|
+
"--model", "sonnet",
|
|
76
|
+
`${DESCRIBE_PROMPT}\n\nLies das Bild unter: ${imagePath}`,
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
let child;
|
|
80
|
+
try {
|
|
81
|
+
child = spawn("claude", args, {
|
|
82
|
+
env: childEnv,
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
resolve("");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let stdout = "";
|
|
91
|
+
let stderr = "";
|
|
92
|
+
let settled = false;
|
|
93
|
+
|
|
94
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
95
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
96
|
+
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
child.kill("SIGKILL");
|
|
101
|
+
resolve("");
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
|
|
104
|
+
child.on("error", () => {
|
|
105
|
+
if (settled) return;
|
|
106
|
+
settled = true;
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolve("");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
child.on("close", (code) => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
settled = true;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
if (code === 0) {
|
|
116
|
+
resolve(stdout.trim());
|
|
117
|
+
} else {
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.warn(`[image-describe] claude exit ${code}:`, stderr.slice(-200));
|
|
120
|
+
resolve("");
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* For each post with image media that has empty description, download the image
|
|
128
|
+
* (if not already cached) and describe it via Claude Code. Mutates the posts
|
|
129
|
+
* in-place AND persists the updated post to library disk so future persona
|
|
130
|
+
* analyses won’t re-do the work.
|
|
131
|
+
*
|
|
132
|
+
* Optimizations vs. naive serial loop:
|
|
133
|
+
* - Only describes the FIRST image per post (the dominant visual). For style
|
|
134
|
+
* analysis a single visual sample per post is sufficient and 25 posts × N
|
|
135
|
+
* images would explode runtime under nginx’s proxy timeout.
|
|
136
|
+
* - Runs up to CONCURRENCY describe-jobs in parallel.
|
|
137
|
+
*
|
|
138
|
+
* Returns the same array (with updated description fields) for chaining.
|
|
139
|
+
*/
|
|
140
|
+
export async function backfillImageDescriptions(
|
|
141
|
+
posts: LibraryPost[],
|
|
142
|
+
): Promise<LibraryPost[]> {
|
|
143
|
+
const CONCURRENCY = 4;
|
|
144
|
+
|
|
145
|
+
type Job = { post: LibraryPost; idx: number };
|
|
146
|
+
const jobs: Job[] = [];
|
|
147
|
+
for (const post of posts) {
|
|
148
|
+
// First image only — style analysis needs one visual signal per post,
|
|
149
|
+
// not all carousel slides.
|
|
150
|
+
const idx = post.media.findIndex(
|
|
151
|
+
(m) => m.type === "image" && !(m.description && m.description.trim()) && !!m.remoteUrl,
|
|
152
|
+
);
|
|
153
|
+
if (idx === -1) continue;
|
|
154
|
+
jobs.push({ post, idx });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (jobs.length === 0) return posts;
|
|
158
|
+
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
const dirtyPosts = new Set<LibraryPost>();
|
|
161
|
+
|
|
162
|
+
async function worker() {
|
|
163
|
+
while (true) {
|
|
164
|
+
const myIdx = cursor++;
|
|
165
|
+
if (myIdx >= jobs.length) return;
|
|
166
|
+
const { post, idx } = jobs[myIdx]!;
|
|
167
|
+
const m = post.media[idx]!;
|
|
168
|
+
|
|
169
|
+
let localPath: string | null = m.localPath ?? null;
|
|
170
|
+
if (!localPath || !existsSync(localPath)) {
|
|
171
|
+
localPath = await downloadImage(m.remoteUrl!, post.id, idx);
|
|
172
|
+
}
|
|
173
|
+
if (!localPath) continue;
|
|
174
|
+
|
|
175
|
+
const desc = await describeImageViaClaudeCode(localPath);
|
|
176
|
+
if (!desc) continue;
|
|
177
|
+
|
|
178
|
+
m.localPath = localPath;
|
|
179
|
+
m.description = desc;
|
|
180
|
+
dirtyPosts.add(post);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
185
|
+
|
|
186
|
+
for (const post of dirtyPosts) savePost(post);
|
|
187
|
+
return posts;
|
|
188
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ─── SocialView Library ──────────────────────────────────────────────────────
|
|
2
|
+
// File-based storage for reference posts. Layout:
|
|
3
|
+
// ~/.heyhank/socialview/library/<platform>/<id>.json
|
|
4
|
+
// ~/.heyhank/socialview/media/<id>-<i>.<ext>
|
|
5
|
+
// No database: a flat directory is plenty for a few thousand posts and makes
|
|
6
|
+
// backup/inspection trivial.
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { HEYHANK_HOME } from "../paths.js";
|
|
11
|
+
import type { LibraryPost, LibraryQuery, SocialPlatform } from "./types.js";
|
|
12
|
+
import { SOCIAL_PLATFORMS } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const LIBRARY_ROOT = join(HEYHANK_HOME, "socialview", "library");
|
|
15
|
+
export const MEDIA_ROOT = join(HEYHANK_HOME, "socialview", "media");
|
|
16
|
+
|
|
17
|
+
function platformDir(platform: SocialPlatform): string {
|
|
18
|
+
return join(LIBRARY_ROOT, platform);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function postPath(platform: SocialPlatform, id: string): string {
|
|
22
|
+
return join(platformDir(platform), `${id}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ensureDirs(): void {
|
|
26
|
+
mkdirSync(MEDIA_ROOT, { recursive: true });
|
|
27
|
+
for (const p of SOCIAL_PLATFORMS) {
|
|
28
|
+
mkdirSync(platformDir(p), { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function savePost(post: LibraryPost): void {
|
|
33
|
+
ensureDirs();
|
|
34
|
+
writeFileSync(postPath(post.platform, post.id), JSON.stringify(post, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getPost(platform: SocialPlatform, id: string): LibraryPost | null {
|
|
38
|
+
const path = postPath(platform, id);
|
|
39
|
+
if (!existsSync(path)) return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(path, "utf-8")) as LibraryPost;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function deletePost(platform: SocialPlatform, id: string): boolean {
|
|
48
|
+
const path = postPath(platform, id);
|
|
49
|
+
if (!existsSync(path)) return false;
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(path);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** List posts across all platforms, newest first, with optional filters. */
|
|
59
|
+
export function listPosts(q: LibraryQuery = {}): LibraryPost[] {
|
|
60
|
+
ensureDirs();
|
|
61
|
+
const platforms = q.platform ? [q.platform] : SOCIAL_PLATFORMS;
|
|
62
|
+
const out: LibraryPost[] = [];
|
|
63
|
+
|
|
64
|
+
for (const p of platforms) {
|
|
65
|
+
const dir = platformDir(p);
|
|
66
|
+
if (!existsSync(dir)) continue;
|
|
67
|
+
for (const file of readdirSync(dir)) {
|
|
68
|
+
if (!file.endsWith(".json")) continue;
|
|
69
|
+
try {
|
|
70
|
+
const post = JSON.parse(readFileSync(join(dir, file), "utf-8")) as LibraryPost;
|
|
71
|
+
if (!matchesQuery(post, q)) continue;
|
|
72
|
+
out.push(post);
|
|
73
|
+
} catch {
|
|
74
|
+
// skip malformed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
out.sort((a, b) => (b.extractedAt > a.extractedAt ? 1 : -1));
|
|
80
|
+
if (q.limit && out.length > q.limit) return out.slice(0, q.limit);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function matchesQuery(post: LibraryPost, q: LibraryQuery): boolean {
|
|
85
|
+
if (q.source && post.source !== q.source) return false;
|
|
86
|
+
if (q.goldOnly && !post.isGold) return false;
|
|
87
|
+
if (typeof q.minEngagementRate === "number") {
|
|
88
|
+
if ((post.engagementRate ?? 0) < q.minEngagementRate) return false;
|
|
89
|
+
}
|
|
90
|
+
if (q.tags && q.tags.length > 0) {
|
|
91
|
+
const tagset = new Set(post.tags);
|
|
92
|
+
for (const t of q.tags) if (!tagset.has(t)) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function updatePost(
|
|
98
|
+
platform: SocialPlatform,
|
|
99
|
+
id: string,
|
|
100
|
+
patch: Partial<Pick<LibraryPost, "tags" | "isGold" | "notes" | "source">>,
|
|
101
|
+
): LibraryPost | null {
|
|
102
|
+
const existing = getPost(platform, id);
|
|
103
|
+
if (!existing) return null;
|
|
104
|
+
const updated: LibraryPost = { ...existing, ...patch };
|
|
105
|
+
savePost(updated);
|
|
106
|
+
return updated;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Top-N posts for agent few-shot. Prefers gold + high engagement on the platform. */
|
|
110
|
+
export function selectForFewShot(platform: SocialPlatform, limit: number = 5): LibraryPost[] {
|
|
111
|
+
const candidates = listPosts({ platform, goldOnly: true });
|
|
112
|
+
// Sort by engagementRate desc (nulls last)
|
|
113
|
+
candidates.sort((a, b) => {
|
|
114
|
+
const ar = a.engagementRate ?? -1;
|
|
115
|
+
const br = b.engagementRate ?? -1;
|
|
116
|
+
return br - ar;
|
|
117
|
+
});
|
|
118
|
+
return candidates.slice(0, limit);
|
|
119
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// ─── SocialView Poster ───────────────────────────────────────────────────────
|
|
2
|
+
// Browser-based posting for X (Twitter) and TikTok. Reuses the persistent
|
|
3
|
+
// Playwright context from browser-manager.ts, so the user's manual login
|
|
4
|
+
// (via noVNC) carries over. No headless — user can watch actions live.
|
|
5
|
+
//
|
|
6
|
+
// Scope v1:
|
|
7
|
+
// - X: text + optional single image
|
|
8
|
+
// - TikTok: video + description
|
|
9
|
+
//
|
|
10
|
+
// Selectors are stable `data-testid` attributes (X) / reasonably stable ARIA
|
|
11
|
+
// roles (TikTok). If UI changes break a selector, the caller surfaces the
|
|
12
|
+
// error and the user can inspect via noVNC.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import type { Page } from "playwright";
|
|
19
|
+
import { HEYHANK_HOME } from "../paths.js";
|
|
20
|
+
|
|
21
|
+
// ─── Media resolution (mirrors postiz-adapter) ───────────────────────────────
|
|
22
|
+
|
|
23
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
24
|
+
png: "image/png",
|
|
25
|
+
jpg: "image/jpeg",
|
|
26
|
+
jpeg: "image/jpeg",
|
|
27
|
+
gif: "image/gif",
|
|
28
|
+
webp: "image/webp",
|
|
29
|
+
mp4: "video/mp4",
|
|
30
|
+
mov: "video/quicktime",
|
|
31
|
+
webm: "video/webm",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Detect MIME from file's magic bytes. See postiz-adapter.ts for rationale. */
|
|
35
|
+
function detectMimeFromBytes(buf: Uint8Array): string | null {
|
|
36
|
+
if (buf.length < 12) return null;
|
|
37
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
|
|
38
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
|
|
39
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return "image/gif";
|
|
40
|
+
if (
|
|
41
|
+
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
|
42
|
+
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
|
|
43
|
+
) return "image/webp";
|
|
44
|
+
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return "video/mp4";
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extForMime(mime: string): string {
|
|
49
|
+
switch (mime) {
|
|
50
|
+
case "image/jpeg": return "jpg";
|
|
51
|
+
case "image/png": return "png";
|
|
52
|
+
case "image/gif": return "gif";
|
|
53
|
+
case "image/webp": return "webp";
|
|
54
|
+
case "video/mp4": return "mp4";
|
|
55
|
+
case "video/quicktime": return "mov";
|
|
56
|
+
case "video/webm": return "webm";
|
|
57
|
+
default: return "bin";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractLocalMediaFilename(url: string): string | null {
|
|
62
|
+
try {
|
|
63
|
+
const withoutHost = url.replace(/^https?:\/\/[^/]+/i, "");
|
|
64
|
+
const match = withoutHost.match(/^\/api\/media\/file\/([^/?#]+)/);
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
return basename(decodeURIComponent(match[1]));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a media URL to a local disk path that Playwright's
|
|
74
|
+
* `setInputFiles` can consume. Handles:
|
|
75
|
+
* - local HeyHank media (/api/media/file/<name>) — reads from ~/.heyhank/media
|
|
76
|
+
* - absolute http(s) URLs — downloaded to /tmp
|
|
77
|
+
* Applies magic-byte detection so a JPEG saved with a .png extension
|
|
78
|
+
* is renamed to .jpg before being handed to the browser upload widget.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveMediaToDiskPath(url: string): Promise<string> {
|
|
81
|
+
const localName = extractLocalMediaFilename(url);
|
|
82
|
+
let buf: Uint8Array;
|
|
83
|
+
let originalName: string;
|
|
84
|
+
|
|
85
|
+
if (localName) {
|
|
86
|
+
const local = join(HEYHANK_HOME, "media", localName);
|
|
87
|
+
if (!existsSync(local)) throw new Error(`Local media file not found: ${localName}`);
|
|
88
|
+
buf = readFileSync(local);
|
|
89
|
+
originalName = localName;
|
|
90
|
+
} else if (/^https?:\/\//i.test(url)) {
|
|
91
|
+
const res = await fetch(url);
|
|
92
|
+
if (!res.ok) throw new Error(`Failed to fetch media URL ${url}: ${res.status}`);
|
|
93
|
+
buf = new Uint8Array(await res.arrayBuffer());
|
|
94
|
+
const urlPath = url.split(/[?#]/)[0];
|
|
95
|
+
originalName = basename(urlPath) || `media_${randomUUID()}`;
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`Unsupported media URL: ${url}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const extFromName = originalName.split(".").pop()?.toLowerCase() ?? "";
|
|
101
|
+
const sniffed = detectMimeFromBytes(buf);
|
|
102
|
+
const correctExt = sniffed ? extForMime(sniffed) : (MIME_BY_EXT[extFromName] ? extFromName : "bin");
|
|
103
|
+
const finalName =
|
|
104
|
+
correctExt && correctExt !== extFromName
|
|
105
|
+
? `${originalName.replace(/\.[^.]+$/, "")}.${correctExt}`
|
|
106
|
+
: originalName;
|
|
107
|
+
|
|
108
|
+
const stageDir = join(tmpdir(), "heyhank-browser-upload");
|
|
109
|
+
mkdirSync(stageDir, { recursive: true });
|
|
110
|
+
const finalPath = join(stageDir, `${Date.now()}_${randomUUID()}_${finalName}`);
|
|
111
|
+
writeFileSync(finalPath, buf);
|
|
112
|
+
return finalPath;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Humanized delays ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function humanDelay(minMs = 50, maxMs = 200): Promise<void> {
|
|
118
|
+
const ms = minMs + Math.floor(Math.random() * (maxMs - minMs));
|
|
119
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface PostResult {
|
|
123
|
+
url: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── X (Twitter) ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export interface PostToXOpts {
|
|
129
|
+
text: string;
|
|
130
|
+
imagePath?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function postToX(page: Page, opts: PostToXOpts): Promise<PostResult> {
|
|
134
|
+
// The dedicated compose URL pops the composer dialog directly, avoiding
|
|
135
|
+
// the timeline's "What's happening?" inline-vs-modal ambiguity.
|
|
136
|
+
await page.goto("https://x.com/compose/post", { waitUntil: "domcontentloaded" });
|
|
137
|
+
await humanDelay(600, 1100);
|
|
138
|
+
|
|
139
|
+
// 1) Find the tweet textarea. X uses a stable data-testid.
|
|
140
|
+
const textareaSelector = '[data-testid="tweetTextarea_0"]';
|
|
141
|
+
await page.waitForSelector(textareaSelector, { timeout: 30_000 });
|
|
142
|
+
const textarea = await page.$(textareaSelector);
|
|
143
|
+
if (!textarea) throw new Error("X tweet textarea not found");
|
|
144
|
+
|
|
145
|
+
await textarea.click();
|
|
146
|
+
await humanDelay(80, 180);
|
|
147
|
+
await page.keyboard.type(opts.text, { delay: 25 });
|
|
148
|
+
await humanDelay(300, 700);
|
|
149
|
+
|
|
150
|
+
// 2) Optional single image upload via the hidden file input.
|
|
151
|
+
if (opts.imagePath) {
|
|
152
|
+
const fileInput = await page.$('[data-testid="fileInput"]') ?? await page.$('input[type="file"]');
|
|
153
|
+
if (!fileInput) throw new Error("X file input not found");
|
|
154
|
+
await fileInput.setInputFiles(opts.imagePath);
|
|
155
|
+
// Wait for the inline preview tile so we know the upload is accepted.
|
|
156
|
+
try {
|
|
157
|
+
await page.waitForSelector('[data-testid="attachments"]', { timeout: 30_000 });
|
|
158
|
+
} catch {
|
|
159
|
+
// Some variants render the preview without that testid; soft-fail.
|
|
160
|
+
}
|
|
161
|
+
await humanDelay(500, 1000);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 3) Click the Post button. X uses two ids depending on context.
|
|
165
|
+
const postBtn =
|
|
166
|
+
(await page.$('[data-testid="tweetButtonInline"]')) ??
|
|
167
|
+
(await page.$('[data-testid="tweetButton"]'));
|
|
168
|
+
if (!postBtn) throw new Error("X Post button not found");
|
|
169
|
+
|
|
170
|
+
// Wait for the button to be enabled (text + media validation passes).
|
|
171
|
+
for (let i = 0; i < 40; i++) {
|
|
172
|
+
const ariaDisabled = await postBtn.getAttribute("aria-disabled");
|
|
173
|
+
if (ariaDisabled !== "true") break;
|
|
174
|
+
await humanDelay(200, 400);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await postBtn.click();
|
|
178
|
+
|
|
179
|
+
// 4) After post, the composer closes and we end up on the timeline.
|
|
180
|
+
// The toast "Your post was sent" appears briefly. We don't reliably
|
|
181
|
+
// capture the new tweet's URL — return null and let the caller treat
|
|
182
|
+
// the absence of an exception as success.
|
|
183
|
+
try {
|
|
184
|
+
await page.waitForFunction(
|
|
185
|
+
() => !location.pathname.includes("/compose"),
|
|
186
|
+
{ timeout: 30_000 },
|
|
187
|
+
);
|
|
188
|
+
} catch {
|
|
189
|
+
// Composer may stay open if there was a validation issue — surface as error.
|
|
190
|
+
const stillThere = await page.$(textareaSelector);
|
|
191
|
+
if (stillThere) throw new Error("X post did not complete (composer still open)");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { url: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── TikTok ──────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface PostToTiktokOpts {
|
|
200
|
+
description: string;
|
|
201
|
+
videoPath: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function postToTiktok(page: Page, opts: PostToTiktokOpts): Promise<PostResult> {
|
|
205
|
+
await page.goto("https://www.tiktok.com/tiktokstudio/upload", { waitUntil: "domcontentloaded" });
|
|
206
|
+
await humanDelay(800, 1400);
|
|
207
|
+
|
|
208
|
+
// 1) The upload input is a hidden <input type="file" accept="video/*">.
|
|
209
|
+
// TikTok sometimes nests it inside a shadow-ish iframe; try top-level first.
|
|
210
|
+
let fileInput = await page.$('input[type="file"][accept*="video"]');
|
|
211
|
+
if (!fileInput) {
|
|
212
|
+
// Fallback: any type=file input on the page.
|
|
213
|
+
fileInput = await page.$('input[type="file"]');
|
|
214
|
+
}
|
|
215
|
+
if (!fileInput) throw new Error("TikTok video file input not found");
|
|
216
|
+
|
|
217
|
+
await fileInput.setInputFiles(opts.videoPath);
|
|
218
|
+
await humanDelay(600, 1200);
|
|
219
|
+
|
|
220
|
+
// 2) Wait until processing finishes. TikTok shows a progress indicator
|
|
221
|
+
// while the video is being uploaded + transcoded. Heuristic: wait
|
|
222
|
+
// until we see the caption/description editor become editable.
|
|
223
|
+
const descSelector = 'div[contenteditable="true"]';
|
|
224
|
+
await page.waitForSelector(descSelector, { timeout: 120_000 });
|
|
225
|
+
|
|
226
|
+
// Give transcoding a moment to settle so the Post button becomes enabled.
|
|
227
|
+
await humanDelay(1500, 2500);
|
|
228
|
+
|
|
229
|
+
// 3) Fill the description. TikTok's composer is a contenteditable div;
|
|
230
|
+
// it may pre-populate with the filename, so clear it first.
|
|
231
|
+
const descEl = await page.$(descSelector);
|
|
232
|
+
if (!descEl) throw new Error("TikTok description editor not found");
|
|
233
|
+
await descEl.click();
|
|
234
|
+
await humanDelay(100, 200);
|
|
235
|
+
await page.keyboard.press("Control+A");
|
|
236
|
+
await humanDelay(50, 150);
|
|
237
|
+
await page.keyboard.press("Delete");
|
|
238
|
+
await humanDelay(100, 200);
|
|
239
|
+
await page.keyboard.type(opts.description, { delay: 25 });
|
|
240
|
+
await humanDelay(400, 800);
|
|
241
|
+
|
|
242
|
+
// 4) Click Post. TikTok's Studio uses a button with text "Post".
|
|
243
|
+
// Try a few shapes — a dedicated data-e2e first, then role=button.
|
|
244
|
+
const postButton =
|
|
245
|
+
(await page.$('[data-e2e="post_video_button"]')) ??
|
|
246
|
+
(await page.$('button:has-text("Post")'));
|
|
247
|
+
if (!postButton) throw new Error("TikTok Post button not found");
|
|
248
|
+
|
|
249
|
+
// Button may start disabled while upload finalizes; wait until enabled.
|
|
250
|
+
for (let i = 0; i < 60; i++) {
|
|
251
|
+
const disabled = await postButton.getAttribute("disabled");
|
|
252
|
+
const ariaDisabled = await postButton.getAttribute("aria-disabled");
|
|
253
|
+
if (!disabled && ariaDisabled !== "true") break;
|
|
254
|
+
await humanDelay(500, 800);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await postButton.click();
|
|
258
|
+
|
|
259
|
+
// 5) After post, TikTok Studio typically navigates to the content manager
|
|
260
|
+
// or shows a success modal. We don't reliably get back the public URL,
|
|
261
|
+
// so we just wait for navigation away from /upload.
|
|
262
|
+
try {
|
|
263
|
+
await page.waitForFunction(
|
|
264
|
+
() => !location.pathname.includes("/upload"),
|
|
265
|
+
{ timeout: 60_000 },
|
|
266
|
+
);
|
|
267
|
+
} catch {
|
|
268
|
+
// Some versions keep you on /upload and just clear the form. If the
|
|
269
|
+
// description editor is gone, treat as success.
|
|
270
|
+
const stillHasEditor = await page.$(descSelector);
|
|
271
|
+
if (stillHasEditor) throw new Error("TikTok post did not complete");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// v1: we don't attempt to fish the public video URL out of the studio UI.
|
|
275
|
+
return { url: null };
|
|
276
|
+
}
|