@zhin.js/adapter-weixin-ilink 5.0.13 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @zhin.js/adapter-weixin-ilink
2
2
 
3
+ ## 6.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+ - Updated dependencies [4e8117c]
9
+ - Updated dependencies [54bfd6b]
10
+ - Updated dependencies [12025ee]
11
+ - Updated dependencies [09b14d6]
12
+ - Updated dependencies [1fc78bc]
13
+ - @zhin.js/adapter@1.2.1
14
+ - @zhin.js/core@1.5.14
15
+ - @zhin.js/command@1.0.16
16
+ - @zhin.js/logger@1.0.77
17
+ - zhin.js@6.0.14
18
+ - @zhin.js/feature-kit@1.0.13
19
+
20
+ ## 6.0.0
21
+
22
+ ### Patch Changes
23
+
24
+ - f2c532f: Expose exact per-Endpoint message operations through one validated Adapter capability model, route Core control calls through declared active capabilities, and connect existing recall, edit, reaction, and typing implementations across platform adapters.
25
+ - Updated dependencies [f2c532f]
26
+ - @zhin.js/adapter@1.2.0
27
+ - @zhin.js/core@1.5.13
28
+ - zhin.js@6.0.13
29
+
3
30
  ## 5.0.13
4
31
 
5
32
  ### 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,13 +3,13 @@
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";
10
9
  export { WeixinIlinkEndpoint } from "../lib/endpoint.js";
