@zhin.js/adapter-weixin-ilink 1.0.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/CHANGELOG.md +512 -0
  2. package/README.md +24 -42
  3. package/adapters/weixin-ilink/index.js +32 -0
  4. package/adapters/weixin-ilink/index.ts +43 -0
  5. package/commands/weixin-ilink/endpoint/add/[id]/index.js +3 -0
  6. package/commands/weixin-ilink/endpoint/add/[id]/index.ts +3 -0
  7. package/commands/weixin-ilink/endpoint/definition.js +17 -0
  8. package/commands/weixin-ilink/endpoint/definition.ts +17 -0
  9. package/commands/weixin-ilink/endpoint/list/index.js +3 -0
  10. package/commands/weixin-ilink/endpoint/list/index.ts +3 -0
  11. package/commands/weixin-ilink/endpoint/remove/[id]/index.js +3 -0
  12. package/commands/weixin-ilink/endpoint/remove/[id]/index.ts +3 -0
  13. package/lib/aes-ecb.d.ts +0 -1
  14. package/lib/aes-ecb.js +0 -1
  15. package/lib/cdn-upload.d.ts +0 -1
  16. package/lib/cdn-upload.js +0 -1
  17. package/lib/cdn-url.d.ts +0 -1
  18. package/lib/cdn-url.js +0 -1
  19. package/lib/client.d.ts +35 -0
  20. package/lib/client.js +89 -0
  21. package/lib/context-store.d.ts +15 -2
  22. package/lib/context-store.js +93 -2
  23. package/lib/credentials.d.ts +15 -8
  24. package/lib/credentials.js +59 -43
  25. package/lib/endpoint.d.ts +40 -35
  26. package/lib/endpoint.js +311 -262
  27. package/lib/ilink-api.d.ts +7 -6
  28. package/lib/ilink-api.js +26 -16
  29. package/lib/ilink-config-cache.d.ts +3 -8
  30. package/lib/ilink-config-cache.js +13 -15
  31. package/lib/ilink-logger.d.ts +0 -2
  32. package/lib/ilink-logger.js +11 -26
  33. package/lib/ilink-meta.d.ts +13 -8
  34. package/lib/ilink-meta.js +24 -21
  35. package/lib/ilink-random.d.ts +0 -1
  36. package/lib/ilink-random.js +0 -1
  37. package/lib/ilink-redact.d.ts +0 -1
  38. package/lib/ilink-redact.js +0 -1
  39. package/lib/ilink-session-guard.d.ts +13 -15
  40. package/lib/ilink-session-guard.js +29 -43
  41. package/lib/ilink-types.d.ts +0 -1
  42. package/lib/ilink-types.js +0 -1
  43. package/lib/index.d.ts +8 -20
  44. package/lib/index.js +7 -38
  45. package/lib/login.d.ts +5 -6
  46. package/lib/login.js +85 -77
  47. package/lib/media-download.d.ts +1 -2
  48. package/lib/media-download.js +1 -2
  49. package/lib/mime.d.ts +0 -1
  50. package/lib/mime.js +0 -1
  51. package/lib/outbound-media.d.ts +4 -5
  52. package/lib/outbound-media.js +85 -86
  53. package/lib/pic-decrypt.d.ts +0 -1
  54. package/lib/pic-decrypt.js +0 -1
  55. package/lib/protocol.d.ts +60 -0
  56. package/lib/protocol.js +127 -0
  57. package/lib/silk-transcode.d.ts +0 -1
  58. package/lib/silk-transcode.js +0 -1
  59. package/lib/types.d.ts +2 -26
  60. package/lib/types.js +0 -1
  61. package/lib/upload.d.ts +1 -2
  62. package/lib/upload.js +1 -2
  63. package/lib/weixin-ilink-runtime-state.d.ts +1 -0
  64. package/lib/weixin-ilink-runtime-state.js +6 -0
  65. package/lib/weixin-inbound.d.ts +4 -43
  66. package/lib/weixin-inbound.js +22 -162
  67. package/lib/weixin-send-media.d.ts +0 -1
  68. package/lib/weixin-send-media.js +5 -2
  69. package/lib/weixin-send.d.ts +1 -2
  70. package/lib/weixin-send.js +4 -3
  71. package/package.json +48 -39
  72. package/plugin.js +14 -0
  73. package/schema.json +116 -0
  74. package/src/client.ts +117 -0
  75. package/src/context-store.ts +101 -8
  76. package/src/credentials.ts +68 -45
  77. package/src/endpoint.ts +384 -315
  78. package/src/ilink-api.ts +31 -21
  79. package/src/ilink-config-cache.ts +17 -13
  80. package/src/ilink-logger.ts +11 -26
  81. package/src/ilink-meta.ts +31 -21
  82. package/src/ilink-session-guard.ts +29 -46
  83. package/src/index.ts +41 -56
  84. package/src/login.ts +112 -85
  85. package/src/media-download.ts +1 -2
  86. package/src/outbound-media.ts +97 -97
  87. package/src/protocol.ts +180 -0
  88. package/src/silk-wasm.d.ts +6 -2
  89. package/src/types.ts +5 -25
  90. package/src/upload.test.ts +11 -8
  91. package/src/upload.ts +2 -2
  92. package/src/weixin-ilink-runtime-state.ts +7 -0
  93. package/src/weixin-inbound.ts +25 -187
  94. package/src/weixin-send-media.ts +5 -1
  95. package/src/weixin-send.ts +6 -4
  96. package/client/LoginAssistPanel.tsx +0 -200
  97. package/client/WeixinIlinkManagement.tsx +0 -139
  98. package/client/index.tsx +0 -11
  99. package/client/tsconfig.json +0 -13
  100. package/client/utils/api.ts +0 -30
  101. package/dist/index.js +0 -29
  102. package/lib/adapter.d.ts +0 -12
  103. package/lib/adapter.d.ts.map +0 -1
  104. package/lib/adapter.js +0 -15
  105. package/lib/adapter.js.map +0 -1
  106. package/lib/aes-ecb.d.ts.map +0 -1
  107. package/lib/aes-ecb.js.map +0 -1
  108. package/lib/cdn-upload.d.ts.map +0 -1
  109. package/lib/cdn-upload.js.map +0 -1
  110. package/lib/cdn-upload.test.d.ts +0 -2
  111. package/lib/cdn-upload.test.d.ts.map +0 -1
  112. package/lib/cdn-upload.test.js +0 -150
  113. package/lib/cdn-upload.test.js.map +0 -1
  114. package/lib/cdn-url.d.ts.map +0 -1
  115. package/lib/cdn-url.js.map +0 -1
  116. package/lib/context-store.d.ts.map +0 -1
  117. package/lib/context-store.js.map +0 -1
  118. package/lib/credentials.d.ts.map +0 -1
  119. package/lib/credentials.js.map +0 -1
  120. package/lib/endpoint.d.ts.map +0 -1
  121. package/lib/endpoint.js.map +0 -1
  122. package/lib/ilink-api.d.ts.map +0 -1
  123. package/lib/ilink-api.js.map +0 -1
  124. package/lib/ilink-config-cache.d.ts.map +0 -1
  125. package/lib/ilink-config-cache.js.map +0 -1
  126. package/lib/ilink-logger.d.ts.map +0 -1
  127. package/lib/ilink-logger.js.map +0 -1
  128. package/lib/ilink-meta.d.ts.map +0 -1
  129. package/lib/ilink-meta.js.map +0 -1
  130. package/lib/ilink-random.d.ts.map +0 -1
  131. package/lib/ilink-random.js.map +0 -1
  132. package/lib/ilink-redact.d.ts.map +0 -1
  133. package/lib/ilink-redact.js.map +0 -1
  134. package/lib/ilink-session-guard.d.ts.map +0 -1
  135. package/lib/ilink-session-guard.js.map +0 -1
  136. package/lib/ilink-types.d.ts.map +0 -1
  137. package/lib/ilink-types.js.map +0 -1
  138. package/lib/index.d.ts.map +0 -1
  139. package/lib/index.js.map +0 -1
  140. package/lib/login-assist-routes.d.ts +0 -7
  141. package/lib/login-assist-routes.d.ts.map +0 -1
  142. package/lib/login-assist-routes.js +0 -29
  143. package/lib/login-assist-routes.js.map +0 -1
  144. package/lib/login.d.ts.map +0 -1
  145. package/lib/login.js.map +0 -1
  146. package/lib/media-download.d.ts.map +0 -1
  147. package/lib/media-download.js.map +0 -1
  148. package/lib/mime.d.ts.map +0 -1
  149. package/lib/mime.js.map +0 -1
  150. package/lib/mime.test.d.ts +0 -2
  151. package/lib/mime.test.d.ts.map +0 -1
  152. package/lib/mime.test.js +0 -62
  153. package/lib/mime.test.js.map +0 -1
  154. package/lib/outbound-media.d.ts.map +0 -1
  155. package/lib/outbound-media.js.map +0 -1
  156. package/lib/pic-decrypt.d.ts.map +0 -1
  157. package/lib/pic-decrypt.js.map +0 -1
  158. package/lib/routes.d.ts +0 -8
  159. package/lib/routes.d.ts.map +0 -1
  160. package/lib/routes.js +0 -29
  161. package/lib/routes.js.map +0 -1
  162. package/lib/silk-transcode.d.ts.map +0 -1
  163. package/lib/silk-transcode.js.map +0 -1
  164. package/lib/silk-transcode.test.d.ts +0 -2
  165. package/lib/silk-transcode.test.d.ts.map +0 -1
  166. package/lib/silk-transcode.test.js +0 -53
  167. package/lib/silk-transcode.test.js.map +0 -1
  168. package/lib/types.d.ts.map +0 -1
  169. package/lib/types.js.map +0 -1
  170. package/lib/typing-indicator.d.ts +0 -32
  171. package/lib/typing-indicator.d.ts.map +0 -1
  172. package/lib/typing-indicator.js +0 -95
  173. package/lib/typing-indicator.js.map +0 -1
  174. package/lib/upload.d.ts.map +0 -1
  175. package/lib/upload.js.map +0 -1
  176. package/lib/upload.test.d.ts +0 -2
  177. package/lib/upload.test.d.ts.map +0 -1
  178. package/lib/upload.test.js +0 -250
  179. package/lib/upload.test.js.map +0 -1
  180. package/lib/weixin-inbound.d.ts.map +0 -1
  181. package/lib/weixin-inbound.js.map +0 -1
  182. package/lib/weixin-send-media.d.ts.map +0 -1
  183. package/lib/weixin-send-media.js.map +0 -1
  184. package/lib/weixin-send.d.ts.map +0 -1
  185. package/lib/weixin-send.js.map +0 -1
  186. package/src/adapter.ts +0 -18
  187. package/src/login-assist-routes.ts +0 -36
  188. package/src/routes.ts +0 -39
  189. package/src/typing-indicator.ts +0 -139
