@larksuite/openclaw-lark 2026.3.29 → 2026.3.30-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larksuite/openclaw-lark",
3
- "version": "2026.3.29",
3
+ "version": "2026.3.30-beta.1",
4
4
  "description": "OpenClaw Lark/Feishu channel plugin",
5
5
  "exports": {
6
6
  ".": {
@@ -31,6 +31,11 @@
31
31
  "peerDependencies": {
32
32
  "openclaw": ">=2026.3.22"
33
33
  },
34
+ "peerDependenciesMeta": {
35
+ "openclaw": {
36
+ "optional": true
37
+ }
38
+ },
34
39
  "openclaw": {
35
40
  "extensions": [
36
41
  "./index.js"
@@ -95,8 +95,10 @@ export declare function formatFooterRuntimeSegments(params: {
95
95
  isError?: boolean;
96
96
  isAborted?: boolean;
97
97
  }): {
98
- zh: string[];
99
- en: string[];
98
+ primaryZh: string[];
99
+ primaryEn: string[];
100
+ detailZh: string[];
101
+ detailEn: string[];
100
102
  };
101
103
  /**
102
104
  * Build a full Feishu Interactive Message Card JSON object for the
@@ -161,35 +161,46 @@ function compactNumber(value) {
161
161
  }
162
162
  function formatFooterRuntimeSegments(params) {
163
163
  const { footer, metrics, elapsedMs, isError, isAborted } = params;
164
- const zhParts = [];
165
- const enParts = [];
164
+ const primaryZh = [];
165
+ const primaryEn = [];
166
+ const detailZh = [];
167
+ const detailEn = [];
168
+ // --- Primary line: status, elapsed, model ---
166
169
  if (footer?.status) {
167
170
  if (isError) {
168
- zhParts.push('出错');
169
- enParts.push('Error');
171
+ primaryZh.push('出错');
172
+ primaryEn.push('Error');
170
173
  }
171
174
  else if (isAborted) {
172
- zhParts.push('已停止');
173
- enParts.push('Stopped');
175
+ primaryZh.push('已停止');
176
+ primaryEn.push('Stopped');
174
177
  }
175
178
  else {
176
- zhParts.push('已完成');
177
- enParts.push('Completed');
179
+ primaryZh.push('已完成');
180
+ primaryEn.push('Completed');
178
181
  }
179
182
  }
180
183
  if (footer?.elapsed && elapsedMs != null) {
181
184
  const d = formatElapsed(elapsedMs);
182
- zhParts.push(`耗时 ${d}`);
183
- enParts.push(`Elapsed ${d}`);
185
+ primaryZh.push(`耗时 ${d}`);
186
+ primaryEn.push(`Elapsed ${d}`);
184
187
  }
188
+ if (footer?.model && metrics?.model) {
189
+ const model = metrics.model.trim();
190
+ if (model) {
191
+ primaryZh.push(model);
192
+ primaryEn.push(model);
193
+ }
194
+ }
195
+ // --- Detail line: tokens, cache, context ---
185
196
  if (footer?.tokens && metrics) {
186
197
  const inTokens = typeof metrics.inputTokens === 'number' ? Math.max(0, metrics.inputTokens) : undefined;
187
198
  const outTokens = typeof metrics.outputTokens === 'number' ? Math.max(0, metrics.outputTokens) : undefined;
188
199
  if (inTokens != null && outTokens != null) {
189
200
  const inLabel = compactNumber(inTokens);
190
201
  const outLabel = compactNumber(outTokens);
191
- zhParts.push(`↑ ${inLabel} ↓ ${outLabel}`);
192
- enParts.push(`↑ ${inLabel} ↓ ${outLabel}`);
202
+ detailZh.push(`↑ ${inLabel} ↓ ${outLabel}`);
203
+ detailEn.push(`↑ ${inLabel} ↓ ${outLabel}`);
193
204
  }
194
205
  }
195
206
  if (footer?.cache && metrics) {
@@ -201,8 +212,8 @@ function formatFooterRuntimeSegments(params) {
201
212
  const hit = total > 0 ? Math.round((read / total) * 100) : 0;
202
213
  const left = compactNumber(read);
203
214
  const right = compactNumber(write);
204
- zhParts.push(`缓存 ${left}/${right} (${hit}%)`);
205
- enParts.push(`Cache ${left}/${right} (${hit}%)`);
215
+ detailZh.push(`缓存 ${left}/${right} (${hit}%)`);
216
+ detailEn.push(`Cache ${left}/${right} (${hit}%)`);
206
217
  }
207
218
  }
208
219
  if (footer?.context && metrics) {
@@ -214,18 +225,11 @@ function formatFooterRuntimeSegments(params) {
214
225
  const ctxLabel = compactNumber(ctx);
215
226
  const pct = ctx > 0 ? Math.round((total / ctx) * 100) : 0;
216
227
  const pctLabel = `${pct}%`;
217
- zhParts.push(`上下文 ${totalLabel}/${ctxLabel} (${pctLabel})`);
218
- enParts.push(`Context ${totalLabel}/${ctxLabel} (${pctLabel})`);
219
- }
220
- }
221
- if (footer?.model && metrics?.model) {
222
- const model = metrics.model.trim();
223
- if (model) {
224
- zhParts.push(model);
225
- enParts.push(model);
228
+ detailZh.push(`上下文 ${totalLabel}/${ctxLabel} (${pctLabel})`);
229
+ detailEn.push(`Context ${totalLabel}/${ctxLabel} (${pctLabel})`);
226
230
  }
227
231
  }
228
- return { zh: zhParts, en: enParts };
232
+ return { primaryZh, primaryEn, detailZh, detailEn };
229
233
  }
230
234
  // ---------------------------------------------------------------------------
231
235
  // buildCardContent
@@ -369,17 +373,28 @@ function buildCompleteCard(params) {
369
373
  text_size: 'notation',
370
374
  });
371
375
  }
372
- // Footer meta-info: each metadata item is independently controlled via
373
- // the `footer` config.
374
- const footerParts = formatFooterRuntimeSegments({
376
+ // Footer meta-info: split into two lines for readability.
377
+ // Line 1 (primary): status · elapsed · model
378
+ // Line 2 (detail): tokens · cache · context
379
+ const fp = formatFooterRuntimeSegments({
375
380
  footer,
376
381
  metrics: footerMetrics,
377
382
  elapsedMs,
378
383
  isError,
379
384
  isAborted,
380
385
  });
381
- if (footerParts.zh.length > 0) {
382
- elements.push(...buildFooter(footerParts.zh.join(' · '), footerParts.en.join(' · '), isError));
386
+ const footerZhLines = [];
387
+ const footerEnLines = [];
388
+ if (fp.primaryZh.length > 0) {
389
+ footerZhLines.push(fp.primaryZh.join(' · '));
390
+ footerEnLines.push(fp.primaryEn.join(' · '));
391
+ }
392
+ if (fp.detailZh.length > 0) {
393
+ footerZhLines.push(fp.detailZh.join(' · '));
394
+ footerEnLines.push(fp.detailEn.join(' · '));
395
+ }
396
+ if (footerZhLines.length > 0) {
397
+ elements.push(...buildFooter(footerZhLines.join('\n'), footerEnLines.join('\n'), isError));
383
398
  }
384
399
  // Use the answer text (not reasoning) as the feed preview summary.
385
400
  // Strip markdown syntax so the preview reads as plain text.
@@ -16,6 +16,7 @@ exports.StreamingCardController = void 0;
16
16
  exports.prepareTerminalCardContent = prepareTerminalCardContent;
17
17
  const promises_1 = require("node:fs/promises");
18
18
  const reply_runtime_1 = require("openclaw/plugin-sdk/reply-runtime");
19
+ const agent_runtime_1 = require("openclaw/plugin-sdk/agent-runtime");
19
20
  const api_error_1 = require("../core/api-error.js");
20
21
  const lark_logger_1 = require("../core/lark-logger.js");
21
22
  const lark_client_1 = require("../core/lark-client.js");
@@ -120,15 +121,37 @@ class StreamingCardController {
120
121
  const cfgWithSession = this.deps.cfg;
121
122
  const sessionStorePath = cfgWithSession.sessions?.store ?? cfgWithSession.session?.store;
122
123
  const key = this.deps.sessionKey.trim().toLowerCase();
124
+ // WORKAROUND: SDK session key round-trip bug.
125
+ // The SDK's toAgentRequestSessionKey() strips the agent scope from keys
126
+ // like "agent:hr:main" → "main", then toAgentStoreSessionKey() rebuilds
127
+ // using the default agent ID → "agent:main:main". This means metrics
128
+ // written by the SDK always land under "agent:<defaultAgentId>:…"
129
+ // regardless of the account-scoped agent ID the plugin routing generated.
130
+ // Fallback: when the primary key misses, try replacing the agent-id
131
+ // segment with the resolved default agent ID.
132
+ // TODO: remove once the SDK preserves the original agent ID during the
133
+ // request→store key round-trip.
134
+ const defaultAgentId = (0, agent_runtime_1.resolveDefaultAgentId)(this.deps.cfg);
135
+ const fallbackKey = key.replace(/^(agent):[^:]+:/, `$1:${defaultAgentId}:`);
136
+ const candidateKeys = fallbackKey !== key ? [key, fallbackKey] : [key];
123
137
  const sessionApi = runtime.agent?.session;
124
138
  if (sessionApi?.resolveStorePath && sessionApi?.loadSessionStore) {
125
139
  const storePath = sessionApi.resolveStorePath(sessionStorePath);
126
140
  const store = sessionApi.loadSessionStore(storePath);
127
- const entry = store[key];
128
- if (!entry || typeof entry !== 'object') {
141
+ let entry;
142
+ let matchedKey;
143
+ for (const candidate of candidateKeys) {
144
+ const val = store[candidate];
145
+ if (val && typeof val === 'object') {
146
+ entry = val;
147
+ matchedKey = candidate;
148
+ break;
149
+ }
150
+ }
151
+ if (!entry) {
129
152
  log.debug('footer metrics lookup: session entry missing', {
130
153
  sessionKey: this.deps.sessionKey,
131
- normalizedSessionKey: key,
154
+ candidateKeys,
132
155
  storePath,
133
156
  source: 'runtime.agent.session',
134
157
  });
@@ -146,16 +169,9 @@ class StreamingCardController {
146
169
  };
147
170
  log.debug('footer metrics lookup: session entry found', {
148
171
  sessionKey: this.deps.sessionKey,
149
- normalizedSessionKey: key,
172
+ matchedKey,
150
173
  storePath,
151
174
  source: 'runtime.agent.session',
152
- hasMetrics: !!(metrics.inputTokens != null ||
153
- metrics.outputTokens != null ||
154
- metrics.cacheRead != null ||
155
- metrics.cacheWrite != null ||
156
- metrics.totalTokens != null ||
157
- metrics.contextTokens != null ||
158
- metrics.model),
159
175
  });
160
176
  return metrics;
161
177
  }
@@ -169,11 +185,20 @@ class StreamingCardController {
169
185
  const store = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
170
186
  ? parsed
171
187
  : {};
172
- const entry = store[key];
173
- if (!entry || typeof entry !== 'object') {
188
+ let entry;
189
+ let matchedKey;
190
+ for (const candidate of candidateKeys) {
191
+ const val = store[candidate];
192
+ if (val && typeof val === 'object') {
193
+ entry = val;
194
+ matchedKey = candidate;
195
+ break;
196
+ }
197
+ }
198
+ if (!entry) {
174
199
  log.debug('footer metrics lookup: session entry missing', {
175
200
  sessionKey: this.deps.sessionKey,
176
- normalizedSessionKey: key,
201
+ candidateKeys,
177
202
  storePath,
178
203
  source: 'channel.session.file',
179
204
  });
@@ -191,16 +216,9 @@ class StreamingCardController {
191
216
  };
192
217
  log.debug('footer metrics lookup: session entry found', {
193
218
  sessionKey: this.deps.sessionKey,
194
- normalizedSessionKey: key,
219
+ matchedKey,
195
220
  storePath,
196
221
  source: 'channel.session.file',
197
- hasMetrics: !!(metrics.inputTokens != null ||
198
- metrics.outputTokens != null ||
199
- metrics.cacheRead != null ||
200
- metrics.cacheWrite != null ||
201
- metrics.totalTokens != null ||
202
- metrics.contextTokens != null ||
203
- metrics.model),
204
222
  });
205
223
  return metrics;
206
224
  }
@@ -37,9 +37,30 @@ function baseConfig(section) {
37
37
  const { accounts: _ignored, ...rest } = section;
38
38
  return rest;
39
39
  }
40
- /** Merge base config with account override (account fields take precedence). */
40
+ /** Merge base config with account override (account fields take precedence).
41
+ * Performs a one-level deep merge for plain-object fields so that partial
42
+ * account overrides (e.g. `footer: { model: false }`) are merged with
43
+ * the base instead of replacing the entire object. */
41
44
  function mergeAccountConfig(base, override) {
42
- return { ...base, ...override };
45
+ const result = { ...base };
46
+ for (const [key, value] of Object.entries(override)) {
47
+ if (value === undefined)
48
+ continue;
49
+ const baseVal = base[key];
50
+ // Deep-merge plain objects one level (footer, tools, heartbeat, etc.)
51
+ if (value &&
52
+ typeof value === 'object' &&
53
+ !Array.isArray(value) &&
54
+ baseVal &&
55
+ typeof baseVal === 'object' &&
56
+ !Array.isArray(baseVal)) {
57
+ result[key] = { ...baseVal, ...value };
58
+ }
59
+ else {
60
+ result[key] = value;
61
+ }
62
+ }
63
+ return result;
43
64
  }
44
65
  /** Coerce a domain string to `LarkBrand`, defaulting to `"feishu"`. */
45
66
  function toBrand(domain) {
@@ -106,15 +106,19 @@ export declare class LarkClient {
106
106
  private waitForAbort;
107
107
  }
108
108
  /**
109
- * Returns the freshest available config for account resolution.
109
+ * Returns the best available config for account resolution.
110
+ *
111
+ * Priority: live config (has `channels.feishu`) > fallback (has
112
+ * `channels.feishu`) > live config (last resort).
110
113
  *
111
114
  * The `config` object captured in tool-registration closures may be stale
112
- * after a hot-reload: openclaw re-initialises the runtime but the plugin
113
- * closure still holds the old snapshot. Calling
114
- * `LarkClient.runtime.config.loadConfig()` always returns the current live
115
- * config, so account lookups pick up any changes made since plugin load.
115
+ * after a hot-reload, so we prefer the live config from
116
+ * `LarkClient.runtime.config.loadConfig()`. However, `loadConfig()` may
117
+ * return `{}` when the runtime config snapshot has been cleared (e.g. in
118
+ * isolated cron sessions), so we fall back to the closure-captured config
119
+ * when the live result lacks Feishu credentials.
116
120
  *
117
121
  * @param fallback - Config to use when the runtime is not yet initialised
118
- * (e.g. during early startup before the first `LarkClient.runtime` attach).
122
+ * or when `loadConfig()` returns an incomplete config.
119
123
  */
120
124
  export declare function getResolvedConfig(fallback: ClawdbotConfig): ClawdbotConfig;
@@ -427,20 +427,35 @@ exports.LarkClient = LarkClient;
427
427
  // Config resolution helper
428
428
  // ---------------------------------------------------------------------------
429
429
  /**
430
- * Returns the freshest available config for account resolution.
430
+ * Returns the best available config for account resolution.
431
+ *
432
+ * Priority: live config (has `channels.feishu`) > fallback (has
433
+ * `channels.feishu`) > live config (last resort).
431
434
  *
432
435
  * The `config` object captured in tool-registration closures may be stale
433
- * after a hot-reload: openclaw re-initialises the runtime but the plugin
434
- * closure still holds the old snapshot. Calling
435
- * `LarkClient.runtime.config.loadConfig()` always returns the current live
436
- * config, so account lookups pick up any changes made since plugin load.
436
+ * after a hot-reload, so we prefer the live config from
437
+ * `LarkClient.runtime.config.loadConfig()`. However, `loadConfig()` may
438
+ * return `{}` when the runtime config snapshot has been cleared (e.g. in
439
+ * isolated cron sessions), so we fall back to the closure-captured config
440
+ * when the live result lacks Feishu credentials.
437
441
  *
438
442
  * @param fallback - Config to use when the runtime is not yet initialised
439
- * (e.g. during early startup before the first `LarkClient.runtime` attach).
443
+ * or when `loadConfig()` returns an incomplete config.
440
444
  */
441
445
  function getResolvedConfig(fallback) {
442
446
  try {
443
- return LarkClient.runtime.config.loadConfig();
447
+ const live = LarkClient.runtime.config.loadConfig();
448
+ // loadConfig() may return {} (empty config) when runtimeConfigSnapshot
449
+ // has been cleared (e.g. after writeConfigFile, secrets teardown, or
450
+ // concurrent cron race conditions in isolated sessions). In that case
451
+ // the closure-captured fallback still holds a valid resolved config.
452
+ if (live?.channels?.feishu)
453
+ return live;
454
+ if (fallback?.channels?.feishu) {
455
+ log.debug(`loadConfig() returned config without channels.feishu, using fallback`);
456
+ return fallback;
457
+ }
458
+ return live;
444
459
  }
445
460
  catch {
446
461
  // runtime not yet initialised — fall back to passed config
@@ -12,14 +12,20 @@
12
12
  * @returns 版本号字符串,如 "2026.2.28.5";读取失败返回 "unknown"
13
13
  */
14
14
  export declare function getPluginVersion(): string;
15
+ /**
16
+ * 获取当前运行平台名称
17
+ *
18
+ * @returns `mac` | `linux` | `windows`
19
+ */
20
+ export declare function getPlatform(): string;
15
21
  /**
16
22
  * 生成 User-Agent 字符串
17
23
  *
18
- * @returns User-Agent 字符串,格式:`openclaw-lark/{version}`
24
+ * @returns User-Agent 字符串,格式:`openclaw-lark/{version}/{platform}`
19
25
  *
20
26
  * @example
21
27
  * ```typescript
22
- * getUserAgent() // => "openclaw-lark/2026.2.28.5"
28
+ * getUserAgent() // => "openclaw-lark/2026.2.28.5/mac"
23
29
  * ```
24
30
  */
25
31
  export declare function getUserAgent(): string;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.getPluginVersion = getPluginVersion;
12
+ exports.getPlatform = getPlatform;
12
13
  exports.getUserAgent = getUserAgent;
13
14
  const node_url_1 = require("node:url");
14
15
  const node_path_1 = require("node:path");
@@ -38,16 +39,31 @@ function getPluginVersion() {
38
39
  return cachedVersion;
39
40
  }
40
41
  }
42
+ /**
43
+ * 获取当前运行平台名称
44
+ *
45
+ * @returns `mac` | `linux` | `windows`
46
+ */
47
+ function getPlatform() {
48
+ switch (process.platform) {
49
+ case 'darwin':
50
+ return 'mac';
51
+ case 'win32':
52
+ return 'windows';
53
+ default:
54
+ return 'linux';
55
+ }
56
+ }
41
57
  /**
42
58
  * 生成 User-Agent 字符串
43
59
  *
44
- * @returns User-Agent 字符串,格式:`openclaw-lark/{version}`
60
+ * @returns User-Agent 字符串,格式:`openclaw-lark/{version}/{platform}`
45
61
  *
46
62
  * @example
47
63
  * ```typescript
48
- * getUserAgent() // => "openclaw-lark/2026.2.28.5"
64
+ * getUserAgent() // => "openclaw-lark/2026.2.28.5/mac"
49
65
  * ```
50
66
  */
51
67
  function getUserAgent() {
52
- return `openclaw-lark/${getPluginVersion()}`;
68
+ return `openclaw-lark/${getPluginVersion()}/${getPlatform()}`;
53
69
  }
@@ -55,9 +55,12 @@ const SUPPORTED_ACTIONS = new Set([
55
55
  function parseCardParam(raw) {
56
56
  if (raw == null)
57
57
  return undefined;
58
- // Already a non-array object — use directly.
58
+ // Already a non-array object — use directly (empty {} is never a valid card).
59
59
  if (typeof raw === 'object' && !Array.isArray(raw)) {
60
- return raw;
60
+ const obj = raw;
61
+ if (Object.keys(obj).length === 0)
62
+ return undefined;
63
+ return obj;
61
64
  }
62
65
  // String — attempt JSON.parse.
63
66
  if (typeof raw === 'string') {