11
10
  export default defineAdapter({
12
11
  capabilities: ['inbound', 'outbound'],
12
+ operations: ['typing'],
13
13
  // 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
14
14
  // 无直发面;微信无卡片交互面,交互段降级纯文本。
15
15
  segments: {
@@ -26,8 +26,6 @@ export default defineAdapter({
26
26
  });
27
27
  return new WeixinIlinkEndpoint({
28
28
  id: context.id,
29
- gateway: context.use(messageGatewayToken),
30
- sideEvents: context.use(sideEventGatewayToken),
31
29
  config,
32
30
  });
33
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,
@@ -21,6 +20,7 @@ export type {
21
20
 
22
21
  export default defineAdapter<WeixinIlinkAdapterConfig>({
23
22
  capabilities: ['inbound', 'outbound'],
23
+ operations: ['typing'],
24
24
  // 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
25
25
  // 无直发面;微信无卡片交互面,交互段降级纯文本。
26
26
  segments: {
@@ -37,8 +37,6 @@ export default defineAdapter<WeixinIlinkAdapterConfig>({
37
37
  });
38
38
  return new WeixinIlinkEndpoint({
39
39
  id: context.id,
40
- gateway: context.use(messageGatewayToken),
41
- sideEvents: context.use(sideEventGatewayToken),
42
40
  config,
43
41
  });
44
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 { 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,15 +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
+ readonly control: EndpointControl;
27
28
  constructor(options: WeixinIlinkEndpointOptions);
28
29
  get hasCredentials(): boolean;
29
- /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
30
- get configName(): string;
31
- apiBaseUrl(): string;
32
- cdnBaseUrl(): string;
33
30
  start(): Promise<void>;
34
31
  open(): void;
35
32
  close(): void;
@@ -37,5 +34,4 @@ export declare class WeixinIlinkEndpoint implements EndpointInstance {
37
34
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
38
35
  /** Test / internal: admit a parsed message when the endpoint is open. */
39
36
  admit(msg: WeixinMessageWithMedia): void;
40
- sendTypingToUser(userId: string, status: number): Promise<void>;
41
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,8 +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);
48
+ control = Object.freeze({
49
+ typing: async (conversation, active) => {
50
+ await this.client.sendTyping(conversation.id, active === false ? 2 : 1);
51
+ },
52
+ });
46
53
  constructor(options) {
54
+ super();
47
55
  this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
48
56
  this.#options = options;
49
57
  this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
@@ -51,19 +59,10 @@ export class WeixinIlinkEndpoint {
51
59
  this.#notifyStop = options.notifyStop ?? notifyStop;
52
60
  this.#getUpdates = options.getUpdates ?? getUpdates;
53
61
  this.#sendText = options.sendText ?? sendMessageWeixin;
62
+ this.client = new WeixinIlinkClient(options.config, () => this.#creds, () => this.#configManager, this.#sendText);
54
63
  }
55
64
  get hasCredentials() {
56
- return Boolean(this.#creds?.botToken);
57
- }
58
- /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
59
- get configName() {
60
- return this.#options.config.id;
61
- }
62
- apiBaseUrl() {
63
- return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
64
- }
65
- cdnBaseUrl() {
66
- return this.#options.config.cdnBaseUrl;
65
+ return this.client.authenticated;
67
66
  }
68
67
  async start() {
69
68
  if (this.#started)
@@ -76,10 +75,10 @@ export class WeixinIlinkEndpoint {
76
75
  this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
77
76
  restoreContextTokens(this.#options.config.id);
78
77
  await this.#notifyStart({
79
- baseUrl: this.apiBaseUrl(),
78
+ baseUrl: this.client.apiBaseUrl,
80
79
  token: this.#creds.botToken,
81
80
  });
82
- 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));
83
82
  this.#pollAbort = new AbortController();
84
83
  this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
85
84
  this.#startMediaSweep();
@@ -87,6 +86,9 @@ export class WeixinIlinkEndpoint {
87
86
  op: 'connect',
88
87
  endpoint: this.#options.config.id,
89
88
  }));
89
+ void this.#emitPlatformEvent('system.online', {
90
+ account: this.#options.config.id,
91
+ });
90
92
  }
91
93
  catch (error) {
92
94
  await this.stop();
@@ -115,7 +117,7 @@ export class WeixinIlinkEndpoint {
115
117
  flushContextTokenPersist(this.#options.config.id);
116
118
  if (this.#creds?.botToken) {
117
119
  try {
118
- await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
120
+ await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
119
121
  }
120
122
  catch (err) {
121
123
  this.#logger.warn(formatCompact({
@@ -126,6 +128,9 @@ export class WeixinIlinkEndpoint {
126
128
  }
127
129
  }
128
130
  this.#started = false;
131
+ void this.#emitPlatformEvent('system.offline', {
132
+ account: this.#options.config.id,
133
+ });
129
134
  this.#logger.debug(formatCompact({
130
135
  op: 'disconnect',
131
136
  }));
@@ -136,7 +141,7 @@ export class WeixinIlinkEndpoint {
136
141
  }
137
142
  // 个人微信仅 private 会话,对端 user_id 即 conversation.id
138
143
  const target = conversation.id;
139
- const contextToken = getContextToken(this.#options.config.id, target);
144
+ const contextToken = this.client.contextToken(target);
140
145
  if (!contextToken) {
141
146
  this.#logger.warn(formatCompact({
142
147
  op: 'send',
@@ -146,11 +151,6 @@ export class WeixinIlinkEndpoint {
146
151
  }));
147
152
  throw new Error(`missing context_token for peer ${target}`);
148
153
  }
149
- const apiOpts = {
150
- baseUrl: this.apiBaseUrl(),
151
- token: this.#creds.botToken,
152
- contextToken,
153
- };
154
154
  const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
155
155
  const wire = formatOutboundSegments(payload);
156
156
  const materialized = await materializeOutboundMedia(wire, outboundDir);
@@ -173,21 +173,11 @@ export class WeixinIlinkEndpoint {
173
173
  if (filePath && fs.existsSync(filePath)) {
174
174
  // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
175
175
  if (pendingText.trim()) {
176
- const textResult = await this.#sendText({
177
- to: target,
178
- text: pendingText,
179
- opts: apiOpts,
180
- });
176
+ const textResult = await this.client.sendText(target, pendingText);
181
177
  pendingText = '';
182
178
  lastId = textResult.messageId;
183
179
  }
184
- const result = await sendWeixinMediaFile({
185
- filePath,
186
- to: target,
187
- text: '',
188
- opts: apiOpts,
189
- cdnBaseUrl: this.cdnBaseUrl(),
190
- });
180
+ const result = await this.client.sendMedia(target, filePath);
191
181
  lastId = result.messageId;
192
182
  continue;
193
183
  }
@@ -200,11 +190,7 @@ export class WeixinIlinkEndpoint {
200
190
  }
201
191
  }
202
192
  if (pendingText.trim()) {
203
- const result = await this.#sendText({
204
- to: target,
205
- text: pendingText,
206
- opts: apiOpts,
207
- });
193
+ const result = await this.client.sendText(target, pendingText);
208
194
  lastId = result.messageId;
209
195
  }
