@netcat-ai/openclaw-weixin 2.4.7
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/CHANGELOG.md +156 -0
- package/CHANGELOG.zh_CN.md +156 -0
- package/LICENSE +27 -0
- package/README.md +355 -0
- package/README.zh_CN.md +351 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/src/api/api.js +484 -0
- package/dist/src/api/api.js.map +1 -0
- package/dist/src/api/config-cache.js +64 -0
- package/dist/src/api/config-cache.js.map +1 -0
- package/dist/src/api/session-guard.js +49 -0
- package/dist/src/api/session-guard.js.map +1 -0
- package/dist/src/api/types.js +37 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/auth/accounts.js +328 -0
- package/dist/src/auth/accounts.js.map +1 -0
- package/dist/src/auth/login-qr.js +331 -0
- package/dist/src/auth/login-qr.js.map +1 -0
- package/dist/src/auth/pairing.js +104 -0
- package/dist/src/auth/pairing.js.map +1 -0
- package/dist/src/cdn/aes-ecb.js +19 -0
- package/dist/src/cdn/aes-ecb.js.map +1 -0
- package/dist/src/cdn/cdn-upload.js +74 -0
- package/dist/src/cdn/cdn-upload.js.map +1 -0
- package/dist/src/cdn/cdn-url.js +14 -0
- package/dist/src/cdn/cdn-url.js.map +1 -0
- package/dist/src/cdn/pic-decrypt.js +89 -0
- package/dist/src/cdn/pic-decrypt.js.map +1 -0
- package/dist/src/cdn/upload.js +115 -0
- package/dist/src/cdn/upload.js.map +1 -0
- package/dist/src/channel.js +478 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/compat.js +67 -0
- package/dist/src/compat.js.map +1 -0
- package/dist/src/config/config-schema.js +20 -0
- package/dist/src/config/config-schema.js.map +1 -0
- package/dist/src/config/reply-progress.js +5 -0
- package/dist/src/config/reply-progress.js.map +1 -0
- package/dist/src/media/media-download.js +95 -0
- package/dist/src/media/media-download.js.map +1 -0
- package/dist/src/media/mime.js +73 -0
- package/dist/src/media/mime.js.map +1 -0
- package/dist/src/media/silk-transcode.js +64 -0
- package/dist/src/media/silk-transcode.js.map +1 -0
- package/dist/src/messaging/debug-mode.js +63 -0
- package/dist/src/messaging/debug-mode.js.map +1 -0
- package/dist/src/messaging/error-notice.js +25 -0
- package/dist/src/messaging/error-notice.js.map +1 -0
- package/dist/src/messaging/inbound.js +201 -0
- package/dist/src/messaging/inbound.js.map +1 -0
- package/dist/src/messaging/markdown-filter.js +368 -0
- package/dist/src/messaging/markdown-filter.js.map +1 -0
- package/dist/src/messaging/outbound-hooks.js +58 -0
- package/dist/src/messaging/outbound-hooks.js.map +1 -0
- package/dist/src/messaging/process-message.js +406 -0
- package/dist/src/messaging/process-message.js.map +1 -0
- package/dist/src/messaging/reply-progress-sender.js +93 -0
- package/dist/src/messaging/reply-progress-sender.js.map +1 -0
- package/dist/src/messaging/send-media.js +54 -0
- package/dist/src/messaging/send-media.js.map +1 -0
- package/dist/src/messaging/send.js +220 -0
- package/dist/src/messaging/send.js.map +1 -0
- package/dist/src/messaging/slash-commands.js +70 -0
- package/dist/src/messaging/slash-commands.js.map +1 -0
- package/dist/src/monitor/monitor.js +146 -0
- package/dist/src/monitor/monitor.js.map +1 -0
- package/dist/src/storage/state-dir.js +9 -0
- package/dist/src/storage/state-dir.js.map +1 -0
- package/dist/src/storage/sync-buf.js +64 -0
- package/dist/src/storage/sync-buf.js.map +1 -0
- package/dist/src/util/logger.js +120 -0
- package/dist/src/util/logger.js.map +1 -0
- package/dist/src/util/random.js +16 -0
- package/dist/src/util/random.js.map +1 -0
- package/dist/src/util/redact.js +54 -0
- package/dist/src/util/redact.js.map +1 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +75 -0
- package/src/api/api.ts +586 -0
- package/src/api/config-cache.ts +79 -0
- package/src/api/session-guard.ts +58 -0
- package/src/api/types.ts +278 -0
- package/src/auth/accounts.ts +394 -0
- package/src/auth/login-qr.ts +458 -0
- package/src/auth/pairing.ts +120 -0
- package/src/cdn/aes-ecb.ts +21 -0
- package/src/cdn/cdn-upload.ts +93 -0
- package/src/cdn/cdn-url.ts +20 -0
- package/src/cdn/pic-decrypt.ts +101 -0
- package/src/cdn/upload.ts +168 -0
- package/src/channel.ts +548 -0
- package/src/compat.ts +77 -0
- package/src/config/config-schema.ts +23 -0
- package/src/config/reply-progress.ts +10 -0
- package/src/media/media-download.ts +149 -0
- package/src/media/mime.ts +76 -0
- package/src/media/silk-transcode.ts +74 -0
- package/src/messaging/debug-mode.ts +69 -0
- package/src/messaging/error-notice.ts +32 -0
- package/src/messaging/inbound.ts +262 -0
- package/src/messaging/markdown-filter.ts +361 -0
- package/src/messaging/outbound-hooks.ts +88 -0
- package/src/messaging/process-message.ts +525 -0
- package/src/messaging/reply-progress-sender.ts +122 -0
- package/src/messaging/send-media.ts +72 -0
- package/src/messaging/send.ts +294 -0
- package/src/messaging/slash-commands.ts +110 -0
- package/src/monitor/monitor.ts +224 -0
- package/src/storage/state-dir.ts +11 -0
- package/src/storage/sync-buf.ts +81 -0
- package/src/util/logger.ts +145 -0
- package/src/util/random.ts +17 -0
- package/src/util/redact.ts +54 -0
- package/src/vendor.d.ts +25 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { apiGetFetch, apiPostFetch } from "../api/api.js";
|
|
4
|
+
import { listIndexedWeixinAccountIds, loadWeixinAccount } from "./accounts.js";
|
|
5
|
+
import { logger } from "../util/logger.js";
|
|
6
|
+
import { redactToken } from "../util/redact.js";
|
|
7
|
+
|
|
8
|
+
type ActiveLogin = {
|
|
9
|
+
sessionKey: string;
|
|
10
|
+
id: string;
|
|
11
|
+
qrcode: string;
|
|
12
|
+
qrcodeUrl: string;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
botToken?: string;
|
|
15
|
+
status?: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect" | "need_verifycode" | "verify_code_blocked" | "binded_redirect";
|
|
16
|
+
error?: string;
|
|
17
|
+
/** The current effective polling base URL; may be updated on IDC redirect. */
|
|
18
|
+
currentApiBaseUrl?: string;
|
|
19
|
+
/** 待提交的配对码,用户输入后暂存,下次轮询时携带 */
|
|
20
|
+
pendingVerifyCode?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
|
|
24
|
+
/** Client-side timeout for the long-poll get_qrcode_status request. */
|
|
25
|
+
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
26
|
+
|
|
27
|
+
/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */
|
|
28
|
+
export const DEFAULT_ILINK_BOT_TYPE = "3";
|
|
29
|
+
|
|
30
|
+
const activeLogins = new Map<string, ActiveLogin>();
|
|
31
|
+
|
|
32
|
+
interface QRCodeResponse {
|
|
33
|
+
qrcode: string;
|
|
34
|
+
qrcode_img_content: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface StatusResponse {
|
|
38
|
+
status: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect" | "need_verifycode" | "verify_code_blocked" | "binded_redirect";
|
|
39
|
+
bot_token?: string;
|
|
40
|
+
ilink_bot_id?: string;
|
|
41
|
+
baseurl?: string;
|
|
42
|
+
/** The user ID of the person who scanned the QR code. */
|
|
43
|
+
ilink_user_id?: string;
|
|
44
|
+
/** New host to redirect polling to when status is scaned_but_redirect. */
|
|
45
|
+
redirect_host?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLoginFresh(login: ActiveLogin): boolean {
|
|
49
|
+
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Remove all expired entries from the activeLogins map to prevent memory leaks. */
|
|
53
|
+
function purgeExpiredLogins(): void {
|
|
54
|
+
for (const [id, login] of activeLogins) {
|
|
55
|
+
if (!isLoginFresh(login)) {
|
|
56
|
+
activeLogins.delete(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 获取本地已登录账号的 bot token 列表,最多返回最新的 10 个。 */
|
|
62
|
+
function getLocalBotTokenList(): string[] {
|
|
63
|
+
const accountIds = listIndexedWeixinAccountIds();
|
|
64
|
+
const tokens: string[] = [];
|
|
65
|
+
// 从最新注册的账号开始取(列表末尾为最新)
|
|
66
|
+
for (let i = accountIds.length - 1; i >= 0 && tokens.length < 10; i--) {
|
|
67
|
+
const data = loadWeixinAccount(accountIds[i]);
|
|
68
|
+
const token = data?.token?.trim();
|
|
69
|
+
if (token) {
|
|
70
|
+
tokens.push(token);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return tokens;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
|
|
77
|
+
logger.info(`NewFetching QR code from: ${apiBaseUrl} bot_type=${botType}`);
|
|
78
|
+
const localTokenList = getLocalBotTokenList();
|
|
79
|
+
logger.info(`newfetchQRCode: local_token_list count=${localTokenList.length}`);
|
|
80
|
+
const rawText = await apiPostFetch({
|
|
81
|
+
baseUrl: apiBaseUrl,
|
|
82
|
+
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
|
|
83
|
+
body: JSON.stringify({ local_token_list: localTokenList }),
|
|
84
|
+
label: "fetchQRCode",
|
|
85
|
+
});
|
|
86
|
+
return JSON.parse(rawText) as QRCodeResponse;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 从 stdin 读取一行用户输入,输出提示语后等待回车确认,返回 trim 后的字符串。 */
|
|
90
|
+
async function readVerifyCodeFromStdin(prompt: string): Promise<string> {
|
|
91
|
+
process.stdout.write(prompt);
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let input = "";
|
|
94
|
+
const onData = (chunk: Buffer | string) => {
|
|
95
|
+
const str = chunk.toString();
|
|
96
|
+
input += str;
|
|
97
|
+
if (input.includes("\n")) {
|
|
98
|
+
process.stdin.removeListener("data", onData);
|
|
99
|
+
process.stdin.pause();
|
|
100
|
+
resolve(input.trim());
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
process.stdin.resume();
|
|
104
|
+
process.stdin.setEncoding("utf-8");
|
|
105
|
+
process.stdin.on("data", onData);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function pollQRStatus(apiBaseUrl: string, qrcode: string, verifyCode?: string): Promise<StatusResponse> {
|
|
110
|
+
logger.debug(`Long-poll QR status from: ${apiBaseUrl} qrcode=***`);
|
|
111
|
+
try {
|
|
112
|
+
let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
|
113
|
+
if (verifyCode) {
|
|
114
|
+
endpoint += `&verify_code=${encodeURIComponent(verifyCode)}`;
|
|
115
|
+
}
|
|
116
|
+
const rawText = await apiGetFetch({
|
|
117
|
+
baseUrl: apiBaseUrl,
|
|
118
|
+
endpoint,
|
|
119
|
+
timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
|
|
120
|
+
label: "pollQRStatus",
|
|
121
|
+
});
|
|
122
|
+
logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
|
|
123
|
+
return JSON.parse(rawText) as StatusResponse;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
126
|
+
logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
|
|
127
|
+
return { status: "wait" };
|
|
128
|
+
}
|
|
129
|
+
// 网关超时(如 Cloudflare 524)或其他网络错误,视为等待状态继续轮询
|
|
130
|
+
logger.warn(`pollQRStatus: network/gateway error, will retry: ${String(err)}`);
|
|
131
|
+
return { status: "wait" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 在终端展示二维码及备用链接。
|
|
137
|
+
* 供 CLI 登录流程和 MCP Tool 登录流程共同复用。
|
|
138
|
+
*/
|
|
139
|
+
export async function displayQRCode(qrcodeUrl: string): Promise<void> {
|
|
140
|
+
try {
|
|
141
|
+
const qrterm = await import("qrcode-terminal");
|
|
142
|
+
qrterm.default.generate(qrcodeUrl, { small: true });
|
|
143
|
+
process.stdout.write(`若二维码未能显示或无法使用,你可以访问以下链接以继续:\n`);
|
|
144
|
+
process.stdout.write(`${qrcodeUrl}\n`);
|
|
145
|
+
} catch {
|
|
146
|
+
process.stdout.write(`若二维码未能显示或无法使用,你可以访问以下链接以继续:\n`);
|
|
147
|
+
process.stdout.write(`${qrcodeUrl}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type WeixinQrStartResult = {
|
|
152
|
+
qrcodeUrl?: string;
|
|
153
|
+
message: string;
|
|
154
|
+
sessionKey: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type WeixinQrWaitResult = {
|
|
158
|
+
connected: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Server reported `binded_redirect`: the scanned bot is already bound to
|
|
161
|
+
* this OpenClaw instance, so no new credentials are issued and existing
|
|
162
|
+
* local credentials remain valid. Callers should treat this as a successful
|
|
163
|
+
* outcome (semantically "already done") rather than a login failure.
|
|
164
|
+
*/
|
|
165
|
+
alreadyConnected?: boolean;
|
|
166
|
+
botToken?: string;
|
|
167
|
+
accountId?: string;
|
|
168
|
+
baseUrl?: string;
|
|
169
|
+
/** The user ID of the person who scanned the QR code; add to allowFrom. */
|
|
170
|
+
userId?: string;
|
|
171
|
+
message: string;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export async function startWeixinLoginWithQr(opts: {
|
|
175
|
+
verbose?: boolean;
|
|
176
|
+
force?: boolean;
|
|
177
|
+
accountId?: string;
|
|
178
|
+
apiBaseUrl: string;
|
|
179
|
+
botType?: string;
|
|
180
|
+
}): Promise<WeixinQrStartResult> {
|
|
181
|
+
const sessionKey = opts.accountId || randomUUID();
|
|
182
|
+
|
|
183
|
+
purgeExpiredLogins();
|
|
184
|
+
|
|
185
|
+
const existing = activeLogins.get(sessionKey);
|
|
186
|
+
if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
|
|
187
|
+
return {
|
|
188
|
+
qrcodeUrl: existing.qrcodeUrl,
|
|
189
|
+
message: "二维码已显示,请用手机微信扫描。",
|
|
190
|
+
sessionKey,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
|
196
|
+
logger.info(`Starting Weixin login with bot_type=${botType}`);
|
|
197
|
+
|
|
198
|
+
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
|
|
199
|
+
logger.info(
|
|
200
|
+
`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`,
|
|
201
|
+
);
|
|
202
|
+
logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`);
|
|
203
|
+
|
|
204
|
+
const login: ActiveLogin = {
|
|
205
|
+
sessionKey,
|
|
206
|
+
id: randomUUID(),
|
|
207
|
+
qrcode: qrResponse.qrcode,
|
|
208
|
+
qrcodeUrl: qrResponse.qrcode_img_content,
|
|
209
|
+
startedAt: Date.now(),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
activeLogins.set(sessionKey, login);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
qrcodeUrl: qrResponse.qrcode_img_content,
|
|
216
|
+
message: "用手机微信扫描以下二维码,以继续连接:",
|
|
217
|
+
sessionKey,
|
|
218
|
+
};
|
|
219
|
+
} catch (err) {
|
|
220
|
+
logger.error(`Failed to start Weixin login: ${String(err)}`);
|
|
221
|
+
return {
|
|
222
|
+
message: `Failed to start login: ${String(err)}`,
|
|
223
|
+
sessionKey,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const MAX_QR_REFRESH_COUNT = 3;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 刷新二维码并展示给用户,返回是否成功。
|
|
232
|
+
* 成功时更新 activeLogin 的 qrcode/qrcodeUrl/startedAt,并重置 scannedPrinted。
|
|
233
|
+
*/
|
|
234
|
+
async function refreshQRCode(
|
|
235
|
+
activeLogin: ActiveLogin,
|
|
236
|
+
apiBaseUrl: string,
|
|
237
|
+
botType: string,
|
|
238
|
+
qrRefreshCount: number,
|
|
239
|
+
onScannedReset: () => void,
|
|
240
|
+
): Promise<{ success: true } | { success: false; message: string }> {
|
|
241
|
+
process.stdout.write(`\n⏳ 正在刷新二维码...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
|
|
242
|
+
logger.info(`waitForWeixinLogin: refreshing QR code (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`);
|
|
243
|
+
try {
|
|
244
|
+
const qrResponse = await fetchQRCode(apiBaseUrl, botType);
|
|
245
|
+
activeLogin.qrcode = qrResponse.qrcode;
|
|
246
|
+
activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
|
|
247
|
+
activeLogin.startedAt = Date.now();
|
|
248
|
+
onScannedReset();
|
|
249
|
+
logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
|
|
250
|
+
process.stdout.write(`🔄 二维码已更新,请重新扫描。\n\n`);
|
|
251
|
+
await displayQRCode(qrResponse.qrcode_img_content);
|
|
252
|
+
return { success: true };
|
|
253
|
+
} catch (refreshErr) {
|
|
254
|
+
logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
|
|
255
|
+
return { success: false, message: `刷新二维码失败: ${String(refreshErr)}` };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function waitForWeixinLogin(opts: {
|
|
260
|
+
timeoutMs?: number;
|
|
261
|
+
verbose?: boolean;
|
|
262
|
+
sessionKey: string;
|
|
263
|
+
apiBaseUrl: string;
|
|
264
|
+
botType?: string;
|
|
265
|
+
}): Promise<WeixinQrWaitResult> {
|
|
266
|
+
let activeLogin = activeLogins.get(opts.sessionKey);
|
|
267
|
+
|
|
268
|
+
if (!activeLogin) {
|
|
269
|
+
logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);
|
|
270
|
+
return {
|
|
271
|
+
connected: false,
|
|
272
|
+
message: "当前没有进行中的登录,请先发起登录。",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!isLoginFresh(activeLogin)) {
|
|
277
|
+
logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);
|
|
278
|
+
activeLogins.delete(opts.sessionKey);
|
|
279
|
+
return {
|
|
280
|
+
connected: false,
|
|
281
|
+
message: "二维码已过期,请重新生成。",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000);
|
|
286
|
+
const deadline = Date.now() + timeoutMs;
|
|
287
|
+
let scannedPrinted = false;
|
|
288
|
+
let qrRefreshCount = 1;
|
|
289
|
+
|
|
290
|
+
// Initialize the effective polling base URL; may be updated on IDC redirect.
|
|
291
|
+
activeLogin.currentApiBaseUrl = opts.apiBaseUrl;
|
|
292
|
+
|
|
293
|
+
logger.info("Starting to poll QR code status...");
|
|
294
|
+
|
|
295
|
+
while (Date.now() < deadline) {
|
|
296
|
+
try {
|
|
297
|
+
const currentBaseUrl = activeLogin.currentApiBaseUrl ?? opts.apiBaseUrl;
|
|
298
|
+
const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode, activeLogin.pendingVerifyCode);
|
|
299
|
+
logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
|
|
300
|
+
activeLogin.status = statusResponse.status;
|
|
301
|
+
|
|
302
|
+
switch (statusResponse.status) {
|
|
303
|
+
case "wait":
|
|
304
|
+
if (opts.verbose) {
|
|
305
|
+
process.stdout.write(".");
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
case "scaned":
|
|
309
|
+
// 若携带了配对码且服务端返回 scaned,说明验证码正确,清除暂存
|
|
310
|
+
if (activeLogin.pendingVerifyCode) {
|
|
311
|
+
logger.info("verify code accepted, resuming polling");
|
|
312
|
+
activeLogin.pendingVerifyCode = undefined;
|
|
313
|
+
}
|
|
314
|
+
if (!scannedPrinted) {
|
|
315
|
+
process.stdout.write("\n正在验证\n");
|
|
316
|
+
scannedPrinted = true;
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
case "need_verifycode": {
|
|
320
|
+
// 首次进入提示输入,再次进入(已有 pendingVerifyCode)说明上次输入错误
|
|
321
|
+
const verifyPrompt = activeLogin.pendingVerifyCode
|
|
322
|
+
? "❌ 你输入的数字不匹配,请重新输入:"
|
|
323
|
+
: "输入手机微信显示的数字,以继续连接:";
|
|
324
|
+
const code = await readVerifyCodeFromStdin(verifyPrompt);
|
|
325
|
+
activeLogin.pendingVerifyCode = code;
|
|
326
|
+
// 立即进入下一次轮询,不等待 1s
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
case "expired": {
|
|
330
|
+
qrRefreshCount++;
|
|
331
|
+
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
|
332
|
+
logger.warn(
|
|
333
|
+
`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,
|
|
334
|
+
);
|
|
335
|
+
activeLogins.delete(opts.sessionKey);
|
|
336
|
+
return {
|
|
337
|
+
connected: false,
|
|
338
|
+
message: "二维码多次失效,连接流程已停止。请稍后再试。",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
process.stdout.write(`\n⏳ 二维码已过期,正在刷新...\n`);
|
|
343
|
+
const expiredRefreshResult = await refreshQRCode(
|
|
344
|
+
activeLogin,
|
|
345
|
+
opts.apiBaseUrl,
|
|
346
|
+
opts.botType || DEFAULT_ILINK_BOT_TYPE,
|
|
347
|
+
qrRefreshCount,
|
|
348
|
+
() => { scannedPrinted = false; },
|
|
349
|
+
);
|
|
350
|
+
if (!expiredRefreshResult.success) {
|
|
351
|
+
activeLogins.delete(opts.sessionKey);
|
|
352
|
+
return { connected: false, message: expiredRefreshResult.message };
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case "verify_code_blocked": {
|
|
357
|
+
logger.warn(
|
|
358
|
+
`waitForWeixinLogin: verify code blocked, qrRefreshCount=${qrRefreshCount} sessionKey=${opts.sessionKey}`,
|
|
359
|
+
);
|
|
360
|
+
process.stdout.write("\n⛔ 多次输入错误,请稍后再试。\n");
|
|
361
|
+
// 清除配对码暂存
|
|
362
|
+
activeLogin.pendingVerifyCode = undefined;
|
|
363
|
+
|
|
364
|
+
qrRefreshCount++;
|
|
365
|
+
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
|
366
|
+
logger.warn(
|
|
367
|
+
`waitForWeixinLogin: verify_code_blocked and QR refresh limit reached, giving up sessionKey=${opts.sessionKey}`,
|
|
368
|
+
);
|
|
369
|
+
activeLogins.delete(opts.sessionKey);
|
|
370
|
+
return {
|
|
371
|
+
connected: false,
|
|
372
|
+
message: "多次输入错误,连接流程已停止。请稍后再试。",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const blockedRefreshResult = await refreshQRCode(
|
|
377
|
+
activeLogin,
|
|
378
|
+
opts.apiBaseUrl,
|
|
379
|
+
opts.botType || DEFAULT_ILINK_BOT_TYPE,
|
|
380
|
+
qrRefreshCount,
|
|
381
|
+
() => { scannedPrinted = false; },
|
|
382
|
+
);
|
|
383
|
+
if (!blockedRefreshResult.success) {
|
|
384
|
+
activeLogins.delete(opts.sessionKey);
|
|
385
|
+
return { connected: false, message: blockedRefreshResult.message };
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case "binded_redirect": {
|
|
390
|
+
logger.info(`waitForWeixinLogin: binded_redirect received, bot already bound sessionKey=${opts.sessionKey}`);
|
|
391
|
+
process.stdout.write("\n✅ 已连接过此 OpenClaw,无需重复连接。\n");
|
|
392
|
+
activeLogins.delete(opts.sessionKey);
|
|
393
|
+
return {
|
|
394
|
+
connected: false,
|
|
395
|
+
alreadyConnected: true,
|
|
396
|
+
message: "已连接过此 OpenClaw,无需重复连接。",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
case "scaned_but_redirect": {
|
|
400
|
+
const redirectHost = statusResponse.redirect_host;
|
|
401
|
+
if (redirectHost) {
|
|
402
|
+
const newBaseUrl = `https://${redirectHost}`;
|
|
403
|
+
activeLogin.currentApiBaseUrl = newBaseUrl;
|
|
404
|
+
logger.info(`waitForWeixinLogin: IDC redirect, switching polling host to ${redirectHost}`);
|
|
405
|
+
} else {
|
|
406
|
+
logger.warn(`waitForWeixinLogin: received scaned_but_redirect but redirect_host is missing, continuing with current host`);
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "confirmed": {
|
|
411
|
+
if (!statusResponse.ilink_bot_id) {
|
|
412
|
+
activeLogins.delete(opts.sessionKey);
|
|
413
|
+
logger.error("Login confirmed but ilink_bot_id missing from response");
|
|
414
|
+
return {
|
|
415
|
+
connected: false,
|
|
416
|
+
message: "登录失败:服务器未返回 ilink_bot_id。",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
activeLogin.botToken = statusResponse.bot_token;
|
|
421
|
+
activeLogins.delete(opts.sessionKey);
|
|
422
|
+
|
|
423
|
+
logger.info(
|
|
424
|
+
`✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
connected: true,
|
|
429
|
+
botToken: statusResponse.bot_token,
|
|
430
|
+
accountId: statusResponse.ilink_bot_id,
|
|
431
|
+
baseUrl: statusResponse.baseurl,
|
|
432
|
+
userId: statusResponse.ilink_user_id,
|
|
433
|
+
message: "已将此 OpenClaw 连接到微信。",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
} catch (err) {
|
|
439
|
+
logger.error(`Error polling QR status: ${String(err)}`);
|
|
440
|
+
activeLogins.delete(opts.sessionKey);
|
|
441
|
+
return {
|
|
442
|
+
connected: false,
|
|
443
|
+
message: `Login failed: ${String(err)}`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
logger.warn(
|
|
451
|
+
`waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`,
|
|
452
|
+
);
|
|
453
|
+
activeLogins.delete(opts.sessionKey);
|
|
454
|
+
return {
|
|
455
|
+
connected: false,
|
|
456
|
+
message: "登录超时,请重试。",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { withFileLock } from "openclaw/plugin-sdk/infra-runtime";
|
|
5
|
+
|
|
6
|
+
import { resolveStateDir } from "../storage/state-dir.js";
|
|
7
|
+
import { logger } from "../util/logger.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the framework credentials directory (mirrors core resolveOAuthDir).
|
|
11
|
+
* Path: $OPENCLAW_OAUTH_DIR || $OPENCLAW_STATE_DIR/credentials || ~/.openclaw/credentials
|
|
12
|
+
*/
|
|
13
|
+
function resolveCredentialsDir(): string {
|
|
14
|
+
const override = process.env.OPENCLAW_OAUTH_DIR?.trim();
|
|
15
|
+
if (override) return override;
|
|
16
|
+
return path.join(resolveStateDir(), "credentials");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sanitize a channel/account key for safe use in filenames (mirrors core safeChannelKey).
|
|
21
|
+
*/
|
|
22
|
+
function safeKey(raw: string): string {
|
|
23
|
+
const trimmed = raw.trim().toLowerCase();
|
|
24
|
+
if (!trimmed) throw new Error("invalid key for allowFrom path");
|
|
25
|
+
const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
|
|
26
|
+
if (!safe || safe === "_") throw new Error("invalid key for allowFrom path");
|
|
27
|
+
return safe;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the framework allowFrom file path for a given account.
|
|
32
|
+
* Mirrors: `resolveAllowFromPath(channel, env, accountId)` from core.
|
|
33
|
+
* Path: `<credDir>/openclaw-weixin-<accountId>-allowFrom.json`
|
|
34
|
+
*/
|
|
35
|
+
export function resolveFrameworkAllowFromPath(accountId: string): string {
|
|
36
|
+
const base = safeKey("openclaw-weixin");
|
|
37
|
+
const safeAccount = safeKey(accountId);
|
|
38
|
+
return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type AllowFromFileContent = {
|
|
42
|
+
version: number;
|
|
43
|
+
allowFrom: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the framework allowFrom list for an account (user IDs authorized via pairing).
|
|
48
|
+
* Returns an empty array when the file is missing or unreadable.
|
|
49
|
+
*/
|
|
50
|
+
export function readFrameworkAllowFromList(accountId: string): string[] {
|
|
51
|
+
const filePath = resolveFrameworkAllowFromPath(accountId);
|
|
52
|
+
try {
|
|
53
|
+
if (!fs.existsSync(filePath)) return [];
|
|
54
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
55
|
+
const parsed = JSON.parse(raw) as AllowFromFileContent;
|
|
56
|
+
if (Array.isArray(parsed.allowFrom)) {
|
|
57
|
+
return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// best-effort
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** File lock options matching the framework's pairing store lock settings. */
|
|
66
|
+
const LOCK_OPTIONS = {
|
|
67
|
+
retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 },
|
|
68
|
+
stale: 10_000,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register a user ID in the framework's channel allowFrom store.
|
|
73
|
+
* This writes directly to the same JSON file that `readChannelAllowFromStore` reads,
|
|
74
|
+
* making the user visible to the framework authorization pipeline.
|
|
75
|
+
*
|
|
76
|
+
* Uses file locking to avoid races with concurrent readers/writers.
|
|
77
|
+
*/
|
|
78
|
+
export async function registerUserInFrameworkStore(params: {
|
|
79
|
+
accountId: string;
|
|
80
|
+
userId: string;
|
|
81
|
+
}): Promise<{ changed: boolean }> {
|
|
82
|
+
const { accountId, userId } = params;
|
|
83
|
+
const trimmedUserId = userId.trim();
|
|
84
|
+
if (!trimmedUserId) return { changed: false };
|
|
85
|
+
|
|
86
|
+
const filePath = resolveFrameworkAllowFromPath(accountId);
|
|
87
|
+
|
|
88
|
+
const dir = path.dirname(filePath);
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
90
|
+
|
|
91
|
+
// Ensure the file exists before locking
|
|
92
|
+
if (!fs.existsSync(filePath)) {
|
|
93
|
+
const initial: AllowFromFileContent = { version: 1, allowFrom: [] };
|
|
94
|
+
fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return await withFileLock(filePath, LOCK_OPTIONS, async () => {
|
|
98
|
+
let content: AllowFromFileContent = { version: 1, allowFrom: [] };
|
|
99
|
+
try {
|
|
100
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
101
|
+
const parsed = JSON.parse(raw) as AllowFromFileContent;
|
|
102
|
+
if (Array.isArray(parsed.allowFrom)) {
|
|
103
|
+
content = parsed;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// If read/parse fails, start fresh
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (content.allowFrom.includes(trimmedUserId)) {
|
|
110
|
+
return { changed: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
content.allowFrom.push(trimmedUserId);
|
|
114
|
+
fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
|
|
115
|
+
logger.info(
|
|
116
|
+
`registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`,
|
|
117
|
+
);
|
|
118
|
+
return { changed: true };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AES-128-ECB crypto utilities for CDN upload and download.
|
|
3
|
+
*/
|
|
4
|
+
import { createCipheriv, createDecipheriv } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
|
|
7
|
+
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
|
8
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
9
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
|
|
13
|
+
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
|
|
14
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
15
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
|
|
19
|
+
export function aesEcbPaddedSize(plaintextSize: number): number {
|
|
20
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
21
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { encryptAesEcb } from "./aes-ecb.js";
|
|
2
|
+
import { buildCdnUploadUrl } from "./cdn-url.js";
|
|
3
|
+
import { logger } from "../util/logger.js";
|
|
4
|
+
import { redactUrl } from "../util/redact.js";
|
|
5
|
+
|
|
6
|
+
/** Maximum retry attempts for CDN upload. */
|
|
7
|
+
const UPLOAD_MAX_RETRIES = 3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Upload one buffer to the Weixin CDN with AES-128-ECB encryption.
|
|
11
|
+
* Returns the download encrypted_query_param from the CDN response.
|
|
12
|
+
* Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.
|
|
13
|
+
*/
|
|
14
|
+
export async function uploadBufferToCdn(params: {
|
|
15
|
+
buf: Buffer;
|
|
16
|
+
/** From getUploadUrl.upload_full_url; POST target when set (takes precedence over uploadParam). */
|
|
17
|
+
uploadFullUrl?: string;
|
|
18
|
+
uploadParam?: string;
|
|
19
|
+
filekey: string;
|
|
20
|
+
cdnBaseUrl: string;
|
|
21
|
+
label: string;
|
|
22
|
+
aeskey: Buffer;
|
|
23
|
+
}): Promise<{ downloadParam: string }> {
|
|
24
|
+
const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
|
|
25
|
+
const ciphertext = encryptAesEcb(buf, aeskey);
|
|
26
|
+
const trimmedFull = uploadFullUrl?.trim();
|
|
27
|
+
let cdnUrl: string;
|
|
28
|
+
if (trimmedFull) {
|
|
29
|
+
cdnUrl = trimmedFull;
|
|
30
|
+
} else if (uploadParam) {
|
|
31
|
+
cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
|
|
32
|
+
} else {
|
|
33
|
+
throw new Error(`${label}: CDN upload URL missing (need upload_full_url or upload_param)`);
|
|
34
|
+
}
|
|
35
|
+
logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
|
|
36
|
+
|
|
37
|
+
let downloadParam: string | undefined;
|
|
38
|
+
let lastError: unknown;
|
|
39
|
+
|
|
40
|
+
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(cdnUrl, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
45
|
+
body: new Uint8Array(ciphertext),
|
|
46
|
+
});
|
|
47
|
+
if (res.status >= 400 && res.status < 500) {
|
|
48
|
+
const errMsg = res.headers.get("x-error-message") ?? (await res.text());
|
|
49
|
+
logger.error(
|
|
50
|
+
`${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
|
|
51
|
+
);
|
|
52
|
+
throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
|
|
53
|
+
}
|
|
54
|
+
if (res.status !== 200) {
|
|
55
|
+
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
|
|
56
|
+
logger.error(
|
|
57
|
+
`${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
|
|
58
|
+
);
|
|
59
|
+
throw new Error(`CDN upload server error: ${errMsg}`);
|
|
60
|
+
}
|
|
61
|
+
downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
|
|
62
|
+
if (!downloadParam) {
|
|
63
|
+
logger.error(
|
|
64
|
+
`${label}: CDN response missing x-encrypted-param header attempt=${attempt}`,
|
|
65
|
+
);
|
|
66
|
+
throw new Error("CDN upload response missing x-encrypted-param header");
|
|
67
|
+
}
|
|
68
|
+
logger.debug(`${label}: CDN upload success attempt=${attempt}`);
|
|
69
|
+
break;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
lastError = err;
|
|
72
|
+
if (err instanceof Error && err.message.includes("client error")) throw err;
|
|
73
|
+
const cause =
|
|
74
|
+
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "";
|
|
75
|
+
if (attempt < UPLOAD_MAX_RETRIES) {
|
|
76
|
+
logger.error(
|
|
77
|
+
`${label}: attempt ${attempt} failed, retrying... url=${redactUrl(cdnUrl)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
logger.error(
|
|
81
|
+
`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed url=${redactUrl(cdnUrl)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!downloadParam) {
|
|
88
|
+
throw lastError instanceof Error
|
|
89
|
+
? lastError
|
|
90
|
+
: new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
|
|
91
|
+
}
|
|
92
|
+
return { downloadParam };
|
|
93
|
+
}
|