zapry-openclaw-plugin 0.0.1

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/src/monitor.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { ZapryApiClient } from "./api-client.js";
2
+ import { processZapryInboundUpdate, tryHandleZapryInboundQuickPaths } from "./inbound.js";
3
+ import type { ResolvedZapryAccount } from "./types.js";
4
+
5
+ export type MonitorContext = {
6
+ account: ResolvedZapryAccount;
7
+ cfg?: any;
8
+ runtime?: any;
9
+ abortSignal?: AbortSignal;
10
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
11
+ log?: {
12
+ info: (...args: any[]) => void;
13
+ warn: (...args: any[]) => void;
14
+ error?: (...args: any[]) => void;
15
+ debug?: (...args: any[]) => void;
16
+ };
17
+ };
18
+
19
+ function asRecord(value: unknown): Record<string, unknown> | null {
20
+ if (!value || typeof value !== "object") {
21
+ return null;
22
+ }
23
+ return value as Record<string, unknown>;
24
+ }
25
+
26
+ function summarizeUnhandledInboundUpdate(update: unknown): string {
27
+ const record = asRecord(update);
28
+ if (!record) {
29
+ return "non-object update payload";
30
+ }
31
+ const topKeys = Object.keys(record);
32
+ const messageLike =
33
+ asRecord(record.message) ??
34
+ asRecord(record.channel_post) ??
35
+ asRecord(record.edited_message) ??
36
+ asRecord(record.edited_channel_post) ??
37
+ asRecord((record.callback_query as any)?.message) ??
38
+ asRecord(record.msg) ??
39
+ asRecord(record.Message);
40
+ const messageKeys = messageLike ? Object.keys(messageLike) : [];
41
+ return `top_keys=${JSON.stringify(topKeys.slice(0, 20))} message_keys=${JSON.stringify(messageKeys.slice(0, 24))}`;
42
+ }
43
+
44
+ export async function monitorZapryProvider(ctx: MonitorContext): Promise<void> {
45
+ const { account } = ctx;
46
+ if (account.config.mode === "webhook") {
47
+ await startWebhookMode(ctx);
48
+ } else {
49
+ await startPollingMode(ctx);
50
+ }
51
+ }
52
+
53
+ async function dispatchInboundUpdate(ctx: MonitorContext, update: any): Promise<boolean> {
54
+ const { statusSink, log } = ctx;
55
+
56
+ // Priority quick paths: handle deterministic moderation actions before any host callback mode.
57
+ const handledByQuickPath = await tryHandleZapryInboundQuickPaths({
58
+ account: ctx.account,
59
+ update,
60
+ statusSink,
61
+ log,
62
+ });
63
+ if (handledByQuickPath) {
64
+ return true;
65
+ }
66
+
67
+ return processZapryInboundUpdate({
68
+ account: ctx.account,
69
+ cfg: ctx.cfg,
70
+ runtime: ctx.runtime,
71
+ update,
72
+ statusSink,
73
+ log,
74
+ });
75
+ }
76
+
77
+ async function startPollingMode(ctx: MonitorContext): Promise<void> {
78
+ const { account, abortSignal, log } = ctx;
79
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken);
80
+
81
+ await client.deleteWebhook();
82
+ log?.info(`[${account.accountId}] polling mode started`);
83
+
84
+ let offset = 0;
85
+ let warnedMissingInboundHandler = false;
86
+ let loggedUnhandledSample = false;
87
+ let lastPollingApiError = "";
88
+ while (!abortSignal?.aborted) {
89
+ try {
90
+ const resp = await client.getUpdates(offset, 100, 30);
91
+ if (!resp.ok) {
92
+ const errorSig = `${resp.error_code ?? "unknown"}:${resp.description ?? "unknown"}`;
93
+ if (errorSig !== lastPollingApiError) {
94
+ lastPollingApiError = errorSig;
95
+ log?.warn(`[${account.accountId}] getUpdates failed: ${errorSig}`);
96
+ }
97
+ await sleep(3000);
98
+ continue;
99
+ }
100
+
101
+ lastPollingApiError = "";
102
+ if (!Array.isArray(resp.result)) {
103
+ continue;
104
+ }
105
+
106
+ for (const update of resp.result) {
107
+ const handled = await dispatchInboundUpdate(ctx, update);
108
+ if (!handled && !warnedMissingInboundHandler) {
109
+ warnedMissingInboundHandler = true;
110
+ log?.warn(
111
+ `[${account.accountId}] inbound update received but no compatible handler is available`,
112
+ );
113
+ }
114
+ if (!handled && !loggedUnhandledSample) {
115
+ loggedUnhandledSample = true;
116
+ log?.warn(
117
+ `[${account.accountId}] unhandled inbound update sample: ${summarizeUnhandledInboundUpdate(update)}`,
118
+ );
119
+ }
120
+ const updateId = (update as any).update_id;
121
+ if (typeof updateId === "number" && updateId >= offset) {
122
+ offset = updateId + 1;
123
+ }
124
+ }
125
+ } catch (err) {
126
+ if (abortSignal?.aborted) break;
127
+ log?.warn(`[${account.accountId}] polling error: ${String(err)}`);
128
+ await sleep(3000);
129
+ }
130
+ }
131
+ log?.info(`[${account.accountId}] polling stopped`);
132
+ }
133
+
134
+ async function startWebhookMode(ctx: MonitorContext): Promise<void> {
135
+ const { account, log } = ctx;
136
+ log?.warn(
137
+ `[${account.accountId}] webhook inbound mode has been removed from the plugin runtime, falling back to polling`,
138
+ );
139
+ return startPollingMode(ctx);
140
+ }
141
+
142
+ function sleep(ms: number): Promise<void> {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
@@ -0,0 +1,195 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import { join, relative, basename } from "node:path";
4
+ import { ZapryApiClient } from "./api-client.js";
5
+ import type {
6
+ ProfileSource,
7
+ ProfileSourceSkill,
8
+ ResolvedZapryAccount,
9
+ } from "./types.js";
10
+
11
+ const SKILL_KEY_RE = /^\s*skillKey:\s*["']?([^"'\n]+)["']?\s*$/m;
12
+ const SKILL_VERSION_RE = /^\s*skillVersion:\s*["']?([^"'\n]+)["']?\s*$/m;
13
+ const GENERIC_VERSION_RE = /^\s*version:\s*["']?([^"'\n]+)["']?\s*$/m;
14
+
15
+ // ── Public entry ──
16
+
17
+ export async function syncProfileToZapry(
18
+ account: ResolvedZapryAccount,
19
+ opts?: { projectRoot?: string; log?: any },
20
+ ): Promise<void> {
21
+ const log = opts?.log;
22
+ const projectRoot = opts?.projectRoot || process.cwd();
23
+
24
+ const soulPath = join(projectRoot, "SOUL.md");
25
+ const soulRaw = await readFileSafe(soulPath);
26
+ if (!soulRaw) {
27
+ log?.debug?.(`[profile-sync] No SOUL.md found at ${soulPath}, skipping`);
28
+ return;
29
+ }
30
+
31
+ const skills = await collectSkills(join(projectRoot, "skills"), projectRoot);
32
+ if (skills.length === 0) {
33
+ log?.debug?.(`[profile-sync] No skills found under ${join(projectRoot, "skills")}, skipping`);
34
+ return;
35
+ }
36
+
37
+ const agentKey = basename(projectRoot);
38
+ const snapshotId = computeSnapshotId(soulRaw, skills);
39
+
40
+ const profileSource: ProfileSource = {
41
+ version: "v1",
42
+ source: "openclaw-plugin",
43
+ agentKey,
44
+ snapshotId,
45
+ soulMd: soulRaw,
46
+ skills,
47
+ };
48
+
49
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken);
50
+
51
+ log?.info?.(
52
+ `[profile-sync] Registering profile: agent=${agentKey} skills=${skills.map((s) => s.skillKey).join(",")} snapshot=${snapshotId.slice(0, 12)}…`,
53
+ );
54
+
55
+ const resp = await client.setMyProfile({ profileSource });
56
+
57
+ if (resp.ok) {
58
+ const derived = (resp.result as any)?.derived ?? (resp as any).derived;
59
+ const derivedName = derived?.profile?.name ?? "";
60
+ const derivedSkills = derived?.profile?.skills?.length ?? 0;
61
+ log?.info?.(
62
+ `[profile-sync] Profile registered: name=${derivedName} derivedSkills=${derivedSkills} snapshot=${derived?.snapshotId?.slice(0, 12) ?? "?"}`,
63
+ );
64
+
65
+ const soulName = deriveNameFromSoul(soulRaw);
66
+ if (soulName) {
67
+ try {
68
+ await client.setMyName(soulName);
69
+ log?.info?.(`[profile-sync] Name synced: ${soulName}`);
70
+ } catch (err) {
71
+ log?.warn?.(
72
+ `[profile-sync] setMyName fallback failed: ${err instanceof Error ? err.message : String(err)}`,
73
+ );
74
+ }
75
+ }
76
+ } else {
77
+ log?.warn?.(
78
+ `[profile-sync] setMyProfile failed: ${resp.error_code ?? "?"} ${resp.description ?? "unknown"}`,
79
+ );
80
+ }
81
+ }
82
+
83
+ // ── File discovery ──
84
+
85
+ async function readFileSafe(path: string): Promise<string | null> {
86
+ try {
87
+ return await readFile(path, "utf-8");
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async function collectSkills(
94
+ skillsRoot: string,
95
+ projectRoot: string,
96
+ ): Promise<ProfileSourceSkill[]> {
97
+ let entries: string[];
98
+ try {
99
+ entries = await readdir(skillsRoot);
100
+ } catch {
101
+ return [];
102
+ }
103
+
104
+ const skills: ProfileSourceSkill[] = [];
105
+
106
+ for (const entry of entries.sort()) {
107
+ const skillMdPath = join(skillsRoot, entry, "SKILL.md");
108
+ const raw = await readFileSafe(skillMdPath);
109
+ if (!raw) continue;
110
+
111
+ const frontmatter = extractFrontmatter(raw);
112
+ const relPath = relative(projectRoot, skillMdPath).replace(/\\/g, "/");
113
+ const skillKey = extractField(SKILL_KEY_RE, frontmatter) || entry;
114
+ const skillVersion =
115
+ extractField(SKILL_VERSION_RE, frontmatter) ||
116
+ extractField(GENERIC_VERSION_RE, frontmatter) ||
117
+ "1.0.0";
118
+
119
+ skills.push({
120
+ skillKey,
121
+ skillVersion,
122
+ source: "local",
123
+ path: relPath,
124
+ content: raw,
125
+ sha256: sha256Hex(raw),
126
+ bytes: Buffer.byteLength(raw, "utf-8"),
127
+ });
128
+ }
129
+
130
+ return skills;
131
+ }
132
+
133
+ // ── Name extraction (mirrors Go SDK deriveNameFromSoul) ──
134
+
135
+ function deriveNameFromSoul(soul: string): string {
136
+ const lines = soul.replace(/\r\n/g, "\n").split("\n");
137
+ for (const line of lines) {
138
+ const text = line.trim();
139
+ if (text.startsWith("#")) {
140
+ let title = text.replace(/^#+\s*/, "");
141
+ title = title.replace(/^SOUL\.md\s*-\s*/i, "").trim();
142
+ return title;
143
+ }
144
+ }
145
+ return "";
146
+ }
147
+
148
+ // ── Frontmatter / field extraction ──
149
+
150
+ function extractFrontmatter(content: string): string {
151
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
152
+ if (!normalized.startsWith("---\n")) return "";
153
+ const lines = normalized.split("\n");
154
+ for (let i = 1; i < lines.length; i++) {
155
+ if (lines[i].trim() === "---") {
156
+ return lines.slice(1, i).join("\n");
157
+ }
158
+ }
159
+ return "";
160
+ }
161
+
162
+ function extractField(re: RegExp, text: string): string {
163
+ const m = re.exec(text);
164
+ return m?.[1]?.trim() ?? "";
165
+ }
166
+
167
+ // ── Snapshot ID (matches Go SDK algorithm) ──
168
+
169
+ function computeSnapshotId(soulMd: string, skills: ProfileSourceSkill[]): string {
170
+ const normalizedSoul = normalizeSoulMarkdown(soulMd);
171
+ const normalizedIndex = normalizeSkillsIndex(skills);
172
+ return sha256Hex(normalizedSoul + "\n" + normalizedIndex);
173
+ }
174
+
175
+ function normalizeSoulMarkdown(content: string): string {
176
+ let c = content.replace(/^\uFEFF/, "");
177
+ c = c.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
178
+ c = c
179
+ .split("\n")
180
+ .map((line) => line.replace(/[ \t]+$/, ""))
181
+ .join("\n");
182
+ return c.replace(/[ \t\n]+$/, "");
183
+ }
184
+
185
+ function normalizeSkillsIndex(skills: ProfileSourceSkill[]): string {
186
+ const sorted = [...skills].sort((a, b) => {
187
+ if (a.skillKey === b.skillKey) return a.skillVersion < b.skillVersion ? -1 : 1;
188
+ return a.skillKey < b.skillKey ? -1 : 1;
189
+ });
190
+ return sorted.map((s) => `${s.skillKey}|${s.skillVersion || "1.0.0"}|${s.sha256}`).join("\n");
191
+ }
192
+
193
+ function sha256Hex(data: string): string {
194
+ return createHash("sha256").update(data, "utf-8").digest("hex");
195
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ type PluginRuntime = any;
4
+
5
+ export type ZaprySkillInvocationContext = {
6
+ senderId: string;
7
+ messageSid?: string;
8
+ sessionKey?: string;
9
+ accountId?: string;
10
+ chatId?: string;
11
+ };
12
+
13
+ let _runtime: PluginRuntime | null = null;
14
+ const _skillInvocationContext = new AsyncLocalStorage<ZaprySkillInvocationContext>();
15
+
16
+ export function setZapryRuntime(runtime: PluginRuntime): void {
17
+ _runtime = runtime;
18
+ }
19
+
20
+ export function getZapryRuntime(): PluginRuntime {
21
+ if (!_runtime) {
22
+ throw new Error("Zapry plugin runtime not initialized");
23
+ }
24
+ return _runtime;
25
+ }
26
+
27
+ export function runWithZaprySkillInvocationContext<T>(
28
+ ctx: ZaprySkillInvocationContext,
29
+ fn: () => T,
30
+ ): T {
31
+ return _skillInvocationContext.run(ctx, fn);
32
+ }
33
+
34
+ export function getZaprySkillInvocationContext(): ZaprySkillInvocationContext | null {
35
+ return _skillInvocationContext.getStore() ?? null;
36
+ }
37
+
38
+ export function buildZaprySkillRequestHeaders(input: {
39
+ senderId?: string;
40
+ messageSid?: string;
41
+ }): Record<string, string> {
42
+ const senderId = String(input.senderId ?? "").trim();
43
+ if (!senderId) {
44
+ throw new Error("Zapry skill invocation requires trusted inbound sender context");
45
+ }
46
+
47
+ const headers: Record<string, string> = {
48
+ "X-Zapry-Invocation-Source": "skill",
49
+ "X-Zapry-Request-Sender-Id": senderId,
50
+ };
51
+
52
+ const messageSid = String(input.messageSid ?? "").trim();
53
+ if (messageSid) {
54
+ headers["X-Zapry-Message-Sid"] = messageSid;
55
+ }
56
+
57
+ return headers;
58
+ }
59
+
60
+ export function resolveZaprySkillRequestHeaders(): Record<string, string> {
61
+ const invocationCtx = getZaprySkillInvocationContext();
62
+ return buildZaprySkillRequestHeaders({
63
+ senderId: invocationCtx?.senderId,
64
+ messageSid: invocationCtx?.messageSid,
65
+ });
66
+ }
package/src/send.ts ADDED
@@ -0,0 +1,257 @@
1
+ import { ZapryApiClient } from "./api-client.js";
2
+ import type { ResolvedZapryAccount, ZaprySendResult } from "./types.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import { extname } from "node:path";
5
+
6
+ const MIME_MAP: Record<string, string> = {
7
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
8
+ ".webp": "image/webp", ".gif": "image/gif", ".bmp": "image/bmp",
9
+ ".heic": "image/heic", ".heif": "image/heif",
10
+ ".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".webm": "video/webm",
11
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".aac": "audio/aac",
12
+ ".ogg": "audio/ogg", ".opus": "audio/opus", ".m4a": "audio/mp4", ".amr": "audio/amr",
13
+ ".flac": "audio/flac",
14
+ };
15
+
16
+ const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "bmp", "heic", "heif"]);
17
+ const ANIMATION_EXTS = new Set(["gif"]);
18
+ const VIDEO_EXTS = new Set(["mp4", "mov", "avi", "webm"]);
19
+ const VOICE_EXTS = new Set(["opus", "ogg", "oga", "amr", "m4a"]);
20
+ const AUDIO_EXTS = new Set(["mp3", "wav", "aac", "flac", "m4b"]);
21
+ const EXTERNAL_IMAGE_FETCH_TIMEOUT_MS = 15_000;
22
+ const MAX_EXTERNAL_IMAGE_BYTES = 10 * 1024 * 1024;
23
+
24
+ async function toDataUri(localPath: string): Promise<string> {
25
+ const buf = await readFile(localPath);
26
+ const ext = extname(localPath).toLowerCase();
27
+ const mime = MIME_MAP[ext] || "application/octet-stream";
28
+ return `data:${mime};base64,${buf.toString("base64")}`;
29
+ }
30
+
31
+ function isLocalPath(url: string): boolean {
32
+ return /^(\/|~\/|\.\/|\.\.\/)/.test(url) || /^[a-zA-Z]:\\/.test(url);
33
+ }
34
+
35
+ function isHttpUrl(url: string): boolean {
36
+ return /^https?:\/\//i.test(url);
37
+ }
38
+
39
+ function extractDataUriMime(source: string): string | null {
40
+ const match = /^data:([^;,]+)[;,]/i.exec(source.trim());
41
+ if (!match) {
42
+ return null;
43
+ }
44
+ return String(match[1]).trim().toLowerCase();
45
+ }
46
+
47
+ function mediaKindFromMime(mime: string): "animation" | "photo" | "video" | "voice" | "audio" | "document" {
48
+ const normalized = mime.toLowerCase();
49
+ if (normalized === "image/gif") {
50
+ return "animation";
51
+ }
52
+ if (normalized.startsWith("image/")) {
53
+ return "photo";
54
+ }
55
+ if (normalized.startsWith("video/")) {
56
+ return "video";
57
+ }
58
+ if (normalized.startsWith("audio/")) {
59
+ if (/(ogg|opus|amr|x-m4a|mp4)/i.test(normalized)) {
60
+ return "voice";
61
+ }
62
+ return "audio";
63
+ }
64
+ return "document";
65
+ }
66
+
67
+ function inferMimeFromPath(pathLike: string): string {
68
+ const clean = pathLike.split("?")[0]?.split("#")[0] ?? pathLike;
69
+ const ext = extname(clean).toLowerCase();
70
+ return MIME_MAP[ext] || "application/octet-stream";
71
+ }
72
+
73
+ function detectMediaKind(mediaRef: string, originalSource: string): "animation" | "photo" | "video" | "voice" | "audio" | "document" {
74
+ const dataUriMime = extractDataUriMime(mediaRef);
75
+ if (dataUriMime) {
76
+ return mediaKindFromMime(dataUriMime);
77
+ }
78
+
79
+ const clean = (originalSource || mediaRef).split("?")[0]?.split("#")[0] ?? (originalSource || mediaRef);
80
+ const ext = clean.split(".").pop()?.toLowerCase() ?? "";
81
+ if (ANIMATION_EXTS.has(ext)) {
82
+ return "animation";
83
+ }
84
+ if (IMAGE_EXTS.has(ext)) {
85
+ return "photo";
86
+ }
87
+ if (VIDEO_EXTS.has(ext)) {
88
+ return "video";
89
+ }
90
+ if (VOICE_EXTS.has(ext)) {
91
+ return "voice";
92
+ }
93
+ if (AUDIO_EXTS.has(ext)) {
94
+ return "audio";
95
+ }
96
+ return "document";
97
+ }
98
+
99
+ function normalizeContentType(contentType: string | null): string {
100
+ return String(contentType ?? "")
101
+ .split(";")[0]
102
+ .trim()
103
+ .toLowerCase();
104
+ }
105
+
106
+ function isGifBinary(binary: Buffer): boolean {
107
+ if (binary.length < 6) {
108
+ return false;
109
+ }
110
+ const header = binary.subarray(0, 6).toString("ascii");
111
+ return header === "GIF87a" || header === "GIF89a";
112
+ }
113
+
114
+ async function readResponseBodyWithSizeLimit(response: Response, maxBytes: number): Promise<Buffer> {
115
+ const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
116
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
117
+ throw new Error(`file is too large (${contentLength} bytes), max ${maxBytes} bytes`);
118
+ }
119
+ if (!response.body) {
120
+ throw new Error("empty response body");
121
+ }
122
+
123
+ const reader = response.body.getReader();
124
+ const chunks: Buffer[] = [];
125
+ let total = 0;
126
+ while (true) {
127
+ const { done, value } = await reader.read();
128
+ if (done) {
129
+ break;
130
+ }
131
+ if (!value || value.byteLength === 0) {
132
+ continue;
133
+ }
134
+ total += value.byteLength;
135
+ if (total > maxBytes) {
136
+ throw new Error(`file is too large (${total} bytes), max ${maxBytes} bytes`);
137
+ }
138
+ chunks.push(Buffer.from(value));
139
+ }
140
+ return Buffer.concat(chunks, total);
141
+ }
142
+
143
+ function isAbortError(err: unknown): boolean {
144
+ if (!err || typeof err !== "object") {
145
+ return false;
146
+ }
147
+ const maybeError = err as { name?: string; code?: string };
148
+ return maybeError.name === "AbortError" || maybeError.code === "ABORT_ERR";
149
+ }
150
+
151
+ async function downloadGifAsDataUri(source: string): Promise<string> {
152
+ const controller = new AbortController();
153
+ const timeout = setTimeout(() => controller.abort(), EXTERNAL_IMAGE_FETCH_TIMEOUT_MS);
154
+ try {
155
+ const response = await fetch(source, {
156
+ method: "GET",
157
+ signal: controller.signal,
158
+ redirect: "follow",
159
+ });
160
+ if (!response.ok) {
161
+ throw new Error(`HTTP ${response.status}`);
162
+ }
163
+
164
+ const declaredMime = normalizeContentType(response.headers.get("content-type"));
165
+ const binary = await readResponseBodyWithSizeLimit(response, MAX_EXTERNAL_IMAGE_BYTES);
166
+ const inferredMime = inferMimeFromPath(source);
167
+ const mime = declaredMime || inferredMime;
168
+ if (mime !== "image/gif" && !isGifBinary(binary)) {
169
+ throw new Error(
170
+ `sendAnimation requires GIF source, but got ${JSON.stringify(mime || "unknown")} (tip: use the direct .gif URL)`,
171
+ );
172
+ }
173
+ return `data:image/gif;base64,${binary.toString("base64")}`;
174
+ } catch (err) {
175
+ if (isAbortError(err)) {
176
+ throw new Error(`animation download timed out after ${EXTERNAL_IMAGE_FETCH_TIMEOUT_MS}ms`);
177
+ }
178
+ if (err instanceof Error) {
179
+ throw new Error(`animation download failed: ${err.message}`);
180
+ }
181
+ throw new Error("animation download failed");
182
+ } finally {
183
+ clearTimeout(timeout);
184
+ }
185
+ }
186
+
187
+ export async function sendMessageZapry(
188
+ account: ResolvedZapryAccount,
189
+ to: string,
190
+ text: string,
191
+ opts?: {
192
+ mediaUrl?: string;
193
+ replyTo?: string;
194
+ accountId?: string;
195
+ },
196
+ ): Promise<ZaprySendResult> {
197
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken);
198
+ const chatId = normalizeTarget(to);
199
+
200
+ if (opts?.mediaUrl) {
201
+ const originalMediaUrl = opts.mediaUrl.trim();
202
+ let mediaRef = originalMediaUrl;
203
+ if (isLocalPath(mediaRef)) {
204
+ const resolved = mediaRef.replace(/^~/, process.env.HOME || "");
205
+ mediaRef = await toDataUri(resolved);
206
+ }
207
+
208
+ const initialKind = detectMediaKind(mediaRef, originalMediaUrl);
209
+ if (initialKind === "animation" && isHttpUrl(mediaRef)) {
210
+ mediaRef = await downloadGifAsDataUri(mediaRef);
211
+ }
212
+
213
+ const mediaKind = detectMediaKind(mediaRef, originalMediaUrl);
214
+
215
+ let resp;
216
+ switch (mediaKind) {
217
+ case "animation":
218
+ resp = await client.sendAnimation(chatId, mediaRef);
219
+ break;
220
+ case "photo":
221
+ resp = await client.sendPhoto(chatId, mediaRef);
222
+ break;
223
+ case "video":
224
+ resp = await client.sendVideo(chatId, mediaRef);
225
+ break;
226
+ case "voice":
227
+ resp = await client.sendVoice(chatId, mediaRef);
228
+ break;
229
+ case "audio":
230
+ resp = await client.sendAudio(chatId, mediaRef);
231
+ break;
232
+ default:
233
+ resp = await client.sendDocument(chatId, mediaRef);
234
+ break;
235
+ }
236
+
237
+ if (!resp.ok) {
238
+ return { ok: false, error: resp.description ?? "send media failed" };
239
+ }
240
+ const result = resp.result as any;
241
+ return { ok: true, messageId: result?.message_id };
242
+ }
243
+
244
+ const resp = await client.sendMessage(chatId, text, {
245
+ replyToMessageId: opts?.replyTo,
246
+ });
247
+
248
+ if (!resp.ok) {
249
+ return { ok: false, error: resp.description ?? "send message failed" };
250
+ }
251
+ const result = resp.result as any;
252
+ return { ok: true, messageId: result?.message_id };
253
+ }
254
+
255
+ function normalizeTarget(to: string): string {
256
+ return to.replace(/^(chat|zapry):/i, "").trim();
257
+ }