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/actions.ts ADDED
@@ -0,0 +1,2241 @@
1
+ import { ZapryApiClient } from "./api-client.js";
2
+ import type { ResolvedZapryAccount } from "./types.js";
3
+ import { spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
+ import os from "node:os";
7
+ import { join as joinPath, resolve as resolvePath } from "node:path";
8
+
9
+ export type ActionContext = {
10
+ action: string;
11
+ channel: string;
12
+ account: ResolvedZapryAccount;
13
+ params: Record<string, any>;
14
+ requestHeaders?: Record<string, string>;
15
+ };
16
+
17
+ export type ActionResult = {
18
+ ok: boolean;
19
+ result?: any;
20
+ error?: string;
21
+ };
22
+
23
+ const ACTION_ALIASES: Record<string, string> = {
24
+ send: "send",
25
+ sendmessage: "send-message",
26
+ sendphoto: "send-photo",
27
+ sendvideo: "send-video",
28
+ senddocument: "send-document",
29
+ sendaudio: "send-audio",
30
+ sendvoice: "send-voice",
31
+ sendanimation: "send-animation",
32
+ generateaudio: "generate-audio",
33
+ renderaudio: "generate-audio",
34
+ ttsaudio: "generate-audio",
35
+ deletemessage: "delete-message",
36
+ answercallbackquery: "answer-callback-query",
37
+
38
+ getupdates: "get-updates",
39
+ getfile: "get-file",
40
+ setwebhook: "set-webhook",
41
+ getwebhookinfo: "get-webhook-info",
42
+ deletewebhook: "delete-webhook",
43
+ webhookstoken: "webhooks-token",
44
+
45
+ getmygroups: "get-my-groups",
46
+ getmychats: "get-my-chats",
47
+ getchatmember: "get-chat-member",
48
+ getchatmembers: "get-chat-members",
49
+ getchatmembercount: "get-chat-member-count",
50
+ getchatmemberscount: "get-chat-member-count",
51
+ getchatadministrators: "get-chat-administrators",
52
+ getchatadmins: "get-chat-administrators",
53
+ mutechatmember: "mute-chat-member",
54
+ kickchatmember: "kick-chat-member",
55
+ invitechatmember: "invite-chat-member",
56
+ setchattitle: "set-chat-title",
57
+ setchatdescription: "set-chat-description",
58
+
59
+ getme: "get-me",
60
+ getuserprofilephotos: "get-user-profile-photos",
61
+ setmywalletaddress: "set-my-wallet-address",
62
+ setmyfriendverify: "set-my-friend-verify",
63
+ getmycontacts: "get-my-contacts",
64
+ getmyfriendrequests: "get-my-friend-requests",
65
+ acceptfriendrequest: "accept-friend-request",
66
+ rejectfriendrequest: "reject-friend-request",
67
+ addfriend: "add-friend",
68
+ deletefriend: "delete-friend",
69
+ setmysoul: "set-my-soul",
70
+ getmysoul: "get-my-soul",
71
+ setmyskills: "set-my-skills",
72
+ getmyskills: "get-my-skills",
73
+ getmyprofile: "get-my-profile",
74
+ setmyname: "set-my-name",
75
+ setmydescription: "set-my-description",
76
+
77
+ gettrendingposts: "get-trending-posts",
78
+ getlatestposts: "get-latest-posts",
79
+ getmyposts: "get-my-posts",
80
+ searchposts: "search-posts",
81
+ createpost: "create-post",
82
+ deletepost: "delete-post",
83
+ commentpost: "comment-post",
84
+ likepost: "like-post",
85
+ sharepost: "share-post",
86
+
87
+ getmyclubs: "get-my-clubs",
88
+ createclub: "create-club",
89
+ updateclub: "update-club",
90
+
91
+ getchathistory: "get-chat-history",
92
+
93
+ sendchataction: "send-chat-action",
94
+ };
95
+
96
+ const CREATE_POST_COMPAT_ACTION = "thread-list";
97
+ const CREATE_POST_COMPAT_PARAM_KEYS = [
98
+ "content",
99
+ "message",
100
+ "text",
101
+ "images",
102
+ "image",
103
+ "image_url",
104
+ "imageUrl",
105
+ "photo",
106
+ "photos",
107
+ "media",
108
+ "media_url",
109
+ "mediaUrl",
110
+ "media_urls",
111
+ "mediaUrls",
112
+ "attachment",
113
+ "attachments",
114
+ "file",
115
+ "files",
116
+ "filename",
117
+ "buffer",
118
+ "contentType",
119
+ "content_type",
120
+ ] as const;
121
+
122
+ type SendMediaAction =
123
+ | "send-photo"
124
+ | "send-video"
125
+ | "send-document"
126
+ | "send-audio"
127
+ | "send-voice"
128
+ | "send-animation";
129
+
130
+ type GenerateAudioMode = "auto" | "tts" | "render";
131
+
132
+ type GeneratedAudioArtifact = {
133
+ mode: Exclude<GenerateAudioMode, "auto">;
134
+ buffer: Buffer;
135
+ mimeType: "audio/mpeg" | "audio/wav";
136
+ fileName: string;
137
+ durationSeconds: number;
138
+ };
139
+
140
+ type MaterializeMediaOptions = {
141
+ allowExternalHttpImages?: boolean;
142
+ sourceLabel?: string;
143
+ };
144
+
145
+ const DEFAULT_GENERATED_AUDIO_DURATION_SECONDS = 12;
146
+ const MIN_GENERATED_AUDIO_DURATION_SECONDS = 2;
147
+ const MAX_GENERATED_AUDIO_DURATION_SECONDS = 30;
148
+ const DEFAULT_GENERATE_AUDIO_FALLBACK_TEXT =
149
+ "抱歉,我刚刚生成音频失败了。请换个描述重试,或让我先发文字版本。";
150
+ const MAX_EXTERNAL_IMAGE_BYTES = 10 * 1024 * 1024;
151
+ const MAX_EXTERNAL_MEDIA_BYTES = 50 * 1024 * 1024;
152
+ const EXTERNAL_IMAGE_FETCH_TIMEOUT_MS = 15_000;
153
+ const EXTERNAL_MEDIA_FETCH_TIMEOUT_MS = 60_000;
154
+
155
+ type MediaFieldName = "photo" | "video" | "document" | "audio" | "voice" | "animation";
156
+
157
+ const MEDIA_FIELD_TO_ENDPOINT: Record<MediaFieldName, string> = {
158
+ photo: "sendPhoto",
159
+ video: "sendVideo",
160
+ audio: "sendAudio",
161
+ document: "sendDocument",
162
+ voice: "sendVoice",
163
+ animation: "sendAnimation",
164
+ };
165
+
166
+ export async function handleZapryAction(ctx: ActionContext): Promise<ActionResult> {
167
+ const { action, account, params, requestHeaders } = ctx;
168
+ const normalizedAction = resolveActionForRuntime(action, params);
169
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken, {
170
+ defaultHeaders: requestHeaders,
171
+ });
172
+ const normalized = normalizeActionParams(normalizedAction, params);
173
+
174
+ if (isNonEmptyString(normalized.chat_id) && !isStandardChatId(normalized.chat_id)) {
175
+ const resolved = await resolveChatIdByName(client, normalized.chat_id);
176
+ if (resolved) {
177
+ normalized.chat_id = resolved;
178
+ }
179
+ }
180
+
181
+ const requiredError = validateRequiredParams(normalizedAction, normalized);
182
+ if (requiredError) {
183
+ return { ok: false, error: requiredError };
184
+ }
185
+
186
+ switch (normalizedAction) {
187
+ // ── Messaging ──
188
+ case "send":
189
+ return handleCoreSendCompat(client, normalized);
190
+ case "send-message":
191
+ return wrap(
192
+ client.sendMessage(normalized.chat_id, normalized.text, {
193
+ replyToMessageId: normalized.reply_to_message_id,
194
+ messageThreadId: normalized.message_thread_id,
195
+ replyMarkup: normalized.reply_markup,
196
+ }),
197
+ );
198
+ case "send-photo": {
199
+ const photoSource = normalized.photo || normalized.media;
200
+ if (!photoSource && isNonEmptyString(normalized.prompt)) {
201
+ return generateAndSendPhoto(client, normalized.chat_id, normalized.prompt);
202
+ }
203
+ if (!photoSource) {
204
+ return { ok: false, error: "missing required params: photo or prompt (provide an image source or a text prompt to auto-generate)" };
205
+ }
206
+ return sendMediaWithAutoDownload(photoSource, "photo", (m) => client.sendPhoto(normalized.chat_id, m), {
207
+ client, chatId: normalized.chat_id, fieldName: "photo",
208
+ });
209
+ }
210
+ case "send-video":
211
+ return sendMediaWithAutoDownload(normalized.video, "video", (m) => client.sendVideo(normalized.chat_id, m), {
212
+ client, chatId: normalized.chat_id, fieldName: "video",
213
+ });
214
+ case "send-document":
215
+ return sendMediaWithAutoDownload(normalized.document, "document", (m) => client.sendDocument(normalized.chat_id, m), {
216
+ client, chatId: normalized.chat_id, fieldName: "document",
217
+ });
218
+ case "send-audio":
219
+ return sendMediaWithAutoDownload(normalized.audio, "audio", (m) => client.sendAudio(normalized.chat_id, m), {
220
+ client, chatId: normalized.chat_id, fieldName: "audio",
221
+ });
222
+ case "send-voice":
223
+ return sendMediaWithAutoDownload(normalized.voice, "voice", (m) => client.sendVoice(normalized.chat_id, m), {
224
+ client, chatId: normalized.chat_id, fieldName: "voice",
225
+ });
226
+ case "send-animation":
227
+ return sendMediaWithAutoDownload(normalized.animation, "animation", (m) => client.sendAnimation(normalized.chat_id, m), {
228
+ client, chatId: normalized.chat_id, fieldName: "animation",
229
+ });
230
+ case "generate-audio":
231
+ return handleGenerateAudioAction(client, normalized);
232
+ case "send-chat-action":
233
+ return wrap(client.sendChatAction(normalized.chat_id, normalized.action));
234
+ case "delete-message":
235
+ return wrap(client.deleteMessage(normalized.chat_id, normalized.message_id));
236
+ case "answer-callback-query":
237
+ return wrap(
238
+ client.answerCallbackQuery(normalized.chat_id, normalized.callback_query_id, {
239
+ text: normalized.text,
240
+ showAlert: normalized.show_alert === true,
241
+ }),
242
+ );
243
+
244
+ // ── Receive / Webhook ──
245
+ case "get-updates":
246
+ return wrap(client.getUpdates(normalized.offset, normalized.limit, normalized.timeout));
247
+ case "get-file":
248
+ return wrap(client.getFile(normalized.file_id));
249
+ case "set-webhook":
250
+ return wrap(client.setWebhook(normalized.url));
251
+ case "get-webhook-info":
252
+ return wrap(client.getWebhookInfo());
253
+ case "delete-webhook":
254
+ return wrap(client.deleteWebhook());
255
+ case "webhooks-token":
256
+ return {
257
+ ok: true,
258
+ result: {
259
+ method: "POST",
260
+ endpoint: `${account.config.apiBaseUrl}/webhooks/${account.botToken}`,
261
+ note: "Inbound webhook endpoint for provider push updates.",
262
+ },
263
+ };
264
+
265
+ // ── Skills ──
266
+ case "set-my-soul":
267
+ return wrap(
268
+ client.setMySoul({
269
+ soulMd: normalized.soulMd,
270
+ version: normalized.version,
271
+ source: normalized.source,
272
+ agentKey: normalized.agentKey,
273
+ }),
274
+ );
275
+ case "get-my-soul":
276
+ return wrap(client.getMySoul());
277
+ case "set-my-skills":
278
+ return wrap(
279
+ client.setMySkills({
280
+ skills: normalized.skills,
281
+ version: normalized.version,
282
+ source: normalized.source,
283
+ agentKey: normalized.agentKey,
284
+ }),
285
+ );
286
+ case "get-my-skills":
287
+ return wrap(client.getMySkills());
288
+ case "get-my-profile":
289
+ return wrap(client.getMyProfile());
290
+
291
+ // ── Group Query & Moderation ──
292
+ case "get-my-groups":
293
+ return wrap(client.getMyGroups(normalized.page, normalized.page_size));
294
+ case "get-my-chats":
295
+ return wrap(client.getMyChats(normalized.page, normalized.page_size));
296
+ case "get-chat-member":
297
+ return wrap(client.getChatMember(normalized.chat_id, normalized.user_id));
298
+ case "get-chat-members":
299
+ return handleGetChatMembersAction(client, normalized);
300
+ case "get-chat-member-count":
301
+ return wrap(client.getChatMemberCount(normalized.chat_id));
302
+ case "get-chat-administrators":
303
+ return wrap(client.getChatAdministrators(normalized.chat_id));
304
+ case "mute-chat-member":
305
+ return wrap(client.muteChatMember(normalized.chat_id, normalized.user_id, normalized.mute));
306
+ case "kick-chat-member":
307
+ return wrap(client.kickChatMember(normalized.chat_id, normalized.user_id));
308
+ case "invite-chat-member":
309
+ return wrap(client.inviteChatMember(normalized.chat_id, normalized.user_id));
310
+ case "set-chat-title":
311
+ return wrap(client.setChatTitle(normalized.chat_id, normalized.title));
312
+ case "set-chat-description":
313
+ return wrap(client.setChatDescription(normalized.chat_id, normalized.description));
314
+
315
+ // ── Feed ──
316
+ case "get-trending-posts":
317
+ return wrap(client.getTrendingPosts(normalized.page, normalized.page_size));
318
+ case "get-latest-posts":
319
+ return wrap(client.getLatestPosts(normalized.page, normalized.page_size));
320
+ case "get-my-posts":
321
+ return wrap(client.getMyPosts(normalized.page, normalized.page_size));
322
+ case "search-posts":
323
+ return wrap(client.searchPosts(normalized.keyword, normalized.page, normalized.page_size));
324
+ case "create-post": {
325
+ let resolvedImages: string[] | undefined;
326
+ try {
327
+ resolvedImages = await materializeCreatePostImages(normalized.images, client);
328
+ } catch (err) {
329
+ const message = err instanceof Error ? err.message : "failed to process create-post images";
330
+ return { ok: false, error: message };
331
+ }
332
+ if (!resolvedImages || resolvedImages.length === 0) {
333
+ const generated = generateTextCardImageDataURI(normalized.content);
334
+ resolvedImages = [generated];
335
+ }
336
+ const imageErr = validateCreatePostImageSources(resolvedImages);
337
+ if (imageErr) {
338
+ return { ok: false, error: imageErr };
339
+ }
340
+ return wrap(client.createPost(normalized.content, resolvedImages));
341
+ }
342
+ case "delete-post":
343
+ return wrap(client.deletePost(normalized.dynamic_id));
344
+ case "comment-post":
345
+ return wrap(client.commentPost(normalized.dynamic_id, normalized.content));
346
+ case "like-post":
347
+ return wrap(client.likePost(normalized.dynamic_id));
348
+ case "share-post":
349
+ return wrap(client.sharePost(normalized.dynamic_id));
350
+
351
+ // ── Chat History ──
352
+ case "get-chat-history":
353
+ return wrap(client.getChatHistory(normalized.chat_id, normalized.limit));
354
+
355
+ // ── Clubs ──
356
+ case "get-my-clubs":
357
+ return wrap(client.getMyClubs(normalized.page, normalized.page_size));
358
+ case "create-club":
359
+ return wrap(client.createClub(normalized.name, normalized.desc, normalized.avatar));
360
+ case "update-club":
361
+ return wrap(
362
+ client.updateClub(normalized.club_id, normalized.name, normalized.desc, normalized.avatar),
363
+ );
364
+
365
+ // ── Bot Self-Management ──
366
+ case "get-me":
367
+ return wrap(client.getMe());
368
+ case "get-user-profile-photos":
369
+ return wrap(client.getUserProfilePhotos(normalized.user_id));
370
+ case "set-my-wallet-address":
371
+ return wrap(client.setMyWalletAddress(normalized.wallet_address));
372
+ case "set-my-friend-verify":
373
+ return wrap(client.setMyFriendVerify(normalized.need_verify));
374
+ case "get-my-contacts":
375
+ return wrap(client.getMyContacts(normalized.page, normalized.page_size));
376
+ case "get-my-friend-requests":
377
+ return wrap(client.getMyFriendRequests(normalized.pending_only));
378
+ case "accept-friend-request":
379
+ return wrap(client.acceptFriendRequest(normalized.user_id));
380
+ case "reject-friend-request":
381
+ return wrap(client.rejectFriendRequest(normalized.user_id));
382
+ case "add-friend":
383
+ return wrap(client.addFriend(normalized.user_id, normalized.message, normalized.remark));
384
+ case "delete-friend":
385
+ return wrap(client.deleteFriend(normalized.user_id));
386
+ case "set-my-name":
387
+ return wrap(client.setMyName(normalized.name));
388
+ case "set-my-description":
389
+ return wrap(client.setMyDescription(normalized.description));
390
+
391
+ default:
392
+ return { ok: false, error: `unknown zapry action: ${action}` };
393
+ }
394
+ }
395
+
396
+ async function generateAndSendPhoto(
397
+ client: ZapryApiClient,
398
+ chatId: string,
399
+ prompt: string,
400
+ ): Promise<ActionResult> {
401
+ if (!chatId) return { ok: false, error: "missing required params: chat_id" };
402
+
403
+ const genScriptDir = resolvePath(
404
+ "/opt/homebrew/lib/node_modules/openclaw/skills/openai-image-gen/scripts",
405
+ );
406
+ const outDir = await mkdtemp(joinPath(os.tmpdir(), "zapry-img-"));
407
+
408
+ try {
409
+ const exitCode = await new Promise<number>((resolve, reject) => {
410
+ const proc = spawn(
411
+ "python3",
412
+ [
413
+ joinPath(genScriptDir, "gen.py"),
414
+ "--prompt", prompt,
415
+ "--count", "1",
416
+ "--model", "gpt-image-1",
417
+ "--quality", "low",
418
+ "--size", "1024x1024",
419
+ "--output-format", "jpeg",
420
+ "--out-dir", outDir,
421
+ ],
422
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 },
423
+ );
424
+
425
+ proc.on("close", (code) => resolve(code ?? 1));
426
+ proc.on("error", reject);
427
+ });
428
+
429
+ if (exitCode !== 0) {
430
+ return { ok: false, error: `image generation failed (exit ${exitCode})` };
431
+ }
432
+
433
+ const { readdir } = await import("node:fs/promises");
434
+ const files = await readdir(outDir);
435
+ const imageFile = files.find((f) => /\.(png|jpe?g|webp)$/i.test(f));
436
+ if (!imageFile) {
437
+ return { ok: false, error: "image generation produced no output file" };
438
+ }
439
+
440
+ const imagePath = joinPath(outDir, imageFile);
441
+ const imageBytes = await readFile(imagePath);
442
+ return sendMediaMultipart(client, chatId, imageBytes, imageFile, "photo");
443
+ } catch (err) {
444
+ return { ok: false, error: `image generation error: ${err instanceof Error ? err.message : String(err)}` };
445
+ } finally {
446
+ rm(outDir, { recursive: true, force: true }).catch(() => {});
447
+ }
448
+ }
449
+
450
+ async function sendMediaMultipart(
451
+ client: ZapryApiClient,
452
+ chatId: string,
453
+ mediaBytes: Buffer,
454
+ fileName: string,
455
+ fieldName: MediaFieldName,
456
+ ): Promise<ActionResult> {
457
+ const mime = inferMimeTypeFromPath(fileName) || "application/octet-stream";
458
+ const safeFileName = fileName.replace(/\.jfif$/i, ".jpeg");
459
+
460
+ const form = new FormData();
461
+ form.append("chat_id", chatId);
462
+ form.append(fieldName, new Blob([new Uint8Array(mediaBytes) as BlobPart], { type: mime }), safeFileName);
463
+
464
+ const baseUrl = (client as any).baseUrl ?? "";
465
+ const token = (client as any).botToken ?? "";
466
+ const endpoint = MEDIA_FIELD_TO_ENDPOINT[fieldName] ?? `send${fieldName.charAt(0).toUpperCase()}${fieldName.slice(1)}`;
467
+ const url = `${baseUrl}/${token}/${endpoint}`;
468
+ try {
469
+ const resp = await fetch(url, {
470
+ method: "POST",
471
+ headers: client.getRequestHeaders(),
472
+ body: form,
473
+ });
474
+ if (!resp.ok) {
475
+ let errBody = "";
476
+ try { errBody = await resp.text(); } catch {}
477
+ const errJson = (() => { try { return JSON.parse(errBody); } catch { return null; } })();
478
+ return { ok: false, error: `HTTP ${resp.status} ${resp.statusText} — ${errJson?.description ?? errBody.slice(0, 200)}` };
479
+ }
480
+ const data = await resp.json();
481
+ return data as ActionResult;
482
+ } catch (err) {
483
+ return { ok: false, error: `multipart upload failed: ${err instanceof Error ? err.message : String(err)}` };
484
+ }
485
+ }
486
+
487
+ async function downloadExternalMediaToBuffer(
488
+ source: string,
489
+ label: string,
490
+ ): Promise<{ buffer: Buffer; mime: string; fileName: string }> {
491
+ const controller = new AbortController();
492
+ const timeout = setTimeout(() => controller.abort(), EXTERNAL_MEDIA_FETCH_TIMEOUT_MS);
493
+ try {
494
+ const response = await fetch(source, {
495
+ method: "GET",
496
+ signal: controller.signal,
497
+ redirect: "follow",
498
+ });
499
+ if (!response.ok) {
500
+ throw new Error(`HTTP ${response.status}`);
501
+ }
502
+ const contentType = normalizeContentType(response.headers.get("content-type"));
503
+ const fallbackMime = inferMimeTypeFromPath(source);
504
+ const mime = contentType || fallbackMime || "application/octet-stream";
505
+ const binary = await readResponseBodyWithSizeLimit(response, MAX_EXTERNAL_MEDIA_BYTES);
506
+ const urlPath = source.split("?")[0].split("#")[0];
507
+ const urlFileName = urlPath.split("/").pop() ?? "";
508
+ const ext = urlFileName.includes(".") ? urlFileName.split(".").pop()! : inferExtFromMime(mime);
509
+ const fileName = urlFileName.includes(".") ? urlFileName : `media.${ext}`;
510
+ return { buffer: binary, mime, fileName };
511
+ } catch (err) {
512
+ if (isAbortError(err)) {
513
+ throw new Error(`${label} download timed out after ${EXTERNAL_MEDIA_FETCH_TIMEOUT_MS}ms`);
514
+ }
515
+ if (err instanceof Error) {
516
+ throw new Error(`${label} download failed: ${err.message}`);
517
+ }
518
+ throw new Error(`${label} download failed`);
519
+ } finally {
520
+ clearTimeout(timeout);
521
+ }
522
+ }
523
+
524
+ function inferExtFromMime(mime: string): string {
525
+ const map: Record<string, string> = {
526
+ "image/jpeg": "jpeg", "image/png": "png", "image/gif": "gif", "image/webp": "webp",
527
+ "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov",
528
+ "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav", "audio/aac": "aac",
529
+ "application/pdf": "pdf", "text/plain": "txt",
530
+ };
531
+ return map[mime.toLowerCase()] ?? "bin";
532
+ }
533
+
534
+ async function sendMediaWithAutoDownload(
535
+ rawSource: string,
536
+ fieldName: string,
537
+ sender: (resolvedSource: string) => Promise<any>,
538
+ multipartCtx?: { client: ZapryApiClient; chatId: string; fieldName: MediaFieldName },
539
+ ): Promise<ActionResult> {
540
+ if (!isNonEmptyString(rawSource)) {
541
+ return { ok: false, error: `missing required params: ${fieldName}` };
542
+ }
543
+ const trimmed = rawSource.trim();
544
+ const isExternalUrl = /^https?:\/\//i.test(trimmed);
545
+ const isDataUri = /^data:[^,]+,.+/i.test(trimmed);
546
+ const isTempMedia = trimmed.startsWith("/_temp/media/") || /^https?:\/\/[^/\s]+\/_temp\/media\//i.test(trimmed);
547
+
548
+ if (multipartCtx && isExternalUrl && !isTempMedia) {
549
+ try {
550
+ const { buffer, fileName } = await downloadExternalMediaToBuffer(trimmed, fieldName);
551
+ return sendMediaMultipart(multipartCtx.client, multipartCtx.chatId, buffer, fileName, multipartCtx.fieldName);
552
+ } catch (err) {
553
+ return { ok: false, error: `${fieldName} download failed: ${err instanceof Error ? err.message : String(err)}` };
554
+ }
555
+ }
556
+
557
+ if (multipartCtx && !isDataUri && !isTempMedia && !isExternalUrl) {
558
+ const localPath = toLocalMediaPath(trimmed);
559
+ if (localPath) {
560
+ try {
561
+ const buffer = await readFile(localPath);
562
+ const fileName = localPath.split("/").pop() ?? `file.${fieldName}`;
563
+ return sendMediaMultipart(multipartCtx.client, multipartCtx.chatId, buffer, fileName, multipartCtx.fieldName);
564
+ } catch (err) {
565
+ return { ok: false, error: `${fieldName} read failed: ${err instanceof Error ? err.message : String(err)}` };
566
+ }
567
+ }
568
+ }
569
+
570
+ try {
571
+ const resolved = await materializeSendMediaSource(trimmed, {
572
+ allowExternalHttpImages: true,
573
+ sourceLabel: fieldName,
574
+ });
575
+ const mediaErr = validateMediaSource(resolved, fieldName);
576
+ if (mediaErr) {
577
+ return { ok: false, error: mediaErr };
578
+ }
579
+ return wrap(sender(resolved));
580
+ } catch (err) {
581
+ return { ok: false, error: `${fieldName} preparation failed: ${err instanceof Error ? err.message : String(err)}` };
582
+ }
583
+ }
584
+
585
+ async function handleCoreSendCompat(
586
+ client: ZapryApiClient,
587
+ params: Record<string, any>,
588
+ ): Promise<ActionResult> {
589
+ if (!hasRequiredValue(params.chat_id)) {
590
+ return { ok: false, error: "missing required params for send: chat_id" };
591
+ }
592
+ const chatId = String(params.chat_id).trim();
593
+ const mediaSource = pickCoreSendMediaSource(params);
594
+ if (mediaSource) {
595
+ const resolvedMediaSource = await materializeSendMediaSource(mediaSource);
596
+ const mediaAction = inferCoreSendMediaAction(resolvedMediaSource, params);
597
+ const mediaField = mediaFieldNameForAction(mediaAction);
598
+ const mediaErr = validateMediaSource(resolvedMediaSource, mediaField);
599
+ if (mediaErr) {
600
+ return { ok: false, error: mediaErr };
601
+ }
602
+
603
+ switch (mediaAction) {
604
+ case "send-photo":
605
+ return wrap(client.sendPhoto(chatId, resolvedMediaSource));
606
+ case "send-video":
607
+ return wrap(client.sendVideo(chatId, resolvedMediaSource));
608
+ case "send-document":
609
+ return wrap(client.sendDocument(chatId, resolvedMediaSource));
610
+ case "send-audio":
611
+ return wrap(client.sendAudio(chatId, resolvedMediaSource));
612
+ case "send-voice":
613
+ return wrap(client.sendVoice(chatId, resolvedMediaSource));
614
+ case "send-animation":
615
+ return wrap(client.sendAnimation(chatId, resolvedMediaSource));
616
+ }
617
+ }
618
+
619
+ if (!hasRequiredValue(params.text)) {
620
+ return { ok: false, error: "missing required params for send: text or media" };
621
+ }
622
+
623
+ return wrap(
624
+ client.sendMessage(chatId, String(params.text), {
625
+ replyToMessageId: params.reply_to_message_id,
626
+ messageThreadId: params.message_thread_id,
627
+ replyMarkup: params.reply_markup,
628
+ }),
629
+ );
630
+ }
631
+
632
+ function pickCoreSendMediaSource(params: Record<string, any>): string | null {
633
+ const direct = pickFirst(params, [
634
+ "photo",
635
+ "video",
636
+ "document",
637
+ "audio",
638
+ "voice",
639
+ "animation",
640
+ "media",
641
+ "media_url",
642
+ "mediaUrl",
643
+ ]);
644
+ if (isNonEmptyString(direct)) {
645
+ return direct.trim();
646
+ }
647
+
648
+ const mediaUrls = pickFirst(params, ["media_urls", "mediaUrls"]);
649
+ if (Array.isArray(mediaUrls)) {
650
+ for (const item of mediaUrls) {
651
+ if (isNonEmptyString(item)) {
652
+ return item.trim();
653
+ }
654
+ }
655
+ }
656
+
657
+ return null;
658
+ }
659
+
660
+ async function materializeSendMediaSource(
661
+ mediaSource: string,
662
+ options?: MaterializeMediaOptions,
663
+ ): Promise<string> {
664
+ const source = mediaSource.trim();
665
+ if (
666
+ /^data:[^,]+,.+/i.test(source) ||
667
+ source.startsWith("/_temp/media/") ||
668
+ /^https?:\/\/[^/\s]+\/_temp\/media\//i.test(source)
669
+ ) {
670
+ return source;
671
+ }
672
+
673
+ const localPath = toLocalMediaPath(source);
674
+ if (!localPath) {
675
+ if (options?.allowExternalHttpImages && /^https?:\/\//i.test(source)) {
676
+ const sourceLabel = options.sourceLabel ?? "image";
677
+ return downloadExternalImageAsDataURI(source, sourceLabel);
678
+ }
679
+ return source;
680
+ }
681
+
682
+ try {
683
+ const binary = await readFile(localPath);
684
+ const mime = inferMimeTypeFromPath(localPath);
685
+ return `data:${mime};base64,${binary.toString("base64")}`;
686
+ } catch {
687
+ return source;
688
+ }
689
+ }
690
+
691
+ async function downloadExternalImageAsDataURI(source: string, sourceLabel: string): Promise<string> {
692
+ const controller = new AbortController();
693
+ const timeout = setTimeout(() => controller.abort(), EXTERNAL_IMAGE_FETCH_TIMEOUT_MS);
694
+ try {
695
+ const response = await fetch(source, {
696
+ method: "GET",
697
+ signal: controller.signal,
698
+ redirect: "follow",
699
+ });
700
+ if (!response.ok) {
701
+ throw new Error(`HTTP ${response.status}`);
702
+ }
703
+
704
+ const contentType = normalizeContentType(response.headers.get("content-type"));
705
+ const fallbackMime = inferMimeTypeFromPath(source);
706
+ const mime = contentType || fallbackMime;
707
+ if (!mime.startsWith("image/")) {
708
+ throw new Error(`content-type ${JSON.stringify(mime)} is not image/*`);
709
+ }
710
+
711
+ const binary = await readResponseBodyWithSizeLimit(response, MAX_EXTERNAL_IMAGE_BYTES);
712
+ return `data:${mime};base64,${binary.toString("base64")}`;
713
+ } catch (err) {
714
+ if (isAbortError(err)) {
715
+ throw new Error(
716
+ `${sourceLabel} download timed out after ${EXTERNAL_IMAGE_FETCH_TIMEOUT_MS}ms`,
717
+ );
718
+ }
719
+ if (err instanceof Error) {
720
+ throw new Error(`${sourceLabel} download failed: ${err.message}`);
721
+ }
722
+ throw new Error(`${sourceLabel} download failed`);
723
+ } finally {
724
+ clearTimeout(timeout);
725
+ }
726
+ }
727
+
728
+ function normalizeContentType(contentType: string | null): string {
729
+ return String(contentType ?? "")
730
+ .split(";")[0]
731
+ .trim()
732
+ .toLowerCase();
733
+ }
734
+
735
+ async function readResponseBodyWithSizeLimit(response: Response, maxBytes: number): Promise<Buffer> {
736
+ const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
737
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
738
+ throw new Error(`file is too large (${contentLength} bytes), max ${maxBytes} bytes`);
739
+ }
740
+
741
+ if (!response.body) {
742
+ throw new Error("empty response body");
743
+ }
744
+
745
+ const reader = response.body.getReader();
746
+ const chunks: Buffer[] = [];
747
+ let total = 0;
748
+ while (true) {
749
+ const { done, value } = await reader.read();
750
+ if (done) {
751
+ break;
752
+ }
753
+ if (!value || value.byteLength === 0) {
754
+ continue;
755
+ }
756
+ total += value.byteLength;
757
+ if (total > maxBytes) {
758
+ throw new Error(`file is too large (${total} bytes), max ${maxBytes} bytes`);
759
+ }
760
+ chunks.push(Buffer.from(value));
761
+ }
762
+ return Buffer.concat(chunks, total);
763
+ }
764
+
765
+ function isAbortError(err: unknown): boolean {
766
+ if (!err || typeof err !== "object") {
767
+ return false;
768
+ }
769
+ const maybeError = err as { name?: string; code?: string };
770
+ return maybeError.name === "AbortError" || maybeError.code === "ABORT_ERR";
771
+ }
772
+
773
+ function generateTextCardImageDataURI(content: string): string {
774
+ const w = 800;
775
+ const h = 420;
776
+ const pixels = new Uint8Array(w * h * 3);
777
+
778
+ const seed = createHash("md5").update(content || "zapry").digest();
779
+ const hue = ((seed[0] << 8) | seed[1]) % 360;
780
+
781
+ for (let y = 0; y < h; y++) {
782
+ const t = y / h;
783
+ const [r1, g1, b1] = hslToRgb(hue, 0.65, 0.5);
784
+ const [r2, g2, b2] = hslToRgb((hue + 40) % 360, 0.7, 0.4);
785
+ const r = Math.round(r1 + (r2 - r1) * t);
786
+ const g = Math.round(g1 + (g2 - g1) * t);
787
+ const b = Math.round(b1 + (b2 - b1) * t);
788
+ for (let x = 0; x < w; x++) {
789
+ const offset = (y * w + x) * 3;
790
+ pixels[offset] = r;
791
+ pixels[offset + 1] = g;
792
+ pixels[offset + 2] = b;
793
+ }
794
+ }
795
+
796
+ const png = encodePNG(w, h, pixels);
797
+ return `data:image/png;base64,${Buffer.from(png).toString("base64")}`;
798
+ }
799
+
800
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
801
+ const c = (1 - Math.abs(2 * l - 1)) * s;
802
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
803
+ const m = l - c / 2;
804
+ let r = 0, g = 0, b = 0;
805
+ if (h < 60) { r = c; g = x; }
806
+ else if (h < 120) { r = x; g = c; }
807
+ else if (h < 180) { g = c; b = x; }
808
+ else if (h < 240) { g = x; b = c; }
809
+ else if (h < 300) { r = x; b = c; }
810
+ else { r = c; b = x; }
811
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
812
+ }
813
+
814
+ function encodePNG(w: number, h: number, rgb: Uint8Array): Buffer {
815
+ const { deflateSync } = require("node:zlib") as typeof import("node:zlib");
816
+ const rowBytes = 1 + w * 3;
817
+ const rawData = Buffer.alloc(h * rowBytes);
818
+ for (let y = 0; y < h; y++) {
819
+ const rowOffset = y * rowBytes;
820
+ rawData[rowOffset] = 0;
821
+ for (let x = 0; x < w * 3; x++) {
822
+ rawData[rowOffset + 1 + x] = rgb[y * w * 3 + x];
823
+ }
824
+ }
825
+ const compressed = deflateSync(rawData, { level: 6 });
826
+
827
+ const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
828
+ const ihdr = pngChunk("IHDR", (() => {
829
+ const d = Buffer.alloc(13);
830
+ d.writeUInt32BE(w, 0);
831
+ d.writeUInt32BE(h, 4);
832
+ d[8] = 8; // bit depth
833
+ d[9] = 2; // color type RGB
834
+ return d;
835
+ })());
836
+ const idat = pngChunk("IDAT", compressed);
837
+ const iend = pngChunk("IEND", Buffer.alloc(0));
838
+ return Buffer.concat([sig, ihdr, idat, iend]);
839
+ }
840
+
841
+ function pngChunk(type: string, data: Buffer): Buffer {
842
+ const { crc32 } = require("node:zlib") as typeof import("node:zlib");
843
+ const len = Buffer.alloc(4);
844
+ len.writeUInt32BE(data.length, 0);
845
+ const typeBytes = Buffer.from(type, "ascii");
846
+ const crcInput = Buffer.concat([typeBytes, data]);
847
+ const crcVal = Buffer.alloc(4);
848
+ crcVal.writeUInt32BE(crc32(crcInput) >>> 0, 0);
849
+ return Buffer.concat([len, typeBytes, data, crcVal]);
850
+ }
851
+
852
+ function extractImageSource(item: unknown): string | null {
853
+ if (typeof item === "string" && item.trim().length > 0) return item.trim();
854
+ if (item && typeof item === "object") {
855
+ for (const key of ["fileId", "file_id", "url", "source", "src", "path", "uri"]) {
856
+ const val = (item as Record<string, unknown>)[key];
857
+ if (typeof val === "string" && val.trim().length > 0) return val.trim();
858
+ }
859
+ }
860
+ return null;
861
+ }
862
+
863
+ async function materializeCreatePostImages(
864
+ rawImages: unknown,
865
+ client?: { getFile: (fileId: string) => Promise<any> },
866
+ ): Promise<string[] | undefined> {
867
+ let images: unknown[];
868
+ if (Array.isArray(rawImages)) {
869
+ images = rawImages;
870
+ } else if (typeof rawImages === "string" && rawImages.trim().length > 0) {
871
+ images = [rawImages];
872
+ } else {
873
+ return undefined;
874
+ }
875
+ const resolved = await Promise.all(images.map(async (item: unknown, idx: number) => {
876
+ let source = extractImageSource(item);
877
+ if (!source) return null;
878
+
879
+ if (client && /^mf_[0-9a-f]+$/i.test(source)) {
880
+ try {
881
+ const fileResp = await client.getFile(source);
882
+ const fileUrl =
883
+ fileResp?.result?.file_url ??
884
+ fileResp?.result?.file_path ??
885
+ fileResp?.result?.url;
886
+ if (typeof fileUrl === "string" && fileUrl.trim().length > 0) {
887
+ source = fileUrl.trim();
888
+ }
889
+ } catch {}
890
+ }
891
+
892
+ return materializeSendMediaSource(source, {
893
+ allowExternalHttpImages: true,
894
+ sourceLabel: `images[${idx}]`,
895
+ });
896
+ }));
897
+ return resolved.filter((item): item is string => isNonEmptyString(item));
898
+ }
899
+
900
+ function validateCreatePostImageSources(images: string[] | undefined): string | null {
901
+ if (!images || images.length === 0) {
902
+ return null;
903
+ }
904
+ for (let idx = 0; idx < images.length; idx += 1) {
905
+ const mediaErr = validateMediaSource(images[idx], `images[${idx}]`);
906
+ if (mediaErr) {
907
+ return (
908
+ `invalid create-post images: ${mediaErr} ` +
909
+ "(tip: provide local file path, data URI, /_temp/media URL, or external image URL)"
910
+ );
911
+ }
912
+ }
913
+ return null;
914
+ }
915
+
916
+ function toLocalMediaPath(source: string): string | null {
917
+ if (!source) {
918
+ return null;
919
+ }
920
+ if (/^https?:\/\//i.test(source) || source.startsWith("data:")) {
921
+ return null;
922
+ }
923
+ if (source.startsWith("file://")) {
924
+ try {
925
+ const url = new URL(source);
926
+ return decodeURIComponent(url.pathname);
927
+ } catch {
928
+ return null;
929
+ }
930
+ }
931
+ if (source.startsWith("/")) {
932
+ return source;
933
+ }
934
+ return resolvePath(process.cwd(), source);
935
+ }
936
+
937
+ function inferMimeTypeFromPath(filePath: string): string {
938
+ const lower = filePath.trim().toLowerCase();
939
+ if (lower.endsWith(".png")) return "image/png";
940
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
941
+ if (lower.endsWith(".gif")) return "image/gif";
942
+ if (lower.endsWith(".webp")) return "image/webp";
943
+ if (lower.endsWith(".bmp")) return "image/bmp";
944
+ if (lower.endsWith(".heic")) return "image/heic";
945
+ if (lower.endsWith(".heif")) return "image/heif";
946
+ if (lower.endsWith(".mp4")) return "video/mp4";
947
+ if (lower.endsWith(".mov")) return "video/quicktime";
948
+ if (lower.endsWith(".webm")) return "video/webm";
949
+ if (lower.endsWith(".m4v")) return "video/mp4";
950
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
951
+ if (lower.endsWith(".wav")) return "audio/wav";
952
+ if (lower.endsWith(".ogg")) return "audio/ogg";
953
+ if (lower.endsWith(".m4a")) return "audio/mp4";
954
+ if (lower.endsWith(".aac")) return "audio/aac";
955
+ if (lower.endsWith(".opus")) return "audio/opus";
956
+ if (lower.endsWith(".pdf")) return "application/pdf";
957
+ if (lower.endsWith(".txt")) return "text/plain";
958
+ return "application/octet-stream";
959
+ }
960
+
961
+ function inferCoreSendMediaAction(
962
+ mediaSource: string,
963
+ params: Record<string, any>,
964
+ ): SendMediaAction {
965
+ const mediaType = normalizeMediaType(pickFirst(params, ["media_type", "mediaType", "type"]));
966
+ if (mediaType) {
967
+ return mediaType;
968
+ }
969
+
970
+ const source = mediaSource.trim().toLowerCase();
971
+ const dataUriMatch = /^data:([^;,]+)[;,]/i.exec(source);
972
+ if (dataUriMatch) {
973
+ const mime = dataUriMatch[1];
974
+ if (mime.startsWith("image/")) {
975
+ return mime === "image/gif" ? "send-animation" : "send-photo";
976
+ }
977
+ if (mime.startsWith("video/")) {
978
+ return "send-video";
979
+ }
980
+ if (mime.startsWith("audio/")) {
981
+ if (/(ogg|opus|amr|x-m4a|mp4)/i.test(mime)) {
982
+ return "send-voice";
983
+ }
984
+ return "send-audio";
985
+ }
986
+ return "send-document";
987
+ }
988
+
989
+ const cleanSource = source.split("?")[0].split("#")[0];
990
+ const dotIndex = cleanSource.lastIndexOf(".");
991
+ const ext = dotIndex >= 0 ? cleanSource.slice(dotIndex + 1) : "";
992
+ if (["jpg", "jpeg", "png", "webp", "bmp", "heic", "heif"].includes(ext)) {
993
+ return "send-photo";
994
+ }
995
+ if (["gif"].includes(ext)) {
996
+ return "send-animation";
997
+ }
998
+ if (["mp4", "mov", "avi", "webm", "m4v", "mkv"].includes(ext)) {
999
+ return "send-video";
1000
+ }
1001
+ if (["mp3", "aac", "wav", "flac", "m4b"].includes(ext)) {
1002
+ return "send-audio";
1003
+ }
1004
+ if (["opus", "ogg", "oga", "amr", "m4a"].includes(ext)) {
1005
+ return "send-voice";
1006
+ }
1007
+ return "send-document";
1008
+ }
1009
+
1010
+ function normalizeMediaType(value: unknown): SendMediaAction | null {
1011
+ if (!isNonEmptyString(value)) {
1012
+ return null;
1013
+ }
1014
+ const mediaType = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
1015
+ if (["image", "photo", "send-photo"].includes(mediaType)) {
1016
+ return "send-photo";
1017
+ }
1018
+ if (["video", "movie", "send-video"].includes(mediaType)) {
1019
+ return "send-video";
1020
+ }
1021
+ if (["file", "doc", "document", "send-document"].includes(mediaType)) {
1022
+ return "send-document";
1023
+ }
1024
+ if (["audio", "music", "send-audio"].includes(mediaType)) {
1025
+ return "send-audio";
1026
+ }
1027
+ if (["voice", "voice-note", "send-voice"].includes(mediaType)) {
1028
+ return "send-voice";
1029
+ }
1030
+ if (["animation", "gif", "send-animation"].includes(mediaType)) {
1031
+ return "send-animation";
1032
+ }
1033
+ return null;
1034
+ }
1035
+
1036
+ function mediaFieldNameForAction(action: SendMediaAction): string {
1037
+ switch (action) {
1038
+ case "send-photo":
1039
+ return "photo";
1040
+ case "send-video":
1041
+ return "video";
1042
+ case "send-document":
1043
+ return "document";
1044
+ case "send-audio":
1045
+ return "audio";
1046
+ case "send-voice":
1047
+ return "voice";
1048
+ case "send-animation":
1049
+ return "animation";
1050
+ }
1051
+ }
1052
+
1053
+ function validateRequiredParams(action: string, params: Record<string, any>): string | null {
1054
+ const mediaByAction: Partial<Record<string, string>> = {
1055
+ "send-photo": "photo",
1056
+ "send-video": "video",
1057
+ "send-document": "document",
1058
+ "send-audio": "audio",
1059
+ "send-voice": "voice",
1060
+ "send-animation": "animation",
1061
+ };
1062
+
1063
+ const requiredByAction: Record<string, string[]> = {
1064
+ // Messaging
1065
+ "send-message": ["chat_id", "text"],
1066
+ "send-photo": ["chat_id"],
1067
+ "send-video": ["chat_id", "video"],
1068
+ "send-document": ["chat_id", "document"],
1069
+ "send-audio": ["chat_id", "audio"],
1070
+ "send-voice": ["chat_id", "voice"],
1071
+ "send-animation": ["chat_id", "animation"],
1072
+ "generate-audio": ["chat_id"],
1073
+ "delete-message": ["chat_id", "message_id"],
1074
+ "answer-callback-query": ["chat_id", "callback_query_id"],
1075
+
1076
+ // Receive / Webhook
1077
+ "get-file": ["file_id"],
1078
+ "set-webhook": ["url"],
1079
+
1080
+ // Skills
1081
+ "set-my-soul": ["soulMd"],
1082
+ "set-my-skills": ["skills"],
1083
+
1084
+ // Group query & moderation
1085
+ "get-chat-member": ["chat_id", "user_id"],
1086
+ "get-chat-members": ["chat_id"],
1087
+ "get-chat-member-count": ["chat_id"],
1088
+ "get-chat-administrators": ["chat_id"],
1089
+ "mute-chat-member": ["chat_id", "user_id", "mute"],
1090
+ "kick-chat-member": ["chat_id", "user_id"],
1091
+ "invite-chat-member": ["chat_id", "user_id"],
1092
+ "set-chat-title": ["chat_id", "title"],
1093
+ "set-chat-description": ["chat_id", "description"],
1094
+
1095
+ // Agent self management
1096
+ "set-my-wallet-address": ["wallet_address"],
1097
+ "set-my-friend-verify": ["need_verify"],
1098
+ "accept-friend-request": ["user_id"],
1099
+ "reject-friend-request": ["user_id"],
1100
+ "add-friend": ["user_id"],
1101
+ "delete-friend": ["user_id"],
1102
+ "set-my-name": ["name"],
1103
+ "set-my-description": ["description"],
1104
+
1105
+ // Feed
1106
+ "search-posts": ["keyword"],
1107
+ "create-post": ["content"],
1108
+ "delete-post": ["dynamic_id"],
1109
+ "comment-post": ["dynamic_id", "content"],
1110
+ "like-post": ["dynamic_id"],
1111
+ "share-post": ["dynamic_id"],
1112
+
1113
+ // Chat History
1114
+ "get-chat-history": ["chat_id"],
1115
+
1116
+ // Club
1117
+ "create-club": ["name"],
1118
+ "update-club": ["club_id"],
1119
+ };
1120
+
1121
+ const required = requiredByAction[action];
1122
+ if (!required || required.length === 0) {
1123
+ return null;
1124
+ }
1125
+
1126
+ const missing = required.filter((key) => !hasRequiredValue(params[key]));
1127
+ if (missing.length > 0) {
1128
+ return `missing required params for ${action}: ${missing.join(", ")}`;
1129
+ }
1130
+
1131
+ if (action === "set-my-skills") {
1132
+ const skillsErr = validateSkillsPayload(params.skills);
1133
+ if (skillsErr) {
1134
+ return skillsErr;
1135
+ }
1136
+ }
1137
+
1138
+ return null;
1139
+ }
1140
+
1141
+ function validateMediaSource(value: unknown, fieldName: string): string | null {
1142
+ if (!isNonEmptyString(value)) {
1143
+ return `missing required params: ${fieldName}`;
1144
+ }
1145
+ const source = String(value).trim();
1146
+ if (/^data:[^,]+,.+/i.test(source)) {
1147
+ return null;
1148
+ }
1149
+ if (source.startsWith("/_temp/media/")) {
1150
+ return null;
1151
+ }
1152
+ if (/^https?:\/\/[^/\s]+\/_temp\/media\//i.test(source)) {
1153
+ return null;
1154
+ }
1155
+ return (
1156
+ `invalid ${fieldName}: only data URI or /_temp/media URL can be sent to Zapry OpenAPI directly ` +
1157
+ "(tip: for create-post images, external image URL is supported and will be auto-downloaded)"
1158
+ );
1159
+ }
1160
+
1161
+ function validateSkillsPayload(skills: unknown): string | null {
1162
+ if (!Array.isArray(skills) || skills.length === 0) {
1163
+ return "invalid skills: non-empty array is required";
1164
+ }
1165
+ for (const [idx, skill] of skills.entries()) {
1166
+ if (!skill || typeof skill !== "object" || Array.isArray(skill)) {
1167
+ return `invalid skills[${idx}]: object is required`;
1168
+ }
1169
+ const item = skill as Record<string, unknown>;
1170
+ if (!isNonEmptyString(item.skillKey)) {
1171
+ return `invalid skills[${idx}].skillKey: non-empty string is required`;
1172
+ }
1173
+ if (!isNonEmptyString(item.content)) {
1174
+ return `invalid skills[${idx}].content: non-empty string is required`;
1175
+ }
1176
+ }
1177
+ return null;
1178
+ }
1179
+
1180
+ function hasRequiredValue(value: unknown): boolean {
1181
+ if (typeof value === "string") {
1182
+ return value.trim().length > 0;
1183
+ }
1184
+ if (Array.isArray(value)) {
1185
+ return value.length > 0;
1186
+ }
1187
+ return value !== undefined && value !== null;
1188
+ }
1189
+
1190
+ function normalizeActionName(action: string): string {
1191
+ const raw = String(action ?? "").trim();
1192
+ if (!raw) return "";
1193
+ const lower = raw.toLowerCase();
1194
+ const hyphen = lower.replace(/[\s_]+/g, "-");
1195
+ const compact = lower.replace(/[^a-z0-9]/g, "");
1196
+ return ACTION_ALIASES[lower] ?? ACTION_ALIASES[hyphen] ?? ACTION_ALIASES[compact] ?? hyphen;
1197
+ }
1198
+
1199
+ function resolveActionForRuntime(action: string, rawParams: Record<string, any>): string {
1200
+ const normalizedAction = normalizeActionName(action);
1201
+ if (
1202
+ normalizedAction === CREATE_POST_COMPAT_ACTION &&
1203
+ looksLikeCreatePostPayload(rawParams)
1204
+ ) {
1205
+ return "create-post";
1206
+ }
1207
+ return normalizedAction;
1208
+ }
1209
+
1210
+ function looksLikeCreatePostPayload(rawParams: Record<string, any>): boolean {
1211
+ if (!rawParams || typeof rawParams !== "object") {
1212
+ return false;
1213
+ }
1214
+ return CREATE_POST_COMPAT_PARAM_KEYS.some((key) => hasMeaningfulValue(rawParams[key]));
1215
+ }
1216
+
1217
+ function hasMeaningfulValue(value: unknown): boolean {
1218
+ if (value === undefined || value === null) {
1219
+ return false;
1220
+ }
1221
+ if (typeof value === "string") {
1222
+ return value.trim().length > 0;
1223
+ }
1224
+ if (Array.isArray(value)) {
1225
+ return value.length > 0;
1226
+ }
1227
+ return true;
1228
+ }
1229
+
1230
+ function normalizeActionParams(action: string, raw: Record<string, any>): Record<string, any> {
1231
+ const params = { ...(raw ?? {}) };
1232
+
1233
+ const chatId = pickFirst(params, ["chat_id", "chatId", "chat", "to", "target", "group", "groupId", "group_id"]);
1234
+ const userId = pickFirst(params, [
1235
+ "user_id",
1236
+ "userId",
1237
+ "target_user_id",
1238
+ "targetUserId",
1239
+ "mentioned_user_id",
1240
+ "mentionedUserId",
1241
+ "reply_user_id",
1242
+ "replyUserId",
1243
+ ]);
1244
+ const messageId = pickFirst(params, ["message_id", "messageId"]);
1245
+ const callbackQueryId = pickFirst(params, ["callback_query_id", "callbackQueryId"]);
1246
+ const fileId = pickFirst(params, ["file_id", "fileId"]);
1247
+ const languageCode = pickFirst(params, ["language_code", "languageCode"]);
1248
+ const replyMarkup = pickFirst(params, ["reply_markup", "replyMarkup"]);
1249
+ const replyToMessageId = pickFirst(params, ["reply_to_message_id", "replyTo", "replyToMessageId"]);
1250
+ const messageThreadId = pickFirst(params, ["message_thread_id", "messageThreadId"]);
1251
+ const showAlert = pickFirst(params, ["show_alert", "showAlert"]);
1252
+ const mute = pickFirst(params, ["mute"]);
1253
+ const walletAddress = pickFirst(params, ["wallet_address", "walletAddress"]);
1254
+ const needVerify = pickFirst(params, ["need_verify", "needVerify", "friend_verify"]);
1255
+ const pendingOnly = pickFirst(params, ["pending_only", "pendingOnly"]);
1256
+ const pageSize = pickFirst(params, ["page_size", "pageSize"]);
1257
+ const dynamicId = pickFirst(params, ["dynamic_id", "dynamicId"]);
1258
+ const clubId = pickFirst(params, ["club_id", "clubId"]);
1259
+ const soulMd = pickFirst(params, ["soulMd", "soul_md"]);
1260
+ const agentKey = pickFirst(params, ["agentKey", "agent_key"]);
1261
+ const skills = pickFirst(params, ["skills"]);
1262
+ const images = pickFirst(params, [
1263
+ "images",
1264
+ "image",
1265
+ "image_url",
1266
+ "imageUrl",
1267
+ "photo",
1268
+ "photos",
1269
+ "media",
1270
+ "media_url",
1271
+ "mediaUrl",
1272
+ "media_urls",
1273
+ "mediaUrls",
1274
+ "attachment",
1275
+ "attachments",
1276
+ "file",
1277
+ "files",
1278
+ "fileIds",
1279
+ "file_ids",
1280
+ ]);
1281
+ const prompt = pickFirst(params, ["prompt", "audio_prompt", "audioPrompt", "script"]);
1282
+ const audioMode = pickFirst(params, ["audio_mode", "audioMode", "generate_mode", "generateMode"]);
1283
+ const ttsVoice = pickFirst(params, ["tts_voice", "ttsVoice", "voice_name", "voiceName"]);
1284
+ const audioFormat = pickFirst(params, ["audio_format", "audioFormat", "format"]);
1285
+ const durationSeconds = pickFirst(params, ["duration_seconds", "durationSeconds", "duration"]);
1286
+ const fallbackText = pickFirst(params, ["fallback_text", "fallbackText", "error_fallback_text"]);
1287
+
1288
+ if (chatId !== undefined) params.chat_id = normalizeChatId(chatId);
1289
+ if (userId !== undefined) params.user_id = String(userId).trim();
1290
+ if (messageId !== undefined) params.message_id = String(messageId).trim();
1291
+ if (callbackQueryId !== undefined) params.callback_query_id = String(callbackQueryId).trim();
1292
+ if (fileId !== undefined) params.file_id = String(fileId).trim();
1293
+ if (languageCode !== undefined) params.language_code = String(languageCode).trim();
1294
+ if (replyMarkup !== undefined) params.reply_markup = replyMarkup;
1295
+ if (replyToMessageId !== undefined) params.reply_to_message_id = String(replyToMessageId).trim();
1296
+ if (messageThreadId !== undefined) params.message_thread_id = String(messageThreadId).trim();
1297
+ if (showAlert !== undefined) params.show_alert = toBoolean(showAlert);
1298
+ if (mute !== undefined) params.mute = toBoolean(mute);
1299
+ if (walletAddress !== undefined) params.wallet_address = String(walletAddress).trim();
1300
+ if (needVerify !== undefined) params.need_verify = toBoolean(needVerify);
1301
+ if (pendingOnly !== undefined) params.pending_only = toBoolean(pendingOnly);
1302
+ if (pageSize !== undefined) params.page_size = toNumberIfPossible(pageSize);
1303
+ if (dynamicId !== undefined) params.dynamic_id = toNumberIfPossible(dynamicId);
1304
+ if (clubId !== undefined) params.club_id = toNumberIfPossible(clubId);
1305
+ if (soulMd !== undefined) params.soulMd = String(soulMd);
1306
+ if (agentKey !== undefined) params.agentKey = String(agentKey).trim();
1307
+
1308
+ const text = pickFirst(params, ["text", "message"]);
1309
+ if (text !== undefined) params.text = String(text);
1310
+ const content = pickFirst(params, ["content"]);
1311
+ if (content !== undefined) params.content = String(content).trim();
1312
+ if (
1313
+ action === "create-post" &&
1314
+ (typeof params.content !== "string" || params.content.trim().length === 0) &&
1315
+ typeof params.text === "string" &&
1316
+ params.text.trim().length > 0
1317
+ ) {
1318
+ params.content = params.text.trim();
1319
+ }
1320
+
1321
+ const keyword = pickFirst(params, ["keyword", "q", "query"]);
1322
+ if (keyword !== undefined) params.keyword = String(keyword).trim();
1323
+ const title = pickFirst(params, ["title"]);
1324
+ if (title !== undefined) params.title = String(title).trim();
1325
+ const description = pickFirst(params, ["description"]);
1326
+ if (description !== undefined) params.description = String(description).trim();
1327
+ const name = pickFirst(params, ["name"]);
1328
+ if (name !== undefined) params.name = String(name).trim();
1329
+ const desc = pickFirst(params, ["desc"]);
1330
+ if (desc !== undefined) params.desc = String(desc).trim();
1331
+ const avatar = pickFirst(params, ["avatar"]);
1332
+ if (avatar !== undefined) params.avatar = String(avatar).trim();
1333
+
1334
+ if (skills !== undefined) {
1335
+ if (Array.isArray(skills)) {
1336
+ params.skills = skills;
1337
+ } else if (typeof skills === "string") {
1338
+ try {
1339
+ params.skills = JSON.parse(skills);
1340
+ } catch {
1341
+ params.skills = skills;
1342
+ }
1343
+ } else {
1344
+ params.skills = skills;
1345
+ }
1346
+ }
1347
+
1348
+ const source = pickFirst(params, ["source"]);
1349
+ if (source !== undefined) params.source = String(source).trim();
1350
+ const version = pickFirst(params, ["version"]);
1351
+ if (version !== undefined) params.version = String(version).trim();
1352
+ if (prompt !== undefined) params.prompt = String(prompt);
1353
+ if (audioMode !== undefined) params.audio_mode = String(audioMode).trim();
1354
+ if (ttsVoice !== undefined) params.tts_voice = String(ttsVoice).trim();
1355
+ if (audioFormat !== undefined) params.audio_format = String(audioFormat).trim();
1356
+ if (durationSeconds !== undefined) params.duration_seconds = toNumberIfPossible(durationSeconds);
1357
+ if (fallbackText !== undefined) params.fallback_text = String(fallbackText).trim();
1358
+
1359
+ const offset = pickFirst(params, ["offset"]);
1360
+ if (offset !== undefined) params.offset = toNumberIfPossible(offset);
1361
+ const limit = pickFirst(params, ["limit"]);
1362
+ if (limit !== undefined) params.limit = toNumberIfPossible(limit);
1363
+ const timeout = pickFirst(params, ["timeout"]);
1364
+ if (timeout !== undefined) params.timeout = toNumberIfPossible(timeout);
1365
+
1366
+ const page = pickFirst(params, ["page"]);
1367
+ if (page !== undefined) params.page = toNumberIfPossible(page);
1368
+ const url = pickFirst(params, ["url", "webhook_url", "webhookUrl"]);
1369
+ if (url !== undefined) params.url = String(url).trim();
1370
+
1371
+ const photo = pickFirst(params, ["photo", "image", "image_url", "imageUrl"]);
1372
+ if (photo !== undefined) params.photo = String(photo).trim();
1373
+ const video = pickFirst(params, ["video"]);
1374
+ if (video !== undefined) params.video = String(video).trim();
1375
+ const document = pickFirst(params, ["document", "file", "file_url", "fileUrl"]);
1376
+ if (document !== undefined) params.document = String(document).trim();
1377
+ const audio = pickFirst(params, ["audio", "audio_url"]);
1378
+ if (audio !== undefined) params.audio = String(audio).trim();
1379
+ const voice = pickFirst(params, ["voice", "voice_url"]);
1380
+ if (voice !== undefined) params.voice = String(voice).trim();
1381
+ const animation = pickFirst(params, ["animation", "animation_url"]);
1382
+ if (animation !== undefined) params.animation = String(animation).trim();
1383
+
1384
+ if (images !== undefined) {
1385
+ const normalizedImages = normalizeStringArray(images);
1386
+ if (normalizedImages) {
1387
+ params.images = normalizedImages;
1388
+ }
1389
+ }
1390
+
1391
+ // For query-style actions, keep plain endpoint behavior and avoid over-coercion.
1392
+ if (action === "get-user-profile-photos" && params.user_id !== undefined) {
1393
+ params.user_id = String(params.user_id).trim();
1394
+ }
1395
+
1396
+ return params;
1397
+ }
1398
+
1399
+ function pickFirst(obj: Record<string, any>, keys: string[]): any {
1400
+ for (const key of keys) {
1401
+ const value = obj[key];
1402
+ if (value !== undefined && value !== null) {
1403
+ if (typeof value !== "string" || value.trim().length > 0) {
1404
+ return value;
1405
+ }
1406
+ }
1407
+ }
1408
+ return undefined;
1409
+ }
1410
+
1411
+ function normalizeChatId(value: unknown): string {
1412
+ return String(value ?? "")
1413
+ .trim()
1414
+ .replace(/^chat:/i, "");
1415
+ }
1416
+
1417
+ function toNumberIfPossible(value: unknown): any {
1418
+ if (typeof value === "number") {
1419
+ return value;
1420
+ }
1421
+ if (typeof value === "string") {
1422
+ const trimmed = value.trim();
1423
+ if (!trimmed) {
1424
+ return value;
1425
+ }
1426
+ const n = Number(trimmed);
1427
+ if (Number.isFinite(n)) {
1428
+ return n;
1429
+ }
1430
+ }
1431
+ return value;
1432
+ }
1433
+
1434
+ function toBoolean(value: unknown): boolean {
1435
+ if (typeof value === "boolean") {
1436
+ return value;
1437
+ }
1438
+ if (typeof value === "number") {
1439
+ return value !== 0;
1440
+ }
1441
+ if (typeof value === "string") {
1442
+ const normalized = value.trim().toLowerCase();
1443
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
1444
+ return true;
1445
+ }
1446
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
1447
+ return false;
1448
+ }
1449
+ }
1450
+ return Boolean(value);
1451
+ }
1452
+
1453
+ function isNonEmptyString(value: unknown): value is string {
1454
+ return typeof value === "string" && value.trim().length > 0;
1455
+ }
1456
+
1457
+ function normalizeStringArray(value: unknown): string[] | null {
1458
+ if (Array.isArray(value)) {
1459
+ return value.map((item) => toMediaSourceString(item)).filter((item): item is string => Boolean(item));
1460
+ }
1461
+ if (typeof value === "string") {
1462
+ const trimmed = value.trim();
1463
+ if (!trimmed) {
1464
+ return [];
1465
+ }
1466
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
1467
+ try {
1468
+ const parsed = JSON.parse(trimmed);
1469
+ if (Array.isArray(parsed)) {
1470
+ return parsed.map((item) => String(item).trim()).filter(Boolean);
1471
+ }
1472
+ } catch {
1473
+ // fall through to plain string handling
1474
+ }
1475
+ }
1476
+ if (trimmed.includes(",")) {
1477
+ return trimmed.split(",").map((item) => item.trim()).filter(Boolean);
1478
+ }
1479
+ return [trimmed];
1480
+ }
1481
+ if (value && typeof value === "object") {
1482
+ const source = toMediaSourceString(value);
1483
+ return source ? [source] : null;
1484
+ }
1485
+ return null;
1486
+ }
1487
+
1488
+ function toMediaSourceString(value: unknown): string | null {
1489
+ if (typeof value === "string") {
1490
+ const trimmed = value.trim();
1491
+ return trimmed.length > 0 ? trimmed : null;
1492
+ }
1493
+ if (!value || typeof value !== "object") {
1494
+ return null;
1495
+ }
1496
+ const record = value as Record<string, unknown>;
1497
+ const source = pickFirst(record, ["url", "uri", "src", "path", "file", "image", "image_url", "imageUrl"]);
1498
+ if (typeof source === "string") {
1499
+ const trimmed = source.trim();
1500
+ return trimmed.length > 0 ? trimmed : null;
1501
+ }
1502
+ return null;
1503
+ }
1504
+
1505
+ async function handleGetChatMembersAction(
1506
+ client: ZapryApiClient,
1507
+ params: Record<string, any>,
1508
+ ): Promise<ActionResult> {
1509
+ const response = await client.getChatMembers(params.chat_id, {
1510
+ page: params.page,
1511
+ pageSize: params.page_size,
1512
+ keyword: params.keyword,
1513
+ });
1514
+ if (response.ok) {
1515
+ return { ok: true, result: response.result };
1516
+ }
1517
+
1518
+ const description = String(response.description ?? "request failed").trim() || "request failed";
1519
+ const loweredDescription = description.toLowerCase();
1520
+ const shouldFallbackToAdmins =
1521
+ response.error_code === 404 ||
1522
+ loweredDescription.includes("not found") ||
1523
+ loweredDescription.includes("404");
1524
+ if (!shouldFallbackToAdmins) {
1525
+ return { ok: false, error: description };
1526
+ }
1527
+
1528
+ const fallbackResponse = await client.getChatAdministrators(params.chat_id);
1529
+ if (!fallbackResponse.ok) {
1530
+ return { ok: false, error: String(fallbackResponse.description ?? description) };
1531
+ }
1532
+ return {
1533
+ ok: true,
1534
+ result: normalizeChatMembersFallbackResult(fallbackResponse.result, {
1535
+ chatId: params.chat_id,
1536
+ page: params.page,
1537
+ pageSize: params.page_size,
1538
+ keyword: params.keyword,
1539
+ }),
1540
+ };
1541
+ }
1542
+
1543
+ function normalizeChatMembersFallbackResult(
1544
+ payload: unknown,
1545
+ options: {
1546
+ chatId: string;
1547
+ page?: unknown;
1548
+ pageSize?: unknown;
1549
+ keyword?: unknown;
1550
+ },
1551
+ ): Record<string, unknown> {
1552
+ const page = parsePositiveInteger(options.page, 1);
1553
+ const pageSize = Math.min(200, parsePositiveInteger(options.pageSize, 50));
1554
+ const keyword = isNonEmptyString(options.keyword) ? options.keyword.trim() : "";
1555
+ const keywordLower = keyword.toLowerCase();
1556
+ const groupRecord = resolveGroupRecord(payload, options.chatId);
1557
+ if (!groupRecord) {
1558
+ return {
1559
+ chat_id: normalizeResultChatId(options.chatId),
1560
+ keyword,
1561
+ page,
1562
+ page_size: pageSize,
1563
+ total: 0,
1564
+ items: [],
1565
+ };
1566
+ }
1567
+
1568
+ const ownerUserId = toNonZeroString(groupRecord.UserId ?? groupRecord.user_id);
1569
+ const managerIdsRaw = Array.isArray(groupRecord.Manages)
1570
+ ? groupRecord.Manages
1571
+ : Array.isArray(groupRecord.manages)
1572
+ ? groupRecord.manages
1573
+ : [];
1574
+ const managerIds = new Set<string>(
1575
+ managerIdsRaw
1576
+ .map((value) => toNonZeroString(value))
1577
+ .filter((value): value is string => Boolean(value)),
1578
+ );
1579
+
1580
+ const membersRecord = asObjectRecord(
1581
+ asObjectRecord(groupRecord.Members)?.Member ??
1582
+ asObjectRecord(groupRecord.Members)?.member ??
1583
+ asObjectRecord(groupRecord.members)?.Member ??
1584
+ asObjectRecord(groupRecord.members)?.member,
1585
+ );
1586
+
1587
+ const items: Array<Record<string, unknown>> = [];
1588
+ const seenUserIds = new Set<string>();
1589
+
1590
+ for (const [rawUserId, rawMember] of Object.entries(membersRecord ?? {})) {
1591
+ const member = asObjectRecord(rawMember);
1592
+ if (!member) {
1593
+ continue;
1594
+ }
1595
+ const userId =
1596
+ toNonZeroString(rawUserId) ??
1597
+ toNonZeroString(member.user_id ?? member.userId ?? member.uid ?? member.Id ?? member.id);
1598
+ if (!userId) {
1599
+ continue;
1600
+ }
1601
+ const nick = toOptionalString(member.Nick ?? member.nick ?? member.name);
1602
+ const groupNick = toOptionalString(
1603
+ member.Gnick ?? member.gnick ?? member.group_nick ?? member.groupNick,
1604
+ );
1605
+ const displayName = groupNick ?? nick ?? `用户(${userId})`;
1606
+ const searchable = `${userId} ${displayName} ${groupNick ?? ""} ${nick ?? ""}`.toLowerCase();
1607
+ if (keywordLower && !searchable.includes(keywordLower)) {
1608
+ continue;
1609
+ }
1610
+ const role = userId === ownerUserId ? "owner" : managerIds.has(userId) ? "admin" : "member";
1611
+ seenUserIds.add(userId);
1612
+ items.push({
1613
+ user_id: userId,
1614
+ display_name: displayName,
1615
+ group_nick: groupNick ?? "",
1616
+ nick: nick ?? "",
1617
+ avatar: toOptionalString(member.Avatar ?? member.avatar) ?? "",
1618
+ status: toOptionalNumber(member.Status ?? member.status) ?? 0,
1619
+ role,
1620
+ is_owner: role === "owner",
1621
+ is_admin: role === "owner" || role === "admin",
1622
+ joined_at: toOptionalNumber(member.Ctime ?? member.ctime ?? member.joined_at) ?? 0,
1623
+ });
1624
+ }
1625
+
1626
+ const adminCandidates = Array.from(managerIds);
1627
+ if (ownerUserId) {
1628
+ adminCandidates.unshift(ownerUserId);
1629
+ }
1630
+ for (const userId of adminCandidates) {
1631
+ if (!userId || seenUserIds.has(userId)) {
1632
+ continue;
1633
+ }
1634
+ const displayName = `用户(${userId})`;
1635
+ const searchable = `${userId} ${displayName}`.toLowerCase();
1636
+ if (keywordLower && !searchable.includes(keywordLower)) {
1637
+ continue;
1638
+ }
1639
+ const role = userId === ownerUserId ? "owner" : "admin";
1640
+ items.push({
1641
+ user_id: userId,
1642
+ display_name: displayName,
1643
+ group_nick: "",
1644
+ nick: "",
1645
+ avatar: "",
1646
+ status: 0,
1647
+ role,
1648
+ is_owner: role === "owner",
1649
+ is_admin: true,
1650
+ joined_at: 0,
1651
+ });
1652
+ }
1653
+
1654
+ items.sort((left, right) => {
1655
+ const leftOwner = left.is_owner === true ? 1 : 0;
1656
+ const rightOwner = right.is_owner === true ? 1 : 0;
1657
+ if (leftOwner !== rightOwner) {
1658
+ return rightOwner - leftOwner;
1659
+ }
1660
+ const leftAdmin = left.is_admin === true ? 1 : 0;
1661
+ const rightAdmin = right.is_admin === true ? 1 : 0;
1662
+ if (leftAdmin !== rightAdmin) {
1663
+ return rightAdmin - leftAdmin;
1664
+ }
1665
+ const leftName = String(left.display_name ?? "").toLowerCase();
1666
+ const rightName = String(right.display_name ?? "").toLowerCase();
1667
+ if (leftName !== rightName) {
1668
+ return leftName.localeCompare(rightName);
1669
+ }
1670
+ return String(left.user_id ?? "").localeCompare(String(right.user_id ?? ""));
1671
+ });
1672
+
1673
+ const total = items.length;
1674
+ const start = Math.min(Math.max(0, (page - 1) * pageSize), total);
1675
+ const end = Math.min(start + pageSize, total);
1676
+ return {
1677
+ chat_id: normalizeResultChatId(options.chatId),
1678
+ keyword,
1679
+ page,
1680
+ page_size: pageSize,
1681
+ total,
1682
+ items: items.slice(start, end),
1683
+ };
1684
+ }
1685
+
1686
+ function resolveGroupRecord(payload: unknown, chatId: string): Record<string, unknown> | null {
1687
+ const root = asObjectRecord(payload);
1688
+ if (!root) {
1689
+ return null;
1690
+ }
1691
+ const normalizedChatId = normalizeResultChatId(chatId);
1692
+ const mapId = normalizedChatId.replace(/^g_/i, "");
1693
+ const byChatId = asObjectRecord(root[normalizedChatId]);
1694
+ if (byChatId) {
1695
+ return byChatId;
1696
+ }
1697
+ const byMapId = asObjectRecord(root[mapId]);
1698
+ if (byMapId) {
1699
+ return byMapId;
1700
+ }
1701
+ for (const value of Object.values(root)) {
1702
+ const record = asObjectRecord(value);
1703
+ if (record) {
1704
+ return record;
1705
+ }
1706
+ }
1707
+ return null;
1708
+ }
1709
+
1710
+ function normalizeResultChatId(value: unknown): string {
1711
+ const normalized = String(value ?? "").trim();
1712
+ if (!normalized) {
1713
+ return "";
1714
+ }
1715
+ if (normalized.startsWith("g_")) {
1716
+ return normalized;
1717
+ }
1718
+ return normalized.startsWith("chat:") ? normalized.slice(5) : normalized;
1719
+ }
1720
+
1721
+ function asObjectRecord(value: unknown): Record<string, unknown> | null {
1722
+ if (!value || typeof value !== "object") {
1723
+ return null;
1724
+ }
1725
+ return value as Record<string, unknown>;
1726
+ }
1727
+
1728
+ function toOptionalString(value: unknown): string | undefined {
1729
+ if (value === undefined || value === null) {
1730
+ return undefined;
1731
+ }
1732
+ const normalized = String(value).trim();
1733
+ return normalized.length > 0 ? normalized : undefined;
1734
+ }
1735
+
1736
+ function toNonZeroString(value: unknown): string | undefined {
1737
+ const normalized = toOptionalString(value);
1738
+ if (!normalized || normalized === "0") {
1739
+ return undefined;
1740
+ }
1741
+ return normalized;
1742
+ }
1743
+
1744
+ function toOptionalNumber(value: unknown): number | undefined {
1745
+ if (typeof value === "number" && Number.isFinite(value)) {
1746
+ return value;
1747
+ }
1748
+ if (typeof value === "string" && value.trim()) {
1749
+ const parsed = Number(value);
1750
+ if (Number.isFinite(parsed)) {
1751
+ return parsed;
1752
+ }
1753
+ }
1754
+ return undefined;
1755
+ }
1756
+
1757
+ function parsePositiveInteger(value: unknown, fallback: number): number {
1758
+ const parsed = toOptionalNumber(value);
1759
+ if (!parsed || parsed <= 0) {
1760
+ return fallback;
1761
+ }
1762
+ return Math.floor(parsed);
1763
+ }
1764
+
1765
+ async function handleGenerateAudioAction(
1766
+ client: ZapryApiClient,
1767
+ params: Record<string, any>,
1768
+ ): Promise<ActionResult> {
1769
+ const chatId = String(params.chat_id ?? "").trim();
1770
+ if (!chatId) {
1771
+ return { ok: false, error: "missing required params for generate-audio: chat_id" };
1772
+ }
1773
+
1774
+ const inputText = resolveGenerateAudioInputText(params);
1775
+ const requestedMode = normalizeGenerateAudioMode(params.audio_mode);
1776
+ const durationSeconds = resolveGenerateAudioDurationSeconds(params.duration_seconds);
1777
+ const fallbackText = isNonEmptyString(params.fallback_text)
1778
+ ? String(params.fallback_text).trim()
1779
+ : DEFAULT_GENERATE_AUDIO_FALLBACK_TEXT;
1780
+ const requestedFormat = normalizeGeneratedAudioFormat(params.audio_format);
1781
+ const ttsVoice = isNonEmptyString(params.tts_voice) ? String(params.tts_voice).trim() : undefined;
1782
+
1783
+ try {
1784
+ const artifact = await generateAudioArtifact({
1785
+ mode: requestedMode,
1786
+ text: inputText,
1787
+ durationSeconds,
1788
+ format: requestedFormat,
1789
+ ttsVoice,
1790
+ });
1791
+ const mediaDataUri = `data:${artifact.mimeType};base64,${artifact.buffer.toString("base64")}`;
1792
+ const sendResp = await client.sendAudio(chatId, mediaDataUri);
1793
+ if (!sendResp.ok) {
1794
+ const error = sendResp.description ?? "sendAudio failed";
1795
+ await sendGenerateAudioFallback(client, chatId, fallbackText);
1796
+ return { ok: false, error: `generate-audio send failed: ${error}` };
1797
+ }
1798
+ return {
1799
+ ok: true,
1800
+ result: {
1801
+ ...(sendResp.result ?? {}),
1802
+ audio_generation: {
1803
+ mode: artifact.mode,
1804
+ file_name: artifact.fileName,
1805
+ mime_type: artifact.mimeType,
1806
+ duration_seconds: artifact.durationSeconds,
1807
+ },
1808
+ },
1809
+ };
1810
+ } catch (error) {
1811
+ await sendGenerateAudioFallback(client, chatId, fallbackText);
1812
+ return { ok: false, error: `generate-audio failed: ${String(error)}` };
1813
+ }
1814
+ }
1815
+
1816
+ function resolveGenerateAudioInputText(params: Record<string, any>): string {
1817
+ const value = pickFirst(params, ["prompt", "text", "message", "script", "tts_text", "ttsText"]);
1818
+ if (!isNonEmptyString(value)) {
1819
+ return "";
1820
+ }
1821
+ const trimmed = String(value).trim();
1822
+ return trimmed.length > 800 ? trimmed.slice(0, 800) : trimmed;
1823
+ }
1824
+
1825
+ function normalizeGenerateAudioMode(value: unknown): GenerateAudioMode {
1826
+ if (!isNonEmptyString(value)) {
1827
+ return "auto";
1828
+ }
1829
+ const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
1830
+ if (["tts", "speech", "read", "voice"].includes(normalized)) {
1831
+ return "tts";
1832
+ }
1833
+ if (["render", "ringtone", "music", "tone", "synth"].includes(normalized)) {
1834
+ return "render";
1835
+ }
1836
+ return "auto";
1837
+ }
1838
+
1839
+ function normalizeGeneratedAudioFormat(value: unknown): "mp3" | "wav" {
1840
+ if (!isNonEmptyString(value)) {
1841
+ return "mp3";
1842
+ }
1843
+ const normalized = value.trim().toLowerCase();
1844
+ if (normalized === "wav" || normalized === "wave") {
1845
+ return "wav";
1846
+ }
1847
+ return "mp3";
1848
+ }
1849
+
1850
+ function resolveGenerateAudioDurationSeconds(value: unknown): number {
1851
+ const numeric = typeof value === "number" ? value : Number(String(value ?? "").trim());
1852
+ if (!Number.isFinite(numeric)) {
1853
+ return DEFAULT_GENERATED_AUDIO_DURATION_SECONDS;
1854
+ }
1855
+ return Math.max(
1856
+ MIN_GENERATED_AUDIO_DURATION_SECONDS,
1857
+ Math.min(MAX_GENERATED_AUDIO_DURATION_SECONDS, Math.floor(numeric)),
1858
+ );
1859
+ }
1860
+
1861
+ function looksLikeSpeechSynthesisIntent(text: string): boolean {
1862
+ const normalized = text.toLowerCase();
1863
+ return [
1864
+ "朗读",
1865
+ "念一下",
1866
+ "播报",
1867
+ "配音",
1868
+ "旁白",
1869
+ "转语音",
1870
+ "读出来",
1871
+ "tts",
1872
+ "read aloud",
1873
+ "speech",
1874
+ "narration",
1875
+ ].some((token) => normalized.includes(token));
1876
+ }
1877
+
1878
+ function looksLikeMusicRenderIntent(text: string): boolean {
1879
+ const normalized = text.toLowerCase();
1880
+ return [
1881
+ "铃声",
1882
+ "音效",
1883
+ "背景音乐",
1884
+ "bgm",
1885
+ "纯音乐",
1886
+ "电子",
1887
+ "伴奏",
1888
+ "ringtone",
1889
+ "music",
1890
+ "beat",
1891
+ "melody",
1892
+ ].some((token) => normalized.includes(token));
1893
+ }
1894
+
1895
+ function resolveAutoAudioMode(text: string): Exclude<GenerateAudioMode, "auto"> {
1896
+ if (text && looksLikeSpeechSynthesisIntent(text) && !looksLikeMusicRenderIntent(text)) {
1897
+ return "tts";
1898
+ }
1899
+ return "render";
1900
+ }
1901
+
1902
+ async function generateAudioArtifact(params: {
1903
+ mode: GenerateAudioMode;
1904
+ text: string;
1905
+ durationSeconds: number;
1906
+ format: "mp3" | "wav";
1907
+ ttsVoice?: string;
1908
+ }): Promise<GeneratedAudioArtifact> {
1909
+ const resolvedMode: Exclude<GenerateAudioMode, "auto"> =
1910
+ params.mode === "auto" ? resolveAutoAudioMode(params.text) : params.mode;
1911
+
1912
+ if (resolvedMode === "tts") {
1913
+ if (!params.text) {
1914
+ throw new Error("tts mode requires prompt/text");
1915
+ }
1916
+ try {
1917
+ return await generateTtsAudioArtifact({
1918
+ text: params.text,
1919
+ format: params.format,
1920
+ ttsVoice: params.ttsVoice,
1921
+ durationSeconds: params.durationSeconds,
1922
+ });
1923
+ } catch (error) {
1924
+ if (params.mode !== "auto") {
1925
+ throw error;
1926
+ }
1927
+ // Auto mode falls back to render when local TTS command is unavailable.
1928
+ return generateRenderedAudioArtifact({
1929
+ text: params.text,
1930
+ durationSeconds: params.durationSeconds,
1931
+ format: params.format,
1932
+ });
1933
+ }
1934
+ }
1935
+
1936
+ return generateRenderedAudioArtifact({
1937
+ text: params.text,
1938
+ durationSeconds: params.durationSeconds,
1939
+ format: params.format,
1940
+ });
1941
+ }
1942
+
1943
+ async function generateTtsAudioArtifact(params: {
1944
+ text: string;
1945
+ format: "mp3" | "wav";
1946
+ ttsVoice?: string;
1947
+ durationSeconds: number;
1948
+ }): Promise<GeneratedAudioArtifact> {
1949
+ if (process.platform !== "darwin") {
1950
+ throw new Error("local TTS is only available on darwin");
1951
+ }
1952
+
1953
+ const tmpDir = await mkdtemp(joinPath(os.tmpdir(), "zapry-generate-audio-tts-"));
1954
+ const aiffPath = joinPath(tmpDir, "speech.aiff");
1955
+ const wavPath = joinPath(tmpDir, "speech.wav");
1956
+ const mp3Path = joinPath(tmpDir, "speech.mp3");
1957
+
1958
+ try {
1959
+ const sayArgs = ["-o", aiffPath];
1960
+ if (params.ttsVoice) {
1961
+ sayArgs.push("-v", params.ttsVoice);
1962
+ }
1963
+ sayArgs.push(params.text);
1964
+ await runProcessWithTimeout("say", sayArgs, 25000);
1965
+
1966
+ await runProcessWithTimeout(
1967
+ "ffmpeg",
1968
+ ["-y", "-i", aiffPath, "-vn", "-ac", "1", "-ar", "24000", wavPath],
1969
+ 25000,
1970
+ );
1971
+
1972
+ if (params.format === "wav") {
1973
+ const buffer = await readFile(wavPath);
1974
+ return {
1975
+ mode: "tts",
1976
+ buffer,
1977
+ mimeType: "audio/wav",
1978
+ fileName: "tts-audio.wav",
1979
+ durationSeconds: params.durationSeconds,
1980
+ };
1981
+ }
1982
+
1983
+ await runProcessWithTimeout(
1984
+ "ffmpeg",
1985
+ ["-y", "-i", wavPath, "-codec:a", "libmp3lame", "-b:a", "96k", mp3Path],
1986
+ 25000,
1987
+ );
1988
+ const buffer = await readFile(mp3Path);
1989
+ return {
1990
+ mode: "tts",
1991
+ buffer,
1992
+ mimeType: "audio/mpeg",
1993
+ fileName: "tts-audio.mp3",
1994
+ durationSeconds: params.durationSeconds,
1995
+ };
1996
+ } finally {
1997
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
1998
+ }
1999
+ }
2000
+
2001
+ async function generateRenderedAudioArtifact(params: {
2002
+ text: string;
2003
+ durationSeconds: number;
2004
+ format: "mp3" | "wav";
2005
+ }): Promise<GeneratedAudioArtifact> {
2006
+ const wavBuffer = synthesizeRingtoneWavBuffer(params.text, params.durationSeconds);
2007
+ if (params.format === "wav") {
2008
+ return {
2009
+ mode: "render",
2010
+ buffer: wavBuffer,
2011
+ mimeType: "audio/wav",
2012
+ fileName: "generated-ringtone.wav",
2013
+ durationSeconds: params.durationSeconds,
2014
+ };
2015
+ }
2016
+
2017
+ try {
2018
+ const mp3Buffer = await transcodeWavToMp3Buffer(wavBuffer);
2019
+ return {
2020
+ mode: "render",
2021
+ buffer: mp3Buffer,
2022
+ mimeType: "audio/mpeg",
2023
+ fileName: "generated-ringtone.mp3",
2024
+ durationSeconds: params.durationSeconds,
2025
+ };
2026
+ } catch {
2027
+ return {
2028
+ mode: "render",
2029
+ buffer: wavBuffer,
2030
+ mimeType: "audio/wav",
2031
+ fileName: "generated-ringtone.wav",
2032
+ durationSeconds: params.durationSeconds,
2033
+ };
2034
+ }
2035
+ }
2036
+
2037
+ function synthesizeRingtoneWavBuffer(text: string, durationSeconds: number): Buffer {
2038
+ const sampleRate = 24000;
2039
+ const totalSamples = Math.max(
2040
+ sampleRate * MIN_GENERATED_AUDIO_DURATION_SECONDS,
2041
+ Math.floor(durationSeconds * sampleRate),
2042
+ );
2043
+ const pcm = new Int16Array(totalSamples);
2044
+ const seed = createHash("sha256").update(text || "zapry-generated-ringtone").digest();
2045
+
2046
+ const highPitch = /高|清脆|明亮|bright|high/i.test(text);
2047
+ const lowPitch = /低沉|厚重|dark|low/i.test(text);
2048
+ const fastTempo = /快|急促|fast|upbeat/i.test(text);
2049
+ const slowTempo = /慢|舒缓|slow|calm/i.test(text);
2050
+ const beatDurationSec = fastTempo ? 0.18 : slowTempo ? 0.32 : 0.24;
2051
+ const beatSamples = Math.max(1, Math.floor(beatDurationSec * sampleRate));
2052
+
2053
+ const notePool = lowPitch
2054
+ ? [174.61, 196.0, 220.0, 261.63, 293.66, 329.63, 349.23]
2055
+ : highPitch
2056
+ ? [392.0, 440.0, 523.25, 659.25, 783.99, 880.0, 987.77]
2057
+ : [261.63, 293.66, 329.63, 392.0, 440.0, 523.25, 659.25, 783.99];
2058
+ const melodyLength = 16;
2059
+ const melody = Array.from({ length: melodyLength }, (_, idx) => {
2060
+ const bucket = seed[idx % seed.length] ?? idx;
2061
+ return notePool[bucket % notePool.length];
2062
+ });
2063
+
2064
+ const attackSamples = Math.max(1, Math.floor(sampleRate * 0.01));
2065
+ const releaseSamples = Math.max(1, Math.floor(sampleRate * 0.06));
2066
+ const masterFadeSamples = Math.min(Math.floor(sampleRate * 0.2), Math.floor(totalSamples / 8));
2067
+
2068
+ for (let i = 0; i < totalSamples; i += 1) {
2069
+ const stepIndex = Math.floor(i / beatSamples) % melody.length;
2070
+ const stepOffset = i % beatSamples;
2071
+ const baseFreq = melody[stepIndex];
2072
+ const t = i / sampleRate;
2073
+
2074
+ let envelope = 1;
2075
+ if (stepOffset < attackSamples) {
2076
+ envelope = stepOffset / attackSamples;
2077
+ } else {
2078
+ const remain = beatSamples - stepOffset;
2079
+ if (remain < releaseSamples) {
2080
+ envelope = Math.max(0, remain / releaseSamples);
2081
+ }
2082
+ }
2083
+ const gate = stepOffset < beatSamples * 0.7 ? 1 : 0.35;
2084
+
2085
+ const carrier = Math.sin(2 * Math.PI * baseFreq * t);
2086
+ const harmonic2 = 0.34 * Math.sin(2 * Math.PI * baseFreq * 2 * t + 0.23);
2087
+ const harmonic3 = 0.17 * Math.sin(2 * Math.PI * baseFreq * 3 * t + 1.1);
2088
+ const sub = 0.2 * Math.sin(2 * Math.PI * Math.max(60, baseFreq / 2) * t);
2089
+ const pulse = stepIndex % 4 === 0
2090
+ ? 0.14 * Math.sin(2 * Math.PI * 120 * t) * Math.exp(-stepOffset / (sampleRate * 0.12))
2091
+ : 0;
2092
+
2093
+ let sample =
2094
+ (carrier * 0.62 + harmonic2 + harmonic3 + sub) *
2095
+ envelope *
2096
+ gate +
2097
+ pulse;
2098
+
2099
+ if (i < masterFadeSamples) {
2100
+ sample *= i / masterFadeSamples;
2101
+ } else if (i > totalSamples - masterFadeSamples) {
2102
+ sample *= (totalSamples - i) / masterFadeSamples;
2103
+ }
2104
+
2105
+ const clamped = Math.max(-1, Math.min(1, sample));
2106
+ pcm[i] = Math.round(clamped * 32767);
2107
+ }
2108
+
2109
+ return encodePcm16MonoWav(pcm, sampleRate);
2110
+ }
2111
+
2112
+ function encodePcm16MonoWav(samples: Int16Array, sampleRate: number): Buffer {
2113
+ const dataSize = samples.length * 2;
2114
+ const buffer = Buffer.alloc(44 + dataSize);
2115
+ buffer.write("RIFF", 0);
2116
+ buffer.writeUInt32LE(36 + dataSize, 4);
2117
+ buffer.write("WAVE", 8);
2118
+ buffer.write("fmt ", 12);
2119
+ buffer.writeUInt32LE(16, 16); // PCM chunk size
2120
+ buffer.writeUInt16LE(1, 20); // audio format = PCM
2121
+ buffer.writeUInt16LE(1, 22); // channels = mono
2122
+ buffer.writeUInt32LE(sampleRate, 24);
2123
+ buffer.writeUInt32LE(sampleRate * 2, 28); // byte rate
2124
+ buffer.writeUInt16LE(2, 32); // block align
2125
+ buffer.writeUInt16LE(16, 34); // bits per sample
2126
+ buffer.write("data", 36);
2127
+ buffer.writeUInt32LE(dataSize, 40);
2128
+ for (let i = 0; i < samples.length; i += 1) {
2129
+ buffer.writeInt16LE(samples[i], 44 + i * 2);
2130
+ }
2131
+ return buffer;
2132
+ }
2133
+
2134
+ async function transcodeWavToMp3Buffer(wavBuffer: Buffer): Promise<Buffer> {
2135
+ const tmpDir = await mkdtemp(joinPath(os.tmpdir(), "zapry-generate-audio-render-"));
2136
+ const inputPath = joinPath(tmpDir, "input.wav");
2137
+ const outputPath = joinPath(tmpDir, "output.mp3");
2138
+ try {
2139
+ await writeFile(inputPath, wavBuffer);
2140
+ await runProcessWithTimeout(
2141
+ "ffmpeg",
2142
+ ["-y", "-i", inputPath, "-codec:a", "libmp3lame", "-b:a", "96k", outputPath],
2143
+ 25000,
2144
+ );
2145
+ return await readFile(outputPath);
2146
+ } finally {
2147
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
2148
+ }
2149
+ }
2150
+
2151
+ async function runProcessWithTimeout(
2152
+ command: string,
2153
+ args: string[],
2154
+ timeoutMs: number,
2155
+ ): Promise<void> {
2156
+ await new Promise<void>((resolve, reject) => {
2157
+ const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
2158
+ let stderr = "";
2159
+ child.stderr?.setEncoding("utf8");
2160
+ child.stderr?.on("data", (chunk: string) => {
2161
+ stderr += chunk;
2162
+ });
2163
+
2164
+ const timer = setTimeout(() => {
2165
+ child.kill("SIGKILL");
2166
+ reject(new Error(`${command} timed out after ${timeoutMs}ms`));
2167
+ }, timeoutMs);
2168
+
2169
+ child.once("error", (error) => {
2170
+ clearTimeout(timer);
2171
+ reject(error);
2172
+ });
2173
+
2174
+ child.once("exit", (code) => {
2175
+ clearTimeout(timer);
2176
+ if (code === 0) {
2177
+ resolve();
2178
+ } else {
2179
+ reject(new Error(`${command} failed (${code}): ${stderr.trim() || "no stderr"}`));
2180
+ }
2181
+ });
2182
+ });
2183
+ }
2184
+
2185
+ async function sendGenerateAudioFallback(
2186
+ client: ZapryApiClient,
2187
+ chatId: string,
2188
+ fallbackText: string,
2189
+ ): Promise<void> {
2190
+ if (!chatId || !fallbackText.trim()) {
2191
+ return;
2192
+ }
2193
+ try {
2194
+ await client.sendMessage(chatId, fallbackText.trim());
2195
+ } catch {
2196
+ // best effort only
2197
+ }
2198
+ }
2199
+
2200
+ function isStandardChatId(chatId: string): boolean {
2201
+ return /^[gup]_\d+$/.test(chatId.trim());
2202
+ }
2203
+
2204
+ async function resolveChatIdByName(client: ZapryApiClient, nameOrId: string): Promise<string | null> {
2205
+ const name = nameOrId.trim();
2206
+ try {
2207
+ const resp = await client.getMyGroups(1, 100);
2208
+ if (!resp.ok) return null;
2209
+ const raw = (resp as any).result;
2210
+ const groups: any[] = Array.isArray(raw) ? raw : raw?.items ?? raw?.groups ?? [];
2211
+
2212
+ const extractNameAndId = (g: any): [string, string] => {
2213
+ const info = g.info ?? g;
2214
+ const gName = info.group_name ?? info.name ?? info.title ?? "";
2215
+ const gId = info.chat_id ?? info.group_id ?? info.chatId ?? info.id ?? "";
2216
+ return [String(gName), String(gId)];
2217
+ };
2218
+
2219
+ for (const g of groups) {
2220
+ const [gName, gId] = extractNameAndId(g);
2221
+ if (gName === name && isNonEmptyString(gId)) return gId;
2222
+ }
2223
+ for (const g of groups) {
2224
+ const [gName, gId] = extractNameAndId(g);
2225
+ if (gName.includes(name) && isNonEmptyString(gId)) return gId;
2226
+ }
2227
+ } catch {}
2228
+ return null;
2229
+ }
2230
+
2231
+ async function wrap(promise: Promise<any>): Promise<ActionResult> {
2232
+ try {
2233
+ const resp = await promise;
2234
+ if (resp.ok) {
2235
+ return { ok: true, result: resp.result };
2236
+ }
2237
+ return { ok: false, error: resp.description ?? "request failed" };
2238
+ } catch (err) {
2239
+ return { ok: false, error: String(err) };
2240
+ }
2241
+ }