@zhin.js/adapter-weixin-ilink 6.0.0 → 6.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @zhin.js/adapter-weixin-ilink
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [ba7e17a]
8
+ - Updated dependencies [7108d0b]
9
+ - @zhin.js/core@1.5.15
10
+ - zhin.js@6.0.15
11
+
12
+ ## 6.0.1
13
+
14
+ ### Patch Changes
15
+
16
+ - 1fc78bc: Unify native platform Client access behind the literal `adapter` discriminant. Handlers infer both native events and Clients, while command, inbound/outbound middleware, and both Agent tool authoring surfaces expose the exact operation-scoped Client through a lazy `$client` getter. Definitions without `adapter` keep `$client` typed as `unknown`, and runtime dispatch rejects adapter mismatches before resolving the Client. Bundled platform tools now use this single path instead of model-provided endpoint ids and adapter-specific dependency wrappers. Every adapter registers one Client/EventMap contract, and protocol adapters including NapCat, Milky, OneBot and Satori now produce transport-independent Client objects rather than letting Endpoint instances impersonate Clients.
17
+ - Updated dependencies [4e8117c]
18
+ - Updated dependencies [54bfd6b]
19
+ - Updated dependencies [12025ee]
20
+ - Updated dependencies [09b14d6]
21
+ - Updated dependencies [1fc78bc]
22
+ - @zhin.js/adapter@1.2.1
23
+ - @zhin.js/core@1.5.14
24
+ - @zhin.js/command@1.0.16
25
+ - @zhin.js/logger@1.0.77
26
+ - zhin.js@6.0.14
27
+ - @zhin.js/feature-kit@1.0.13
28
+
3
29
  ## 6.0.0
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  | 项 | weixin-ilink | wechat-mp |
10
10
  |---|---|---|
11
11
  | 账号类型 | 个人微信(ClawBot) | 微信公众号 |
12
- | 入站 | 长轮询 `getupdates` → `messageGatewayToken` | Webhook(`httpHostToken`) |
12
+ | 入站 | 长轮询 `getupdates` → `Endpoint.emit(...)` | Webhook(`httpHostToken`) |
13
13
  | 登录 | 扫码 / 侧车凭证 / `botToken` | AppId/Secret/Token |
14
14
  | 群聊 | 不支持(仅私聊) | 支持 |
15
15
 
@@ -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({ conversation, payload })`(`conversation` 为入站归一化出的 `ConversationRef`,kind 恒为 `private`,`id` 即对端 user_id)。
55
+ 出站经 Runtime:`OutboundMessageService` → `endpoint.send({ conversation, payload })`(`conversation` 为入站归一化出的 `ConversationRef`,kind 恒为 `private`,`id` 即对端 user_id)。
56
56
 
57
57
  **图文限制**:微信单条消息不支持图文混排。适配器会自动:
58
58
 
@@ -3,7 +3,6 @@
3
3
  * Convention entry: discover `adapters/weixin-ilink.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
6
  import { WeixinIlinkEndpoint } from "../lib/endpoint.js";
8
7
  import { resolveWeixinIlinkConfig, } from "../lib/protocol.js";
9
8
  import { weixinIlinkRuntimeStateToken } from "../lib/weixin-ilink-runtime-state.js";
@@ -27,8 +26,6 @@ export default defineAdapter({
27
26
  });
28
27
  return new WeixinIlinkEndpoint({
29
28
  id: context.id,
30
- gateway: context.use(messageGatewayToken),
31
- sideEvents: context.use(sideEventGatewayToken),
32
29
  config,
33
30
  });
34
31
  },
