heyhank 0.1.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/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// ─── Postiz Adapter ──────────────────────────────────────────────────────────
|
|
2
|
+
// REST client for Postiz Public API v1.
|
|
3
|
+
// Works with both hosted (api.postiz.com) and self-hosted instances.
|
|
4
|
+
// Auth: raw API key in Authorization header (no Bearer prefix).
|
|
5
|
+
// API key found at: Settings → Developers → Public API
|
|
6
|
+
|
|
7
|
+
import type { SocialMediaAdapter } from "../adapter.js";
|
|
8
|
+
import type { SocialProfile, CreatePostInput, PostAnalytics, AccountAnalytics, SocialComment, SocialPlatform } from "../types.js";
|
|
9
|
+
|
|
10
|
+
const HOSTED_API = "https://api.postiz.com";
|
|
11
|
+
|
|
12
|
+
// Postiz uses different identifiers than our standard platform names
|
|
13
|
+
const PLATFORM_TO_POSTIZ: Record<string, string> = {
|
|
14
|
+
twitter: "x",
|
|
15
|
+
instagram: "instagram",
|
|
16
|
+
linkedin: "linkedin",
|
|
17
|
+
facebook: "facebook",
|
|
18
|
+
tiktok: "tiktok",
|
|
19
|
+
threads: "threads",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const POSTIZ_TO_PLATFORM: Record<string, SocialPlatform> = {
|
|
23
|
+
x: "twitter",
|
|
24
|
+
instagram: "instagram",
|
|
25
|
+
linkedin: "linkedin",
|
|
26
|
+
facebook: "facebook",
|
|
27
|
+
tiktok: "tiktok",
|
|
28
|
+
threads: "threads",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class PostizAdapter implements SocialMediaAdapter {
|
|
32
|
+
private baseUrl: string;
|
|
33
|
+
private apiKey: string;
|
|
34
|
+
/** Cached integrations (channel list) */
|
|
35
|
+
private integrations: Array<{ id: string; name: string; identifier: string; picture: string | null }> | null = null;
|
|
36
|
+
|
|
37
|
+
private apiPrefix: string;
|
|
38
|
+
|
|
39
|
+
constructor(config: { url?: string; apiKey: string }) {
|
|
40
|
+
const raw = (config.url || HOSTED_API).replace(/\/+$/, "");
|
|
41
|
+
// Ensure we point to the API base, not the frontend
|
|
42
|
+
this.baseUrl = raw.includes("/public/v1") ? raw.replace(/\/public\/v1.*/, "") : raw;
|
|
43
|
+
// Hosted (api.postiz.com) uses /public/v1, self-hosted uses /api/public/v1
|
|
44
|
+
this.apiPrefix = raw === HOSTED_API || !config.url ? "/public/v1" : "/api/public/v1";
|
|
45
|
+
this.apiKey = config.apiKey;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private url(path: string): string {
|
|
49
|
+
return `${this.baseUrl}${this.apiPrefix}${path}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private headers(): Record<string, string> {
|
|
53
|
+
return {
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
"Authorization": this.apiKey,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Connection ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async testConnection(): Promise<{ ok: boolean; error?: string; data?: unknown }> {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(this.url("/is-connected"), { headers: this.headers() });
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const text = await res.text().catch(() => "");
|
|
66
|
+
return { ok: false, error: `Postiz returned ${res.status}: ${text || res.statusText}` };
|
|
67
|
+
}
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (data.connected) {
|
|
70
|
+
return { ok: true, data };
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, error: "Not connected" };
|
|
73
|
+
} catch (err: any) {
|
|
74
|
+
return { ok: false, error: err?.message ?? "Connection failed" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Integrations / Profiles ────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
private async loadIntegrations(): Promise<typeof this.integrations> {
|
|
81
|
+
if (this.integrations) return this.integrations;
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(this.url("/integrations"), { headers: this.headers() });
|
|
84
|
+
if (!res.ok) return [];
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
this.integrations = (Array.isArray(data) ? data : []).map((item: any) => ({
|
|
87
|
+
id: item.id ?? "",
|
|
88
|
+
name: item.name ?? item.profile ?? item.identifier ?? "",
|
|
89
|
+
identifier: item.identifier ?? "",
|
|
90
|
+
picture: item.picture ?? null,
|
|
91
|
+
}));
|
|
92
|
+
return this.integrations;
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async getProfiles(): Promise<SocialProfile[]> {
|
|
99
|
+
const integrations = await this.loadIntegrations();
|
|
100
|
+
return (integrations || []).map((item) => ({
|
|
101
|
+
id: item.id,
|
|
102
|
+
platform: (POSTIZ_TO_PLATFORM[item.identifier] ?? item.identifier) as SocialPlatform,
|
|
103
|
+
name: item.name,
|
|
104
|
+
picture: item.picture,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
supportedPlatforms(): SocialPlatform[] {
|
|
109
|
+
return ["twitter", "instagram", "linkedin", "facebook", "tiktok", "threads"];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Posts ──────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async createPost(input: CreatePostInput): Promise<{ id: string | null; status: string; backendData?: unknown }> {
|
|
115
|
+
try {
|
|
116
|
+
const integrations = await this.loadIntegrations();
|
|
117
|
+
|
|
118
|
+
// Resolve integration IDs for selected platforms
|
|
119
|
+
const matchedIntegrations = (integrations || []).filter((ig) => {
|
|
120
|
+
const platform = POSTIZ_TO_PLATFORM[ig.identifier];
|
|
121
|
+
return platform && input.platforms.includes(platform);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (matchedIntegrations.length === 0) {
|
|
125
|
+
return { id: null, status: "failed", backendData: { error: "No connected integrations match the selected platforms" } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Upload media from URLs if present
|
|
129
|
+
const mediaItems: Array<{ id: string; path: string }> = [];
|
|
130
|
+
if (input.mediaUrls?.length) {
|
|
131
|
+
for (const mediaUrl of input.mediaUrls) {
|
|
132
|
+
try {
|
|
133
|
+
const uploadRes = await fetch(this.url("/upload-from-url"), {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: this.headers(),
|
|
136
|
+
body: JSON.stringify({ url: mediaUrl }),
|
|
137
|
+
});
|
|
138
|
+
if (uploadRes.ok) {
|
|
139
|
+
const uploadData = await uploadRes.json();
|
|
140
|
+
if (uploadData.id && uploadData.path) {
|
|
141
|
+
mediaItems.push({ id: uploadData.id, path: uploadData.path });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Skip failed uploads
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build post payload per Postiz API
|
|
151
|
+
const postEntries = matchedIntegrations.map((ig) => ({
|
|
152
|
+
integration: { id: ig.id },
|
|
153
|
+
value: [{
|
|
154
|
+
content: input.text,
|
|
155
|
+
image: mediaItems,
|
|
156
|
+
}],
|
|
157
|
+
settings: { __type: ig.identifier },
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
const body: Record<string, unknown> = {
|
|
161
|
+
type: input.scheduledAt ? "schedule" : "now",
|
|
162
|
+
date: input.scheduledAt || new Date().toISOString(),
|
|
163
|
+
shortLink: false,
|
|
164
|
+
posts: postEntries,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const res = await fetch(this.url("/posts"), {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: this.headers(),
|
|
170
|
+
body: JSON.stringify(body),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const data = await res.json();
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
return { id: null, status: "failed", backendData: data };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
id: data.id ?? data.groupId ?? null,
|
|
180
|
+
status: input.scheduledAt ? "scheduled" : "published",
|
|
181
|
+
backendData: data,
|
|
182
|
+
};
|
|
183
|
+
} catch (err: any) {
|
|
184
|
+
return { id: null, status: "failed", backendData: { error: err?.message } };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async listPosts(opts?: { limit?: number }): Promise<Array<{ id: string; text: string; status: string; platforms: string[]; createdAt?: string | null; scheduledAt?: string | null }>> {
|
|
189
|
+
try {
|
|
190
|
+
// Postiz requires startDate/endDate
|
|
191
|
+
const now = new Date();
|
|
192
|
+
const endDate = new Date(now.getTime() + 30 * 86400000).toISOString(); // +30 days
|
|
193
|
+
const startDate = new Date(now.getTime() - 90 * 86400000).toISOString(); // -90 days
|
|
194
|
+
|
|
195
|
+
const res = await fetch(
|
|
196
|
+
this.url(`/posts?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`),
|
|
197
|
+
{ headers: this.headers() },
|
|
198
|
+
);
|
|
199
|
+
if (!res.ok) return [];
|
|
200
|
+
|
|
201
|
+
const data = await res.json();
|
|
202
|
+
const posts = Array.isArray(data) ? data : data.posts ?? [];
|
|
203
|
+
const limit = opts?.limit ?? 50;
|
|
204
|
+
|
|
205
|
+
const STATE_MAP: Record<string, string> = {
|
|
206
|
+
QUEUE: "scheduled",
|
|
207
|
+
PUBLISHED: "published",
|
|
208
|
+
ERROR: "failed",
|
|
209
|
+
DRAFT: "draft",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return posts.slice(0, limit).map((p: any) => ({
|
|
213
|
+
id: p.id ?? "",
|
|
214
|
+
text: p.content ?? "",
|
|
215
|
+
status: STATE_MAP[p.state] ?? p.state?.toLowerCase() ?? "unknown",
|
|
216
|
+
platforms: p.integration?.identifier
|
|
217
|
+
? [POSTIZ_TO_PLATFORM[p.integration.identifier] ?? p.integration.identifier]
|
|
218
|
+
: [],
|
|
219
|
+
createdAt: p.createdAt ?? null,
|
|
220
|
+
scheduledAt: p.publishDate ?? null,
|
|
221
|
+
}));
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async deletePost(postId: string): Promise<boolean> {
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch(this.url(`/posts/${postId}`), {
|
|
230
|
+
method: "DELETE",
|
|
231
|
+
headers: this.headers(),
|
|
232
|
+
});
|
|
233
|
+
return res.ok;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Analytics ──────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async getAnalytics(postId: string): Promise<PostAnalytics> {
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch(this.url(`/analytics/post/${postId}?date=30`), { headers: this.headers() });
|
|
244
|
+
if (!res.ok) return { impressions: 0, likes: 0, shares: 0, comments: 0 };
|
|
245
|
+
const data = await res.json();
|
|
246
|
+
if (data.missing) return { impressions: 0, likes: 0, shares: 0, comments: 0 };
|
|
247
|
+
|
|
248
|
+
// Postiz returns array of { label, data: [{total, date}], percentageChange }
|
|
249
|
+
const metrics = Array.isArray(data) ? data : [];
|
|
250
|
+
const getValue = (label: string) => {
|
|
251
|
+
const m = metrics.find((x: any) => x.label?.toLowerCase().includes(label));
|
|
252
|
+
return m?.data?.reduce((sum: number, d: any) => sum + (parseInt(d.total) || 0), 0) ?? 0;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
impressions: getValue("impression") || getValue("reach") || getValue("view"),
|
|
257
|
+
likes: getValue("like") || getValue("favorite"),
|
|
258
|
+
shares: getValue("share") || getValue("retweet") || getValue("repost"),
|
|
259
|
+
comments: getValue("comment") || getValue("reply"),
|
|
260
|
+
};
|
|
261
|
+
} catch {
|
|
262
|
+
return { impressions: 0, likes: 0, shares: 0, comments: 0 };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async getAccountAnalytics(profileId: string): Promise<AccountAnalytics> {
|
|
267
|
+
try {
|
|
268
|
+
const res = await fetch(this.url(`/analytics/${profileId}?date=30`), { headers: this.headers() });
|
|
269
|
+
if (!res.ok) return { followers: 0, following: 0, posts: 0 };
|
|
270
|
+
const data = await res.json();
|
|
271
|
+
|
|
272
|
+
const metrics = Array.isArray(data) ? data : [];
|
|
273
|
+
const getLatest = (label: string) => {
|
|
274
|
+
const m = metrics.find((x: any) => x.label?.toLowerCase().includes(label));
|
|
275
|
+
const latest = m?.data?.[m.data.length - 1];
|
|
276
|
+
return parseInt(latest?.total) || 0;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
followers: getLatest("follower"),
|
|
281
|
+
following: getLatest("following"),
|
|
282
|
+
posts: getLatest("post") || getLatest("tweet"),
|
|
283
|
+
};
|
|
284
|
+
} catch {
|
|
285
|
+
return { followers: 0, following: 0, posts: 0 };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Comments (not supported by Postiz API) ────────────────────────────────
|
|
290
|
+
|
|
291
|
+
async getComments(_postId: string): Promise<SocialComment[]> {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async replyToComment(_postId: string, _commentId: string | null, _text: string): Promise<{ ok: boolean; error?: string }> {
|
|
296
|
+
return { ok: false, error: "Postiz does not support comment management via API" };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// ─── Social Media Manager ────────────────────────────────────────────────────
|
|
2
|
+
// Business logic layer for social media operations.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import type { SocialMediaAdapter } from "./adapter.js";
|
|
6
|
+
import type {
|
|
7
|
+
SocialMediaSettings,
|
|
8
|
+
SocialPost,
|
|
9
|
+
CreatePostInput,
|
|
10
|
+
PostAnalytics,
|
|
11
|
+
AccountAnalytics,
|
|
12
|
+
SocialComment,
|
|
13
|
+
SocialProfile,
|
|
14
|
+
ListPostsOpts,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
import * as store from "./store.js";
|
|
17
|
+
|
|
18
|
+
let cachedAdapter: SocialMediaAdapter | null = null;
|
|
19
|
+
let cachedBackendKey: string | null = null;
|
|
20
|
+
|
|
21
|
+
export async function getAdapter(settings?: SocialMediaSettings): Promise<SocialMediaAdapter> {
|
|
22
|
+
const s = settings ?? store.getSettings();
|
|
23
|
+
if (!s.backend) {
|
|
24
|
+
throw new Error("No social media backend configured");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const config = s.backends[s.backend];
|
|
28
|
+
if (!config) {
|
|
29
|
+
throw new Error(`No configuration found for backend: ${s.backend}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Cache adapter if same backend + config
|
|
33
|
+
const key = `${s.backend}:${config.apiKey}:${config.url ?? ""}`;
|
|
34
|
+
if (cachedAdapter && cachedBackendKey === key) {
|
|
35
|
+
return cachedAdapter;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let adapter: SocialMediaAdapter;
|
|
39
|
+
|
|
40
|
+
switch (s.backend) {
|
|
41
|
+
case "postiz": {
|
|
42
|
+
const { PostizAdapter } = await import("./adapters/postiz-adapter.js");
|
|
43
|
+
adapter = new PostizAdapter({ url: config.url ?? "", apiKey: config.apiKey });
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "ayrshare": {
|
|
47
|
+
const { AyrshareAdapter } = await import("./adapters/ayrshare-adapter.js");
|
|
48
|
+
adapter = new AyrshareAdapter({ apiKey: config.apiKey });
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "buffer": {
|
|
52
|
+
const { BufferAdapter } = await import("./adapters/buffer-adapter.js");
|
|
53
|
+
adapter = new BufferAdapter({ apiKey: config.apiKey });
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`Unknown social media backend: ${s.backend}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cachedAdapter = adapter;
|
|
61
|
+
cachedBackendKey = key;
|
|
62
|
+
return adapter;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function testConnection(): Promise<{ ok: boolean; error?: string; data?: unknown }> {
|
|
66
|
+
try {
|
|
67
|
+
const adapter = await getAdapter();
|
|
68
|
+
return await adapter.testConnection();
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
return { ok: false, error: err?.message ?? "Unknown error" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function getProfiles(): Promise<SocialProfile[]> {
|
|
75
|
+
const adapter = await getAdapter();
|
|
76
|
+
return adapter.getProfiles();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function createPost(input: CreatePostInput): Promise<SocialPost> {
|
|
80
|
+
const settings = store.getSettings();
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
|
|
83
|
+
const post: SocialPost = {
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
text: input.text,
|
|
86
|
+
platforms: input.platforms,
|
|
87
|
+
scheduledAt: input.scheduledAt ?? null,
|
|
88
|
+
mediaUrls: input.mediaUrls ?? [],
|
|
89
|
+
status: "draft",
|
|
90
|
+
backendId: settings.backend,
|
|
91
|
+
backendPostId: null,
|
|
92
|
+
createdAt: now,
|
|
93
|
+
updatedAt: now,
|
|
94
|
+
title: input.title,
|
|
95
|
+
firstComment: input.firstComment,
|
|
96
|
+
videoUrl: input.videoUrl,
|
|
97
|
+
thumbnailUrl: input.thumbnailUrl,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// If isDraft, save locally and return without calling backend
|
|
101
|
+
if (input.isDraft) {
|
|
102
|
+
post.backendId = null;
|
|
103
|
+
store.savePost(post);
|
|
104
|
+
return post;
|
|
105
|
+
}
|
|
106
|
+
|
|
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 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
post.updatedAt = new Date().toISOString();
|
|
119
|
+
store.savePost(post);
|
|
120
|
+
return post;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function listPosts(opts?: ListPostsOpts): Promise<SocialPost[]> {
|
|
124
|
+
return store.listLocalPosts(opts);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getPost(id: string): SocialPost | null {
|
|
128
|
+
return store.getPost(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function deletePost(id: string): Promise<boolean> {
|
|
132
|
+
const post = store.getPost(id);
|
|
133
|
+
if (!post) return false;
|
|
134
|
+
|
|
135
|
+
// Try to delete from backend if we have a backend post ID
|
|
136
|
+
if (post.backendPostId) {
|
|
137
|
+
try {
|
|
138
|
+
const adapter = await getAdapter();
|
|
139
|
+
await adapter.deletePost(post.backendPostId);
|
|
140
|
+
} catch {
|
|
141
|
+
// Continue with local deletion even if backend fails
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return store.deleteLocalPost(id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function getPostAnalytics(id: string): Promise<PostAnalytics> {
|
|
149
|
+
const post = store.getPost(id);
|
|
150
|
+
if (!post?.backendPostId) {
|
|
151
|
+
return { impressions: 0, likes: 0, shares: 0, comments: 0 };
|
|
152
|
+
}
|
|
153
|
+
const adapter = await getAdapter();
|
|
154
|
+
return adapter.getAnalytics(post.backendPostId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function getAccountAnalytics(profileId: string): Promise<AccountAnalytics> {
|
|
158
|
+
const adapter = await getAdapter();
|
|
159
|
+
return adapter.getAccountAnalytics(profileId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function getComments(postId: string): Promise<SocialComment[]> {
|
|
163
|
+
const post = store.getPost(postId);
|
|
164
|
+
if (!post?.backendPostId) return [];
|
|
165
|
+
const adapter = await getAdapter();
|
|
166
|
+
return adapter.getComments(post.backendPostId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function replyToComment(postId: string, commentId: string | null, text: string): Promise<{ ok: boolean; error?: string }> {
|
|
170
|
+
const post = store.getPost(postId);
|
|
171
|
+
if (!post?.backendPostId) {
|
|
172
|
+
return { ok: false, error: "Post not found or has no backend ID" };
|
|
173
|
+
}
|
|
174
|
+
const adapter = await getAdapter();
|
|
175
|
+
return adapter.replyToComment(post.backendPostId, commentId, text);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function createDraft(input: CreatePostInput & { createdBy?: "user" | "gemini" | "agent" }): Promise<SocialPost> {
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
const post: SocialPost = {
|
|
181
|
+
id: randomUUID(),
|
|
182
|
+
text: input.text,
|
|
183
|
+
platforms: input.platforms,
|
|
184
|
+
scheduledAt: input.scheduledAt ?? null,
|
|
185
|
+
mediaUrls: input.mediaUrls ?? [],
|
|
186
|
+
status: "draft",
|
|
187
|
+
backendId: null,
|
|
188
|
+
backendPostId: null,
|
|
189
|
+
createdAt: now,
|
|
190
|
+
updatedAt: now,
|
|
191
|
+
title: input.title,
|
|
192
|
+
firstComment: input.firstComment,
|
|
193
|
+
videoUrl: input.videoUrl,
|
|
194
|
+
thumbnailUrl: input.thumbnailUrl,
|
|
195
|
+
createdBy: input.createdBy ?? "user",
|
|
196
|
+
};
|
|
197
|
+
store.savePost(post);
|
|
198
|
+
return post;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function publishDraft(id: string): Promise<SocialPost> {
|
|
202
|
+
const post = store.getPost(id);
|
|
203
|
+
if (!post) throw new Error("Post not found");
|
|
204
|
+
if (post.status !== "draft") throw new Error("Post is not a draft");
|
|
205
|
+
|
|
206
|
+
const settings = store.getSettings();
|
|
207
|
+
try {
|
|
208
|
+
const adapter = await getAdapter(settings);
|
|
209
|
+
const result = await adapter.createPost({
|
|
210
|
+
text: post.text,
|
|
211
|
+
platforms: post.platforms,
|
|
212
|
+
scheduledAt: post.scheduledAt,
|
|
213
|
+
mediaUrls: post.mediaUrls,
|
|
214
|
+
});
|
|
215
|
+
post.backendId = settings.backend;
|
|
216
|
+
post.backendPostId = result.id;
|
|
217
|
+
post.status = result.status as SocialPost["status"];
|
|
218
|
+
post.backendData = result.backendData;
|
|
219
|
+
} catch (err: any) {
|
|
220
|
+
post.status = "failed";
|
|
221
|
+
post.backendData = { error: err?.message };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
post.updatedAt = new Date().toISOString();
|
|
225
|
+
store.savePost(post);
|
|
226
|
+
return post;
|
|
227
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ─── Social Media Store ──────────────────────────────────────────────────────
|
|
2
|
+
// File-based persistence for social media settings and posts.
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type { SocialMediaSettings, SocialPost, ListPostsOpts } from "./types.js";
|
|
9
|
+
import { DEFAULT_SOCIAL_SETTINGS } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const BASE_DIR = join(homedir(), ".heyhank", "socialmedia");
|
|
12
|
+
const SETTINGS_FILE = join(BASE_DIR, "settings.json");
|
|
13
|
+
const POSTS_DIR = join(BASE_DIR, "posts");
|
|
14
|
+
|
|
15
|
+
function ensureDirs(): void {
|
|
16
|
+
if (!existsSync(BASE_DIR)) mkdirSync(BASE_DIR, { recursive: true });
|
|
17
|
+
if (!existsSync(POSTS_DIR)) mkdirSync(POSTS_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Settings ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export function getSettings(): SocialMediaSettings {
|
|
23
|
+
ensureDirs();
|
|
24
|
+
if (!existsSync(SETTINGS_FILE)) return { ...DEFAULT_SOCIAL_SETTINGS };
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(SETTINGS_FILE, "utf-8");
|
|
27
|
+
return { ...DEFAULT_SOCIAL_SETTINGS, ...JSON.parse(raw) };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ...DEFAULT_SOCIAL_SETTINGS };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveSettings(settings: SocialMediaSettings): void {
|
|
34
|
+
ensureDirs();
|
|
35
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Posts ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function savePost(post: SocialPost): void {
|
|
41
|
+
ensureDirs();
|
|
42
|
+
if (!post.id) {
|
|
43
|
+
post.id = randomUUID();
|
|
44
|
+
}
|
|
45
|
+
const file = join(POSTS_DIR, `${post.id}.json`);
|
|
46
|
+
writeFileSync(file, JSON.stringify(post, null, 2), "utf-8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getPost(postId: string): SocialPost | null {
|
|
50
|
+
const file = join(POSTS_DIR, `${postId}.json`);
|
|
51
|
+
if (!existsSync(file)) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listLocalPosts(opts?: ListPostsOpts): SocialPost[] {
|
|
60
|
+
ensureDirs();
|
|
61
|
+
const limit = opts?.limit ?? 50;
|
|
62
|
+
try {
|
|
63
|
+
const files = readdirSync(POSTS_DIR)
|
|
64
|
+
.filter((f) => f.endsWith(".json"))
|
|
65
|
+
.sort()
|
|
66
|
+
.reverse();
|
|
67
|
+
|
|
68
|
+
let posts = files.map((f) => {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(join(POSTS_DIR, f), "utf-8")) as SocialPost;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}).filter(Boolean) as SocialPost[];
|
|
75
|
+
|
|
76
|
+
if (opts?.status) {
|
|
77
|
+
posts = posts.filter((p) => p.status === opts.status);
|
|
78
|
+
}
|
|
79
|
+
if (opts?.platform) {
|
|
80
|
+
posts = posts.filter((p) => p.platforms.includes(opts.platform as any));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return posts.slice(0, limit);
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function deleteLocalPost(postId: string): boolean {
|
|
90
|
+
const file = join(POSTS_DIR, `${postId}.json`);
|
|
91
|
+
if (!existsSync(file)) return false;
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(file);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|