@zhin.js/adapter-weixin-ilink 3.0.1 → 3.0.3

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.
Files changed (164) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +16 -39
  3. package/adapters/weixin-ilink.ts +30 -0
  4. package/lib/aes-ecb.d.ts +0 -1
  5. package/lib/aes-ecb.js +0 -1
  6. package/lib/cdn-upload.d.ts +0 -1
  7. package/lib/cdn-upload.js +0 -1
  8. package/lib/cdn-url.d.ts +0 -1
  9. package/lib/cdn-url.js +0 -1
  10. package/lib/context-store.d.ts +0 -1
  11. package/lib/context-store.js +0 -1
  12. package/lib/credentials.d.ts +0 -1
  13. package/lib/credentials.js +0 -1
  14. package/lib/endpoint.d.ts +35 -30
  15. package/lib/endpoint.js +218 -236
  16. package/lib/ilink-api.d.ts +0 -1
  17. package/lib/ilink-api.js +0 -1
  18. package/lib/ilink-config-cache.d.ts +0 -1
  19. package/lib/ilink-config-cache.js +0 -1
  20. package/lib/ilink-logger.d.ts +0 -1
  21. package/lib/ilink-logger.js +0 -1
  22. package/lib/ilink-meta.d.ts +0 -1
  23. package/lib/ilink-meta.js +0 -1
  24. package/lib/ilink-random.d.ts +0 -1
  25. package/lib/ilink-random.js +0 -1
  26. package/lib/ilink-redact.d.ts +0 -1
  27. package/lib/ilink-redact.js +0 -1
  28. package/lib/ilink-session-guard.d.ts +0 -1
  29. package/lib/ilink-session-guard.js +0 -1
  30. package/lib/ilink-types.d.ts +0 -1
  31. package/lib/ilink-types.js +0 -1
  32. package/lib/index.d.ts +4 -19
  33. package/lib/index.js +2 -37
  34. package/lib/login.d.ts +4 -6
  35. package/lib/login.js +43 -65
  36. package/lib/media-download.d.ts +1 -2
  37. package/lib/media-download.js +1 -2
  38. package/lib/mime.d.ts +0 -1
  39. package/lib/mime.js +0 -1
  40. package/lib/outbound-media.d.ts +4 -4
  41. package/lib/outbound-media.js +44 -42
  42. package/lib/pic-decrypt.d.ts +0 -1
  43. package/lib/pic-decrypt.js +0 -1
  44. package/lib/protocol.d.ts +52 -0
  45. package/lib/protocol.js +109 -0
  46. package/lib/silk-transcode.d.ts +0 -1
  47. package/lib/silk-transcode.js +0 -1
  48. package/lib/types.d.ts +2 -16
  49. package/lib/types.js +0 -1
  50. package/lib/upload.d.ts +1 -2
  51. package/lib/upload.js +1 -2
  52. package/lib/weixin-inbound.d.ts +1 -2
  53. package/lib/weixin-inbound.js +1 -2
  54. package/lib/weixin-send-media.d.ts +0 -1
  55. package/lib/weixin-send-media.js +0 -1
  56. package/lib/weixin-send.d.ts +1 -2
  57. package/lib/weixin-send.js +2 -3
  58. package/package.json +37 -41
  59. package/plugin.ts +8 -0
  60. package/schema.json +30 -0
  61. package/src/endpoint.ts +261 -279
  62. package/src/index.ts +24 -53
  63. package/src/login.ts +56 -72
  64. package/src/media-download.ts +1 -2
  65. package/src/outbound-media.ts +56 -50
  66. package/src/protocol.ts +172 -0
  67. package/src/types.ts +6 -15
  68. package/src/upload.ts +2 -2
  69. package/src/weixin-inbound.ts +2 -2
  70. package/src/weixin-send.ts +4 -4
  71. package/client/LoginAssistPanel.tsx +0 -200
  72. package/client/WeixinIlinkManagement.tsx +0 -139
  73. package/client/index.tsx +0 -11
  74. package/client/tsconfig.json +0 -13
  75. package/client/utils/api.ts +0 -30
  76. package/dist/index.js +0 -29
  77. package/lib/adapter.d.ts +0 -15
  78. package/lib/adapter.d.ts.map +0 -1
  79. package/lib/adapter.js +0 -21
  80. package/lib/adapter.js.map +0 -1
  81. package/lib/aes-ecb.d.ts.map +0 -1
  82. package/lib/aes-ecb.js.map +0 -1
  83. package/lib/cdn-upload.d.ts.map +0 -1
  84. package/lib/cdn-upload.js.map +0 -1
  85. package/lib/cdn-upload.test.d.ts +0 -2
  86. package/lib/cdn-upload.test.d.ts.map +0 -1
  87. package/lib/cdn-upload.test.js +0 -150
  88. package/lib/cdn-upload.test.js.map +0 -1
  89. package/lib/cdn-url.d.ts.map +0 -1
  90. package/lib/cdn-url.js.map +0 -1
  91. package/lib/context-store.d.ts.map +0 -1
  92. package/lib/context-store.js.map +0 -1
  93. package/lib/credentials.d.ts.map +0 -1
  94. package/lib/credentials.js.map +0 -1
  95. package/lib/endpoint.d.ts.map +0 -1
  96. package/lib/endpoint.js.map +0 -1
  97. package/lib/ilink-api.d.ts.map +0 -1
  98. package/lib/ilink-api.js.map +0 -1
  99. package/lib/ilink-config-cache.d.ts.map +0 -1
  100. package/lib/ilink-config-cache.js.map +0 -1
  101. package/lib/ilink-logger.d.ts.map +0 -1
  102. package/lib/ilink-logger.js.map +0 -1
  103. package/lib/ilink-meta.d.ts.map +0 -1
  104. package/lib/ilink-meta.js.map +0 -1
  105. package/lib/ilink-random.d.ts.map +0 -1
  106. package/lib/ilink-random.js.map +0 -1
  107. package/lib/ilink-redact.d.ts.map +0 -1
  108. package/lib/ilink-redact.js.map +0 -1
  109. package/lib/ilink-session-guard.d.ts.map +0 -1
  110. package/lib/ilink-session-guard.js.map +0 -1
  111. package/lib/ilink-types.d.ts.map +0 -1
  112. package/lib/ilink-types.js.map +0 -1
  113. package/lib/index.d.ts.map +0 -1
  114. package/lib/index.js.map +0 -1
  115. package/lib/login-assist-routes.d.ts +0 -7
  116. package/lib/login-assist-routes.d.ts.map +0 -1
  117. package/lib/login-assist-routes.js +0 -29
  118. package/lib/login-assist-routes.js.map +0 -1
  119. package/lib/login.d.ts.map +0 -1
  120. package/lib/login.js.map +0 -1
  121. package/lib/media-download.d.ts.map +0 -1
  122. package/lib/media-download.js.map +0 -1
  123. package/lib/mime.d.ts.map +0 -1
  124. package/lib/mime.js.map +0 -1
  125. package/lib/mime.test.d.ts +0 -2
  126. package/lib/mime.test.d.ts.map +0 -1
  127. package/lib/mime.test.js +0 -62
  128. package/lib/mime.test.js.map +0 -1
  129. package/lib/outbound-media.d.ts.map +0 -1
  130. package/lib/outbound-media.js.map +0 -1
  131. package/lib/pic-decrypt.d.ts.map +0 -1
  132. package/lib/pic-decrypt.js.map +0 -1
  133. package/lib/routes.d.ts +0 -8
  134. package/lib/routes.d.ts.map +0 -1
  135. package/lib/routes.js +0 -29
  136. package/lib/routes.js.map +0 -1
  137. package/lib/segment-mapper.d.ts +0 -2
  138. package/lib/segment-mapper.d.ts.map +0 -1
  139. package/lib/segment-mapper.js +0 -2
  140. package/lib/segment-mapper.js.map +0 -1
  141. package/lib/silk-transcode.d.ts.map +0 -1
  142. package/lib/silk-transcode.js.map +0 -1
  143. package/lib/silk-transcode.test.d.ts +0 -2
  144. package/lib/silk-transcode.test.d.ts.map +0 -1
  145. package/lib/silk-transcode.test.js +0 -53
  146. package/lib/silk-transcode.test.js.map +0 -1
  147. package/lib/types.d.ts.map +0 -1
  148. package/lib/types.js.map +0 -1
  149. package/lib/upload.d.ts.map +0 -1
  150. package/lib/upload.js.map +0 -1
  151. package/lib/upload.test.d.ts +0 -2
  152. package/lib/upload.test.d.ts.map +0 -1
  153. package/lib/upload.test.js +0 -250
  154. package/lib/upload.test.js.map +0 -1
  155. package/lib/weixin-inbound.d.ts.map +0 -1
  156. package/lib/weixin-inbound.js.map +0 -1
  157. package/lib/weixin-send-media.d.ts.map +0 -1
  158. package/lib/weixin-send-media.js.map +0 -1
  159. package/lib/weixin-send.d.ts.map +0 -1
  160. package/lib/weixin-send.js.map +0 -1
  161. package/src/adapter.ts +0 -25
  162. package/src/login-assist-routes.ts +0 -36
  163. package/src/routes.ts +0 -39
  164. package/src/segment-mapper.ts +0 -1
