@zhin.js/adapter-weixin-ilink 4.0.0 → 4.0.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/CHANGELOG.md +33 -0
- package/adapters/weixin-ilink.ts +13 -1
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/context-store.d.ts +1 -1
- package/lib/context-store.js +1 -1
- package/lib/endpoint.d.ts +5 -2
- package/lib/endpoint.js +84 -8
- package/lib/ilink-api.d.ts +1 -0
- package/lib/ilink-api.js +5 -1
- package/lib/login.d.ts +2 -2
- package/lib/login.js +33 -6
- package/lib/weixin-ilink-endpoint-commands.d.ts +1 -0
- package/lib/weixin-ilink-endpoint-commands.js +16 -0
- package/lib/weixin-ilink-runtime-state.d.ts +1 -0
- package/lib/weixin-ilink-runtime-state.js +6 -0
- package/lib/weixin-inbound.d.ts +9 -1
- package/lib/weixin-inbound.js +44 -2
- package/package.json +14 -8
- package/plugin.ts +6 -0
- package/src/context-store.ts +2 -0
- package/src/endpoint.ts +89 -9
- package/src/ilink-api.ts +5 -1
- package/src/login.ts +33 -3
- package/src/weixin-ilink-endpoint-commands.ts +17 -0
- package/src/weixin-ilink-runtime-state.ts +7 -0
- package/src/weixin-inbound.ts +45 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @zhin.js/adapter-weixin-ilink
|
|
2
2
|
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
|
|
8
|
+
|
|
9
|
+
- **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
|
|
10
|
+
- **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
|
|
11
|
+
- **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
|
|
12
|
+
- **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
|
|
13
|
+
- **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
|
|
14
|
+
- **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
|
|
15
|
+
|
|
16
|
+
- 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
|
|
17
|
+
- 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
|
|
18
|
+
- Updated dependencies [cdf64e7]
|
|
19
|
+
- Updated dependencies [5691aba]
|
|
20
|
+
- Updated dependencies [078e3f7]
|
|
21
|
+
- Updated dependencies [50497a5]
|
|
22
|
+
- Updated dependencies [9c997b2]
|
|
23
|
+
- Updated dependencies [09d4f25]
|
|
24
|
+
- Updated dependencies [43485a9]
|
|
25
|
+
- Updated dependencies [f0ec5ab]
|
|
26
|
+
- Updated dependencies [3e925d0]
|
|
27
|
+
- Updated dependencies [fa66c4c]
|
|
28
|
+
- Updated dependencies [fa66c4c]
|
|
29
|
+
- Updated dependencies [6cb6152]
|
|
30
|
+
- @zhin.js/command@1.0.3
|
|
31
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
32
|
+
- zhin.js@5.0.1
|
|
33
|
+
- @zhin.js/adapter@1.1.1
|
|
34
|
+
- @zhin.js/core@1.4.1
|
|
35
|
+
|
|
3
36
|
## 4.0.0
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/adapters/weixin-ilink.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveWeixinIlinkConfig,
|
|
9
9
|
type WeixinIlinkAdapterConfig,
|
|
10
10
|
} from '../src/protocol.js';
|
|
11
|
+
import { weixinIlinkRuntimeStateToken } from '../src/weixin-ilink-runtime-state.js';
|
|
11
12
|
|
|
12
13
|
export { WeixinIlinkEndpoint } from '../src/endpoint.js';
|
|
13
14
|
export type {
|
|
@@ -20,11 +21,22 @@ export type {
|
|
|
20
21
|
|
|
21
22
|
export default defineAdapter<WeixinIlinkAdapterConfig>({
|
|
22
23
|
capabilities: ['inbound', 'outbound'],
|
|
24
|
+
// base64 / 远程 URL 落盘后走 CDN 上传(sendWeixinMediaFile);微信无卡片交互面,交互段降级纯文本。
|
|
25
|
+
segments: {
|
|
26
|
+
outboundMedia: ['base64', 'url', 'upload'],
|
|
27
|
+
interactive: 'text',
|
|
28
|
+
},
|
|
23
29
|
create(context) {
|
|
30
|
+
const config = resolveWeixinIlinkConfig(context.config);
|
|
31
|
+
// 注册到插件运行时状态(weixin-ilink endpoint list 的"运行中"数据源)
|
|
32
|
+
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.name, {
|
|
33
|
+
name: config.name,
|
|
34
|
+
mode: config.connection,
|
|
35
|
+
});
|
|
24
36
|
return new WeixinIlinkEndpoint({
|
|
25
37
|
id: context.id,
|
|
26
38
|
gateway: context.use(messageGatewayToken),
|
|
27
|
-
config
|
|
39
|
+
config,
|
|
28
40
|
});
|
|
29
41
|
},
|
|
30
42
|
});
|
package/lib/context-store.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { setContextToken, getContextToken, restoreContextTokens, clearContextTokensForAccount, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
|
|
1
|
+
export { setContextToken, getContextToken, listContextTokenUserIds, restoreContextTokens, clearContextTokensForAccount, flushContextTokenPersist, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
|
package/lib/context-store.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { setContextToken, getContextToken, restoreContextTokens, clearContextTokensForAccount, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
|
|
1
|
+
export { setContextToken, getContextToken, listContextTokenUserIds, restoreContextTokens, clearContextTokensForAccount, flushContextTokenPersist, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
1
|
+
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
2
2
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
3
3
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
4
4
|
import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
|
|
@@ -13,7 +13,7 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
13
13
|
readonly id: CapabilityId;
|
|
14
14
|
readonly gateway: MessageGateway;
|
|
15
15
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
16
|
-
readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig) => Promise<WeixinIlinkCredentials>;
|
|
16
|
+
readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
|
|
17
17
|
/** Test / internal: override network side effects. */
|
|
18
18
|
readonly notifyStart?: WeixinIlinkNotifyStart;
|
|
19
19
|
readonly notifyStop?: WeixinIlinkNotifyStop;
|
|
@@ -22,8 +22,11 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
22
22
|
}
|
|
23
23
|
export declare class WeixinIlinkEndpoint implements EndpointInstance {
|
|
24
24
|
#private;
|
|
25
|
+
readonly management: EndpointManagement;
|
|
25
26
|
constructor(options: WeixinIlinkEndpointOptions);
|
|
26
27
|
get hasCredentials(): boolean;
|
|
28
|
+
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
29
|
+
get configName(): string;
|
|
27
30
|
apiBaseUrl(): string;
|
|
28
31
|
cdnBaseUrl(): string;
|
|
29
32
|
start(): Promise<void>;
|
package/lib/endpoint.js
CHANGED
|
@@ -9,7 +9,7 @@ import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js'
|
|
|
9
9
|
import { configureIlinkMeta } from './ilink-meta.js';
|
|
10
10
|
import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from './credentials.js';
|
|
11
11
|
import { resolveCredentials } from './login.js';
|
|
12
|
-
import { getContextToken, restoreContextTokens, setContextToken, } from './context-store.js';
|
|
12
|
+
import { getContextToken, listContextTokenUserIds, restoreContextTokens, setContextToken, flushContextTokenPersist, } from './context-store.js';
|
|
13
13
|
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
14
14
|
import { getRemainingPauseMs, isSessionPaused, pauseSession, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
|
|
15
15
|
import { downloadMediaFromItem } from './media-download.js';
|
|
@@ -24,6 +24,9 @@ const MAX_CONSECUTIVE_FAILURES = 3;
|
|
|
24
24
|
const BACKOFF_DELAY_MS = 30_000;
|
|
25
25
|
const RETRY_DELAY_MS = 2_000;
|
|
26
26
|
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
27
|
+
/** 入站媒体保留 24h,每小时清扫一次。 */
|
|
28
|
+
const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
27
30
|
export class WeixinIlinkEndpoint {
|
|
28
31
|
#options;
|
|
29
32
|
#resolveCredentials;
|
|
@@ -34,9 +37,12 @@ export class WeixinIlinkEndpoint {
|
|
|
34
37
|
#creds = null;
|
|
35
38
|
#pollAbort;
|
|
36
39
|
#pollPromise;
|
|
40
|
+
#loginAbort;
|
|
37
41
|
#configManager;
|
|
42
|
+
#mediaSweepTimer;
|
|
38
43
|
#open = false;
|
|
39
44
|
#started = false;
|
|
45
|
+
management = createWeixinIlinkEndpointManagement(this);
|
|
40
46
|
constructor(options) {
|
|
41
47
|
this.#options = options;
|
|
42
48
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
@@ -48,6 +54,10 @@ export class WeixinIlinkEndpoint {
|
|
|
48
54
|
get hasCredentials() {
|
|
49
55
|
return Boolean(this.#creds?.botToken);
|
|
50
56
|
}
|
|
57
|
+
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
58
|
+
get configName() {
|
|
59
|
+
return this.#options.config.name;
|
|
60
|
+
}
|
|
51
61
|
apiBaseUrl() {
|
|
52
62
|
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
53
63
|
}
|
|
@@ -60,7 +70,9 @@ export class WeixinIlinkEndpoint {
|
|
|
60
70
|
this.#started = true;
|
|
61
71
|
try {
|
|
62
72
|
configureIlinkMeta({ botAgent: this.#options.config.botAgent });
|
|
63
|
-
|
|
73
|
+
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
74
|
+
this.#loginAbort = new AbortController();
|
|
75
|
+
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
64
76
|
restoreContextTokens(this.#options.config.name);
|
|
65
77
|
await this.#notifyStart({
|
|
66
78
|
baseUrl: this.apiBaseUrl(),
|
|
@@ -69,6 +81,7 @@ export class WeixinIlinkEndpoint {
|
|
|
69
81
|
this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken }, (msg) => logger.debug(msg));
|
|
70
82
|
this.#pollAbort = new AbortController();
|
|
71
83
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
84
|
+
this.#startMediaSweep();
|
|
72
85
|
logger.info(formatCompact({
|
|
73
86
|
op: 'connect',
|
|
74
87
|
endpoint: this.#options.config.name,
|
|
@@ -88,6 +101,7 @@ export class WeixinIlinkEndpoint {
|
|
|
88
101
|
}
|
|
89
102
|
async stop() {
|
|
90
103
|
this.#open = false;
|
|
104
|
+
this.#loginAbort?.abort();
|
|
91
105
|
this.#pollAbort?.abort();
|
|
92
106
|
try {
|
|
93
107
|
await this.#pollPromise;
|
|
@@ -95,6 +109,9 @@ export class WeixinIlinkEndpoint {
|
|
|
95
109
|
catch {
|
|
96
110
|
/* poll loop exit */
|
|
97
111
|
}
|
|
112
|
+
this.#stopMediaSweep();
|
|
113
|
+
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
114
|
+
flushContextTokenPersist(this.#options.config.name);
|
|
98
115
|
if (this.#creds?.botToken) {
|
|
99
116
|
try {
|
|
100
117
|
await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
|
|
@@ -274,8 +291,8 @@ export class WeixinIlinkEndpoint {
|
|
|
274
291
|
continue;
|
|
275
292
|
}
|
|
276
293
|
consecutiveFailures += 1;
|
|
294
|
+
// 退避后不清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS。
|
|
277
295
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
278
|
-
consecutiveFailures = 0;
|
|
279
296
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
280
297
|
}
|
|
281
298
|
else {
|
|
@@ -284,13 +301,15 @@ export class WeixinIlinkEndpoint {
|
|
|
284
301
|
continue;
|
|
285
302
|
}
|
|
286
303
|
consecutiveFailures = 0;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
saveSyncBuf(endpointName, getUpdatesBuf);
|
|
290
|
-
}
|
|
304
|
+
// 先分发再推进 buf:分发途中崩溃时 buf 未推进,下一轮重拉不丢消息。
|
|
305
|
+
const nextBuf = resp.get_updates_buf;
|
|
291
306
|
for (const inbound of resp.msgs ?? []) {
|
|
292
307
|
await this.#handleInboundMessage(inbound);
|
|
293
308
|
}
|
|
309
|
+
if (nextBuf) {
|
|
310
|
+
getUpdatesBuf = nextBuf;
|
|
311
|
+
saveSyncBuf(endpointName, getUpdatesBuf);
|
|
312
|
+
}
|
|
294
313
|
}
|
|
295
314
|
catch (err) {
|
|
296
315
|
if (abortSignal.aborted)
|
|
@@ -302,7 +321,6 @@ export class WeixinIlinkEndpoint {
|
|
|
302
321
|
error: err instanceof Error ? err.message : String(err),
|
|
303
322
|
}));
|
|
304
323
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
305
|
-
consecutiveFailures = 0;
|
|
306
324
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
307
325
|
}
|
|
308
326
|
else {
|
|
@@ -361,4 +379,62 @@ export class WeixinIlinkEndpoint {
|
|
|
361
379
|
fs.writeFileSync(filePath, buffer);
|
|
362
380
|
return { path: filePath };
|
|
363
381
|
}
|
|
382
|
+
/** 入站媒体 TTL 清扫:start 时立即扫一次,之后每小时扫一次。 */
|
|
383
|
+
#startMediaSweep() {
|
|
384
|
+
this.#stopMediaSweep();
|
|
385
|
+
const sweep = () => this.#sweepInboundMedia();
|
|
386
|
+
sweep();
|
|
387
|
+
this.#mediaSweepTimer = setInterval(sweep, MEDIA_SWEEP_INTERVAL_MS);
|
|
388
|
+
this.#mediaSweepTimer.unref?.();
|
|
389
|
+
}
|
|
390
|
+
#stopMediaSweep() {
|
|
391
|
+
if (this.#mediaSweepTimer) {
|
|
392
|
+
clearInterval(this.#mediaSweepTimer);
|
|
393
|
+
this.#mediaSweepTimer = undefined;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
#sweepInboundMedia() {
|
|
397
|
+
const dir = path.join(resolveStateDir(), 'media', 'inbound');
|
|
398
|
+
let entries;
|
|
399
|
+
try {
|
|
400
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
for (const entry of entries) {
|
|
407
|
+
if (!entry.isFile())
|
|
408
|
+
continue;
|
|
409
|
+
const filePath = path.join(dir, entry.name);
|
|
410
|
+
try {
|
|
411
|
+
const stat = fs.statSync(filePath);
|
|
412
|
+
if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS)
|
|
413
|
+
continue;
|
|
414
|
+
fs.unlinkSync(filePath);
|
|
415
|
+
logger.debug(formatCompact({
|
|
416
|
+
op: 'media_sweep',
|
|
417
|
+
endpoint: this.#options.config.name,
|
|
418
|
+
file: entry.name,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
/* 单个文件失败不阻塞整体清扫 */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function createWeixinIlinkEndpointManagement(endpoint) {
|
|
428
|
+
return Object.freeze({
|
|
429
|
+
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
430
|
+
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
431
|
+
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
432
|
+
async listFriends() {
|
|
433
|
+
return listContextTokenUserIds(endpoint.configName).map((userId) => ({
|
|
434
|
+
user_id: userId,
|
|
435
|
+
nickname: userId,
|
|
436
|
+
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
|
437
|
+
}));
|
|
438
|
+
},
|
|
439
|
+
});
|
|
364
440
|
}
|
package/lib/ilink-api.d.ts
CHANGED
package/lib/ilink-api.js
CHANGED
|
@@ -54,11 +54,12 @@ export async function apiGetFetch(params) {
|
|
|
54
54
|
const t = controller != null && timeoutMs != null
|
|
55
55
|
? setTimeout(() => controller.abort(), timeoutMs)
|
|
56
56
|
: undefined;
|
|
57
|
+
const { signal, cleanup } = combineAbortSignals(controller, params.abortSignal);
|
|
57
58
|
try {
|
|
58
59
|
const res = await fetch(url.toString(), {
|
|
59
60
|
method: "GET",
|
|
60
61
|
headers: hdrs,
|
|
61
|
-
...(
|
|
62
|
+
...(signal ? { signal } : {}),
|
|
62
63
|
});
|
|
63
64
|
if (t !== undefined)
|
|
64
65
|
clearTimeout(t);
|
|
@@ -74,6 +75,9 @@ export async function apiGetFetch(params) {
|
|
|
74
75
|
clearTimeout(t);
|
|
75
76
|
throw err;
|
|
76
77
|
}
|
|
78
|
+
finally {
|
|
79
|
+
cleanup();
|
|
80
|
+
}
|
|
77
81
|
}
|
|
78
82
|
function combineAbortSignals(internal, external) {
|
|
79
83
|
if (!internal && !external)
|
package/lib/login.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type WeixinIlinkCredentials } from './credentials.js';
|
|
2
2
|
import type { ResolvedWeixinIlinkConfig } from './protocol.js';
|
|
3
|
-
export declare function resolveCredentials(config: ResolvedWeixinIlinkConfig): Promise<WeixinIlinkCredentials>;
|
|
4
|
-
export declare function loginWithQr(config: ResolvedWeixinIlinkConfig): Promise<WeixinIlinkCredentials>;
|
|
3
|
+
export declare function resolveCredentials(config: ResolvedWeixinIlinkConfig, signal?: AbortSignal): Promise<WeixinIlinkCredentials>;
|
|
4
|
+
export declare function loginWithQr(config: ResolvedWeixinIlinkConfig, signal?: AbortSignal): Promise<WeixinIlinkCredentials>;
|
package/lib/login.js
CHANGED
|
@@ -21,7 +21,7 @@ async function fetchQRCode(apiBaseUrl, botType) {
|
|
|
21
21
|
});
|
|
22
22
|
return JSON.parse(rawText);
|
|
23
23
|
}
|
|
24
|
-
async function pollQRStatus(apiBaseUrl, qrcode) {
|
|
24
|
+
async function pollQRStatus(apiBaseUrl, qrcode, signal) {
|
|
25
25
|
try {
|
|
26
26
|
const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
|
27
27
|
const rawText = await apiGetFetch({
|
|
@@ -29,18 +29,42 @@ async function pollQRStatus(apiBaseUrl, qrcode) {
|
|
|
29
29
|
endpoint,
|
|
30
30
|
timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
|
|
31
31
|
label: 'pollQRStatus',
|
|
32
|
+
abortSignal: signal,
|
|
32
33
|
});
|
|
33
34
|
return JSON.parse(rawText);
|
|
34
35
|
}
|
|
35
36
|
catch (err) {
|
|
36
37
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
38
|
+
// 外部 stop 的 abort 不能吞掉当普通长轮询超时
|
|
39
|
+
if (signal?.aborted)
|
|
40
|
+
throw err;
|
|
37
41
|
return { status: 'wait' };
|
|
38
42
|
}
|
|
39
43
|
logger.warn(`pollQRStatus error: ${String(err)}`);
|
|
40
44
|
return { status: 'wait' };
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
|
-
|
|
47
|
+
function abortError() {
|
|
48
|
+
return Object.assign(new Error('扫码登录已取消'), { name: 'AbortError' });
|
|
49
|
+
}
|
|
50
|
+
function sleepWithAbort(ms, signal) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
if (signal?.aborted) {
|
|
53
|
+
reject(abortError());
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
signal?.removeEventListener('abort', onAbort);
|
|
58
|
+
resolve();
|
|
59
|
+
}, ms);
|
|
60
|
+
const onAbort = () => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
reject(abortError());
|
|
63
|
+
};
|
|
64
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function resolveCredentials(config, signal) {
|
|
44
68
|
const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
|
|
45
69
|
if (envToken) {
|
|
46
70
|
const stored = loadCredentials(config.name);
|
|
@@ -58,9 +82,9 @@ export async function resolveCredentials(config) {
|
|
|
58
82
|
baseUrl: config.baseUrl ?? stored.baseUrl ?? DEFAULT_API_BASE_URL,
|
|
59
83
|
};
|
|
60
84
|
}
|
|
61
|
-
return loginWithQr(config);
|
|
85
|
+
return loginWithQr(config, signal);
|
|
62
86
|
}
|
|
63
|
-
export async function loginWithQr(config) {
|
|
87
|
+
export async function loginWithQr(config, signal) {
|
|
64
88
|
const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
|
|
65
89
|
const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
|
|
66
90
|
const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
|
|
@@ -68,7 +92,10 @@ export async function loginWithQr(config) {
|
|
|
68
92
|
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
69
93
|
let currentBaseUrl = apiBaseUrl;
|
|
70
94
|
while (Date.now() < deadline) {
|
|
71
|
-
|
|
95
|
+
// stop() 的 AbortSignal:不打满 8 分钟,立即退出登录循环
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
throw abortError();
|
|
98
|
+
const status = await pollQRStatus(currentBaseUrl, qr.qrcode, signal);
|
|
72
99
|
if (status.status === 'scaned_but_redirect' && status.redirect_host) {
|
|
73
100
|
currentBaseUrl = status.redirect_host.startsWith('http')
|
|
74
101
|
? status.redirect_host
|
|
@@ -93,7 +120,7 @@ export async function loginWithQr(config) {
|
|
|
93
120
|
if (status.status === 'expired') {
|
|
94
121
|
throw new Error('二维码已过期,请重启 bot 重新登录');
|
|
95
122
|
}
|
|
96
|
-
await
|
|
123
|
+
await sleepWithAbort(500, signal);
|
|
97
124
|
}
|
|
98
125
|
throw new Error('微信扫码登录超时');
|
|
99
126
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `weixin-ilink endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { weixinIlinkRuntimeStateToken } from './weixin-ilink-runtime-state.js';
|
|
8
|
+
export const weixinIlinkEndpointCommands = createEndpointCommands({
|
|
9
|
+
adapterKey: 'weixin-ilink',
|
|
10
|
+
adapterDisplayName: 'Weixin iLink',
|
|
11
|
+
fields: [
|
|
12
|
+
{ key: 'botToken', required: true, env: true, description: 'iLink bot token' },
|
|
13
|
+
],
|
|
14
|
+
running: (use) => use(weixinIlinkRuntimeStateToken).endpoints.values(),
|
|
15
|
+
describeEntry: (entry) => `botToken: ${String(entry.botToken)}`,
|
|
16
|
+
}, defineCommand);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const weixinIlinkRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weixin iLink 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
export const weixinIlinkRuntimeStateToken = defineEndpointRuntimeStateToken('weixin-ilink');
|
package/lib/weixin-inbound.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type WeixinMessage, type MessageItem } from './ilink-types.js';
|
|
2
|
+
/** 立即落盘所有待写(endpoint stop / 测试用)。 */
|
|
3
|
+
export declare function flushContextTokenPersist(accountId?: string): void;
|
|
2
4
|
/**
|
|
3
5
|
* Restore persisted context tokens for an account into the in-memory map.
|
|
4
6
|
* Called once during gateway startAccount to survive restarts.
|
|
@@ -6,10 +8,16 @@ import { type WeixinMessage, type MessageItem } from './ilink-types.js';
|
|
|
6
8
|
export declare function restoreContextTokens(accountId: string): void;
|
|
7
9
|
/** Remove all context tokens for a given account (memory + disk). */
|
|
8
10
|
export declare function clearContextTokensForAccount(accountId: string): void;
|
|
9
|
-
/** Store a context token for a given account+user pair (memory + disk). */
|
|
11
|
+
/** Store a context token for a given account+user pair (memory + debounced disk). */
|
|
10
12
|
export declare function setContextToken(accountId: string, userId: string, token: string): void;
|
|
11
13
|
/** Retrieve the cached context token for a given account+user pair. */
|
|
12
14
|
export declare function getContextToken(accountId: string, userId: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* List all peer userIds that hold a context token for the given account
|
|
17
|
+
* (memory map, already merged with the disk-backed file on restore).
|
|
18
|
+
* Personal WeChat has no contact/profile API; this is the only peer catalog.
|
|
19
|
+
*/
|
|
20
|
+
export declare function listContextTokenUserIds(accountId: string): string[];
|
|
13
21
|
/**
|
|
14
22
|
* Find all accountIds that have an active contextToken for the given userId.
|
|
15
23
|
* Used to infer the sending bot account from the recipient address when
|
package/lib/weixin-inbound.js
CHANGED
|
@@ -41,6 +41,34 @@ function persistContextTokens(accountId) {
|
|
|
41
41
|
logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* 每条入站消息都会 setContextToken;全量同步写盘在消息高峰时是纯浪费。
|
|
46
|
+
* 按 account 防抖合并,500ms 内多次写入只落盘一次。
|
|
47
|
+
*/
|
|
48
|
+
const PERSIST_DEBOUNCE_MS = 500;
|
|
49
|
+
const persistTimers = new Map();
|
|
50
|
+
function schedulePersistContextTokens(accountId) {
|
|
51
|
+
const existing = persistTimers.get(accountId);
|
|
52
|
+
if (existing)
|
|
53
|
+
clearTimeout(existing);
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
persistTimers.delete(accountId);
|
|
56
|
+
persistContextTokens(accountId);
|
|
57
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
58
|
+
// 不让防抖定时器拖住进程退出
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
persistTimers.set(accountId, timer);
|
|
61
|
+
}
|
|
62
|
+
/** 立即落盘所有待写(endpoint stop / 测试用)。 */
|
|
63
|
+
export function flushContextTokenPersist(accountId) {
|
|
64
|
+
for (const [id, timer] of [...persistTimers]) {
|
|
65
|
+
if (accountId !== undefined && id !== accountId)
|
|
66
|
+
continue;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
persistTimers.delete(id);
|
|
69
|
+
persistContextTokens(id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
44
72
|
/**
|
|
45
73
|
* Restore persisted context tokens for an account into the in-memory map.
|
|
46
74
|
* Called once during gateway startAccount to survive restarts.
|
|
@@ -83,12 +111,12 @@ export function clearContextTokensForAccount(accountId) {
|
|
|
83
111
|
}
|
|
84
112
|
logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
|
|
85
113
|
}
|
|
86
|
-
/** Store a context token for a given account+user pair (memory + disk). */
|
|
114
|
+
/** Store a context token for a given account+user pair (memory + debounced disk). */
|
|
87
115
|
export function setContextToken(accountId, userId, token) {
|
|
88
116
|
const k = contextTokenKey(accountId, userId);
|
|
89
117
|
logger.debug(`setContextToken: key=${k}`);
|
|
90
118
|
contextTokenStore.set(k, token);
|
|
91
|
-
|
|
119
|
+
schedulePersistContextTokens(accountId);
|
|
92
120
|
}
|
|
93
121
|
/** Retrieve the cached context token for a given account+user pair. */
|
|
94
122
|
export function getContextToken(accountId, userId) {
|
|
@@ -97,6 +125,20 @@ export function getContextToken(accountId, userId) {
|
|
|
97
125
|
logger.debug(`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`);
|
|
98
126
|
return val;
|
|
99
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* List all peer userIds that hold a context token for the given account
|
|
130
|
+
* (memory map, already merged with the disk-backed file on restore).
|
|
131
|
+
* Personal WeChat has no contact/profile API; this is the only peer catalog.
|
|
132
|
+
*/
|
|
133
|
+
export function listContextTokenUserIds(accountId) {
|
|
134
|
+
const prefix = `${accountId}:`;
|
|
135
|
+
const ids = [];
|
|
136
|
+
for (const k of contextTokenStore.keys()) {
|
|
137
|
+
if (k.startsWith(prefix))
|
|
138
|
+
ids.push(k.slice(prefix.length));
|
|
139
|
+
}
|
|
140
|
+
return ids;
|
|
141
|
+
}
|
|
100
142
|
/**
|
|
101
143
|
* Find all accountIds that have an active contextToken for the given userId.
|
|
102
144
|
* Used to infer the sending bot account from the recipient address when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-weixin-ilink",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"adapters",
|
|
31
|
+
"commands",
|
|
31
32
|
"plugin.ts",
|
|
32
33
|
"schema.json",
|
|
33
34
|
"src",
|
|
@@ -47,16 +48,17 @@
|
|
|
47
48
|
}
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
|
-
"@zhin.js/adapter": "1.1.
|
|
51
|
-
"@zhin.js/
|
|
51
|
+
"@zhin.js/adapter": "1.1.1",
|
|
52
|
+
"@zhin.js/command": "1.0.3",
|
|
53
|
+
"@zhin.js/core": "1.4.1",
|
|
52
54
|
"@zhin.js/logger": "1.0.75",
|
|
53
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
55
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
54
56
|
},
|
|
55
57
|
"peerDependencies": {
|
|
56
|
-
"@zhin.js/adapter": "1.1.
|
|
57
|
-
"@zhin.js/core": "1.4.
|
|
58
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
59
|
-
"zhin.js": "5.0.
|
|
58
|
+
"@zhin.js/adapter": "1.1.1",
|
|
59
|
+
"@zhin.js/core": "1.4.1",
|
|
60
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
61
|
+
"zhin.js": "5.0.1"
|
|
60
62
|
},
|
|
61
63
|
"peerDependenciesMeta": {
|
|
62
64
|
"zhin.js": {
|
|
@@ -82,6 +84,10 @@
|
|
|
82
84
|
{
|
|
83
85
|
"package": "@zhin.js/adapter",
|
|
84
86
|
"api": "^1.0.0"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"package": "@zhin.js/command",
|
|
90
|
+
"api": "^1.0.0"
|
|
85
91
|
}
|
|
86
92
|
],
|
|
87
93
|
"plugins": []
|
package/plugin.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
1
2
|
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { weixinIlinkRuntimeStateToken } from './src/weixin-ilink-runtime-state.js';
|
|
2
4
|
|
|
3
5
|
export default definePlugin({
|
|
4
6
|
name: 'weixin-ilink',
|
|
5
7
|
metadata: {
|
|
6
8
|
displayName: 'Weixin iLink Adapter',
|
|
7
9
|
},
|
|
10
|
+
setup(context) {
|
|
11
|
+
// 运行中 endpoint 注册表(weixin-ilink endpoint list 的"运行中"数据源)
|
|
12
|
+
context.resources.provide(weixinIlinkRuntimeStateToken, createEndpointRuntimeState());
|
|
13
|
+
},
|
|
8
14
|
});
|
package/src/context-store.ts
CHANGED
package/src/endpoint.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
7
|
+
import type { EndpointFriend, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
8
8
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
9
9
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
10
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
@@ -19,8 +19,10 @@ import {
|
|
|
19
19
|
import { resolveCredentials } from './login.js';
|
|
20
20
|
import {
|
|
21
21
|
getContextToken,
|
|
22
|
+
listContextTokenUserIds,
|
|
22
23
|
restoreContextTokens,
|
|
23
24
|
setContextToken,
|
|
25
|
+
flushContextTokenPersist,
|
|
24
26
|
} from './context-store.js';
|
|
25
27
|
import { WeixinConfigManager } from './ilink-config-cache.js';
|
|
26
28
|
import {
|
|
@@ -53,6 +55,9 @@ const MAX_CONSECUTIVE_FAILURES = 3;
|
|
|
53
55
|
const BACKOFF_DELAY_MS = 30_000;
|
|
54
56
|
const RETRY_DELAY_MS = 2_000;
|
|
55
57
|
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
58
|
+
/** 入站媒体保留 24h,每小时清扫一次。 */
|
|
59
|
+
const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
56
61
|
|
|
57
62
|
export type WeixinIlinkSendText = typeof sendMessageWeixin;
|
|
58
63
|
export type WeixinIlinkNotifyStart = typeof notifyStart;
|
|
@@ -65,6 +70,7 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
65
70
|
readonly config: ResolvedWeixinIlinkConfig;
|
|
66
71
|
readonly resolveCredentials?: (
|
|
67
72
|
config: ResolvedWeixinIlinkConfig,
|
|
73
|
+
signal?: AbortSignal,
|
|
68
74
|
) => Promise<WeixinIlinkCredentials>;
|
|
69
75
|
/** Test / internal: override network side effects. */
|
|
70
76
|
readonly notifyStart?: WeixinIlinkNotifyStart;
|
|
@@ -77,6 +83,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
77
83
|
readonly #options: WeixinIlinkEndpointOptions;
|
|
78
84
|
readonly #resolveCredentials: (
|
|
79
85
|
config: ResolvedWeixinIlinkConfig,
|
|
86
|
+
signal?: AbortSignal,
|
|
80
87
|
) => Promise<WeixinIlinkCredentials>;
|
|
81
88
|
readonly #notifyStart: WeixinIlinkNotifyStart;
|
|
82
89
|
readonly #notifyStop: WeixinIlinkNotifyStop;
|
|
@@ -85,9 +92,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
85
92
|
#creds: WeixinIlinkCredentials | null = null;
|
|
86
93
|
#pollAbort?: AbortController;
|
|
87
94
|
#pollPromise?: Promise<void>;
|
|
95
|
+
#loginAbort?: AbortController;
|
|
88
96
|
#configManager?: WeixinConfigManager;
|
|
97
|
+
#mediaSweepTimer?: ReturnType<typeof setInterval>;
|
|
89
98
|
#open = false;
|
|
90
99
|
#started = false;
|
|
100
|
+
readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
|
|
91
101
|
|
|
92
102
|
constructor(options: WeixinIlinkEndpointOptions) {
|
|
93
103
|
this.#options = options;
|
|
@@ -102,6 +112,11 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
102
112
|
return Boolean(this.#creds?.botToken);
|
|
103
113
|
}
|
|
104
114
|
|
|
115
|
+
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
116
|
+
get configName(): string {
|
|
117
|
+
return this.#options.config.name;
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
apiBaseUrl(): string {
|
|
106
121
|
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
107
122
|
}
|
|
@@ -115,7 +130,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
115
130
|
this.#started = true;
|
|
116
131
|
try {
|
|
117
132
|
configureIlinkMeta({ botAgent: this.#options.config.botAgent });
|
|
118
|
-
|
|
133
|
+
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
134
|
+
this.#loginAbort = new AbortController();
|
|
135
|
+
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
119
136
|
restoreContextTokens(this.#options.config.name);
|
|
120
137
|
|
|
121
138
|
await this.#notifyStart({
|
|
@@ -130,6 +147,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
130
147
|
|
|
131
148
|
this.#pollAbort = new AbortController();
|
|
132
149
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
150
|
+
this.#startMediaSweep();
|
|
133
151
|
logger.info(formatCompact({
|
|
134
152
|
op: 'connect',
|
|
135
153
|
endpoint: this.#options.config.name,
|
|
@@ -151,12 +169,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
151
169
|
|
|
152
170
|
async stop(): Promise<void> {
|
|
153
171
|
this.#open = false;
|
|
172
|
+
this.#loginAbort?.abort();
|
|
154
173
|
this.#pollAbort?.abort();
|
|
155
174
|
try {
|
|
156
175
|
await this.#pollPromise;
|
|
157
176
|
} catch {
|
|
158
177
|
/* poll loop exit */
|
|
159
178
|
}
|
|
179
|
+
this.#stopMediaSweep();
|
|
180
|
+
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
181
|
+
flushContextTokenPersist(this.#options.config.name);
|
|
160
182
|
if (this.#creds?.botToken) {
|
|
161
183
|
try {
|
|
162
184
|
await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
|
|
@@ -350,8 +372,8 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
350
372
|
continue;
|
|
351
373
|
}
|
|
352
374
|
consecutiveFailures += 1;
|
|
375
|
+
// 退避后不清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS。
|
|
353
376
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
354
|
-
consecutiveFailures = 0;
|
|
355
377
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
356
378
|
} else {
|
|
357
379
|
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
@@ -360,14 +382,15 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
360
382
|
}
|
|
361
383
|
|
|
362
384
|
consecutiveFailures = 0;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
saveSyncBuf(endpointName, getUpdatesBuf);
|
|
366
|
-
}
|
|
367
|
-
|
|
385
|
+
// 先分发再推进 buf:分发途中崩溃时 buf 未推进,下一轮重拉不丢消息。
|
|
386
|
+
const nextBuf = resp.get_updates_buf;
|
|
368
387
|
for (const inbound of resp.msgs ?? []) {
|
|
369
388
|
await this.#handleInboundMessage(inbound);
|
|
370
389
|
}
|
|
390
|
+
if (nextBuf) {
|
|
391
|
+
getUpdatesBuf = nextBuf;
|
|
392
|
+
saveSyncBuf(endpointName, getUpdatesBuf);
|
|
393
|
+
}
|
|
371
394
|
} catch (err) {
|
|
372
395
|
if (abortSignal.aborted) return;
|
|
373
396
|
consecutiveFailures += 1;
|
|
@@ -377,7 +400,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
377
400
|
error: err instanceof Error ? err.message : String(err),
|
|
378
401
|
}));
|
|
379
402
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
380
|
-
consecutiveFailures = 0;
|
|
381
403
|
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
382
404
|
} else {
|
|
383
405
|
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
@@ -458,4 +480,62 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
458
480
|
fs.writeFileSync(filePath, buffer);
|
|
459
481
|
return { path: filePath };
|
|
460
482
|
}
|
|
483
|
+
|
|
484
|
+
/** 入站媒体 TTL 清扫:start 时立即扫一次,之后每小时扫一次。 */
|
|
485
|
+
#startMediaSweep(): void {
|
|
486
|
+
this.#stopMediaSweep();
|
|
487
|
+
const sweep = () => this.#sweepInboundMedia();
|
|
488
|
+
sweep();
|
|
489
|
+
this.#mediaSweepTimer = setInterval(sweep, MEDIA_SWEEP_INTERVAL_MS);
|
|
490
|
+
(this.#mediaSweepTimer as { unref?: () => void }).unref?.();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#stopMediaSweep(): void {
|
|
494
|
+
if (this.#mediaSweepTimer) {
|
|
495
|
+
clearInterval(this.#mediaSweepTimer);
|
|
496
|
+
this.#mediaSweepTimer = undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#sweepInboundMedia(): void {
|
|
501
|
+
const dir = path.join(resolveStateDir(), 'media', 'inbound');
|
|
502
|
+
let entries: fs.Dirent[];
|
|
503
|
+
try {
|
|
504
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
505
|
+
} catch {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const now = Date.now();
|
|
509
|
+
for (const entry of entries) {
|
|
510
|
+
if (!entry.isFile()) continue;
|
|
511
|
+
const filePath = path.join(dir, entry.name);
|
|
512
|
+
try {
|
|
513
|
+
const stat = fs.statSync(filePath);
|
|
514
|
+
if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS) continue;
|
|
515
|
+
fs.unlinkSync(filePath);
|
|
516
|
+
logger.debug(formatCompact({
|
|
517
|
+
op: 'media_sweep',
|
|
518
|
+
endpoint: this.#options.config.name,
|
|
519
|
+
file: entry.name,
|
|
520
|
+
}));
|
|
521
|
+
} catch {
|
|
522
|
+
/* 单个文件失败不阻塞整体清扫 */
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function createWeixinIlinkEndpointManagement(endpoint: WeixinIlinkEndpoint): EndpointManagement {
|
|
529
|
+
return Object.freeze<EndpointManagement>({
|
|
530
|
+
// 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
|
|
531
|
+
// "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
|
|
532
|
+
// 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
|
|
533
|
+
async listFriends(): Promise<readonly EndpointFriend[]> {
|
|
534
|
+
return listContextTokenUserIds(endpoint.configName).map((userId) => ({
|
|
535
|
+
user_id: userId,
|
|
536
|
+
nickname: userId,
|
|
537
|
+
remark: 'ilink: 从会话 context_token 推导,非通讯录',
|
|
538
|
+
}));
|
|
539
|
+
},
|
|
540
|
+
});
|
|
461
541
|
}
|
package/src/ilink-api.ts
CHANGED
|
@@ -83,6 +83,7 @@ export async function apiGetFetch(params: {
|
|
|
83
83
|
endpoint: string;
|
|
84
84
|
timeoutMs?: number;
|
|
85
85
|
label: string;
|
|
86
|
+
abortSignal?: AbortSignal;
|
|
86
87
|
}): Promise<string> {
|
|
87
88
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
88
89
|
const url = new URL(params.endpoint, base);
|
|
@@ -96,11 +97,12 @@ export async function apiGetFetch(params: {
|
|
|
96
97
|
controller != null && timeoutMs != null
|
|
97
98
|
? setTimeout(() => controller.abort(), timeoutMs)
|
|
98
99
|
: undefined;
|
|
100
|
+
const { signal, cleanup } = combineAbortSignals(controller, params.abortSignal);
|
|
99
101
|
try {
|
|
100
102
|
const res = await fetch(url.toString(), {
|
|
101
103
|
method: "GET",
|
|
102
104
|
headers: hdrs,
|
|
103
|
-
...(
|
|
105
|
+
...(signal ? { signal } : {}),
|
|
104
106
|
});
|
|
105
107
|
if (t !== undefined) clearTimeout(t);
|
|
106
108
|
const rawText = await res.text();
|
|
@@ -112,6 +114,8 @@ export async function apiGetFetch(params: {
|
|
|
112
114
|
} catch (err) {
|
|
113
115
|
if (t !== undefined) clearTimeout(t);
|
|
114
116
|
throw err;
|
|
117
|
+
} finally {
|
|
118
|
+
cleanup();
|
|
115
119
|
}
|
|
116
120
|
}
|
|
117
121
|
|
package/src/login.ts
CHANGED
|
@@ -55,6 +55,7 @@ async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeR
|
|
|
55
55
|
async function pollQRStatus(
|
|
56
56
|
apiBaseUrl: string,
|
|
57
57
|
qrcode: string,
|
|
58
|
+
signal?: AbortSignal,
|
|
58
59
|
): Promise<StatusResponse> {
|
|
59
60
|
try {
|
|
60
61
|
const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
|
@@ -63,10 +64,13 @@ async function pollQRStatus(
|
|
|
63
64
|
endpoint,
|
|
64
65
|
timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
|
|
65
66
|
label: 'pollQRStatus',
|
|
67
|
+
abortSignal: signal,
|
|
66
68
|
});
|
|
67
69
|
return JSON.parse(rawText) as StatusResponse;
|
|
68
70
|
} catch (err) {
|
|
69
71
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
72
|
+
// 外部 stop 的 abort 不能吞掉当普通长轮询超时
|
|
73
|
+
if (signal?.aborted) throw err;
|
|
70
74
|
return { status: 'wait' };
|
|
71
75
|
}
|
|
72
76
|
logger.warn(`pollQRStatus error: ${String(err)}`);
|
|
@@ -74,8 +78,31 @@ async function pollQRStatus(
|
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
function abortError(): Error {
|
|
82
|
+
return Object.assign(new Error('扫码登录已取消'), { name: 'AbortError' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
if (signal?.aborted) {
|
|
88
|
+
reject(abortError());
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
signal?.removeEventListener('abort', onAbort);
|
|
93
|
+
resolve();
|
|
94
|
+
}, ms);
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
reject(abortError());
|
|
98
|
+
};
|
|
99
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
export async function resolveCredentials(
|
|
78
104
|
config: ResolvedWeixinIlinkConfig,
|
|
105
|
+
signal?: AbortSignal,
|
|
79
106
|
): Promise<WeixinIlinkCredentials> {
|
|
80
107
|
const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
|
|
81
108
|
if (envToken) {
|
|
@@ -96,11 +123,12 @@ export async function resolveCredentials(
|
|
|
96
123
|
};
|
|
97
124
|
}
|
|
98
125
|
|
|
99
|
-
return loginWithQr(config);
|
|
126
|
+
return loginWithQr(config, signal);
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
export async function loginWithQr(
|
|
103
130
|
config: ResolvedWeixinIlinkConfig,
|
|
131
|
+
signal?: AbortSignal,
|
|
104
132
|
): Promise<WeixinIlinkCredentials> {
|
|
105
133
|
const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
|
|
106
134
|
const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
|
|
@@ -114,7 +142,9 @@ export async function loginWithQr(
|
|
|
114
142
|
let currentBaseUrl = apiBaseUrl;
|
|
115
143
|
|
|
116
144
|
while (Date.now() < deadline) {
|
|
117
|
-
|
|
145
|
+
// stop() 的 AbortSignal:不打满 8 分钟,立即退出登录循环
|
|
146
|
+
if (signal?.aborted) throw abortError();
|
|
147
|
+
const status = await pollQRStatus(currentBaseUrl, qr.qrcode, signal);
|
|
118
148
|
|
|
119
149
|
if (status.status === 'scaned_but_redirect' && status.redirect_host) {
|
|
120
150
|
currentBaseUrl = status.redirect_host.startsWith('http')
|
|
@@ -143,7 +173,7 @@ export async function loginWithQr(
|
|
|
143
173
|
throw new Error('二维码已过期,请重启 bot 重新登录');
|
|
144
174
|
}
|
|
145
175
|
|
|
146
|
-
await
|
|
176
|
+
await sleepWithAbort(500, signal);
|
|
147
177
|
}
|
|
148
178
|
|
|
149
179
|
throw new Error('微信扫码登录超时');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `weixin-ilink endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { weixinIlinkRuntimeStateToken } from './weixin-ilink-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export const weixinIlinkEndpointCommands = createEndpointCommands({
|
|
10
|
+
adapterKey: 'weixin-ilink',
|
|
11
|
+
adapterDisplayName: 'Weixin iLink',
|
|
12
|
+
fields: [
|
|
13
|
+
{ key: 'botToken', required: true, env: true, description: 'iLink bot token' },
|
|
14
|
+
],
|
|
15
|
+
running: (use) => use(weixinIlinkRuntimeStateToken).endpoints.values(),
|
|
16
|
+
describeEntry: (entry) => `botToken: ${String(entry.botToken)}`,
|
|
17
|
+
}, defineCommand);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weixin iLink 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
|
|
7
|
+
export const weixinIlinkRuntimeStateToken = defineEndpointRuntimeStateToken('weixin-ilink');
|
package/src/weixin-inbound.ts
CHANGED
|
@@ -49,6 +49,35 @@ function persistContextTokens(accountId: string): void {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 每条入站消息都会 setContextToken;全量同步写盘在消息高峰时是纯浪费。
|
|
54
|
+
* 按 account 防抖合并,500ms 内多次写入只落盘一次。
|
|
55
|
+
*/
|
|
56
|
+
const PERSIST_DEBOUNCE_MS = 500;
|
|
57
|
+
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
58
|
+
|
|
59
|
+
function schedulePersistContextTokens(accountId: string): void {
|
|
60
|
+
const existing = persistTimers.get(accountId);
|
|
61
|
+
if (existing) clearTimeout(existing);
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
persistTimers.delete(accountId);
|
|
64
|
+
persistContextTokens(accountId);
|
|
65
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
66
|
+
// 不让防抖定时器拖住进程退出
|
|
67
|
+
(timer as { unref?: () => void }).unref?.();
|
|
68
|
+
persistTimers.set(accountId, timer);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 立即落盘所有待写(endpoint stop / 测试用)。 */
|
|
72
|
+
export function flushContextTokenPersist(accountId?: string): void {
|
|
73
|
+
for (const [id, timer] of [...persistTimers]) {
|
|
74
|
+
if (accountId !== undefined && id !== accountId) continue;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
persistTimers.delete(id);
|
|
77
|
+
persistContextTokens(id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
/**
|
|
53
82
|
* Restore persisted context tokens for an account into the in-memory map.
|
|
54
83
|
* Called once during gateway startAccount to survive restarts.
|
|
@@ -89,12 +118,12 @@ export function clearContextTokensForAccount(accountId: string): void {
|
|
|
89
118
|
logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
/** Store a context token for a given account+user pair (memory + disk). */
|
|
121
|
+
/** Store a context token for a given account+user pair (memory + debounced disk). */
|
|
93
122
|
export function setContextToken(accountId: string, userId: string, token: string): void {
|
|
94
123
|
const k = contextTokenKey(accountId, userId);
|
|
95
124
|
logger.debug(`setContextToken: key=${k}`);
|
|
96
125
|
contextTokenStore.set(k, token);
|
|
97
|
-
|
|
126
|
+
schedulePersistContextTokens(accountId);
|
|
98
127
|
}
|
|
99
128
|
|
|
100
129
|
/** Retrieve the cached context token for a given account+user pair. */
|
|
@@ -107,6 +136,20 @@ export function getContextToken(accountId: string, userId: string): string | und
|
|
|
107
136
|
return val;
|
|
108
137
|
}
|
|
109
138
|
|
|
139
|
+
/**
|
|
140
|
+
* List all peer userIds that hold a context token for the given account
|
|
141
|
+
* (memory map, already merged with the disk-backed file on restore).
|
|
142
|
+
* Personal WeChat has no contact/profile API; this is the only peer catalog.
|
|
143
|
+
*/
|
|
144
|
+
export function listContextTokenUserIds(accountId: string): string[] {
|
|
145
|
+
const prefix = `${accountId}:`;
|
|
146
|
+
const ids: string[] = [];
|
|
147
|
+
for (const k of contextTokenStore.keys()) {
|
|
148
|
+
if (k.startsWith(prefix)) ids.push(k.slice(prefix.length));
|
|
149
|
+
}
|
|
150
|
+
return ids;
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
/**
|
|
111
154
|
* Find all accountIds that have an active contextToken for the given userId.
|
|
112
155
|
* Used to infer the sending bot account from the recipient address when
|