ylib-openclaw-weixin 2.1.7-beta.9 → 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -310,3 +310,21 @@ Ensure `plugins.entries.openclaw-weixin.enabled` is `true` in `~/.openclaw/openc
310
310
  openclaw config set plugins.entries.openclaw-weixin.enabled true
311
311
  openclaw gateway restart
312
312
  ```
313
+
314
+ ## Long Markdown Regression (One-Click)
315
+
316
+ Use this to run an end-to-end send with a real long markdown payload (links/images/code blocks included):
317
+
318
+ ```bash
319
+ npm run regression:long-markdown -- \
320
+ --base-url https://weixin-gateway.example.com \
321
+ --to o9cq809SI88YrRxbFQXu39Lf3Xu8@im.wechat \
322
+ --context-token <context_token> \
323
+ --token <token> \
324
+ --repeat 120 \
325
+ --timeout-ms 45000
326
+ ```
327
+
328
+ Notes:
329
+ - This command builds first, then runs `scripts/regression-long-markdown.mjs`.
330
+ - It validates chunked sending behavior in real traffic.
package/README.zh_CN.md CHANGED
@@ -309,3 +309,21 @@ openclaw plugins install @tencent-weixin/openclaw-weixin@legacy
309
309
  openclaw config set plugins.entries.openclaw-weixin.enabled true
310
310
  openclaw gateway restart