210
196
  return lastId || `weixin-ilink-${Date.now()}`;
@@ -219,7 +205,7 @@ export class WeixinIlinkEndpoint {
219
205
  }
220
206
  const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
221
207
  const replyTo = weixinReplyTo(msg);
222
- void this.#options.gateway.receive({
208
+ void this.emit('message.receive', {
223
209
  conversation,
224
210
  message: { conversation, id: inboundMessageId(msg) },
225
211
  content: formatInboundContent(msg),
@@ -239,29 +225,6 @@ export class WeixinIlinkEndpoint {
239
225
  }));
240
226
  });
241
227
  }
242
- async sendTypingToUser(userId, status) {
243
- if (!this.#creds?.botToken || !this.#configManager)
244
- return;
245
- const contextToken = getContextToken(this.#options.config.id, userId);
246
- const cfg = await this.#configManager.getForUser(userId, contextToken);
247
- if (!cfg.typingTicket) {
248
- this.#logger.debug(formatCompact({
249
- op: 'send_typing',
250
- skip: 'no_typing_ticket',
251
- userId,
252
- }));
253
- return;
254
- }
255
- await sendTyping({
256
- baseUrl: this.apiBaseUrl(),
257
- token: this.#creds.botToken,
258
- body: {
259
- ilink_user_id: userId,
260
- typing_ticket: cfg.typingTicket,
261
- status,
262
- },
263
- });
264
- }
265
228
  async #pollLoop(abortSignal) {
266
229
  if (!this.#creds?.botToken)
267
230
  return;
@@ -276,7 +239,7 @@ export class WeixinIlinkEndpoint {
276
239
  }
277
240
  try {
278
241
  const resp = await this.#getUpdates({
279
- baseUrl: this.apiBaseUrl(),
242
+ baseUrl: this.client.apiBaseUrl,
280
243
  token: this.#creds.botToken,
281
244
  get_updates_buf: getUpdatesBuf,
282
245
  timeoutMs: nextTimeoutMs,
@@ -335,6 +298,7 @@ export class WeixinIlinkEndpoint {
335
298
  }
336
299
  }
337
300
  async #handleInboundMessage(full) {
301
+ void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
338
302
  const fromUserId = full.from_user_id ?? '';
339
303
  if (full.context_token && fromUserId) {
340
304
  setContextToken(this.#options.config.id, fromUserId, full.context_token);
@@ -348,6 +312,15 @@ export class WeixinIlinkEndpoint {
348
312
  preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
349
313
  }));
350
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
+ }
351
324
  async #downloadInboundMedia(full) {
352
325
  const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
353
326
  const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
