@larksuite/openclaw-lark 2026.3.12 → 2026.3.17-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/bin/openclaw-lark.js +48 -0
  2. package/package.json +4 -1
  3. package/src/card/builder.d.ts +8 -3
  4. package/src/card/builder.js +44 -21
  5. package/src/card/image-resolver.d.ts +45 -0
  6. package/src/card/image-resolver.js +113 -0
  7. package/src/card/markdown-style.js +6 -6
  8. package/src/card/streaming-card-controller.d.ts +1 -0
  9. package/src/card/streaming-card-controller.js +25 -7
  10. package/src/commands/auth.d.ts +7 -1
  11. package/src/commands/auth.js +95 -18
  12. package/src/commands/doctor.d.ts +10 -1
  13. package/src/commands/doctor.js +209 -63
  14. package/src/commands/index.d.ts +18 -1
  15. package/src/commands/index.js +122 -48
  16. package/src/commands/locale.d.ts +7 -0
  17. package/src/commands/locale.js +8 -0
  18. package/src/core/auth-errors.d.ts +12 -10
  19. package/src/core/auth-errors.js +13 -14
  20. package/src/core/domains.d.ts +18 -0
  21. package/src/core/domains.js +29 -0
  22. package/src/core/targets.d.ts +11 -0
  23. package/src/core/targets.js +38 -1
  24. package/src/core/tool-scopes.d.ts +3 -3
  25. package/src/core/tool-scopes.js +3 -2
  26. package/src/core/tools-config.d.ts +21 -0
  27. package/src/core/tools-config.js +50 -0
  28. package/src/core/uat-client.js +31 -16
  29. package/src/messaging/inbound/dispatch-builders.d.ts +1 -0
  30. package/src/messaging/inbound/dispatch-builders.js +1 -1
  31. package/src/messaging/inbound/dispatch.js +72 -0
  32. package/src/messaging/inbound/reaction-handler.js +5 -4
  33. package/src/messaging/outbound/media.d.ts +5 -0
  34. package/src/messaging/outbound/media.js +10 -0
  35. package/src/messaging/outbound/outbound.js +25 -15
  36. package/src/messaging/outbound/send.d.ts +17 -0
  37. package/src/messaging/outbound/send.js +89 -22
  38. package/src/tools/auto-auth.js +76 -81
  39. package/src/tools/helpers.d.ts +41 -0
  40. package/src/tools/helpers.js +67 -0
  41. package/src/tools/mcp/shared.d.ts +3 -1
  42. package/src/tools/mcp/shared.js +11 -8
  43. package/src/tools/oapi/bitable/app-table-field.js +2 -3
  44. package/src/tools/oapi/bitable/app-table-record.js +4 -16
  45. package/src/tools/oapi/bitable/app-table-view.js +2 -3
  46. package/src/tools/oapi/bitable/app-table.js +2 -3
  47. package/src/tools/oapi/bitable/app.js +2 -3
  48. package/src/tools/oapi/calendar/calendar.js +2 -3
  49. package/src/tools/oapi/calendar/event-attendee.js +5 -16
  50. package/src/tools/oapi/calendar/event.js +9 -20
  51. package/src/tools/oapi/calendar/freebusy.js +2 -3
  52. package/src/tools/oapi/chat/chat.js +4 -5
  53. package/src/tools/oapi/chat/members.js +3 -4
  54. package/src/tools/oapi/common/get-user.js +3 -4
  55. package/src/tools/oapi/common/search-user.js +2 -3
  56. package/src/tools/oapi/drive/doc-comments.js +6 -14
  57. package/src/tools/oapi/drive/doc-media.js +5 -6
  58. package/src/tools/oapi/drive/file.js +4 -5
  59. package/src/tools/oapi/helpers.d.ts +10 -1
  60. package/src/tools/oapi/helpers.js +15 -1
  61. package/src/tools/oapi/im/message-read.js +11 -11
  62. package/src/tools/oapi/im/message.js +5 -26
  63. package/src/tools/oapi/im/resource.js +3 -4
  64. package/src/tools/oapi/im/user-name-uat.d.ts +3 -0
  65. package/src/tools/oapi/im/user-name-uat.js +25 -12
  66. package/src/tools/oapi/search/doc-search.js +15 -26
  67. package/src/tools/oapi/sheets/sheet.js +7 -6
  68. package/src/tools/oapi/task/comment.js +3 -4
  69. package/src/tools/oapi/task/subtask.js +3 -4
  70. package/src/tools/oapi/task/task.js +8 -9
  71. package/src/tools/oapi/task/tasklist.js +5 -6
  72. package/src/tools/oapi/wiki/space-node.js +5 -27
  73. package/src/tools/oapi/wiki/space.js +2 -3
  74. package/src/tools/oauth-batch-auth.js +4 -3
  75. package/src/tools/oauth-cards.d.ts +18 -5
  76. package/src/tools/oauth-cards.js +109 -44
  77. package/src/tools/oauth.js +19 -17
  78. package/src/tools/tat/im/resource.js +3 -3
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { dirname, join } from "node:path";
5
+
6
+ // --tools-version <ver> lets the user pin a specific version
7
+ const args = process.argv.slice(2);
8
+ let version = "latest";
9
+
10
+ const vIdx = args.indexOf("--tools-version");
11
+ if (vIdx !== -1) {
12
+ version = args[vIdx + 1];
13
+ // Remove --tools-version <ver> from forwarded args
14
+ args.splice(vIdx, 2);
15
+ }
16
+
17
+ const allArgs = ["--yes", `@larksuite/openclaw-lark-tools@${version}`, ...args];
18
+
19
+ try {
20
+ if (process.platform === "win32") {
21
+ // On Windows, npx is a .cmd shim that can be broken or trigger
22
+ // DEP0190. Bypass it entirely: run node with the npx-cli.js
23
+ // script located next to the running node binary.
24
+ const npxCli = join(
25
+ dirname(process.execPath),
26
+ "node_modules",
27
+ "npm",
28
+ "bin",
29
+ "npx-cli.js",
30
+ );
31
+ execFileSync(process.execPath, [npxCli, ...allArgs], {
32
+ stdio: "inherit",
33
+ env: {
34
+ ...process.env,
35
+ NODE_OPTIONS: [
36
+ process.env.NODE_OPTIONS,
37
+ "--disable-warning=DEP0190",
38
+ ]
39
+ .filter(Boolean)
40
+ .join(" "),
41
+ },
42
+ });
43
+ } else {
44
+ execFileSync("npx", allArgs, { stdio: "inherit" });
45
+ }
46
+ } catch (error) {
47
+ process.exit(error.status ?? 1);
48
+ }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@larksuite/openclaw-lark",
3
- "version": "2026.3.12",
3
+ "version": "2026.3.17-beta.0",
4
4
  "description": "OpenClaw Lark/Feishu channel plugin",