package/lib/endpoint.js CHANGED
@@ -1,282 +1,263 @@
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, } from "zhin.js";
8
- import { getUpdates, notifyStart, notifyStop, sendTyping } from "./ilink-api.js";
9
- import { configureIlinkMeta, DEFAULT_API_BASE_URL, DEFAULT_CDN_BASE_URL, } from "./ilink-meta.js";
10
- import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from "./credentials.js";
11
- import { resolveCredentials } from "./login.js";
12
- import { bodyFromItemList, getContextToken, isMediaItem, 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, segmentMediaRef } from "./outbound-media.js";
19
- import { MessageItemType } from "./ilink-types.js";
20
- import { getExtensionFromMime, sniffMimeFromBuffer } from "./mime.js";
21
- import { WeixinIlinkTypingIndicatorManager, } from "./typing-indicator.js";
22
- const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { Endpoint, } from 'zhin.js/adapter';
8
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
+ import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
10
+ import { IlinkClientMetadata } from './ilink-meta.js';
11
+ import { WeixinIlinkStateStore, } from './credentials.js';
12
+ import { resolveCredentials } from './login.js';
13
+ import { WeixinContextTokenStore, } from './context-store.js';
14
+ import { WeixinConfigManager } from './ilink-config-cache.js';
15
+ import { IlinkSessionGuard, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
16
+ import { downloadMediaFromItem } from './media-download.js';
17
+ import { sendMessageWeixin } from './weixin-send.js';
18
+ import { materializeOutboundMedia } from './outbound-media.js';
19
+ import { MessageItemType } from './ilink-types.js';
20
+ import { getExtensionFromMime, sniffMimeFromBuffer } from './mime.js';
21
+ import { formatInboundContent, weixinInboundSegments, weixinReplyTo, formatOutboundSegments, inboundMessageId, segmentLocalPath, sleep, weixinIlinkInboundConversation, } from './protocol.js';
22
+ import { WeixinIlinkClient } from './client.js';
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 });
27
+ /** 入站媒体保留 24h,每小时清扫一次。 */
28
+ const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
29
+ const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
30
+ export class WeixinIlinkEndpoint extends Endpoint {
31
+ client;
32
+ #logger;
33
+ #options;
34
+ #resolveCredentials;
35
+ #notifyStart;
36
+ #notifyStop;
37
+ #getUpdates;
38
+ #sendText;
39
+ #state;
40
+ #contextTokens;
41
+ #sessionGuard;
42
+ #metadata;
43
+ #creds = null;
44
+ #pollAbort;
45
+ #pollPromise;
46
+ #loginAbort;
47
+ #configManager;
48
+ #mediaSweepTimer;
49
+ #open = false;
50
+ #started = false;
51
+ management = createWeixinIlinkEndpointManagement(() => this.client);
52
+ control = Object.freeze({
53
+ typing: async (conversation, active) => {
54
+ await this.client.sendTyping(conversation.id, active === false ? 2 : 1);
55
+ },
38
56
  });
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
- export class WeixinIlinkEndpoint {
48
- adapter;
49
- $config;
50
- $connected = false;
51
- $typingIndicator;
52
- creds = null;
53
- pollAbort;
54
- pollPromise;
55
- configManager;
56
- get $id() {
57
- return this.$config.name;
57
+ constructor(options) {
58
+ super();
59
+ this.#logger = getAdapterLogger('weixin-ilink', options.config.id);
60
+ this.#options = options;
61
+ this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
62
+ this.#notifyStart = options.notifyStart ?? notifyStart;
63
+ this.#notifyStop = options.notifyStop ?? notifyStop;
64
+ this.#getUpdates = options.getUpdates ?? getUpdates;
65
+ this.#sendText = options.sendText ?? sendMessageWeixin;
66
+ this.#state = options.state
67
+ ?? new WeixinIlinkStateStore(options.config.id, options.config.dataDir);
68
+ this.#contextTokens = options.contextTokens ?? new WeixinContextTokenStore(this.#state);
69
+ this.#sessionGuard = options.sessionGuard ?? new IlinkSessionGuard(options.config.id);
70
+ this.#metadata = new IlinkClientMetadata({ botAgent: options.config.botAgent });
71
+ this.client = new WeixinIlinkClient(options.config, this.#metadata, () => this.#creds, () => this.#configManager, this.#sendText, this.#contextTokens);
58
72
  }
59
73
  get hasCredentials() {
60
- return Boolean(this.creds?.botToken);
74
+ return this.client.authenticated;
61
75
  }
62
- get pluginLogger() {
63
- return this.adapter.plugin.logger;
64
- }
65
- constructor(adapter, $config) {
66
- this.adapter = adapter;
67
- this.$config = $config;
68
- }
69
- apiBaseUrl() {
70
- return this.$config.baseUrl ?? this.creds?.baseUrl ?? DEFAULT_API_BASE_URL;
76
+ async start() {
77
+ if (this.#started)
78
+ return;
79
+ this.#started = true;
80
+ try {
81
+ // QR 登录最长 8 分钟:stop() 必须能打断
82
+ this.#loginAbort = new AbortController();
83
+ this.#creds = await this.#resolveCredentials(this.#options.config, this.#metadata, this.#state, this.#loginAbort.signal);
84
+ this.#contextTokens.restore();
85
+ await this.#notifyStart({
86
+ baseUrl: this.client.apiBaseUrl,
87
+ metadata: this.#metadata,
88
+ token: this.#creds.botToken,
89
+ });
90
+ this.#configManager = new WeixinConfigManager({
91
+ baseUrl: this.client.apiBaseUrl,
92
+ metadata: this.#metadata,
93
+ token: this.#creds.botToken,
94
+ }, (msg) => this.#logger.debug(msg));
95
+ this.#pollAbort = new AbortController();
96
+ this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
97
+ this.#startMediaSweep();
98
+ this.#logger.info(formatCompact({
99
+ op: 'connect',
100
+ endpoint: this.#options.config.id,
101
+ }));
102
+ void this.#emitPlatformEvent('system.online', {
103
+ account: this.#options.config.id,
104
+ });
105
+ }
106
+ catch (error) {
107
+ await this.stop();
108
+ this.#logger.error('Failed to connect weixin-ilink bot:', error);
109
+ throw error;
110
+ }
71
111
  }
