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