@zhin.js/adapter-weixin-ilink 1.1.0 → 1.1.2
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 +74 -0
- package/README.md +11 -6
- package/adapters/{weixin-ilink.js → weixin-ilink/index.js} +6 -6
- package/adapters/{weixin-ilink.ts → weixin-ilink/index.ts} +9 -9
- package/commands/{endpoint/add/[id].js → weixin-ilink/endpoint/add/[id]/index.js} +1 -1
- package/commands/weixin-ilink/endpoint/add/[id]/index.ts +3 -0
- package/{lib/weixin-ilink-endpoint-commands.js → commands/weixin-ilink/endpoint/definition.js} +4 -3
- package/{src/weixin-ilink-endpoint-commands.ts → commands/weixin-ilink/endpoint/definition.ts} +3 -3
- package/commands/{endpoint/list.js → weixin-ilink/endpoint/list/index.js} +1 -1
- package/commands/weixin-ilink/endpoint/list/index.ts +3 -0
- package/commands/{endpoint/remove/[id].js → weixin-ilink/endpoint/remove/[id]/index.js} +1 -1
- package/commands/weixin-ilink/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +5 -4
- package/lib/client.js +33 -16
- package/lib/context-store.d.ts +15 -1
- package/lib/context-store.js +93 -1
- package/lib/credentials.d.ts +15 -7
- package/lib/credentials.js +59 -42
- package/lib/endpoint.d.ts +9 -4
- package/lib/endpoint.js +41 -27
- package/lib/ilink-api.d.ts +6 -5
- package/lib/ilink-api.js +21 -14
- package/lib/ilink-config-cache.d.ts +3 -7
- package/lib/ilink-config-cache.js +13 -14
- package/lib/ilink-logger.d.ts +0 -1
- package/lib/ilink-logger.js +11 -25
- package/lib/ilink-meta.d.ts +13 -7
- package/lib/ilink-meta.js +24 -20
- package/lib/ilink-session-guard.d.ts +13 -14
- package/lib/ilink-session-guard.js +29 -42
- package/lib/index.d.ts +5 -2
- package/lib/index.js +4 -0
- package/lib/login.d.ts +4 -3
- package/lib/login.js +24 -21
- package/lib/outbound-media.js +1 -1
- package/lib/protocol.d.ts +7 -10
- package/lib/protocol.js +15 -15
- package/lib/types.d.ts +1 -1
- package/lib/weixin-ilink-runtime-state.js +1 -1
- package/lib/weixin-inbound.d.ts +3 -49
- package/lib/weixin-inbound.js +20 -183
- package/lib/weixin-send-media.js +5 -1
- package/lib/weixin-send.js +2 -0
- package/package.json +11 -11
- package/plugin.js +1 -1
- package/schema.json +32 -4
- package/src/client.ts +46 -14
- package/src/context-store.ts +101 -10
- package/src/credentials.ts +68 -45
- package/src/endpoint.ts +59 -38
- package/src/ilink-api.ts +26 -20
- package/src/ilink-config-cache.ts +17 -13
- package/src/ilink-logger.ts +11 -26
- package/src/ilink-meta.ts +31 -21
- package/src/ilink-session-guard.ts +29 -46
- package/src/index.ts +15 -2
- package/src/login.ts +32 -19
- package/src/outbound-media.ts +1 -1
- package/src/protocol.ts +20 -29
- package/src/types.ts +0 -1
- package/src/upload.test.ts +11 -8
- package/src/weixin-ilink-runtime-state.ts +1 -1
- package/src/weixin-inbound.ts +23 -215
- package/src/weixin-send-media.ts +5 -1
- package/src/weixin-send.ts +2 -0
- package/commands/endpoint/add/[id].ts +0 -3
- package/commands/endpoint/list.ts +0 -3
- package/commands/endpoint/remove/[id].ts +0 -3
- package/lib/weixin-ilink-endpoint-commands.d.ts +0 -1
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
-
import type { EndpointControl, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
1
|
+
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
3
2
|
import type { CapabilityId } from 'zhin.js';
|
|
4
3
|
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
5
|
-
import {
|
|
4
|
+
import { IlinkClientMetadata } from './ilink-meta.js';
|
|
5
|
+
import { type WeixinIlinkCredentials, WeixinIlinkStateStore } from './credentials.js';
|
|
6
|
+
import { WeixinContextTokenStore } from './context-store.js';
|
|
7
|
+
import { IlinkSessionGuard } from './ilink-session-guard.js';
|
|
6
8
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
7
9
|
import { type ResolvedWeixinIlinkConfig, type WeixinMessageWithMedia } from './protocol.js';
|
|
8
10
|
import { WeixinIlinkClient } from './client.js';
|
|
@@ -13,12 +15,15 @@ export type WeixinIlinkGetUpdates = typeof getUpdates;
|
|
|
13
15
|
export interface WeixinIlinkEndpointOptions {
|
|
14
16
|
readonly id: CapabilityId;
|
|
15
17
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
16
|
-
readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
|
|
18
|
+
readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, metadata: IlinkClientMetadata, state: WeixinIlinkStateStore, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
|
|
17
19
|
/** Test / internal: override network side effects. */
|
|
18
20
|
readonly notifyStart?: WeixinIlinkNotifyStart;
|
|
19
21
|
readonly notifyStop?: WeixinIlinkNotifyStop;
|
|
20
22
|
readonly getUpdates?: WeixinIlinkGetUpdates;
|
|
21
23
|
readonly sendText?: WeixinIlinkSendText;
|
|
24
|
+
readonly state?: WeixinIlinkStateStore;
|
|
25
|
+
readonly contextTokens?: WeixinContextTokenStore;
|
|
26
|
+
readonly sessionGuard?: IlinkSessionGuard;
|
|
22
27
|
}
|
|
23
28
|
export declare class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
24
29
|
#private;
|
package/lib/endpoint.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { Endpoint } from 'zhin.js/adapter';
|
|
2
1
|
/**
|
|
3
2
|
* WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
|
|
4
3
|
*/
|
|
5
4
|
import crypto from 'node:crypto';
|
|
6
5
|
import fs from 'node:fs';
|
|
7
6
|
import path from 'node:path';
|
|
7
|
+
import { Endpoint, } from 'zhin.js/adapter';
|
|
8
8
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
9
9
|
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { IlinkClientMetadata } from './ilink-meta.js';
|
|
11
|
+
import { WeixinIlinkStateStore, } from './credentials.js';
|
|
12
12
|
import { resolveCredentials } from './login.js';
|
|
13
|
-
import {
|
|
13
|
+
import { WeixinContextTokenStore, } from './context-store.js';
|
|
14
14
|
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
15
|
-
import {
|
|
15
|
+
import { IlinkSessionGuard, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
|
|
16
16
|
import { downloadMediaFromItem } from './media-download.js';
|
|
17
17
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
18
18
|
import { materializeOutboundMedia } from './outbound-media.js';
|
|
@@ -36,6 +36,10 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
36
36
|
#notifyStop;
|
|
37
37
|
#getUpdates;
|
|
38
38
|
#sendText;
|
|
39
|
+
#state;
|
|
40
|
+
#contextTokens;
|
|
41
|
+
#sessionGuard;
|
|
42
|
+
#metadata;
|
|
39
43
|
#creds = null;
|
|
40
44
|
#pollAbort;
|
|
41
45
|
#pollPromise;
|
|
@@ -59,7 +63,12 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
59
63
|
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
60
64
|
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
61
65
|
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
62
|
-
this
|
|
66
|
+
this.#state = options.state
|
|
67
|
+
?? new WeixinIlinkStateStore(options.config.id, options.config.dataDir);
|
|
68
|
+
this.#contextTokens = options.contextTokens ?? new WeixinContextTokenStore(this.#state);
|
|
69
|
+
this.#sessionGuard = options.sessionGuard ?? new IlinkSessionGuard(options.config.id);
|
|
70
|
+
this.#metadata = new IlinkClientMetadata({ botAgent: options.config.botAgent });
|
|
71
|
+
this.client = new WeixinIlinkClient(options.config, this.#metadata, () => this.#creds, () => this.#configManager, this.#sendText, this.#contextTokens);
|
|
63
72
|
}
|
|
64
73
|
get hasCredentials() {
|
|
65
74
|
return this.client.authenticated;
|
|
@@ -69,16 +78,20 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
69
78
|
return;
|
|
70
79
|
this.#started = true;
|
|
71
80
|
try {
|
|
72
|
-
configureIlinkMeta({ botAgent: this.#options.config.botAgent });
|
|
73
81
|
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
74
82
|
this.#loginAbort = new AbortController();
|
|
75
|
-
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
76
|
-
|
|
83
|
+
this.#creds = await this.#resolveCredentials(this.#options.config, this.#metadata, this.#state, this.#loginAbort.signal);
|
|
84
|
+
this.#contextTokens.restore();
|
|
77
85
|
await this.#notifyStart({
|
|
78
86
|
baseUrl: this.client.apiBaseUrl,
|
|
87
|
+
metadata: this.#metadata,
|
|
79
88
|
token: this.#creds.botToken,
|
|
80
89
|
});
|
|
81
|
-
this.#configManager = new WeixinConfigManager({
|
|
90
|
+
this.#configManager = new WeixinConfigManager({
|
|
91
|
+
baseUrl: this.client.apiBaseUrl,
|
|
92
|
+
metadata: this.#metadata,
|
|
93
|
+
token: this.#creds.botToken,
|
|
94
|
+
}, (msg) => this.#logger.debug(msg));
|
|
82
95
|
this.#pollAbort = new AbortController();
|
|
83
96
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
84
97
|
this.#startMediaSweep();
|
|
@@ -114,10 +127,14 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
114
127
|
}
|
|
115
128
|
this.#stopMediaSweep();
|
|
116
129
|
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
117
|
-
|
|
130
|
+
this.#contextTokens.flush();
|
|
118
131
|
if (this.#creds?.botToken) {
|
|
119
132
|
try {
|
|
120
|
-
await this.#notifyStop({
|
|
133
|
+
await this.#notifyStop({
|
|
134
|
+
baseUrl: this.client.apiBaseUrl,
|
|
135
|
+
metadata: this.#metadata,
|
|
136
|
+
token: this.#creds.botToken,
|
|
137
|
+
});
|
|
121
138
|
}
|
|
122
139
|
catch (err) {
|
|
123
140
|
this.#logger.warn(formatCompact({
|
|
@@ -151,7 +168,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
151
168
|
}));
|
|
152
169
|
throw new Error(`missing context_token for peer ${target}`);
|
|
153
170
|
}
|
|
154
|
-
const outboundDir =
|
|
171
|
+
const outboundDir = this.#state.mediaDirectory('outbound');
|
|
155
172
|
const wire = formatOutboundSegments(payload);
|
|
156
173
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
157
174
|
const segments = Array.isArray(materialized) ? materialized : [materialized];
|
|
@@ -201,7 +218,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
201
218
|
return;
|
|
202
219
|
const userId = msg.from_user_id ?? '';
|
|
203
220
|
if (msg.context_token && userId) {
|
|
204
|
-
|
|
221
|
+
this.#contextTokens.set(userId, msg.context_token);
|
|
205
222
|
}
|
|
206
223
|
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
207
224
|
const replyTo = weixinReplyTo(msg);
|
|
@@ -228,18 +245,18 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
228
245
|
async #pollLoop(abortSignal) {
|
|
229
246
|
if (!this.#creds?.botToken)
|
|
230
247
|
return;
|
|
231
|
-
|
|
232
|
-
let getUpdatesBuf = loadSyncBuf(endpointId);
|
|
248
|
+
let getUpdatesBuf = this.#state.loadSyncBuf();
|
|
233
249
|
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
234
250
|
let consecutiveFailures = 0;
|
|
235
251
|
while (!abortSignal.aborted) {
|
|
236
|
-
if (
|
|
237
|
-
await sleep(
|
|
252
|
+
if (this.#sessionGuard.paused) {
|
|
253
|
+
await sleep(this.#sessionGuard.remainingMs, abortSignal);
|
|
238
254
|
continue;
|
|
239
255
|
}
|
|
240
256
|
try {
|
|
241
257
|
const resp = await this.#getUpdates({
|
|
242
258
|
baseUrl: this.client.apiBaseUrl,
|
|
259
|
+
metadata: this.#metadata,
|
|
243
260
|
token: this.#creds.botToken,
|
|
244
261
|
get_updates_buf: getUpdatesBuf,
|
|
245
262
|
timeoutMs: nextTimeoutMs,
|
|
@@ -253,9 +270,9 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
253
270
|
if (isApiError) {
|
|
254
271
|
const sessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
255
272
|
if (sessionExpired) {
|
|
256
|
-
|
|
273
|
+
this.#sessionGuard.pause();
|
|
257
274
|
consecutiveFailures = 0;
|
|
258
|
-
await sleep(
|
|
275
|
+
await sleep(this.#sessionGuard.remainingMs, abortSignal);
|
|
259
276
|
continue;
|
|
260
277
|
}
|
|
261
278
|
consecutiveFailures += 1;
|
|
@@ -276,7 +293,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
276
293
|
}
|
|
277
294
|
if (nextBuf) {
|
|
278
295
|
getUpdatesBuf = nextBuf;
|
|
279
|
-
saveSyncBuf(
|
|
296
|
+
this.#state.saveSyncBuf(getUpdatesBuf);
|
|
280
297
|
}
|
|
281
298
|
}
|
|
282
299
|
catch (err) {
|
|
@@ -300,9 +317,6 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
300
317
|
async #handleInboundMessage(full) {
|
|
301
318
|
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
302
319
|
const fromUserId = full.from_user_id ?? '';
|
|
303
|
-
if (full.context_token && fromUserId) {
|
|
304
|
-
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
305
|
-
}
|
|
306
320
|
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
307
321
|
this.admit({ ...full, _media: mediaOpts });
|
|
308
322
|
this.#logger.debug(formatCompact({
|
|
@@ -341,7 +355,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
341
355
|
if (buffer.length > maxBytes) {
|
|
342
356
|
throw new Error(`media exceeds max size ${maxBytes}`);
|
|
343
357
|
}
|
|
344
|
-
const dir =
|
|
358
|
+
const dir = this.#state.mediaDirectory(subdir);
|
|
345
359
|
fs.mkdirSync(dir, { recursive: true });
|
|
346
360
|
const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
|
|
347
361
|
const ext = originalFilename
|
|
@@ -372,7 +386,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
|
|
|
372
386
|
}
|
|
373
387
|
}
|
|
374
388
|
#sweepInboundMedia() {
|
|
375
|
-
const dir =
|
|
389
|
+
const dir = this.#state.mediaDirectory('inbound');
|
|
376
390
|
let entries;
|
|
377
391
|
try {
|
|
378
392
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -408,7 +422,7 @@ function createWeixinIlinkEndpointManagement(requireClient) {
|
|
|
408
422
|
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
409
423
|
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
410
424
|
async listFriends() {
|
|
411
|
-
return
|
|
425
|
+
return requireClient().reachableUserIds().map((userId) => ({
|
|
412
426
|
user_id: userId,
|
|
413
427
|
nickname: userId,
|
|
414
428
|
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
package/lib/ilink-api.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { type IlinkClientMetadata } from "./ilink-meta.js";
|
|
1
2
|
import type { GetUploadUrlReq, GetUploadUrlResp, GetUpdatesReq, GetUpdatesResp, NotifyStopResp, NotifyStartResp, SendMessageReq, SendTypingReq, GetConfigResp } from "./ilink-types.js";
|
|
2
|
-
export {
|
|
3
|
+
export { IlinkClientMetadata, sanitizeBotAgent } from "./ilink-meta.js";
|
|
3
4
|
export type WeixinApiOptions = {
|
|
4
5
|
baseUrl: string;
|
|
6
|
+
metadata: IlinkClientMetadata;
|
|
5
7
|
token?: string;
|
|
6
8
|
timeoutMs?: number;
|
|
7
9
|
/** Long-poll timeout for getUpdates (server may hold the request up to this). */
|
|
@@ -15,6 +17,7 @@ export type WeixinApiOptions = {
|
|
|
15
17
|
*/
|
|
16
18
|
export declare function apiGetFetch(params: {
|
|
17
19
|
baseUrl: string;
|
|
20
|
+
metadata: IlinkClientMetadata;
|
|
18
21
|
endpoint: string;
|
|
19
22
|
timeoutMs?: number;
|
|
20
23
|
label: string;
|
|
@@ -29,6 +32,7 @@ export declare function apiGetFetch(params: {
|
|
|
29
32
|
*/
|
|
30
33
|
export declare function apiPostFetch(params: {
|
|
31
34
|
baseUrl: string;
|
|
35
|
+
metadata: IlinkClientMetadata;
|
|
32
36
|
endpoint: string;
|
|
33
37
|
body: string;
|
|
34
38
|
token?: string;
|
|
@@ -42,10 +46,7 @@ export declare function apiPostFetch(params: {
|
|
|
42
46
|
* On client-side timeout (no server response within timeoutMs), returns an empty response
|
|
43
47
|
* with ret=0 so the caller can simply retry. This is normal for long-poll.
|
|
44
48
|
*/
|
|
45
|
-
export declare function getUpdates(params: GetUpdatesReq & {
|
|
46
|
-
baseUrl: string;
|
|
47
|
-
token?: string;
|
|
48
|
-
timeoutMs?: number;
|
|
49
|
+
export declare function getUpdates(params: GetUpdatesReq & WeixinApiOptions & {
|
|
49
50
|
/**
|
|
50
51
|
* Optional external abort signal (e.g. from the gateway when stopping the
|
|
51
52
|
* channel). When this aborts, the in-flight long-poll is terminated
|
package/lib/ilink-api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { logger } from "./ilink-logger.js";
|
|
3
|
-
import {
|
|
3
|
+
import { ILINK_APP_ID, } from "./ilink-meta.js";
|
|
4
4
|
import { redactBody, redactUrl } from "./ilink-redact.js";
|
|
5
|
-
export {
|
|
5
|
+
export { IlinkClientMetadata, sanitizeBotAgent } from "./ilink-meta.js";
|
|
6
6
|
/** Default timeout for long-poll getUpdates requests. */
|
|
7
7
|
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
8
8
|
/** Default timeout for regular API requests (sendMessage, getUploadUrl). */
|
|
@@ -18,10 +18,10 @@ function randomWechatUin() {
|
|
|
18
18
|
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
19
19
|
}
|
|
20
20
|
/** Build headers shared by both GET and POST requests. */
|
|
21
|
-
function buildCommonHeaders() {
|
|
21
|
+
function buildCommonHeaders(metadata) {
|
|
22
22
|
const headers = {
|
|
23
23
|
"iLink-App-Id": ILINK_APP_ID,
|
|
24
|
-
"iLink-App-ClientVersion": String(
|
|
24
|
+
"iLink-App-ClientVersion": String(metadata.appClientVersion),
|
|
25
25
|
};
|
|
26
26
|
return headers;
|
|
27
27
|
}
|
|
@@ -30,7 +30,7 @@ function buildHeaders(opts) {
|
|
|
30
30
|
"Content-Type": "application/json",
|
|
31
31
|
AuthorizationType: "ilink_bot_token",
|
|
32
32
|
"X-WECHAT-UIN": randomWechatUin(),
|
|
33
|
-
...buildCommonHeaders(),
|
|
33
|
+
...buildCommonHeaders(opts.metadata),
|
|
34
34
|
};
|
|
35
35
|
if (opts.token?.trim()) {
|
|
36
36
|
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
@@ -47,7 +47,7 @@ function buildHeaders(opts) {
|
|
|
47
47
|
export async function apiGetFetch(params) {
|
|
48
48
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
49
49
|
const url = new URL(params.endpoint, base);
|
|
50
|
-
const hdrs = buildCommonHeaders();
|
|
50
|
+
const hdrs = buildCommonHeaders(params.metadata);
|
|
51
51
|
logger.debug(`GET ${redactUrl(url.toString())}`);
|
|
52
52
|
const timeoutMs = params.timeoutMs;
|
|
53
53
|
const controller = timeoutMs != null && timeoutMs > 0 ? new AbortController() : undefined;
|
|
@@ -107,7 +107,7 @@ function combineAbortSignals(internal, external) {
|
|
|
107
107
|
export async function apiPostFetch(params) {
|
|
108
108
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
109
109
|
const url = new URL(params.endpoint, base);
|
|
110
|
-
const hdrs = buildHeaders({ token: params.token });
|
|
110
|
+
const hdrs = buildHeaders({ metadata: params.metadata, token: params.token });
|
|
111
111
|
logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
|
|
112
112
|
const controller = params.timeoutMs !== undefined ? new AbortController() : undefined;
|
|
113
113
|
const t = controller != null && params.timeoutMs !== undefined
|
|
@@ -148,10 +148,11 @@ export async function getUpdates(params) {
|
|
|
148
148
|
try {
|
|
149
149
|
const rawText = await apiPostFetch({
|
|
150
150
|
baseUrl: params.baseUrl,
|
|
151
|
+
metadata: params.metadata,
|
|
151
152
|
endpoint: "ilink/bot/getupdates",
|
|
152
153
|
body: JSON.stringify({
|
|
153
154
|
get_updates_buf: params.get_updates_buf ?? "",
|
|
154
|
-
base_info: buildBaseInfo(),
|
|
155
|
+
base_info: params.metadata.buildBaseInfo(),
|
|
155
156
|
}),
|
|
156
157
|
token: params.token,
|
|
157
158
|
timeoutMs: timeout,
|
|
@@ -183,6 +184,7 @@ export async function getUpdates(params) {
|
|
|
183
184
|
export async function getUploadUrl(params) {
|
|
184
185
|
const rawText = await apiPostFetch({
|
|
185
186
|
baseUrl: params.baseUrl,
|
|
187
|
+
metadata: params.metadata,
|
|
186
188
|
endpoint: "ilink/bot/getuploadurl",
|
|
187
189
|
body: JSON.stringify({
|
|
188
190
|
filekey: params.filekey,
|
|
@@ -196,7 +198,7 @@ export async function getUploadUrl(params) {
|
|
|
196
198
|
thumb_filesize: params.thumb_filesize,
|
|
197
199
|
no_need_thumb: params.no_need_thumb,
|
|
198
200
|
aeskey: params.aeskey,
|
|
199
|
-
base_info: buildBaseInfo(),
|
|
201
|
+
base_info: params.metadata.buildBaseInfo(),
|
|
200
202
|
}),
|
|
201
203
|
token: params.token,
|
|
202
204
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
@@ -209,8 +211,9 @@ export async function getUploadUrl(params) {
|
|
|
209
211
|
export async function sendMessage(params) {
|
|
210
212
|
await apiPostFetch({
|
|
211
213
|
baseUrl: params.baseUrl,
|
|
214
|
+
metadata: params.metadata,
|
|
212
215
|
endpoint: "ilink/bot/sendmessage",
|
|
213
|
-
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
216
|
+
body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
|
|
214
217
|
token: params.token,
|
|
215
218
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
216
219
|
label: "sendMessage",
|
|
@@ -220,11 +223,12 @@ export async function sendMessage(params) {
|
|
|
220
223
|
export async function getConfig(params) {
|
|
221
224
|
const rawText = await apiPostFetch({
|
|
222
225
|
baseUrl: params.baseUrl,
|
|
226
|
+
metadata: params.metadata,
|
|
223
227
|
endpoint: "ilink/bot/getconfig",
|
|
224
228
|
body: JSON.stringify({
|
|
225
229
|
ilink_user_id: params.ilinkUserId,
|
|
226
230
|
context_token: params.contextToken,
|
|
227
|
-
base_info: buildBaseInfo(),
|
|
231
|
+
base_info: params.metadata.buildBaseInfo(),
|
|
228
232
|
}),
|
|
229
233
|
token: params.token,
|
|
230
234
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
@@ -237,8 +241,9 @@ export async function getConfig(params) {
|
|
|
237
241
|
export async function sendTyping(params) {
|
|
238
242
|
await apiPostFetch({
|
|
239
243
|
baseUrl: params.baseUrl,
|
|
244
|
+
metadata: params.metadata,
|
|
240
245
|
endpoint: "ilink/bot/sendtyping",
|
|
241
|
-
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
246
|
+
body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
|
|
242
247
|
token: params.token,
|
|
243
248
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
244
249
|
label: "sendTyping",
|
|
@@ -252,8 +257,9 @@ export async function sendTyping(params) {
|
|
|
252
257
|
export async function notifyStop(params) {
|
|
253
258
|
const rawText = await apiPostFetch({
|
|
254
259
|
baseUrl: params.baseUrl,
|
|
260
|
+
metadata: params.metadata,
|
|
255
261
|
endpoint: "ilink/bot/msg/notifystop",
|
|
256
|
-
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
262
|
+
body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
|
|
257
263
|
token: params.token,
|
|
258
264
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
259
265
|
label: "notifyStop",
|
|
@@ -266,8 +272,9 @@ export async function notifyStop(params) {
|
|
|
266
272
|
export async function notifyStart(params) {
|
|
267
273
|
const rawText = await apiPostFetch({
|
|
268
274
|
baseUrl: params.baseUrl,
|
|
275
|
+
metadata: params.metadata,
|
|
269
276
|
endpoint: "ilink/bot/msg/notifystart",
|
|
270
|
-
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
277
|
+
body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
|
|
271
278
|
token: params.token,
|
|
272
279
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
273
280
|
label: "notifyStart",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WeixinApiOptions } from "./ilink-api.js";
|
|
1
2
|
/** Subset of getConfig fields that we actually need; add new fields here as needed. */
|
|
2
3
|
export interface CachedConfig {
|
|
3
4
|
typingTicket: string;
|
|
@@ -7,12 +8,7 @@ export interface CachedConfig {
|
|
|
7
8
|
* exponential-backoff retry (up to 1h) on failure.
|
|
8
9
|
*/
|
|
9
10
|
export declare class WeixinConfigManager {
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
private cache;
|
|
13
|
-
constructor(apiOpts: {
|
|
14
|
-
baseUrl: string;
|
|
15
|
-
token?: string;
|
|
16
|
-
}, log: (msg: string) => void);
|
|
11
|
+
#private;
|
|
12
|
+
constructor(apiOptions: WeixinApiOptions, log: (msg: string) => void);
|
|
17
13
|
getForUser(userId: string, contextToken?: string): Promise<CachedConfig>;
|
|
18
14
|
}
|
|
@@ -7,39 +7,38 @@ const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
|
|
|
7
7
|
* exponential-backoff retry (up to 1h) on failure.
|
|
8
8
|
*/
|
|
9
9
|
export class WeixinConfigManager {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
constructor(
|
|
14
|
-
this
|
|
15
|
-
this
|
|
10
|
+
#cache = new Map();
|
|
11
|
+
#apiOptions;
|
|
12
|
+
#log;
|
|
13
|
+
constructor(apiOptions, log) {
|
|
14
|
+
this.#apiOptions = apiOptions;
|
|
15
|
+
this.#log = log;
|
|
16
16
|
}
|
|
17
17
|
async getForUser(userId, contextToken) {
|
|
18
18
|
const now = Date.now();
|
|
19
|
-
const entry = this
|
|
19
|
+
const entry = this.#cache.get(userId);
|
|
20
20
|
const shouldFetch = !entry || now >= entry.nextFetchAt;
|
|
21
21
|
if (shouldFetch) {
|
|
22
22
|
let fetchOk = false;
|
|
23
23
|
try {
|
|
24
24
|
const resp = await getConfig({
|
|
25
|
-
|
|
26
|
-
token: this.apiOpts.token,
|
|
25
|
+
...this.#apiOptions,
|
|
27
26
|
ilinkUserId: userId,
|
|
28
27
|
contextToken,
|
|
29
28
|
});
|
|
30
29
|
if (resp.ret === 0) {
|
|
31
|
-
this
|
|
30
|
+
this.#cache.set(userId, {
|
|
32
31
|
config: { typingTicket: resp.typing_ticket ?? "" },
|
|
33
32
|
everSucceeded: true,
|
|
34
33
|
nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
|
|
35
34
|
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
36
35
|
});
|
|
37
|
-
this
|
|
36
|
+
this.#log(`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`);
|
|
38
37
|
fetchOk = true;
|
|
39
38
|
}
|
|
40
39
|
}
|
|
41
40
|
catch (err) {
|
|
42
|
-
this
|
|
41
|
+
this.#log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
|
|
43
42
|
}
|
|
44
43
|
if (!fetchOk) {
|
|
45
44
|
const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
|
|
@@ -49,7 +48,7 @@ export class WeixinConfigManager {
|
|
|
49
48
|
entry.retryDelayMs = nextDelay;
|
|
50
49
|
}
|
|
51
50
|
else {
|
|
52
|
-
this
|
|
51
|
+
this.#cache.set(userId, {
|
|
53
52
|
config: { typingTicket: "" },
|
|
54
53
|
everSucceeded: false,
|
|
55
54
|
nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
@@ -58,6 +57,6 @@ export class WeixinConfigManager {
|
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
}
|
|
61
|
-
return this
|
|
60
|
+
return this.#cache.get(userId)?.config ?? { typingTicket: "" };
|
|
62
61
|
}
|
|
63
62
|
}
|
package/lib/ilink-logger.d.ts
CHANGED
package/lib/ilink-logger.js
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
warn: (msg) => sink.warn(`${prefix} ${msg}`),
|
|
12
|
-
error: (msg) => sink.error(`${prefix} ${msg}`),
|
|
13
|
-
withAccount: () => this,
|
|
14
|
-
};
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
export function setIlinkLogger(next) {
|
|
18
|
-
sink = next;
|
|
1
|
+
function createIlinkLogger(prefix = '[weixin-ilink]') {
|
|
2
|
+
return Object.freeze({
|
|
3
|
+
debug: () => { },
|
|
4
|
+
info: () => { },
|
|
5
|
+
warn: (msg) => console.warn(`${prefix} ${msg}`),
|
|
6
|
+
error: (msg) => console.error(`${prefix} ${msg}`),
|
|
7
|
+
withAccount(accountId) {
|
|
8
|
+
return createIlinkLogger(`[weixin-ilink:${accountId}]`);
|
|
9
|
+
},
|
|
10
|
+
});
|
|
19
11
|
}
|
|
20
|
-
export const logger =
|
|
21
|
-
debug: (msg) => sink.debug(msg),
|
|
22
|
-
info: (msg) => sink.info(msg),
|
|
23
|
-
warn: (msg) => sink.warn(msg),
|
|
24
|
-
error: (msg) => sink.error(msg),
|
|
25
|
-
withAccount: (accountId) => sink.withAccount(accountId),
|
|
26
|
-
};
|
|
12
|
+
export const logger = createIlinkLogger();
|
package/lib/ilink-meta.d.ts
CHANGED
|
@@ -2,11 +2,17 @@ import type { BaseInfo } from "./ilink-types.js";
|
|
|
2
2
|
export declare const ILINK_APP_ID = "bot";
|
|
3
3
|
export declare const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
4
4
|
export declare const DEFAULT_API_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
5
|
-
export declare function configureIlinkMeta(opts: {
|
|
6
|
-
botAgent?: string;
|
|
7
|
-
version?: string;
|
|
8
|
-
}): void;
|
|
9
5
|
export declare function buildClientVersion(version: string): number;
|
|
10
|
-
export declare
|
|
11
|
-
export
|
|
12
|
-
|
|
6
|
+
export declare function sanitizeBotAgent(raw: string | undefined, fallback?: string): string;
|
|
7
|
+
export interface IlinkClientMetadataOptions {
|
|
8
|
+
readonly botAgent?: string;
|
|
9
|
+
readonly version?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Immutable request identity owned by one endpoint. */
|
|
12
|
+
export declare class IlinkClientMetadata {
|
|
13
|
+
readonly channelVersion: string;
|
|
14
|
+
readonly botAgent: string;
|
|
15
|
+
readonly appClientVersion: number;
|
|
16
|
+
constructor(options?: IlinkClientMetadataOptions);
|
|
17
|
+
buildBaseInfo(): BaseInfo;
|
|
18
|
+
}
|
package/lib/ilink-meta.js
CHANGED
|
@@ -6,8 +6,6 @@ export const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
|
6
6
|
export const DEFAULT_API_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
7
7
|
const DEFAULT_BOT_AGENT = "Zhin.js";
|
|
8
8
|
const BOT_AGENT_MAX_LEN = 256;
|
|
9
|
-
let channelVersion = "0.0.0";
|
|
10
|
-
let configuredBotAgent;
|
|
11
9
|
function readPackageVersion() {
|
|
12
10
|
try {
|
|
13
11
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
@@ -18,13 +16,7 @@ function readPackageVersion() {
|
|
|
18
16
|
return "0.0.0";
|
|
19
17
|
}
|
|
20
18
|
}
|
|
21
|
-
|
|
22
|
-
export function configureIlinkMeta(opts) {
|
|
23
|
-
if (opts.botAgent)
|
|
24
|
-
configuredBotAgent = opts.botAgent;
|
|
25
|
-
if (opts.version)
|
|
26
|
-
channelVersion = opts.version;
|
|
27
|
-
}
|
|
19
|
+
const PACKAGE_VERSION = readPackageVersion();
|
|
28
20
|
export function buildClientVersion(version) {
|
|
29
21
|
const parts = version.split(".").map((p) => parseInt(p, 10));
|
|
30
22
|
const major = parts[0] ?? 0;
|
|
@@ -32,25 +24,37 @@ export function buildClientVersion(version) {
|
|
|
32
24
|
const patch = parts[2] ?? 0;
|
|
33
25
|
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
34
26
|
}
|
|
35
|
-
export
|
|
36
|
-
export function sanitizeBotAgent(raw) {
|
|
27
|
+
export function sanitizeBotAgent(raw, fallback = DEFAULT_BOT_AGENT) {
|
|
37
28
|
if (!raw || typeof raw !== "string")
|
|
38
|
-
return
|
|
29
|
+
return fallback;
|
|
39
30
|
const trimmed = raw.trim();
|
|
40
31
|
if (!trimmed)
|
|
41
|
-
return
|
|
32
|
+
return fallback;
|
|
42
33
|
const productRe = /^[A-Za-z0-9_.\-]{1,32}\/[A-Za-z0-9_.+\-]{1,32}$/;
|
|
43
34
|
const tokens = trimmed.split(/\s+/).filter((tok) => productRe.test(tok));
|
|
44
35
|
if (tokens.length === 0)
|
|
45
|
-
return
|
|
36
|
+
return fallback;
|
|
46
37
|
const joined = tokens.join(" ");
|
|
47
38
|
if (Buffer.byteLength(joined, "utf-8") <= BOT_AGENT_MAX_LEN)
|
|
48
39
|
return joined;
|
|
49
|
-
return
|
|
40
|
+
return fallback;
|
|
50
41
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
/** Immutable request identity owned by one endpoint. */
|
|
43
|
+
export class IlinkClientMetadata {
|
|
44
|
+
channelVersion;
|
|
45
|
+
botAgent;
|
|
46
|
+
appClientVersion;
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.channelVersion = options.version ?? PACKAGE_VERSION;
|
|
49
|
+
const defaultBotAgent = `Zhin.js/${this.channelVersion}`;
|
|
50
|
+
this.botAgent = sanitizeBotAgent(options.botAgent, defaultBotAgent);
|
|
51
|
+
this.appClientVersion = buildClientVersion(this.channelVersion);
|
|
52
|
+
Object.freeze(this);
|
|
53
|
+
}
|
|
54
|
+
buildBaseInfo() {
|
|
55
|
+
return {
|
|
56
|
+
channel_version: this.channelVersion,
|
|
57
|
+
bot_agent: this.botAgent,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
56
60
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
/** Error code returned by the server when the bot session has expired. */
|
|
2
1
|
export declare const SESSION_EXPIRED_ERRCODE = -14;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
2
|
+
export declare const SESSION_PAUSE_DURATION_MS: number;
|
|
3
|
+
/** Endpoint-owned cooldown state for an authenticated iLink session. */
|
|
4
|
+
export declare class IlinkSessionGuard {
|
|
5
|
+
#private;
|
|
6
|
+
readonly accountId: string;
|
|
7
|
+
private readonly now;
|
|
8
|
+
private readonly pauseDurationMs;
|
|
9
|
+
constructor(accountId: string, now?: () => number, pauseDurationMs?: number);
|
|
10
|
+
pause(): void;
|
|
11
|
+
get paused(): boolean;
|
|
12
|
+
get remainingMs(): number;
|
|
13
|
+
assertActive(): void;
|
|
14
|
+
}
|