72
- cdnBaseUrl() {
73
- return this.$config.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL;
112
+ open() {
113
+ this.#open = true;
74
114
  }
75
- async $connect() {
76
- configureIlinkMeta({ botAgent: this.$config.botAgent });
77
- this.creds = await resolveCredentials(this.adapter.plugin, this.$config);
78
- restoreContextTokens(this.$id);
79
- await notifyStart({
80
- baseUrl: this.apiBaseUrl(),
81
- token: this.creds.botToken,
82
- });
83
- this.configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.creds.botToken }, (msg) => this.pluginLogger.debug(msg));
84
- if (this.$config.typingIndicator?.enabled !== false) {
85
- this.$typingIndicator = new WeixinIlinkTypingIndicatorManager(this, this.$config.typingIndicator);
86
- }
87
- this.pollAbort = new AbortController();
88
- this.pollPromise = this.pollLoop(this.pollAbort.signal);
89
- this.$connected = true;
90
- this.pluginLogger.info(`weixin-ilink endpoint ${this.$id} connected`);
115
+ close() {
116
+ this.#open = false;
91
117
  }
92
- async $disconnect() {
93
- this.pollAbort?.abort();
118
+ async stop() {
119
+ this.#open = false;
120
+ this.#loginAbort?.abort();
121
+ this.#pollAbort?.abort();
94
122
  try {
95
- await this.pollPromise;
123
+ await this.#pollPromise;
96
124
  }
97
125
  catch {
98
126
  /* poll loop exit */
99
127
  }
100
- if (this.creds?.botToken) {
128
+ this.#stopMediaSweep();
129
+ // 防抖中的 context token 落盘,避免 stop 丢尾部写入
130
+ this.#contextTokens.flush();
131
+ if (this.#creds?.botToken) {
101
132
  try {
102
- await notifyStop({ baseUrl: this.apiBaseUrl(), token: this.creds.botToken });
133
+ await this.#notifyStop({
134
+ baseUrl: this.client.apiBaseUrl,
135
+ metadata: this.#metadata,
136
+ token: this.#creds.botToken,
137
+ });
103
138
  }
104
139
  catch (err) {
105
- this.pluginLogger.warn(`notifyStop failed: ${String(err)}`);
140
+ this.#logger.warn(formatCompact({
141
+ op: 'notify_stop',
142
+ ok: false,
143
+ error: err instanceof Error ? err.message : String(err),
144
+ }));
106
145
  }
