@clowder-ai/connector-weixin 0.0.0-stage → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +55 -0
- package/README.md +27 -2
- package/assets/icon.svg +3 -0
- package/dist/WeixinAdapter.d.ts +255 -0
- package/dist/WeixinAdapter.d.ts.map +1 -0
- package/dist/WeixinAdapter.js +1130 -0
- package/dist/WeixinAdapter.js.map +1 -0
- package/dist/command-ingress.d.ts +10 -0
- package/dist/command-ingress.d.ts.map +1 -0
- package/dist/command-ingress.js +13 -0
- package/dist/command-ingress.js.map +1 -0
- package/dist/inbound-download.d.ts +13 -0
- package/dist/inbound-download.d.ts.map +1 -0
- package/dist/inbound-download.js +70 -0
- package/dist/inbound-download.js.map +1 -0
- package/dist/inbound-media-source.d.ts +25 -0
- package/dist/inbound-media-source.d.ts.map +1 -0
- package/dist/inbound-media-source.js +111 -0
- package/dist/inbound-media-source.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/materialize-media.d.ts +7 -0
- package/dist/materialize-media.d.ts.map +1 -0
- package/dist/materialize-media.js +35 -0
- package/dist/materialize-media.js.map +1 -0
- package/dist/media-notice.d.ts +6 -0
- package/dist/media-notice.d.ts.map +1 -0
- package/dist/media-notice.js +50 -0
- package/dist/media-notice.js.map +1 -0
- package/dist/plugin-entrypoint.d.ts +8 -0
- package/dist/plugin-entrypoint.d.ts.map +1 -0
- package/dist/plugin-entrypoint.js +295 -0
- package/dist/plugin-entrypoint.js.map +1 -0
- package/dist/reply-sender-map.d.ts +10 -0
- package/dist/reply-sender-map.d.ts.map +1 -0
- package/dist/reply-sender-map.js +123 -0
- package/dist/reply-sender-map.js.map +1 -0
- package/dist/rich-block-plaintext.d.ts +5 -0
- package/dist/rich-block-plaintext.d.ts.map +1 -0
- package/dist/rich-block-plaintext.js +45 -0
- package/dist/rich-block-plaintext.js.map +1 -0
- package/dist/runtime.d.ts +51 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +142 -0
- package/dist/runtime.js.map +1 -0
- package/dist/settings.d.ts +19 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +39 -0
- package/dist/settings.js.map +1 -0
- package/dist/thread-mappings.d.ts +46 -0
- package/dist/thread-mappings.d.ts.map +1 -0
- package/dist/thread-mappings.js +62 -0
- package/dist/thread-mappings.js.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/weixin-cdn.d.ts +43 -0
- package/dist/weixin-cdn.d.ts.map +1 -0
- package/dist/weixin-cdn.js +226 -0
- package/dist/weixin-cdn.js.map +1 -0
- package/npm-shrinkwrap.json +1013 -0
- package/package.json +58 -4
- package/plugin.yaml +107 -0
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat Personal (iLink Bot) Adapter
|
|
3
|
+
* Inbound: Long-poll via /ilink/bot/getupdates → parse text messages
|
|
4
|
+
* Outbound: Send reply via /ilink/bot/sendmessage (requires context_token)
|
|
5
|
+
*
|
|
6
|
+
* Uses Tencent's iLink Bot protocol for personal WeChat accounts.
|
|
7
|
+
* No SDK dependency — pure HTTP (fetch) implementation.
|
|
8
|
+
*
|
|
9
|
+
* MVP: DM-only, text-only, single-account.
|
|
10
|
+
*
|
|
11
|
+
* F137 WeChat Personal Gateway
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { materializeMedia } from './materialize-media.js';
|
|
15
|
+
// Plugin safety bound. The iLink send API does not publish a stable per-file
|
|
16
|
+
// limit; this prevents unbounded local materialization and must not be
|
|
17
|
+
// presented to users as a provider limit.
|
|
18
|
+
export const WEIXIN_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
|
19
|
+
const ILINK_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
|
20
|
+
const GETUPDATES_TIMEOUT_MS = 35_000;
|
|
21
|
+
const POLL_ERROR_BACKOFF_MS = 3_000;
|
|
22
|
+
const POLL_MAX_BACKOFF_MS = 60_000;
|
|
23
|
+
// BUG-5 (2026-03-25): iLink context_token supports multiple sendmessage calls.
|
|
24
|
+
// Previous BUG-3 "single-use token" conclusion was a misdiagnosis — see F137 spec.
|
|
25
|
+
/** Debounce window for aggregating multi-cat replies into one outbound message (ms) */
|
|
26
|
+
const WEIXIN_REPLY_DEBOUNCE_MS = 3_000;
|
|
27
|
+
/** Typing keepalive interval (ms) — openclaw v2 uses 5s */
|
|
28
|
+
const TYPING_KEEPALIVE_MS = 5_000;
|
|
29
|
+
/** errcode -14 means session expired — need re-login */
|
|
30
|
+
const ERRCODE_SESSION_EXPIRED = -14;
|
|
31
|
+
/** QR code status poll interval (ms) */
|
|
32
|
+
const QRCODE_POLL_INTERVAL_MS = 2_000;
|
|
33
|
+
/** iLink get_qrcode_status is a ~30 s long-poll; timeout must exceed that */
|
|
34
|
+
const QRCODE_STATUS_POLL_TIMEOUT_MS = 40_000;
|
|
35
|
+
/** QR code timeout (5 minutes) */
|
|
36
|
+
const QRCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
37
|
+
const ILINK_TRANSIENT_ERRCODE = -2;
|
|
38
|
+
const SENDMESSAGE_MAX_ATTEMPTS = 2;
|
|
39
|
+
const SENDMESSAGE_RETRY_DELAY_MS = 250;
|
|
40
|
+
const CONTENT_DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
41
|
+
const CONTENT_DEDUP_MAX_ENTRIES = 10_000;
|
|
42
|
+
/**
|
|
43
|
+
* Voice item payload mode for WeChat voice delivery.
|
|
44
|
+
* - `minimal`: send only `{ media }` — experimental native voice payload.
|
|
45
|
+
* - `playtime`: send `{ media, playtime }` — shows correct duration but won't play.
|
|
46
|
+
* - `playtime-sec`: send `{ media, playtime }` with playtime in SECONDS (not ms) — hypothesis test.
|
|
47
|
+
* - `playtime-encode`: send `{ media, encode_type: 6, playtime }` — confirmed broken (encode_type is poison).
|
|
48
|
+
* - `metadata`: send all SILK fields — confirmed broken (voice "completely gone").
|
|
49
|
+
*
|
|
50
|
+
* Runtime-configurable via WEIXIN_VOICE_ITEM_MODE so co-creator can A/B test without code changes.
|
|
51
|
+
* Default audio delivery stays on file_item attachment because native voice_item is unreliable.
|
|
52
|
+
*/
|
|
53
|
+
const VOICE_ITEM_MODES = ['minimal', 'playtime', 'playtime-sec', 'playtime-encode', 'metadata'];
|
|
54
|
+
const VOICE_ITEM_MODE_VALUES = new Set(VOICE_ITEM_MODES);
|
|
55
|
+
const UNSAFE_VOICE_MODES = new Set(['playtime-encode', 'metadata']);
|
|
56
|
+
function readWeixinVoiceItemMode(options) {
|
|
57
|
+
const mode = options.voiceItemMode?.trim().toLowerCase();
|
|
58
|
+
return mode && VOICE_ITEM_MODE_VALUES.has(mode) ? mode : undefined;
|
|
59
|
+
}
|
|
60
|
+
function isNativeVoiceItemRequested(options) {
|
|
61
|
+
return readWeixinVoiceItemMode(options) !== undefined;
|
|
62
|
+
}
|
|
63
|
+
function getWeixinVoiceItemMode(options, log) {
|
|
64
|
+
const mode = readWeixinVoiceItemMode(options);
|
|
65
|
+
if (mode) {
|
|
66
|
+
if (UNSAFE_VOICE_MODES.has(mode) && !options.enableUnsafeVoiceModes) {
|
|
67
|
+
log?.warn({ requestedMode: mode, fallbackMode: 'playtime' }, '[WeixinAdapter] unsafe voice mode disabled — falling back to playtime');
|
|
68
|
+
return 'playtime';
|
|
69
|
+
}
|
|
70
|
+
return mode;
|
|
71
|
+
}
|
|
72
|
+
return 'minimal';
|
|
73
|
+
}
|
|
74
|
+
function generateClientId() {
|
|
75
|
+
return `cat-cafe-weixin-${crypto.randomUUID()}`;
|
|
76
|
+
}
|
|
77
|
+
/** MessageItemType constants — mirrors openclaw-weixin. */
|
|
78
|
+
const MessageItemType = {
|
|
79
|
+
TEXT: 1,
|
|
80
|
+
IMAGE: 2,
|
|
81
|
+
VOICE: 3,
|
|
82
|
+
FILE: 4,
|
|
83
|
+
VIDEO: 5,
|
|
84
|
+
};
|
|
85
|
+
/** MessageState constants — mirrors openclaw-weixin. */
|
|
86
|
+
const MessageState = {
|
|
87
|
+
NEW: 0,
|
|
88
|
+
GENERATING: 1,
|
|
89
|
+
FINISH: 2,
|
|
90
|
+
};
|
|
91
|
+
// ── Adapter ──
|
|
92
|
+
export class WeixinAdapter {
|
|
93
|
+
connectorId = 'weixin';
|
|
94
|
+
log;
|
|
95
|
+
botToken;
|
|
96
|
+
polling = false;
|
|
97
|
+
pollAbortController = null;
|
|
98
|
+
consecutiveErrors = 0;
|
|
99
|
+
getUpdatesBuf = '';
|
|
100
|
+
contextTokens = new Map();
|
|
101
|
+
// BUG-5: lastConsumedToken removed — iLink context_token is reusable (verified 2026-03-25).
|
|
102
|
+
pendingReplies = new Map();
|
|
103
|
+
fetchFn = globalThis.fetch;
|
|
104
|
+
sessionExpiredCallback = null;
|
|
105
|
+
sessionStateStore;
|
|
106
|
+
runtimeOptions;
|
|
107
|
+
contentDedupFingerprints = new Map();
|
|
108
|
+
typingTickets = new Map();
|
|
109
|
+
typingTimers = new Map();
|
|
110
|
+
typingEpoch = new Map();
|
|
111
|
+
fallbackMessageSequence = 0;
|
|
112
|
+
constructor(botToken, log, sessionStateStore, runtimeOptions = {}) {
|
|
113
|
+
this.botToken = botToken;
|
|
114
|
+
this.log = log;
|
|
115
|
+
this.sessionStateStore = sessionStateStore;
|
|
116
|
+
this.runtimeOptions = runtimeOptions;
|
|
117
|
+
}
|
|
118
|
+
hasBotToken() {
|
|
119
|
+
return this.botToken !== '';
|
|
120
|
+
}
|
|
121
|
+
async downloadInboundMedia(locator) {
|
|
122
|
+
if (!this.hasBotToken())
|
|
123
|
+
throw new Error('Weixin connector is disconnected');
|
|
124
|
+
const { downloadMediaFromCdn } = await import('./weixin-cdn.js');
|
|
125
|
+
return downloadMediaFromCdn({
|
|
126
|
+
platformKey: locator.platformKey,
|
|
127
|
+
cdnBaseUrl: 'https://novac2c.cdn.weixin.qq.com/c2c',
|
|
128
|
+
log: this.log,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
setBotToken(token) {
|
|
132
|
+
const changed = this.botToken !== token;
|
|
133
|
+
this.botToken = token;
|
|
134
|
+
if (changed) {
|
|
135
|
+
if (this.polling) {
|
|
136
|
+
this.log.info('[WeixinAdapter] Bot token changed while polling — preserving live session state');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.clearSessionState();
|
|
140
|
+
void this.clearPersistedSessionState('[WeixinAdapter] Failed to clear persisted session state after token change');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* F137 Phase D: Full disconnect — stop polling, clear bot_token and all session state.
|
|
145
|
+
* After disconnect, the adapter returns to pre-login state (hasBotToken() === false).
|
|
146
|
+
*/
|
|
147
|
+
async disconnect() {
|
|
148
|
+
await this.stopPolling();
|
|
149
|
+
this.botToken = '';
|
|
150
|
+
this.clearSessionState();
|
|
151
|
+
await this.clearPersistedSessionState('[WeixinAdapter] Failed to clear persisted session state on disconnect');
|
|
152
|
+
this.typingTickets.clear();
|
|
153
|
+
this.log.info('[WeixinAdapter] Disconnected — bot_token and session state cleared');
|
|
154
|
+
}
|
|
155
|
+
setOnSessionExpired(cb) {
|
|
156
|
+
this.sessionExpiredCallback = cb;
|
|
157
|
+
}
|
|
158
|
+
async restoreSessionState() {
|
|
159
|
+
if (!this.sessionStateStore)
|
|
160
|
+
return;
|
|
161
|
+
const state = await this.sessionStateStore.load().catch((err) => {
|
|
162
|
+
this.log.warn({ err }, '[WeixinAdapter] Failed to restore session state');
|
|
163
|
+
return null;
|
|
164
|
+
});
|
|
165
|
+
if (!state)
|
|
166
|
+
return;
|
|
167
|
+
this.getUpdatesBuf = typeof state.getUpdatesBuf === 'string' ? state.getUpdatesBuf : '';
|
|
168
|
+
this.contextTokens.clear();
|
|
169
|
+
if (state.contextTokens) {
|
|
170
|
+
for (const [chatId, token] of Object.entries(state.contextTokens)) {
|
|
171
|
+
if (typeof token === 'string' && token)
|
|
172
|
+
this.contextTokens.set(chatId, token);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.log.info({ cursorRestored: Boolean(this.getUpdatesBuf), contextTokenCount: this.contextTokens.size }, '[WeixinAdapter] Session state restored');
|
|
176
|
+
}
|
|
177
|
+
// ── Auth headers ──
|
|
178
|
+
getHeaders() {
|
|
179
|
+
// X-WECHAT-UIN: random uint32 base64-encoded (protocol requirement)
|
|
180
|
+
const uin = Buffer.from(String(Math.floor(Math.random() * 0xffffffff))).toString('base64');
|
|
181
|
+
return {
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
AuthorizationType: 'ilink_bot_token',
|
|
184
|
+
Authorization: `Bearer ${this.botToken}`,
|
|
185
|
+
'X-WECHAT-UIN': uin,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// ── Inbound: Long-poll ──
|
|
189
|
+
/**
|
|
190
|
+
* Parse a raw iLink getupdates response into inbound messages.
|
|
191
|
+
* Returns parsed messages and updated cursor.
|
|
192
|
+
*/
|
|
193
|
+
parseUpdates(raw) {
|
|
194
|
+
const errorCode = raw.errcode ?? raw.ret;
|
|
195
|
+
if (errorCode === ERRCODE_SESSION_EXPIRED) {
|
|
196
|
+
return { messages: [], newCursor: this.getUpdatesBuf, sessionExpired: true };
|
|
197
|
+
}
|
|
198
|
+
if (errorCode && errorCode !== 0) {
|
|
199
|
+
this.log.warn({ ret: raw.ret, errcode: raw.errcode, errmsg: raw.errmsg }, '[WeixinAdapter] getupdates error');
|
|
200
|
+
return { messages: [], newCursor: this.getUpdatesBuf, sessionExpired: false };
|
|
201
|
+
}
|
|
202
|
+
const newCursor = raw.get_updates_buf ?? this.getUpdatesBuf;
|
|
203
|
+
const messages = [];
|
|
204
|
+
if (raw.msgs) {
|
|
205
|
+
for (const [index, msg] of raw.msgs.entries()) {
|
|
206
|
+
const parsed = this.parseMessage(msg, raw.get_updates_buf, index);
|
|
207
|
+
if (parsed)
|
|
208
|
+
messages.push(parsed);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { messages, newCursor, sessionExpired: false };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Parse a single iLink WeixinMessage into our standard format.
|
|
215
|
+
* Uses item_list[].type to determine message kind (TEXT=1, IMAGE=2, VOICE=3, FILE=4, VIDEO=5).
|
|
216
|
+
*/
|
|
217
|
+
buildMediaKey(media) {
|
|
218
|
+
if (!media?.aes_key)
|
|
219
|
+
return '';
|
|
220
|
+
if (media.full_url) {
|
|
221
|
+
return JSON.stringify({ fullUrl: media.full_url, aesKey: media.aes_key });
|
|
222
|
+
}
|
|
223
|
+
if (media.encrypt_query_param) {
|
|
224
|
+
return JSON.stringify({ encryptQueryParam: media.encrypt_query_param, aesKey: media.aes_key });
|
|
225
|
+
}
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
parseMessage(msg, responseCursor, messageIndex) {
|
|
229
|
+
const senderId = msg.from_user_id;
|
|
230
|
+
const contextToken = msg.context_token;
|
|
231
|
+
if (!senderId || !contextToken)
|
|
232
|
+
return null;
|
|
233
|
+
const firstItem = msg.item_list?.[0];
|
|
234
|
+
if (!firstItem) {
|
|
235
|
+
this.log.debug({ messageId: msg.message_id }, '[WeixinAdapter] Message with empty item_list, skipping');
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const itemType = firstItem.type ?? MessageItemType.TEXT;
|
|
239
|
+
const msgId = msg.message_id != null
|
|
240
|
+
? String(msg.message_id)
|
|
241
|
+
: this.buildFallbackMessageId(msg, senderId, contextToken, firstItem, responseCursor, messageIndex);
|
|
242
|
+
if (itemType === MessageItemType.TEXT) {
|
|
243
|
+
const text = firstItem.text_item?.text;
|
|
244
|
+
if (!text)
|
|
245
|
+
return null;
|
|
246
|
+
return {
|
|
247
|
+
chatId: senderId,
|
|
248
|
+
text,
|
|
249
|
+
messageId: msgId,
|
|
250
|
+
senderId,
|
|
251
|
+
contextToken,
|
|
252
|
+
createdAtMs: msg.create_time_ms,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (itemType === MessageItemType.IMAGE) {
|
|
256
|
+
const media = firstItem.image_item?.media;
|
|
257
|
+
const mediaKey = this.buildMediaKey(media);
|
|
258
|
+
return {
|
|
259
|
+
chatId: senderId,
|
|
260
|
+
text: '[图片]',
|
|
261
|
+
messageId: msgId,
|
|
262
|
+
senderId,
|
|
263
|
+
contextToken,
|
|
264
|
+
createdAtMs: msg.create_time_ms,
|
|
265
|
+
attachments: mediaKey ? [{ type: 'image', mediaUrl: mediaKey }] : undefined,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (itemType === MessageItemType.VOICE) {
|
|
269
|
+
const media = firstItem.voice_item?.media;
|
|
270
|
+
const mediaKey = this.buildMediaKey(media);
|
|
271
|
+
return {
|
|
272
|
+
chatId: senderId,
|
|
273
|
+
text: firstItem.voice_item?.text || '[语音]',
|
|
274
|
+
messageId: msgId,
|
|
275
|
+
senderId,
|
|
276
|
+
contextToken,
|
|
277
|
+
createdAtMs: msg.create_time_ms,
|
|
278
|
+
attachments: this.runtimeOptions.captureInboundVoiceMedia && mediaKey
|
|
279
|
+
? [{ type: 'file', mediaUrl: mediaKey, fileName: `weixin-voice-${msgId}.silk` }]
|
|
280
|
+
: undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (itemType === MessageItemType.FILE) {
|
|
284
|
+
const media = firstItem.file_item?.media;
|
|
285
|
+
const mediaKey = this.buildMediaKey(media);
|
|
286
|
+
return {
|
|
287
|
+
chatId: senderId,
|
|
288
|
+
text: `[文件] ${firstItem.file_item?.file_name ?? ''}`.trim(),
|
|
289
|
+
messageId: msgId,
|
|
290
|
+
senderId,
|
|
291
|
+
contextToken,
|
|
292
|
+
createdAtMs: msg.create_time_ms,
|
|
293
|
+
attachments: mediaKey
|
|
294
|
+
? [{ type: 'file', mediaUrl: mediaKey, fileName: firstItem.file_item?.file_name }]
|
|
295
|
+
: undefined,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
this.log.debug({ itemType, messageId: msg.message_id }, '[WeixinAdapter] Unsupported item type, skipping');
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
buildFallbackMessageId(msg, senderId, contextToken, firstItem, responseCursor, messageIndex) {
|
|
302
|
+
const itemType = firstItem.type ?? MessageItemType.TEXT;
|
|
303
|
+
const media = firstItem.image_item?.media ??
|
|
304
|
+
firstItem.voice_item?.media ??
|
|
305
|
+
firstItem.file_item?.media ??
|
|
306
|
+
firstItem.video_item?.media;
|
|
307
|
+
const text = firstItem.text_item?.text ?? firstItem.voice_item?.text ?? '';
|
|
308
|
+
const fileName = firstItem.file_item?.file_name ?? '';
|
|
309
|
+
const mediaKey = this.buildMediaKey(media);
|
|
310
|
+
let deliveryScope;
|
|
311
|
+
if (msg.create_time_ms != null) {
|
|
312
|
+
deliveryScope = `time:${msg.create_time_ms}`;
|
|
313
|
+
}
|
|
314
|
+
else if (responseCursor) {
|
|
315
|
+
// iLink redeliveries replay the same cursor; the index keeps same-content messages distinct within that batch.
|
|
316
|
+
deliveryScope = `cursor:${responseCursor}:index:${messageIndex}`;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const sequence = this.nextFallbackMessageSequence();
|
|
320
|
+
this.log.warn({ itemType, messageIndex, senderId }, '[WeixinAdapter] Building non-deterministic fallback message id without message_id, timestamp, or cursor');
|
|
321
|
+
// Last resort: avoids over-deduping legitimate same-content messages, but cannot guarantee redelivery dedup.
|
|
322
|
+
deliveryScope = `sequence:${sequence}`;
|
|
323
|
+
}
|
|
324
|
+
const fingerprint = crypto
|
|
325
|
+
.createHash('sha256')
|
|
326
|
+
.update(`${senderId}\0${contextToken}\0${deliveryScope}\0${itemType}\0${text}\0${mediaKey}\0${fileName}`)
|
|
327
|
+
.digest('hex')
|
|
328
|
+
.slice(0, 16);
|
|
329
|
+
return `weixin-${fingerprint}`;
|
|
330
|
+
}
|
|
331
|
+
nextFallbackMessageSequence() {
|
|
332
|
+
this.fallbackMessageSequence = (this.fallbackMessageSequence % Number.MAX_SAFE_INTEGER) + 1;
|
|
333
|
+
return this.fallbackMessageSequence;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Start long-polling loop for inbound messages.
|
|
337
|
+
* Similar pattern to TelegramAdapter.startPolling().
|
|
338
|
+
*/
|
|
339
|
+
startPolling(handler) {
|
|
340
|
+
if (this.polling)
|
|
341
|
+
return;
|
|
342
|
+
this.polling = true;
|
|
343
|
+
this.consecutiveErrors = 0;
|
|
344
|
+
const poll = async () => {
|
|
345
|
+
while (this.polling) {
|
|
346
|
+
try {
|
|
347
|
+
this.pollAbortController = new AbortController();
|
|
348
|
+
const body = {
|
|
349
|
+
get_updates_buf: this.getUpdatesBuf || '',
|
|
350
|
+
base_info: { channel_version: '1.0.0' },
|
|
351
|
+
};
|
|
352
|
+
const res = await this.fetchFn(`${ILINK_BASE_URL}/ilink/bot/getupdates`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: this.getHeaders(),
|
|
355
|
+
body: JSON.stringify(body),
|
|
356
|
+
signal: AbortSignal.any([
|
|
357
|
+
this.pollAbortController.signal,
|
|
358
|
+
AbortSignal.timeout(GETUPDATES_TIMEOUT_MS + 5_000),
|
|
359
|
+
]),
|
|
360
|
+
});
|
|
361
|
+
if (!res.ok) {
|
|
362
|
+
throw new Error(`getupdates HTTP ${res.status}: ${res.statusText}`);
|
|
363
|
+
}
|
|
364
|
+
const raw = (await res.json());
|
|
365
|
+
const { messages, newCursor, sessionExpired } = this.parseUpdates(raw);
|
|
366
|
+
if (messages.length > 0) {
|
|
367
|
+
this.log.info({ count: messages.length }, '[WeixinAdapter] Received messages');
|
|
368
|
+
}
|
|
369
|
+
if (sessionExpired) {
|
|
370
|
+
this.log.error('[WeixinAdapter] Session expired (errcode -14). Bot token invalid — need re-login.');
|
|
371
|
+
this.polling = false;
|
|
372
|
+
this.sessionExpiredCallback?.();
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
this.consecutiveErrors = 0;
|
|
376
|
+
let hasHandlerError = false;
|
|
377
|
+
for (const msg of messages) {
|
|
378
|
+
const tokenHash = msg.contextToken.slice(-8);
|
|
379
|
+
this.contextTokens.set(msg.chatId, msg.contextToken);
|
|
380
|
+
this.persistSessionState();
|
|
381
|
+
this.log.info({ chatId: msg.chatId, tokenHash }, '[WeixinAdapter] Inbound token cached');
|
|
382
|
+
const contentDedupFingerprint = this.buildContentDedupFingerprint(msg);
|
|
383
|
+
if (contentDedupFingerprint && this.hasRecentContentDedupFingerprint(contentDedupFingerprint)) {
|
|
384
|
+
this.log.info({ chatId: msg.chatId, messageId: msg.messageId }, '[WeixinAdapter] Duplicate content skipped');
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
// Start typing indicator (non-blocking, epoch-guarded against stale starts)
|
|
388
|
+
const epoch = (this.typingEpoch.get(msg.chatId) ?? 0) + 1;
|
|
389
|
+
this.typingEpoch.set(msg.chatId, epoch);
|
|
390
|
+
this.fetchTypingTicket(msg.chatId, msg.contextToken)
|
|
391
|
+
.then(() => {
|
|
392
|
+
if (this.typingEpoch.get(msg.chatId) === epoch) {
|
|
393
|
+
this.startTyping(msg.chatId);
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
.catch(() => { });
|
|
397
|
+
try {
|
|
398
|
+
await handler(msg);
|
|
399
|
+
if (contentDedupFingerprint)
|
|
400
|
+
this.rememberContentDedupFingerprint(contentDedupFingerprint);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
hasHandlerError = true;
|
|
404
|
+
this.log.error({ err, chatId: msg.chatId }, '[WeixinAdapter] Handler error');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!hasHandlerError && this.getUpdatesBuf !== newCursor) {
|
|
408
|
+
this.getUpdatesBuf = newCursor;
|
|
409
|
+
this.persistSessionState();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
if (!this.polling)
|
|
414
|
+
break;
|
|
415
|
+
this.consecutiveErrors++;
|
|
416
|
+
const backoff = Math.min(POLL_ERROR_BACKOFF_MS * 2 ** (this.consecutiveErrors - 1), POLL_MAX_BACKOFF_MS);
|
|
417
|
+
this.log.warn({ err, consecutiveErrors: this.consecutiveErrors, backoffMs: backoff }, '[WeixinAdapter] Poll error, backing off');
|
|
418
|
+
await this.sleep(backoff);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
// Fire and forget — poll loop runs until stopPolling()
|
|
423
|
+
poll().catch((err) => {
|
|
424
|
+
this.log.error({ err }, '[WeixinAdapter] Poll loop crashed');
|
|
425
|
+
});
|
|
426
|
+
this.log.info('[WeixinAdapter] Long polling started');
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Stop long-polling gracefully.
|
|
430
|
+
*/
|
|
431
|
+
async stopPolling() {
|
|
432
|
+
this.polling = false;
|
|
433
|
+
this.pollAbortController?.abort();
|
|
434
|
+
this.pollAbortController = null;
|
|
435
|
+
const stoppedError = new Error('Provider polling stopped');
|
|
436
|
+
for (const [, bucket] of this.pendingReplies) {
|
|
437
|
+
clearTimeout(bucket.timer);
|
|
438
|
+
for (const { reject } of bucket.resolvers)
|
|
439
|
+
reject(stoppedError);
|
|
440
|
+
}
|
|
441
|
+
this.pendingReplies.clear();
|
|
442
|
+
// Clean up all typing timers
|
|
443
|
+
for (const chatId of this.typingTimers.keys()) {
|
|
444
|
+
this.stopTyping(chatId);
|
|
445
|
+
}
|
|
446
|
+
this.log.info('[WeixinAdapter] Long polling stopped');
|
|
447
|
+
}
|
|
448
|
+
// ── Typing indicator (iLink protocol: getconfig → sendtyping keepalive) ──
|
|
449
|
+
async fetchTypingTicket(chatId, contextToken) {
|
|
450
|
+
try {
|
|
451
|
+
const res = await this.fetchFn(`${ILINK_BASE_URL}/ilink/bot/getconfig`, {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
headers: this.getHeaders(),
|
|
454
|
+
body: JSON.stringify({
|
|
455
|
+
ilink_user_id: chatId,
|
|
456
|
+
context_token: contextToken,
|
|
457
|
+
base_info: { channel_version: '1.0.0' },
|
|
458
|
+
}),
|
|
459
|
+
signal: AbortSignal.timeout(10_000),
|
|
460
|
+
});
|
|
461
|
+
if (!res.ok) {
|
|
462
|
+
this.log.warn({ status: res.status }, '[WeixinAdapter] getconfig HTTP error');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const data = (await res.json());
|
|
466
|
+
if (data.typing_ticket) {
|
|
467
|
+
this.typingTickets.set(chatId, data.typing_ticket);
|
|
468
|
+
this.log.info({ chatId }, '[WeixinAdapter] typing_ticket acquired');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
this.log.warn({ err }, '[WeixinAdapter] getconfig failed (non-fatal)');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
startTyping(chatId) {
|
|
476
|
+
const ticket = this.typingTickets.get(chatId);
|
|
477
|
+
if (!ticket)
|
|
478
|
+
return;
|
|
479
|
+
// Clear any existing keepalive timer for this chatId (no CANCEL — just stop the old interval)
|
|
480
|
+
const oldTimer = this.typingTimers.get(chatId);
|
|
481
|
+
if (oldTimer)
|
|
482
|
+
clearInterval(oldTimer);
|
|
483
|
+
const send = () => {
|
|
484
|
+
this.fetchFn(`${ILINK_BASE_URL}/ilink/bot/sendtyping`, {
|
|
485
|
+
method: 'POST',
|
|
486
|
+
headers: this.getHeaders(),
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
ilink_user_id: chatId,
|
|
489
|
+
typing_ticket: ticket,
|
|
490
|
+
status: 1,
|
|
491
|
+
base_info: { channel_version: '1.0.0' },
|
|
492
|
+
}),
|
|
493
|
+
signal: AbortSignal.timeout(5_000),
|
|
494
|
+
}).catch((err) => this.log.debug({ err }, '[WeixinAdapter] sendTyping error (non-fatal)'));
|
|
495
|
+
};
|
|
496
|
+
send();
|
|
497
|
+
this.typingTimers.set(chatId, setInterval(send, TYPING_KEEPALIVE_MS));
|
|
498
|
+
}
|
|
499
|
+
stopTyping(chatId) {
|
|
500
|
+
// Bump epoch to invalidate any pending fetchTypingTicket→startTyping chain
|
|
501
|
+
this.typingEpoch.set(chatId, (this.typingEpoch.get(chatId) ?? 0) + 1);
|
|
502
|
+
const timer = this.typingTimers.get(chatId);
|
|
503
|
+
if (timer) {
|
|
504
|
+
clearInterval(timer);
|
|
505
|
+
this.typingTimers.delete(chatId);
|
|
506
|
+
}
|
|
507
|
+
const ticket = this.typingTickets.get(chatId);
|
|
508
|
+
if (!ticket)
|
|
509
|
+
return;
|
|
510
|
+
this.fetchFn(`${ILINK_BASE_URL}/ilink/bot/sendtyping`, {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
headers: this.getHeaders(),
|
|
513
|
+
body: JSON.stringify({
|
|
514
|
+
ilink_user_id: chatId,
|
|
515
|
+
typing_ticket: ticket,
|
|
516
|
+
status: 2,
|
|
517
|
+
base_info: { channel_version: '1.0.0' },
|
|
518
|
+
}),
|
|
519
|
+
signal: AbortSignal.timeout(5_000),
|
|
520
|
+
}).catch(() => { });
|
|
521
|
+
}
|
|
522
|
+
// ── Outbound: Send reply ──
|
|
523
|
+
// Weixin's legacy adapter path does not consume outbound metadata (no group @ capability);
|
|
524
|
+
// the optional parameter keeps parity with the connector contract so callers can pass it through.
|
|
525
|
+
async sendReply(externalChatId, content, _metadata) {
|
|
526
|
+
const currentToken = this.contextTokens.get(externalChatId) ?? '';
|
|
527
|
+
this.log.info({ chatId: externalChatId, contentLen: content.length, tokenHash: currentToken.slice(-8) || 'none' }, '[WeixinAdapter] sendReply() queued for debounce');
|
|
528
|
+
// No token and no existing pending bucket → skip immediately (don't poison future buckets)
|
|
529
|
+
if (!currentToken && !this.pendingReplies.has(externalChatId)) {
|
|
530
|
+
this.log.warn({ chatId: externalChatId }, '[WeixinAdapter] No context_token and no pending bucket — skipping reply');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// If pending exists with a DIFFERENT token → flush old bucket first (isolate turns)
|
|
534
|
+
const existing = this.pendingReplies.get(externalChatId);
|
|
535
|
+
if (existing && currentToken && existing.token !== currentToken) {
|
|
536
|
+
this.log.info({ chatId: externalChatId, oldTokenHash: existing.token.slice(-8), newTokenHash: currentToken.slice(-8) }, '[WeixinAdapter] Token changed mid-debounce — flushing old bucket');
|
|
537
|
+
clearTimeout(existing.timer);
|
|
538
|
+
await this.flushReply(externalChatId);
|
|
539
|
+
}
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
const pending = this.pendingReplies.get(externalChatId);
|
|
542
|
+
if (pending && pending.token === currentToken) {
|
|
543
|
+
// Same token — safe to merge into existing bucket
|
|
544
|
+
pending.parts.push(content);
|
|
545
|
+
pending.resolvers.push({ resolve, reject });
|
|
546
|
+
clearTimeout(pending.timer);
|
|
547
|
+
pending.timer = setTimeout(() => this.flushReply(externalChatId), WEIXIN_REPLY_DEBOUNCE_MS);
|
|
548
|
+
}
|
|
549
|
+
else if (pending) {
|
|
550
|
+
// Different token bucket exists (created by concurrent sendReply during our flush await)
|
|
551
|
+
// Refuse cross-token merge — content is still in the thread, not lost
|
|
552
|
+
this.log.warn({ chatId: externalChatId, ownTokenHash: currentToken.slice(-8), bucketTokenHash: pending.token.slice(-8) }, '[WeixinAdapter] Token mismatch during debounce — refusing cross-token merge');
|
|
553
|
+
resolve();
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const timer = setTimeout(() => this.flushReply(externalChatId), WEIXIN_REPLY_DEBOUNCE_MS);
|
|
557
|
+
this.pendingReplies.set(externalChatId, {
|
|
558
|
+
token: currentToken,
|
|
559
|
+
parts: [content],
|
|
560
|
+
timer,
|
|
561
|
+
resolvers: [{ resolve, reject }],
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
async flushReply(externalChatId) {
|
|
567
|
+
const pending = this.pendingReplies.get(externalChatId);
|
|
568
|
+
if (!pending)
|
|
569
|
+
return;
|
|
570
|
+
this.pendingReplies.delete(externalChatId);
|
|
571
|
+
const { token: boundToken, parts, resolvers } = pending;
|
|
572
|
+
const merged = parts.join('\n\n');
|
|
573
|
+
const tokenHash = boundToken ? boundToken.slice(-8) : 'none';
|
|
574
|
+
this.log.info({ chatId: externalChatId, partsCount: parts.length, mergedLen: merged.length, tokenHash }, '[WeixinAdapter] flushReply() — sending aggregated reply');
|
|
575
|
+
if (!boundToken) {
|
|
576
|
+
this.log.warn({ chatId: externalChatId, tokenHash }, '[WeixinAdapter] Cannot send — no context_token bound');
|
|
577
|
+
this.stopTyping(externalChatId);
|
|
578
|
+
for (const r of resolvers)
|
|
579
|
+
r.resolve();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const plainContent = WeixinAdapter.stripMarkdownForWeixin(merged);
|
|
584
|
+
// BUG-5: iLink context_token supports multiple sendmessage calls (verified 2026-03-25).
|
|
585
|
+
// Token is NOT consumed after first send — retain for subsequent cat replies.
|
|
586
|
+
// Previous BUG-3 "single-use" assumption was a misdiagnosis.
|
|
587
|
+
await this.sendMessageApi(externalChatId, plainContent, boundToken);
|
|
588
|
+
// Token intentionally NOT consumed/deleted — allows relay chain A→B→C
|
|
589
|
+
// to deliver each cat's reply as a separate WeChat message.
|
|
590
|
+
this.stopTyping(externalChatId);
|
|
591
|
+
this.log.info({ chatId: externalChatId, textLen: plainContent.length, tokenHash }, '[WeixinAdapter] flushReply() completed — token retained for potential follow-up replies');
|
|
592
|
+
for (const r of resolvers)
|
|
593
|
+
r.resolve();
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
this.stopTyping(externalChatId);
|
|
597
|
+
this.log.error({ err, chatId: externalChatId }, '[WeixinAdapter] flushReply() failed');
|
|
598
|
+
for (const r of resolvers)
|
|
599
|
+
r.reject(err instanceof Error ? err : new Error(String(err)));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// ── Media send (Phase B): CDN upload → sendmessage with media item ──
|
|
603
|
+
async sendMedia(externalChatId, payload) {
|
|
604
|
+
const contextToken = this.contextTokens.get(externalChatId) ?? '';
|
|
605
|
+
if (!contextToken) {
|
|
606
|
+
throw new Error('Weixin media delivery requires an active context token');
|
|
607
|
+
}
|
|
608
|
+
const materialized = await materializeMedia(payload.content, payload.fileName, WEIXIN_MEDIA_MAX_BYTES);
|
|
609
|
+
let actualFilePath = materialized.path;
|
|
610
|
+
// Native WeChat voice messages require SILK codec. Keep that path opt-in; default audio is a file attachment.
|
|
611
|
+
const nativeVoiceRequested = payload.type === 'audio' && isNativeVoiceItemRequested(this.runtimeOptions);
|
|
612
|
+
let voiceMeta;
|
|
613
|
+
let silkDirToClean;
|
|
614
|
+
try {
|
|
615
|
+
if (nativeVoiceRequested && actualFilePath.endsWith('.wav')) {
|
|
616
|
+
const converted = await this.convertWavToSilk(actualFilePath);
|
|
617
|
+
if (converted) {
|
|
618
|
+
actualFilePath = converted.silkPath;
|
|
619
|
+
silkDirToClean = converted.silkDir;
|
|
620
|
+
voiceMeta = { durationMs: converted.durationMs, sampleRate: converted.sampleRate };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const { uploadMediaToCdn, UploadMediaType } = await import('./weixin-cdn.js');
|
|
624
|
+
const cdnBaseUrl = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
625
|
+
const audioAsVoice = nativeVoiceRequested && actualFilePath.endsWith('.silk');
|
|
626
|
+
const mediaTypeMap = {
|
|
627
|
+
image: UploadMediaType.IMAGE,
|
|
628
|
+
file: UploadMediaType.FILE,
|
|
629
|
+
audio: audioAsVoice ? UploadMediaType.VOICE : UploadMediaType.FILE,
|
|
630
|
+
};
|
|
631
|
+
this.log.info({ chatId: externalChatId, type: payload.type }, '[WeixinAdapter] sendMedia: uploading to CDN');
|
|
632
|
+
const uploaded = await uploadMediaToCdn({
|
|
633
|
+
filePath: actualFilePath,
|
|
634
|
+
toUserId: externalChatId,
|
|
635
|
+
mediaType: mediaTypeMap[payload.type],
|
|
636
|
+
botToken: this.botToken,
|
|
637
|
+
cdnBaseUrl,
|
|
638
|
+
log: this.log,
|
|
639
|
+
fetchFn: this.fetchFn,
|
|
640
|
+
});
|
|
641
|
+
const itemType = payload.type === 'image'
|
|
642
|
+
? MessageItemType.IMAGE
|
|
643
|
+
: payload.type === 'audio' && audioAsVoice
|
|
644
|
+
? MessageItemType.VOICE
|
|
645
|
+
: MessageItemType.FILE;
|
|
646
|
+
const mediaRef = {
|
|
647
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
648
|
+
aes_key: Buffer.from(uploaded.aeskey).toString('base64'),
|
|
649
|
+
encrypt_type: 1,
|
|
650
|
+
};
|
|
651
|
+
const mediaItem = { type: itemType };
|
|
652
|
+
if (payload.type === 'image') {
|
|
653
|
+
mediaItem.image_item = { media: mediaRef, mid_size: uploaded.fileSizeCiphertext };
|
|
654
|
+
}
|
|
655
|
+
else if (payload.type === 'audio' && audioAsVoice) {
|
|
656
|
+
const voiceMode = getWeixinVoiceItemMode(this.runtimeOptions, this.log);
|
|
657
|
+
if (voiceMode === 'metadata') {
|
|
658
|
+
mediaItem.voice_item = {
|
|
659
|
+
media: mediaRef,
|
|
660
|
+
encode_type: 6,
|
|
661
|
+
bits_per_sample: 16,
|
|
662
|
+
sample_rate: voiceMeta?.sampleRate ?? 24_000,
|
|
663
|
+
playtime: voiceMeta?.durationMs ?? 0,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
else if (voiceMode === 'playtime-encode' && voiceMeta?.durationMs && voiceMeta.durationMs > 0) {
|
|
667
|
+
mediaItem.voice_item = { media: mediaRef, encode_type: 6, playtime: Math.round(voiceMeta.durationMs) };
|
|
668
|
+
}
|
|
669
|
+
else if (voiceMode === 'playtime-sec' && voiceMeta?.durationMs && voiceMeta.durationMs > 0) {
|
|
670
|
+
mediaItem.voice_item = { media: mediaRef, playtime: Math.max(1, Math.round(voiceMeta.durationMs / 1000)) };
|
|
671
|
+
}
|
|
672
|
+
else if (voiceMode === 'playtime' && voiceMeta?.durationMs && voiceMeta.durationMs > 0) {
|
|
673
|
+
mediaItem.voice_item = { media: mediaRef, playtime: Math.round(voiceMeta.durationMs) };
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
mediaItem.voice_item = { media: mediaRef };
|
|
677
|
+
}
|
|
678
|
+
this.log.info({
|
|
679
|
+
chatId: externalChatId,
|
|
680
|
+
mode: voiceMode,
|
|
681
|
+
requestedMode: this.runtimeOptions.voiceItemMode,
|
|
682
|
+
unsafeVoiceModesEnabled: this.runtimeOptions.enableUnsafeVoiceModes === true,
|
|
683
|
+
durationMs: voiceMeta?.durationMs,
|
|
684
|
+
}, '[WeixinAdapter] sendMedia: voice_item mode');
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
const { basename } = await import('node:path');
|
|
688
|
+
mediaItem.file_item = {
|
|
689
|
+
media: mediaRef,
|
|
690
|
+
file_name: payload.type === 'audio' ? (payload.fileName ?? basename(actualFilePath)) : (payload.fileName ?? 'file'),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const body = {
|
|
694
|
+
msg: {
|
|
695
|
+
from_user_id: '',
|
|
696
|
+
to_user_id: externalChatId,
|
|
697
|
+
client_id: generateClientId(),
|
|
698
|
+
message_type: 2,
|
|
699
|
+
context_token: contextToken,
|
|
700
|
+
message_state: MessageState.FINISH,
|
|
701
|
+
item_list: [mediaItem],
|
|
702
|
+
},
|
|
703
|
+
base_info: { channel_version: '1.0.0' },
|
|
704
|
+
};
|
|
705
|
+
await this.postSendMessageWithRetry('sendMedia', externalChatId, body);
|
|
706
|
+
// BUG-5: token is reusable — do NOT consume/delete.
|
|
707
|
+
this.log.info({ chatId: externalChatId, type: payload.type, filekey: uploaded.filekey }, '[WeixinAdapter] sendMedia: delivered — token retained');
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
await materialized.cleanup().catch(() => undefined);
|
|
711
|
+
if (silkDirToClean) {
|
|
712
|
+
// Remove the private mkdtemp directory the converted voice.silk lived in.
|
|
713
|
+
const { rm } = await import('node:fs/promises');
|
|
714
|
+
await rm(silkDirToClean, { recursive: true, force: true }).catch(() => { });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Convert WAV audio to SILK v3 (WeChat's native voice codec).
|
|
720
|
+
* Returns path to temp .silk file, or null if conversion fails.
|
|
721
|
+
*/
|
|
722
|
+
async convertWavToSilk(wavPath) {
|
|
723
|
+
try {
|
|
724
|
+
const { readFile, writeFile } = await import('node:fs/promises');
|
|
725
|
+
const { tmpdir } = await import('node:os');
|
|
726
|
+
const { join } = await import('node:path');
|
|
727
|
+
const { mkdtemp } = await import('node:fs/promises');
|
|
728
|
+
const { encode } = await import('silk-wasm');
|
|
729
|
+
const wavData = await readFile(wavPath);
|
|
730
|
+
const parsed = this.extractMonoPcmFromWav(wavData);
|
|
731
|
+
if (!parsed) {
|
|
732
|
+
this.log.warn({ wavPath, len: wavData.length }, '[WeixinAdapter] convertWavToSilk: unsupported WAV format');
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const result = await encode(parsed.pcm, parsed.sampleRate);
|
|
736
|
+
// Write raw SILK output — do NOT append 0xFFFF EOS marker.
|
|
737
|
+
// Evidence: inbound WeChat SILK has no EOS marker and ends exactly at last frame.
|
|
738
|
+
// The 0xFFFF bytes are read as int16LE frame-size = -1, which crashes WeChat's decoder.
|
|
739
|
+
// Private temp directory (mkdtemp): a shared tmpdir() + Date.now() name
|
|
740
|
+
// collides across concurrent conversions, cross-writing SILK bytes (G5).
|
|
741
|
+
const silkDir = await mkdtemp(join(tmpdir(), 'cat-cafe-weixin-silk-'));
|
|
742
|
+
const silkPath = join(silkDir, 'voice.silk');
|
|
743
|
+
await writeFile(silkPath, Buffer.from(result.data));
|
|
744
|
+
this.log.info({ wavPath, silkPath, duration: result.duration, sampleRate: parsed.sampleRate }, '[WeixinAdapter] convertWavToSilk: success');
|
|
745
|
+
return { silkPath, silkDir, durationMs: result.duration, sampleRate: parsed.sampleRate };
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
this.log.warn({ err, wavPath }, '[WeixinAdapter] convertWavToSilk: failed, uploading WAV as fallback');
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Parse WAV buffer (tolerant to RIFF size mismatch) and return mono PCM s16le.
|
|
754
|
+
* This avoids passing malformed WAV headers directly into silk-wasm as raw PCM.
|
|
755
|
+
*/
|
|
756
|
+
extractMonoPcmFromWav(wavData) {
|
|
757
|
+
if (wavData.length < 44)
|
|
758
|
+
return null;
|
|
759
|
+
if (wavData.toString('ascii', 0, 4) !== 'RIFF' || wavData.toString('ascii', 8, 12) !== 'WAVE')
|
|
760
|
+
return null;
|
|
761
|
+
let offset = 12;
|
|
762
|
+
let sampleRate = 0;
|
|
763
|
+
let channels = 0;
|
|
764
|
+
let bitsPerSample = 0;
|
|
765
|
+
let dataOffset = -1;
|
|
766
|
+
let dataSize = 0;
|
|
767
|
+
while (offset + 8 <= wavData.length) {
|
|
768
|
+
const chunkId = wavData.toString('ascii', offset, offset + 4);
|
|
769
|
+
const chunkSize = wavData.readUInt32LE(offset + 4);
|
|
770
|
+
const chunkDataStart = offset + 8;
|
|
771
|
+
if (chunkDataStart > wavData.length)
|
|
772
|
+
break;
|
|
773
|
+
const maxReadable = Math.max(0, Math.min(chunkSize, wavData.length - chunkDataStart));
|
|
774
|
+
if (chunkId === 'fmt ' && maxReadable >= 16) {
|
|
775
|
+
channels = wavData.readUInt16LE(chunkDataStart + 2);
|
|
776
|
+
sampleRate = wavData.readUInt32LE(chunkDataStart + 4);
|
|
777
|
+
bitsPerSample = wavData.readUInt16LE(chunkDataStart + 14);
|
|
778
|
+
}
|
|
779
|
+
else if (chunkId === 'data') {
|
|
780
|
+
dataOffset = chunkDataStart;
|
|
781
|
+
dataSize = maxReadable;
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
offset = chunkDataStart + maxReadable + (chunkSize % 2);
|
|
785
|
+
}
|
|
786
|
+
if (dataOffset < 0 || dataSize <= 0)
|
|
787
|
+
return null;
|
|
788
|
+
if (sampleRate <= 0 || channels <= 0 || bitsPerSample !== 16)
|
|
789
|
+
return null;
|
|
790
|
+
const data = wavData.subarray(dataOffset, dataOffset + dataSize);
|
|
791
|
+
if (channels === 1)
|
|
792
|
+
return { pcm: data, sampleRate };
|
|
793
|
+
// Downmix multi-channel int16 PCM to mono.
|
|
794
|
+
const frameCount = Math.floor(data.length / (channels * 2));
|
|
795
|
+
const mono = Buffer.alloc(frameCount * 2);
|
|
796
|
+
for (let i = 0; i < frameCount; i++) {
|
|
797
|
+
let sum = 0;
|
|
798
|
+
const base = i * channels * 2;
|
|
799
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
800
|
+
sum += data.readInt16LE(base + ch * 2);
|
|
801
|
+
}
|
|
802
|
+
const mixed = Math.max(-32768, Math.min(32767, Math.round(sum / channels)));
|
|
803
|
+
mono.writeInt16LE(mixed, i * 2);
|
|
804
|
+
}
|
|
805
|
+
return { pcm: mono, sampleRate };
|
|
806
|
+
}
|
|
807
|
+
static stripMarkdownForWeixin(text) {
|
|
808
|
+
return text
|
|
809
|
+
.replace(/```[^\n]*\n([\s\S]*?)```/g, '$1') // multi-line fence → keep code body
|
|
810
|
+
.replace(/```(.+?)```/g, '$1') // single-line fence → keep content
|
|
811
|
+
.replace(/`([^`]+)`/g, '$1') // inline code → plain
|
|
812
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') //  → alt (must precede link regex)
|
|
813
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
|
|
814
|
+
.replace(/^#{1,6}\s+/gm, '') // strip heading markers
|
|
815
|
+
.replace(/(\*\*|__)(.*?)\1/g, '$2') // bold → plain
|
|
816
|
+
.replace(/(?<!\w)\*(?=\S)(.*?\S)\*(?!\w)/gm, '$1') // italic *word* → plain (not inside identifiers)
|
|
817
|
+
.replace(/(?<!\w)_(?=\S)(.*?\S)_(?!\w)/gm, '$1') // italic _word_ → plain (not inside identifiers)
|
|
818
|
+
.replace(/~~(.*?)~~/g, '$1') // strikethrough → plain
|
|
819
|
+
.replace(/^[>\s]*>\s?/gm, '') // blockquote markers
|
|
820
|
+
.replace(/^[-*+]\s+/gm, '• ') // unordered list → bullet
|
|
821
|
+
.replace(/^\d+\.\s+/gm, '') // ordered list markers
|
|
822
|
+
.replace(/^---+$/gm, '') // horizontal rules
|
|
823
|
+
.replace(/\n{3,}/g, '\n\n') // collapse excessive newlines
|
|
824
|
+
.trim();
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Low-level: call /ilink/bot/sendmessage API.
|
|
828
|
+
*/
|
|
829
|
+
async sendMessageApi(chatId, text, contextToken) {
|
|
830
|
+
const body = {
|
|
831
|
+
msg: {
|
|
832
|
+
from_user_id: '',
|
|
833
|
+
to_user_id: chatId,
|
|
834
|
+
client_id: generateClientId(),
|
|
835
|
+
message_type: 2,
|
|
836
|
+
context_token: contextToken,
|
|
837
|
+
message_state: MessageState.FINISH,
|
|
838
|
+
item_list: [
|
|
839
|
+
{
|
|
840
|
+
type: MessageItemType.TEXT,
|
|
841
|
+
text_item: { text },
|
|
842
|
+
},
|
|
843
|
+
],
|
|
844
|
+
},
|
|
845
|
+
base_info: { channel_version: '1.0.0' },
|
|
846
|
+
};
|
|
847
|
+
this.log.info({ chatId, textLen: text.length }, '[WeixinAdapter] sendMessageApi() calling iLink API');
|
|
848
|
+
await this.postSendMessageWithRetry('sendmessage', chatId, body);
|
|
849
|
+
}
|
|
850
|
+
async postSendMessageWithRetry(operation, chatId, body) {
|
|
851
|
+
for (let attempt = 1; attempt <= SENDMESSAGE_MAX_ATTEMPTS; attempt++) {
|
|
852
|
+
const { data, rawText } = await this.postSendMessageOnce(operation, chatId, body);
|
|
853
|
+
const errorCode = data.errcode !== undefined ? data.errcode : data.ret;
|
|
854
|
+
this.log.debug({ chatId, rawText }, `[WeixinAdapter] ${operation} raw response`);
|
|
855
|
+
this.log.info({ chatId, attempt, errcode: errorCode, errmsg: data.errmsg }, `[WeixinAdapter] ${operation} response received`);
|
|
856
|
+
if (errorCode == null)
|
|
857
|
+
return;
|
|
858
|
+
if (errorCode === 0)
|
|
859
|
+
return;
|
|
860
|
+
const errmsg = data.errmsg === undefined ? 'unknown' : data.errmsg;
|
|
861
|
+
const classification = this.classifySendError(errorCode, errmsg);
|
|
862
|
+
if (classification === 'stale-session') {
|
|
863
|
+
this.log.error({ chatId, errcode: errorCode, errmsg }, `[WeixinAdapter] ${operation} stale session`);
|
|
864
|
+
this.sessionExpiredCallback?.();
|
|
865
|
+
throw new Error(`${operation} stale session errcode ${errorCode}: ${errmsg}`);
|
|
866
|
+
}
|
|
867
|
+
if (classification === 'retryable' && attempt < SENDMESSAGE_MAX_ATTEMPTS) {
|
|
868
|
+
this.log.warn({ chatId, errcode: errorCode, errmsg, attempt }, `[WeixinAdapter] ${operation} transient iLink error — retrying`);
|
|
869
|
+
await this.sleep(SENDMESSAGE_RETRY_DELAY_MS);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
throw new Error(`${operation} errcode ${errorCode}: ${errmsg}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async postSendMessageOnce(operation, chatId, body) {
|
|
876
|
+
const res = await this.fetchFn(`${ILINK_BASE_URL}/ilink/bot/sendmessage`, {
|
|
877
|
+
method: 'POST',
|
|
878
|
+
headers: this.getHeaders(),
|
|
879
|
+
body: JSON.stringify(body),
|
|
880
|
+
});
|
|
881
|
+
if (!res.ok) {
|
|
882
|
+
const errorText = await res.text().catch(() => '');
|
|
883
|
+
this.log.error({ chatId, status: res.status, errorText }, `[WeixinAdapter] ${operation} HTTP error`);
|
|
884
|
+
throw new Error(`${operation} HTTP ${res.status}: ${errorText}`);
|
|
885
|
+
}
|
|
886
|
+
if (typeof res.text === 'function') {
|
|
887
|
+
const rawText = await res.text().catch(() => '');
|
|
888
|
+
if (!rawText.trim()) {
|
|
889
|
+
this.log.error({ chatId }, `[WeixinAdapter] ${operation} returned empty body`);
|
|
890
|
+
throw new Error(`${operation} returned empty response body`);
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
return { data: JSON.parse(rawText), rawText };
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
this.log.error({ chatId, rawText, error: String(error) }, `[WeixinAdapter] ${operation} non-JSON body`);
|
|
897
|
+
throw new Error(`${operation} returned non-JSON response: ${rawText}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (typeof res.json === 'function') {
|
|
901
|
+
const data = (await res.json());
|
|
902
|
+
return { data, rawText: JSON.stringify(data) };
|
|
903
|
+
}
|
|
904
|
+
this.log.error({ chatId }, `[WeixinAdapter] ${operation} response body reader missing`);
|
|
905
|
+
throw new Error(`${operation} response body unreadable`);
|
|
906
|
+
}
|
|
907
|
+
classifySendError(errorCode, errmsg) {
|
|
908
|
+
if (errorCode === ERRCODE_SESSION_EXPIRED)
|
|
909
|
+
return 'stale-session';
|
|
910
|
+
if (errorCode === ILINK_TRANSIENT_ERRCODE && /unknown error/i.test(errmsg))
|
|
911
|
+
return 'stale-session';
|
|
912
|
+
if (errorCode === ILINK_TRANSIENT_ERRCODE)
|
|
913
|
+
return 'retryable';
|
|
914
|
+
return 'fatal';
|
|
915
|
+
}
|
|
916
|
+
// ── Helpers ──
|
|
917
|
+
/**
|
|
918
|
+
* Split text into chunks of maxLen characters, breaking at newlines or spaces.
|
|
919
|
+
*/
|
|
920
|
+
chunkMessage(text, maxLen) {
|
|
921
|
+
if (text.length <= maxLen)
|
|
922
|
+
return [text];
|
|
923
|
+
const chunks = [];
|
|
924
|
+
let remaining = text;
|
|
925
|
+
while (remaining.length > 0) {
|
|
926
|
+
if (remaining.length <= maxLen) {
|
|
927
|
+
chunks.push(remaining);
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
// Try to break at newline
|
|
931
|
+
let breakAt = remaining.lastIndexOf('\n', maxLen);
|
|
932
|
+
// Fall back to space
|
|
933
|
+
if (breakAt <= 0)
|
|
934
|
+
breakAt = remaining.lastIndexOf(' ', maxLen);
|
|
935
|
+
// Fall back to hard cut
|
|
936
|
+
if (breakAt <= 0)
|
|
937
|
+
breakAt = maxLen;
|
|
938
|
+
chunks.push(remaining.slice(0, breakAt));
|
|
939
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
940
|
+
}
|
|
941
|
+
return chunks;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Get the current polling state (for IM Hub status display).
|
|
945
|
+
*/
|
|
946
|
+
isPolling() {
|
|
947
|
+
return this.polling;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Check if we have a context_token for a given chatId.
|
|
951
|
+
*/
|
|
952
|
+
hasContextToken(chatId) {
|
|
953
|
+
return this.contextTokens.has(chatId);
|
|
954
|
+
}
|
|
955
|
+
clearSessionState() {
|
|
956
|
+
this.contextTokens.clear();
|
|
957
|
+
this.getUpdatesBuf = '';
|
|
958
|
+
this.contentDedupFingerprints.clear();
|
|
959
|
+
}
|
|
960
|
+
async clearPersistedSessionState(warnMessage) {
|
|
961
|
+
if (!this.sessionStateStore)
|
|
962
|
+
return;
|
|
963
|
+
await this.sessionStateStore.clear().catch((err) => {
|
|
964
|
+
this.log.warn({ err }, warnMessage);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
persistSessionState() {
|
|
968
|
+
if (!this.sessionStateStore)
|
|
969
|
+
return;
|
|
970
|
+
const contextTokens = Object.fromEntries(this.contextTokens.entries());
|
|
971
|
+
void this.sessionStateStore
|
|
972
|
+
.save({
|
|
973
|
+
getUpdatesBuf: this.getUpdatesBuf,
|
|
974
|
+
contextTokens,
|
|
975
|
+
})
|
|
976
|
+
.catch((err) => {
|
|
977
|
+
this.log.warn({ err }, '[WeixinAdapter] Failed to persist session state');
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
buildContentDedupFingerprint(msg) {
|
|
981
|
+
if (msg.createdAtMs == null)
|
|
982
|
+
return undefined;
|
|
983
|
+
const attachments = msg.attachments
|
|
984
|
+
?.map((a) => {
|
|
985
|
+
const fileName = a.fileName === undefined ? '' : a.fileName;
|
|
986
|
+
return `${a.type}:${a.mediaUrl}:${fileName}`;
|
|
987
|
+
})
|
|
988
|
+
.join('|');
|
|
989
|
+
const attachmentFingerprint = attachments === undefined ? '' : attachments;
|
|
990
|
+
return crypto
|
|
991
|
+
.createHash('sha256')
|
|
992
|
+
.update(`${msg.chatId}\0${msg.contextToken}\0${msg.createdAtMs}\0${msg.text}\0${attachmentFingerprint}`)
|
|
993
|
+
.digest('hex');
|
|
994
|
+
}
|
|
995
|
+
hasRecentContentDedupFingerprint(fingerprint) {
|
|
996
|
+
const now = Date.now();
|
|
997
|
+
const expiresAt = this.contentDedupFingerprints.get(fingerprint);
|
|
998
|
+
return expiresAt !== undefined && expiresAt > now;
|
|
999
|
+
}
|
|
1000
|
+
rememberContentDedupFingerprint(fingerprint) {
|
|
1001
|
+
const now = Date.now();
|
|
1002
|
+
if (this.contentDedupFingerprints.size >= CONTENT_DEDUP_MAX_ENTRIES) {
|
|
1003
|
+
this.pruneContentDedupFingerprints(now);
|
|
1004
|
+
}
|
|
1005
|
+
this.contentDedupFingerprints.set(fingerprint, now + CONTENT_DEDUP_TTL_MS);
|
|
1006
|
+
}
|
|
1007
|
+
pruneContentDedupFingerprints(now) {
|
|
1008
|
+
for (const [fingerprint, expiresAt] of this.contentDedupFingerprints) {
|
|
1009
|
+
if (expiresAt <= now) {
|
|
1010
|
+
this.contentDedupFingerprints.delete(fingerprint);
|
|
1011
|
+
}
|
|
1012
|
+
if (expiresAt > now && this.contentDedupFingerprints.size >= CONTENT_DEDUP_MAX_ENTRIES) {
|
|
1013
|
+
this.contentDedupFingerprints.delete(fingerprint);
|
|
1014
|
+
}
|
|
1015
|
+
if (this.contentDedupFingerprints.size < CONTENT_DEDUP_MAX_ENTRIES)
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
sleep(ms) {
|
|
1020
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1021
|
+
}
|
|
1022
|
+
// ── Test helpers ──
|
|
1023
|
+
/** @internal Test helper: inject a mock fetch function. */
|
|
1024
|
+
_injectFetch(fn) {
|
|
1025
|
+
this.fetchFn = fn;
|
|
1026
|
+
}
|
|
1027
|
+
/** @internal Test helper: inject a context_token for a chatId. */
|
|
1028
|
+
_injectContextToken(chatId, token) {
|
|
1029
|
+
this.contextTokens.set(chatId, token);
|
|
1030
|
+
}
|
|
1031
|
+
/** @internal Test helper: set the getupdates cursor. */
|
|
1032
|
+
_setCursor(cursor) {
|
|
1033
|
+
this.getUpdatesBuf = cursor;
|
|
1034
|
+
}
|
|
1035
|
+
/** @internal Test helper: get the current cursor. */
|
|
1036
|
+
_getCursor() {
|
|
1037
|
+
return this.getUpdatesBuf;
|
|
1038
|
+
}
|
|
1039
|
+
/** @internal Test helper: flush all pending debounced replies immediately. */
|
|
1040
|
+
async _flushAllPending() {
|
|
1041
|
+
const chatIds = [...this.pendingReplies.keys()];
|
|
1042
|
+
for (const chatId of chatIds) {
|
|
1043
|
+
const pending = this.pendingReplies.get(chatId);
|
|
1044
|
+
if (pending)
|
|
1045
|
+
clearTimeout(pending.timer);
|
|
1046
|
+
await this.flushReply(chatId);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// ── QR Code Login (static — no adapter instance needed) ──
|
|
1050
|
+
static staticFetchFn = globalThis.fetch;
|
|
1051
|
+
static async fetchQrCode() {
|
|
1052
|
+
const url = `${ILINK_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`;
|
|
1053
|
+
const res = await WeixinAdapter.staticFetchFn(url, {
|
|
1054
|
+
method: 'GET',
|
|
1055
|
+
signal: AbortSignal.timeout(10_000),
|
|
1056
|
+
});
|
|
1057
|
+
if (!res.ok) {
|
|
1058
|
+
throw new Error(`get_bot_qrcode HTTP ${res.status}: ${res.statusText}`);
|
|
1059
|
+
}
|
|
1060
|
+
const data = (await res.json());
|
|
1061
|
+
const errorCode = data.errcode ?? data.ret;
|
|
1062
|
+
if (errorCode && errorCode !== 0) {
|
|
1063
|
+
throw new Error(`get_bot_qrcode errcode ${errorCode}: ${data.errmsg ?? 'unknown'}`);
|
|
1064
|
+
}
|
|
1065
|
+
// iLink API returns qrcode_img_content (not qrcode_url) — accept both for resilience
|
|
1066
|
+
const qrUrl = data.qrcode_img_content ?? data.qrcode_url;
|
|
1067
|
+
const qrPayload = data.qrcode;
|
|
1068
|
+
if (!qrUrl || !qrPayload) {
|
|
1069
|
+
throw new Error('get_bot_qrcode: missing qrcode_img_content/qrcode_url or qrcode in response');
|
|
1070
|
+
}
|
|
1071
|
+
return { qrUrl, qrPayload };
|
|
1072
|
+
}
|
|
1073
|
+
static async pollQrCodeStatus(qrPayload) {
|
|
1074
|
+
const url = `${ILINK_BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrPayload)}`;
|
|
1075
|
+
const res = await WeixinAdapter.staticFetchFn(url, {
|
|
1076
|
+
method: 'GET',
|
|
1077
|
+
signal: AbortSignal.timeout(QRCODE_STATUS_POLL_TIMEOUT_MS),
|
|
1078
|
+
});
|
|
1079
|
+
if (!res.ok) {
|
|
1080
|
+
return { status: 'error', message: `HTTP ${res.status}` };
|
|
1081
|
+
}
|
|
1082
|
+
const data = (await res.json());
|
|
1083
|
+
const errorCode = data.errcode ?? data.ret;
|
|
1084
|
+
if (errorCode && errorCode !== 0) {
|
|
1085
|
+
return { status: 'error', message: data.errmsg ?? `errcode ${errorCode}` };
|
|
1086
|
+
}
|
|
1087
|
+
// iLink API returns status as number (0/1/2/3) or string ("wait"/"scanned"/"confirmed"/"expired")
|
|
1088
|
+
const s = data.status;
|
|
1089
|
+
switch (s) {
|
|
1090
|
+
case 0:
|
|
1091
|
+
case 'wait':
|
|
1092
|
+
return { status: 'waiting' };
|
|
1093
|
+
case 1:
|
|
1094
|
+
case 'scanned':
|
|
1095
|
+
return { status: 'scanned' };
|
|
1096
|
+
case 2:
|
|
1097
|
+
case 'confirmed':
|
|
1098
|
+
if (!data.bot_token) {
|
|
1099
|
+
return { status: 'error', message: 'confirmed but no bot_token in response' };
|
|
1100
|
+
}
|
|
1101
|
+
return { status: 'confirmed', botToken: data.bot_token };
|
|
1102
|
+
case 3:
|
|
1103
|
+
case 'expired':
|
|
1104
|
+
return { status: 'expired' };
|
|
1105
|
+
default:
|
|
1106
|
+
return { status: 'error', message: `unknown status ${s}` };
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
static async waitForQrCodeLogin(qrPayload, onStatusChange) {
|
|
1110
|
+
const deadline = Date.now() + QRCODE_TIMEOUT_MS;
|
|
1111
|
+
let lastStatus = '';
|
|
1112
|
+
while (Date.now() < deadline) {
|
|
1113
|
+
const result = await WeixinAdapter.pollQrCodeStatus(qrPayload);
|
|
1114
|
+
if (result.status !== lastStatus) {
|
|
1115
|
+
lastStatus = result.status;
|
|
1116
|
+
onStatusChange?.(result);
|
|
1117
|
+
}
|
|
1118
|
+
if (result.status === 'confirmed' || result.status === 'expired' || result.status === 'error') {
|
|
1119
|
+
return result;
|
|
1120
|
+
}
|
|
1121
|
+
await new Promise((resolve) => setTimeout(resolve, QRCODE_POLL_INTERVAL_MS));
|
|
1122
|
+
}
|
|
1123
|
+
return { status: 'expired' };
|
|
1124
|
+
}
|
|
1125
|
+
/** @internal Test helper: inject a mock fetch function for static QR methods. */
|
|
1126
|
+
static _injectStaticFetch(fn) {
|
|
1127
|
+
WeixinAdapter.staticFetchFn = fn;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
//# sourceMappingURL=WeixinAdapter.js.map
|