@@ -357,7 +330,7 @@ export class WeixinIlinkEndpoint {
357
330
  if (!mediaItem)
358
331
  return {};
359
332
  return downloadMediaFromItem(mediaItem, {
360
- cdnBaseUrl: this.cdnBaseUrl(),
333
+ cdnBaseUrl: this.client.cdnBaseUrl,
361
334
  saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
362
335
  log: (msg) => this.#logger.debug(msg),
363
336
  errLog: (msg) => this.#logger.warn(msg),
@@ -429,13 +402,13 @@ export class WeixinIlinkEndpoint {
429
402
  }
430
403
  }
431
404
  }
432
- function createWeixinIlinkEndpointManagement(endpoint) {
405
+ function createWeixinIlinkEndpointManagement(requireClient) {
433
406
  return Object.freeze({
434
407
  // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
435
408
  // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
436
409
  // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
437
410
  async listFriends() {
438
- return listContextTokenUserIds(endpoint.configName).map((userId) => ({
411
+ return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
439
412
  user_id: userId,
440
413
  nickname: userId,
441
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": "5.0.13",
3
+ "version": "6.0.1",
4
4
  "type": "module",
5
5
  "description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
6
6
  "main": "./lib/index.js",
@@ -48,16 +48,17 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@zhin.js/adapter": "1.1.11",
52
- "@zhin.js/core": "1.5.12",
51
+ "@zhin.js/adapter": "1.2.1",
52
+ "@zhin.js/core": "1.5.14",
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.1.11",
58
- "@zhin.js/command": "1.0.15",
59
- "@zhin.js/core": "1.5.12",
60
- "zhin.js": "6.0.12"
58
+ "@zhin.js/adapter": "1.2.1",
59
+ "@zhin.js/command": "1.0.16",
60
+ "@zhin.js/core": "1.5.14",
61
+ "zhin.js": "6.0.14"
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.12"
75
+ "zhin.js": "6.0.14"
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
  */
@@ -6,14 +7,13 @@ import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import type {
8
9
  EndpointFriend,
9
- EndpointInstance,
10
+ EndpointControl,
10
11
  EndpointManagement,
11
12
  EndpointSendRequest,
12
13
  } from 'zhin.js/adapter';
13
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
14
14
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
15
15
  import type { CapabilityId } from 'zhin.js';
16
- import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
16
+ import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
17
17
  import { configureIlinkMeta } from './ilink-meta.js';
18
18
  import {
19
19
  loadSyncBuf,
@@ -23,7 +23,6 @@ import {
23
23
  } from './credentials.js';
24
24
  import { resolveCredentials } from './login.js';
25
25
  import {
26
- getContextToken,
27
26
  listContextTokenUserIds,
28
27
  restoreContextTokens,
29
28
  setContextToken,
@@ -38,7 +37,6 @@ import {
38
37
  } from './ilink-session-guard.js';
39
38
  import { downloadMediaFromItem } from './media-download.js';
40
39
  import { sendMessageWeixin } from './weixin-send.js';
41
- import { sendWeixinMediaFile } from './weixin-send-media.js';
42
40
  import { materializeOutboundMedia } from './outbound-media.js';
43
41
  import { MessageItemType, type WeixinMessage } from './ilink-types.js';
44
42
  import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
@@ -56,6 +54,7 @@ import {
56
54
  type WeixinMessageWithMedia,
57
55
  type WeixinWireSegment,
58
56
  } from './protocol.js';
57
+ import { WeixinIlinkClient } from './client.js';
59
58
 
60
59
  const MAX_CONSECUTIVE_FAILURES = 3;
61
60
  const BACKOFF_DELAY_MS = 30_000;
@@ -72,8 +71,6 @@ export type WeixinIlinkGetUpdates = typeof getUpdates;
72
71
 
73
72
  export interface WeixinIlinkEndpointOptions {
74
73
  readonly id: CapabilityId;
75
- readonly gateway: MessageGateway;
76
- readonly sideEvents?: SideEventGateway;
77
74
  readonly config: ResolvedWeixinIlinkConfig;
78
75
  readonly resolveCredentials?: (
79
76
  config: ResolvedWeixinIlinkConfig,
@@ -86,7 +83,8 @@ export interface WeixinIlinkEndpointOptions {
86
83
  readonly sendText?: WeixinIlinkSendText;
87
84
  }
88
85
 
89
- export class WeixinIlinkEndpoint implements EndpointInstance {
86
+ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
87
+ readonly client: WeixinIlinkClient;
90
88
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
91
89
 
92
90
  readonly #options: WeixinIlinkEndpointOptions;
@@ -106,9 +104,18 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
106
104
  #mediaSweepTimer?: ReturnType<typeof setInterval>;
107
105
  #open = false;
108
106
  #started = false;
109
- readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
107
+ readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(() => this.client);
108
+ readonly control: EndpointControl = Object.freeze<EndpointControl>({
109
+ typing: async (conversation, active) => {
110
+ await this.client.sendTyping(
111
+ conversation.id,
112
+ active === false ? 2 : 1,
113
+ );
114
+ },
115
+ });
110
116
 
111
117
  constructor(options: WeixinIlinkEndpointOptions) {
118
+ super();
112
119
  this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
113
120
  this.#options = options;
114
121
  this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
@@ -116,23 +123,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
116
123
  this.#notifyStop = options.notifyStop ?? notifyStop;
117
124
  this.#getUpdates = options.getUpdates ?? getUpdates;
118
125
  this.#sendText = options.sendText ?? sendMessageWeixin;
126
+ this.client = new WeixinIlinkClient(
127
+ options.config,
128
+ () => this.#creds,
129
+ () => this.#configManager,
130
+ this.#sendText,
131
+ );
119
132
  }
120
133
 
121
134
  get hasCredentials(): boolean {
122
- return Boolean(this.#creds?.botToken);
123
- }
124
-
125
- /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
126
- get configName(): string {
127
- return this.#options.config.id;
128
- }
129
-
130
- apiBaseUrl(): string {
131
- return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
132
- }
133
-
134
- cdnBaseUrl(): string {
135
- return this.#options.config.cdnBaseUrl;
135
+ return this.client.authenticated;
136
136
  }
137
137
 
138
138
  async start(): Promise<void> {
@@ -146,12 +146,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
146
146
  restoreContextTokens(this.#options.config.id);
147
147
 
148
148
  await this.#notifyStart({
149
- baseUrl: this.apiBaseUrl(),
149
+ baseUrl: this.client.apiBaseUrl,
150
150
  token: this.#creds.botToken,
151
151
  });
152
152
 
153
153
  this.#configManager = new WeixinConfigManager(
154
- { baseUrl: this.apiBaseUrl(), token: this.#creds.botToken },
154
+ { baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken },
155
155
  (msg) => this.#logger.debug(msg),
156
156
  );
157
157
 
@@ -162,6 +162,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
162
162
  op: 'connect',
163
163
  endpoint: this.#options.config.id,
164
164
  }));
165
+ void this.#emitPlatformEvent('system.online', {
166
+ account: this.#options.config.id,
167
+ });
165
168
  } catch (error) {
166
169
  await this.stop();
167
170
  this.#logger.error('Failed to connect weixin-ilink bot:', error);
@@ -191,7 +194,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
191
194
  flushContextTokenPersist(this.#options.config.id);
192
195
  if (this.#creds?.botToken) {
193
196
  try {
194
- await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
197
+ await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
195
198
  } catch (err) {
196
199
  this.#logger.warn(formatCompact({
197
200
  op: 'notify_stop',
@@ -201,6 +204,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
201
204
  }
202
205
  }
203
206
  this.#started = false;
207
+ void this.#emitPlatformEvent('system.offline', {
208
+ account: this.#options.config.id,
209
+ });
204
210
  this.#logger.debug(formatCompact({
205
211
  op: 'disconnect',
206
212
  }));
@@ -212,7 +218,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
212
218
  }
213
219
  // 个人微信仅 private 会话,对端 user_id 即 conversation.id
214
220
  const target = conversation.id;
215
- const contextToken = getContextToken(this.#options.config.id, target);
221
+ const contextToken = this.client.contextToken(target);
216
222
  if (!contextToken) {
217
223
  this.#logger.warn(formatCompact({
218
224
  op: 'send',
@@ -223,12 +229,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
223
229
  throw new Error(`missing context_token for peer ${target}`);
224
230
  }
225
231
 
226
- const apiOpts = {
227
- baseUrl: this.apiBaseUrl(),
228
- token: this.#creds.botToken,
229
- contextToken,
230
- };
231
-
232
232
  const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
233
233
  const wire = formatOutboundSegments(payload);
234
234
  const materialized = await materializeOutboundMedia(wire, outboundDir);
@@ -252,21 +252,11 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
252
252
  if (filePath && fs.existsSync(filePath)) {
253
253
  // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
254
254
  if (pendingText.trim()) {
255
- const textResult = await this.#sendText({
256
- to: target,
257
- text: pendingText,
258
- opts: apiOpts,
259
- });
255
+ const textResult = await this.client.sendText(target, pendingText);
260
256
  pendingText = '';
261
257
  lastId = textResult.messageId;
262
258
  }
263
- const result = await sendWeixinMediaFile({
264
- filePath,
265
- to: target,
266
- text: '',
267
- opts: apiOpts,
268
- cdnBaseUrl: this.cdnBaseUrl(),
269
- });
259
+ const result = await this.client.sendMedia(target, filePath);
270
260
  lastId = result.messageId;
271
261
  continue;
272
262
  }
@@ -281,11 +271,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
281
271
  }
282
272
 
283
273
  if (pendingText.trim()) {
284
- const result = await this.#sendText({
285
- to: target,
286
- text: pendingText,
287
- opts: apiOpts,
288
- });
274
+ const result = await this.client.sendText(target, pendingText);
289
275
  lastId = result.messageId;
290
276
  }
291
277
 
@@ -301,7 +287,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
301
287
  }