107
146
  }
108
- this.$connected = false;
109
- this.pluginLogger.info(`weixin-ilink endpoint ${this.$id} disconnected`);
110
- }
111
- async sendTypingToUser(userId, status) {
112
- if (!this.creds?.botToken || !this.configManager)
113
- return;
114
- const contextToken = getContextToken(this.$id, userId);
115
- const cfg = await this.configManager.getForUser(userId, contextToken);
116
- if (!cfg.typingTicket) {
117
- this.pluginLogger.debug(`sendTyping: no typing_ticket for user=${userId}`);
118
- return;
119
- }
120
- await sendTyping({
121
- baseUrl: this.apiBaseUrl(),
122
- token: this.creds.botToken,
123
- body: {
124
- ilink_user_id: userId,
125
- typing_ticket: cfg.typingTicket,
126
- status,
127
- },
128
- });
129
- }
130
- async $recallMessage(_id) {
131
- this.pluginLogger.debug("weixin-ilink does not support message recall");
132
- }
133
- $formatMessage(msg) {
134
- const userId = msg.from_user_id ?? "";
135
- const content = this.buildInboundContent(msg);
136
- const quoteId = Message.quoteIdFromContent(content);
137
- Message.alignReplySegments(content, quoteId);
138
- const msgId = String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
139
- return Message.from(msg, {
140
- $id: msgId,
141
- $adapter: "weixin-ilink",
142
- $endpoint: this.$config.name,
143
- $sender: {
144
- id: userId,
145
- name: userId,
146
- },
147
- $channel: {
148
- id: userId,
149
- type: "private",
150
- },
151
- $content: content,
152
- $quote_id: quoteId,
153
- $raw: bodyFromItemList(msg.item_list),
154
- $timestamp: msg.create_time_ms ?? Date.now(),
155
- $recall: async () => this.$recallMessage(msgId),
156
- $reply: async (replyContent, _quote) => {
157
- if (!Array.isArray(replyContent))
158
- replyContent = [replyContent];
159
- return this.adapter.sendMessage({
160
- context: "weixin-ilink",
161
- endpoint: this.$config.name,
162
- id: userId,
163
- type: "private",
164
- content: replyContent,
165
- });
166
- },
147
+ this.#started = false;
148
+ void this.#emitPlatformEvent('system.offline', {
149
+ account: this.#options.config.id,
167
150
  });
151
+ this.#logger.debug(formatCompact({
152
+ op: 'disconnect',
153
+ }));
168
154
  }
