@zhin.js/adapter-weixin-ilink 5.0.1 → 5.0.4
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 +51 -0
- package/README.md +1 -2
- package/adapters/weixin-ilink.js +4 -3
- package/adapters/weixin-ilink.ts +4 -3
- package/lib/credentials.d.ts +6 -6
- package/lib/credentials.js +12 -12
- package/lib/endpoint.d.ts +2 -5
- package/lib/endpoint.js +43 -41
- package/lib/login.js +5 -5
- package/lib/protocol.d.ts +8 -2
- package/lib/protocol.js +14 -3
- package/package.json +11 -10
- package/schema.json +41 -5
- package/src/credentials.ts +12 -12
- package/src/endpoint.ts +51 -43
- package/src/login.ts +5 -5
- package/src/protocol.ts +18 -5
- package/src/silk-wasm.d.ts +6 -2
- /package/commands/endpoint/add/{[name:string].js → [id].js} +0 -0
- /package/commands/endpoint/add/{[name:string].ts → [id].ts} +0 -0
- /package/commands/endpoint/remove/{[name:string].js → [id].js} +0 -0
- /package/commands/endpoint/remove/{[name:string].ts → [id].ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @zhin.js/adapter-weixin-ilink
|
|
2
2
|
|
|
3
|
+
## 5.0.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [c106ecc]
|
|
8
|
+
- Updated dependencies [ca92e03]
|
|
9
|
+
- Updated dependencies [b0f37ae]
|
|
10
|
+
- Updated dependencies [ba08a2f]
|
|
11
|
+
- Updated dependencies [daffd4c]
|
|
12
|
+
- Updated dependencies [36c7400]
|
|
13
|
+
- Updated dependencies [162fa34]
|
|
14
|
+
- Updated dependencies [e40b048]
|
|
15
|
+
- Updated dependencies [f1708c3]
|
|
16
|
+
- Updated dependencies [e53444f]
|
|
17
|
+
- Updated dependencies [92b0dd7]
|
|
18
|
+
- Updated dependencies [a7df753]
|
|
19
|
+
- @zhin.js/im-contract@1.0.3
|
|
20
|
+
- @zhin.js/adapter@1.1.7
|
|
21
|
+
- @zhin.js/plugin-runtime@1.1.5
|
|
22
|
+
- @zhin.js/command@1.0.9
|
|
23
|
+
- @zhin.js/core@1.5.4
|
|
24
|
+
- zhin.js@6.0.4
|
|
25
|
+
|
|
26
|
+
## 5.0.3
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- f8c7a54: fix: im
|
|
31
|
+
- Updated dependencies [f8c7a54]
|
|
32
|
+
- @zhin.js/logger@1.0.76
|
|
33
|
+
- @zhin.js/adapter@1.1.6
|
|
34
|
+
- @zhin.js/command@1.0.8
|
|
35
|
+
- @zhin.js/core@1.5.3
|
|
36
|
+
- @zhin.js/im-contract@1.0.2
|
|
37
|
+
- @zhin.js/plugin-runtime@1.1.4
|
|
38
|
+
- zhin.js@6.0.3
|
|
39
|
+
|
|
40
|
+
## 5.0.2
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- Updated dependencies [afc0e66]
|
|
45
|
+
- Updated dependencies [2e41ad5]
|
|
46
|
+
- Updated dependencies [9f57124]
|
|
47
|
+
- @zhin.js/core@1.5.2
|
|
48
|
+
- @zhin.js/adapter@1.1.5
|
|
49
|
+
- @zhin.js/im-contract@1.0.1
|
|
50
|
+
- @zhin.js/plugin-runtime@1.1.3
|
|
51
|
+
- @zhin.js/command@1.0.7
|
|
52
|
+
- zhin.js@6.0.2
|
|
53
|
+
|
|
3
54
|
## 5.0.1
|
|
4
55
|
|
|
5
56
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ data/weixin-ilink/<bot-name>.json
|
|
|
52
52
|
|
|
53
53
|
回复必须携带入站时缓存的 `context_token`(按 `endpointId + peerUserId`)。若用户久未发消息导致 token 缺失,出站会拒绝并打 warn。
|
|
54
54
|
|
|
55
|
-
出站经 Runtime:`MessageGateway` → `endpoint.send({
|
|
55
|
+
出站经 Runtime:`MessageGateway` → `endpoint.send({ conversation, payload })`(`conversation` 为入站归一化出的 `ConversationRef`,kind 恒为 `private`,`id` 即对端 user_id)。
|
|
56
56
|
|
|
57
57
|
**图文限制**:微信单条消息不支持图文混排。适配器会自动:
|
|
58
58
|
|
|
@@ -71,4 +71,3 @@ data/weixin-ilink/<bot-name>.json
|
|
|
71
71
|
## 文档
|
|
72
72
|
|
|
73
73
|
- 适配器索引:[`plugins/adapters/README.md`](../README.md)
|
|
74
|
-
- 原位迁移:[`docs/architecture/target-implementation/in-place-migration.md`](../../../docs/architecture/target-implementation/in-place-migration.md)
|
package/adapters/weixin-ilink.js
CHANGED
|
@@ -19,9 +19,10 @@ export default defineAdapter({
|
|
|
19
19
|
create(context) {
|
|
20
20
|
const config = resolveWeixinIlinkConfig(context.config);
|
|
21
21
|
// 注册到插件运行时状态(weixin-ilink.endpoint list 的"运行中"数据源)
|
|
22
|
-
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.id, {
|
|
23
|
+
id: config.id,
|
|
24
|
+
// iLink 仅长轮询一种接入方式,无 config.connection 字段
|
|
25
|
+
mode: 'long-poll',
|
|
25
26
|
});
|
|
26
27
|
return new WeixinIlinkEndpoint({
|
|
27
28
|
id: context.id,
|
package/adapters/weixin-ilink.ts
CHANGED
|
@@ -30,9 +30,10 @@ export default defineAdapter<WeixinIlinkAdapterConfig>({
|
|
|
30
30
|
create(context) {
|
|
31
31
|
const config = resolveWeixinIlinkConfig(context.config);
|
|
32
32
|
// 注册到插件运行时状态(weixin-ilink.endpoint list 的"运行中"数据源)
|
|
33
|
-
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.id, {
|
|
34
|
+
id: config.id,
|
|
35
|
+
// iLink 仅长轮询一种接入方式,无 config.connection 字段
|
|
36
|
+
mode: 'long-poll',
|
|
36
37
|
});
|
|
37
38
|
return new WeixinIlinkEndpoint({
|
|
38
39
|
id: context.id,
|
package/lib/credentials.d.ts
CHANGED
|
@@ -6,9 +6,9 @@ export interface WeixinIlinkCredentials {
|
|
|
6
6
|
updatedAt?: string;
|
|
7
7
|
}
|
|
8
8
|
export declare function resolveStateDir(): string;
|
|
9
|
-
export declare function credentialsPath(
|
|
10
|
-
export declare function loadCredentials(
|
|
11
|
-
export declare function saveCredentials(
|
|
12
|
-
export declare function syncBufPath(
|
|
13
|
-
export declare function loadSyncBuf(
|
|
14
|
-
export declare function saveSyncBuf(
|
|
9
|
+
export declare function credentialsPath(endpointId: string): string;
|
|
10
|
+
export declare function loadCredentials(endpointId: string): WeixinIlinkCredentials | null;
|
|
11
|
+
export declare function saveCredentials(endpointId: string, creds: WeixinIlinkCredentials): void;
|
|
12
|
+
export declare function syncBufPath(endpointId: string): string;
|
|
13
|
+
export declare function loadSyncBuf(endpointId: string): string;
|
|
14
|
+
export declare function saveSyncBuf(endpointId: string, buf: string): void;
|
package/lib/credentials.js
CHANGED
|
@@ -6,11 +6,11 @@ export function resolveStateDir() {
|
|
|
6
6
|
? path.join(process.env.ZHIN_DATA_DIR, "weixin-ilink")
|
|
7
7
|
: DEFAULT_DATA_DIR;
|
|
8
8
|
}
|
|
9
|
-
export function credentialsPath(
|
|
10
|
-
return path.join(resolveStateDir(), `${
|
|
9
|
+
export function credentialsPath(endpointId) {
|
|
10
|
+
return path.join(resolveStateDir(), `${endpointId}.json`);
|
|
11
11
|
}
|
|
12
|
-
export function loadCredentials(
|
|
13
|
-
const file = credentialsPath(
|
|
12
|
+
export function loadCredentials(endpointId) {
|
|
13
|
+
const file = credentialsPath(endpointId);
|
|
14
14
|
try {
|
|
15
15
|
if (!fs.existsSync(file))
|
|
16
16
|
return null;
|
|
@@ -24,16 +24,16 @@ export function loadCredentials(endpointName) {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
export function saveCredentials(
|
|
28
|
-
const file = credentialsPath(
|
|
27
|
+
export function saveCredentials(endpointId, creds) {
|
|
28
|
+
const file = credentialsPath(endpointId);
|
|
29
29
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
30
|
fs.writeFileSync(file, JSON.stringify({ ...creds, updatedAt: new Date().toISOString() }, null, 2), "utf-8");
|
|
31
31
|
}
|
|
32
|
-
export function syncBufPath(
|
|
33
|
-
return path.join(resolveStateDir(), `${
|
|
32
|
+
export function syncBufPath(endpointId) {
|
|
33
|
+
return path.join(resolveStateDir(), `${endpointId}.sync-buf`);
|
|
34
34
|
}
|
|
35
|
-
export function loadSyncBuf(
|
|
36
|
-
const file = syncBufPath(
|
|
35
|
+
export function loadSyncBuf(endpointId) {
|
|
36
|
+
const file = syncBufPath(endpointId);
|
|
37
37
|
try {
|
|
38
38
|
return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : "";
|
|
39
39
|
}
|
|
@@ -41,8 +41,8 @@ export function loadSyncBuf(endpointName) {
|
|
|
41
41
|
return "";
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
export function saveSyncBuf(
|
|
45
|
-
const file = syncBufPath(
|
|
44
|
+
export function saveSyncBuf(endpointId, buf) {
|
|
45
|
+
const file = syncBufPath(endpointId);
|
|
46
46
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
47
|
fs.writeFileSync(file, buf, "utf-8");
|
|
48
48
|
}
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
1
|
+
import type { EndpointInstance, EndpointManagement, EndpointSendRequest } 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';
|
|
@@ -33,10 +33,7 @@ export declare class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
33
33
|
open(): void;
|
|
34
34
|
close(): void;
|
|
35
35
|
stop(): Promise<void>;
|
|
36
|
-
send({
|
|
37
|
-
readonly target: string;
|
|
38
|
-
readonly payload: unknown;
|
|
39
|
-
}): Promise<string>;
|
|
36
|
+
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
40
37
|
/** Test / internal: admit a parsed message when the endpoint is open. */
|
|
41
38
|
admit(msg: WeixinMessageWithMedia): void;
|
|
42
39
|
sendTypingToUser(userId: string, status: number): Promise<void>;
|
package/lib/endpoint.js
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 { formatCompact,
|
|
7
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
8
|
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';
|
|
@@ -18,8 +18,7 @@ import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
|
18
18
|
import { materializeOutboundMedia } from './outbound-media.js';
|
|
19
19
|
import { MessageItemType } from './ilink-types.js';
|
|
20
20
|
import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
|
|
21
|
-
import { formatInboundContent, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, } from './protocol.js';
|
|
22
|
-
const logger = getLogger('weixin-ilink');
|
|
21
|
+
import { formatInboundContent, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, weixinIlinkInboundConversation, } from './protocol.js';
|
|
23
22
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
24
23
|
const BACKOFF_DELAY_MS = 30_000;
|
|
25
24
|
const RETRY_DELAY_MS = 2_000;
|
|
@@ -28,6 +27,7 @@ const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
|
28
27
|
const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
28
|
const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
30
29
|
export class WeixinIlinkEndpoint {
|
|
30
|
+
#logger;
|
|
31
31
|
#options;
|
|
32
32
|
#resolveCredentials;
|
|
33
33
|
#notifyStart;
|
|
@@ -44,6 +44,7 @@ export class WeixinIlinkEndpoint {
|
|
|
44
44
|
#started = false;
|
|
45
45
|
management = createWeixinIlinkEndpointManagement(this);
|
|
46
46
|
constructor(options) {
|
|
47
|
+
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
47
48
|
this.#options = options;
|
|
48
49
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
49
50
|
this.#notifyStart = options.notifyStart ?? notifyStart;
|
|
@@ -56,7 +57,7 @@ export class WeixinIlinkEndpoint {
|
|
|
56
57
|
}
|
|
57
58
|
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
58
59
|
get configName() {
|
|
59
|
-
return this.#options.config.
|
|
60
|
+
return this.#options.config.id;
|
|
60
61
|
}
|
|
61
62
|
apiBaseUrl() {
|
|
62
63
|
return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
|
|
@@ -73,23 +74,23 @@ export class WeixinIlinkEndpoint {
|
|
|
73
74
|
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
74
75
|
this.#loginAbort = new AbortController();
|
|
75
76
|
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
76
|
-
restoreContextTokens(this.#options.config.
|
|
77
|
+
restoreContextTokens(this.#options.config.id);
|
|
77
78
|
await this.#notifyStart({
|
|
78
79
|
baseUrl: this.apiBaseUrl(),
|
|
79
80
|
token: this.#creds.botToken,
|
|
80
81
|
});
|
|
81
|
-
this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken }, (msg) => logger.debug(msg));
|
|
82
|
+
this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken }, (msg) => this.#logger.debug(msg));
|
|
82
83
|
this.#pollAbort = new AbortController();
|
|
83
84
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
84
85
|
this.#startMediaSweep();
|
|
85
|
-
logger.info(formatCompact({
|
|
86
|
+
this.#logger.info(formatCompact({
|
|
86
87
|
op: 'connect',
|
|
87
|
-
endpoint: this.#options.config.
|
|
88
|
+
endpoint: this.#options.config.id,
|
|
88
89
|
}));
|
|
89
90
|
}
|
|
90
91
|
catch (error) {
|
|
91
92
|
await this.stop();
|
|
92
|
-
logger.error('Failed to connect weixin-ilink bot:', error);
|
|
93
|
+
this.#logger.error('Failed to connect weixin-ilink bot:', error);
|
|
93
94
|
throw error;
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -111,13 +112,13 @@ export class WeixinIlinkEndpoint {
|
|
|
111
112
|
}
|
|
112
113
|
this.#stopMediaSweep();
|
|
113
114
|
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
114
|
-
flushContextTokenPersist(this.#options.config.
|
|
115
|
+
flushContextTokenPersist(this.#options.config.id);
|
|
115
116
|
if (this.#creds?.botToken) {
|
|
116
117
|
try {
|
|
117
118
|
await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
|
|
118
119
|
}
|
|
119
120
|
catch (err) {
|
|
120
|
-
logger.warn(formatCompact({
|
|
121
|
+
this.#logger.warn(formatCompact({
|
|
121
122
|
op: 'notify_stop',
|
|
122
123
|
ok: false,
|
|
123
124
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -125,18 +126,19 @@ export class WeixinIlinkEndpoint {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
this.#started = false;
|
|
128
|
-
logger.debug(formatCompact({
|
|
129
|
+
this.#logger.debug(formatCompact({
|
|
129
130
|
op: 'disconnect',
|
|
130
|
-
endpoint: this.#options.config.name,
|
|
131
131
|
}));
|
|
132
132
|
}
|
|
133
|
-
async send({
|
|
133
|
+
async send({ conversation, payload }) {
|
|
134
134
|
if (!this.#creds?.botToken) {
|
|
135
135
|
throw new Error('weixin-ilink bot not authenticated');
|
|
136
136
|
}
|
|
137
|
-
|
|
137
|
+
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
138
|
+
const target = conversation.id;
|
|
139
|
+
const contextToken = getContextToken(this.#options.config.id, target);
|
|
138
140
|
if (!contextToken) {
|
|
139
|
-
logger.warn(formatCompact({
|
|
141
|
+
this.#logger.warn(formatCompact({
|
|
140
142
|
op: 'send',
|
|
141
143
|
ok: false,
|
|
142
144
|
reason: 'missing_context_token',
|
|
@@ -190,7 +192,7 @@ export class WeixinIlinkEndpoint {
|
|
|
190
192
|
continue;
|
|
191
193
|
}
|
|
192
194
|
if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
|
|
193
|
-
logger.warn(formatCompact({
|
|
195
|
+
this.#logger.warn(formatCompact({
|
|
194
196
|
op: 'send',
|
|
195
197
|
skip: 'no_local_file',
|
|
196
198
|
type: seg.type,
|
|
@@ -213,21 +215,21 @@ export class WeixinIlinkEndpoint {
|
|
|
213
215
|
return;
|
|
214
216
|
const userId = msg.from_user_id ?? '';
|
|
215
217
|
if (msg.context_token && userId) {
|
|
216
|
-
setContextToken(this.#options.config.
|
|
218
|
+
setContextToken(this.#options.config.id, userId, msg.context_token);
|
|
217
219
|
}
|
|
220
|
+
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
218
221
|
void this.#options.gateway.receive({
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
conversation,
|
|
223
|
+
message: { conversation, id: inboundMessageId(msg) },
|
|
221
224
|
content: formatInboundContent(msg),
|
|
222
|
-
sender: userId,
|
|
223
|
-
|
|
225
|
+
sender: { id: userId },
|
|
226
|
+
endpointId: this.#options.config.id,
|
|
224
227
|
metadata: Object.freeze({
|
|
225
|
-
endpoint: this.#options.config.name,
|
|
226
228
|
messageType: msg.message_type,
|
|
227
229
|
createTimeMs: msg.create_time_ms,
|
|
228
230
|
}),
|
|
229
231
|
}).catch((err) => {
|
|
230
|
-
logger.warn(formatCompact({
|
|
232
|
+
this.#logger.warn(formatCompact({
|
|
231
233
|
op: 'weixin_ilink_gateway_receive_failed',
|
|
232
234
|
target: userId,
|
|
233
235
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -237,10 +239,10 @@ export class WeixinIlinkEndpoint {
|
|
|
237
239
|
async sendTypingToUser(userId, status) {
|
|
238
240
|
if (!this.#creds?.botToken || !this.#configManager)
|
|
239
241
|
return;
|
|
240
|
-
const contextToken = getContextToken(this.#options.config.
|
|
242
|
+
const contextToken = getContextToken(this.#options.config.id, userId);
|
|
241
243
|
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
242
244
|
if (!cfg.typingTicket) {
|
|
243
|
-
logger.debug(formatCompact({
|
|
245
|
+
this.#logger.debug(formatCompact({
|
|
244
246
|
op: 'send_typing',
|
|
245
247
|
skip: 'no_typing_ticket',
|
|
246
248
|
userId,
|
|
@@ -260,13 +262,13 @@ export class WeixinIlinkEndpoint {
|
|
|
260
262
|
async #pollLoop(abortSignal) {
|
|
261
263
|
if (!this.#creds?.botToken)
|
|
262
264
|
return;
|
|
263
|
-
const
|
|
264
|
-
let getUpdatesBuf = loadSyncBuf(
|
|
265
|
+
const endpointId = this.#options.config.id;
|
|
266
|
+
let getUpdatesBuf = loadSyncBuf(endpointId);
|
|
265
267
|
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
266
268
|
let consecutiveFailures = 0;
|
|
267
269
|
while (!abortSignal.aborted) {
|
|
268
|
-
if (isSessionPaused(
|
|
269
|
-
await sleep(getRemainingPauseMs(
|
|
270
|
+
if (isSessionPaused(endpointId)) {
|
|
271
|
+
await sleep(getRemainingPauseMs(endpointId), abortSignal);
|
|
270
272
|
continue;
|
|
271
273
|
}
|
|
272
274
|
try {
|
|
@@ -285,9 +287,9 @@ export class WeixinIlinkEndpoint {
|
|
|
285
287
|
if (isApiError) {
|
|
286
288
|
const sessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
287
289
|
if (sessionExpired) {
|
|
288
|
-
pauseSession(
|
|
290
|
+
pauseSession(endpointId);
|
|
289
291
|
consecutiveFailures = 0;
|
|
290
|
-
await sleep(getRemainingPauseMs(
|
|
292
|
+
await sleep(getRemainingPauseMs(endpointId), abortSignal);
|
|
291
293
|
continue;
|
|
292
294
|
}
|
|
293
295
|
consecutiveFailures += 1;
|
|
@@ -308,14 +310,14 @@ export class WeixinIlinkEndpoint {
|
|
|
308
310
|
}
|
|
309
311
|
if (nextBuf) {
|
|
310
312
|
getUpdatesBuf = nextBuf;
|
|
311
|
-
saveSyncBuf(
|
|
313
|
+
saveSyncBuf(endpointId, getUpdatesBuf);
|
|
312
314
|
}
|
|
313
315
|
}
|
|
314
316
|
catch (err) {
|
|
315
317
|
if (abortSignal.aborted)
|
|
316
318
|
return;
|
|
317
319
|
consecutiveFailures += 1;
|
|
318
|
-
logger.error(formatCompact({
|
|
320
|
+
this.#logger.error(formatCompact({
|
|
319
321
|
op: 'poll',
|
|
320
322
|
ok: false,
|
|
321
323
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -332,13 +334,13 @@ export class WeixinIlinkEndpoint {
|
|
|
332
334
|
async #handleInboundMessage(full) {
|
|
333
335
|
const fromUserId = full.from_user_id ?? '';
|
|
334
336
|
if (full.context_token && fromUserId) {
|
|
335
|
-
setContextToken(this.#options.config.
|
|
337
|
+
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
336
338
|
}
|
|
337
339
|
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
338
340
|
this.admit({ ...full, _media: mediaOpts });
|
|
339
|
-
logger.debug(formatCompact({
|
|
341
|
+
this.#logger.debug(formatCompact({
|
|
340
342
|
op: 'recv',
|
|
341
|
-
endpoint: this.#options.config.
|
|
343
|
+
endpoint: this.#options.config.id,
|
|
342
344
|
target: fromUserId,
|
|
343
345
|
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
344
346
|
}));
|
|
@@ -354,8 +356,8 @@ export class WeixinIlinkEndpoint {
|
|
|
354
356
|
return downloadMediaFromItem(mediaItem, {
|
|
355
357
|
cdnBaseUrl: this.cdnBaseUrl(),
|
|
356
358
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
357
|
-
log: (msg) => logger.debug(msg),
|
|
358
|
-
errLog: (msg) => logger.warn(msg),
|
|
359
|
+
log: (msg) => this.#logger.debug(msg),
|
|
360
|
+
errLog: (msg) => this.#logger.warn(msg),
|
|
359
361
|
label: `inbound:${full.from_user_id ?? 'unknown'}`,
|
|
360
362
|
});
|
|
361
363
|
}
|
|
@@ -412,9 +414,9 @@ export class WeixinIlinkEndpoint {
|
|
|
412
414
|
if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS)
|
|
413
415
|
continue;
|
|
414
416
|
fs.unlinkSync(filePath);
|
|
415
|
-
logger.debug(formatCompact({
|
|
417
|
+
this.#logger.debug(formatCompact({
|
|
416
418
|
op: 'media_sweep',
|
|
417
|
-
endpoint: this.#options.config.
|
|
419
|
+
endpoint: this.#options.config.id,
|
|
418
420
|
file: entry.name,
|
|
419
421
|
}));
|
|
420
422
|
}
|
package/lib/login.js
CHANGED
|
@@ -67,7 +67,7 @@ function sleepWithAbort(ms, signal) {
|
|
|
67
67
|
export async function resolveCredentials(config, signal) {
|
|
68
68
|
const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
|
|
69
69
|
if (envToken) {
|
|
70
|
-
const stored = loadCredentials(config.
|
|
70
|
+
const stored = loadCredentials(config.id);
|
|
71
71
|
return {
|
|
72
72
|
botToken: envToken,
|
|
73
73
|
ilinkUserId: stored?.ilinkUserId,
|
|
@@ -75,7 +75,7 @@ export async function resolveCredentials(config, signal) {
|
|
|
75
75
|
baseUrl: config.baseUrl ?? stored?.baseUrl ?? DEFAULT_API_BASE_URL,
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
const stored = loadCredentials(config.
|
|
78
|
+
const stored = loadCredentials(config.id);
|
|
79
79
|
if (stored?.botToken) {
|
|
80
80
|
return {
|
|
81
81
|
...stored,
|
|
@@ -88,7 +88,7 @@ export async function loginWithQr(config, signal) {
|
|
|
88
88
|
const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
|
|
89
89
|
const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
|
|
90
90
|
const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
|
|
91
|
-
logger.info(`weixin-ilink QR login for ${config.
|
|
91
|
+
logger.info(`weixin-ilink QR login for ${config.id}: scan with WeChat ClawBot entry — ${qrcodeUrl.slice(0, 120)}${qrcodeUrl.length > 120 ? '…' : ''}`);
|
|
92
92
|
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
93
93
|
let currentBaseUrl = apiBaseUrl;
|
|
94
94
|
while (Date.now() < deadline) {
|
|
@@ -106,7 +106,7 @@ export async function loginWithQr(config, signal) {
|
|
|
106
106
|
throw new Error('扫码成功但未返回 bot_token');
|
|
107
107
|
}
|
|
108
108
|
const creds = {
|
|
109
|
-
botToken: status.bot_token ?? loadCredentials(config.
|
|
109
|
+
botToken: status.bot_token ?? loadCredentials(config.id)?.botToken ?? '',
|
|
110
110
|
ilinkUserId: status.ilink_user_id,
|
|
111
111
|
ilinkBotId: status.ilink_endpoint_id,
|
|
112
112
|
baseUrl: status.baseurl ?? currentBaseUrl,
|
|
@@ -114,7 +114,7 @@ export async function loginWithQr(config, signal) {
|
|
|
114
114
|
if (!creds.botToken) {
|
|
115
115
|
throw new Error('binded_redirect 但本地无有效 bot_token');
|
|
116
116
|
}
|
|
117
|
-
saveCredentials(config.
|
|
117
|
+
saveCredentials(config.id, creds);
|
|
118
118
|
return creds;
|
|
119
119
|
}
|
|
120
120
|
if (status.status === 'expired') {
|
package/lib/protocol.d.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Weixin iLink protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
|
|
3
3
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
4
|
*/
|
|
5
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
5
6
|
import type { WeixinMessage } from './ilink-types.js';
|
|
6
7
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
7
8
|
export interface WeixinIlinkAdapterConfig {
|
|
8
|
-
readonly
|
|
9
|
+
readonly id?: string;
|
|
9
10
|
readonly botAgent?: string;
|
|
10
11
|
readonly baseUrl?: string;
|
|
11
12
|
readonly cdnBaseUrl?: string;
|
|
@@ -18,7 +19,7 @@ export interface WeixinIlinkAdapterConfig {
|
|
|
18
19
|
}
|
|
19
20
|
export interface ResolvedWeixinIlinkConfig {
|
|
20
21
|
readonly context: 'weixin-ilink';
|
|
21
|
-
readonly
|
|
22
|
+
readonly id: string;
|
|
22
23
|
readonly botAgent?: string;
|
|
23
24
|
readonly baseUrl: string;
|
|
24
25
|
readonly cdnBaseUrl: string;
|
|
@@ -43,6 +44,11 @@ export declare function resolveWeixinIlinkConfig(config?: WeixinIlinkAdapterConf
|
|
|
43
44
|
/** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
|
|
44
45
|
export declare function formatInboundContent(msg: WeixinMessageWithMedia): string;
|
|
45
46
|
export declare function inboundMessageId(msg: WeixinMessage): string;
|
|
47
|
+
/**
|
|
48
|
+
* 入站归一化 → ConversationRef:个人微信无群/频道概念,全部会话都是
|
|
49
|
+
* 与 `from_user_id` 的 private 会话(无 parent)。
|
|
50
|
+
*/
|
|
51
|
+
export declare function weixinIlinkInboundConversation(endpointKey: string, userId: string): ConversationRef;
|
|
46
52
|
/**
|
|
47
53
|
* Wire-encode an already-rendered outbound payload into Weixin wire segments.
|
|
48
54
|
* Segment canonicalization is intentionally not done here.
|
package/lib/protocol.js
CHANGED
|
@@ -9,8 +9,8 @@ import { DEFAULT_API_BASE_URL, DEFAULT_CDN_BASE_URL } from './ilink-meta.js';
|
|
|
9
9
|
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
10
10
|
export function resolveWeixinIlinkConfig(config = {}) {
|
|
11
11
|
const entry = config.endpoints?.find((item) => item.context === 'weixin-ilink');
|
|
12
|
-
const
|
|
13
|
-
|| (typeof entry?.
|
|
12
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
13
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
14
14
|
|| process.env.WEIXIN_ILINK_BOT_NAME
|
|
15
15
|
|| 'weixin-ilink-bot';
|
|
16
16
|
const botToken = pickCredential(typeof config.botToken === 'string' ? config.botToken.trim() : config.botToken, typeof entry?.botToken === 'string' ? entry.botToken.trim() : entry?.botToken, process.env.WEIXIN_ILINK_TOKEN?.trim());
|
|
@@ -18,7 +18,7 @@ export function resolveWeixinIlinkConfig(config = {}) {
|
|
|
18
18
|
// (sidecar credentials file / QR login) during endpoint.start().
|
|
19
19
|
return {
|
|
20
20
|
context: 'weixin-ilink',
|
|
21
|
-
|
|
21
|
+
id,
|
|
22
22
|
botAgent: config.botAgent ?? entry?.botAgent,
|
|
23
23
|
baseUrl: config.baseUrl ?? entry?.baseUrl ?? DEFAULT_API_BASE_URL,
|
|
24
24
|
cdnBaseUrl: config.cdnBaseUrl ?? entry?.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL,
|
|
@@ -55,6 +55,17 @@ export function formatInboundContent(msg) {
|
|
|
55
55
|
export function inboundMessageId(msg) {
|
|
56
56
|
return String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* 入站归一化 → ConversationRef:个人微信无群/频道概念,全部会话都是
|
|
60
|
+
* 与 `from_user_id` 的 private 会话(无 parent)。
|
|
61
|
+
*/
|
|
62
|
+
export function weixinIlinkInboundConversation(endpointKey, userId) {
|
|
63
|
+
return {
|
|
64
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
65
|
+
kind: 'private',
|
|
66
|
+
id: userId,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
58
69
|
/**
|
|
59
70
|
* Wire-encode an already-rendered outbound payload into Weixin wire segments.
|
|
60
71
|
* Segment canonicalization is intentionally not done here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-weixin-ilink",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -48,17 +48,18 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@zhin.js/adapter": "1.1.
|
|
52
|
-
"@zhin.js/command": "1.0.
|
|
53
|
-
"@zhin.js/core": "1.5.
|
|
54
|
-
"@zhin.js/
|
|
55
|
-
"@zhin.js/
|
|
51
|
+
"@zhin.js/adapter": "1.1.7",
|
|
52
|
+
"@zhin.js/command": "1.0.9",
|
|
53
|
+
"@zhin.js/core": "1.5.4",
|
|
54
|
+
"@zhin.js/im-contract": "1.0.3",
|
|
55
|
+
"@zhin.js/logger": "1.0.76",
|
|
56
|
+
"@zhin.js/plugin-runtime": "1.1.5"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@zhin.js/adapter": "1.1.
|
|
59
|
-
"@zhin.js/core": "1.5.
|
|
60
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
61
|
-
"zhin.js": "6.0.
|
|
59
|
+
"@zhin.js/adapter": "1.1.7",
|
|
60
|
+
"@zhin.js/core": "1.5.4",
|
|
61
|
+
"@zhin.js/plugin-runtime": "1.1.5",
|
|
62
|
+
"zhin.js": "6.0.4"
|
|
62
63
|
},
|
|
63
64
|
"peerDependenciesMeta": {
|
|
64
65
|
"zhin.js": {
|
package/schema.json
CHANGED
|
@@ -18,24 +18,60 @@
|
|
|
18
18
|
"type": "number",
|
|
19
19
|
"default": 35000
|
|
20
20
|
},
|
|
21
|
+
"master": {
|
|
22
|
+
"type": [
|
|
23
|
+
"string",
|
|
24
|
+
"number"
|
|
25
|
+
],
|
|
26
|
+
"description": "框架 master(Weixin user id;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖"
|
|
27
|
+
},
|
|
28
|
+
"trusted": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"items": {
|
|
31
|
+
"type": [
|
|
32
|
+
"string",
|
|
33
|
+
"number"
|
|
34
|
+
],
|
|
35
|
+
"description": "Trusted Weixin user id"
|
|
36
|
+
},
|
|
37
|
+
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加"
|
|
38
|
+
},
|
|
21
39
|
"endpoints": {
|
|
22
40
|
"type": "array",
|
|
23
|
-
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(
|
|
41
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
|
|
24
42
|
"items": {
|
|
25
43
|
"type": "object",
|
|
26
44
|
"additionalProperties": true,
|
|
27
45
|
"properties": {
|
|
28
|
-
"
|
|
29
|
-
"type":
|
|
30
|
-
|
|
46
|
+
"master": {
|
|
47
|
+
"type": [
|
|
48
|
+
"string",
|
|
49
|
+
"number"
|
|
50
|
+
],
|
|
51
|
+
"description": "本 endpoint 的框架 master(Weixin user id);覆盖顶层 master"
|
|
52
|
+
},
|
|
53
|
+
"trusted": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": {
|
|
56
|
+
"type": [
|
|
57
|
+
"string",
|
|
58
|
+
"number"
|
|
59
|
+
],
|
|
60
|
+
"description": "Trusted Weixin user id"
|
|
61
|
+
},
|
|
62
|
+
"description": "本 endpoint 的 trusted 列表"
|
|
31
63
|
},
|
|
32
64
|
"botToken": {
|
|
33
65
|
"type": "string",
|
|
34
66
|
"description": "iLink bot token (prefer env WEIXIN_ILINK_TOKEN or sidecar credential file)"
|
|
67
|
+
},
|
|
68
|
+
"id": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"description": "Weixin iLink bot name"
|
|
35
71
|
}
|
|
36
72
|
},
|
|
37
73
|
"required": [
|
|
38
|
-
"
|
|
74
|
+
"id",
|
|
39
75
|
"botToken"
|
|
40
76
|
]
|
|
41
77
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -17,12 +17,12 @@ export function resolveStateDir(): string {
|
|
|
17
17
|
: DEFAULT_DATA_DIR;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function credentialsPath(
|
|
21
|
-
return path.join(resolveStateDir(), `${
|
|
20
|
+
export function credentialsPath(endpointId: string): string {
|
|
21
|
+
return path.join(resolveStateDir(), `${endpointId}.json`);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function loadCredentials(
|
|
25
|
-
const file = credentialsPath(
|
|
24
|
+
export function loadCredentials(endpointId: string): WeixinIlinkCredentials | null {
|
|
25
|
+
const file = credentialsPath(endpointId);
|
|
26
26
|
try {
|
|
27
27
|
if (!fs.existsSync(file)) return null;
|
|
28
28
|
const raw = fs.readFileSync(file, "utf-8");
|
|
@@ -34,8 +34,8 @@ export function loadCredentials(endpointName: string): WeixinIlinkCredentials |
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function saveCredentials(
|
|
38
|
-
const file = credentialsPath(
|
|
37
|
+
export function saveCredentials(endpointId: string, creds: WeixinIlinkCredentials): void {
|
|
38
|
+
const file = credentialsPath(endpointId);
|
|
39
39
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
40
40
|
fs.writeFileSync(
|
|
41
41
|
file,
|
|
@@ -44,12 +44,12 @@ export function saveCredentials(endpointName: string, creds: WeixinIlinkCredenti
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function syncBufPath(
|
|
48
|
-
return path.join(resolveStateDir(), `${
|
|
47
|
+
export function syncBufPath(endpointId: string): string {
|
|
48
|
+
return path.join(resolveStateDir(), `${endpointId}.sync-buf`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export function loadSyncBuf(
|
|
52
|
-
const file = syncBufPath(
|
|
51
|
+
export function loadSyncBuf(endpointId: string): string {
|
|
52
|
+
const file = syncBufPath(endpointId);
|
|
53
53
|
try {
|
|
54
54
|
return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : "";
|
|
55
55
|
} catch {
|
|
@@ -57,8 +57,8 @@ export function loadSyncBuf(endpointName: string): string {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function saveSyncBuf(
|
|
61
|
-
const file = syncBufPath(
|
|
60
|
+
export function saveSyncBuf(endpointId: string, buf: string): void {
|
|
61
|
+
const file = syncBufPath(endpointId);
|
|
62
62
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
63
|
fs.writeFileSync(file, buf, "utf-8");
|
|
64
64
|
}
|
package/src/endpoint.ts
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
EndpointFriend,
|
|
9
|
+
EndpointInstance,
|
|
10
|
+
EndpointManagement,
|
|
11
|
+
EndpointSendRequest,
|
|
12
|
+
} from '@zhin.js/adapter';
|
|
8
13
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
9
|
-
import { formatCompact,
|
|
14
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
10
15
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
11
16
|
import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
|
|
12
17
|
import { configureIlinkMeta } from './ilink-meta.js';
|
|
@@ -44,13 +49,12 @@ import {
|
|
|
44
49
|
inboundMessageId,
|
|
45
50
|
segmentLocalPath,
|
|
46
51
|
sleep,
|
|
52
|
+
weixinIlinkInboundConversation,
|
|
47
53
|
type ResolvedWeixinIlinkConfig,
|
|
48
54
|
type WeixinMessageWithMedia,
|
|
49
55
|
type WeixinWireSegment,
|
|
50
56
|
} from './protocol.js';
|
|
51
57
|
|
|
52
|
-
const logger = getLogger('weixin-ilink');
|
|
53
|
-
|
|
54
58
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
55
59
|
const BACKOFF_DELAY_MS = 30_000;
|
|
56
60
|
const RETRY_DELAY_MS = 2_000;
|
|
@@ -80,6 +84,8 @@ export interface WeixinIlinkEndpointOptions {
|
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
87
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
88
|
+
|
|
83
89
|
readonly #options: WeixinIlinkEndpointOptions;
|
|
84
90
|
readonly #resolveCredentials: (
|
|
85
91
|
config: ResolvedWeixinIlinkConfig,
|
|
@@ -100,6 +106,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
100
106
|
readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
|
|
101
107
|
|
|
102
108
|
constructor(options: WeixinIlinkEndpointOptions) {
|
|
109
|
+
this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
|
|
103
110
|
this.#options = options;
|
|
104
111
|
this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
|
|
105
112
|
this.#notifyStart = options.notifyStart ?? notifyStart;
|
|
@@ -114,7 +121,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
114
121
|
|
|
115
122
|
/** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
|
|
116
123
|
get configName(): string {
|
|
117
|
-
return this.#options.config.
|
|
124
|
+
return this.#options.config.id;
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
apiBaseUrl(): string {
|
|
@@ -133,7 +140,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
133
140
|
// QR 登录最长 8 分钟:stop() 必须能打断
|
|
134
141
|
this.#loginAbort = new AbortController();
|
|
135
142
|
this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
|
|
136
|
-
restoreContextTokens(this.#options.config.
|
|
143
|
+
restoreContextTokens(this.#options.config.id);
|
|
137
144
|
|
|
138
145
|
await this.#notifyStart({
|
|
139
146
|
baseUrl: this.apiBaseUrl(),
|
|
@@ -142,19 +149,19 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
142
149
|
|
|
143
150
|
this.#configManager = new WeixinConfigManager(
|
|
144
151
|
{ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken },
|
|
145
|
-
(msg) => logger.debug(msg),
|
|
152
|
+
(msg) => this.#logger.debug(msg),
|
|
146
153
|
);
|
|
147
154
|
|
|
148
155
|
this.#pollAbort = new AbortController();
|
|
149
156
|
this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
|
|
150
157
|
this.#startMediaSweep();
|
|
151
|
-
logger.info(formatCompact({
|
|
158
|
+
this.#logger.info(formatCompact({
|
|
152
159
|
op: 'connect',
|
|
153
|
-
endpoint: this.#options.config.
|
|
160
|
+
endpoint: this.#options.config.id,
|
|
154
161
|
}));
|
|
155
162
|
} catch (error) {
|
|
156
163
|
await this.stop();
|
|
157
|
-
logger.error('Failed to connect weixin-ilink bot:', error);
|
|
164
|
+
this.#logger.error('Failed to connect weixin-ilink bot:', error);
|
|
158
165
|
throw error;
|
|
159
166
|
}
|
|
160
167
|
}
|
|
@@ -178,12 +185,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
178
185
|
}
|
|
179
186
|
this.#stopMediaSweep();
|
|
180
187
|
// 防抖中的 context token 落盘,避免 stop 丢尾部写入
|
|
181
|
-
flushContextTokenPersist(this.#options.config.
|
|
188
|
+
flushContextTokenPersist(this.#options.config.id);
|
|
182
189
|
if (this.#creds?.botToken) {
|
|
183
190
|
try {
|
|
184
191
|
await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
|
|
185
192
|
} catch (err) {
|
|
186
|
-
logger.warn(formatCompact({
|
|
193
|
+
this.#logger.warn(formatCompact({
|
|
187
194
|
op: 'notify_stop',
|
|
188
195
|
ok: false,
|
|
189
196
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -191,19 +198,20 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
this.#started = false;
|
|
194
|
-
logger.debug(formatCompact({
|
|
195
|
-
|
|
196
|
-
endpoint: this.#options.config.name,
|
|
201
|
+
this.#logger.debug(formatCompact({
|
|
202
|
+
op: 'disconnect',
|
|
197
203
|
}));
|
|
198
204
|
}
|
|
199
205
|
|
|
200
|
-
async send({
|
|
206
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
201
207
|
if (!this.#creds?.botToken) {
|
|
202
208
|
throw new Error('weixin-ilink bot not authenticated');
|
|
203
209
|
}
|
|
204
|
-
|
|
210
|
+
// 个人微信仅 private 会话,对端 user_id 即 conversation.id
|
|
211
|
+
const target = conversation.id;
|
|
212
|
+
const contextToken = getContextToken(this.#options.config.id, target);
|
|
205
213
|
if (!contextToken) {
|
|
206
|
-
logger.warn(formatCompact({
|
|
214
|
+
this.#logger.warn(formatCompact({
|
|
207
215
|
op: 'send',
|
|
208
216
|
ok: false,
|
|
209
217
|
reason: 'missing_context_token',
|
|
@@ -261,7 +269,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
|
|
264
|
-
logger.warn(formatCompact({
|
|
272
|
+
this.#logger.warn(formatCompact({
|
|
265
273
|
op: 'send',
|
|
266
274
|
skip: 'no_local_file',
|
|
267
275
|
type: seg.type,
|
|
@@ -286,21 +294,21 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
286
294
|
if (!this.#open) return;
|
|
287
295
|
const userId = msg.from_user_id ?? '';
|
|
288
296
|
if (msg.context_token && userId) {
|
|
289
|
-
setContextToken(this.#options.config.
|
|
297
|
+
setContextToken(this.#options.config.id, userId, msg.context_token);
|
|
290
298
|
}
|
|
299
|
+
const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
|
|
291
300
|
void this.#options.gateway.receive({
|
|
292
|
-
|
|
293
|
-
|
|
301
|
+
conversation,
|
|
302
|
+
message: { conversation, id: inboundMessageId(msg) },
|
|
294
303
|
content: formatInboundContent(msg),
|
|
295
|
-
sender: userId,
|
|
296
|
-
|
|
304
|
+
sender: { id: userId },
|
|
305
|
+
endpointId: this.#options.config.id,
|
|
297
306
|
metadata: Object.freeze({
|
|
298
|
-
endpoint: this.#options.config.name,
|
|
299
307
|
messageType: msg.message_type,
|
|
300
308
|
createTimeMs: msg.create_time_ms,
|
|
301
309
|
}),
|
|
302
310
|
}).catch((err) => {
|
|
303
|
-
logger.warn(formatCompact({
|
|
311
|
+
this.#logger.warn(formatCompact({
|
|
304
312
|
op: 'weixin_ilink_gateway_receive_failed',
|
|
305
313
|
target: userId,
|
|
306
314
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -310,10 +318,10 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
310
318
|
|
|
311
319
|
async sendTypingToUser(userId: string, status: number): Promise<void> {
|
|
312
320
|
if (!this.#creds?.botToken || !this.#configManager) return;
|
|
313
|
-
const contextToken = getContextToken(this.#options.config.
|
|
321
|
+
const contextToken = getContextToken(this.#options.config.id, userId);
|
|
314
322
|
const cfg = await this.#configManager.getForUser(userId, contextToken);
|
|
315
323
|
if (!cfg.typingTicket) {
|
|
316
|
-
logger.debug(formatCompact({
|
|
324
|
+
this.#logger.debug(formatCompact({
|
|
317
325
|
op: 'send_typing',
|
|
318
326
|
skip: 'no_typing_ticket',
|
|
319
327
|
userId,
|
|
@@ -334,14 +342,14 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
334
342
|
async #pollLoop(abortSignal: AbortSignal): Promise<void> {
|
|
335
343
|
if (!this.#creds?.botToken) return;
|
|
336
344
|
|
|
337
|
-
const
|
|
338
|
-
let getUpdatesBuf = loadSyncBuf(
|
|
345
|
+
const endpointId = this.#options.config.id;
|
|
346
|
+
let getUpdatesBuf = loadSyncBuf(endpointId);
|
|
339
347
|
let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
|
|
340
348
|
let consecutiveFailures = 0;
|
|
341
349
|
|
|
342
350
|
while (!abortSignal.aborted) {
|
|
343
|
-
if (isSessionPaused(
|
|
344
|
-
await sleep(getRemainingPauseMs(
|
|
351
|
+
if (isSessionPaused(endpointId)) {
|
|
352
|
+
await sleep(getRemainingPauseMs(endpointId), abortSignal);
|
|
345
353
|
continue;
|
|
346
354
|
}
|
|
347
355
|
|
|
@@ -366,9 +374,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
366
374
|
const sessionExpired =
|
|
367
375
|
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
368
376
|
if (sessionExpired) {
|
|
369
|
-
pauseSession(
|
|
377
|
+
pauseSession(endpointId);
|
|
370
378
|
consecutiveFailures = 0;
|
|
371
|
-
await sleep(getRemainingPauseMs(
|
|
379
|
+
await sleep(getRemainingPauseMs(endpointId), abortSignal);
|
|
372
380
|
continue;
|
|
373
381
|
}
|
|
374
382
|
consecutiveFailures += 1;
|
|
@@ -389,12 +397,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
389
397
|
}
|
|
390
398
|
if (nextBuf) {
|
|
391
399
|
getUpdatesBuf = nextBuf;
|
|
392
|
-
saveSyncBuf(
|
|
400
|
+
saveSyncBuf(endpointId, getUpdatesBuf);
|
|
393
401
|
}
|
|
394
402
|
} catch (err) {
|
|
395
403
|
if (abortSignal.aborted) return;
|
|
396
404
|
consecutiveFailures += 1;
|
|
397
|
-
logger.error(formatCompact({
|
|
405
|
+
this.#logger.error(formatCompact({
|
|
398
406
|
op: 'poll',
|
|
399
407
|
ok: false,
|
|
400
408
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -411,14 +419,14 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
411
419
|
async #handleInboundMessage(full: WeixinMessage): Promise<void> {
|
|
412
420
|
const fromUserId = full.from_user_id ?? '';
|
|
413
421
|
if (full.context_token && fromUserId) {
|
|
414
|
-
setContextToken(this.#options.config.
|
|
422
|
+
setContextToken(this.#options.config.id, fromUserId, full.context_token);
|
|
415
423
|
}
|
|
416
424
|
|
|
417
425
|
const mediaOpts = await this.#downloadInboundMedia(full);
|
|
418
426
|
this.admit({ ...full, _media: mediaOpts });
|
|
419
|
-
logger.debug(formatCompact({
|
|
427
|
+
this.#logger.debug(formatCompact({
|
|
420
428
|
op: 'recv',
|
|
421
|
-
endpoint: this.#options.config.
|
|
429
|
+
endpoint: this.#options.config.id,
|
|
422
430
|
target: fromUserId,
|
|
423
431
|
preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
|
|
424
432
|
}));
|
|
@@ -448,8 +456,8 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
448
456
|
cdnBaseUrl: this.cdnBaseUrl(),
|
|
449
457
|
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
|
|
450
458
|
this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
451
|
-
log: (msg) => logger.debug(msg),
|
|
452
|
-
errLog: (msg) => logger.warn(msg),
|
|
459
|
+
log: (msg) => this.#logger.debug(msg),
|
|
460
|
+
errLog: (msg) => this.#logger.warn(msg),
|
|
453
461
|
label: `inbound:${full.from_user_id ?? 'unknown'}`,
|
|
454
462
|
});
|
|
455
463
|
}
|
|
@@ -513,9 +521,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
|
|
|
513
521
|
const stat = fs.statSync(filePath);
|
|
514
522
|
if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS) continue;
|
|
515
523
|
fs.unlinkSync(filePath);
|
|
516
|
-
logger.debug(formatCompact({
|
|
524
|
+
this.#logger.debug(formatCompact({
|
|
517
525
|
op: 'media_sweep',
|
|
518
|
-
endpoint: this.#options.config.
|
|
526
|
+
endpoint: this.#options.config.id,
|
|
519
527
|
file: entry.name,
|
|
520
528
|
}));
|
|
521
529
|
} catch {
|
package/src/login.ts
CHANGED
|
@@ -106,7 +106,7 @@ export async function resolveCredentials(
|
|
|
106
106
|
): Promise<WeixinIlinkCredentials> {
|
|
107
107
|
const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
|
|
108
108
|
if (envToken) {
|
|
109
|
-
const stored = loadCredentials(config.
|
|
109
|
+
const stored = loadCredentials(config.id);
|
|
110
110
|
return {
|
|
111
111
|
botToken: envToken,
|
|
112
112
|
ilinkUserId: stored?.ilinkUserId,
|
|
@@ -115,7 +115,7 @@ export async function resolveCredentials(
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
const stored = loadCredentials(config.
|
|
118
|
+
const stored = loadCredentials(config.id);
|
|
119
119
|
if (stored?.botToken) {
|
|
120
120
|
return {
|
|
121
121
|
...stored,
|
|
@@ -135,7 +135,7 @@ export async function loginWithQr(
|
|
|
135
135
|
const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
|
|
136
136
|
|
|
137
137
|
logger.info(
|
|
138
|
-
`weixin-ilink QR login for ${config.
|
|
138
|
+
`weixin-ilink QR login for ${config.id}: scan with WeChat ClawBot entry — ${qrcodeUrl.slice(0, 120)}${qrcodeUrl.length > 120 ? '…' : ''}`,
|
|
139
139
|
);
|
|
140
140
|
|
|
141
141
|
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
@@ -157,7 +157,7 @@ export async function loginWithQr(
|
|
|
157
157
|
throw new Error('扫码成功但未返回 bot_token');
|
|
158
158
|
}
|
|
159
159
|
const creds: WeixinIlinkCredentials = {
|
|
160
|
-
botToken: status.bot_token ?? loadCredentials(config.
|
|
160
|
+
botToken: status.bot_token ?? loadCredentials(config.id)?.botToken ?? '',
|
|
161
161
|
ilinkUserId: status.ilink_user_id,
|
|
162
162
|
ilinkBotId: status.ilink_endpoint_id,
|
|
163
163
|
baseUrl: status.baseurl ?? currentBaseUrl,
|
|
@@ -165,7 +165,7 @@ export async function loginWithQr(
|
|
|
165
165
|
if (!creds.botToken) {
|
|
166
166
|
throw new Error('binded_redirect 但本地无有效 bot_token');
|
|
167
167
|
}
|
|
168
|
-
saveCredentials(config.
|
|
168
|
+
saveCredentials(config.id, creds);
|
|
169
169
|
return creds;
|
|
170
170
|
}
|
|
171
171
|
|
package/src/protocol.ts
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
import { pickCredential } from '@zhin.js/adapter';
|
|
7
7
|
import { isMediaRef } from '@zhin.js/core';
|
|
8
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
8
9
|
import { bodyFromItemList, isMediaItem } from './weixin-inbound.js';
|
|
9
10
|
import { DEFAULT_API_BASE_URL, DEFAULT_CDN_BASE_URL } from './ilink-meta.js';
|
|
10
11
|
import type { WeixinMessage } from './ilink-types.js';
|
|
11
12
|
|
|
12
13
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
13
14
|
export interface WeixinIlinkAdapterConfig {
|
|
14
|
-
readonly
|
|
15
|
+
readonly id?: string;
|
|
15
16
|
readonly botAgent?: string;
|
|
16
17
|
readonly baseUrl?: string;
|
|
17
18
|
readonly cdnBaseUrl?: string;
|
|
@@ -25,7 +26,7 @@ export interface WeixinIlinkAdapterConfig {
|
|
|
25
26
|
|
|
26
27
|
export interface ResolvedWeixinIlinkConfig {
|
|
27
28
|
readonly context: 'weixin-ilink';
|
|
28
|
-
readonly
|
|
29
|
+
readonly id: string;
|
|
29
30
|
readonly botAgent?: string;
|
|
30
31
|
readonly baseUrl: string;
|
|
31
32
|
readonly cdnBaseUrl: string;
|
|
@@ -57,8 +58,8 @@ export function resolveWeixinIlinkConfig(
|
|
|
57
58
|
config: WeixinIlinkAdapterConfig = {},
|
|
58
59
|
): ResolvedWeixinIlinkConfig {
|
|
59
60
|
const entry = config.endpoints?.find((item) => item.context === 'weixin-ilink');
|
|
60
|
-
const
|
|
61
|
-
|| (typeof entry?.
|
|
61
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
62
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
62
63
|
|| process.env.WEIXIN_ILINK_BOT_NAME
|
|
63
64
|
|| 'weixin-ilink-bot';
|
|
64
65
|
const botToken = pickCredential(
|
|
@@ -70,7 +71,7 @@ export function resolveWeixinIlinkConfig(
|
|
|
70
71
|
// (sidecar credentials file / QR login) during endpoint.start().
|
|
71
72
|
return {
|
|
72
73
|
context: 'weixin-ilink',
|
|
73
|
-
|
|
74
|
+
id,
|
|
74
75
|
botAgent: config.botAgent ?? entry?.botAgent,
|
|
75
76
|
baseUrl: config.baseUrl ?? entry?.baseUrl ?? DEFAULT_API_BASE_URL,
|
|
76
77
|
cdnBaseUrl: config.cdnBaseUrl ?? entry?.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL,
|
|
@@ -107,6 +108,18 @@ export function inboundMessageId(msg: WeixinMessage): string {
|
|
|
107
108
|
return String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 入站归一化 → ConversationRef:个人微信无群/频道概念,全部会话都是
|
|
113
|
+
* 与 `from_user_id` 的 private 会话(无 parent)。
|
|
114
|
+
*/
|
|
115
|
+
export function weixinIlinkInboundConversation(endpointKey: string, userId: string): ConversationRef {
|
|
116
|
+
return {
|
|
117
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
118
|
+
kind: 'private',
|
|
119
|
+
id: userId,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
/**
|
|
111
124
|
* Wire-encode an already-rendered outbound payload into Weixin wire segments.
|
|
112
125
|
* Segment canonicalization is intentionally not done here.
|
package/src/silk-wasm.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* silk-wasm 是纯可选运行时依赖(不入 package.json):silk-transcode.ts 在
|
|
3
|
+
* try/catch 里动态 import,缺失时降级返回 null。这里只声明用到的最小表面。
|
|
4
|
+
*/
|
|
5
|
+
declare module 'silk-wasm' {
|
|
2
6
|
export function decode(
|
|
3
|
-
|
|
7
|
+
input: Uint8Array,
|
|
4
8
|
sampleRate: number,
|
|
5
9
|
): Promise<{ data: Uint8Array; duration: number }>;
|
|
6
10
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|