302
288
  const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
303
289
  const replyTo = weixinReplyTo(msg);
304
- void this.#options.gateway.receive({
290
+ void this.emit('message.receive', {
305
291
  conversation,
306
292
  message: { conversation, id: inboundMessageId(msg) },
307
293
  content: formatInboundContent(msg),
@@ -322,29 +308,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
322
308
  });
323
309
  }
324
310
 
325
- async sendTypingToUser(userId: string, status: number): Promise<void> {
326
- if (!this.#creds?.botToken || !this.#configManager) return;
327
- const contextToken = getContextToken(this.#options.config.id, userId);
328
- const cfg = await this.#configManager.getForUser(userId, contextToken);
329
- if (!cfg.typingTicket) {
330
- this.#logger.debug(formatCompact({
331
- op: 'send_typing',
332
- skip: 'no_typing_ticket',
333
- userId,
334
- }));
335
- return;
336
- }
337
- await sendTyping({
338
- baseUrl: this.apiBaseUrl(),
339
- token: this.#creds.botToken,
340
- body: {
341
- ilink_user_id: userId,
342
- typing_ticket: cfg.typingTicket,
343
- status,
344
- },
345
- });
346
- }
347
-
348
311
  async #pollLoop(abortSignal: AbortSignal): Promise<void> {
