@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +12 -0
- package/dist/services/weixin-api.service.d.ts +125 -0
- package/dist/services/weixin-api.service.js +182 -0
- package/dist/services/weixin-auth-capability.service.d.ts +20 -0
- package/dist/services/weixin-auth-capability.service.js +33 -0
- package/dist/services/weixin-channel-adapter.service.d.ts +53 -0
- package/dist/services/weixin-channel-adapter.service.js +303 -0
- package/dist/services/weixin-extension-runtime.service.d.ts +19 -0
- package/dist/services/weixin-extension-runtime.service.js +65 -0
- package/dist/services/weixin-login.service.d.ts +55 -0
- package/dist/services/weixin-login.service.js +203 -0
- package/dist/services/weixin-media-part-reader.service.js +192 -0
- package/dist/services/weixin-reply-chat.service.js +141 -0
- package/dist/services/weixin-typing-controller.service.js +109 -0
- package/dist/stores/weixin-account.store.d.ts +19 -0
- package/dist/stores/weixin-account.store.js +56 -0
- package/dist/types/weixin-extension.types.d.ts +43 -0
- package/dist/utils/weixin-config.utils.d.ts +69 -0
- package/dist/utils/weixin-config.utils.js +107 -0
- package/dist/utils/weixin-inbound-media.utils.js +215 -0
- package/dist/utils/weixin-media.utils.js +168 -0
- package/dist/utils/weixin-session-route.utils.js +38 -0
- package/nextclaw.extension.json +58 -0
- package/package.json +36 -0
|
@@ -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 };
|