package/src/endpoint.ts CHANGED
@@ -1,309 +1,236 @@
1
1
  /**
2
- * 微信 iLink Bot:长轮询入站 + CDN 媒体收发
2
+ * WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
3
3
  */
4
- import crypto from "node:crypto";
5
- import fs from "node:fs";
6
- import path from "node:path";
7
- import {
8
- Endpoint,
9
- Message,
10
- type MessageSegment,
11
- type SendContent,
12
- type SendOptions,
13
- segment,
14
- expandInteractiveSegmentsInContent,} from 'zhin.js';
15
- import { getUpdates, notifyStart, notifyStop, sendTyping } from "./ilink-api.js";
16
- import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
17
- import {
18
- configureIlinkMeta,
19
- DEFAULT_API_BASE_URL,
20
- DEFAULT_CDN_BASE_URL,
21
- } from "./ilink-meta.js";
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import type { EndpointInstance } from '@zhin.js/adapter';
8
+ import type { MessageGateway } from '@zhin.js/core/runtime';
9
+ import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
11
+ import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js';
12
+ import { configureIlinkMeta } from './ilink-meta.js';
22
13
  import {
23
14
  loadSyncBuf,
24
15
  resolveStateDir,
25
16
  saveSyncBuf,
26
17
  type WeixinIlinkCredentials,
27
- } from "./credentials.js";
28
- import { resolveCredentials } from "./login.js";
18
+ } from './credentials.js';
19
+ import { resolveCredentials } from './login.js';
29
20
  import {
30
- bodyFromItemList,
31
21
  getContextToken,
32
- isMediaItem,
33
22
  restoreContextTokens,
34
23
  setContextToken,
35
- } from "./context-store.js";
36
- import { WeixinConfigManager } from "./ilink-config-cache.js";
24
+ } from './context-store.js';
25
+ import { WeixinConfigManager } from './ilink-config-cache.js';
37
26
  import {
38
27
  getRemainingPauseMs,
39
28
  isSessionPaused,
40
29
  pauseSession,
41
30
  SESSION_EXPIRED_ERRCODE,
42
- } from "./ilink-session-guard.js";
43
- import { downloadMediaFromItem } from "./media-download.js";
44
- import { sendMessageWeixin } from "./weixin-send.js";
45
- import { sendWeixinMediaFile } from "./weixin-send-media.js";
46
- import { materializeOutboundMedia, segmentMediaRef } from "./outbound-media.js";
47
- import type { MessageItem, WeixinMessage } from "./ilink-types.js";
48
- import { MessageItemType } from "./ilink-types.js";
49
- import { getExtensionFromMime, sniffMimeFromBuffer } from "./mime.js";
50
- import type { WeixinInboundMediaOpts } from "./weixin-inbound.js";
51
- import type { WeixinIlinkEndpointConfig } from "./types.js";
52
- import type { WeixinIlinkAdapter } from "./adapter.js";
53
-
54
- const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
31
+ } from './ilink-session-guard.js';
32
+ import { downloadMediaFromItem } from './media-download.js';
33
+ import { sendMessageWeixin } from './weixin-send.js';
34
+ import { sendWeixinMediaFile } from './weixin-send-media.js';
35
+ import { materializeOutboundMedia } from './outbound-media.js';
36
+ import { MessageItemType, type WeixinMessage } from './ilink-types.js';
37
+ import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
38
+ import type { WeixinInboundMediaOpts } from './weixin-inbound.js';
39
+ import {
40
+ formatInboundContent,
41
+ formatOutboundSegments,
42
+ inboundMessageId,
43
+ segmentLocalPath,
44
+ sleep,
45
+ type ResolvedWeixinIlinkConfig,
46
+ type WeixinMessageWithMedia,
47
+ type WeixinWireSegment,
48
+ } from './protocol.js';
49
+
50
+ const logger = getLogger('weixin-ilink');
51
+
55
52
  const MAX_CONSECUTIVE_FAILURES = 3;