169
- buildInboundContent(msg) {
170
- const segments = [];
171
- const text = bodyFromItemList(msg.item_list);
172
- if (text)
173
- segments.push(segment.text(text));
174
- const media = msg._media;
175
- if (media?.decryptedPicPath) {
176
- segments.push(segment("image", { file: media.decryptedPicPath }));
155
+ async send({ conversation, payload }) {
156
+ if (!this.#creds?.botToken) {
157
+ throw new Error('weixin-ilink bot not authenticated');
177
158
  }
178
- else if (media?.decryptedVideoPath) {
179
- segments.push(segment("video", { file: media.decryptedVideoPath }));
180
- }
181
- else if (media?.decryptedFilePath) {
182
- segments.push(segment("file", {
183
- file: media.decryptedFilePath,
184
- name: path.basename(media.decryptedFilePath),
185
- }));
186
- }
187
- else if (media?.decryptedVoicePath) {
188
- segments.push(segment("record", { file: media.decryptedVoicePath }));
189
- }
190
- else if (!text && msg.item_list?.some((i) => isMediaItem(i))) {
191
- segments.push(segment.text("[媒体消息]"));
192
- }
193
- return segments.length ? segments : [segment.text("")];
194
- }
195
- async $sendMessage(options) {
196
- if (!this.creds?.botToken) {
197
- throw new Error("weixin-ilink bot not authenticated");
198
- }
199
- const peerId = options.id;
200
- const contextToken = getContextToken(this.$id, peerId);
159
+ // 个人微信仅 private 会话,对端 user_id 即 conversation.id
160
+ const target = conversation.id;
161
+ const contextToken = this.client.contextToken(target);
201
162
  if (!contextToken) {
202
- this.pluginLogger.warn(`missing context_token for peer ${peerId}, refusing send`);
203
- throw new Error(`missing context_token for peer ${peerId}`);
163
+ this.#logger.warn(formatCompact({
164
+ op: 'send',
165
+ ok: false,
166
+ reason: 'missing_context_token',
167
+ target,
168
+ }));
169
+ throw new Error(`missing context_token for peer ${target}`);
204
170
  }
205
- const apiOpts = {
206
- baseUrl: this.apiBaseUrl(),
207
- token: this.creds.botToken,
208
- contextToken,
209
- };
210
- const outboundDir = path.join(resolveStateDir(), "media", "outbound");
211
- const materialized = await materializeOutboundMedia(options.content, outboundDir);
171
+ const outboundDir = this.#state.mediaDirectory('outbound');
172
+ const wire = formatOutboundSegments(payload);
173
+ const materialized = await materializeOutboundMedia(wire, outboundDir);
212
174
  const segments = Array.isArray(materialized) ? materialized : [materialized];
