@tencent-weixin/openclaw-weixin 1.0.1 → 1.0.3

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": "@tencent-weixin/openclaw-weixin",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
6
6
 
7
7
  import { getWeixinRuntime } from "../runtime.js";
8
8
  import { resolveStateDir } from "../storage/state-dir.js";
9
+ import { resolveFrameworkAllowFromPath } from "./pairing.js";
9
10
  import { logger } from "../util/logger.js";
10
11
 
11
12
  export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
@@ -70,6 +71,41 @@ export function registerWeixinAccountId(accountId: string): void {
70
71
  fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
71
72
  }
72
73
 
74
+ /** Remove accountId from the persistent index. */
75
+ export function unregisterWeixinAccountId(accountId: string): void {
76
+ const existing = listIndexedWeixinAccountIds();
77
+ const updated = existing.filter((id) => id !== accountId);
78
+ if (updated.length !== existing.length) {
79
+ fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Remove stale accounts that share the same userId as the newly-bound account.
85
+ * Called after a successful QR login to ensure only the latest account remains
86
+ * for a given WeChat user, preventing ambiguous contextToken matches.
87
+ *
88
+ * @param onClearContextTokens callback to clear context tokens for the removed account
89
+ */
90
+ export function clearStaleAccountsForUserId(
91
+ currentAccountId: string,
92
+ userId: string,
93
+ onClearContextTokens?: (accountId: string) => void,
94
+ ): void {
95
+ if (!userId) return;
96
+ const allIds = listIndexedWeixinAccountIds();
97
+ for (const id of allIds) {
98
+ if (id === currentAccountId) continue;
99
+ const data = loadWeixinAccount(id);
100
+ if (data?.userId?.trim() === userId) {
101
+ logger.info(`clearStaleAccountsForUserId: removing stale account=${id} (same userId=${userId})`);
102
+ onClearContextTokens?.(id);
103
+ clearWeixinAccount(id);
104
+ unregisterWeixinAccountId(id);
105
+ }
106
+ }
107
+ }
108
+
73
109
  // ---------------------------------------------------------------------------
74
110
  // Account store (per-account credential files)
75
111
  // ---------------------------------------------------------------------------
@@ -175,10 +211,29 @@ export function saveWeixinAccount(
175
211
  }
176
212
  }
177
213
 
178
- /** Remove account data file. */
214
+ /**
215
+ * Remove all files associated with an account:
216
+ * - accounts/{accountId}.json (credentials)
217
+ * - accounts/{accountId}.sync.json (getUpdates sync buf)
218
+ * - accounts/{accountId}.context-tokens.json (context tokens on disk)
219
+ * - credentials/openclaw-weixin-{accountId}-allowFrom.json (authorized users)
220
+ */
179
221
  export function clearWeixinAccount(accountId: string): void {
222
+ const dir = resolveAccountsDir();
223
+ const accountFiles = [
224
+ `${accountId}.json`,
225
+ `${accountId}.sync.json`,
226
+ `${accountId}.context-tokens.json`,
227
+ ];
228
+ for (const file of accountFiles) {
229
+ try {
230
+ fs.unlinkSync(path.join(dir, file));
231
+ } catch {
232
+ // ignore if not found
233
+ }
234
+ }
180
235
  try {
181
- fs.unlinkSync(resolveAccountPath(accountId));
236
+ fs.unlinkSync(resolveFrameworkAllowFromPath(accountId));
182
237
  } catch {
183
238
  // ignore if not found
184
239
  }
@@ -198,29 +253,41 @@ function resolveConfigPath(): string {
198
253
  * Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).
199
254
  * Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level
200
255
  * `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`.
256
+ *
257
+ * The config is cached after the first read since routeTag does not change at runtime.
201
258
  */
202
- export function loadConfigRouteTag(accountId?: string): string | undefined {
259
+ let cachedRouteTagSection: Record<string, unknown> | null | undefined;
260
+
261
+ function loadRouteTagSection(): Record<string, unknown> | null {
262
+ if (cachedRouteTagSection !== undefined) return cachedRouteTagSection;
203
263
  try {
204
264
  const configPath = resolveConfigPath();
205
- if (!fs.existsSync(configPath)) return undefined;
265
+ if (!fs.existsSync(configPath)) { cachedRouteTagSection = null; return null; }
206
266
  const raw = fs.readFileSync(configPath, "utf-8");
207
267
  const cfg = JSON.parse(raw) as Record<string, unknown>;
208
268
  const channels = cfg.channels as Record<string, unknown> | undefined;
209
- const section = channels?.["openclaw-weixin"] as Record<string, unknown> | undefined;
210
- if (!section) return undefined;
211
- if (accountId) {
212
- const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
213
- const tag = accounts?.[accountId]?.routeTag;
214
- if (typeof tag === "number") return String(tag);
215
- if (typeof tag === "string" && tag.trim()) return tag.trim();
216
- }
217
- if (typeof section.routeTag === "number") return String(section.routeTag);
218
- return typeof section.routeTag === "string" && section.routeTag.trim()
219
- ? section.routeTag.trim()
220
- : undefined;
269
+ const section = (channels?.["openclaw-weixin"] as Record<string, unknown>) ?? null;
270
+ cachedRouteTagSection = section;
271
+ return section;
221
272
  } catch {
222
- return undefined;
273
+ cachedRouteTagSection = null;
274
+ return null;
275
+ }
276
+ }
277
+
278
+ export function loadConfigRouteTag(accountId?: string): string | undefined {
279
+ const section = loadRouteTagSection();
280
+ if (!section) return undefined;
281
+ if (accountId) {
282
+ const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
283
+ const tag = accounts?.[accountId]?.routeTag;
284
+ if (typeof tag === "number") return String(tag);
285
+ if (typeof tag === "string" && tag.trim()) return tag.trim();
223
286
  }
287
+ if (typeof section.routeTag === "number") return String(section.routeTag);
288
+ return typeof section.routeTag === "string" && section.routeTag.trim()
289
+ ? section.routeTag.trim()
290
+ : undefined;
224
291
  }
225
292
 
226
293
  /**
@@ -269,8 +269,11 @@ export async function waitForWeixinLogin(opts: {
269
269
  try {
270
270
  const qrterm = await import("qrcode-terminal");
271
271
  qrterm.default.generate(qrResponse.qrcode_img_content, { small: true });
272
+ process.stdout.write(`如果二维码未能成功展示,请用浏览器打开以下链接扫码:\n`);
273
+ process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
272
274
  } catch {
273
- process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`);
275
+ process.stdout.write(`二维码未加载成功,请用浏览器打开以下链接扫码:\n`);
276
+ process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
274
277
  }
275
278
  } catch (refreshErr) {
276
279
  logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
@@ -318,6 +321,8 @@ export async function waitForWeixinLogin(opts: {
318
321
  message: `Login failed: ${String(err)}`,
319
322
  };
320
323
  }
324
+
325
+ await new Promise((r) => setTimeout(r, 1000));
321
326
  }
322
327
 
323
328
  logger.warn(
package/src/channel.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
4
- import { normalizeAccountId } from "openclaw/plugin-sdk";
4
+ import { normalizeAccountId, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
5
5
 
6
6
  import {
7
7
  registerWeixinAccountId,
@@ -10,11 +10,12 @@ import {
10
10
  listWeixinAccountIds,
11
11
  resolveWeixinAccount,
12
12
  triggerWeixinChannelReload,
13
+ clearStaleAccountsForUserId,
13
14
  DEFAULT_BASE_URL,
14
15
  } from "./auth/accounts.js";
15
16
  import type { ResolvedWeixinAccount } from "./auth/accounts.js";
16
17
  import { assertSessionActive } from "./api/session-guard.js";
17
- import { getContextToken } from "./messaging/inbound.js";
18
+ import { getContextToken, findAccountIdsByContextToken, restoreContextTokens, clearContextTokensForAccount } from "./messaging/inbound.js";
18
19
  import { logger } from "./util/logger.js";
19
20
  import {
20
21
  DEFAULT_ILINK_BOT_TYPE,
@@ -37,7 +38,7 @@ function isRemoteUrl(mediaUrl: string): boolean {
37
38
  return mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
38
39
  }
39
40
 
40
- const MEDIA_OUTBOUND_TEMP_DIR = "/tmp/openclaw/weixin/media/outbound-temp";
41
+ const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
41
42
 
42
43
  /** Resolve any local path scheme to an absolute filesystem path. */
43
44
  function resolveLocalPath(mediaUrl: string): string {
@@ -47,6 +48,58 @@ function resolveLocalPath(mediaUrl: string): string {
47
48
  return mediaUrl;
48
49
  }
49
50
 
51
+ /**
52
+ * Resolve the effective accountId for an outbound message when the caller
53
+ * did not provide one (e.g. cron delivery without explicit accountId).
54
+ *
55
+ * Priority:
56
+ * 1. Multiple accounts → match via contextToken for the `to` recipient
57
+ * 2. Single account → use it directly
58
+ * 3. No match → throw a descriptive error
59
+ */
60
+ function resolveOutboundAccountId(
61
+ cfg: OpenClawConfig,
62
+ to: string,
63
+ ): string {
64
+ const allIds = listWeixinAccountIds(cfg);
65
+
66
+ if (allIds.length === 0) {
67
+ throw new Error(
68
+ `weixin: no accounts registered — run \`openclaw channels login --channel openclaw-weixin\``,
69
+ );
70
+ }
71
+
72
+ if (allIds.length === 1) {
73
+ logger.info(`resolveOutboundAccountId: single account, using ${allIds[0]}`);
74
+ return allIds[0];
75
+ }
76
+
77
+ // Multiple accounts: find which ones have a contextToken for the recipient.
78
+ const matched = findAccountIdsByContextToken(allIds, to);
79
+
80
+ if (matched.length === 1) {
81
+ logger.info(`resolveOutboundAccountId: matched accountId=${matched[0]} for to=${to}`);
82
+ return matched[0];
83
+ }
84
+
85
+ if (matched.length > 1) {
86
+ logger.warn(
87
+ `resolveOutboundAccountId: ambiguous — ${matched.length} accounts matched for to=${to}: ${matched.join(", ")}`,
88
+ );
89
+ throw new Error(
90
+ `weixin: ambiguous account for to=${to} ` +
91
+ `(${matched.length} accounts have active sessions with this recipient: ${matched.join(", ")}). ` +
92
+ `Specify accountId in the delivery config to disambiguate.`,
93
+ );
94
+ }
95
+
96
+ throw new Error(
97
+ `weixin: cannot determine which account to use for to=${to} ` +
98
+ `(${allIds.length} accounts registered, none has an active session with this recipient). ` +
99
+ `Specify accountId in the delivery config, or ensure the recipient has recently messaged the bot.`,
100
+ );
101
+ }
102
+
50
103
  async function sendWeixinOutbound(params: {
51
104
  cfg: OpenClawConfig;
52
105
  to: string;
@@ -63,8 +116,7 @@ async function sendWeixinOutbound(params: {
63
116
  throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
64
117
  }
65
118
  if (!params.contextToken) {
66
- aLog.error(`sendWeixinOutbound: contextToken missing, refusing to send to=${params.to}`);
67
- throw new Error("sendWeixinOutbound: contextToken is required");
119
+ aLog.warn(`sendWeixinOutbound: contextToken missing for to=${params.to}, sending without context`);
68
120
  }
69
121
  const result = await sendMessageWeixin({ to: params.to, text: params.text, opts: {
70
122
  baseUrl: account.baseUrl,
@@ -95,6 +147,13 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
95
147
  capabilities: {
96
148
  chatTypes: ["direct"],
97
149
  media: true,
150
+ blockStreaming: true,
151
+ },
152
+ streaming: {
153
+ blockStreamingCoalesceDefaults: {
154
+ minChars: 200,
155
+ idleMs: 3000,
156
+ },
98
157
  },
99
158
  messaging: {
100
159
  targetResolver: {
@@ -107,7 +166,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
107
166
  "To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.",
108
167
  "When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.",
109
168
  "IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.",
110
- "IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation). Without an explicit 'to', the cron delivery will fail with 'requires target'. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '<current_user_id@im.wechat>' }.",
169
+ "IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation) AND set delivery.accountId to the current AccountId. Without an explicit 'to', the cron delivery will fail with 'requires target'. Without an explicit 'accountId', the message may be sent from the wrong bot account. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '<current_user_id@im.wechat>', accountId: '<current_AccountId>' }.",
111
170
  ],
112
171
  },
113
172
  reload: { configPrefixes: ["channels.openclaw-weixin"] },
@@ -126,17 +185,19 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
126
185
  deliveryMode: "direct",
127
186
  textChunkLimit: 4000,
128
187
  sendText: async (ctx) => {
188
+ const accountId = ctx.accountId || resolveOutboundAccountId(ctx.cfg, ctx.to);
129
189
  const result = await sendWeixinOutbound({
130
190
  cfg: ctx.cfg,
131
191
  to: ctx.to,
132
192
  text: ctx.text,
133
- accountId: ctx.accountId,
134
- contextToken: getContextToken(ctx.accountId!, ctx.to),
193
+ accountId,
194
+ contextToken: getContextToken(accountId!, ctx.to),
135
195
  });
136
196
  return result;
137
197
  },
138
198
  sendMedia: async (ctx) => {
139
- const account = resolveWeixinAccount(ctx.cfg, ctx.accountId);
199
+ const accountId = ctx.accountId || resolveOutboundAccountId(ctx.cfg, ctx.to);
200
+ const account = resolveWeixinAccount(ctx.cfg, accountId);
140
201
  const aLog = logger.withAccount(account.accountId);
141
202
  assertSessionActive(account.accountId);
142
203
  if (!account.configured) {
@@ -173,8 +234,8 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
173
234
  cfg: ctx.cfg,
174
235
  to: ctx.to,
175
236
  text: ctx.text ?? "",
176
- accountId: ctx.accountId,
177
- contextToken: getContextToken(ctx.accountId!, ctx.to),
237
+ accountId,
238
+ contextToken: getContextToken(account.accountId, ctx.to),
178
239
  });
179
240
  return result;
180
241
  },
@@ -231,6 +292,8 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
231
292
  await new Promise<void>((resolve) => {
232
293
  qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
233
294
  console.log(qr);
295
+ log(`如果二维码未能成功展示,请用浏览器打开以下链接扫码:`);
296
+ log(startResult.qrcodeUrl!);
234
297
  resolve();
235
298
  });
236
299
  });
@@ -238,7 +301,8 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
238
301
  logger.warn(
239
302
  `auth.login: qrcode-terminal unavailable, falling back to URL err=${String(err)}`,
240
303
  );
241
- log(`二维码链接: ${startResult.qrcodeUrl}`);
304
+ log(`二维码未加载成功,请用浏览器打开以下链接扫码:`);
305
+ log(startResult.qrcodeUrl!);
242
306
  }
243
307
 
244
308
  const loginTimeoutMs = 480_000;
@@ -263,6 +327,9 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
263
327
  userId: waitResult.userId,
264
328
  });
265
329
  registerWeixinAccountId(normalizedId);
330
+ if (waitResult.userId) {
331
+ clearStaleAccountsForUserId(normalizedId, waitResult.userId, clearContextTokensForAccount);
332
+ }
266
333
  void triggerWeixinChannelReload();
267
334
  log(`\n✅ 与微信连接成功!`);
268
335
  } catch (err) {
@@ -290,6 +357,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
290
357
  const account = ctx.account;
291
358
  const aLog = logger.withAccount(account.accountId);
292
359
  aLog.debug(`about to call monitorWeixinProvider`);
360
+ restoreContextTokens(account.accountId);
293
361
  aLog.info(`starting weixin webhook`);
294
362
 
295
363
  ctx.setStatus?.({
@@ -363,6 +431,9 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
363
431
  userId: result.userId,
364
432
  });
365
433
  registerWeixinAccountId(normalizedId);
434
+ if (result.userId) {
435
+ clearStaleAccountsForUserId(normalizedId, result.userId, clearContextTokensForAccount);
436
+ }
366
437
  triggerWeixinChannelReload();
367
438
  logger.info(`loginWithQrWait: saved account data for accountId=${normalizedId}`);
368
439
  } catch (err) {
package/src/log-upload.ts CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
5
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
5
6
 
6
7
 
7
8
  /** Minimal subset of commander's Command used by registerWeixinCli. */
@@ -42,7 +43,7 @@ function resolveLogFileName(file: string): string {
42
43
  }
43
44
 
44
45
  function mainLogDir(): string {
45
- return path.join("/tmp", "openclaw");
46
+ return resolvePreferredOpenClawTmpDir();
46
47
  }
47
48
 
48
49
  function getConfiguredUploadUrl(config: OpenClawConfig): string | undefined {
@@ -15,8 +15,7 @@ export async function sendWeixinErrorNotice(params: {
15
15
  errLog: (m: string) => void;
16
16
  }): Promise<void> {
17
17
  if (!params.contextToken) {
18
- logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
19
- return;
18
+ logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, sending without context`);
20
19
  }
21
20
  try {
22
21
  await sendMessageWeixin({ to: params.to, text: params.message, opts: {
@@ -1,17 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
1
4
  import { logger } from "../util/logger.js";
2
5
  import { generateId } from "../util/random.js";
3
6
  import type { WeixinMessage, MessageItem } from "../api/types.js";
4
7
  import { MessageItemType } from "../api/types.js";
8
+ import { resolveStateDir } from "../storage/state-dir.js";
5
9
 
6
10
  // ---------------------------------------------------------------------------
7
- // Context token store (in-process cache: accountId+userId contextToken)
11
+ // Context token store (in-process cache + disk persistence)
8
12
  // ---------------------------------------------------------------------------
9
13
 
10
14
  /**
11
15
  * contextToken is issued per-message by the Weixin getupdates API and must
12
- * be echoed verbatim in every outbound send. It is not persisted: the monitor
13
- * loop populates this map on each inbound message, and the outbound adapter
14
- * reads it back when the agent sends a reply.
16
+ * be echoed verbatim in every outbound send. The in-memory map is the primary
17
+ * lookup; a disk-backed file per account ensures tokens survive gateway restarts.
15
18
  */
16
19
  const contextTokenStore = new Map<string, string>();
17
20
 
@@ -19,11 +22,84 @@ function contextTokenKey(accountId: string, userId: string): string {
19
22
  return `${accountId}:${userId}`;
20
23
  }
21
24
 
22
- /** Store a context token for a given account+user pair. */
25
+ // ---------------------------------------------------------------------------
26
+ // Disk persistence helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function resolveContextTokenFilePath(accountId: string): string {
30
+ return path.join(
31
+ resolveStateDir(),
32
+ "openclaw-weixin",
33
+ "accounts",
34
+ `${accountId}.context-tokens.json`,
35
+ );
36
+ }
37
+
38
+ /** Persist all context tokens for a given account to disk. */
39
+ function persistContextTokens(accountId: string): void {
40
+ const prefix = `${accountId}:`;
41
+ const tokens: Record<string, string> = {};
42
+ for (const [k, v] of contextTokenStore) {
43
+ if (k.startsWith(prefix)) {
44
+ tokens[k.slice(prefix.length)] = v;
45
+ }
46
+ }
47
+ const filePath = resolveContextTokenFilePath(accountId);
48
+ try {
49
+ const dir = path.dirname(filePath);
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
52
+ } catch (err) {
53
+ logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Restore persisted context tokens for an account into the in-memory map.
59
+ * Called once during gateway startAccount to survive restarts.
60
+ */
61
+ export function restoreContextTokens(accountId: string): void {
62
+ const filePath = resolveContextTokenFilePath(accountId);
63
+ try {
64
+ if (!fs.existsSync(filePath)) return;
65
+ const raw = fs.readFileSync(filePath, "utf-8");
66
+ const tokens = JSON.parse(raw) as Record<string, string>;
67
+ let count = 0;
68
+ for (const [userId, token] of Object.entries(tokens)) {
69
+ if (typeof token === "string" && token) {
70
+ contextTokenStore.set(contextTokenKey(accountId, userId), token);
71
+ count++;
72
+ }
73
+ }
74
+ logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`);
75
+ } catch (err) {
76
+ logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`);
77
+ }
78
+ }
79
+
80
+ /** Remove all context tokens for a given account (memory + disk). */
81
+ export function clearContextTokensForAccount(accountId: string): void {
82
+ const prefix = `${accountId}:`;
83
+ for (const k of [...contextTokenStore.keys()]) {
84
+ if (k.startsWith(prefix)) {
85
+ contextTokenStore.delete(k);
86
+ }
87
+ }
88
+ const filePath = resolveContextTokenFilePath(accountId);
89
+ try {
90
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
91
+ } catch (err) {
92
+ logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`);
93
+ }
94
+ logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
95
+ }
96
+
97
+ /** Store a context token for a given account+user pair (memory + disk). */
23
98
  export function setContextToken(accountId: string, userId: string, token: string): void {
24
99
  const k = contextTokenKey(accountId, userId);
25
100
  logger.debug(`setContextToken: key=${k}`);
26
101
  contextTokenStore.set(k, token);
102
+ persistContextTokens(accountId);
27
103
  }
28
104
 
29
105
  /** Retrieve the cached context token for a given account+user pair. */
@@ -36,6 +112,21 @@ export function getContextToken(accountId: string, userId: string): string | und
36
112
  return val;
37
113
  }
38
114
 
115
+ /**
116
+ * Find all accountIds that have an active contextToken for the given userId.
117
+ * Used to infer the sending bot account from the recipient address when
118
+ * accountId is not explicitly provided (e.g. cron delivery).
119
+ *
120
+ * Returns all matching accountIds (not just the first) so the caller can
121
+ * detect ambiguity when multiple accounts have sessions with the same user.
122
+ */
123
+ export function findAccountIdsByContextToken(
124
+ accountIds: string[],
125
+ userId: string,
126
+ ): string[] {
127
+ return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId)));
128
+ }
129
+
39
130
  // ---------------------------------------------------------------------------