56
53
  const BACKOFF_DELAY_MS = 30_000;
57
54
  const RETRY_DELAY_MS = 2_000;
58
55
  const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
59
56
 
60
- export type WeixinMessageWithMedia = WeixinMessage & {
61
- _media?: WeixinInboundMediaOpts;
62
- };
63
-
64
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
65
- return new Promise((resolve) => {
66
- if (signal?.aborted) {
67
- resolve();
68
- return;
69
- }
70
- const timer = setTimeout(resolve, ms);
71
- signal?.addEventListener(
72
- "abort",
73
- () => {
74
- clearTimeout(timer);
75
- resolve();
76
- },
77
- { once: true },
78
- );
79
- });
57
+ export type WeixinIlinkSendText = typeof sendMessageWeixin;
58
+ export type WeixinIlinkNotifyStart = typeof notifyStart;
59
+ export type WeixinIlinkNotifyStop = typeof notifyStop;
60
+ export type WeixinIlinkGetUpdates = typeof getUpdates;
61
+
62
+ export interface WeixinIlinkEndpointOptions {
63
+ readonly id: CapabilityId;
64
+ readonly gateway: MessageGateway;
65
+ readonly config: ResolvedWeixinIlinkConfig;
66
+ readonly resolveCredentials?: (
67
+ config: ResolvedWeixinIlinkConfig,
68
+ ) => Promise<WeixinIlinkCredentials>;
69
+ /** Test / internal: override network side effects. */
70
+ readonly notifyStart?: WeixinIlinkNotifyStart;
71
+ readonly notifyStop?: WeixinIlinkNotifyStop;
72
+ readonly getUpdates?: WeixinIlinkGetUpdates;
73
+ readonly sendText?: WeixinIlinkSendText;
80
74
  }
