dsh-im-ops/src/channels/weixin/weixin-api.mjs

897 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID,
} from 'node:crypto';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
export const DEFAULT_BOT_TYPE = '3';
export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
export const DEFAULT_WEIXIN_MAX_MESSAGE_CHARS = 1_800;
const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
const ILINK_APP_ID = 'bot';
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const WEIXIN_CDN_UPLOAD_RETRIES = 3;
const WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT = 22n;
const WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
const WEIXIN_MESSAGE_ID_MAX_FUTURE_MS = 24 * 60 * 60 * 1_000;
const LOGIN_STATUSES = new Set([
'wait',
'scaned',
'confirmed',
'expired',
'scaned_but_redirect',
'need_verifycode',
'verify_code_blocked',
'binded_redirect',
]);
export class WeixinApiError extends Error {
constructor(code, message, options = {}) {
super(message, options);
this.name = 'WeixinApiError';
this.code = code;
this.status = options.status;
this.providerCode = options.providerCode;
}
}
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeProviderCode(value) {
const code = value === undefined || value === null ? null : String(value).trim();
return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
}
function preserveArtifactMetadata(target, source) {
if (Number.isInteger(source?.status)) target.status = source.status;
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
return target;
}
function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
if (cause?.code?.startsWith?.('artifact-')) return cause;
const status = Number(cause?.status);
const providerCode = safeProviderCode(cause?.providerCode);
const providerText = providerCode ?? '';
let code = fallback;
let message = 'Weixin could not prepare the file for delivery.';
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|| /(?:permission|forbidden|unauthor|access.?denied)/i.test(providerText)) {
code = 'artifact-permission-required';
message = 'Weixin denied permission to send the file.';
} else if (status === 413 || providerCode === '413'
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
code = 'artifact-too-large';
message = 'The file exceeds Weixin\'s size limit.';
} else if (status === 429 || providerCode === '429'
|| /(?:rate.?limit|too.?many)/i.test(providerText)) {
code = 'artifact-rate-limited';
message = 'Weixin rate-limited file delivery.';
} else if (fallback === 'artifact-provider-rejected') {
message = 'Weixin rejected the file message.';
}
const error = new Error(message, { cause });
error.code = code;
return preserveArtifactMetadata(error, cause);
}
function uncertainWeixinDelivery(cause) {
const error = new Error('Weixin file delivery result is uncertain', { cause });
error.code = 'artifact-delivery-uncertain';
return preserveArtifactMetadata(error, cause);
}
function rejectedProviderResponse(value) {
if (!value || typeof value !== 'object') return null;
for (const field of ['ret', 'errcode']) {
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
return safeProviderCode(value[field]) ?? 'rejected';
}
}
return null;
}
function classifyWeixinFinalDeliveryError(error, signal) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
if (error?.code === 'network-error' || error?.code === 'timeout'
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
return uncertainWeixinDelivery(error);
}
return weixinArtifactError(error);
}
function strictBase64(value) {
const text = nonEmptyString(value);
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
return Buffer.from(text, 'base64');
}
/** Parse the two AES key encodings used by Weixin iLink image messages. */
export function parseWeixinImageAesKey(imageItem) {
const directHex = nonEmptyString(imageItem?.aeskey);
if (directHex) {
if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
}
return Buffer.from(directHex, 'hex');
}
const encoded = strictBase64(imageItem?.media?.aes_key);
if (encoded?.length === 16) return encoded;
if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString('ascii'))) {
return Buffer.from(encoded.toString('ascii'), 'hex');
}
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
}
export function decryptWeixinImage(ciphertext, key) {
const encrypted = Buffer.from(ciphertext);
const aesKey = Buffer.from(key);
if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
throw new WeixinApiError('invalid-image-ciphertext', '微信图片的加密数据无效。');
}
try {
const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
} catch (error) {
throw new WeixinApiError('image-decryption-failed', '微信图片解密失败。', { cause: error });
}
}
export function weixinImageDownloadUrl(media) {
const query = nonEmptyString(media?.encrypt_query_param);
if (query) {
return `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
}
const fullUrl = nonEmptyString(media?.full_url);
if (!fullUrl) throw new WeixinApiError('missing-image-url', '微信图片没有可用的下载地址。');
let url;
try {
url = new URL(fullUrl);
} catch {
throw new WeixinApiError('invalid-image-url', '微信图片的下载地址无效。');
}
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|| (url.port && url.port !== '443') || !url.pathname.startsWith('/c2c/')) {
throw new WeixinApiError('untrusted-image-url', '微信图片的下载地址不受信任。');
}
url.username = '';
url.password = '';
url.hash = '';
return url.toString();
}
/** Convert iLink image items into lazily downloaded, decrypted image references. */
export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
const images = [];
for (const item of message?.item_list ?? []) {
const imageItem = item?.image_item;
if (!imageItem || typeof imageItem !== 'object') continue;
images.push({
name: images.length === 0 ? 'image' : `image-${images.length + 1}`,
load: async ({ signal, maxBytes }) => {
const key = parseWeixinImageAesKey(imageItem);
const url = weixinImageDownloadUrl(imageItem.media);
const ciphertext = await fetchImageBuffer(url, {
fetchImpl,
signal,
maxBytes: maxBytes + 16,
allowedHosts: [WEIXIN_CDN_HOST],
});
return decryptWeixinImage(ciphertext, key);
},
});
}
return images;
}
async function fetchWeixinFileCiphertext(url, { fetchImpl, signal }) {
const response = await fetchImpl(new URL(url), {
method: 'GET',
signal,
redirect: 'manual',
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new WeixinApiError(
'file-download-failed',
`微信文件下载失败(HTTP ${response?.status ?? 'unknown'})。`,
{ status: response?.status },
);
}
return Buffer.from(await response.arrayBuffer());
}
/** Convert native iLink file items into lazily downloaded, decrypted file references. */
export function extractWeixinFiles(message, { fetchImpl = fetch } = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
const files = [];
for (const item of message?.item_list ?? []) {
const fileItem = item?.file_item;
if (!fileItem || typeof fileItem !== 'object') continue;
const declaredSize = Number(fileItem.len);
files.push({
name: nonEmptyString(fileItem.file_name) ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
load: async ({ signal } = {}) => {
signal?.throwIfAborted();
const key = parseWeixinImageAesKey(fileItem);
const url = weixinImageDownloadUrl(fileItem.media);
const ciphertext = await fetchWeixinFileCiphertext(url, { fetchImpl, signal });
signal?.throwIfAborted();
return decryptWeixinImage(ciphertext, key);
},
});
}
return files;
}
function isWeixinHost(hostname) {
const normalized = hostname.toLowerCase().replace(/\.$/, '');
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com')
|| normalized === 'wechat.com' || normalized.endsWith('.wechat.com');
}
export function normalizeWeixinApiBaseUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new WeixinApiError('invalid-base-url', '微信服务返回了无效的连接地址。');
}
if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)
|| (url.port !== '' && url.port !== '443')) {
throw new WeixinApiError('untrusted-base-url', '微信服务返回了不受信任的连接地址。');
}
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
if (!url.pathname.endsWith('/')) url.pathname += '/';
return url.toString();
}
export function normalizeWeixinQrUrl(value) {
const text = nonEmptyString(value);
if (!text) throw new WeixinApiError('invalid-qr', '微信服务没有返回扫码地址。');
let url;
try {
url = new URL(text);
} catch {
throw new WeixinApiError('invalid-qr', '微信服务返回了无效的扫码地址。');
}
if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)) {
throw new WeixinApiError('untrusted-qr', '微信服务返回了不受信任的扫码地址。');
}
return url.toString();
}
function commonHeaders() {
return {
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': String(ILINK_CLIENT_VERSION),
};
}
function authenticatedHeaders(token) {
const headers = {
...commonHeaders(),
'content-type': 'application/json',
AuthorizationType: 'ilink_bot_token',
'X-WECHAT-UIN': Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf8').toString('base64'),
};
if (nonEmptyString(token)) headers.Authorization = `Bearer ${token.trim()}`;
return headers;
}
function baseInfo() {
return {
channel_version: WEIXIN_PROTOCOL_VERSION,
bot_agent: 'DeepSeekHarness/1.1.0',
};
}
function aesEcbPaddedSize(size) {
return Math.ceil((size + 1) / 16) * 16;
}
function trustedWeixinCdnUploadUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new WeixinApiError('invalid-upload-url', '微信服务返回了无效的文件上传地址。');
}
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|| (url.port && url.port !== '443') || url.pathname !== '/c2c/upload'
|| url.username || url.password) {
throw new WeixinApiError('untrusted-upload-url', '微信服务返回了不受信任的文件上传地址。');
}
url.hash = '';
return url;
}
function weixinCdnUploadUrl(response, fileKey) {
const fullUrl = nonEmptyString(response?.upload_full_url);
if (fullUrl) return trustedWeixinCdnUploadUrl(fullUrl);
const uploadParam = nonEmptyString(response?.upload_param);
if (!uploadParam) {
throw new WeixinApiError('missing-upload-url', '微信服务没有返回文件上传地址。');
}
const url = new URL(`${WEIXIN_CDN_BASE_URL}/upload`);
url.searchParams.set('encrypted_query_param', uploadParam);
url.searchParams.set('filekey', fileKey);
return trustedWeixinCdnUploadUrl(url);
}
function encryptWeixinUpload(bytes, key) {
const cipher = createCipheriv('aes-128-ecb', key, null);
return Buffer.concat([cipher.update(bytes), cipher.final()]);
}
async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
let lastError;
for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
signal?.throwIfAborted();
try {
const response = await fetchImpl(url, {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body: ciphertext,
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000),
redirect: 'error',
});
if (response.status >= 400 && response.status < 500) {
throw new WeixinApiError(
'upload-rejected',
`微信文件上传被拒绝(HTTP ${response.status})。`,
{ status: response.status },
);
}
if (response.status !== 200) {
throw new WeixinApiError(
'upload-failed',
`微信文件上传失败(HTTP ${response.status})。`,
{ status: response.status },
);
}
const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
await response.body?.cancel?.().catch(() => undefined);
if (!downloadParam) {
throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
}
return downloadParam;
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (error instanceof WeixinApiError
&& (error.code === 'upload-rejected' || error.status < 500)) throw error;
lastError = error;
}
}
if (lastError instanceof WeixinApiError) throw lastError;
throw new WeixinApiError('upload-failed', '微信文件上传失败。', { cause: lastError });
}
function abortError(signal) {
if (signal?.reason instanceof Error) return signal.reason;
return new DOMException('The operation was aborted', 'AbortError');
}
async function requestJson(fetchImpl, {
method,
baseUrl,
endpoint,
body,
token,
timeoutMs = DEFAULT_TIMEOUT_MS,
signal,
authenticated = true,
}) {
const trustedBase = normalizeWeixinApiBaseUrl(baseUrl);
const url = new URL(endpoint, trustedBase);
if (!isWeixinHost(url.hostname)) {
throw new WeixinApiError('untrusted-endpoint', '拒绝访问不受信任的微信服务地址。');
}
const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort(signal?.reason);
if (signal?.aborted) throw abortError(signal);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = timeoutMs > 0 ? setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs) : null;
try {
const response = await fetchImpl(url, {
method,
headers: authenticated ? authenticatedHeaders(token) : commonHeaders(),
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: controller.signal,
});
if (!response.ok) {
throw new WeixinApiError(
'http-error',
`微信服务请求失败(HTTP ${response.status})。`,
{ status: response.status },
);
}
try {
return await response.json();
} catch (error) {
throw new WeixinApiError('invalid-response', '微信服务返回了无法解析的响应。', { cause: error });
}
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (timedOut) {
throw new WeixinApiError('timeout', '微信服务请求超时。', { cause: error });
}
if (error instanceof WeixinApiError) throw error;
throw new WeixinApiError('network-error', '暂时无法访问微信服务。', { cause: error });
} finally {
if (timer) clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
function validateLoginResponse(value) {
if (!value || typeof value !== 'object' || !LOGIN_STATUSES.has(value.status)) {
throw new WeixinApiError('invalid-login-status', '微信服务返回了无法识别的扫码状态。');
}
return value;
}
export function createWeixinApi({ fetchImpl = fetch } = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
async function sendArtifact({
baseUrl,
token,
toUserId,
file,
contextToken,
runId,
signal,
}, { mediaType, createItem }) {
const recipient = nonEmptyString(toUserId);
if (!recipient || !file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('toUserId and a file are required');
}
signal?.throwIfAborted();
const fileKey = randomBytes(16).toString('hex');
const aesKey = randomBytes(16);
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
let upload;
try {
upload = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/getuploadurl',
token,
signal,
body: {
filekey: fileKey,
media_type: mediaType,
to_user_id: recipient,
rawsize: file.bytes.byteLength,
rawfilemd5: rawMd5,
filesize: aesEcbPaddedSize(file.bytes.byteLength),
no_need_thumb: true,
aeskey: aesKey.toString('hex'),
base_info: baseInfo(),
},
});
} catch (error) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
? 'artifact-provider-rejected'
: 'artifact-provider-failed';
throw weixinArtifactError(error, { fallback });
}
const uploadRejection = rejectedProviderResponse(upload);
if (uploadRejection) {
throw weixinArtifactError(new WeixinApiError(
'upload-url-rejected',
'微信服务拒绝了文件上传请求。',
{ providerCode: uploadRejection },
));
}
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
let downloadParam;
try {
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
} catch (error) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
? 'artifact-provider-rejected'
: 'artifact-provider-failed';
throw weixinArtifactError(error, { fallback });
}
signal?.throwIfAborted();
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
?? randomUUID();
const clientIdSeed = mediaType === 3 ? deliverySeed : `${deliverySeed}\u0000${mediaType}`;
const clientId = `dsh-weixin-${createHash('sha256')
.update(clientIdSeed)
.digest('hex')
.slice(0, 32)}`;
const media = {
encrypt_query_param: downloadParam,
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
encrypt_type: 1,
};
let response;
try {
response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/sendmessage',
token,
signal,
body: {
msg: {
from_user_id: '',
to_user_id: recipient,
client_id: clientId,
message_type: 2,
message_state: 2,
item_list: [createItem({ file, media, ciphertextSize: ciphertext.byteLength })],
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
},
base_info: baseInfo(),
},
});
} catch (error) {
throw classifyWeixinFinalDeliveryError(error, signal);
}
const sendRejection = rejectedProviderResponse(response);
if (sendRejection) {
throw weixinArtifactError(new WeixinApiError(
'send-rejected',
'微信服务拒绝了文件消息。',
{ providerCode: sendRejection },
));
}
return { messageId: clientId };
}
return Object.freeze({
inboundImages(message) {
return extractWeixinImages(message, { fetchImpl });
},
inboundFiles(message) {
return extractWeixinFiles(message, { fetchImpl });
},
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
const response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl: WEIXIN_QR_BASE_URL,
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
body: { local_token_list: tokens },
timeoutMs: 10_000,
signal,
});
const qrcode = nonEmptyString(response?.qrcode);
if (!qrcode) throw new WeixinApiError('invalid-qr', '微信服务没有返回二维码令牌。');
return {
qrcode,
qrcodeUrl: normalizeWeixinQrUrl(response.qrcode_img_content),
};
},
async pollLogin({ qrcode, baseUrl = WEIXIN_QR_BASE_URL, verifyCode, signal }) {
const qr = nonEmptyString(qrcode);
if (!qr) throw new TypeError('qrcode is required');
let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr)}`;
if (nonEmptyString(verifyCode)) endpoint += `&verify_code=${encodeURIComponent(verifyCode.trim())}`;
const response = await requestJson(fetchImpl, {
method: 'GET',
baseUrl,
endpoint,
timeoutMs: DEFAULT_LONG_POLL_TIMEOUT_MS,
signal,
authenticated: false,
});
return validateLoginResponse(response);
},
async getUpdates({ baseUrl, token, getUpdatesBuf = '', timeoutMs, signal }) {
try {
return await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/getupdates',
body: { get_updates_buf: getUpdatesBuf, base_info: baseInfo() },
token,
timeoutMs: timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS,
signal,
});
} catch (error) {
if (error instanceof WeixinApiError && error.code === 'timeout') {
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf };
}
throw error;
}
},
async getConfig({ baseUrl, token, toUserId, contextToken, signal }) {
const recipient = nonEmptyString(toUserId);
if (!recipient) throw new TypeError('toUserId is required');
const response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/getconfig',
token,
signal,
timeoutMs: 10_000,
body: {
ilink_user_id: recipient,
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
base_info: baseInfo(),
},
});
if (response?.ret !== undefined && response.ret !== 0) {
throw new WeixinApiError('config-rejected', '微信服务拒绝了机器人配置请求。');
}
return { typingTicket: nonEmptyString(response?.typing_ticket) };
},
async sendTyping({ baseUrl, token, toUserId, typingTicket, status, signal }) {
const recipient = nonEmptyString(toUserId);
const ticket = nonEmptyString(typingTicket);
if (!recipient || !ticket) throw new TypeError('toUserId and typingTicket are required');
if (status !== 1 && status !== 2) throw new TypeError('typing status must be 1 or 2');
const response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/sendtyping',
token,
signal,
timeoutMs: 10_000,
body: {
ilink_user_id: recipient,
typing_ticket: ticket,
status,
base_info: baseInfo(),
},
});
if (response?.ret !== undefined && response.ret !== 0) {
throw new WeixinApiError('typing-rejected', '微信服务拒绝了输入状态请求。');
}
return true;
},
async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) {
const recipient = nonEmptyString(toUserId);
const content = nonEmptyString(text);
if (!recipient || !content) throw new TypeError('toUserId and text are required');
const clientId = `dsh-weixin-${randomUUID()}`;
const response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/sendmessage',
token,
signal,
body: {
msg: {
from_user_id: '',
to_user_id: recipient,
client_id: clientId,
message_type: 2,
message_state: 2,
item_list: [{ type: 1, text_item: { text: content } }],
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
},
base_info: baseInfo(),
},
});
const sendRejection = rejectedProviderResponse(response);
if (sendRejection) {
throw new WeixinApiError(
'send-rejected',
'微信服务拒绝了回复消息。',
{ providerCode: sendRejection },
);
}
return {
...(response && typeof response === 'object' ? response : {}),
providerMessageIds: [clientId],
};
},
async sendFile(request) {
return sendArtifact(request, {
mediaType: 3,
createItem: ({ file, media }) => ({
type: 4,
file_item: {
media,
file_name: file.fileName,
len: String(file.bytes.byteLength),
},
}),
});
},
async sendImage(request) {
return sendArtifact(request, {
mediaType: 1,
createItem: ({ media, ciphertextSize }) => ({
type: 2,
image_item: {
media,
mid_size: ciphertextSize,
},
}),
});
},
async notifyStart({ baseUrl, token, signal }) {
const response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/msg/notifystart',
token,
signal,
timeoutMs: 10_000,
body: { base_info: baseInfo() },
});
if (response?.ret !== undefined && response.ret !== 0) {
throw new WeixinApiError('start-rejected', '微信账号连接启动失败。');
}
return response;
},
async notifyStop({ baseUrl, token, signal }) {
return requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/msg/notifystop',
token,
signal,
timeoutMs: 10_000,
body: { base_info: baseInfo() },
});
},
});
}
export function extractWeixinText(message) {
for (const item of message?.item_list ?? []) {
if (item?.type === 1 && typeof item.text_item?.text === 'string') {
const text = item.text_item.text.trim();
if (text) return text;
}
if (item?.type === 3 && typeof item.voice_item?.text === 'string') {
const text = item.voice_item.text.trim();
if (text) return text;
}
}
return null;
}
function weixinReplyAttachment(item) {
if (item?.type === 2 || (item?.image_item && typeof item.image_item === 'object')) {
return { kind: 'image' };
}
if (item?.type === 3 || (item?.voice_item && typeof item.voice_item === 'object')) {
return { kind: 'audio' };
}
if (item?.type === 4 || (item?.file_item && typeof item.file_item === 'object')) {
const name = nonEmptyString(item?.file_item?.file_name);
return { kind: 'file', ...(name ? { name } : {}) };
}
if (item?.type === 5 || (item?.video_item && typeof item.video_item === 'object')) {
return { kind: 'video' };
}
return null;
}
function weixinQuotedText(item) {
const text = nonEmptyString(item?.text_item?.text);
if (text) return text;
return nonEmptyString(item?.voice_item?.text);
}
/** Extract the one-level reply snapshot embedded in an inbound iLink message item. */
export function extractWeixinReplyReference(message, { resolveContent, loadContent } = {}) {
const container = (message?.item_list ?? []).find((item) => (
item?.ref_msg && typeof item.ref_msg === 'object'
));
if (!container) return null;
const ref = container.ref_msg;
const quotedItem = ref.message_item && typeof ref.message_item === 'object'
? ref.message_item
: null;
const quotedText = weixinQuotedText(quotedItem);
let content = quotedText ?? nonEmptyString(ref.title);
const attachment = weixinReplyAttachment(quotedItem);
const messageId = quotedItem?.msg_id === undefined || quotedItem?.msg_id === null
? null
: nonEmptyString(String(quotedItem.msg_id));
const referenceDetails = {
messageId,
createTimeMs: quotedItem?.create_time_ms ?? ref.create_time_ms,
updateTimeMs: quotedItem?.update_time_ms ?? ref.update_time_ms,
};
if (!content && !attachment && typeof resolveContent === 'function') {
content = nonEmptyString(resolveContent(referenceDetails));
}
const load = !content && !attachment && typeof loadContent === 'function'
? (options) => loadContent(referenceDetails, options)
: null;
const knownType = quotedItem && [1, 2, 3, 4, 5, 8].includes(quotedItem.type);
return {
...(messageId ? { messageId } : {}),
...(content ? { content } : {}),
...(attachment ? { attachments: [attachment] } : {}),
...(load ? { load } : {}),
...(!content && !attachment && !load
? { unavailableReason: quotedItem && !knownType ? 'unsupported' : 'not-delivered' }
: {}),
};
}
/** Decode the millisecond timestamp carried by current 64-bit iLink message IDs. */
export function weixinMessageTimestampMs(messageId, { now = Date.now() } = {}) {
const value = messageId === undefined || messageId === null ? '' : String(messageId).trim();
if (!/^\d{16,20}$/u.test(value)) return null;
try {
const timestampMs = Number(BigInt(value) >> WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT);
if (!Number.isSafeInteger(timestampMs)
|| timestampMs < WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS
|| timestampMs > now + WEIXIN_MESSAGE_ID_MAX_FUTURE_MS) return null;
return timestampMs;
} catch {
return null;
}
}
export function weixinMessageId(message) {
if (message?.message_id !== undefined && message.message_id !== null) {
return String(message.message_id);
}
return nonEmptyString(message?.client_id);
}
export function splitWeixinText(text, maxChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS) {
if (text.length <= maxChars) return [text];
const chunks = [];
let remaining = text;
while (remaining.length > maxChars) {
let splitAt = remaining.lastIndexOf('\n', maxChars);
if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
}
if (remaining) chunks.push(remaining);
return chunks;
}