40
131
  // Message ID generation
41
132
  // ---------------------------------------------------------------------------
@@ -381,11 +381,7 @@ export async function processOneMessage(
381
381
  deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
382
382
  const errMsg = err instanceof Error ? err.message : String(err);
383
383
  let notice: string;
384
- if (errMsg.includes("contextToken is required")) {
385
- // No contextToken means we cannot send a notice either; just log.
386
- logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`);
387
- return;
388
- } else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
384
+ if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
389
385
  notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
390
386
  } else if (
391
387
  errMsg.includes("getUploadUrl") ||
@@ -416,7 +412,7 @@ export async function processOneMessage(
416
412
  ctx: finalized,
417
413
  cfg: deps.config,
418
414
  dispatcher,
419
- replyOptions,
415
+ replyOptions: { ...replyOptions, disableBlockStreaming: false },
420
416
  }),
421
417
  });
422
418
  logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
@@ -77,7 +77,6 @@ function buildSendMessageReq(params: {
77
77
 
78
78
  /**
79
79
  * Send a plain text message downstream.
80
- * contextToken is required for all reply sends; missing it breaks conversation association.
81
80
  */
82
81
  export async function sendMessageWeixin(params: {
83
82
  to: string;
@@ -86,8 +85,7 @@ export async function sendMessageWeixin(params: {
86
85
  }): Promise<{ messageId: string }> {
87
86
  const { to, text, opts } = params;
88
87
  if (!opts.contextToken) {
89
- logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
90
- throw new Error("sendMessageWeixin: contextToken is required");
88
+ logger.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`);
91
89
  }