349
312
  if (!this.#creds?.botToken) return;
350
313
 
@@ -361,7 +324,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
361
324
 
362
325
  try {
363
326
  const resp = await this.#getUpdates({
364
- baseUrl: this.apiBaseUrl(),
327
+ baseUrl: this.client.apiBaseUrl,
365
328
  token: this.#creds.botToken,
366
329
  get_updates_buf: getUpdatesBuf,
367
330
  timeoutMs: nextTimeoutMs,
@@ -423,6 +386,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
423
386
  }
424
387
 
425
388
  async #handleInboundMessage(full: WeixinMessage): Promise<void> {
389
+ void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
426
390
  const fromUserId = full.from_user_id ?? '';
427
391
  if (full.context_token && fromUserId) {
428
392
  setContextToken(this.#options.config.id, fromUserId, full.context_token);
@@ -438,6 +402,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
438
402
  }));
439
403
  }
440
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
+
441
415
  async #downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
442
416
  const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
443
417
  Boolean(m?.encrypt_query_param || m?.full_url);
@@ -459,7 +433,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
459
433
  if (!mediaItem) return {};
460
434
 
461
435
  return downloadMediaFromItem(mediaItem, {
462
- cdnBaseUrl: this.cdnBaseUrl(),
436
+ cdnBaseUrl: this.client.cdnBaseUrl,
463
437
  saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
464
438
  this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
465
439
  log: (msg) => this.#logger.debug(msg),
@@ -539,13 +513,15 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
539
513
  }
540
514
  }
541
515
 
542
- function createWeixinIlinkEndpointManagement(endpoint: WeixinIlinkEndpoint): EndpointManagement {
516
+ function createWeixinIlinkEndpointManagement(
517
+ requireClient: () => WeixinIlinkClient,
518
+ ): EndpointManagement {
543
519
  return Object.freeze<EndpointManagement>({
544
520
  // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
545
521
  // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
546
522
  // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
547
523
  async listFriends(): Promise<readonly EndpointFriend[]> {
548
- return listContextTokenUserIds(endpoint.configName).map((userId) => ({
524
+ return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
549
525
  user_id: userId,
550
526
  nickname: userId,
551
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
  }