@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 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({ target, payload })`。
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)
@@ -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.name, {
23
- name: config.name,
24
- mode: config.connection,
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,
@@ -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.name, {
34
- name: config.name,
35
- mode: config.connection,
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,
@@ -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(endpointName: string): string;
10
- export declare function loadCredentials(endpointName: string): WeixinIlinkCredentials | null;
11
- export declare function saveCredentials(endpointName: string, creds: WeixinIlinkCredentials): void;
12
- export declare function syncBufPath(endpointName: string): string;
13
- export declare function loadSyncBuf(endpointName: string): string;
14
- export declare function saveSyncBuf(endpointName: string, buf: string): void;
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;
@@ -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(endpointName) {
10
- return path.join(resolveStateDir(), `${endpointName}.json`);
9
+ export function credentialsPath(endpointId) {
10
+ return path.join(resolveStateDir(), `${endpointId}.json`);
11
11
  }
12
- export function loadCredentials(endpointName) {
13
- const file = credentialsPath(endpointName);
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(endpointName, creds) {
28
- const file = credentialsPath(endpointName);
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(endpointName) {
33
- return path.join(resolveStateDir(), `${endpointName}.sync-buf`);
32
+ export function syncBufPath(endpointId) {
33
+ return path.join(resolveStateDir(), `${endpointId}.sync-buf`);
34
34
  }
35
- export function loadSyncBuf(endpointName) {
36
- const file = syncBufPath(endpointName);
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(endpointName, buf) {
45
- const file = syncBufPath(endpointName);
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({ target, payload }: {
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, getLogger } from '@zhin.js/logger';
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.name;
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.name);
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.name,
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.name);
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({ target, payload }) {
133
+ async send({ conversation, payload }) {
134
134
  if (!this.#creds?.botToken) {
135
135
  throw new Error('weixin-ilink bot not authenticated');
136
136
  }
137
- const contextToken = getContextToken(this.#options.config.name, target);
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.name, userId, msg.context_token);
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
- adapter: this.#options.id,
220
- target: userId,
222
+ conversation,
223
+ message: { conversation, id: inboundMessageId(msg) },
221
224
  content: formatInboundContent(msg),
222
- sender: userId,
223
- id: inboundMessageId(msg),
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.name, userId);
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 endpointName = this.#options.config.name;
264
- let getUpdatesBuf = loadSyncBuf(endpointName);
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(endpointName)) {
269
- await sleep(getRemainingPauseMs(endpointName), abortSignal);
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(endpointName);
290
+ pauseSession(endpointId);
289
291
  consecutiveFailures = 0;
290
- await sleep(getRemainingPauseMs(endpointName), abortSignal);
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(endpointName, getUpdatesBuf);
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.name, fromUserId, full.context_token);
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.name,
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.name,
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.name);
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.name);
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.name}: scan with WeChat ClawBot entry — ${qrcodeUrl.slice(0, 120)}${qrcodeUrl.length > 120 ? '…' : ''}`);
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.name)?.botToken ?? '',
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.name, creds);
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 name?: string;
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 name: string;
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 name = (typeof config.name === 'string' && config.name)
13
- || (typeof entry?.name === 'string' && entry.name)
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
- name,
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.1",
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.4",
52
- "@zhin.js/command": "1.0.6",
53
- "@zhin.js/core": "1.5.1",
54
- "@zhin.js/logger": "1.0.75",
55
- "@zhin.js/plugin-runtime": "1.1.2"
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.4",
59
- "@zhin.js/core": "1.5.1",
60
- "@zhin.js/plugin-runtime": "1.1.2",
61
- "zhin.js": "6.0.1"
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。每项与顶层字段同构(name 必填,其余覆盖顶层)",
41
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
24
42
  "items": {
25
43
  "type": "object",
26
44
  "additionalProperties": true,
27
45
  "properties": {
28
- "name": {
29
- "type": "string",
30
- "description": "Weixin iLink bot name"
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
- "name",
74
+ "id",
39
75
  "botToken"
40
76
  ]
41
77
  }
@@ -17,12 +17,12 @@ export function resolveStateDir(): string {
17
17
  : DEFAULT_DATA_DIR;
18
18
  }
19
19
 
20
- export function credentialsPath(endpointName: string): string {
21
- return path.join(resolveStateDir(), `${endpointName}.json`);
20
+ export function credentialsPath(endpointId: string): string {
21
+ return path.join(resolveStateDir(), `${endpointId}.json`);
22
22
  }
23
23
 
24
- export function loadCredentials(endpointName: string): WeixinIlinkCredentials | null {
25
- const file = credentialsPath(endpointName);
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(endpointName: string, creds: WeixinIlinkCredentials): void {
38
- const file = credentialsPath(endpointName);
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(endpointName: string): string {
48
- return path.join(resolveStateDir(), `${endpointName}.sync-buf`);
47
+ export function syncBufPath(endpointId: string): string {
48
+ return path.join(resolveStateDir(), `${endpointId}.sync-buf`);
49
49
  }
50
50
 
51
- export function loadSyncBuf(endpointName: string): string {
52
- const file = syncBufPath(endpointName);
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(endpointName: string, buf: string): void {
61
- const file = syncBufPath(endpointName);
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 { EndpointFriend, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
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, getLogger } from '@zhin.js/logger';
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.name;
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.name);
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.name,
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.name);
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
- op: 'disconnect',
196
- endpoint: this.#options.config.name,
201
+ this.#logger.debug(formatCompact({
202
+ op: 'disconnect',
197
203
  }));
198
204
  }
199
205
 
200
- async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
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
- const contextToken = getContextToken(this.#options.config.name, target);
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.name, userId, msg.context_token);
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
- adapter: this.#options.id,
293
- target: userId,
301
+ conversation,
302
+ message: { conversation, id: inboundMessageId(msg) },
294
303
  content: formatInboundContent(msg),
295
- sender: userId,
296
- id: inboundMessageId(msg),
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.name, userId);
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 endpointName = this.#options.config.name;
338
- let getUpdatesBuf = loadSyncBuf(endpointName);
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(endpointName)) {
344
- await sleep(getRemainingPauseMs(endpointName), abortSignal);
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(endpointName);
377
+ pauseSession(endpointId);
370
378
  consecutiveFailures = 0;
371
- await sleep(getRemainingPauseMs(endpointName), abortSignal);
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(endpointName, getUpdatesBuf);
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.name, fromUserId, full.context_token);
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.name,
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.name,
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.name);
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.name);
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.name}: scan with WeChat ClawBot entry — ${qrcodeUrl.slice(0, 120)}${qrcodeUrl.length > 120 ? '…' : ''}`,
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.name)?.botToken ?? '',
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.name, creds);
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 name?: string;
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 name: string;
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 name = (typeof config.name === 'string' && config.name)
61
- || (typeof entry?.name === 'string' && entry.name)
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
- name,
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.
@@ -1,6 +1,10 @@
1
- declare module "silk-wasm" {
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
- buf: Buffer,
7
+ input: Uint8Array,
4
8
  sampleRate: number,
5
9
  ): Promise<{ data: Uint8Array; duration: number }>;
6
10
  }