@nextclaw/channel-extension-weixin 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ import { FileWeixinAccountStore } from "../stores/weixin-account.store.js";
2
+ import { HttpWeixinApiClient } from "./weixin-api.service.js";
3
+ import { NcpEventQueue, TERMINAL_NCP_EVENT_TYPES, WeixinReplyChat } from "./weixin-reply-chat.service.js";
4
+ import { resolveWeixinInboundAttachments } from "../utils/weixin-inbound-media.utils.js";
5
+ import { readWeixinEventSessionId, resolveWeixinSessionRoute } from "../utils/weixin-session-route.utils.js";
6
+ import { WeixinTypingController } from "./weixin-typing-controller.service.js";
7
+ import { NcpReplyConsumer } from "@nextclaw/ncp-toolkit";
8
+ //#region src/services/weixin-channel-adapter.service.ts
9
+ const DEFAULT_WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com";
10
+ const DEFAULT_WEIXIN_POLL_TIMEOUT_MS = 35e3;
11
+ const WEIXIN_MESSAGE_ITEM_TYPE_IMAGE = 2;
12
+ const WEIXIN_MESSAGE_ITEM_TYPE_VOICE = 3;
13
+ const WEIXIN_MESSAGE_ITEM_TYPE_FILE = 4;
14
+ const WEIXIN_MESSAGE_ITEM_TYPE_VIDEO = 5;
15
+ async function defaultSleep(ms, signal) {
16
+ await new Promise((resolve) => {
17
+ const timer = setTimeout(resolve, Math.max(0, ms));
18
+ const onAbort = () => {
19
+ clearTimeout(timer);
20
+ resolve();
21
+ };
22
+ signal?.addEventListener("abort", onAbort, { once: true });
23
+ });
24
+ }
25
+ function readStringArray(value) {
26
+ if (!Array.isArray(value)) return [];
27
+ return value.map((entry) => typeof entry === "string" ? entry.trim() : "").filter(Boolean);
28
+ }
29
+ function isAllowedSender(allowFrom, senderId) {
30
+ if (allowFrom.length === 0) return true;
31
+ if (allowFrom.includes(senderId)) return true;
32
+ return senderId.includes("|") && senderId.split("|").some((part) => allowFrom.includes(part));
33
+ }
34
+ function normalizeRouteKey(conversationId) {
35
+ return conversationId.toLowerCase();
36
+ }
37
+ function isSyntheticAttachmentText(text) {
38
+ return text === "[收到图片]" || text === "[收到视频]" || text === "[收到语音]" || /^\[收到文件(?:: .+)?]$/.test(text);
39
+ }
40
+ function extractText(message) {
41
+ const items = Array.isArray(message.item_list) ? message.item_list : [];
42
+ for (const item of items) {
43
+ const text = item.text_item?.text?.trim();
44
+ if (text) return text;
45
+ const voiceText = item.voice_item?.text?.trim();
46
+ if (voiceText) return voiceText;
47
+ }
48
+ for (const item of items) {
49
+ if (item.type === WEIXIN_MESSAGE_ITEM_TYPE_IMAGE) return "[收到图片]";
50
+ if (item.type === WEIXIN_MESSAGE_ITEM_TYPE_VIDEO) return "[收到视频]";
51
+ if (item.type === WEIXIN_MESSAGE_ITEM_TYPE_VOICE) return "[收到语音]";
52
+ if (item.type === WEIXIN_MESSAGE_ITEM_TYPE_FILE) {
53
+ const fileName = item.file_item?.file_name?.trim();
54
+ return fileName ? `[收到文件: ${fileName}]` : "[收到文件]";
55
+ }
56
+ }
57
+ return "";
58
+ }
59
+ var WeixinChannelAdapter = class {
60
+ messageHandler = null;
61
+ api;
62
+ store;
63
+ sleep;
64
+ logger;
65
+ replyConsumer;
66
+ typingController;
67
+ contextTokens = /* @__PURE__ */ new Map();
68
+ accountControllers = /* @__PURE__ */ new Map();
69
+ pollTasks = [];
70
+ replySessions = /* @__PURE__ */ new Map();
71
+ canonicalRoutes = /* @__PURE__ */ new Map();
72
+ running = false;
73
+ config = {};
74
+ constructor(deps = {}) {
75
+ this.api = deps.api ?? new HttpWeixinApiClient();
76
+ this.store = deps.store ?? new FileWeixinAccountStore();
77
+ this.sleep = deps.sleep ?? defaultSleep;
78
+ this.logger = deps.logger ?? console;
79
+ this.typingController = new WeixinTypingController({
80
+ fetchTicket: async (runtime) => {
81
+ return (await this.api.fetchConfig({
82
+ baseUrl: runtime.baseUrl,
83
+ token: runtime.token,
84
+ ilinkUserId: runtime.userId,
85
+ contextToken: runtime.contextToken
86
+ })).typing_ticket?.trim();
87
+ },
88
+ sendTyping: async (params) => {
89
+ const { baseUrl, status, ticket, token, userId } = params;
90
+ await this.api.sendTyping({
91
+ baseUrl,
92
+ token,
93
+ toUserId: userId,
94
+ typingTicket: ticket,
95
+ status
96
+ });
97
+ }
98
+ });
99
+ this.replyConsumer = new NcpReplyConsumer(new WeixinReplyChat({
100
+ resolveAccount: this.resolveSendAccount,
101
+ resolveContextToken: this.resolveContextToken,
102
+ sendText: this.sendText,
103
+ typingController: this.typingController
104
+ }));
105
+ }
106
+ configure = async (config) => {
107
+ this.config = config;
108
+ if (!this.running) return;
109
+ await this.stop();
110
+ await this.start();
111
+ };
112
+ start = async () => {
113
+ if (this.running) return;
114
+ this.running = true;
115
+ for (const accountId of this.listAvailableAccountIds()) {
116
+ const controller = new AbortController();
117
+ this.accountControllers.set(accountId, controller);
118
+ this.pollTasks.push(this.runPollingLoop(accountId, controller.signal));
119
+ }
120
+ };
121
+ stop = async () => {
122
+ if (!this.running) return;
123
+ this.running = false;
124
+ for (const controller of this.accountControllers.values()) controller.abort();
125
+ this.accountControllers.clear();
126
+ for (const session of this.replySessions.values()) session.queue.close();
127
+ this.replySessions.clear();
128
+ await Promise.allSettled(this.pollTasks.splice(0, this.pollTasks.length));
129
+ await this.typingController.stopAll();
130
+ };
131
+ onMessage = (handler) => {
132
+ this.messageHandler = handler;
133
+ return () => {
134
+ if (this.messageHandler === handler) this.messageHandler = null;
135
+ };
136
+ };
137
+ sendNcpEvent = async (event) => {
138
+ if (!this.running) return;
139
+ const route = resolveWeixinSessionRoute(event);
140
+ if (!route) return;
141
+ const sessionId = readWeixinEventSessionId(event);
142
+ if (!sessionId) return;
143
+ const session = this.resolveReplySession(sessionId, this.resolveCanonicalRoute(route));
144
+ session.queue.push(event);
145
+ if (TERMINAL_NCP_EVENT_TYPES.has(event.type)) {
146
+ session.queue.close();
147
+ this.replySessions.delete(sessionId);
148
+ await session.consuming;
149
+ }
150
+ };
151
+ emitMessageForTest = async (message) => {
152
+ await this.messageHandler?.(message);
153
+ };
154
+ sendText = async (params) => {
155
+ const { account, contextToken, conversationId, text } = params;
156
+ await this.api.sendTextMessage({
157
+ baseUrl: account.baseUrl,
158
+ token: account.token,
159
+ toUserId: conversationId,
160
+ text,
161
+ contextToken
162
+ });
163
+ };
164
+ resolveReplySession = (sessionId, route) => {
165
+ const existing = this.replySessions.get(sessionId);
166
+ if (existing) return existing;
167
+ const queue = new NcpEventQueue();
168
+ const session = {
169
+ queue,
170
+ consuming: this.replyConsumer.consume({
171
+ target: {
172
+ conversationId: route.conversationId,
173
+ ...route.accountId ? { accountId: route.accountId } : {}
174
+ },
175
+ eventStream: queue
176
+ })
177
+ };
178
+ this.replySessions.set(sessionId, session);
179
+ return session;
180
+ };
181
+ listAvailableAccountIds = () => {
182
+ return Array.from(new Set([
183
+ ...this.config.defaultAccountId ? [this.config.defaultAccountId] : [],
184
+ ...Object.keys(this.config.accounts ?? {}),
185
+ ...this.store.listAccountIds()
186
+ ]));
187
+ };
188
+ resolveCanonicalRoute = (route) => {
189
+ const remembered = this.canonicalRoutes.get(normalizeRouteKey(route.conversationId));
190
+ if (remembered) return {
191
+ ...remembered,
192
+ ...route.accountId ? { accountId: route.accountId } : {}
193
+ };
194
+ return this.resolveConfiguredCanonicalRoute(route.conversationId) ?? route;
195
+ };
196
+ resolveConfiguredCanonicalRoute = (conversationId) => {
197
+ const routeKey = normalizeRouteKey(conversationId);
198
+ const globalMatch = readStringArray(this.config.allowFrom).find((candidate) => normalizeRouteKey(candidate) === routeKey);
199
+ if (globalMatch) return { conversationId: globalMatch };
200
+ for (const [accountId, accountConfig] of Object.entries(this.config.accounts ?? {})) {
201
+ const accountMatch = readStringArray(accountConfig.allowFrom).find((candidate) => normalizeRouteKey(candidate) === routeKey);
202
+ if (accountMatch) return {
203
+ accountId,
204
+ conversationId: accountMatch
205
+ };
206
+ }
207
+ return null;
208
+ };
209
+ resolveRuntimeAccount = (accountId) => {
210
+ const stored = this.store.loadAccount(accountId);
211
+ if (!stored?.token) return null;
212
+ const accountConfig = this.config.accounts?.[accountId] ?? {};
213
+ return {
214
+ accountId,
215
+ token: stored.token,
216
+ enabled: accountConfig.enabled !== false && this.config.enabled !== false,
217
+ baseUrl: accountConfig.baseUrl || stored.baseUrl || this.config.baseUrl || DEFAULT_WEIXIN_BASE_URL,
218
+ pollTimeoutMs: this.config.pollTimeoutMs ?? DEFAULT_WEIXIN_POLL_TIMEOUT_MS,
219
+ allowFrom: Array.from(new Set([...readStringArray(this.config.allowFrom), ...readStringArray(accountConfig.allowFrom)]))
220
+ };
221
+ };
222
+ resolveSelectedAccountId = (requestedAccountId) => {
223
+ if (requestedAccountId) return requestedAccountId;
224
+ if (this.config.defaultAccountId) return this.config.defaultAccountId;
225
+ const accountIds = this.listAvailableAccountIds();
226
+ if (accountIds.length === 1 && accountIds[0]) return accountIds[0];
227
+ throw new Error("weixin send failed: accountId is required when multiple accounts are configured");
228
+ };
229
+ resolveSendAccount = (target) => {
230
+ const account = this.resolveRuntimeAccount(this.resolveSelectedAccountId(target.accountId));
231
+ if (!account?.enabled || !account.token) throw new Error(`weixin send failed: account "${target.accountId ?? this.config.defaultAccountId ?? ""}" is not logged in`);
232
+ return account;
233
+ };
234
+ resolveContextToken = (target, accountId) => {
235
+ const metadataToken = target.metadata?.context_token;
236
+ if (typeof metadataToken === "string" && metadataToken.trim()) return metadataToken.trim();
237
+ return this.contextTokens.get(`${accountId}:${target.conversationId}`);
238
+ };
239
+ runPollingLoop = async (accountId, signal) => {
240
+ while (this.running && !signal.aborted) try {
241
+ const account = this.resolveRuntimeAccount(accountId);
242
+ if (!account?.enabled) {
243
+ await this.sleep(3e3, signal);
244
+ continue;
245
+ }
246
+ const response = await this.api.fetchUpdates({
247
+ baseUrl: account.baseUrl,
248
+ token: account.token,
249
+ cursor: this.store.loadCursor(accountId),
250
+ timeoutMs: account.pollTimeoutMs,
251
+ signal
252
+ });
253
+ if (response.get_updates_buf !== void 0) this.store.saveCursor(accountId, response.get_updates_buf);
254
+ for (const message of response.msgs ?? []) await this.handleInboundMessage(account, message);
255
+ } catch (error) {
256
+ if (!signal.aborted) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ if (message.includes("errcode=-14") || message.includes("session timeout")) {
259
+ this.store.deleteCursor(accountId);
260
+ await this.sleep(1e3, signal);
261
+ continue;
262
+ }
263
+ this.logger.warn(`[weixin] polling failed for ${accountId}: ${message}`);
264
+ await this.sleep(3e3, signal);
265
+ }
266
+ }
267
+ };
268
+ handleInboundMessage = async (account, message) => {
269
+ const senderId = message.from_user_id?.trim();
270
+ if (!senderId || senderId === account.accountId || !isAllowedSender(account.allowFrom, senderId)) return;
271
+ const attachments = await resolveWeixinInboundAttachments({
272
+ message,
273
+ baseUrl: account.baseUrl
274
+ });
275
+ const extractedText = extractText(message);
276
+ const text = attachments.length > 0 && isSyntheticAttachmentText(extractedText) ? "" : extractedText;
277
+ if (!text && attachments.length === 0) return;
278
+ const contextToken = message.context_token?.trim();
279
+ if (contextToken) this.contextTokens.set(`${account.accountId}:${senderId}`, contextToken);
280
+ this.canonicalRoutes.set(normalizeRouteKey(senderId), {
281
+ accountId: account.accountId,
282
+ conversationId: senderId
283
+ });
284
+ if (contextToken) this.typingController.start({
285
+ accountId: account.accountId,
286
+ userId: senderId,
287
+ contextToken,
288
+ baseUrl: account.baseUrl,
289
+ token: account.token
290
+ });
291
+ await this.messageHandler?.({
292
+ conversationId: senderId,
293
+ senderId,
294
+ text,
295
+ attachments,
296
+ accountId: account.accountId,
297
+ ...contextToken ? { contextToken } : {},
298
+ raw: message
299
+ });
300
+ };
301
+ };
302
+ //#endregion
303
+ export { WeixinChannelAdapter };
@@ -0,0 +1,19 @@
1
+ import { WeixinChannelAdapter } from "../types/weixin-extension.types.js";
2
+ import { ExtensionChannel } from "@nextclaw/extension-sdk";
3
+
4
+ //#region src/services/weixin-extension-runtime.service.d.ts
5
+ declare class WeixinExtensionRuntime {
6
+ private readonly channel;
7
+ private readonly adapter;
8
+ private unsubscribeConfig;
9
+ private unsubscribeMessages;
10
+ private unsubscribeNcpEvents;
11
+ constructor(channel: ExtensionChannel, adapter: WeixinChannelAdapter);
12
+ readonly start: () => Promise<void>;
13
+ readonly stop: () => Promise<void>;
14
+ private readonly applyConfig;
15
+ private readonly submitMessage;
16
+ private readonly sendNcpEvent;
17
+ }
18
+ //#endregion
19
+ export { WeixinExtensionRuntime };
@@ -0,0 +1,65 @@
1
+ //#region src/services/weixin-extension-runtime.service.ts
2
+ var WeixinExtensionRuntime = class {
3
+ unsubscribeConfig = null;
4
+ unsubscribeMessages = null;
5
+ unsubscribeNcpEvents = null;
6
+ constructor(channel, adapter) {
7
+ this.channel = channel;
8
+ this.adapter = adapter;
9
+ }
10
+ start = async () => {
11
+ this.unsubscribeMessages = this.adapter.onMessage(this.submitMessage);
12
+ this.unsubscribeNcpEvents = this.channel.onNcpEvent(this.sendNcpEvent);
13
+ this.unsubscribeConfig = this.channel.config.onChange(async () => {
14
+ await this.applyConfig();
15
+ });
16
+ await this.applyConfig();
17
+ };
18
+ stop = async () => {
19
+ this.unsubscribeConfig?.();
20
+ this.unsubscribeMessages?.();
21
+ this.unsubscribeNcpEvents?.();
22
+ this.unsubscribeConfig = null;
23
+ this.unsubscribeMessages = null;
24
+ this.unsubscribeNcpEvents = null;
25
+ await this.adapter.stop();
26
+ };
27
+ applyConfig = async () => {
28
+ const config = await this.channel.config.get();
29
+ await this.adapter.configure(config);
30
+ if (config.enabled === false) {
31
+ await this.adapter.stop();
32
+ return;
33
+ }
34
+ await this.adapter.start();
35
+ };
36
+ submitMessage = async (message) => {
37
+ await this.channel.submitMessage({
38
+ conversationId: message.conversationId,
39
+ senderId: message.senderId,
40
+ content: {
41
+ type: "text",
42
+ text: message.text
43
+ },
44
+ ...message.attachments ? { attachments: message.attachments } : {},
45
+ metadata: {
46
+ ...message.accountId ? {
47
+ accountId: message.accountId,
48
+ account_id: message.accountId
49
+ } : {},
50
+ ...message.contextToken ? { context_token: message.contextToken } : {},
51
+ ...message.raw === void 0 ? {} : { raw: message.raw }
52
+ }
53
+ });
54
+ };
55
+ sendNcpEvent = async (event) => {
56
+ try {
57
+ await this.adapter.sendNcpEvent(event);
58
+ } catch (error) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ console.warn(`[weixin] failed to send NCP event: ${message}`);
61
+ }
62
+ };
63
+ };
64
+ //#endregion
65
+ export { WeixinExtensionRuntime };
@@ -0,0 +1,55 @@
1
+ import { WeixinAccountStore } from "../stores/weixin-account.store.js";
2
+ import { WeixinApiClient } from "./weixin-api.service.js";
3
+
4
+ //#region src/services/weixin-login.service.d.ts
5
+ type WeixinLoginParams = {
6
+ pluginConfig?: Record<string, unknown>;
7
+ requestedAccountId?: string | null;
8
+ baseUrl?: string | null;
9
+ verbose?: boolean;
10
+ };
11
+ type WeixinAuthStartResult = {
12
+ channel: string;
13
+ kind: "qr_code";
14
+ sessionId: string;
15
+ qrCode: string;
16
+ qrCodeUrl: string;
17
+ expiresAt: string;
18
+ intervalMs: number;
19
+ note?: string;
20
+ };
21
+ type WeixinAuthPollResult = {
22
+ channel: string;
23
+ status: "pending" | "scanned" | "authorized" | "expired" | "error";
24
+ message?: string;
25
+ nextPollMs?: number;
26
+ accountId?: string | null;
27
+ notes?: string[];
28
+ pluginConfig?: Record<string, unknown>;
29
+ };
30
+ type WeixinLoginServiceDeps = {
31
+ api?: WeixinApiClient;
32
+ store?: WeixinAccountStore;
33
+ };
34
+ declare class WeixinLoginService {
35
+ private readonly api;
36
+ private readonly store;
37
+ private readonly sessions;
38
+ constructor(deps?: WeixinLoginServiceDeps);
39
+ readonly start: (params: WeixinLoginParams) => Promise<WeixinAuthStartResult>;
40
+ readonly poll: ({
41
+ sessionId
42
+ }: {
43
+ sessionId: string;
44
+ }) => Promise<WeixinAuthPollResult | null>;
45
+ readonly login: (params: WeixinLoginParams) => Promise<{
46
+ pluginConfig: Record<string, unknown>;
47
+ accountId?: string | null;
48
+ notes?: string[];
49
+ }>;
50
+ private readonly confirmLogin;
51
+ private readonly resolveReplacementAccountIds;
52
+ private readonly cleanupExpiredSessions;
53
+ }
54
+ //#endregion
55
+ export { WeixinAuthPollResult, WeixinAuthStartResult, WeixinLoginParams, WeixinLoginService };
@@ -0,0 +1,203 @@
1
+ import { FileWeixinAccountStore } from "../stores/weixin-account.store.js";
2
+ import { HttpWeixinApiClient } from "./weixin-api.service.js";
3
+ import { WEIXIN_CHANNEL_ID, buildLoggedInWeixinChannelConfig, normalizeWeixinChannelConfig } from "../utils/weixin-config.utils.js";
4
+ import { randomUUID } from "node:crypto";
5
+ //#region src/services/weixin-login.service.ts
6
+ const WEIXIN_LOGIN_TIMEOUT_MS = 8 * 6e4;
7
+ const WEIXIN_AUTH_POLL_INTERVAL_MS = 2e3;
8
+ const WEIXIN_AUTH_STATUS_TIMEOUT_MS = 5e3;
9
+ async function sleep(ms) {
10
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
11
+ }
12
+ function resolveLoginBaseUrl(params, currentConfig) {
13
+ return params.baseUrl?.trim() || currentConfig.baseUrl || "https://ilinkai.weixin.qq.com";
14
+ }
15
+ function normalizeQrStatus(status) {
16
+ const normalized = status?.trim().toLowerCase() ?? "";
17
+ if (normalized === "scaned" || normalized === "scanned") return "scanned";
18
+ if (normalized === "confirmed" || normalized === "authorized" || normalized === "success") return "authorized";
19
+ if (normalized === "expired" || normalized === "timeout") return "expired";
20
+ return "pending";
21
+ }
22
+ function hasAuthorizedCredentials(status) {
23
+ return Boolean(status.bot_token?.trim() && status.ilink_bot_id?.trim());
24
+ }
25
+ var WeixinLoginService = class {
26
+ api;
27
+ store;
28
+ sessions = /* @__PURE__ */ new Map();
29
+ constructor(deps = {}) {
30
+ this.api = deps.api ?? new HttpWeixinApiClient();
31
+ this.store = deps.store ?? new FileWeixinAccountStore();
32
+ }
33
+ start = async (params) => {
34
+ this.cleanupExpiredSessions();
35
+ const currentConfig = normalizeWeixinChannelConfig(params.pluginConfig);
36
+ const baseUrl = resolveLoginBaseUrl(params, currentConfig);
37
+ const qrCode = await this.api.fetchQrCode({ baseUrl });
38
+ const qrCodeUrl = qrCode.qrcode_img_content?.trim();
39
+ const qrCodeValue = qrCode.qrcode?.trim();
40
+ if (!qrCodeUrl || !qrCodeValue) throw new Error("weixin login failed: QR code is unavailable");
41
+ const sessionId = randomUUID();
42
+ const expiresAtMs = Date.now() + WEIXIN_LOGIN_TIMEOUT_MS;
43
+ this.sessions.set(sessionId, {
44
+ currentConfig,
45
+ requestedAccountId: params.requestedAccountId,
46
+ baseUrl,
47
+ qrCode: qrCodeValue,
48
+ expiresAtMs
49
+ });
50
+ return {
51
+ channel: WEIXIN_CHANNEL_ID,
52
+ kind: "qr_code",
53
+ sessionId,
54
+ qrCode: qrCodeValue,
55
+ qrCodeUrl,
56
+ expiresAt: new Date(expiresAtMs).toISOString(),
57
+ intervalMs: WEIXIN_AUTH_POLL_INTERVAL_MS,
58
+ note: "请使用微信扫码,并在手机上确认登录。"
59
+ };
60
+ };
61
+ poll = async ({ sessionId }) => {
62
+ this.cleanupExpiredSessions();
63
+ const session = this.sessions.get(sessionId);
64
+ if (!session) return null;
65
+ if (session.expiresAtMs <= Date.now()) {
66
+ this.sessions.delete(sessionId);
67
+ return {
68
+ channel: WEIXIN_CHANNEL_ID,
69
+ status: "expired",
70
+ message: "二维码已过期,请重新扫码。"
71
+ };
72
+ }
73
+ try {
74
+ const status = await this.api.fetchQrStatus({
75
+ baseUrl: session.baseUrl,
76
+ qrcode: session.qrCode,
77
+ timeoutMs: WEIXIN_AUTH_STATUS_TIMEOUT_MS
78
+ });
79
+ const normalizedStatus = hasAuthorizedCredentials(status) ? "authorized" : normalizeQrStatus(status.status);
80
+ if (normalizedStatus === "scanned") return {
81
+ channel: WEIXIN_CHANNEL_ID,
82
+ status: "scanned",
83
+ message: "二维码已扫码,请在微信中确认登录。",
84
+ nextPollMs: WEIXIN_AUTH_POLL_INTERVAL_MS
85
+ };
86
+ if (normalizedStatus === "authorized") {
87
+ const result = this.confirmLogin(session, status);
88
+ this.sessions.delete(sessionId);
89
+ return {
90
+ channel: WEIXIN_CHANNEL_ID,
91
+ status: "authorized",
92
+ message: "微信已连接。",
93
+ nextPollMs: 0,
94
+ accountId: result.accountId,
95
+ notes: result.notes,
96
+ pluginConfig: result.pluginConfig
97
+ };
98
+ }
99
+ if (normalizedStatus === "expired") {
100
+ this.sessions.delete(sessionId);
101
+ return {
102
+ channel: WEIXIN_CHANNEL_ID,
103
+ status: "expired",
104
+ message: "二维码已过期,请重新扫码。"
105
+ };
106
+ }
107
+ return {
108
+ channel: WEIXIN_CHANNEL_ID,
109
+ status: "pending",
110
+ nextPollMs: WEIXIN_AUTH_POLL_INTERVAL_MS
111
+ };
112
+ } catch (error) {
113
+ return {
114
+ channel: WEIXIN_CHANNEL_ID,
115
+ status: "error",
116
+ message: error instanceof Error ? error.message : String(error)
117
+ };
118
+ }
119
+ };
120
+ login = async (params) => {
121
+ const started = await this.start(params);
122
+ console.log("使用微信扫描以下二维码链接完成连接:");
123
+ console.log(started.qrCodeUrl);
124
+ console.log("");
125
+ console.log("等待扫码确认...");
126
+ let seenScanned = false;
127
+ while (Date.now() < new Date(started.expiresAt).getTime()) {
128
+ const status = await this.poll({ sessionId: started.sessionId });
129
+ if (!status) throw new Error("weixin login failed: auth session not found");
130
+ if (status.status === "scanned" && !seenScanned) {
131
+ console.log(status.message ?? "二维码已扫码,请在微信中确认登录。");
132
+ seenScanned = true;
133
+ }
134
+ if (status.status === "authorized") return {
135
+ pluginConfig: status.pluginConfig ?? {},
136
+ accountId: status.accountId,
137
+ notes: status.notes
138
+ };
139
+ if (status.status === "expired") throw new Error(status.message ?? "weixin login failed: QR code expired, please retry");
140
+ if (status.status === "error") throw new Error(status.message ?? "weixin login failed");
141
+ if (params.verbose) process.stdout.write(".");
142
+ await sleep(status.nextPollMs ?? WEIXIN_AUTH_POLL_INTERVAL_MS);
143
+ }
144
+ throw new Error("weixin login timed out");
145
+ };
146
+ confirmLogin = (session, status) => {
147
+ const token = status.bot_token?.trim();
148
+ const accountId = status.ilink_bot_id?.trim() || session.requestedAccountId?.trim();
149
+ const baseUrl = status.baseurl?.trim() || session.baseUrl;
150
+ const userId = status.ilink_user_id?.trim() || void 0;
151
+ if (!token || !accountId) throw new Error("weixin login failed: missing bot token or account id");
152
+ const replacementAccountIds = this.resolveReplacementAccountIds({
153
+ currentConfig: session.currentConfig,
154
+ requestedAccountId: session.requestedAccountId,
155
+ accountId,
156
+ userId
157
+ });
158
+ for (const replacementAccountId of replacementAccountIds) {
159
+ this.store.deleteAccount(replacementAccountId);
160
+ this.store.deleteCursor(replacementAccountId);
161
+ }
162
+ this.store.saveAccount({
163
+ accountId,
164
+ token,
165
+ baseUrl,
166
+ userId,
167
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
168
+ });
169
+ return {
170
+ accountId,
171
+ notes: [
172
+ ...session.requestedAccountId?.trim() && session.requestedAccountId.trim() !== accountId ? [`Weixin account resolved to ${accountId}.`] : [],
173
+ ...replacementAccountIds.map((replacementAccountId) => `Replaced previous Weixin account: ${replacementAccountId}`),
174
+ ...userId ? [`Authorized initial user: ${userId}`] : []
175
+ ],
176
+ pluginConfig: buildLoggedInWeixinChannelConfig({
177
+ config: session.currentConfig,
178
+ accountId,
179
+ baseUrl,
180
+ allowUserId: userId,
181
+ replaceAccountIds: replacementAccountIds
182
+ })
183
+ };
184
+ };
185
+ resolveReplacementAccountIds = (params) => {
186
+ const { accountId, currentConfig, requestedAccountId: requestedAccountIdRaw, userId } = params;
187
+ const replacementIds = /* @__PURE__ */ new Set();
188
+ const requestedAccountId = requestedAccountIdRaw?.trim();
189
+ const defaultAccountId = currentConfig.defaultAccountId?.trim();
190
+ if (requestedAccountId && requestedAccountId !== accountId) replacementIds.add(requestedAccountId);
191
+ else if (!requestedAccountId && defaultAccountId && defaultAccountId !== accountId) replacementIds.add(defaultAccountId);
192
+ if (userId) {
193
+ for (const [candidateAccountId, accountConfig] of Object.entries(currentConfig.accounts ?? {})) if (candidateAccountId !== accountId && accountConfig.allowFrom?.includes(userId)) replacementIds.add(candidateAccountId);
194
+ for (const candidateAccountId of this.store.listAccountIds()) if (candidateAccountId !== accountId && this.store.loadAccount(candidateAccountId)?.userId === userId) replacementIds.add(candidateAccountId);
195
+ }
196
+ return [...replacementIds];
197
+ };
198
+ cleanupExpiredSessions = (now = Date.now()) => {
199
+ for (const [sessionId, session] of this.sessions.entries()) if (session.expiresAtMs <= now) this.sessions.delete(sessionId);
200
+ };
201
+ };
202
+ //#endregion
203
+ export { WeixinLoginService };