92
90
  const clientId = generateClientId();
93
91
  const req = buildSendMessageReq({
@@ -158,7 +156,7 @@ async function sendMediaItems(params: {
158
156
  }
159
157
  }
160
158
 
161
- logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);
159
+ logger.info(`${label}: success to=${to} clientId=${lastClientId}`);
162
160
  return { messageId: lastClientId };
163
161
  }
164
162
 
@@ -179,10 +177,9 @@ export async function sendImageMessageWeixin(params: {
179
177
  }): Promise<{ messageId: string }> {
180
178
  const { to, text, uploaded, opts } = params;
181
179
  if (!opts.contextToken) {
182
- logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);
183
- throw new Error("sendImageMessageWeixin: contextToken is required");
180
+ logger.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`);
184
181
  }
185
- logger.debug(
182
+ logger.info(
186
183
  `sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
187
184
  );
188
185
 
@@ -214,8 +211,7 @@ export async function sendVideoMessageWeixin(params: {
214
211
  }): Promise<{ messageId: string }> {
215
212
  const { to, text, uploaded, opts } = params;
216
213
  if (!opts.contextToken) {
217
- logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);
218
- throw new Error("sendVideoMessageWeixin: contextToken is required");
214
+ logger.warn(`sendVideoMessageWeixin: contextToken missing for to=${to}, sending without context`);
219
215
  }
