@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/src/credentials.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
export interface WeixinIlinkCredentials {
|
|
5
5
|
botToken: string;
|
|
@@ -9,56 +9,79 @@ export interface WeixinIlinkCredentials {
|
|
|
9
9
|
updatedAt?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** Endpoint-owned persistence for credentials, cursors, context tokens, and media. */
|
|
13
|
+
export class WeixinIlinkStateStore {
|
|
14
|
+
readonly endpointId: string;
|
|
15
|
+
readonly rootDir: string;
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
constructor(
|
|
18
|
+
endpointId: string,
|
|
19
|
+
rootDir: string,
|
|
20
|
+
) {
|
|
21
|
+
if (!/^[A-Za-z0-9_.-]+$/u.test(endpointId)) {
|
|
22
|
+
throw new TypeError('Weixin iLink state endpoint id is invalid');
|
|
23
|
+
}
|
|
24
|
+
if (!rootDir.trim()) throw new TypeError('Weixin iLink state root is required');
|
|
25
|
+
this.endpointId = endpointId;
|
|
26
|
+
this.rootDir = rootDir;
|
|
27
|
+
}
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
29
|
+
endpointDirectory(): string {
|
|
30
|
+
return path.join(this.rootDir, this.endpointId);
|
|
31
|
+
}
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
if (!fs.existsSync(file)) return null;
|
|
28
|
-
const raw = fs.readFileSync(file, "utf-8");
|
|
29
|
-
const data = JSON.parse(raw) as WeixinIlinkCredentials;
|
|
30
|
-
if (!data.botToken?.trim()) return null;
|
|
31
|
-
return data;
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
33
|
+
credentialsPath(): string {
|
|
34
|
+
return path.join(this.endpointDirectory(), 'credentials.json');
|
|
34
35
|
}
|
|
35
|
-
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
37
|
+
loadCredentials(): WeixinIlinkCredentials | null {
|
|
38
|
+
const file = this.credentialsPath();
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(file)) return null;
|
|
41
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as WeixinIlinkCredentials;
|
|
42
|
+
return data.botToken?.trim() ? data : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
48
|
+
saveCredentials(credentials: WeixinIlinkCredentials): void {
|
|
49
|
+
const file = this.credentialsPath();
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
file,
|
|
53
|
+
JSON.stringify({ ...credentials, updatedAt: new Date().toISOString() }, null, 2),
|
|
54
|
+
'utf-8',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
syncBufPath(): string {
|
|
59
|
+
return path.join(this.endpointDirectory(), 'sync-buf');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
loadSyncBuf(): string {
|
|
63
|
+
const file = this.syncBufPath();
|
|
64
|
+
try {
|
|
65
|
+
return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
|
66
|
+
} catch {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
saveSyncBuf(value: string): void {
|
|
72
|
+
const file = this.syncBufPath();
|
|
73
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
74
|
+
fs.writeFileSync(file, value, 'utf-8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
contextTokensPath(): string {
|
|
78
|
+
return path.join(this.endpointDirectory(), 'context-tokens.json');
|
|
57
79
|
}
|
|
58
|
-
}
|
|
59
80
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
mediaDirectory(subdir: string): string {
|
|
82
|
+
if (subdir !== 'inbound' && subdir !== 'outbound') {
|
|
83
|
+
throw new TypeError(`Unsupported Weixin iLink media directory: ${subdir}`);
|
|
84
|
+
}
|
|
85
|
+
return path.join(this.endpointDirectory(), 'media', subdir);
|
|
86
|
+
}
|
|
64
87
|
}
|
package/src/endpoint.ts
CHANGED
|
@@ -1,38 +1,31 @@
|
|
|
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';
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
import {
|
|
8
|
+
Endpoint,
|
|
9
|
+
type EndpointFriend,
|
|
10
|
+
type EndpointControl,
|
|
11
|
+
type EndpointManagement,
|
|
12
|
+
type EndpointSendRequest,
|
|
13
13
|
} from 'zhin.js/adapter';
|
|
14
14
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
15
15
|
import type { CapabilityId } from 'zhin.js';
|
|
16
16
|
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
17
|
-
import {
|
|
17
|
+
import { IlinkClientMetadata } from './ilink-meta.js';
|
|
18
18
|
import {
|
|
19
|
-
loadSyncBuf,
|
|
20
|
-
resolveStateDir,
|
|
21
|
-
saveSyncBuf,
|
|
22
19
|
type WeixinIlinkCredentials,
|
|
20
|
+
WeixinIlinkStateStore,
|
|
23
21
|
} from './credentials.js';
|
|
24
22
|
import { resolveCredentials } from './login.js';
|
|
25
23
|
import {
|
|
26
|
-
|
|
27
|
-
restoreContextTokens,
|
|
28
|
-
setContextToken,
|
|
29
|
-
flushContextTokenPersist,
|
|
24
|
+
WeixinContextTokenStore,
|
|
30
25
|
} from './context-store.js';
|
|
31
26
|
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
32
27
|
import {
|
|
33
|
-
|
|
34
|
-
isSessionPaused,
|
|
35
|
-
pauseSession,
|
|
28
|
+
IlinkSessionGuard,
|
|
36
29
|
SESSION_EXPIRED_ERRCODE,
|
|
37
30
|
} from './ilink-session-guard.js';
|
|
38
31
|
import { downloadMediaFromItem } from './media-download.js';
|
|
@@ -74,6 +67,8 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
74
67
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
75
68
|
readonly resolveCredentials?: (
|
|
76
69
|
config: ResolvedWeixinIlinkConfig,
|
|
70
|
+
metadata: IlinkClientMetadata,
|
|
71
|
+
state: WeixinIlinkStateStore,
|
|
77
72
|
signal?: AbortSignal,
|
|
78
73
|
) => Promise<WeixinIlinkCredentials>;
|
|
79
74
|
/** Test / internal: override network side effects. */
|
|
@@ -81,6 +76,9 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
81
76
|
readonly notifyStop?: WeixinIlinkNotifyStop;
|
|
82
77
|
readonly getUpdates?: WeixinIlinkGetUpdates;
|
|
83
78
|
readonly sendText?: WeixinIlinkSendText;
|
|
79
|
+
readonly state?: WeixinIlinkStateStore;
|
|
80
|
+
readonly contextTokens?: WeixinContextTokenStore;
|
|
81
|
+
readonly sessionGuard?: IlinkSessionGuard;
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
@@ -90,12 +88,18 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
90
88
|
readonly #options: WeixinIlinkEndpointOptions;
|
|
91
89
|
readonly #resolveCredentials: (
|
|
92
90
|
config: ResolvedWeixinIlinkConfig,
|
|
91
|
+
metadata: IlinkClientMetadata,
|
|
92
|
+
state: WeixinIlinkStateStore,
|
|
93
93
|
signal?: AbortSignal,
|
|
94
94
|
) => Promise<WeixinIlinkCredentials>;
|
|
95
95
|
readonly #notifyStart: WeixinIlinkNotifyStart;
|
|
96
96
|
readonly #notifyStop: WeixinIlinkNotifyStop;
|
|
97
97
|
readonly #getUpdates: WeixinIlinkGetUpdates;
|
|
98
98
|
readonly #sendText: WeixinIlinkSendText;
|
|
99
|
+
readonly #state: WeixinIlinkStateStore;
|
|
100
|
+
readonly #contextTokens: WeixinContextTokenStore;
|
|
101
|
+
readonly #sessionGuard: IlinkSessionGuard;
|
|
102
|
+
readonly #metadata: IlinkClientMetadata;
|
|
99
103
|
#creds: WeixinIlinkCredentials | null = null;
|
|
100
104
|
#pollAbort?: AbortController;
|
|
101
105
|
#pollPromise?: Promise<void>;
|
|
@@ -123,11 +127,18 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
123
127
|
this.#notifyStop = options.notifyStop ?? notifyStop;
|
|
124
128
|
this.#getUpdates = options.getUpdates ?? getUpdates;
|
|
125
129
|
this.#sendText = options.sendText ?? sendMessageWeixin;
|
|
130
|
+
this.#state = options.state
|
|
131
|
+
?? new WeixinIlinkStateStore(options.config.id, options.config.dataDir);
|
|
132
|
+
this.#contextTokens = options.contextTokens ?? new WeixinContextTokenStore(this.#state);
|
|
133
|
+
this.#sessionGuard = options.sessionGuard ?? new IlinkSessionGuard(options.config.id);
|
|
134
|
+
this.#metadata = new IlinkClientMetadata({ botAgent: options.config.botAgent });
|
|
126
135
|
this.client = new WeixinIlinkClient(
|
|
127
136
|
options.config,
|
|
137
|
+
this.#metadata,
|
|
128
138
|
() => this.#creds,
|
|
129
139
|
() => this.#configManager,
|
|
130
140
|
this.#sendText,
|
|
141
|
+
this.#contextTokens,
|
|
131
142
|
);
|
|
132
143
|
}
|
|
133
144
|
|
|
@@ -139,19 +150,28 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
139
150
|
if (this.#started) return;
|
|
140
151
|
this.#started = true;
|
|
141
152
|
try {
|
|
142
|
-
configureIlinkMeta({ botAgent: this.#options.config.botAgent });
|
|
143
153
|
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
144
154
|
this.#loginAbort = new AbortController();
|
|
145
|
-
this.#creds = await this.#resolveCredentials(
|
|
146
|
-
|
|
155
|
+
this.#creds = await this.#resolveCredentials(
|
|
156
|
+
this.#options.config,
|
|
157
|
+
this.#metadata,
|
|
158
|
+
this.#state,
|
|
159
|
+
this.#loginAbort.signal,
|
|
160
|
+
);
|
|
161
|
+
this.#contextTokens.restore();
|
|
147
162
|
|
|
148
163
|
await this.#notifyStart({
|
|
149
164
|
baseUrl: this.client.apiBaseUrl,
|
|
165
|
+
metadata: this.#metadata,
|
|
150
166
|
token: this.#creds.botToken,
|
|
151
167
|
});
|
|
152
168
|
|
|
153
169
|
this.#configManager = new WeixinConfigManager(
|
|
154
|
-
{
|
|
170
|
+
{
|
|
171
|
+
baseUrl: this.client.apiBaseUrl,
|
|
172
|
+
metadata: this.#metadata,
|
|
173
|
+
token: this.#creds.botToken,
|
|
174
|
+
},
|
|
155
175
|
(msg) => this.#logger.debug(msg),
|
|
156
176
|
);
|
|
157
177
|
|
|
@@ -191,10 +211,14 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
191
211
|
}
|
|
192
212
|
this.#stopMediaSweep();
|
|
193
213
|
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
194
|
-
|
|
214
|
+
this.#contextTokens.flush();
|
|
195
215
|
if (this.#creds?.botToken) {
|
|
196
216
|
try {
|
|
197
|
-
await this.#notifyStop({
|
|
217
|
+
await this.#notifyStop({
|
|
218
|
+
baseUrl: this.client.apiBaseUrl,
|
|
219
|
+
metadata: this.#metadata,
|
|
220
|
+
token: this.#creds.botToken,
|
|
221
|
+
});
|
|
198
222
|
} catch (err) {
|
|
199
223
|
this.#logger.warn(formatCompact({
|
|
200
224
|
op: 'notify_stop',
|
|
@@ -229,7 +253,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
229
253
|
throw new Error(`missing context_token for peer ${target}`);
|
|
230
254
|
}
|
|
231
255
|
|
|
232
|
-
const outboundDir =
|
|
256
|
+
const outboundDir = this.#state.mediaDirectory('outbound');
|
|
233
257
|
const wire = formatOutboundSegments(payload);
|
|
234
258
|
const materialized = await materializeOutboundMedia(wire, outboundDir);
|
|
235
259
|
const segments = Array.isArray(materialized) ? materialized : [materialized];
|
|
@@ -283,7 +307,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
283
307
|
if (!this.#open) return;
|
|
284
308
|
const userId = msg.from_user_id ?? '';
|
|
285
309
|
if (msg.context_token && userId) {
|
|
286
|
-
|
|
310
|
+
this.#contextTokens.set(userId, msg.context_token);
|
|
287
311
|
}
|
|
288
312
|
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
289
313
|
const replyTo = weixinReplyTo(msg);
|
|
@@ -311,20 +335,20 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
311
335
|
async #pollLoop(abortSignal: AbortSignal): Promise<void> {
|
|
312
336
|
if (!this.#creds?.botToken) return;
|
|
313
337
|
|
|
314
|
-
|
|
315
|
-
let getUpdatesBuf = loadSyncBuf(endpointId);
|
|
338
|
+
let getUpdatesBuf = this.#state.loadSyncBuf();
|
|
316
339
|
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
317
340
|
let consecutiveFailures = 0;
|
|
318
341
|
|
|
319
342
|
while (!abortSignal.aborted) {
|
|
320
|
-
if (
|
|
321
|
-
await sleep(
|
|
343
|
+
if (this.#sessionGuard.paused) {
|
|
344
|
+
await sleep(this.#sessionGuard.remainingMs, abortSignal);
|
|
322
345
|
continue;
|
|
323
346
|
}
|
|
324
347
|
|
|
325
348
|
try {
|
|
326
349
|
const resp = await this.#getUpdates({
|
|
327
350
|
baseUrl: this.client.apiBaseUrl,
|
|
351
|
+
metadata: this.#metadata,
|
|
328
352
|
token: this.#creds.botToken,
|
|
329
353
|
get_updates_buf: getUpdatesBuf,
|
|
330
354
|
timeoutMs: nextTimeoutMs,
|
|
@@ -343,9 +367,9 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
343
367
|
const sessionExpired =
|
|
344
368
|
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
345
369
|
if (sessionExpired) {
|
|
346
|
-
|
|
370
|
+
this.#sessionGuard.pause();
|
|
347
371
|
consecutiveFailures = 0;
|
|
348
|
-
await sleep(
|
|
372
|
+
await sleep(this.#sessionGuard.remainingMs, abortSignal);
|
|
349
373
|
continue;
|
|
350
374
|
}
|
|
351
375
|
consecutiveFailures += 1;
|
|
@@ -366,7 +390,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
366
390
|
}
|
|
367
391
|
if (nextBuf) {
|
|
368
392
|
getUpdatesBuf = nextBuf;
|
|
369
|
-
saveSyncBuf(
|
|
393
|
+
this.#state.saveSyncBuf(getUpdatesBuf);
|
|
370
394
|
}
|
|
371
395
|
} catch (err) {
|
|
372
396
|
if (abortSignal.aborted) return;
|
|
@@ -388,9 +412,6 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
388
412
|
async #handleInboundMessage(full: WeixinMessage): Promise<void> {
|
|
389
413
|
void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
|
|
390
414
|
const fromUserId = full.from_user_id ?? '';
|
|
391
|
-
if (full.context_token && fromUserId) {
|
|
392
|
-
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
393
|
-
}
|
|
394
415
|
|
|
395
416
|
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
396
417
|
this.admit({ ...full, _media: mediaOpts });
|
|
@@ -452,7 +473,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
452
473
|
if (buffer.length > maxBytes) {
|
|
453
474
|
throw new Error(`media exceeds max size ${maxBytes}`);
|
|
454
475
|
}
|
|
455
|
-
const dir =
|
|
476
|
+
const dir = this.#state.mediaDirectory(subdir);
|
|
456
477
|
fs.mkdirSync(dir, { recursive: true });
|
|
457
478
|
const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
|
|
458
479
|
const ext = originalFilename
|
|
@@ -486,7 +507,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
|
|
|
486
507
|
}
|
|
487
508
|
|
|
488
509
|
#sweepInboundMedia(): void {
|
|
489
|
-
const dir =
|
|
510
|
+
const dir = this.#state.mediaDirectory('inbound');
|
|
490
511
|
let entries: fs.Dirent[];
|
|
491
512
|
try {
|
|
492
513
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -521,7 +542,7 @@ function createWeixinIlinkEndpointManagement(
|
|
|
521
542
|
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
522
543
|
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
523
544
|
async listFriends(): Promise<readonly EndpointFriend[]> {
|
|
524
|
-
return
|
|
545
|
+
return requireClient().reachableUserIds().map((userId) => ({
|
|
525
546
|
user_id: userId,
|
|
526
547
|
nickname: userId,
|
|
527
548
|
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
package/src/ilink-api.ts
CHANGED
|
@@ -2,8 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { logger } from "./ilink-logger.js";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
ILINK_APP_CLIENT_VERSION,
|
|
5
|
+
type IlinkClientMetadata,
|
|
7
6
|
ILINK_APP_ID,
|
|
8
7
|
} from "./ilink-meta.js";
|
|
9
8
|
import { redactBody, redactUrl } from "./ilink-redact.js";
|
|
@@ -20,10 +19,11 @@ import type {
|
|
|
20
19
|
GetConfigResp,
|
|
21
20
|
} from "./ilink-types.js";
|
|
22
21
|
|
|
23
|
-
export {
|
|
22
|
+
export { IlinkClientMetadata, sanitizeBotAgent } from "./ilink-meta.js";
|
|
24
23
|
|
|
25
24
|
export type WeixinApiOptions = {
|
|
26
25
|
baseUrl: string;
|
|
26
|
+
metadata: IlinkClientMetadata;
|
|
27
27
|
token?: string;
|
|
28
28
|
timeoutMs?: number;
|
|
29
29
|
/** Long-poll timeout for getUpdates (server may hold the request up to this). */
|
|
@@ -48,20 +48,20 @@ function randomWechatUin(): string {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Build headers shared by both GET and POST requests. */
|
|
51
|
-
function buildCommonHeaders(): Record<string, string> {
|
|
51
|
+
function buildCommonHeaders(metadata: IlinkClientMetadata): Record<string, string> {
|
|
52
52
|
const headers: Record<string, string> = {
|
|
53
53
|
"iLink-App-Id": ILINK_APP_ID,
|
|
54
|
-
"iLink-App-ClientVersion": String(
|
|
54
|
+
"iLink-App-ClientVersion": String(metadata.appClientVersion),
|
|
55
55
|
};
|
|
56
56
|
return headers;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function buildHeaders(opts: { token?: string }): Record<string, string> {
|
|
59
|
+
function buildHeaders(opts: { metadata: IlinkClientMetadata; token?: string }): Record<string, string> {
|
|
60
60
|
const headers: Record<string, string> = {
|
|
61
61
|
"Content-Type": "application/json",
|
|
62
62
|
AuthorizationType: "ilink_bot_token",
|
|
63
63
|
"X-WECHAT-UIN": randomWechatUin(),
|
|
64
|
-
...buildCommonHeaders(),
|
|
64
|
+
...buildCommonHeaders(opts.metadata),
|
|
65
65
|
};
|
|
66
66
|
if (opts.token?.trim()) {
|
|
67
67
|
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
@@ -80,6 +80,7 @@ function buildHeaders(opts: { token?: string }): Record<string, string> {
|
|
|
80
80
|
*/
|
|
81
81
|
export async function apiGetFetch(params: {
|
|
82
82
|
baseUrl: string;
|
|
83
|
+
metadata: IlinkClientMetadata;
|
|
83
84
|
endpoint: string;
|
|
84
85
|
timeoutMs?: number;
|
|
85
86
|
label: string;
|
|
@@ -87,7 +88,7 @@ export async function apiGetFetch(params: {
|
|
|
87
88
|
}): Promise<string> {
|
|
88
89
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
89
90
|
const url = new URL(params.endpoint, base);
|
|
90
|
-
const hdrs = buildCommonHeaders();
|
|
91
|
+
const hdrs = buildCommonHeaders(params.metadata);
|
|
91
92
|
logger.debug(`GET ${redactUrl(url.toString())}`);
|
|
92
93
|
|
|
93
94
|
const timeoutMs = params.timeoutMs;
|
|
@@ -149,6 +150,7 @@ function combineAbortSignals(
|
|
|
149
150
|
*/
|
|
150
151
|
export async function apiPostFetch(params: {
|
|
151
152
|
baseUrl: string;
|
|
153
|
+
metadata: IlinkClientMetadata;
|
|
152
154
|
endpoint: string;
|
|
153
155
|
body: string;
|
|
154
156
|
token?: string;
|
|
@@ -158,7 +160,7 @@ export async function apiPostFetch(params: {
|
|
|
158
160
|
}): Promise<string> {
|
|
159
161
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
160
162
|
const url = new URL(params.endpoint, base);
|
|
161
|
-
const hdrs = buildHeaders({ token: params.token });
|
|
163
|
+
const hdrs = buildHeaders({ metadata: params.metadata, token: params.token });
|
|
162
164
|
logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
|
|
163
165
|
|
|
164
166
|
const controller =
|
|
@@ -196,10 +198,7 @@ export async function apiPostFetch(params: {
|
|
|
196
198
|
* with ret=0 so the caller can simply retry. This is normal for long-poll.
|
|
197
199
|
*/
|
|
198
200
|
export async function getUpdates(
|
|
199
|
-
params: GetUpdatesReq & {
|
|
200
|
-
baseUrl: string;
|
|
201
|
-
token?: string;
|
|
202
|
-
timeoutMs?: number;
|
|
201
|
+
params: GetUpdatesReq & WeixinApiOptions & {
|
|
203
202
|
/**
|
|
204
203
|
* Optional external abort signal (e.g. from the gateway when stopping the
|
|
205
204
|
* channel). When this aborts, the in-flight long-poll is terminated
|
|
@@ -213,10 +212,11 @@ export async function getUpdates(
|
|
|
213
212
|
try {
|
|
214
213
|
const rawText = await apiPostFetch({
|
|
215
214
|
baseUrl: params.baseUrl,
|
|
215
|
+
metadata: params.metadata,
|
|
216
216
|
endpoint: "ilink/bot/getupdates",
|
|
217
217
|
body: JSON.stringify({
|
|
218
218
|
get_updates_buf: params.get_updates_buf ?? "",
|
|
219
|
-
base_info: buildBaseInfo(),
|
|
219
|
+
base_info: params.metadata.buildBaseInfo(),
|
|
220
220
|
}),
|
|
221
221
|
token: params.token,
|
|
222
222
|
timeoutMs: timeout,
|
|
@@ -249,6 +249,7 @@ export async function getUploadUrl(
|
|
|
249
249
|
): Promise<GetUploadUrlResp> {
|
|
250
250
|
const rawText = await apiPostFetch({
|
|
251
251
|
baseUrl: params.baseUrl,
|
|
252
|
+
metadata: params.metadata,
|
|
252
253
|
endpoint: "ilink/bot/getuploadurl",
|
|
253
254
|
body: JSON.stringify({
|
|
254
255
|
filekey: params.filekey,
|
|
@@ -262,7 +263,7 @@ export async function getUploadUrl(
|
|
|
262
263
|
thumb_filesize: params.thumb_filesize,
|
|
263
264
|
no_need_thumb: params.no_need_thumb,
|
|
264
265
|
aeskey: params.aeskey,
|
|
265
|
-
base_info: buildBaseInfo(),
|
|
266
|
+
base_info: params.metadata.buildBaseInfo(),
|
|
266
267
|
}),
|
|
267
268
|
token: params.token,
|
|
268
269
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
@@ -278,8 +279,9 @@ export async function sendMessage(
|
|
|
278
279
|
): Promise<void> {
|
|
279
280
|
await apiPostFetch({
|
|
280
281
|
baseUrl: params.baseUrl,
|
|
282
|
+
metadata: params.metadata,
|
|
281
283
|
endpoint: "ilink/bot/sendmessage",
|
|
282
|
-
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
284
|
+
body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
|
|
283
285
|
token: params.token,
|
|
284
286
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
285
287
|
label: "sendMessage",
|
|
@@ -292,11 +294,12 @@ export async function getConfig(
|
|
|
292
294
|
): Promise<GetConfigResp> {
|
|
293
295
|
const rawText = await apiPostFetch({
|
|
294
296
|
baseUrl: params.baseUrl,
|
|
297
|
+
metadata: params.metadata,
|
|
295
298
|
endpoint: "ilink/bot/getconfig",
|
|
296
299
|
body: JSON.stringify({
|
|
297
300
|
ilink_user_id: params.ilinkUserId,
|
|
298
301
|
context_token: params.contextToken,
|
|
299
|
-
base_info: buildBaseInfo(),
|
|
302
|
+
base_info: params.metadata.buildBaseInfo(),
|
|
300
303
|
}),
|
|
301
304
|
token: params.token,
|
|
302
305
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
@@ -312,8 +315,9 @@ export async function sendTyping(
|
|
|
312
315
|
): Promise<void> {
|
|
313
316
|
await apiPostFetch({
|
|
314
317
|
baseUrl: params.baseUrl,
|
|
318
|
+
metadata: params.metadata,
|
|
315
319
|
endpoint: "ilink/bot/sendtyping",
|
|
316
|
-
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
320
|
+
body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
|
|
317
321
|
token: params.token,
|
|
318
322
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
319
323
|
label: "sendTyping",
|
|
@@ -328,8 +332,9 @@ export async function sendTyping(
|
|
|
328
332
|
export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopResp> {
|
|
329
333
|
const rawText = await apiPostFetch({
|
|
330
334
|
baseUrl: params.baseUrl,
|
|
335
|
+
metadata: params.metadata,
|
|
331
336
|
endpoint: "ilink/bot/msg/notifystop",
|
|
332
|
-
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
337
|
+
body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
|
|
333
338
|
token: params.token,
|
|
334
339
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
335
340
|
label: "notifyStop",
|
|
@@ -343,8 +348,9 @@ export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopRe
|
|
|
343
348
|
export async function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp> {
|
|
344
349
|
const rawText = await apiPostFetch({
|
|
345
350
|
baseUrl: params.baseUrl,
|
|
351
|
+
metadata: params.metadata,
|
|
346
352
|
endpoint: "ilink/bot/msg/notifystart",
|
|
347
|
-
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
353
|
+
body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
|
|
348
354
|
token: params.token,
|
|
349
355
|
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
350
356
|
label: "notifyStart",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getConfig } from "./ilink-api.js";
|
|
1
|
+
import { getConfig, type WeixinApiOptions } from "./ilink-api.js";
|
|
2
2
|
|
|
3
3
|
/** Subset of getConfig fields that we actually need; add new fields here as needed. */
|
|
4
4
|
export interface CachedConfig {
|
|
@@ -21,41 +21,45 @@ interface ConfigCacheEntry {
|
|
|
21
21
|
* exponential-backoff retry (up to 1h) on failure.
|
|
22
22
|
*/
|
|
23
23
|
export class WeixinConfigManager {
|
|
24
|
-
|
|
24
|
+
readonly #cache = new Map<string, ConfigCacheEntry>();
|
|
25
|
+
readonly #apiOptions: WeixinApiOptions;
|
|
26
|
+
readonly #log: (msg: string) => void;
|
|
25
27
|
|
|
26
28
|
constructor(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
) {
|
|
29
|
+
apiOptions: WeixinApiOptions,
|
|
30
|
+
log: (msg: string) => void,
|
|
31
|
+
) {
|
|
32
|
+
this.#apiOptions = apiOptions;
|
|
33
|
+
this.#log = log;
|
|
34
|
+
}
|
|
30
35
|
|
|
31
36
|
async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
|
|
32
37
|
const now = Date.now();
|
|
33
|
-
const entry = this
|
|
38
|
+
const entry = this.#cache.get(userId);
|
|
34
39
|
const shouldFetch = !entry || now >= entry.nextFetchAt;
|
|
35
40
|
|
|
36
41
|
if (shouldFetch) {
|
|
37
42
|
let fetchOk = false;
|
|
38
43
|
try {
|
|
39
44
|
const resp = await getConfig({
|
|
40
|
-
|
|
41
|
-
token: this.apiOpts.token,
|
|
45
|
+
...this.#apiOptions,
|
|
42
46
|
ilinkUserId: userId,
|
|
43
47
|
contextToken,
|
|
44
48
|
});
|
|
45
49
|
if (resp.ret === 0) {
|
|
46
|
-
this
|
|
50
|
+
this.#cache.set(userId, {
|
|
47
51
|
config: { typingTicket: resp.typing_ticket ?? "" },
|
|
48
52
|
everSucceeded: true,
|
|
49
53
|
nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
|
|
50
54
|
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
51
55
|
});
|
|
52
|
-
this
|
|
56
|
+
this.#log(
|
|
53
57
|
`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`,
|
|
54
58
|
);
|
|
55
59
|
fetchOk = true;
|
|
56
60
|
}
|
|
57
61
|
} catch (err) {
|
|
58
|
-
this
|
|
62
|
+
this.#log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
|
|
59
63
|
}
|
|
60
64
|
if (!fetchOk) {
|
|
61
65
|
const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
|
|
@@ -64,7 +68,7 @@ export class WeixinConfigManager {
|
|
|
64
68
|
entry.nextFetchAt = now + nextDelay;
|
|
65
69
|
entry.retryDelayMs = nextDelay;
|
|
66
70
|
} else {
|
|
67
|
-
this
|
|
71
|
+
this.#cache.set(userId, {
|
|
68
72
|
config: { typingTicket: "" },
|
|
69
73
|
everSucceeded: false,
|
|
70
74
|
nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
@@ -74,6 +78,6 @@ export class WeixinConfigManager {
|
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
return this
|
|
81
|
+
return this.#cache.get(userId)?.config ?? { typingTicket: "" };
|
|
78
82
|
}
|
|
79
83
|
}
|