81
75
 
82
- function segmentLocalPath(seg: MessageSegment): string | undefined {
83
- const ref = segmentMediaRef(seg);
84
- if (!ref || ref.startsWith("base64://") || /^data:/.test(ref) || /^https?:\/\//i.test(ref)) {
85
- return undefined;
86
- }
87
- return ref.replace(/^file:\/\//, "");
88
- }
89
-
90
- export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig, WeixinMessage> {
91
- $connected = false;
92
-
93
- private creds: WeixinIlinkCredentials | null = null;
94
- private pollAbort?: AbortController;
95
- private pollPromise?: Promise<void>;
96
- private configManager?: WeixinConfigManager;
97
-
98
- get $id(): string {
99
- return this.$config.name;
76
+ export class WeixinIlinkEndpoint implements EndpointInstance {
77
+ readonly #options: WeixinIlinkEndpointOptions;
78
+ readonly #resolveCredentials: (
79
+ config: ResolvedWeixinIlinkConfig,
80
+ ) => Promise<WeixinIlinkCredentials>;
81
+ readonly #notifyStart: WeixinIlinkNotifyStart;
82
+ readonly #notifyStop: WeixinIlinkNotifyStop;
83
+ readonly #getUpdates: WeixinIlinkGetUpdates;
84
+ readonly #sendText: WeixinIlinkSendText;
85
+ #creds: WeixinIlinkCredentials | null = null;
86
+ #pollAbort?: AbortController;
87
+ #pollPromise?: Promise<void>;
88
+ #configManager?: WeixinConfigManager;
89
+ #open = false;
90
+ #started = false;
91
+
92
+ constructor(options: WeixinIlinkEndpointOptions) {
93
+ this.#options = options;
94
+ this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
95
+ this.#notifyStart = options.notifyStart ?? notifyStart;
96
+ this.#notifyStop = options.notifyStop ?? notifyStop;
97
+ this.#getUpdates = options.getUpdates ?? getUpdates;
98
+ this.#sendText = options.sendText ?? sendMessageWeixin;
100
99
  }
101
100
 
102
101
  get hasCredentials(): boolean {
103
- return Boolean(this.creds?.botToken);
102
+ return Boolean(this.#creds?.botToken);
104
103
  }
105
104
 
106
- get pluginLogger() {
107
- return this.adapter.plugin.logger;
108
- }
109
-
110
- constructor(
111
- public adapter: WeixinIlinkAdapter,
112
- public $config: WeixinIlinkEndpointConfig,
113
- ) {}
114
-
115
105
  apiBaseUrl(): string {
116
- return this.$config.baseUrl ?? this.creds?.baseUrl ?? DEFAULT_API_BASE_URL;
106
+ return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
117
107
  }
118
108
 
119
109
  cdnBaseUrl(): string {
120
- return this.$config.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL;
110
+ return this.#options.config.cdnBaseUrl;
121
111
  }
122
112
 
123
- async $connect(): Promise<void> {
124
- configureIlinkMeta({ botAgent: this.$config.botAgent });
125
- this.creds = await resolveCredentials(this.adapter.plugin, this.$config);
126
- restoreContextTokens(this.$id);
113
+ async start(): Promise<void> {
114
+ if (this.#started) return;
115
+ this.#started = true;
116
+ try {
117
+ configureIlinkMeta({ botAgent: this.#options.config.botAgent });
118
+ this.#creds = await this.#resolveCredentials(this.#options.config);
119
+ restoreContextTokens(this.#options.config.name);
127
120
 
128
- await notifyStart({
129
- baseUrl: this.apiBaseUrl(),
130
- token: this.creds.botToken,
131
- });
121
+ await this.#notifyStart({
122
+ baseUrl: this.apiBaseUrl(),
123
+ token: this.#creds.botToken,
124
+ });
125
+
126
+ this.#configManager = new WeixinConfigManager(
127
+ { baseUrl: this.apiBaseUrl(), token: this.#creds.botToken },
128
+ (msg) => logger.debug(msg),
129
+ );
130
+
131
+ this.#pollAbort = new AbortController();
132
+ this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
133
+ logger.info(formatCompact({
134
+ op: 'connect',
135
+ endpoint: this.#options.config.name,
136
+ }));
137
+ } catch (error) {
138
+ await this.stop();
139
+ logger.error('Failed to connect weixin-ilink bot:', error);
140
+ throw error;
141
+ }
142
+ }
132
143
 
133
- this.configManager = new WeixinConfigManager(
134
- { baseUrl: this.apiBaseUrl(), token: this.creds.botToken },
135
- (msg) => this.pluginLogger.debug(msg),
136
- );
144
+ open(): void {
145
+ this.#open = true;
146
+ }
137
147
 
138
- this.pollAbort = new AbortController();
139
- this.pollPromise = this.pollLoop(this.pollAbort.signal);
140
- this.$connected = true;
141
- this.pluginLogger.info(`weixin-ilink endpoint ${this.$id} connected`);
148
+ close(): void {
149
+ this.#open = false;
142
150
  }
143
151
 
144
- async $disconnect(): Promise<void> {
145
- this.pollAbort?.abort();
152
+ async stop(): Promise<void> {
153
+ this.#open = false;
154
+ this.#pollAbort?.abort();
146
155
  try {
147
- await this.pollPromise;
156
+ await this.#pollPromise;
148
157
  } catch {
149
158
  /* poll loop exit */
150
159
  }
151
- if (this.creds?.botToken) {
160
+ if (this.#creds?.botToken) {
152
161
  try {
153
- await notifyStop({ baseUrl: this.apiBaseUrl(), token: this.creds.botToken });
162
+ await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
154
163
  } catch (err) {
155
- this.pluginLogger.warn(`notifyStop failed: ${String(err)}`);
164
+ logger.warn(formatCompact({
165
+ op: 'notify_stop',
166
+ ok: false,
167
+ error: err instanceof Error ? err.message : String(err),
168
+ }));
156
169
  }
157
170
  }
158
- this.$connected = false;
159
- this.pluginLogger.info(`weixin-ilink endpoint ${this.$id} disconnected`);
171
+ this.#started = false;
172
+ logger.debug(formatCompact({
173
+ op: 'disconnect',
174
+ endpoint: this.#options.config.name,
175
+ }));
160
176
  }
161
177
 
162
- async sendTypingToUser(userId: string, status: number): Promise<void> {
163
- if (!this.creds?.botToken || !this.configManager) return;
164
- const contextToken = getContextToken(this.$id, userId);
165
- const cfg = await this.configManager.getForUser(userId, contextToken);
166
- if (!cfg.typingTicket) {
167
- this.pluginLogger.debug(`sendTyping: no typing_ticket for user=${userId}`);
168
- return;
178
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
179
+ if (!this.#creds?.botToken) {
180
+ throw new Error('weixin-ilink bot not authenticated');
169
181
  }
170
- await sendTyping({
171
- baseUrl: this.apiBaseUrl(),
172
- token: this.creds.botToken,
173
- body: {
174
- ilink_user_id: userId,
175
- typing_ticket: cfg.typingTicket,
176
- status,
177
- },
178
- });
179
- }
180
-
181
- async $recallMessage(_id: string): Promise<void> {
182
- this.pluginLogger.debug("weixin-ilink does not support message recall");
183
- }
184
-
185
- $formatMessage(msg: WeixinMessageWithMedia): Message<WeixinMessage> {
186
- const userId = msg.from_user_id ?? "";
187
- const wire = this.buildInboundContent(msg);
188
- const quoteId = Message.quoteIdFromContent(wire);
189
- Message.alignReplySegments(wire, quoteId);
190
- const content = toCanonicalSegments(wire);
191
- const msgId = String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
192
-
193
- return Message.from(msg, {
194
- $id: msgId,
195
- $adapter: "weixin-ilink",
196
- $endpoint: this.$config.name,
197
- $sender: {
198
- id: userId,
199
- name: userId,
200
- },
201
- $channel: {
202
- id: userId,
203
- type: "private",
204
- },
205
- $content: content,
206
- $quote_id: quoteId,
207
- $raw: bodyFromItemList(msg.item_list),
208
- $timestamp: msg.create_time_ms ?? Date.now(),
209
- $recall: async () => this.$recallMessage(msgId),
210
- $reply: async (replyContent: SendContent, _quote?: boolean | string) => {
211
- if (!Array.isArray(replyContent)) replyContent = [replyContent];
212
- return this.adapter.sendMessage({
213
- context: "weixin-ilink",
214
- endpoint: this.$config.name,
215
- id: userId,
216
- type: "private",
217
- content: replyContent,
218
- });
219
- },
220
- });
221
- }
222
-
223
- private buildInboundContent(msg: WeixinMessageWithMedia): MessageSegment[] {
224
- const segments: MessageSegment[] = [];
225
- const text = bodyFromItemList(msg.item_list);
226
- if (text) segments.push(segment.text(text));
227
-
228
- const media = msg._media;
229
- if (media?.decryptedPicPath) {
230
- segments.push(segment("image", { file: media.decryptedPicPath }));
231
- } else if (media?.decryptedVideoPath) {
232
- segments.push(segment("video", { file: media.decryptedVideoPath }));
233
- } else if (media?.decryptedFilePath) {
234
- segments.push(segment("file", {
235
- file: media.decryptedFilePath,
236
- name: path.basename(media.decryptedFilePath),
237
- }));
238
- } else if (media?.decryptedVoicePath) {
239
- segments.push(segment("record", { file: media.decryptedVoicePath }));
240
- } else if (!text && msg.item_list?.some((i) => isMediaItem(i))) {
241
- segments.push(segment.text("[媒体消息]"));
242
- }
243
-
244
- return segments.length ? segments : [segment.text("")];
245
- }
246
-
247
- async $sendMessage(options: SendOptions): Promise<string> {
248
- if (!this.creds?.botToken) {
249
- throw new Error("weixin-ilink bot not authenticated");
250
- }
251
- const peerId = options.id;
252
- const contextToken = getContextToken(this.$id, peerId);
182
+ const contextToken = getContextToken(this.#options.config.name, target);
253
183
  if (!contextToken) {
254
- this.pluginLogger.warn(`missing context_token for peer ${peerId}, refusing send`);
255
- throw new Error(`missing context_token for peer ${peerId}`);
184
+ logger.warn(formatCompact({
185
+ op: 'send',
186
+ ok: false,
187
+ reason: 'missing_context_token',
188
+ target,
189
+ }));
190
+ throw new Error(`missing context_token for peer ${target}`);
256
191
  }
257
192
 
258
193
  const apiOpts = {
259
194
  baseUrl: this.apiBaseUrl(),
260
- token: this.creds.botToken,
195
+ token: this.#creds.botToken,
261
196
  contextToken,
262
197
  };
263
198
 
264
- const outboundDir = path.join(resolveStateDir(), "media", "outbound");
265
- const canonical = expandInteractiveSegmentsInContent(options.content);
266
- const wire = fromCanonicalSegments(
267
- (Array.isArray(canonical) ? canonical : [canonical]).map((s) =>
268
- typeof s === 'string' ? { type: 'text' as const, data: { text: s } } : s,
269
- ),
270
- );
199
+ const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
200
+ const wire = formatOutboundSegments(payload);
271
201
  const materialized = await materializeOutboundMedia(wire, outboundDir);
272
202
  const segments = Array.isArray(materialized) ? materialized : [materialized];
273
- let lastId = "";
274
- let pendingText = "";
203
+ let lastId = '';
204
+ let pendingText = '';
275
205
 
276
206
  for (const raw of segments) {
277
- if (typeof raw === "string") {
207
+ if (typeof raw === 'string') {
278
208
  pendingText += raw;
279
209
  continue;
280
210
  }
281
- if (typeof raw !== "object" || raw === null || !("type" in raw)) {
282
- continue;
283
- }
284
- const seg = raw as MessageSegment;
285
- if (seg.type === "text" || seg.type === "markdown") {
286
- const t = (seg.data as { text?: string }).text ?? "";
287
- pendingText += t;
211
+ if (typeof raw !== 'object' || raw === null || !('type' in raw)) continue;
212
+ const seg = raw as WeixinWireSegment;
213
+ if (seg.type === 'text' || seg.type === 'markdown') {
214
+ pendingText += String((seg.data as { text?: string } | undefined)?.text ?? '');
288
215
  continue;
289
216
  }
290
217
 
291
218
  const filePath = segmentLocalPath(seg);
292
219
  if (filePath && fs.existsSync(filePath)) {
293
- // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体(不带 caption)
220
+ // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
294
221
  if (pendingText.trim()) {
295
- const textResult = await sendMessageWeixin({
296
- to: peerId,
222
+ const textResult = await this.#sendText({
223
+ to: target,
297
224
  text: pendingText,
298
225
  opts: apiOpts,
299
226
  });
300
- pendingText = "";
227
+ pendingText = '';
301
228
  lastId = textResult.messageId;
302
229
  }
303
230
  const result = await sendWeixinMediaFile({
304
231
  filePath,
305
- to: peerId,
306
- text: "",
232
+ to: target,
233
+ text: '',
307
234
  opts: apiOpts,
308
235
  cdnBaseUrl: this.cdnBaseUrl(),
309
236
  });
@@ -311,14 +238,18 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
311
238
  continue;
312
239
  }
313
240
 
314
- if (seg.type === "image" || seg.type === "video" || seg.type === "file" || seg.type === "record") {
315
- this.pluginLogger.warn(`skip outbound ${seg.type}: no local file path`);
241
+ if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
242
+ logger.warn(formatCompact({
243
+ op: 'send',
244
+ skip: 'no_local_file',
245
+ type: seg.type,
246
+ }));
316
247
  }
317
248
  }
318
249
 
319
250
  if (pendingText.trim()) {
320
- const result = await sendMessageWeixin({
321
- to: peerId,
251
+ const result = await this.#sendText({
252
+ to: target,
322
253
  text: pendingText,
323
254
  opts: apiOpts,
324
255
  });
@@ -328,23 +259,74 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
328
259
  return lastId || `weixin-ilink-${Date.now()}`;
329
260
  }
330
261
 
331
- private async pollLoop(abortSignal: AbortSignal): Promise<void> {
332
- if (!this.creds?.botToken) return;
262
+ /** Test / internal: admit a parsed message when the endpoint is open. */
263
+ admit(msg: WeixinMessageWithMedia): void {
264
+ if (!this.#open) return;
265
+ const userId = msg.from_user_id ?? '';
266
+ if (msg.context_token && userId) {
267
+ setContextToken(this.#options.config.name, userId, msg.context_token);
268
+ }
269
+ void this.#options.gateway.receive({
270
+ adapter: this.#options.id,
271
+ target: userId,
272
+ content: formatInboundContent(msg),
273
+ sender: userId,
274
+ id: inboundMessageId(msg),
275
+ metadata: Object.freeze({
276
+ endpoint: this.#options.config.name,
277
+ messageType: msg.message_type,
278
+ createTimeMs: msg.create_time_ms,
279
+ }),
280
+ }).catch((err) => {
281
+ logger.warn(formatCompact({
282
+ op: 'weixin_ilink_gateway_receive_failed',
283
+ target: userId,
284
+ error: err instanceof Error ? err.message : String(err),
285
+ }));
286
+ });
287
+ }
333
288
 
334
- let getUpdatesBuf = loadSyncBuf(this.$id);
335
- let nextTimeoutMs = this.$config.longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
289
+ async sendTypingToUser(userId: string, status: number): Promise<void> {
290
+ if (!this.#creds?.botToken || !this.#configManager) return;
291
+ const contextToken = getContextToken(this.#options.config.name, userId);
292
+ const cfg = await this.#configManager.getForUser(userId, contextToken);
293
+ if (!cfg.typingTicket) {
294
+ logger.debug(formatCompact({
295
+ op: 'send_typing',
296
+ skip: 'no_typing_ticket',
297
+ userId,
298
+ }));
299
+ return;
300
+ }
301
+ await sendTyping({
302
+ baseUrl: this.apiBaseUrl(),
303
+ token: this.#creds.botToken,
304
+ body: {
305
+ ilink_user_id: userId,
306
+ typing_ticket: cfg.typingTicket,
307
+ status,
308
+ },
309
+ });
310
+ }
311
+
312
+ async #pollLoop(abortSignal: AbortSignal): Promise<void> {
313
+ if (!this.#creds?.botToken) return;
314
+
315
+ const endpointName = this.#options.config.name;
316
+ let getUpdatesBuf = loadSyncBuf(endpointName);
317
+ let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
336
318
  let consecutiveFailures = 0;
337
319
 
338
320
  while (!abortSignal.aborted) {
339
- if (isSessionPaused(this.$id)) {
340
- await sleep(getRemainingPauseMs(this.$id), abortSignal);
321
+ if (isSessionPaused(endpointName)) {
322
+ await sleep(getRemainingPauseMs(endpointName), abortSignal);
341
323
  continue;
342
324
  }
343
325
 
344
326
  try {
345
- const resp = await getUpdates({
327
+ const resp = await this.#getUpdates({
346
328
  baseUrl: this.apiBaseUrl(),
347
- token: this.creds.botToken,
329
+ token: this.#creds.botToken,
348
330
  get_updates_buf: getUpdatesBuf,
349
331
  timeoutMs: nextTimeoutMs,
350
332
  abortSignal,
@@ -355,16 +337,16 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
355
337
  }
356
338
 
357
339
  const isApiError =
358
- (resp.ret !== undefined && resp.ret !== 0) ||
359
- (resp.errcode !== undefined && resp.errcode !== 0);
340
+ (resp.ret !== undefined && resp.ret !== 0)
341
+ || (resp.errcode !== undefined && resp.errcode !== 0);
360
342
 
361
343
  if (isApiError) {
362
344
  const sessionExpired =
363
345
  resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
364
346
  if (sessionExpired) {
365
- pauseSession(this.$id);
347
+ pauseSession(endpointName);
366
348
  consecutiveFailures = 0;
367
- await sleep(getRemainingPauseMs(this.$id), abortSignal);
349
+ await sleep(getRemainingPauseMs(endpointName), abortSignal);
368
350
  continue;
369
351
  }
370
352
  consecutiveFailures += 1;
@@ -380,16 +362,20 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
380
362
  consecutiveFailures = 0;
381
363
  if (resp.get_updates_buf) {
382
364
  getUpdatesBuf = resp.get_updates_buf;
383
- saveSyncBuf(this.$id, getUpdatesBuf);
365
+ saveSyncBuf(endpointName, getUpdatesBuf);
384
366
  }
385
367
 
386
368
  for (const inbound of resp.msgs ?? []) {
387
- await this.handleInboundMessage(inbound);
369
+ await this.#handleInboundMessage(inbound);
388
370
  }
389
371
  } catch (err) {
390
372
  if (abortSignal.aborted) return;
391
373
  consecutiveFailures += 1;
392
- this.pluginLogger.error(`weixin-ilink poll error: ${String(err)}`);
374
+ logger.error(formatCompact({
375
+ op: 'poll',
376
+ ok: false,
377
+ error: err instanceof Error ? err.message : String(err),
378
+ }));
393
379
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
394
380
  consecutiveFailures = 0;
395
381
  await sleep(BACKOFF_DELAY_MS, abortSignal);
@@ -400,37 +386,37 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
400
386
  }
401
387
  }
402
388
 
403
- private async handleInboundMessage(full: WeixinMessage): Promise<void> {
404
- const fromUserId = full.from_user_id ?? "";
389
+ async #handleInboundMessage(full: WeixinMessage): Promise<void> {
390
+ const fromUserId = full.from_user_id ?? '';
405
391
  if (full.context_token && fromUserId) {
406
- setContextToken(this.$id, fromUserId, full.context_token);
392
+ setContextToken(this.#options.config.name, fromUserId, full.context_token);
407
393
  }
408
394
 
409
- const mediaOpts = await this.downloadInboundMedia(full);
410
- // 入站保持单条 message.receive(文字+媒体同条),供 AI context / im_transcripts / agent_messages 组装。
411
- // 出站才按微信限制拆分图文(见 sendMessage)。
412
- const message = this.$formatMessage({ ...full, _media: mediaOpts });
413
- this.adapter.emit("message.receive", message);
414
- this.pluginLogger.debug(
415
- `${this.$id} recv private(${message.$channel.id}): ${segment.raw(message.$content)}`,
416
- );
395
+ const mediaOpts = await this.#downloadInboundMedia(full);
396
+ this.admit({ ...full, _media: mediaOpts });
397
+ logger.debug(formatCompact({
398
+ op: 'recv',
399
+ endpoint: this.#options.config.name,
400
+ target: fromUserId,
401
+ preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
402
+ }));
417
403
  }
418
404
 
419
- private async downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
405
+ async #downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
420
406
  const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
421
407
  Boolean(m?.encrypt_query_param || m?.full_url);
422
408
 
423
409
  const mediaItem =
424
410
  full.item_list?.find(
425
411
  (i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),
426
- ) ??
427
- full.item_list?.find(
412
+ )
413
+ ?? full.item_list?.find(
428
414
  (i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),
429
- ) ??
430
- full.item_list?.find(
415
+ )
416
+ ?? full.item_list?.find(
431
417
  (i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),
432
- ) ??
433
- full.item_list?.find(
418
+ )
419
+ ?? full.item_list?.find(
434
420
  (i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media),
435
421
  );
436
422
 
@@ -439,41 +425,37 @@ export class WeixinIlinkEndpoint implements Endpoint<WeixinIlinkEndpointConfig,
439
425
  return downloadMediaFromItem(mediaItem, {
440
426
  cdnBaseUrl: this.cdnBaseUrl(),
441
427
  saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
442
- this.saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
443
- log: (msg) => this.pluginLogger.debug(msg),
444
- errLog: (msg) => this.pluginLogger.warn(msg),
445
- label: `inbound:${fromUserIdLabel(full)}`,
428
+ this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
429
+ log: (msg) => logger.debug(msg),
430
+ errLog: (msg) => logger.warn(msg),
431
+ label: `inbound:${full.from_user_id ?? 'unknown'}`,
446
432
  });
447
433
  }
448
434
 
449
- private async saveInboundMedia(
435
+ async #saveInboundMedia(
450
436
  buffer: Buffer,
451
437
  contentType?: string,
452
- subdir = "inbound",
438
+ subdir = 'inbound',
453
439
  maxBytes = WEIXIN_MEDIA_MAX_BYTES,
454
440
  originalFilename?: string,
455
441
  ): Promise<{ path: string }> {
456
442
  if (buffer.length > maxBytes) {
457
443
  throw new Error(`media exceeds max size ${maxBytes}`);
458
444
  }
459
- const dir = path.join(resolveStateDir(), "media", subdir);
445
+ const dir = path.join(resolveStateDir(), 'media', subdir);
460
446
  fs.mkdirSync(dir, { recursive: true });
461
447
  const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
462
448
  const ext = originalFilename
463
449
  ? path.extname(originalFilename)
464
450
  : resolvedMime
465
451
  ? getExtensionFromMime(resolvedMime)
466
- : ".bin";
452
+ : '.bin';
467
453
  const base = originalFilename
468
454
  ? path.basename(originalFilename, ext)
469
- : "media";
470
- const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
455
+ : 'media';
456
+ const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
471
457
  const filePath = path.join(dir, fileName);
472
458
  fs.writeFileSync(filePath, buffer);
473
459
  return { path: filePath };
474
460
  }
475
461
  }
476
-
477
- function fromUserIdLabel(msg: WeixinMessage): string {
478
- return msg.from_user_id ?? "unknown";
479
- }