311
311
  ```
312
+
313
+ ## 超长 Markdown 一键回归
314
+
315
+ 用于在真实链路下发送“超长 markdown 样例”(含链接/图片/代码块):
316
+
317
+ ```bash
318
+ npm run regression:long-markdown -- \
319
+ --base-url https://weixin-gateway.example.com \
320
+ --to o9cq809SI88YrRxbFQXu39Lf3Xu8@im.wechat \
321
+ --context-token <context_token> \
322
+ --token <token> \
323
+ --repeat 120 \
324
+ --timeout-ms 45000
325
+ ```
326
+
327
+ 说明:
328
+ - 命令会先构建,再执行 `scripts/regression-long-markdown.mjs`。
329
+ - 可用于验证分片发送在真实流量中的行为。
package/index.ts CHANGED
@@ -4,6 +4,8 @@ import { buildChannelConfigSchema } from "ylib-openclaw/plugin-sdk";
4
4
  import { weixinPlugin } from "./src/channel.js";
5
5
  export { weixinPlugin } from "./src/channel.js";
6
6
  import { resolveWeixinAccount } from "./src/auth/accounts.js";
7
+ import { getConfig, sendTyping } from "./src/api/api.js";
8
+ import { TypingStatus } from "./src/api/types.js";
7
9
  import { assertHostCompatibility } from "./src/compat.js";
8
10
  import { WeixinConfigSchema } from "./src/config/config-schema.js";
9
11
  import {
@@ -45,7 +47,7 @@ export default {
45
47
  };
46
48
  const normalizeMockScope = (
47
49
  params: any,
48
- ): { scopeType: "my_workspace" | "project"; projectId: string } => {
50
+ ): { scopeType: "workspace" | "my_workspace" | "project"; workspaceId: string; projectId: string } => {
49
51
  const rawScopeType = String(
50
52
  params?.scope_kind ||
51
53
  params?.scopeKind ||
@@ -55,16 +57,10 @@ export default {
55
57
  )
56
58
  .trim()
57
59
  .toLowerCase();
58
- const rawProjectId = String(
59
- params?.project_id || params?.projectId || "",
60
+ const workspaceId = String(
61
+ params?.workspace_id || params?.workspaceId || params?.project_id || params?.projectId || "",
60
62
  ).trim();
61
- if (
62
- (rawScopeType === "project" || (!rawScopeType && rawProjectId)) &&
63
- rawProjectId
64
- ) {
65
- return { scopeType: "project", projectId: rawProjectId };
66
- }
67
- return { scopeType: "my_workspace", projectId: "" };
63
+ return { scopeType: "workspace", workspaceId, projectId: workspaceId };
68
64
  };
69
65
  const resolveMockTargetId = (params: any): string => {
70
66
  return String(
@@ -75,6 +71,7 @@ export default {
75
71
  "",
76
72
  ).trim();
77
73
  };
74
+ const TRIGGER_TYPING_KEEPALIVE_INTERVAL_MS = 5000;
78
75
 
79
76
  api.registerGatewayMethod(
80
77
  "weixin.loginWithQrStart",
@@ -359,39 +356,107 @@ export default {
359
356
 
360
357
  const senderId =
361
358
  String(params?.senderId || targetId).trim() || targetId;
362
- void dispatchWeixinViaEndpoint({
363
- cfg,
364
- accountId: account.accountId,
365
- ctx: {
366
- Body: content,
367
- From: senderId,
368
- To: targetId,
369
- AccountId: account.accountId,
370
- OriginatingChannel: "openclaw-weixin",
371
- OriginatingTo: targetId,
372
- MessageSid: `mock_${Date.now()}_${Math.random().toString(36).slice(2)}`,
373
- Timestamp: Date.now(),
374
- Provider: "openclaw-weixin",
375
- ChatType: "direct",
376
- },
377
- endpointConfig,
378
- routeAgentId: resolvedAgentId,
379
- baseUrl: account.baseUrl,
380
- token: account.token,
381
- cdnBaseUrl: account.cdnBaseUrl,
382
- }).catch((err: unknown) => {
383
- const errText = err instanceof Error ? err.message : String(err);
384
- if (api.logger?.error) {
385
- api.logger.error(
386
- `[Weixin][MockTrigger] async dispatch failed: ${errText}`,
387
- );
388
- } else {
389
- // eslint-disable-next-line no-console -- fallback when runtime logger is unavailable
390
- console.error(
391
- `[Weixin][MockTrigger] async dispatch failed: ${errText}`,
392
- );
359
+ void (async () => {
360
+ let triggerTypingTicket = "";
361
+ let typingKeepaliveTimer: ReturnType<typeof setInterval> | null =
362
+ null;
363
+ let typingKeepaliveInFlight = false;
364
+ const sendTriggerTyping = async (status: number): Promise<void> => {
365
+ if (!triggerTypingTicket) return;
366
+ await sendTyping({
367
+ baseUrl: account.baseUrl,
368
+ token: account.token,
369
+ body: {
370
+ ilink_user_id: targetId,
371
+ typing_ticket: triggerTypingTicket,
372
+ status,
373
+ },
374
+ });
375
+ };
376
+ try {
377
+ try {
378
+ const cfgResp = await getConfig({
379
+ baseUrl: account.baseUrl,
380
+ token: account.token,
381
+ ilinkUserId: targetId,
382
+ });
383
+ triggerTypingTicket = String(cfgResp?.typing_ticket || "").trim();
384
+ if (triggerTypingTicket) {
385
+ await sendTriggerTyping(TypingStatus.TYPING);
386
+ typingKeepaliveTimer = setInterval(() => {
387
+ if (typingKeepaliveInFlight) return;
388
+ typingKeepaliveInFlight = true;
389
+ void sendTriggerTyping(TypingStatus.TYPING)
390
+ .catch((err) => {
391
+ const errText =
392
+ err instanceof Error ? err.message : String(err);
393
+ api.logger?.warn?.(
394
+ `[Weixin][MockTrigger] typing keepalive failed: ${errText}`,
395
+ );
396
+ })
397
+ .finally(() => {
398
+ typingKeepaliveInFlight = false;
399
+ });
400
+ }, TRIGGER_TYPING_KEEPALIVE_INTERVAL_MS);
401
+ }
402
+ } catch (err) {
403
+ const errText = err instanceof Error ? err.message : String(err);
404
+ api.logger?.warn?.(
405
+ `[Weixin][MockTrigger] typing start skipped: ${errText}`,
406
+ );
407
+ }
408
+
409
+ await dispatchWeixinViaEndpoint({
410
+ cfg,
411
+ accountId: account.accountId,
412
+ ctx: {
413
+ Body: content,
414
+ From: senderId,
415
+ To: targetId,
416
+ AccountId: account.accountId,
417
+ OriginatingChannel: "openclaw-weixin",
418
+ OriginatingTo: targetId,
419
+ MessageSid: `mock_${Date.now()}_${Math.random().toString(36).slice(2)}`,
420
+ Timestamp: Date.now(),
421
+ Provider: "openclaw-weixin",
422
+ ChatType: "direct",
423
+ },
424
+ endpointConfig,
425
+ routeAgentId: resolvedAgentId,
426
+ baseUrl: account.baseUrl,
427
+ token: account.token,
428
+ cdnBaseUrl: account.cdnBaseUrl,
429
+ });
430
+ } catch (err: unknown) {
431
+ const errText = err instanceof Error ? err.message : String(err);
432
+ if (api.logger?.error) {
433
+ api.logger.error(
434
+ `[Weixin][MockTrigger] async dispatch failed: ${errText}`,
435
+ );
436
+ } else {
437
+ // eslint-disable-next-line no-console -- fallback when runtime logger is unavailable
438
+ console.error(
439
+ `[Weixin][MockTrigger] async dispatch failed: ${errText}`,
440
+ );
441
+ }
442
+ } finally {
443
+ if (typingKeepaliveTimer) {
444
+ clearInterval(typingKeepaliveTimer);
445
+ typingKeepaliveTimer = null;
446
+ }
447
+ if (triggerTypingTicket) {
448
+ try {
449
+ await sendTriggerTyping(TypingStatus.CANCEL);
450
+ } catch (err) {
451
+ const errText =
452
+ err instanceof Error ? err.message : String(err);
453
+ api.logger?.warn?.(
454
+ `[Weixin][MockTrigger] typing cancel failed: ${errText}`,
455
+ );
456
+ }
457
+ }
393
458
  }
394
- });
459
+ })();
395
460
 
396
461
  return respond(true, {
397
462
  ok: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylib-openclaw-weixin",
3
- "version": "2.1.7-beta.9",
3
+ "version": "2.1.8",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
@@ -10,6 +10,8 @@
10
10
  "!src/**/*.test.ts",
11
11
  "!src/**/node_modules/",
12
12
  "index.ts",
13
+ "schema.json",
14
+ "scripts/generate-schema.mjs",
13
15
  "openclaw.plugin.json",
14
16
  "README.md",
15
17
  "README.zh_CN.md",
@@ -19,12 +21,16 @@
19
21
  "scripts": {
20
22
  "test": "vitest run --coverage",
21
23
  "typecheck": "tsc --noEmit",
22
- "build": "tsc"
24
+ "build": "tsc",
25
+ "regression:long-markdown": "npm run build && node ./scripts/regression-long-markdown.mjs",
26
+ "schema:generate": "node ./scripts/generate-schema.mjs",
27
+ "prepublishOnly": "npm run schema:generate"
23
28
  },
24
29
  "engines": {
25
30
  "node": ">=22"
26
31
  },
27
32
  "dependencies": {
33
+ "marked": "^15.0.12",
28
34
  "qrcode-terminal": "0.12.0",
29
35
  "zod": "4.3.6",
30
36
  "ylib-openclaw": "2026.3.9-beta.5"
package/schema.json ADDED
@@ -0,0 +1,205 @@
1
+ {
2
+ "pluginId": "openclaw-weixin",
3
+ "channel": "weixin",
4
+ "rawSchema": {
5
+ "$schema": "http://json-schema.org/draft-07/schema#",
6
+ "type": "object",
7
+ "properties": {
8
+ "name": {
9
+ "type": "string"
10
+ },
11
+ "enabled": {
12
+ "type": "boolean"
13
+ },
14
+ "baseUrl": {
15
+ "default": "https://ilinkai.weixin.qq.com",
16
+ "type": "string"
17
+ },
18
+ "cdnBaseUrl": {
19
+ "default": "https://novac2c.cdn.weixin.qq.com/c2c",
20
+ "type": "string"
21
+ },
22
+ "routeTag": {
23
+ "type": "number"
24
+ },
25
+ "botToken": {
26
+ "type": "string"
27
+ },
28
+ "botId": {
29
+ "type": "string"
30
+ },
31
+ "modelName": {
32
+ "default": "main",
33
+ "type": "string"
34
+ },
35
+ "agentId": {
36
+ "default": "main",
37
+ "type": "string"
38
+ },
39
+ "gatewayBaseUrl": {
40
+ "type": "string"
41
+ },
42
+ "gatewayToken": {
43
+ "type": "string"
44
+ },
45
+ "uploadHost": {
46
+ "type": "string"
47
+ },
48
+ "showFinalAnswerOnly": {
49
+ "default": false,
50
+ "type": "boolean"
51
+ },
52
+ "scopeType": {
53
+ "default": "workspace",
54
+ "readOnly": true,
55
+ "type": "string"
56
+ },
57
+ "workspaceId": {
58
+ "default": "",
59
+ "readOnly": true,
60
+ "type": "string"
61
+ },
62
+ "projectId": {
63
+ "default": "",
64
+ "readOnly": true,
65
+ "type": "string"
66
+ },
67
+ "scope_kind": {
68
+ "readOnly": true,
69
+ "type": "string"
70
+ },
71
+ "scopeKind": {
72
+ "readOnly": true,
73
+ "type": "string"
74
+ },
75
+ "scope_type": {
76
+ "readOnly": true,
77
+ "type": "string"
78
+ },
79
+ "workspace_id": {
80
+ "readOnly": true,
81
+ "type": "string"
82
+ },
83
+ "project_id": {
84
+ "readOnly": true,
85
+ "type": "string"
86
+ },
87
+ "accounts": {
88
+ "type": "object",
89
+ "propertyNames": {
90
+ "type": "string"
91
+ },
92
+ "additionalProperties": {
93
+ "type": "object",
94
+ "properties": {
95
+ "name": {
96
+ "type": "string"
97
+ },
98
+ "enabled": {
99
+ "type": "boolean"
100
+ },
101
+ "baseUrl": {
102
+ "default": "https://ilinkai.weixin.qq.com",
103
+ "type": "string"
104
+ },
105
+ "cdnBaseUrl": {
106
+ "default": "https://novac2c.cdn.weixin.qq.com/c2c",
107
+ "type": "string"
108
+ },
109
+ "routeTag": {
110
+ "type": "number"
111
+ },
112
+ "botToken": {
113
+ "type": "string"
114
+ },
115
+ "botId": {
116
+ "type": "string"
117
+ },
118
+ "modelName": {
119
+ "default": "main",
120
+ "type": "string"
121
+ },
122
+ "agentId": {
123
+ "default": "main",
124
+ "type": "string"
125
+ },
126
+ "gatewayBaseUrl": {
127
+ "type": "string"
128
+ },
129
+ "gatewayToken": {
130
+ "type": "string"
131
+ },
132
+ "uploadHost": {
133
+ "type": "string"
134
+ },
135
+ "showFinalAnswerOnly": {
136
+ "default": false,
137
+ "type": "boolean"
138
+ },
139
+ "scopeType": {
140
+ "default": "workspace",
141
+ "readOnly": true,
142
+ "type": "string"
143
+ },
144
+ "workspaceId": {
145
+ "default": "",
146
+ "readOnly": true,
147
+ "type": "string"
148
+ },
149
+ "projectId": {
150
+ "default": "",
151
+ "readOnly": true,
152
+ "type": "string"
153
+ },
154
+ "scope_kind": {
155
+ "readOnly": true,
156
+ "type": "string"
157
+ },
158
+ "scopeKind": {
159
+ "readOnly": true,
160
+ "type": "string"
161
+ },
162
+ "scope_type": {
163
+ "readOnly": true,
164
+ "type": "string"
165
+ },
166
+ "workspace_id": {
167
+ "readOnly": true,
168
+ "type": "string"
169
+ },
170
+ "project_id": {
171
+ "readOnly": true,
172
+ "type": "string"
173
+ }
174
+ },
175
+ "required": [
176
+ "baseUrl",
177
+ "cdnBaseUrl",
178
+ "modelName",
179
+ "agentId",
180
+ "showFinalAnswerOnly",
181
+ "scopeType",
182
+ "workspaceId",
183
+ "projectId"
184
+ ],
185
+ "additionalProperties": false
186
+ }
187
+ },
188
+ "channelConfigUpdatedAt": {
189
+ "type": "string"
190
+ }
191
+ },
192
+ "required": [
193
+ "baseUrl",
194
+ "cdnBaseUrl",
195
+ "modelName",
196
+ "agentId",
197
+ "showFinalAnswerOnly",
198
+ "scopeType",
199
+ "workspaceId",
200
+ "projectId"
201
+ ],
202
+ "additionalProperties": false
203
+ },
204
+ "schemaVersion": 1
205
+ }
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const entryPath = path.join(root, "dist", "index.js");
8
+ const outputPath = path.join(root, "schema.json");
9
+
10
+ if (!fs.existsSync(entryPath)) {
11
+ throw new Error("dist/index.js not found, run build first");
12
+ }
13
+
14
+ const mod = await import(pathToFileURL(entryPath).href);
15
+ const plugin = mod.default;
16
+ const rawSchema = plugin?.configSchema?.schema;
17
+ if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) {
18
+ throw new Error("failed to resolve weixin plugin raw schema from dist/index.js");
19
+ }
20
+
21
+ const payload = {
22
+ pluginId: "openclaw-weixin",
23
+ channel: "weixin",
24
+ rawSchema,
25
+ schemaVersion: 1,
26
+ // generatedAt: new Date().toISOString(),
27
+ };
28
+
29
+ fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
30
+ console.log(`[schema] generated ${outputPath}`);
package/src/api/api.ts CHANGED
@@ -134,7 +134,6 @@ function buildHeaders(opts: {
134
134
  const headers: Record<string, string> = {
135
135
  "Content-Type": "application/json",
136
136
  AuthorizationType: "ilink_bot_token",
137
- "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
138
137
  "X-WECHAT-UIN": randomWechatUin(),
139
138
  ...buildCommonHeaders(),
140
139
  };
@@ -274,6 +273,7 @@ async function apiPostFetch(params: {
274
273
  }
275
274
  return rawText;
276
275
  } catch (err) {
276
+ console.error(`Error in ${params.label}:`, err);
277
277
  clearTimeout(t);
278
278
  if (params.abortSignal) {
279
279
  params.abortSignal.removeEventListener("abort", onExternalAbort);
package/src/cdn/upload.ts CHANGED
@@ -11,6 +11,8 @@ import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
11
11
  import { tempFileName } from "../util/random.js";
12
12
  import { UploadMediaType } from "../api/types.js";
13
13
 
14
+ const DOWNLOAD_TEMP_WRITE_MAX_RETRIES = 8;
15
+
14
16
  export type UploadedFileInfo = {
15
17
  filekey: string;
16
18
  /** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
@@ -23,15 +25,21 @@ export type UploadedFileInfo = {
23
25
  fileSizeCiphertext: number;
24
26
  };
25
27
 
28
+ export type DownloadedRemoteTempFile = {
29
+ filePath: string;
30
+ contentDisposition?: string;
31
+ contentType?: string;
32
+ };
33
+
26
34
  /**
27
35
  * Download a remote media URL (image, video, file) to a local temp file in destDir.
28
36
  * Returns the local file path; extension is inferred from Content-Type / URL.
29
37
  */
30
- export async function downloadRemoteImageToTemp(
38
+ export async function downloadRemoteImageToTempWithMeta(
31
39
  url: string,
32
40
  destDir: string,
33
41
  opts?: { gatewayToken?: string },
34
- ): Promise<string> {
42
+ ): Promise<DownloadedRemoteTempFile> {
35
43
  logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
36
44
  const gatewayToken = opts?.gatewayToken?.trim();
37
45
  const fetchInit: RequestInit | undefined = gatewayToken
@@ -47,11 +55,45 @@ export async function downloadRemoteImageToTemp(
47
55
  logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
48
56
  await fs.mkdir(destDir, { recursive: true });
49
57
  const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
50
- const name = tempFileName("weixin-remote", ext);
51
- const filePath = path.join(destDir, name);
52
- await fs.writeFile(filePath, buf);
53
- logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
54
- return filePath;
58
+ let lastErr: unknown;
59
+ for (let i = 0; i < DOWNLOAD_TEMP_WRITE_MAX_RETRIES; i += 1) {
60
+ const name = tempFileName("weixin-remote", ext);
61
+ const filePath = path.join(destDir, name);
62
+ try {
63
+ const fd = await fs.open(filePath, "wx");
64
+ try {
65
+ await fd.writeFile(buf);
66
+ } finally {
67
+ await fd.close();
68
+ }
69
+ logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
70
+ return {
71
+ filePath,
72
+ contentDisposition: res.headers.get("content-disposition") ?? undefined,
73
+ contentType: res.headers.get("content-type") ?? undefined,
74
+ };
75
+ } catch (err) {
76
+ lastErr = err;
77
+ const code = (err as NodeJS.ErrnoException)?.code;
78
+ if (code !== "EEXIST") {
79
+ throw err;
80
+ }
81
+ logger.warn(
82
+ `downloadRemoteImageToTemp: temp file name conflict, retry=${i + 1}/${DOWNLOAD_TEMP_WRITE_MAX_RETRIES} url=${url}`,
83
+ );
84
+ }
85
+ }
86
+ throw new Error(
87
+ `downloadRemoteImageToTemp: failed to persist temp file after ${DOWNLOAD_TEMP_WRITE_MAX_RETRIES} retries url=${url} err=${String(lastErr)}`,
88
+ );
89
+ }
90
+
91
+ export async function downloadRemoteImageToTemp(
92
+ url: string,
93
+ destDir: string,
94
+ opts?: { gatewayToken?: string },
95
+ ): Promise<string> {
96
+ return (await downloadRemoteImageToTempWithMeta(url, destDir, opts)).filePath;
55
97
  }
56
98
 
57
99
  /**
@@ -22,16 +22,23 @@ const weixinAccountSchema = z.object({
22
22
  gatewayBaseUrl: z.string().optional(),
23
23
  gatewayToken: z.string().optional(),
24
24
  uploadHost: z.string().optional(),
25
+ /**
26
+ * 默认关闭以保持历史输出;开启后普通 content、sub-agent 过程文本和资源过程产物不展示,
27
+ * 但 error 与主流程确认兜底仍会展示,避免失败信息丢失或确认流程卡住。
28
+ */
29
+ showFinalAnswerOnly: z.boolean().optional().default(false),
25
30
  /** Runtime readonly scope fields injected by backend. */
26
31
  scopeType: z
27
32
  .string()
28
33
  .optional()
29
- .default("my_workspace")
34
+ .default("workspace")
30
35
  .meta({ readOnly: true }),
36
+ workspaceId: z.string().optional().default("").meta({ readOnly: true }),
31
37
  projectId: z.string().optional().default("").meta({ readOnly: true }),
32
38
  scope_kind: z.string().optional().meta({ readOnly: true }),
33
39
  scopeKind: z.string().optional().meta({ readOnly: true }),
34
40
  scope_type: z.string().optional().meta({ readOnly: true }),
41
+ workspace_id: z.string().optional().meta({ readOnly: true }),
35
42
  project_id: z.string().optional().meta({ readOnly: true }),
36
43
  });
37
44
 
@@ -39,7 +39,14 @@ export async function downloadMediaFromItem(
39
39
 
40
40
  if (item.type === MessageItemType.IMAGE) {
41
41
  const img = item.image_item;
42
- if (!img?.media?.encrypt_query_param && !img?.media?.full_url) return result;
42
+ if (!img?.media?.encrypt_query_param && !img?.media?.full_url) {
43
+ logger.warn(
44
+ `${label} image skipped: missing media.encrypt_query_param/full_url hasImageItem=${Boolean(
45
+ img,
46
+ )} hasMedia=${Boolean(img?.media)} hasUrlOnly=${Boolean(img?.url)}`,
47
+ );
48
+ return result;
49
+ }
43
50
  const aesKeyBase64 = img.aeskey
44
51
  ? Buffer.from(img.aeskey, "hex").toString("base64")
45
52
  : img.media.aes_key;
@@ -119,6 +126,7 @@ export async function downloadMediaFromItem(
119
126
  );
120
127
  result.decryptedFilePath = saved.path;
121
128
  result.fileMediaType = mime;
129
+ result.fileName = fileItem.file_name ?? undefined;
122
130
  logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`);
123
131
  } catch (err) {
124
132
  logger.error(`${label} file download failed: ${String(err)}`);