@sidleo3/dsh-chat-weixin 0.0.4
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/client/index.js +605 -0
- package/cordis.patch.yml +5 -0
- package/host/config-store.mjs +115 -0
- package/host/controller.mjs +434 -0
- package/host/ilink-client.mjs +623 -0
- package/host/index.mjs +59 -0
- package/host/media.mjs +408 -0
- package/host/runtime.mjs +577 -0
- package/host/state-store.mjs +146 -0
- package/lib/client.js +631 -0
- package/lib/index.js +1939 -0
- package/package.json +51 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信 iLink 协议客户端(私聊文本 + 出站图片/文件)。
|
|
3
|
+
*
|
|
4
|
+
* **出处**:本文件是 `xmanrui/dsh-im`(MIT)`src/channels/weixin/weixin-api.mjs`
|
|
5
|
+
* 协议行为的移植版本——iLink 没有公开文档,只能按上游实测出来的协议重写客户端。
|
|
6
|
+
* 原始许可与出处见仓库 THIRD_PARTY_NOTICES.md。本移植覆盖:扫码登录、长轮询收消息、
|
|
7
|
+
* 输入状态、发文本,以及出站媒体(`getuploadurl` → 加密上传 CDN → `sendmessage`
|
|
8
|
+
* 带 `file_item`/`image_item`);加解密与 CDN 传输在 `./media.mjs`。
|
|
9
|
+
*
|
|
10
|
+
* 安全约定:baseUrl 与二维码地址都必须落在 `*.weixin.qq.com` / `*.wechat.com`
|
|
11
|
+
* 且为 https;CDN 上传地址只信任 `novac2c.cdn.weixin.qq.com/c2c/upload`
|
|
12
|
+
* ——服务端返回的地址不能让我们去连任意主机。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-chat-weixin/ilink-client
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import { aesEcbPaddedSize, mediaUploadUrl, uploadMediaToCdn } from './media.mjs';
|
|
20
|
+
|
|
21
|
+
/** 扫码登录与默认 API 基址。 */
|
|
22
|
+
export const DEFAULT_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
|
|
23
|
+
|
|
24
|
+
/** 协议版本(服务端按它分派行为)。 */
|
|
25
|
+
export const PROTOCOL_VERSION = '2.4.6';
|
|
26
|
+
|
|
27
|
+
/** 机器人类型(个人微信机器人)。 */
|
|
28
|
+
export const DEFAULT_BOT_TYPE = '3';
|
|
29
|
+
|
|
30
|
+
/** 单条消息字符上限(超出按行分段)。 */
|
|
31
|
+
export const MAX_MESSAGE_CHARS = 1_800;
|
|
32
|
+
|
|
33
|
+
const ILINK_APP_ID = 'bot';
|
|
34
|
+
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
36
|
+
const LONG_POLL_TIMEOUT_MS = 35_000;
|
|
37
|
+
|
|
38
|
+
/** 扫码状态机(服务端返回值)。 */
|
|
39
|
+
export const LOGIN_STATUSES = Object.freeze([
|
|
40
|
+
'wait', 'scaned', 'confirmed', 'expired',
|
|
41
|
+
'scaned_but_redirect', 'need_verifycode', 'verify_code_blocked', 'binded_redirect',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** 协议错误:带稳定 code,便于上层区分"网络"与"业务拒绝"。 */
|
|
45
|
+
export class IlinkError extends Error {
|
|
46
|
+
constructor(code, message, options = {}) {
|
|
47
|
+
super(message, options);
|
|
48
|
+
this.name = 'IlinkError';
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.status = options.status;
|
|
51
|
+
this.providerCode = options.providerCode;
|
|
52
|
+
this.timeoutMs = options.timeoutMs;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nonEmptyString(value) {
|
|
57
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function abortError(signal) {
|
|
61
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
62
|
+
const error = new Error('操作已取消');
|
|
63
|
+
error.name = 'AbortError';
|
|
64
|
+
return error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 服务端是否明确拒绝了这次调用(`ret` / `errcode` 非 0)。
|
|
69
|
+
*
|
|
70
|
+
* @param value - 响应体。
|
|
71
|
+
* @param fields - 需要检查的字段。
|
|
72
|
+
* @returns 拒绝时的提供方错误码,否则 null。
|
|
73
|
+
*/
|
|
74
|
+
export function rejectedResponse(value, fields = ['ret', 'errcode']) {
|
|
75
|
+
if (!value || typeof value !== 'object') return null;
|
|
76
|
+
for (const field of fields) {
|
|
77
|
+
const raw = value[field];
|
|
78
|
+
if (raw === undefined || raw === 0 || raw === '0') continue;
|
|
79
|
+
return typeof raw === 'string' || typeof raw === 'number' ? String(raw) : 'rejected';
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isWeixinHost(hostname) {
|
|
85
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
86
|
+
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com')
|
|
87
|
+
|| normalized === 'wechat.com' || normalized.endsWith('.wechat.com');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 校验并归一化 API 基址(只允许微信自己的 https 域名)。
|
|
92
|
+
*
|
|
93
|
+
* @param value - 候选基址。
|
|
94
|
+
* @returns 归一化后的 URL 字符串。
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeBaseUrl(value) {
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
url = new URL(value);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new IlinkError('invalid-base-url', '微信服务返回了无效的连接地址。');
|
|
102
|
+
}
|
|
103
|
+
if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)
|
|
104
|
+
|| (url.port !== '' && url.port !== '443')) {
|
|
105
|
+
throw new IlinkError('untrusted-base-url', '微信服务返回了不受信任的连接地址。');
|
|
106
|
+
}
|
|
107
|
+
url.username = '';
|
|
108
|
+
url.password = '';
|
|
109
|
+
url.search = '';
|
|
110
|
+
url.hash = '';
|
|
111
|
+
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
112
|
+
return url.toString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 校验二维码图片地址(同上,避免被指向任意主机)。 */
|
|
116
|
+
export function normalizeQrUrl(value) {
|
|
117
|
+
const text = nonEmptyString(value);
|
|
118
|
+
if (!text) return null;
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(text);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new IlinkError('invalid-qr', '微信服务返回了无效的扫码地址。');
|
|
124
|
+
}
|
|
125
|
+
if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)) {
|
|
126
|
+
throw new IlinkError('untrusted-qr', '微信服务返回了不受信任的扫码地址。');
|
|
127
|
+
}
|
|
128
|
+
return url.toString();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function commonHeaders() {
|
|
132
|
+
return {
|
|
133
|
+
'iLink-App-Id': ILINK_APP_ID,
|
|
134
|
+
'iLink-App-ClientVersion': String(ILINK_CLIENT_VERSION),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function authenticatedHeaders(token) {
|
|
139
|
+
const headers = {
|
|
140
|
+
...commonHeaders(),
|
|
141
|
+
'content-type': 'application/json',
|
|
142
|
+
AuthorizationType: 'ilink_bot_token',
|
|
143
|
+
'X-WECHAT-UIN': Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf8').toString('base64'),
|
|
144
|
+
};
|
|
145
|
+
const value = nonEmptyString(token);
|
|
146
|
+
if (value) headers.Authorization = `Bearer ${value}`;
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function baseInfo() {
|
|
151
|
+
return { channel_version: PROTOCOL_VERSION, bot_agent: 'dsh-chat/0.0.1' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function requestJson(fetchImpl, {
|
|
155
|
+
method,
|
|
156
|
+
baseUrl,
|
|
157
|
+
endpoint,
|
|
158
|
+
body,
|
|
159
|
+
token,
|
|
160
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
161
|
+
signal,
|
|
162
|
+
authenticated = true,
|
|
163
|
+
}) {
|
|
164
|
+
const trustedBase = normalizeBaseUrl(baseUrl);
|
|
165
|
+
const url = new URL(endpoint, trustedBase);
|
|
166
|
+
if (!isWeixinHost(url.hostname)) {
|
|
167
|
+
throw new IlinkError('untrusted-endpoint', '拒绝访问不受信任的微信服务地址。');
|
|
168
|
+
}
|
|
169
|
+
if (signal?.aborted) throw abortError(signal);
|
|
170
|
+
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
173
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
174
|
+
let timedOut = false;
|
|
175
|
+
const timer = timeoutMs > 0
|
|
176
|
+
? setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
controller.abort();
|
|
179
|
+
}, timeoutMs)
|
|
180
|
+
: null;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const response = await fetchImpl(url, {
|
|
184
|
+
method,
|
|
185
|
+
headers: authenticated ? authenticatedHeaders(token) : commonHeaders(),
|
|
186
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
187
|
+
signal: controller.signal,
|
|
188
|
+
});
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
throw new IlinkError('http-error', `微信服务请求失败(HTTP ${response.status})。`, {
|
|
191
|
+
status: response.status,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return await response.json();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw new IlinkError('invalid-response', '微信服务返回了无法解析的响应。', { cause: error });
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (signal?.aborted) throw abortError(signal);
|
|
201
|
+
if (timedOut) {
|
|
202
|
+
throw new IlinkError('timeout', '微信服务请求超时。', { cause: error, timeoutMs });
|
|
203
|
+
}
|
|
204
|
+
throw error instanceof IlinkError
|
|
205
|
+
? error
|
|
206
|
+
: new IlinkError('network-error', '暂时无法访问微信服务。', { cause: error });
|
|
207
|
+
} finally {
|
|
208
|
+
if (timer) clearTimeout(timer);
|
|
209
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 从入站消息里取文本(文本项或语音转写)。
|
|
215
|
+
*
|
|
216
|
+
* @param message - iLink 消息。
|
|
217
|
+
* @returns 文本,取不到时为 null。
|
|
218
|
+
*/
|
|
219
|
+
export function extractText(message) {
|
|
220
|
+
for (const item of message?.item_list ?? []) {
|
|
221
|
+
if (item?.type === 1 && typeof item.text_item?.text === 'string') {
|
|
222
|
+
const text = item.text_item.text.trim();
|
|
223
|
+
if (text) return text;
|
|
224
|
+
}
|
|
225
|
+
if (item?.type === 3 && typeof item.voice_item?.text === 'string') {
|
|
226
|
+
const text = item.voice_item.text.trim();
|
|
227
|
+
if (text) return text;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @returns 消息 id(缺 message_id 时用 client_id 兜底)。 */
|
|
234
|
+
export function messageId(message) {
|
|
235
|
+
if (message?.message_id !== undefined && message.message_id !== null) {
|
|
236
|
+
return String(message.message_id);
|
|
237
|
+
}
|
|
238
|
+
return nonEmptyString(message?.client_id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 按字符上限切分文本,优先在换行处断开。
|
|
243
|
+
*
|
|
244
|
+
* @param text - 回复正文。
|
|
245
|
+
* @param maxChars - 单条上限。
|
|
246
|
+
* @returns 段落数组。
|
|
247
|
+
*/
|
|
248
|
+
export function splitText(text, maxChars = MAX_MESSAGE_CHARS) {
|
|
249
|
+
if (text.length <= maxChars) return [text];
|
|
250
|
+
const chunks = [];
|
|
251
|
+
let remaining = text;
|
|
252
|
+
while (remaining.length > maxChars) {
|
|
253
|
+
let splitAt = remaining.lastIndexOf('\n', maxChars);
|
|
254
|
+
if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars;
|
|
255
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
256
|
+
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
|
|
257
|
+
}
|
|
258
|
+
if (remaining) chunks.push(remaining);
|
|
259
|
+
return chunks;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 发送一项媒体(图片或文件)。
|
|
264
|
+
*
|
|
265
|
+
* 三步:① `ilink/bot/getuploadurl` 拿上传地址(顺带把原始大小、MD5、填充后大小、
|
|
266
|
+
* AES 密钥报备给服务端)→ ② 加密上传到 CDN,拿回 `encrypt_query_param`
|
|
267
|
+
* → ③ `ilink/bot/sendmessage` 发一条引用该媒体的消息。
|
|
268
|
+
*
|
|
269
|
+
* @param fetchImpl - 注入的 fetch。
|
|
270
|
+
* @param request - { baseUrl, token, toUserId, bytes, contextToken, runId, signal }。
|
|
271
|
+
* @param options - { mediaType, buildItem }。
|
|
272
|
+
* @returns { providerMessageIds }。
|
|
273
|
+
*/
|
|
274
|
+
async function sendArtifact(fetchImpl, {
|
|
275
|
+
baseUrl, token, toUserId, bytes, contextToken, runId, signal,
|
|
276
|
+
}, { mediaType, buildItem }) {
|
|
277
|
+
const recipient = nonEmptyString(toUserId);
|
|
278
|
+
if (!recipient || !bytes?.byteLength) {
|
|
279
|
+
throw new TypeError('发送媒体需要 toUserId 与非空字节。');
|
|
280
|
+
}
|
|
281
|
+
signal?.throwIfAborted();
|
|
282
|
+
|
|
283
|
+
const fileKey = randomBytes(16).toString('hex');
|
|
284
|
+
const aesKey = randomBytes(16);
|
|
285
|
+
const ciphertextSize = aesEcbPaddedSize(bytes.byteLength);
|
|
286
|
+
const upload = await requestJson(fetchImpl, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
baseUrl,
|
|
289
|
+
endpoint: 'ilink/bot/getuploadurl',
|
|
290
|
+
token,
|
|
291
|
+
signal,
|
|
292
|
+
body: {
|
|
293
|
+
filekey: fileKey,
|
|
294
|
+
media_type: mediaType,
|
|
295
|
+
to_user_id: recipient,
|
|
296
|
+
rawsize: bytes.byteLength,
|
|
297
|
+
rawfilemd5: createHash('md5').update(bytes).digest('hex'),
|
|
298
|
+
filesize: ciphertextSize,
|
|
299
|
+
no_need_thumb: true,
|
|
300
|
+
aeskey: aesKey.toString('hex'),
|
|
301
|
+
base_info: baseInfo(),
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
const uploadRejection = rejectedResponse(upload);
|
|
305
|
+
if (uploadRejection) {
|
|
306
|
+
throw new IlinkError('upload-url-rejected', '微信服务拒绝了文件上传请求。', {
|
|
307
|
+
providerCode: uploadRejection,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const downloadParam = await uploadMediaToCdn({
|
|
312
|
+
url: mediaUploadUrl(upload, fileKey),
|
|
313
|
+
bytes,
|
|
314
|
+
key: aesKey,
|
|
315
|
+
signal,
|
|
316
|
+
fetchImpl,
|
|
317
|
+
});
|
|
318
|
+
const media = {
|
|
319
|
+
encrypt_query_param: downloadParam,
|
|
320
|
+
// 服务端要的是"十六进制字符串再做 base64",与入站解析保持一致。
|
|
321
|
+
aes_key: Buffer.from(aesKey.toString('hex'), 'utf8').toString('base64'),
|
|
322
|
+
encrypt_type: 1,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const clientId = `dsh-chat-weixin-${randomUUID()}`;
|
|
326
|
+
const response = await requestJson(fetchImpl, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
baseUrl,
|
|
329
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
330
|
+
token,
|
|
331
|
+
signal,
|
|
332
|
+
body: {
|
|
333
|
+
msg: {
|
|
334
|
+
from_user_id: '',
|
|
335
|
+
to_user_id: recipient,
|
|
336
|
+
client_id: clientId,
|
|
337
|
+
message_type: 2,
|
|
338
|
+
message_state: 2,
|
|
339
|
+
item_list: [buildItem({ media, ciphertextSize })],
|
|
340
|
+
...(nonEmptyString(contextToken) ? { context_token: contextToken } : {}),
|
|
341
|
+
...(nonEmptyString(runId) ? { run_id: runId } : {}),
|
|
342
|
+
},
|
|
343
|
+
base_info: baseInfo(),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
const sendRejection = rejectedResponse(response);
|
|
347
|
+
if (sendRejection) {
|
|
348
|
+
throw new IlinkError('send-rejected', '微信服务拒绝了文件消息。', { providerCode: sendRejection });
|
|
349
|
+
}
|
|
350
|
+
return { providerMessageIds: [clientId] };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 创建 iLink 客户端。
|
|
355
|
+
*
|
|
356
|
+
* @param options - { fetchImpl },测试可注入假 fetch。
|
|
357
|
+
* @returns 客户端 API。
|
|
358
|
+
*/
|
|
359
|
+
export function createIlinkClient({ fetchImpl = fetch } = {}) {
|
|
360
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('ilink 客户端需要 fetch。');
|
|
361
|
+
|
|
362
|
+
return Object.freeze({
|
|
363
|
+
/**
|
|
364
|
+
* 申请登录二维码。
|
|
365
|
+
*
|
|
366
|
+
* @param options - { localTokens, botType, signal }。
|
|
367
|
+
* @returns { qrcode, qrcodeUrl }。
|
|
368
|
+
*/
|
|
369
|
+
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
|
370
|
+
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
|
371
|
+
const response = await requestJson(fetchImpl, {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
baseUrl: DEFAULT_QR_BASE_URL,
|
|
374
|
+
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
|
|
375
|
+
body: { local_token_list: tokens },
|
|
376
|
+
timeoutMs: 10_000,
|
|
377
|
+
signal,
|
|
378
|
+
});
|
|
379
|
+
const rejection = rejectedResponse(response, ['errcode', 'ret']);
|
|
380
|
+
if (rejection) {
|
|
381
|
+
throw new IlinkError('qr-request-rejected', '微信服务拒绝了二维码申请。', {
|
|
382
|
+
providerCode: rejection,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const qrcode = nonEmptyString(response?.qrcode);
|
|
386
|
+
if (!qrcode) throw new IlinkError('invalid-qr', '微信服务没有返回二维码令牌。');
|
|
387
|
+
return { qrcode, qrcodeUrl: normalizeQrUrl(response?.qrcode_img_content) };
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 轮询扫码状态。
|
|
392
|
+
*
|
|
393
|
+
* @param options - { qrcode, baseUrl, verifyCode, signal }。
|
|
394
|
+
* @returns 服务端状态对象。
|
|
395
|
+
*/
|
|
396
|
+
async pollLogin({ qrcode, baseUrl = DEFAULT_QR_BASE_URL, verifyCode, signal }) {
|
|
397
|
+
const qr = nonEmptyString(qrcode);
|
|
398
|
+
if (!qr) throw new TypeError('pollLogin 需要 qrcode。');
|
|
399
|
+
let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr)}`;
|
|
400
|
+
const code = nonEmptyString(verifyCode);
|
|
401
|
+
if (code) endpoint += `&verify_code=${encodeURIComponent(code)}`;
|
|
402
|
+
const response = await requestJson(fetchImpl, {
|
|
403
|
+
method: 'GET',
|
|
404
|
+
baseUrl,
|
|
405
|
+
endpoint,
|
|
406
|
+
timeoutMs: LONG_POLL_TIMEOUT_MS,
|
|
407
|
+
signal,
|
|
408
|
+
authenticated: false,
|
|
409
|
+
});
|
|
410
|
+
if (!response || typeof response !== 'object' || !LOGIN_STATUSES.includes(response.status)) {
|
|
411
|
+
throw new IlinkError('invalid-login-status', '微信服务返回了无法识别的扫码状态。');
|
|
412
|
+
}
|
|
413
|
+
return response;
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 长轮询收取消息;超时视为"这一轮没有新消息"。
|
|
418
|
+
*
|
|
419
|
+
* @param options - { baseUrl, token, getUpdatesBuf, timeoutMs, signal }。
|
|
420
|
+
* @returns { ret, msgs, get_updates_buf }。
|
|
421
|
+
*/
|
|
422
|
+
async getUpdates({ baseUrl, token, getUpdatesBuf = '', timeoutMs, signal }) {
|
|
423
|
+
try {
|
|
424
|
+
return await requestJson(fetchImpl, {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
baseUrl,
|
|
427
|
+
endpoint: 'ilink/bot/getupdates',
|
|
428
|
+
body: { get_updates_buf: getUpdatesBuf, base_info: baseInfo() },
|
|
429
|
+
token,
|
|
430
|
+
timeoutMs: timeoutMs ?? LONG_POLL_TIMEOUT_MS,
|
|
431
|
+
signal,
|
|
432
|
+
});
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (error instanceof IlinkError && error.code === 'timeout') {
|
|
435
|
+
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf };
|
|
436
|
+
}
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 取该用户的机器人配置(主要是 typing_ticket)。
|
|
443
|
+
*
|
|
444
|
+
* @param options - { baseUrl, token, toUserId, contextToken, signal }。
|
|
445
|
+
* @returns { typingTicket }。
|
|
446
|
+
*/
|
|
447
|
+
async getConfig({ baseUrl, token, toUserId, contextToken, signal }) {
|
|
448
|
+
const recipient = nonEmptyString(toUserId);
|
|
449
|
+
if (!recipient) throw new TypeError('getConfig 需要 toUserId。');
|
|
450
|
+
const response = await requestJson(fetchImpl, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
baseUrl,
|
|
453
|
+
endpoint: 'ilink/bot/getconfig',
|
|
454
|
+
token,
|
|
455
|
+
signal,
|
|
456
|
+
timeoutMs: 10_000,
|
|
457
|
+
body: {
|
|
458
|
+
ilink_user_id: recipient,
|
|
459
|
+
...(nonEmptyString(contextToken) ? { context_token: contextToken } : {}),
|
|
460
|
+
base_info: baseInfo(),
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
if (response?.ret !== undefined && response.ret !== 0) {
|
|
464
|
+
throw new IlinkError('config-rejected', '微信服务拒绝了机器人配置请求。', {
|
|
465
|
+
providerCode: String(response.ret),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
return { typingTicket: nonEmptyString(response?.typing_ticket) };
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 发送/结束"正在输入"。
|
|
473
|
+
*
|
|
474
|
+
* @param options - { baseUrl, token, toUserId, typingTicket, status },status 1=开始 2=结束。
|
|
475
|
+
*/
|
|
476
|
+
async sendTyping({ baseUrl, token, toUserId, typingTicket, status, signal }) {
|
|
477
|
+
const recipient = nonEmptyString(toUserId);
|
|
478
|
+
const ticket = nonEmptyString(typingTicket);
|
|
479
|
+
if (!recipient || !ticket) throw new TypeError('sendTyping 需要 toUserId 与 typingTicket。');
|
|
480
|
+
if (status !== 1 && status !== 2) throw new TypeError('typing status 只能是 1 或 2。');
|
|
481
|
+
const response = await requestJson(fetchImpl, {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
baseUrl,
|
|
484
|
+
endpoint: 'ilink/bot/sendtyping',
|
|
485
|
+
token,
|
|
486
|
+
signal,
|
|
487
|
+
timeoutMs: 10_000,
|
|
488
|
+
body: {
|
|
489
|
+
ilink_user_id: recipient,
|
|
490
|
+
typing_ticket: ticket,
|
|
491
|
+
status,
|
|
492
|
+
base_info: baseInfo(),
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
if (response?.ret !== undefined && response.ret !== 0) {
|
|
496
|
+
throw new IlinkError('typing-rejected', '微信服务拒绝了输入状态请求。', {
|
|
497
|
+
providerCode: String(response.ret),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 发送一条文本消息。
|
|
505
|
+
*
|
|
506
|
+
* @param options - { baseUrl, token, toUserId, text, contextToken, runId, signal }。
|
|
507
|
+
* @returns { providerMessageIds }。
|
|
508
|
+
*/
|
|
509
|
+
async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) {
|
|
510
|
+
const recipient = nonEmptyString(toUserId);
|
|
511
|
+
const content = nonEmptyString(text);
|
|
512
|
+
if (!recipient || !content) throw new TypeError('sendText 需要 toUserId 与 text。');
|
|
513
|
+
const clientId = `dsh-chat-weixin-${randomUUID()}`;
|
|
514
|
+
const response = await requestJson(fetchImpl, {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
baseUrl,
|
|
517
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
518
|
+
token,
|
|
519
|
+
signal,
|
|
520
|
+
body: {
|
|
521
|
+
msg: {
|
|
522
|
+
from_user_id: '',
|
|
523
|
+
to_user_id: recipient,
|
|
524
|
+
client_id: clientId,
|
|
525
|
+
message_type: 2,
|
|
526
|
+
message_state: 2,
|
|
527
|
+
item_list: [{ type: 1, text_item: { text: content } }],
|
|
528
|
+
...(nonEmptyString(contextToken) ? { context_token: contextToken } : {}),
|
|
529
|
+
...(nonEmptyString(runId) ? { run_id: runId } : {}),
|
|
530
|
+
},
|
|
531
|
+
base_info: baseInfo(),
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
const rejection = rejectedResponse(response);
|
|
535
|
+
if (rejection) {
|
|
536
|
+
throw new IlinkError('send-rejected', '微信服务拒绝了回复消息。', { providerCode: rejection });
|
|
537
|
+
}
|
|
538
|
+
return { providerMessageIds: [clientId] };
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 发送一个文件(`file_item`)。
|
|
543
|
+
*
|
|
544
|
+
* @param options - { baseUrl, token, toUserId, fileName, bytes, contextToken, runId, signal }。
|
|
545
|
+
* @returns { providerMessageIds }。
|
|
546
|
+
*/
|
|
547
|
+
async sendFile({
|
|
548
|
+
baseUrl, token, toUserId, fileName, bytes, contextToken, runId, signal,
|
|
549
|
+
}) {
|
|
550
|
+
const name = nonEmptyString(fileName);
|
|
551
|
+
if (!name) throw new TypeError('sendFile 需要 fileName。');
|
|
552
|
+
return sendArtifact(fetchImpl, {
|
|
553
|
+
baseUrl, token, toUserId, bytes, contextToken, runId, signal,
|
|
554
|
+
}, {
|
|
555
|
+
mediaType: 3,
|
|
556
|
+
buildItem: ({ media }) => ({
|
|
557
|
+
type: 4,
|
|
558
|
+
file_item: { media, file_name: name, len: String(bytes.byteLength) },
|
|
559
|
+
}),
|
|
560
|
+
});
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 发送一张图片(`image_item`,聊天里显示为图片气泡)。
|
|
565
|
+
*
|
|
566
|
+
* @param options - { baseUrl, token, toUserId, bytes, contextToken, runId, signal }。
|
|
567
|
+
* @returns { providerMessageIds }。
|
|
568
|
+
*/
|
|
569
|
+
async sendImage({
|
|
570
|
+
baseUrl, token, toUserId, bytes, contextToken, runId, signal,
|
|
571
|
+
}) {
|
|
572
|
+
return sendArtifact(fetchImpl, {
|
|
573
|
+
baseUrl, token, toUserId, bytes, contextToken, runId, signal,
|
|
574
|
+
}, {
|
|
575
|
+
mediaType: 1,
|
|
576
|
+
buildItem: ({ media, ciphertextSize }) => ({
|
|
577
|
+
type: 2,
|
|
578
|
+
image_item: { media, mid_size: ciphertextSize },
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
/** 告诉服务端本机器人开始工作(连接建立时调用)。 */
|
|
584
|
+
async notifyStart({ baseUrl, token, signal }) {
|
|
585
|
+
const response = await requestJson(fetchImpl, {
|
|
586
|
+
method: 'POST',
|
|
587
|
+
baseUrl,
|
|
588
|
+
endpoint: 'ilink/bot/msg/notifystart',
|
|
589
|
+
token,
|
|
590
|
+
signal,
|
|
591
|
+
timeoutMs: 10_000,
|
|
592
|
+
body: { base_info: baseInfo() },
|
|
593
|
+
});
|
|
594
|
+
const rejection = rejectedResponse(response, ['errcode', 'ret']);
|
|
595
|
+
if (rejection) {
|
|
596
|
+
throw new IlinkError(
|
|
597
|
+
rejection === '-14' ? 'stale-token' : 'start-rejected',
|
|
598
|
+
rejection === '-14' ? '微信登录已失效,请重新扫码。' : '微信账号连接启动失败。',
|
|
599
|
+
{ providerCode: rejection },
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
return response;
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
/** 告诉服务端本机器人停止工作。 */
|
|
606
|
+
async notifyStop({ baseUrl, token, signal }) {
|
|
607
|
+
const response = await requestJson(fetchImpl, {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
baseUrl,
|
|
610
|
+
endpoint: 'ilink/bot/msg/notifystop',
|
|
611
|
+
token,
|
|
612
|
+
signal,
|
|
613
|
+
timeoutMs: 10_000,
|
|
614
|
+
body: { base_info: baseInfo() },
|
|
615
|
+
});
|
|
616
|
+
const rejection = rejectedResponse(response, ['errcode', 'ret']);
|
|
617
|
+
if (rejection) {
|
|
618
|
+
throw new IlinkError('stop-rejected', '微信服务未确认停止通知。', { providerCode: rejection });
|
|
619
|
+
}
|
|
620
|
+
return response;
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
package/host/index.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chat-weixin(host 侧):把微信渠道注册进 hub。
|
|
3
|
+
*
|
|
4
|
+
* 本包**不 import hub 包**,只依赖运行期契约(见仓库 CONTRACT.md)。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-chat-weixin/host
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createWeixinController } from './controller.mjs';
|
|
10
|
+
|
|
11
|
+
/** 渠道包版本:设置页的「版本与更新」面板用它,`npm run check` 会与 package.json 对账。 */
|
|
12
|
+
const CHANNEL_VERSION = '0.0.4';
|
|
13
|
+
|
|
14
|
+
export const name = 'dsh-chat-weixin-host';
|
|
15
|
+
|
|
16
|
+
export const inject = ['dshChat'];
|
|
17
|
+
|
|
18
|
+
const EXPECTED_CONTRACT = 1;
|
|
19
|
+
|
|
20
|
+
const CHANNEL_ID = 'weixin';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cordis host 插件入口。
|
|
24
|
+
*
|
|
25
|
+
* @param ctx - host 上下文。
|
|
26
|
+
*/
|
|
27
|
+
export function apply(ctx) {
|
|
28
|
+
const service = ctx.dshChat;
|
|
29
|
+
const actual = service?.contractVersion;
|
|
30
|
+
if (actual !== EXPECTED_CONTRACT) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`dsh-chat-weixin 需要 dsh-chat 契约 v${EXPECTED_CONTRACT},当前 hub 提供 v${String(actual)};`
|
|
33
|
+
+ '请升级 dsh-chat 或安装匹配版本的渠道插件(见 CONTRACT.md)。',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ctx.effect(() => service.registerChannel({
|
|
38
|
+
id: CHANNEL_ID,
|
|
39
|
+
label: '微信',
|
|
40
|
+
version: CHANNEL_VERSION,
|
|
41
|
+
order: 10,
|
|
42
|
+
legacy: { dir: 'dsh-weixin' },
|
|
43
|
+
async createChannel(deps) {
|
|
44
|
+
const controller = createWeixinController({ deps, logger: deps.logger });
|
|
45
|
+
void controller.start().catch((error) => {
|
|
46
|
+
deps.reportStatus('failed', error);
|
|
47
|
+
deps.logger.error?.(`[dsh-chat-weixin] 启动失败:${error?.message ?? error}`);
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
async stop() {
|
|
51
|
+
await controller.stop();
|
|
52
|
+
},
|
|
53
|
+
endpoints: controller.endpoints,
|
|
54
|
+
// hub 用它把"主动投递"接到该渠道上。
|
|
55
|
+
delivery: controller.delivery,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
}), 'dsh-chat-weixin: 注册渠道');
|
|
59
|
+
}
|