5
5
  "type": "module",
6
+ "bin": {
7
+ "openclaw-lark": "bin/openclaw-lark.js"
8
+ },
6
9
  "files": [
7
10
  "**/*"
8
11
  ],
@@ -28,6 +28,7 @@ export interface FeishuCard {
28
28
  config: {
29
29
  wide_screen_mode: boolean;
30
30
  update_multi?: boolean;
31
+ locales?: string[];
31
32
  summary?: {
32
33
  content: string;
33
34
  };
@@ -36,6 +37,7 @@ export interface FeishuCard {
36
37
  title: {
37
38
  tag: 'plain_text';
38
39
  content: string;
40
+ i18n_content?: Record<string, string>;
39
41
  };
40
42
  template: string;
41
43
  };
@@ -66,10 +68,13 @@ export declare function splitReasoningText(text?: string): {
66
68
  */
67
69
  export declare function stripReasoningTags(text: string): string;
68
70
  /**
69
- * Format reasoning duration into a human-readable string.
70
- * e.g. "Thought for 3.2s" or "Thought for 1m 15s"
71
+ * Format reasoning duration into a human-readable i18n pair.
72
+ * e.g. { zh: "思考了 3.2s", en: "Thought for 3.2s" }
71
73
  */
72
- export declare function formatReasoningDuration(ms: number): string;
74
+ export declare function formatReasoningDuration(ms: number): {
75
+ zh: string;
76
+ en: string;
77
+ };
73
78
  /**
74
79
  * Format milliseconds into a human-readable duration string.
75
80
  */
@@ -107,11 +107,12 @@ function cleanReasoningPrefix(text) {
107
107
  return cleaned.trim();
108
108
  }
109
109
  /**
110
- * Format reasoning duration into a human-readable string.
111
- * e.g. "Thought for 3.2s" or "Thought for 1m 15s"
110
+ * Format reasoning duration into a human-readable i18n pair.
111
+ * e.g. { zh: "思考了 3.2s", en: "Thought for 3.2s" }
112
112
  */
113
113
  export function formatReasoningDuration(ms) {
114
- return `Thought for ${formatElapsed(ms)}`;
114
+ const d = formatElapsed(ms);
115
+ return { zh: `思考了 ${d}`, en: `Thought for ${d}` };
115
116
  }
116
117
  /**
117
118
  * Format milliseconds into a human-readable duration string.
@@ -121,12 +122,18 @@ export function formatElapsed(ms) {
121
122
  return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
122
123
  }
123
124
  /**
124
- * Build footer meta-info: hr separator + notation-sized text.
125
+ * Build footer meta-info: notation-sized text with i18n support.
125
126
  * Error text is rendered in red; normal text uses default grey (notation).
126
127
  */
127
- function buildFooter(text, isError) {
128
- const content = isError ? `<font color='red'>${text}</font>` : text;
129
- return [{ tag: 'markdown', content, text_size: 'notation' }];
128
+ function buildFooter(zhText, enText, isError) {
129
+ const zhContent = isError ? `<font color='red'>${zhText}</font>` : zhText;
130
+ const enContent = isError ? `<font color='red'>${enText}</font>` : enText;
131
+ return [{
132
+ tag: 'markdown',
133
+ content: enContent,
134
+ i18n_content: { zh_cn: zhContent, en_us: enContent },
135
+ text_size: 'notation',
136
+ }];
130
137
  }
131
138
  // ---------------------------------------------------------------------------
132
139
  // buildCardContent
@@ -163,11 +170,12 @@ export function buildCardContent(state, data = {}) {
163
170
  // ---------------------------------------------------------------------------
164
171
  function buildThinkingCard() {
165
172
  return {
166
- config: { wide_screen_mode: true, update_multi: true },
173
+ config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
167
174
  elements: [
168
175
  {
169
176
  tag: 'markdown',
170
- content: '思考中...',
177
+ content: 'Thinking...',
178
+ i18n_content: { zh_cn: '思考中...', en_us: 'Thinking...' },
171
179
  },
172
180
  ],
173
181
  };
@@ -179,6 +187,10 @@ function buildStreamingCard(partialText, toolCalls, reasoningText) {
179
187
  elements.push({
180
188
  tag: 'markdown',
181
189
  content: `💭 **Thinking...**\n\n${reasoningText}`,
190
+ i18n_content: {
191
+ zh_cn: `💭 **思考中...**\n\n${reasoningText}`,
192
+ en_us: `💭 **Thinking...**\n\n${reasoningText}`,
193
+ },
182
194
  text_size: 'notation',
183
195
  });
184
196
  }
@@ -202,7 +214,7 @@ function buildStreamingCard(partialText, toolCalls, reasoningText) {
202
214
  });
203
215
  }
204
216
  return {
205
- config: { wide_screen_mode: true, update_multi: true },
217
+ config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
206
218
  elements,
207
219
  };
208
220
  }
@@ -211,14 +223,20 @@ function buildCompleteCard(params) {
211
223
  const elements = [];
212
224
  // Collapsible reasoning panel (before main content)
213
225
  if (reasoningText) {
214
- const durationLabel = reasoningElapsedMs ? formatReasoningDuration(reasoningElapsedMs) : 'Thought';
226
+ const dur = reasoningElapsedMs ? formatReasoningDuration(reasoningElapsedMs) : null;
227
+ const zhLabel = dur ? dur.zh : '思考';
228
+ const enLabel = dur ? dur.en : 'Thought';
215
229
  elements.push({
216
230
  tag: 'collapsible_panel',
217
231
  expanded: false,
218
232
  header: {
219
233
  title: {
220
234
  tag: 'markdown',
221
- content: `💭 ${durationLabel}`,
235
+ content: `💭 ${enLabel}`,
236
+ i18n_content: {
237
+ zh_cn: `💭 ${zhLabel}`,
238
+ en_us: `💭 ${enLabel}`,
239
+ },
222
240
  },
223
241
  vertical_align: 'center',
224
242
  icon: {
@@ -260,31 +278,36 @@ function buildCompleteCard(params) {
260
278
  }
261
279
  // Footer meta-info: each metadata item is independently controlled via
262
280
  // the `footer` config. Both status and elapsed default to hidden.
263
- const parts = [];
281
+ const zhParts = [];
282
+ const enParts = [];
264
283
  if (footer?.status) {
265
284
  if (isError) {
266
- parts.push('出错');
285
+ zhParts.push('出错');
286
+ enParts.push('Error');
267
287
  }
268
288
  else if (isAborted) {
269
- parts.push('已停止');
289
+ zhParts.push('已停止');
290
+ enParts.push('Stopped');
270
291
  }
271
292
  else {
272
- parts.push('已完成');
293
+ zhParts.push('已完成');
294
+ enParts.push('Completed');
273
295
  }
274
296
  }
275
297
  if (footer?.elapsed && elapsedMs != null) {
276
- parts.push(`耗时 ${formatElapsed(elapsedMs)}`);
298
+ const d = formatElapsed(elapsedMs);
299
+ zhParts.push(`耗时 ${d}`);
300
+ enParts.push(`Elapsed ${d}`);
277
301
  }
278
- if (parts.length > 0) {
279
- const footerText = parts.join(' · ');
280
- elements.push(...buildFooter(footerText, isError));
302
+ if (zhParts.length > 0) {
303
+ elements.push(...buildFooter(zhParts.join(' · '), enParts.join(' · '), isError));
281
304
  }
282
305
  // Use the answer text (not reasoning) as the feed preview summary.
283
306
  // Strip markdown syntax so the preview reads as plain text.
284
307
  const summaryText = text.replace(/[*_`#>\[\]()~]/g, '').trim();
285
308
  const summary = summaryText ? { content: summaryText.slice(0, 120) } : undefined;
286
309
  return {
287
- config: { wide_screen_mode: true, update_multi: true, summary },
310
+ config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'], summary },
288
311
  elements,
289
312
  };
290
313
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ImageResolver — converts image URLs in markdown to Feishu image keys.
6
+ *
7
+ * Used by StreamingCardController to asynchronously download and upload
8
+ * images referenced via `![alt](https://...)` in model-generated markdown,
9
+ * replacing them with `![alt](img_xxx)` that Feishu cards can render.
10
+ */
11
+ import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
12
+ export interface ImageResolverOptions {
13
+ cfg: ClawdbotConfig;
14
+ accountId: string | undefined;
15
+ /** Called when a previously-pending image upload completes. */
16
+ onImageResolved: () => void;
17
+ }
18
+ export declare class ImageResolver {
19
+ /** URL → imageKey for successfully uploaded images. */
20
+ private readonly resolved;
21
+ /** URL → upload Promise for in-flight uploads (dedup). */
22
+ private readonly pending;
23
+ /** URLs that have already failed — skip retries. */
24
+ private readonly failed;
25
+ private readonly cfg;
26
+ private readonly accountId;
27
+ private readonly onImageResolved;
28
+ constructor(opts: ImageResolverOptions);
29
+ /**
30
+ * Synchronously resolve image URLs in markdown text.
31
+ *
32
+ * - `img_xxx` references are kept as-is.
33
+ * - URLs with a cached imageKey are replaced inline.
34
+ * - URLs with an in-flight upload are stripped (will appear after re-flush).
35
+ * - New URLs trigger an async upload and are stripped for now.
36
+ */
37
+ resolveImages(text: string): string;
38
+ /**
39
+ * Resolve all image URLs in text synchronously: trigger uploads for new
40
+ * URLs, wait for all pending uploads, then return text with image keys.
41
+ */
42
+ resolveImagesAwait(text: string, timeoutMs: number): Promise<string>;
43
+ private startUpload;
44
+ private doUpload;
45
+ }
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * ImageResolver — converts image URLs in markdown to Feishu image keys.
7
+ *
8
+ * Used by StreamingCardController to asynchronously download and upload
9
+ * images referenced via `![alt](https://...)` in model-generated markdown,
10
+ * replacing them with `![alt](img_xxx)` that Feishu cards can render.
11
+ */
12
+ import { fetchRemoteImageBuffer, uploadImageLark } from '../messaging/outbound/media';
13
+ import { larkLogger } from '../core/lark-logger';
14
+ const log = larkLogger('card/image-resolver');
15
+ /** Matches complete markdown image syntax: `![alt](value)` */
16
+ const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
17
+ export class ImageResolver {
18
+ /** URL → imageKey for successfully uploaded images. */
19
+ resolved = new Map();
20
+ /** URL → upload Promise for in-flight uploads (dedup). */
21
+ pending = new Map();
22
+ /** URLs that have already failed — skip retries. */
23
+ failed = new Set();
24
+ cfg;
25
+ accountId;
26
+ onImageResolved;
27
+ constructor(opts) {
28
+ this.cfg = opts.cfg;
29
+ this.accountId = opts.accountId;
30
+ this.onImageResolved = opts.onImageResolved;
31
+ }
32
+ /**
33
+ * Synchronously resolve image URLs in markdown text.
34
+ *
35
+ * - `img_xxx` references are kept as-is.
36
+ * - URLs with a cached imageKey are replaced inline.
37
+ * - URLs with an in-flight upload are stripped (will appear after re-flush).
38
+ * - New URLs trigger an async upload and are stripped for now.
39
+ */
40
+ resolveImages(text) {
41
+ if (!text.includes('!['))
42
+ return text;
43
+ return text.replace(IMAGE_RE, (fullMatch, alt, value) => {
44
+ // Already a Feishu image key — keep.
45
+ if (value.startsWith('img_'))
46
+ return fullMatch;
47
+ // Not a remote URL — strip (local paths, data URIs, etc.).
48
+ if (!value.startsWith('http://') && !value.startsWith('https://'))
49
+ return '';
50
+ // Cached — replace with image key.
51
+ const cached = this.resolved.get(value);
52
+ if (cached)
53
+ return `![${alt}](${cached})`;
54
+ // Already failed — don't retry, strip.
55
+ if (this.failed.has(value))
56
+ return '';
57
+ // Upload in progress — strip for now.
58
+ if (this.pending.has(value))
59
+ return '';
60
+ // New URL — kick off async upload, strip for now.
61
+ this.startUpload(value);
62
+ return '';
63
+ });
64
+ }
65
+ /**
66
+ * Resolve all image URLs in text synchronously: trigger uploads for new
67
+ * URLs, wait for all pending uploads, then return text with image keys.
68
+ */
69
+ async resolveImagesAwait(text, timeoutMs) {
70
+ // First pass: trigger uploads for any new URLs
71
+ this.resolveImages(text);
72
+ if (this.pending.size > 0) {
73
+ log.info('resolveImagesAwait: waiting for uploads', { count: this.pending.size, timeoutMs });
74
+ const allUploads = Promise.all(this.pending.values());
75
+ const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
76
+ await Promise.race([allUploads, timeout]);
77
+ if (this.pending.size > 0) {
78
+ log.warn('resolveImagesAwait: timed out with pending uploads', {
79
+ remaining: this.pending.size,
80
+ });
81
+ }
82
+ }
83
+ // Second pass: replace URLs with resolved image keys
84
+ return this.resolveImages(text);
85
+ }
86
+ startUpload(url) {
87
+ const uploadPromise = this.doUpload(url);
88
+ this.pending.set(url, uploadPromise);
89
+ }
90
+ async doUpload(url) {
91
+ try {
92
+ log.info('uploading image', { url });
93
+ const buffer = await fetchRemoteImageBuffer(url);
94
+ const { imageKey } = await uploadImageLark({
95
+ cfg: this.cfg,
96
+ image: buffer,
97
+ imageType: 'message',
98
+ accountId: this.accountId,
99
+ });
100
+ log.info('image uploaded', { url, imageKey });
101
+ this.resolved.set(url, imageKey);
102
+ this.pending.delete(url);
103
+ this.onImageResolved();
104
+ return imageKey;
105
+ }
106
+ catch (err) {
107
+ log.warn('image upload failed', { url, error: String(err) });
108
+ this.pending.delete(url);
109
+ this.failed.add(url);
110
+ return null;
111
+ }
112
+ }
113
+ }
@@ -81,7 +81,11 @@ function _optimizeMarkdownStyle(text, cardVersion = 2) {
81
81
  const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
82
82
  /**
83
83
  * Strip `![alt](value)` where value is not a valid Feishu image key
84
- * (`img_xxx`) or remote URL. Prevents CardKit error 200570.
84
+ * (`img_xxx`). Prevents CardKit error 200570.
85
+ *
86
+ * HTTP URLs are stripped as well — ImageResolver should have already
87
+ * replaced them with `img_xxx` keys before this point. This serves
88
+ * as a safety net for any unresolved URLs.
85
89
  */
86
90
  function stripInvalidImageKeys(text) {
87
91
  if (!text.includes('!['))
@@ -89,10 +93,6 @@ function stripInvalidImageKeys(text) {
89
93
  return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
90
94
  if (value.startsWith('img_'))
91
95
  return fullMatch;
92
- if (value.startsWith('http://'))
93
- return fullMatch;
94
- if (value.startsWith('https://'))
95
- return fullMatch;
96
- return value;
96
+ return ''; // strip all non-img_ image references (URLs, local paths, etc.)
97
97
  });
98
98
  }
@@ -19,6 +19,7 @@ export declare class StreamingCardController {
19
19
  private reasoning;
20
20
  private readonly flush;
21
21
  private readonly guard;
22
+ private readonly imageResolver;
22
23
  private createEpoch;
23
24
  private _terminalReason;
24
25
  private dispatchFullyComplete;
@@ -18,6 +18,7 @@ import { sendCardFeishu, updateCardFeishu } from '../messaging/outbound/send';
18
18
  import { createCardEntity, sendCardByCardId, streamCardContent, updateCardKitCard, setCardStreamingMode, } from './cardkit';
19
19
  import { buildCardContent, splitReasoningText, stripReasoningTags, STREAMING_ELEMENT_ID, toCardKit2 } from './builder';
20
20
  import { optimizeMarkdownStyle } from './markdown-style';
21
+ import { ImageResolver } from './image-resolver';
21
22
  import { registerShutdownHook } from '../core/shutdown-hooks';
22
23
  import { FlushController } from './flush-controller';
23
24
  import { UnavailableGuard } from './unavailable-guard';
@@ -30,7 +31,11 @@ const STREAMING_THINKING_CARD = {
30
31
  schema: '2.0',
31
32
  config: {
32
33
  streaming_mode: true,
33
- summary: { content: '思考中...' },
34
+ locales: ['zh_cn', 'en_us'],
35
+ summary: {
36
+ content: 'Thinking...',
37
+ i18n_content: { zh_cn: '思考中...', en_us: 'Thinking...' },
38
+ },
34
39
  },
35
40
  body: {
36
41
  elements: [
@@ -83,6 +88,7 @@ export class StreamingCardController {
83
88
  // ---- Sub-controllers ----
84
89
  flush;
85
90
  guard;
91
+ imageResolver;
86
92
  // ---- Lifecycle ----
87
93
  createEpoch = 0;
88
94
  _terminalReason = null;
@@ -105,6 +111,15 @@ export class StreamingCardController {
105
111
  },
106
112
  });
107
113
  this.flush = new FlushController(() => this.performFlush());
114
+ this.imageResolver = new ImageResolver({
115
+ cfg: deps.cfg,
116
+ accountId: deps.accountId,
117
+ onImageResolved: () => {
118
+ if (!this.isTerminalPhase && this.cardKit.cardMessageId) {
119
+ void this.throttledCardUpdate();
120
+ }
121
+ },
122
+ });
108
123
  }
109
124
  // ------------------------------------------------------------------
110
125
  // Public accessors
@@ -300,9 +315,10 @@ export class StreamingCardController {
300
315
  const errorEffectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
301
316
  if (this.cardKit.cardMessageId) {
302
317
  try {
303
- const errorText = this.text.accumulatedText
318
+ const rawErrorText = this.text.accumulatedText
304
319
  ? `${this.text.accumulatedText}\n\n---\n**Error**: An error occurred while generating the response.`
305
320
  : '**Error**: An error occurred while generating the response.';
321
+ const errorText = this.imageResolver.resolveImages(rawErrorText);
306
322
  const errorCard = buildCardContent('complete', {
307
323
  text: errorText,
308
324
  reasoningText: this.reasoning.accumulatedReasoningText || undefined,
@@ -365,8 +381,9 @@ export class StreamingCardController {
365
381
  if (!this.text.completedText && !this.text.accumulatedText) {
366
382
  log.warn('reply completed without visible text, using empty-reply fallback');
367
383
  }
384
+ const resolvedDisplayText = await this.imageResolver.resolveImagesAwait(displayText, 15_000);
368
385
  const completeCard = buildCardContent('complete', {
369
- text: displayText,
386
+ text: resolvedDisplayText,
370
387
  reasoningText: this.reasoning.accumulatedReasoningText || undefined,
371
388
  reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
372
389
  elapsedMs: this.elapsed(),
@@ -427,7 +444,7 @@ export class StreamingCardController {
427
444
  const effectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
428
445
  if (effectiveCardId) {
429
446
  const elapsedMs = Date.now() - this.dispatchStartTime;
430
- const abortText = this.text.accumulatedText || 'Aborted.';
447
+ const abortText = this.imageResolver.resolveImages(this.text.accumulatedText || 'Aborted.');
431
448
  const abortCardContent = buildCardContent('complete', {
432
449
  text: abortText,
433
450
  reasoningText: this.reasoning.accumulatedReasoningText || undefined,
@@ -442,7 +459,7 @@ export class StreamingCardController {
442
459
  else if (this.cardKit.cardMessageId) {
443
460
  // IM fallback: 卡片不是通过 CardKit 发的,用 im.message.patch 更新
444
461
  const elapsedMs = Date.now() - this.dispatchStartTime;
445
- const abortText = this.text.accumulatedText || 'Aborted.';
462
+ const abortText = this.imageResolver.resolveImages(this.text.accumulatedText || 'Aborted.');
446
463
  const abortCard = buildCardContent('complete', {
447
464
  text: abortText,
448
465
  reasoningText: this.reasoning.accumulatedReasoningText || undefined,
@@ -602,6 +619,7 @@ export class StreamingCardController {
602
619
  });
603
620
  try {
604
621
  const displayText = this.buildDisplayText();
622
+ const resolvedText = this.imageResolver.resolveImages(displayText);
605
623
  if (this.cardKit.cardKitCardId) {
606
624
  // CardKit streaming — typewriter effect
607
625
  const prevSeq = this.cardKit.cardKitSequence;
@@ -614,7 +632,7 @@ export class StreamingCardController {
614
632
  cfg: this.deps.cfg,
615
633
  cardId: this.cardKit.cardKitCardId,
616
634
  elementId: STREAMING_ELEMENT_ID,
617
- content: optimizeMarkdownStyle(displayText),
635
+ content: optimizeMarkdownStyle(resolvedText),
618
636
  sequence: this.cardKit.cardKitSequence,
619
637
  accountId: this.deps.accountId,
620
638
  });
@@ -622,7 +640,7 @@ export class StreamingCardController {
622
640
  else {
623
641
  log.debug('flushCardUpdate: IM patch fallback');
624
642
  const card = buildCardContent('streaming', {
625
- text: this.reasoning.isReasoningPhase ? '' : displayText,
643
+ text: this.reasoning.isReasoningPhase ? '' : resolvedText,
626
644
  reasoningText: this.reasoning.isReasoningPhase ? this.reasoning.accumulatedReasoningText : undefined,
627
645
  });
628
646
  await updateCardFeishu({
@@ -8,8 +8,14 @@
8
8
  * 注意:此命令仅限应用 owner 执行(与 onboarding 逻辑一致)
9
9
  */
10
10
  import type { OpenClawConfig } from 'openclaw/plugin-sdk';
11
+ import type { FeishuLocale } from './locale';
11
12
  /**
12
13
  * 执行飞书用户权限批量授权命令
13
14
  * 直接调用 triggerOnboarding(),包含 owner 检查
14
15
  */
15
- export declare function runFeishuAuth(config: OpenClawConfig): Promise<string>;
16
+ export declare function runFeishuAuth(config: OpenClawConfig, locale?: FeishuLocale): Promise<string>;
17
+ /**
18
+ * 运行飞书授权命令,同时生成中英双语结果。
19
+ * 副作用(triggerOnboarding)只执行一次,结果格式化为双语文本。
20
+ */
21
+ export declare function runFeishuAuthI18n(config: OpenClawConfig): Promise<Record<FeishuLocale, string>>;