@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 +1 -1
- package/src/auth/accounts.ts +84 -17
- package/src/auth/login-qr.ts +6 -1
- package/src/channel.ts +83 -12
- package/src/log-upload.ts +2 -1
- package/src/messaging/error-notice.ts +1 -2
- package/src/messaging/inbound.ts +96 -5
- package/src/messaging/process-message.ts +2 -6
- package/src/messaging/send.ts +6 -11
- package/src/util/logger.ts +4 -2
- package/src/util/redact.ts +11 -3
package/package.json
CHANGED
package/src/auth/accounts.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
|
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>
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/auth/login-qr.ts
CHANGED
|
@@ -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(
|
|
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 = "
|
|
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.
|
|
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
|
|
134
|
-
contextToken: getContextToken(
|
|
193
|
+
accountId,
|
|
194
|
+
contextToken: getContextToken(accountId!, ctx.to),
|
|
135
195
|
});
|
|
136
196
|
return result;
|
|
137
197
|
},
|
|
138
198
|
sendMedia: async (ctx) => {
|
|
139
|
-
const
|
|
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
|
|
177
|
-
contextToken: getContextToken(
|
|
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(
|
|
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
|
|
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},
|
|
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: {
|
package/src/messaging/inbound.ts
CHANGED
|
@@ -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
|
|
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.
|
|
13
|
-
*
|
|
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
|
-
|
|
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("
|
|
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)"}`);
|
package/src/messaging/send.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
183
|
-
throw new Error("sendImageMessageWeixin: contextToken is required");
|
|
180
|
+
logger.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
184
181
|
}
|
|
185
|
-
logger.
|
|
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.
|
|
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.
|
|
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,
|
package/src/util/logger.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 =
|
|
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;
|
package/src/util/redact.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
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
|
/**
|