@@ -2,7 +2,6 @@
2
2
  * Convention entry: discover `adapters/weixin-ilink.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
5
  import { WeixinIlinkEndpoint } from '../src/endpoint.js';
7
6
  import {
8
7
  resolveWeixinIlinkConfig,
@@ -38,8 +37,6 @@ export default defineAdapter<WeixinIlinkAdapterConfig>({
38
37
  });
39
38
  return new WeixinIlinkEndpoint({
40
39
  id: context.id,
41
- gateway: context.use(messageGatewayToken),
42
- sideEvents: context.use(sideEventGatewayToken),
43
40
  config,
44
41
  });
45
42
  },
@@ -0,0 +1,34 @@
1
+ import type { WeixinIlinkCredentials } from './credentials.js';
2
+ import type { WeixinConfigManager } from './ilink-config-cache.js';
3
+ import type { ResolvedWeixinIlinkConfig } from './protocol.js';
4
+ import { sendMessageWeixin } from './weixin-send.js';
5
+ /** Direct iLink API client backed by the Endpoint's live authenticated session. */
6
+ export declare class WeixinIlinkClient {
7
+ readonly config: ResolvedWeixinIlinkConfig;
8
+ private readonly resolveCredentials;
9
+ private readonly resolveConfigManager;
10
+ private readonly sendTextImpl;
11
+ constructor(config: ResolvedWeixinIlinkConfig, resolveCredentials: () => WeixinIlinkCredentials | null, resolveConfigManager: () => WeixinConfigManager | undefined, sendTextImpl: typeof sendMessageWeixin);
12
+ get credentials(): WeixinIlinkCredentials;
13
+ get authenticated(): boolean;
14
+ get apiBaseUrl(): string;
15
+ get cdnBaseUrl(): string;
16
+ contextToken(userId: string): string | undefined;
17
+ sendText(to: string, text: string): Promise<{
18
+ messageId: string;
19
+ }>;
20
+ sendMedia(to: string, filePath: string, text?: string): Promise<{
21
+ messageId: string;
22
+ }>;
23
+ sendTyping(userId: string, status: number): Promise<boolean>;
24
+ }
25
+ export type WeixinIlinkClientEventMap = Record<string, unknown>;
26
+ declare module '@zhin.js/feature-kit' {
27
+ interface AdapterClientRegistry {
28
+ readonly 'weixin-ilink': {
29
+ readonly client: WeixinIlinkClient;
30
+ readonly events: WeixinIlinkClientEventMap;
31
+ };
32
+ }
33
+ }
34
+ export declare const weixinIlinkClient: import("@zhin.js/adapter").EndpointClientToken<WeixinIlinkClient, WeixinIlinkClientEventMap>;
package/lib/client.js ADDED
@@ -0,0 +1,72 @@
1
+ import { getContextToken } from './context-store.js';
2
+ import { sendTyping } from './ilink-api.js';
3
+ import { sendWeixinMediaFile } from './weixin-send-media.js';
4
+ import { defineEndpointClient } from 'zhin.js/adapter';
5
+ /** Direct iLink API client backed by the Endpoint's live authenticated session. */
6
+ export class WeixinIlinkClient {
7
+ config;
8
+ resolveCredentials;
9
+ resolveConfigManager;
10
+ sendTextImpl;
11
+ constructor(config, resolveCredentials, resolveConfigManager, sendTextImpl) {
12
+ this.config = config;
13
+ this.resolveCredentials = resolveCredentials;
14
+ this.resolveConfigManager = resolveConfigManager;
15
+ this.sendTextImpl = sendTextImpl;
16
+ }
17
+ get credentials() {
18
+ const credentials = this.resolveCredentials();
19
+ if (!credentials?.botToken)
20
+ throw new Error('weixin-ilink client not authenticated');
21
+ return credentials;
22
+ }
23
+ get authenticated() {
24
+ return Boolean(this.resolveCredentials()?.botToken);
25
+ }
26
+ get apiBaseUrl() {
27
+ return this.resolveCredentials()?.baseUrl ?? this.config.baseUrl;
28
+ }
29
+ get cdnBaseUrl() {
30
+ return this.config.cdnBaseUrl;
31
+ }
32
+ contextToken(userId) {
33
+ return getContextToken(this.config.id, userId);
34
+ }
35
+ async sendText(to, text) {
36
+ const contextToken = this.contextToken(to);
37
+ if (!contextToken)
38
+ throw new Error(`missing context_token for peer ${to}`);
39
+ return this.sendTextImpl({
40
+ to,
41
+ text,
42
+ opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
43
+ });
44
+ }
45
+ async sendMedia(to, filePath, text = '') {
46
+ const contextToken = this.contextToken(to);
47
+ if (!contextToken)
48
+ throw new Error(`missing context_token for peer ${to}`);
49
+ return sendWeixinMediaFile({
50
+ filePath,
51
+ to,
52
+ text,
53
+ opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
54
+ cdnBaseUrl: this.cdnBaseUrl,
55
+ });
56
+ }
57
+ async sendTyping(userId, status) {
58
+ const manager = this.resolveConfigManager();
59
+ if (!this.authenticated || !manager)
60
+ return false;
61
+ const cfg = await manager.getForUser(userId, this.contextToken(userId));
62
+ if (!cfg.typingTicket)
63
+ return false;
64
+ await sendTyping({
65
+ baseUrl: this.apiBaseUrl,
66
+ token: this.credentials.botToken,
67
+ body: { ilink_user_id: userId, typing_ticket: cfg.typingTicket, status },
68
+ });
69
+ return true;
70
+ }
71
+ }
72
+ export const weixinIlinkClient = defineEndpointClient('weixin-ilink');
package/lib/endpoint.d.ts CHANGED
@@ -1,18 +1,17 @@
1
- import type { EndpointControl, EndpointInstance, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ import type { EndpointControl, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
3
3
  import type { CapabilityId } from 'zhin.js';
4
4
  import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
5
5
  import { type WeixinIlinkCredentials } from './credentials.js';
6
6
  import { sendMessageWeixin } from './weixin-send.js';
7
7
  import { type ResolvedWeixinIlinkConfig, type WeixinMessageWithMedia } from './protocol.js';
8
+ import { WeixinIlinkClient } from './client.js';
8
9
  export type WeixinIlinkSendText = typeof sendMessageWeixin;
9
10
  export type WeixinIlinkNotifyStart = typeof notifyStart;
10
11
  export type WeixinIlinkNotifyStop = typeof notifyStop;
11
12
  export type WeixinIlinkGetUpdates = typeof getUpdates;
12
13
  export interface WeixinIlinkEndpointOptions {
13
14
  readonly id: CapabilityId;
14
- readonly gateway: MessageGateway;
15
- readonly sideEvents?: SideEventGateway;
16
15
  readonly config: ResolvedWeixinIlinkConfig;
17
16
  readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
18
17
  /** Test / internal: override network side effects. */
@@ -21,16 +20,13 @@ export interface WeixinIlinkEndpointOptions {
21
20
  readonly getUpdates?: WeixinIlinkGetUpdates;
22
21
  readonly sendText?: WeixinIlinkSendText;
23
22
  }
24
- export declare class WeixinIlinkEndpoint implements EndpointInstance {
23
+ export declare class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
25
24
  #private;
25
+ readonly client: WeixinIlinkClient;
26
26
  readonly management: EndpointManagement;
27
27
  readonly control: EndpointControl;
28
28
  constructor(options: WeixinIlinkEndpointOptions);
29
29
  get hasCredentials(): boolean;
30
- /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
31
- get configName(): string;
32
- apiBaseUrl(): string;
33
- cdnBaseUrl(): string;
34
30
  start(): Promise<void>;
35
31
  open(): void;
36
32
  close(): void;
@@ -38,5 +34,4 @@ export declare class WeixinIlinkEndpoint implements EndpointInstance {
38
34
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
39
35
  /** Test / internal: admit a parsed message when the endpoint is open. */
40
36
  admit(msg: WeixinMessageWithMedia): void;
41
- sendTypingToUser(userId: string, status: number): Promise<void>;
42
37
  }
package/lib/endpoint.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
3
  * WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
3
4
  */
@@ -5,20 +6,20 @@ import crypto from 'node:crypto';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
- import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
9
+ import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
9
10
  import { configureIlinkMeta } from './ilink-meta.js';
10
11
  import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from './credentials.js';
11
12
  import { resolveCredentials } from './login.js';
12
- import { getContextToken, listContextTokenUserIds, restoreContextTokens, setContextToken, flushContextTokenPersist, } from './context-store.js';
13
+ import { listContextTokenUserIds, restoreContextTokens, setContextToken, flushContextTokenPersist, } from './context-store.js';
13
14
  import { WeixinConfigManager } from './ilink-config-cache.js';
14
15
  import { getRemainingPauseMs, isSessionPaused, pauseSession, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
15
16
  import { downloadMediaFromItem } from './media-download.js';
16
17
  import { sendMessageWeixin } from './weixin-send.js';
17
- 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
21
  import { formatInboundContent, weixinInboundSegments, weixinReplyTo, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, weixinIlinkInboundConversation, } from './protocol.js';
22
+ import { WeixinIlinkClient } from './client.js';
22
23
  const MAX_CONSECUTIVE_FAILURES = 3;
23
24
  const BACKOFF_DELAY_MS = 30_000;
24
25
  const RETRY_DELAY_MS = 2_000;
@@ -26,7 +27,8 @@ const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
26
27
  /** 入站媒体保留 24h,每小时清扫一次。 */
27
28
  const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
28
29
  const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
29
- export class WeixinIlinkEndpoint {
30
+ export class WeixinIlinkEndpoint extends Endpoint {
31
+ client;
30
32
  #logger;
31
33
  #options;
32
34
  #resolveCredentials;
@@ -42,11 +44,14 @@ export class WeixinIlinkEndpoint {
42
44
  #mediaSweepTimer;
43
45
  #open = false;
44
46
  #started = false;
45
- management = createWeixinIlinkEndpointManagement(this);
47
+ management = createWeixinIlinkEndpointManagement(() => this.client);
46
48
  control = Object.freeze({
47
- typing: (conversation, active) => this.sendTypingToUser(conversation.id, active === false ? 2 : 1),
49
+ typing: async (conversation, active) => {
50
+ await this.client.sendTyping(conversation.id, active === false ? 2 : 1);
51
+ },
48
52
  });
49
53
  constructor(options) {
54
+ super();
50
55
  this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
51
56
  this.#options = options;
52
57
  this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
@@ -54,19 +59,10 @@ export class WeixinIlinkEndpoint {
54
59
  this.#notifyStop = options.notifyStop ?? notifyStop;
55
60
  this.#getUpdates = options.getUpdates ?? getUpdates;
56
61
  this.#sendText = options.sendText ?? sendMessageWeixin;
62
+ this.client = new WeixinIlinkClient(options.config, () => this.#creds, () => this.#configManager, this.#sendText);
57
63
  }
58
64
  get hasCredentials() {
59
- return Boolean(this.#creds?.botToken);
60
- }
61
- /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
62
- get configName() {
63
- return this.#options.config.id;
64
- }
65
- apiBaseUrl() {
66
- return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
67
- }
68
- cdnBaseUrl() {
69
- return this.#options.config.cdnBaseUrl;
65
+ return this.client.authenticated;
70
66
  }
71
67
  async start() {
72
68
  if (this.#started)
@@ -79,10 +75,10 @@ export class WeixinIlinkEndpoint {
79
75
  this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
80
76
  restoreContextTokens(this.#options.config.id);
81
77
  await this.#notifyStart({
82
- baseUrl: this.apiBaseUrl(),
78
+ baseUrl: this.client.apiBaseUrl,
83
79
  token: this.#creds.botToken,
84
80
  });
85
- this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken }, (msg) => this.#logger.debug(msg));
81
+ this.#configManager = new WeixinConfigManager({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken }, (msg) => this.#logger.debug(msg));
86
82
  this.#pollAbort = new AbortController();
87
83
  this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
88
84
  this.#startMediaSweep();
@@ -90,6 +86,9 @@ export class WeixinIlinkEndpoint {
90
86
  op: 'connect',
91
87
  endpoint: this.#options.config.id,
92
88
  }));
89
+ void this.#emitPlatformEvent('system.online', {
90
+ account: this.#options.config.id,
91
+ });
93
92
  }
94
93
  catch (error) {
95
94
  await this.stop();
@@ -118,7 +117,7 @@ export class WeixinIlinkEndpoint {
118
117
  flushContextTokenPersist(this.#options.config.id);
119
118
  if (this.#creds?.botToken) {
120
119
  try {
121
- await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
120
+ await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
122
121
  }
123
122
  catch (err) {
124
123
  this.#logger.warn(formatCompact({
@@ -129,6 +128,9 @@ export class WeixinIlinkEndpoint {
129
128
  }
130
129
  }
131
130
  this.#started = false;
131
+ void this.#emitPlatformEvent('system.offline', {
132
+ account: this.#options.config.id,
133
+ });
132
134
  this.#logger.debug(formatCompact({
133
135
  op: 'disconnect',
134
136
  }));
@@ -139,7 +141,7 @@ export class WeixinIlinkEndpoint {
139
141
  }
140
142
  // 个人微信仅 private 会话,对端 user_id 即 conversation.id
141
143
  const target = conversation.id;
142
- const contextToken = getContextToken(this.#options.config.id, target);
144
+ const contextToken = this.client.contextToken(target);
143
145
  if (!contextToken) {
144
146
  this.#logger.warn(formatCompact({
145
147
  op: 'send',
@@ -149,11 +151,6 @@ export class WeixinIlinkEndpoint {
149
151
  }));
150
152
  throw new Error(`missing context_token for peer ${target}`);
151
153
  }
152
- const apiOpts = {
153
- baseUrl: this.apiBaseUrl(),
154
- token: this.#creds.botToken,
155
- contextToken,
156
- };
157
154
  const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
158
155
  const wire = formatOutboundSegments(payload);
159
156
  const materialized = await materializeOutboundMedia(wire, outboundDir);
@@ -176,21 +173,11 @@ export class WeixinIlinkEndpoint {
176
173
  if (filePath && fs.existsSync(filePath)) {
177
174
  // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
178
175
  if (pendingText.trim()) {
179
- const textResult = await this.#sendText({
180
- to: target,
181
- text: pendingText,
182
- opts: apiOpts,
183
- });
176
+ const textResult = await this.client.sendText(target, pendingText);
184
177
  pendingText = '';
185
178
  lastId = textResult.messageId;
186
179
  }
187
- const result = await sendWeixinMediaFile({
188
- filePath,
189
- to: target,
190
- text: '',
191
- opts: apiOpts,
192
- cdnBaseUrl: this.cdnBaseUrl(),
193
- });
180
+ const result = await this.client.sendMedia(target, filePath);
194
181
  lastId = result.messageId;
195
182
  continue;
196
183
  }
@@ -203,11 +190,7 @@ export class WeixinIlinkEndpoint {
203
190
  }
204
191
  }
205
192
  if (pendingText.trim()) {
206
- const result = await this.#sendText({
207
- to: target,
208
- text: pendingText,
209
- opts: apiOpts,
210
- });
193
+ const result = await this.client.sendText(target, pendingText);
211
194
  lastId = result.messageId;
212
195
  }
213
196
  return lastId || `weixin-ilink-${Date.now()}`;
@@ -222,7 +205,7 @@ export class WeixinIlinkEndpoint {
222
205
  }
223
206
  const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
224
207
  const replyTo = weixinReplyTo(msg);
225
- void this.#options.gateway.receive({
208
+ void this.emit('message.receive', {
226
209
  conversation,
227
210
  message: { conversation, id: inboundMessageId(msg) },
228
211
  content: formatInboundContent(msg),
@@ -242,29 +225,6 @@ export class WeixinIlinkEndpoint {
242
225
  }));
243
226
  });
244
227
  }
245
- async sendTypingToUser(userId, status) {
246
- if (!this.#creds?.botToken || !this.#configManager)
247
- return;
248
- const contextToken = getContextToken(this.#options.config.id, userId);
249
- const cfg = await this.#configManager.getForUser(userId, contextToken);
250
- if (!cfg.typingTicket) {
251
- this.#logger.debug(formatCompact({
252
- op: 'send_typing',
253
- skip: 'no_typing_ticket',
254
- userId,
255
- }));
256
- return;
257
- }
258
- await sendTyping({
259
- baseUrl: this.apiBaseUrl(),
260
- token: this.#creds.botToken,
261
- body: {
262
- ilink_user_id: userId,
263
- typing_ticket: cfg.typingTicket,
264
- status,
265
- },
266
- });
267
- }
268
228
  async #pollLoop(abortSignal) {
269
229
  if (!this.#creds?.botToken)
270
230
  return;
@@ -279,7 +239,7 @@ export class WeixinIlinkEndpoint {
279
239
  }
280
240
  try {
281
241
  const resp = await this.#getUpdates({
282
- baseUrl: this.apiBaseUrl(),
242
+ baseUrl: this.client.apiBaseUrl,
283
243
  token: this.#creds.botToken,
284
244
  get_updates_buf: getUpdatesBuf,
285
245
  timeoutMs: nextTimeoutMs,
@@ -338,6 +298,7 @@ export class WeixinIlinkEndpoint {
338
298
  }
339
299
  }
340
300
  async #handleInboundMessage(full) {
301
+ void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
341
302
  const fromUserId = full.from_user_id ?? '';
342
303
  if (full.context_token && fromUserId) {
343
304
  setContextToken(this.#options.config.id, fromUserId, full.context_token);
@@ -351,6 +312,15 @@ export class WeixinIlinkEndpoint {
351
312
  preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
352
313
  }));
353
314
  }
315
+ async #emitPlatformEvent(name, event) {
316
+ await this.emitPlatform(name, event).catch((error) => {
317
+ this.#logger.warn(formatCompact({
318
+ op: 'weixin_ilink_platform_event_failed',
319
+ event: name,
320
+ error: error instanceof Error ? error.message : String(error),
321
+ }));
322
+ });
323
+ }
354
324
  async #downloadInboundMedia(full) {
355
325
  const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
356
326
  const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
@@ -360,7 +330,7 @@ export class WeixinIlinkEndpoint {
360
330
  if (!mediaItem)
361
331
  return {};
362
332
  return downloadMediaFromItem(mediaItem, {
363
- cdnBaseUrl: this.cdnBaseUrl(),
333
+ cdnBaseUrl: this.client.cdnBaseUrl,
364
334
  saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
365
335
  log: (msg) => this.#logger.debug(msg),
366
336
  errLog: (msg) => this.#logger.warn(msg),
@@ -432,13 +402,13 @@ export class WeixinIlinkEndpoint {
432
402
  }
433
403
  }
434
404
  }
435
- function createWeixinIlinkEndpointManagement(endpoint) {
405
+ function createWeixinIlinkEndpointManagement(requireClient) {
436
406
  return Object.freeze({
437
407
  // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
438
408
  // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
439
409
  // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
440
410
  async listFriends() {
441
- return listContextTokenUserIds(endpoint.configName).map((userId) => ({
411
+ return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
442
412
  user_id: userId,
443
413
  nickname: userId,
444
414
  remark: 'ilink: 从会话 context_token 推导,非通讯录',
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { formatInboundContent, formatOutboundSegments, inboundMessageId, resolveWeixinIlinkConfig, segmentLocalPath, sleep, type ResolvedWeixinIlinkConfig, type WeixinIlinkAdapterConfig, type WeixinIlinkEndpointConfig, type WeixinInboundMediaPaths, type WeixinMessageWithMedia, type WeixinWireSegment, } from './protocol.js';
2
2
  export type { WeixinMessage, MessageItem } from './ilink-types.js';
3
3
  export type { WeixinIlinkCredentials } from './credentials.js';
4
+ export { WeixinIlinkClient, weixinIlinkClient, type WeixinIlinkClientEventMap, } from './client.js';
4
5
  export { WeixinIlinkEndpoint, type WeixinIlinkEndpointOptions, type WeixinIlinkGetUpdates, type WeixinIlinkNotifyStart, type WeixinIlinkNotifyStop, type WeixinIlinkSendText, } from './endpoint.js';
package/lib/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { formatInboundContent, formatOutboundSegments, inboundMessageId, resolveWeixinIlinkConfig, segmentLocalPath, sleep, } from './protocol.js';
2
+ export { WeixinIlinkClient, weixinIlinkClient, } from './client.js';
2
3
  export { WeixinIlinkEndpoint, } from './endpoint.js';
package/lib/protocol.d.ts CHANGED
@@ -41,7 +41,7 @@ export type WeixinMessageWithMedia = WeixinMessage & {
41
41
  readonly _media?: WeixinInboundMediaPaths;
42
42
  };
43
43
  export declare function resolveWeixinIlinkConfig(config?: WeixinIlinkAdapterConfig): ResolvedWeixinIlinkConfig;
44
- /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
44
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
45
45
  export declare function formatInboundContent(msg: WeixinMessageWithMedia): string;
46
46
  export declare function weixinInboundSegments(msg: WeixinMessageWithMedia): readonly Segment[];
47
47
  export declare function weixinReplyTo(msg: WeixinMessageWithMedia): Readonly<{
package/lib/protocol.js CHANGED
@@ -28,7 +28,7 @@ export function resolveWeixinIlinkConfig(config = {}) {
28
28
  botToken,
29
29
  };
30
30
  }
31
- /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
31
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
32
32
  export function formatInboundContent(msg) {
33
33
  return bodyFromItemList(msg.item_list).trim();
34
34
  }
@@ -1 +1 @@
1
- export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
1
+ export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-weixin-ilink",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
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,16 +48,17 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@zhin.js/adapter": "1.2.0",
52
- "@zhin.js/core": "1.5.13",
51
+ "@zhin.js/adapter": "1.2.1",
52
+ "@zhin.js/core": "1.5.15",
53
+ "@zhin.js/feature-kit": "1.0.13",
53
54
  "@zhin.js/im-contract": "1.0.4",
54
- "@zhin.js/logger": "1.0.76"
55
+ "@zhin.js/logger": "1.0.77"
55
56
  },
56
57
  "peerDependencies": {
57
- "@zhin.js/adapter": "1.2.0",
58
- "@zhin.js/command": "1.0.15",
59
- "@zhin.js/core": "1.5.13",
60
- "zhin.js": "6.0.13"
58
+ "@zhin.js/adapter": "1.2.1",
59
+ "@zhin.js/command": "1.0.16",
60
+ "@zhin.js/core": "1.5.15",
61
+ "zhin.js": "6.0.15"
61
62
  },
62
63
  "peerDependenciesMeta": {
63
64
  "@zhin.js/command": {
@@ -71,7 +72,7 @@
71
72
  "@types/node": "^26.1.2",
72
73
  "typescript": "^6.0.3",
73
74
  "vitest": "^4.1.10",
74
- "zhin.js": "6.0.13"
75
+ "zhin.js": "6.0.15"
75
76
  },
76
77
  "ilink_appid": "bot",
77
78
  "engines": {
package/src/client.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { getContextToken } from './context-store.js';
2
+ import type { WeixinIlinkCredentials } from './credentials.js';
3
+ import { sendTyping } from './ilink-api.js';
4
+ import type { WeixinConfigManager } from './ilink-config-cache.js';
5
+ import type { ResolvedWeixinIlinkConfig } from './protocol.js';
6
+ import { sendMessageWeixin } from './weixin-send.js';
7
+ import { sendWeixinMediaFile } from './weixin-send-media.js';
8
+ import { defineEndpointClient } from 'zhin.js/adapter';
9
+
10
+ /** Direct iLink API client backed by the Endpoint's live authenticated session. */
11
+ export class WeixinIlinkClient {
12
+ constructor(
13
+ readonly config: ResolvedWeixinIlinkConfig,
14
+ private readonly resolveCredentials: () => WeixinIlinkCredentials | null,
15
+ private readonly resolveConfigManager: () => WeixinConfigManager | undefined,
16
+ private readonly sendTextImpl: typeof sendMessageWeixin,
17
+ ) {}
18
+
19
+ get credentials(): WeixinIlinkCredentials {
20
+ const credentials = this.resolveCredentials();
21
+ if (!credentials?.botToken) throw new Error('weixin-ilink client not authenticated');
22
+ return credentials;
23
+ }
24
+
25
+ get authenticated(): boolean {
26
+ return Boolean(this.resolveCredentials()?.botToken);
27
+ }
28
+
29
+ get apiBaseUrl(): string {
30
+ return this.resolveCredentials()?.baseUrl ?? this.config.baseUrl;
31
+ }
32
+
33
+ get cdnBaseUrl(): string {
34
+ return this.config.cdnBaseUrl;
35
+ }
36
+
37
+ contextToken(userId: string): string | undefined {
38
+ return getContextToken(this.config.id, userId);
39
+ }
40
+
41
+ async sendText(to: string, text: string): Promise<{ messageId: string }> {
42
+ const contextToken = this.contextToken(to);
43
+ if (!contextToken) throw new Error(`missing context_token for peer ${to}`);
44
+ return this.sendTextImpl({
45
+ to,
46
+ text,
47
+ opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
48
+ });
49
+ }
50
+
51
+ async sendMedia(to: string, filePath: string, text = ''): Promise<{ messageId: string }> {
52
+ const contextToken = this.contextToken(to);
53
+ if (!contextToken) throw new Error(`missing context_token for peer ${to}`);
54
+ return sendWeixinMediaFile({
55
+ filePath,
56
+ to,
57
+ text,
58
+ opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
59
+ cdnBaseUrl: this.cdnBaseUrl,
60
+ });
61
+ }
62
+
63
+ async sendTyping(userId: string, status: number): Promise<boolean> {
64
+ const manager = this.resolveConfigManager();
65
+ if (!this.authenticated || !manager) return false;
66
+ const cfg = await manager.getForUser(userId, this.contextToken(userId));
67
+ if (!cfg.typingTicket) return false;
68
+ await sendTyping({
69
+ baseUrl: this.apiBaseUrl,
70
+ token: this.credentials.botToken,
71
+ body: { ilink_user_id: userId, typing_ticket: cfg.typingTicket, status },
72
+ });
73
+ return true;
74
+ }
75
+ }
76
+
77
+ export type WeixinIlinkClientEventMap = Record<string, unknown>;
78
+
79
+ declare module '@zhin.js/feature-kit' {
80
+ interface AdapterClientRegistry {
81
+ readonly 'weixin-ilink': { readonly client: WeixinIlinkClient; readonly events: WeixinIlinkClientEventMap };
82
+ }
83
+ }
84
+
85
+ export const weixinIlinkClient = defineEndpointClient<WeixinIlinkClient, WeixinIlinkClientEventMap>('weixin-ilink');
package/src/endpoint.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
3
  * WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
3
4
  */
@@ -7,14 +8,12 @@ import path from 'node:path';
7
8
  import type {
8
9
  EndpointFriend,
9
10
  EndpointControl,
10
- EndpointInstance,
11
11
  EndpointManagement,
12
12
  EndpointSendRequest,
13
13
  } from 'zhin.js/adapter';
14
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
15
14
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
16
15
  import type { CapabilityId } from 'zhin.js';
17
- import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
16
+ import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
18
17
  import { configureIlinkMeta } from './ilink-meta.js';
19
18
  import {
20
19
  loadSyncBuf,
@@ -24,7 +23,6 @@ import {
24
23
  } from './credentials.js';
25
24
  import { resolveCredentials } from './login.js';
26
25
  import {
27
- getContextToken,
28
26
  listContextTokenUserIds,
29
27
  restoreContextTokens,
30
28
  setContextToken,
@@ -39,7 +37,6 @@ import {
39
37
  } from './ilink-session-guard.js';
40
38
  import { downloadMediaFromItem } from './media-download.js';
41
39
  import { sendMessageWeixin } from './weixin-send.js';
42
- import { sendWeixinMediaFile } from './weixin-send-media.js';
43
40
  import { materializeOutboundMedia } from './outbound-media.js';
44
41
  import { MessageItemType, type WeixinMessage } from './ilink-types.js';
45
42
  import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
@@ -57,6 +54,7 @@ import {
57
54
  type WeixinMessageWithMedia,
58
55
  type WeixinWireSegment,
59
56
  } from './protocol.js';
57
+ import { WeixinIlinkClient } from './client.js';
60
58
 
61
59
  const MAX_CONSECUTIVE_FAILURES = 3;
62
60
  const BACKOFF_DELAY_MS = 30_000;
@@ -73,8 +71,6 @@ export type WeixinIlinkGetUpdates = typeof getUpdates;
73
71
 
74
72
  export interface WeixinIlinkEndpointOptions {
75
73
  readonly id: CapabilityId;
76
- readonly gateway: MessageGateway;
77
- readonly sideEvents?: SideEventGateway;
78
74
  readonly config: ResolvedWeixinIlinkConfig;
79
75
  readonly resolveCredentials?: (
80
76
  config: ResolvedWeixinIlinkConfig,
@@ -87,7 +83,8 @@ export interface WeixinIlinkEndpointOptions {
87
83
  readonly sendText?: WeixinIlinkSendText;
88
84
  }
89
85
 
90
- export class WeixinIlinkEndpoint implements EndpointInstance {
86
+ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
87
+ readonly client: WeixinIlinkClient;
91
88
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
92
89
 
93
90
  readonly #options: WeixinIlinkEndpointOptions;
@@ -107,15 +104,18 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
107
104
  #mediaSweepTimer?: ReturnType<typeof setInterval>;
108
105
  #open = false;
109
106
  #started = false;
110
- readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
107
+ readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(() => this.client);
111
108
  readonly control: EndpointControl = Object.freeze<EndpointControl>({
112
- typing: (conversation, active) => this.sendTypingToUser(
113
- conversation.id,
114
- active === false ? 2 : 1,
115
- ),
109
+ typing: async (conversation, active) => {
110
+ await this.client.sendTyping(
111
+ conversation.id,
112
+ active === false ? 2 : 1,
113
+ );
114
+ },
116
115
  });
117
116
 
118
117
  constructor(options: WeixinIlinkEndpointOptions) {
118
+ super();
119
119
  this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
120
120
  this.#options = options;
121
121
  this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
@@ -123,23 +123,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
123
123
  this.#notifyStop = options.notifyStop ?? notifyStop;
124
124
  this.#getUpdates = options.getUpdates ?? getUpdates;
125
125
  this.#sendText = options.sendText ?? sendMessageWeixin;
126
+ this.client = new WeixinIlinkClient(
127
+ options.config,
128
+ () => this.#creds,
129
+ () => this.#configManager,
130
+ this.#sendText,
131
+ );
126
132
  }
127
133
 
128
134
  get hasCredentials(): boolean {
129
- return Boolean(this.#creds?.botToken);
130
- }
131
-
132
- /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
133
- get configName(): string {
134
- return this.#options.config.id;
135
- }
136
-
137
- apiBaseUrl(): string {
138
- return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
139
- }
140
-
141
- cdnBaseUrl(): string {
142
- return this.#options.config.cdnBaseUrl;
135
+ return this.client.authenticated;
143
136
  }
144
137
 
145
138
  async start(): Promise<void> {
@@ -153,12 +146,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
153
146
  restoreContextTokens(this.#options.config.id);
154
147
 
155
148
  await this.#notifyStart({
156
- baseUrl: this.apiBaseUrl(),
149
+ baseUrl: this.client.apiBaseUrl,
157
150
  token: this.#creds.botToken,
158
151
  });
159
152
 
160
153
  this.#configManager = new WeixinConfigManager(
161
- { baseUrl: this.apiBaseUrl(), token: this.#creds.botToken },
154
+ { baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken },
162
155
  (msg) => this.#logger.debug(msg),
163
156
  );
164
157
 
@@ -169,6 +162,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
169
162
  op: 'connect',
170
163
  endpoint: this.#options.config.id,
171
164
  }));
165
+ void this.#emitPlatformEvent('system.online', {
166
+ account: this.#options.config.id,
167
+ });
172
168
  } catch (error) {
173
169
  await this.stop();
174
170
  this.#logger.error('Failed to connect weixin-ilink bot:', error);
@@ -198,7 +194,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
198
194
  flushContextTokenPersist(this.#options.config.id);
199
195
  if (this.#creds?.botToken) {
200
196
  try {
201
- await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
197
+ await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
202
198
  } catch (err) {
203
199
  this.#logger.warn(formatCompact({
204
200
  op: 'notify_stop',
@@ -208,6 +204,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
208
204
  }
209
205
  }
210
206
  this.#started = false;
207
+ void this.#emitPlatformEvent('system.offline', {
208
+ account: this.#options.config.id,
209
+ });
211
210
  this.#logger.debug(formatCompact({
212
211
  op: 'disconnect',
213
212
  }));
@@ -219,7 +218,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
219
218
  }
220
219
  // 个人微信仅 private 会话,对端 user_id 即 conversation.id
221
220
  const target = conversation.id;
222
- const contextToken = getContextToken(this.#options.config.id, target);
221
+ const contextToken = this.client.contextToken(target);
223
222
  if (!contextToken) {
224
223
  this.#logger.warn(formatCompact({
225
224
  op: 'send',
@@ -230,12 +229,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
230
229
  throw new Error(`missing context_token for peer ${target}`);
231
230
  }
232
231
 
233
- const apiOpts = {
234
- baseUrl: this.apiBaseUrl(),
235
- token: this.#creds.botToken,
236
- contextToken,
237
- };
238
-
239
232
  const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
240
233
  const wire = formatOutboundSegments(payload);
241
234
  const materialized = await materializeOutboundMedia(wire, outboundDir);
@@ -259,21 +252,11 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
259
252
  if (filePath && fs.existsSync(filePath)) {
260
253
  // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
261
254
  if (pendingText.trim()) {
262
- const textResult = await this.#sendText({
263
- to: target,
264
- text: pendingText,
265
- opts: apiOpts,
266
- });
255
+ const textResult = await this.client.sendText(target, pendingText);
267
256
  pendingText = '';
268
257
  lastId = textResult.messageId;
269
258
  }
270
- const result = await sendWeixinMediaFile({
271
- filePath,
272
- to: target,
273
- text: '',
274
- opts: apiOpts,
275
- cdnBaseUrl: this.cdnBaseUrl(),
276
- });
259
+ const result = await this.client.sendMedia(target, filePath);
277
260
  lastId = result.messageId;
278
261
  continue;
279
262
  }
@@ -288,11 +271,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
288
271
  }
289
272
 
290
273
  if (pendingText.trim()) {
291
- const result = await this.#sendText({
292
- to: target,
293
- text: pendingText,
294
- opts: apiOpts,
295
- });
274
+ const result = await this.client.sendText(target, pendingText);
296
275
  lastId = result.messageId;
297
276
  }
298
277
 
@@ -308,7 +287,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
308
287
  }
309
288
  const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
310
289
  const replyTo = weixinReplyTo(msg);
311
- void this.#options.gateway.receive({
290
+ void this.emit('message.receive', {
312
291
  conversation,
313
292
  message: { conversation, id: inboundMessageId(msg) },
314
293
  content: formatInboundContent(msg),
@@ -329,29 +308,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
329
308
  });
330
309
  }
331
310
 
332
- async sendTypingToUser(userId: string, status: number): Promise<void> {
333
- if (!this.#creds?.botToken || !this.#configManager) return;
334
- const contextToken = getContextToken(this.#options.config.id, userId);
335
- const cfg = await this.#configManager.getForUser(userId, contextToken);
336
- if (!cfg.typingTicket) {
337
- this.#logger.debug(formatCompact({
338
- op: 'send_typing',
339
- skip: 'no_typing_ticket',
340
- userId,
341
- }));
342
- return;
343
- }
344
- await sendTyping({
345
- baseUrl: this.apiBaseUrl(),
346
- token: this.#creds.botToken,
347
- body: {
348
- ilink_user_id: userId,
349
- typing_ticket: cfg.typingTicket,
350
- status,
351
- },
352
- });
353
- }
354
-
355
311
  async #pollLoop(abortSignal: AbortSignal): Promise<void> {
356
312
  if (!this.#creds?.botToken) return;
357
313
 
@@ -368,7 +324,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
368
324
 
369
325
  try {
370
326
  const resp = await this.#getUpdates({
371
- baseUrl: this.apiBaseUrl(),
327
+ baseUrl: this.client.apiBaseUrl,
372
328
  token: this.#creds.botToken,
373
329
  get_updates_buf: getUpdatesBuf,
374
330
  timeoutMs: nextTimeoutMs,
@@ -430,6 +386,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
430
386
  }
431
387
 
432
388
  async #handleInboundMessage(full: WeixinMessage): Promise<void> {
389
+ void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
433
390
  const fromUserId = full.from_user_id ?? '';
434
391
  if (full.context_token && fromUserId) {
435
392
  setContextToken(this.#options.config.id, fromUserId, full.context_token);
@@ -445,6 +402,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
445
402
  }));
446
403
  }
447
404
 
405
+ async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
406
+ await this.emitPlatform(name, event).catch((error) => {
407
+ this.#logger.warn(formatCompact({
408
+ op: 'weixin_ilink_platform_event_failed',
409
+ event: name,
410
+ error: error instanceof Error ? error.message : String(error),
411
+ }));
412
+ });
413
+ }
414
+
448
415
  async #downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
449
416
  const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
450
417
  Boolean(m?.encrypt_query_param || m?.full_url);
@@ -466,7 +433,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
466
433
  if (!mediaItem) return {};
467
434
 
468
435
  return downloadMediaFromItem(mediaItem, {
469
- cdnBaseUrl: this.cdnBaseUrl(),
436
+ cdnBaseUrl: this.client.cdnBaseUrl,
470
437
  saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
471
438
  this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
472
439
  log: (msg) => this.#logger.debug(msg),
@@ -546,13 +513,15 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
546
513
  }
547
514
  }
548
515
 
549
- function createWeixinIlinkEndpointManagement(endpoint: WeixinIlinkEndpoint): EndpointManagement {
516
+ function createWeixinIlinkEndpointManagement(
517
+ requireClient: () => WeixinIlinkClient,
518
+ ): EndpointManagement {
550
519
  return Object.freeze<EndpointManagement>({
551
520
  // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
552
521
  // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
553
522
  // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
554
523
  async listFriends(): Promise<readonly EndpointFriend[]> {
555
- return listContextTokenUserIds(endpoint.configName).map((userId) => ({
524
+ return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
556
525
  user_id: userId,
557
526
  nickname: userId,
558
527
  remark: 'ilink: 从会话 context_token 推导,非通讯录',
package/src/index.ts CHANGED
@@ -16,6 +16,12 @@ export {
16
16
  export type { WeixinMessage, MessageItem } from './ilink-types.js';
17
17
  export type { WeixinIlinkCredentials } from './credentials.js';
18
18
 
19
+ export {
20
+ WeixinIlinkClient,
21
+ weixinIlinkClient,
22
+ type WeixinIlinkClientEventMap,
23
+ } from './client.js';
24
+
19
25
  export {
20
26
  WeixinIlinkEndpoint,
21
27
  type WeixinIlinkEndpointOptions,
package/src/protocol.ts CHANGED
@@ -82,7 +82,7 @@ export function resolveWeixinIlinkConfig(
82
82
  };
83
83
  }
84
84
 
85
- /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
85
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
86
86
  export function formatInboundContent(msg: WeixinMessageWithMedia): string {
87
87
  return bodyFromItemList(msg.item_list).trim();
88
88
  }