220
216
 
221
217
  const videoItem: MessageItem = {
@@ -247,8 +243,7 @@ export async function sendFileMessageWeixin(params: {
247
243
  }): Promise<{ messageId: string }> {
248
244
  const { to, text, fileName, uploaded, opts } = params;
249
245
  if (!opts.contextToken) {
250
- logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);
251
- throw new Error("sendFileMessageWeixin: contextToken is required");
246
+ logger.warn(`sendFileMessageWeixin: contextToken missing for to=${to}, sending without context`);
252
247
  }
253
248
  const fileItem: MessageItem = {
254
249
  type: MessageItemType.FILE,
@@ -2,13 +2,15 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
6
+
5
7
  /**
6
8
  * Plugin logger — writes JSON lines to the main openclaw log file:
7
- * /tmp/openclaw/openclaw-YYYY-MM-DD.log
9
+ * <tmpDir>/openclaw-YYYY-MM-DD.log
8
10
  * Same file and format used by all other channels.
9
11
  */
10
12
 
11
- const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
13
+ const MAIN_LOG_DIR = resolvePreferredOpenClawTmpDir();
12
14
  const SUBSYSTEM = "gateway/channels/openclaw-weixin";
13
15
  const RUNTIME = "node";
14
16
  const RUNTIME_VERSION = process.versions.node;
@@ -21,14 +21,22 @@ export function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN
21
21
  return `${token.slice(0, prefixLen)}…(len=${token.length})`;
22
22
  }
23
23
 
24
+ /** Field names whose values should be masked in logged JSON bodies. */
25
+ const SENSITIVE_FIELDS = /\b(context_token|bot_token|token|authorization|Authorization)\b/;
26
+
24
27
  /**
25
28
  * Truncate a JSON body string to `maxLen` chars for safe logging.
26
- * Appends original length so the reader knows how much was dropped.
29
+ * Redacts known sensitive fields before truncating.
27
30
  */
28
31
  export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string {
29
32
  if (!body) return "(empty)";
30
- if (body.length <= maxLen) return body;
31
- return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
33
+ // Mask values of known sensitive JSON keys: "key":"value" → "key":"<redacted>"
34
+ const redacted = body.replace(
35
+ /"(context_token|bot_token|token|authorization|Authorization)"\s*:\s*"[^"]*"/g,
36
+ '"$1":"<redacted>"',
37
+ );
38
+ if (redacted.length <= maxLen) return redacted;
39
+ return `${redacted.slice(0, maxLen)}…(truncated, totalLen=${redacted.length})`;
32
40
  }
33
41
 
34
42
  /**