213
- let lastId = "";
214
- let pendingText = "";
175
+ let lastId = '';
176
+ let pendingText = '';
215
177
  for (const raw of segments) {
216
- if (typeof raw === "string") {
178
+ if (typeof raw === 'string') {
217
179
  pendingText += raw;
218
180
  continue;
219
181
  }
220
- if (typeof raw !== "object" || raw === null || !("type" in raw)) {
182
+ if (typeof raw !== 'object' || raw === null || !('type' in raw))
221
183
  continue;
222
- }
223
184
  const seg = raw;
224
- if (seg.type === "text" || seg.type === "markdown") {
225
- const t = seg.data.text ?? "";
226
- pendingText += t;
185
+ if (seg.type === 'text' || seg.type === 'markdown') {
186
+ pendingText += String(seg.data?.text ?? '');
227
187
  continue;
228
188
  }
229
189
  const filePath = segmentLocalPath(seg);
230
190
  if (filePath && fs.existsSync(filePath)) {
231
- // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体(不带 caption)
191
+ // 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体
232
192
  if (pendingText.trim()) {
233
- const textResult = await sendMessageWeixin({
234
- to: peerId,
235
- text: pendingText,
236
- opts: apiOpts,
237
- });
238
- pendingText = "";
193
+ const textResult = await this.client.sendText(target, pendingText);
194
+ pendingText = '';
239
195
  lastId = textResult.messageId;
240
196
  }
241
- const result = await sendWeixinMediaFile({
242
- filePath,
243
- to: peerId,
244
- text: "",
245
- opts: apiOpts,
246
- cdnBaseUrl: this.cdnBaseUrl(),
247
- });
197
+ const result = await this.client.sendMedia(target, filePath);
248
198
  lastId = result.messageId;
249
199
  continue;
250
200
  }
251
- if (seg.type === "image" || seg.type === "video" || seg.type === "file" || seg.type === "record") {
252
- this.pluginLogger.warn(`skip outbound ${seg.type}: no local file path`);
201
+ if (seg.type === 'image' || seg.type === 'video' || seg.type === 'file' || seg.type === 'record') {
202
+ this.#logger.warn(formatCompact({
203
+ op: 'send',
204
+ skip: 'no_local_file',
205
+ type: seg.type,
206
+ }));
253
207
  }
254
208
  }
255
209
  if (pendingText.trim()) {
256
- const result = await sendMessageWeixin({
257
- to: peerId,
258
- text: pendingText,
259
- opts: apiOpts,
260
- });
210
+ const result = await this.client.sendText(target, pendingText);
261
211
  lastId = result.messageId;
262
212
  }
263
213
  return lastId || `weixin-ilink-${Date.now()}`;
264
214
  }
265
- async pollLoop(abortSignal) {
266
- if (!this.creds?.botToken)
215
+ /** Test / internal: admit a parsed message when the endpoint is open. */
216
+ admit(msg) {
217
+ if (!this.#open)
218
+ return;
219
+ const userId = msg.from_user_id ?? '';
220
+ if (msg.context_token && userId) {
221
+ this.#contextTokens.set(userId, msg.context_token);
222
+ }
223
+ const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
224
+ const replyTo = weixinReplyTo(msg);
225
+ void this.emit('message.receive', {
226
+ conversation,
227
+ message: { conversation, id: inboundMessageId(msg) },
228
+ content: formatInboundContent(msg),
229
+ segments: weixinInboundSegments(msg),
230
+ ...(replyTo ? { replyTo } : {}),
231
+ sender: { id: userId },
232
+ endpointId: this.#options.config.id,
233
+ metadata: Object.freeze({
234
+ messageType: msg.message_type,
235
+ createTimeMs: msg.create_time_ms,
236
+ }),
237
+ }).catch((err) => {
238
+ this.#logger.warn(formatCompact({
239
+ op: 'weixin_ilink_gateway_receive_failed',
240
+ target: userId,
241
+ error: err instanceof Error ? err.message : String(err),
242
+ }));
243
+ });
244
+ }
245
+ async #pollLoop(abortSignal) {
246
+ if (!this.#creds?.botToken)
267
247
  return;
268
- let getUpdatesBuf = loadSyncBuf(this.$id);
269
- let nextTimeoutMs = this.$config.longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
248
+ let getUpdatesBuf = this.#state.loadSyncBuf();
249
+ let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
270
250
  let consecutiveFailures = 0;
271
251
  while (!abortSignal.aborted) {
272
- if (isSessionPaused(this.$id)) {
273
- await sleep(getRemainingPauseMs(this.$id), abortSignal);
252
+ if (this.#sessionGuard.paused) {
253
+ await sleep(this.#sessionGuard.remainingMs, abortSignal);
274
254
  continue;
275
255
  }
276
256
  try {
277
- const resp = await getUpdates({
278
- baseUrl: this.apiBaseUrl(),
279
- token: this.creds.botToken,
257
+ const resp = await this.#getUpdates({
258
+ baseUrl: this.client.apiBaseUrl,
259
+ metadata: this.#metadata,
260
+ token: this.#creds.botToken,
280
261
  get_updates_buf: getUpdatesBuf,
281
262
  timeoutMs: nextTimeoutMs,
282
263
  abortSignal,
@@ -284,19 +265,19 @@ export class WeixinIlinkEndpoint {
284
265
  if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
285
266
  nextTimeoutMs = resp.longpolling_timeout_ms;
286
267
  }
287
- const isApiError = (resp.ret !== undefined && resp.ret !== 0) ||
288
- (resp.errcode !== undefined && resp.errcode !== 0);
268
+ const isApiError = (resp.ret !== undefined && resp.ret !== 0)
269
+ || (resp.errcode !== undefined && resp.errcode !== 0);
289
270
  if (isApiError) {
290
271
  const sessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
291
272
  if (sessionExpired) {
292
- pauseSession(this.$id);
273
+ this.#sessionGuard.pause();
293
274
  consecutiveFailures = 0;
294
- await sleep(getRemainingPauseMs(this.$id), abortSignal);
275
+ await sleep(this.#sessionGuard.remainingMs, abortSignal);
295
276
  continue;
296
277
  }
297
278
  consecutiveFailures += 1;
279
+ // 退避后不清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS。
298
280
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
299
- consecutiveFailures = 0;
300
281
  await sleep(BACKOFF_DELAY_MS, abortSignal);
301
282
  }
302
283
  else {
@@ -305,21 +286,26 @@ export class WeixinIlinkEndpoint {
305
286
  continue;
306
287
  }
307
288
  consecutiveFailures = 0;
308
- if (resp.get_updates_buf) {
309
- getUpdatesBuf = resp.get_updates_buf;
310
- saveSyncBuf(this.$id, getUpdatesBuf);
311
- }
289
+ // 先分发再推进 buf:分发途中崩溃时 buf 未推进,下一轮重拉不丢消息。
290
+ const nextBuf = resp.get_updates_buf;
312
291
  for (const inbound of resp.msgs ?? []) {
313
- await this.handleInboundMessage(inbound);
292
+ await this.#handleInboundMessage(inbound);
293
+ }
294
+ if (nextBuf) {
295
+ getUpdatesBuf = nextBuf;
296
+ this.#state.saveSyncBuf(getUpdatesBuf);
314
297
  }
315
298
  }
316
299
  catch (err) {
317
300
  if (abortSignal.aborted)
318
301
  return;
319
302
  consecutiveFailures += 1;
320
- this.pluginLogger.error(`weixin-ilink poll error: ${String(err)}`);
303
+ this.#logger.error(formatCompact({
304
+ op: 'poll',
305
+ ok: false,
306
+ error: err instanceof Error ? err.message : String(err),
307
+ }));
321
308
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
322
- consecutiveFailures = 0;
323
309
  await sleep(BACKOFF_DELAY_MS, abortSignal);
324
310
  }
325
311
  else {
@@ -328,56 +314,119 @@ export class WeixinIlinkEndpoint {
328
314
  }
329
315
  }
330
316
  }
331
- async handleInboundMessage(full) {
332
- const fromUserId = full.from_user_id ?? "";
333
- if (full.context_token && fromUserId) {
334
- setContextToken(this.$id, fromUserId, full.context_token);
335
- }
336
- const mediaOpts = await this.downloadInboundMedia(full);
337
- // 入站保持单条 message.receive(文字+媒体同条),供 AI context / im_transcripts / agent_messages 组装。
338
- // 出站才按微信限制拆分图文(见 sendMessage)。
339
- const message = this.$formatMessage({ ...full, _media: mediaOpts });
340
- this.adapter.emit("message.receive", message);
341
- this.pluginLogger.debug(`${this.$id} recv private(${message.$channel.id}): ${segment.raw(message.$content)}`);
317
+ async #handleInboundMessage(full) {
318
+ void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
319
+ const fromUserId = full.from_user_id ?? '';
320
+ const mediaOpts = await this.#downloadInboundMedia(full);
321
+ this.admit({ ...full, _media: mediaOpts });
322
+ this.#logger.debug(formatCompact({
323
+ op: 'recv',
324
+ endpoint: this.#options.config.id,
325
+ target: fromUserId,
326
+ preview: formatInboundContent({ ...full, _media: mediaOpts }).slice(0, 80),
327
+ }));
342
328
  }
343
- async downloadInboundMedia(full) {
329
+ async #emitPlatformEvent(name, event) {
330
+ await this.emitPlatform(name, event).catch((error) => {
331
+ this.#logger.warn(formatCompact({
332
+ op: 'weixin_ilink_platform_event_failed',
333
+ event: name,
334
+ error: error instanceof Error ? error.message : String(error),
335
+ }));
336
+ });
337
+ }
338
+ async #downloadInboundMedia(full) {
344
339
  const hasDownloadableMedia = (m) => Boolean(m?.encrypt_query_param || m?.full_url);
345
- const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media)) ??
346
- full.item_list?.find((i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media)) ??
347
- full.item_list?.find((i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media)) ??
348
- full.item_list?.find((i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media));
340
+ const mediaItem = full.item_list?.find((i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media))
341
+ ?? full.item_list?.find((i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media))
342
+ ?? full.item_list?.find((i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media))
343
+ ?? full.item_list?.find((i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media));
349
344
  if (!mediaItem)
350
345
  return {};
351
346
  return downloadMediaFromItem(mediaItem, {
352
- cdnBaseUrl: this.cdnBaseUrl(),
353
- saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
354
- log: (msg) => this.pluginLogger.debug(msg),
355
- errLog: (msg) => this.pluginLogger.warn(msg),
356
- label: `inbound:${fromUserIdLabel(full)}`,
347
+ cdnBaseUrl: this.client.cdnBaseUrl,
348
+ saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) => this.#saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
349
+ log: (msg) => this.#logger.debug(msg),
350
+ errLog: (msg) => this.#logger.warn(msg),
351
+ label: `inbound:${full.from_user_id ?? 'unknown'}`,
357
352
  });
358
353
  }
359
- async saveInboundMedia(buffer, contentType, subdir = "inbound", maxBytes = WEIXIN_MEDIA_MAX_BYTES, originalFilename) {
354
+ async #saveInboundMedia(buffer, contentType, subdir = 'inbound', maxBytes = WEIXIN_MEDIA_MAX_BYTES, originalFilename) {
360
355
  if (buffer.length > maxBytes) {
361
356
  throw new Error(`media exceeds max size ${maxBytes}`);
362
357
  }
363
- const dir = path.join(resolveStateDir(), "media", subdir);
358
+ const dir = this.#state.mediaDirectory(subdir);
364
359
  fs.mkdirSync(dir, { recursive: true });
365
360
  const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
366
361
  const ext = originalFilename
367
362
  ? path.extname(originalFilename)
368
363
  : resolvedMime
369
364
  ? getExtensionFromMime(resolvedMime)
370
- : ".bin";
365
+ : '.bin';
371
366
  const base = originalFilename
372
367
  ? path.basename(originalFilename, ext)
373
- : "media";
374
- const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
368
+ : 'media';
369
+ const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
375
370
  const filePath = path.join(dir, fileName);
376
371
  fs.writeFileSync(filePath, buffer);
377
372
  return { path: filePath };
378
373
  }
374
+ /** 入站媒体 TTL 清扫:start 时立即扫一次,之后每小时扫一次。 */
375
+ #startMediaSweep() {
376
+ this.#stopMediaSweep();
377
+ const sweep = () => this.#sweepInboundMedia();
378
+ sweep();
379
+ this.#mediaSweepTimer = setInterval(sweep, MEDIA_SWEEP_INTERVAL_MS);
380
+ this.#mediaSweepTimer.unref?.();
381
+ }
382
+ #stopMediaSweep() {
383
+ if (this.#mediaSweepTimer) {
384
+ clearInterval(this.#mediaSweepTimer);
385
+ this.#mediaSweepTimer = undefined;
386
+ }
387
+ }
388
+ #sweepInboundMedia() {
389
+ const dir = this.#state.mediaDirectory('inbound');
390
+ let entries;
391
+ try {
392
+ entries = fs.readdirSync(dir, { withFileTypes: true });
393
+ }
394
+ catch {
395
+ return;
396
+ }
397
+ const now = Date.now();
398
+ for (const entry of entries) {
399
+ if (!entry.isFile())
400
+ continue;
401
+ const filePath = path.join(dir, entry.name);
402
+ try {
403
+ const stat = fs.statSync(filePath);
404
+ if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS)
405
+ continue;
406
+ fs.unlinkSync(filePath);
407
+ this.#logger.debug(formatCompact({
408
+ op: 'media_sweep',
409
+ endpoint: this.#options.config.id,
410
+ file: entry.name,
411
+ }));
412
+ }
413
+ catch {
414
+ /* 单个文件失败不阻塞整体清扫 */
415
+ }
416
+ }
417
+ }
379
418
  }
380
- function fromUserIdLabel(msg) {
381
- return msg.from_user_id ?? "unknown";
419
+ function createWeixinIlinkEndpointManagement(requireClient) {
420
+ return Object.freeze({
421
+ // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
422
+ // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
423
+ // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
424
+ async listFriends() {
425
+ return requireClient().reachableUserIds().map((userId) => ({
426
+ user_id: userId,
427
+ nickname: userId,
428
+ remark: 'ilink: 从会话 context_token 推导,非通讯录',
429
+ }));
430
+ },
431
+ });
382
432
  }
383
- //# sourceMappingURL=endpoint.js.map