码桶
发现社区成员的开源项目
ilink.js12.4 KB
/**
* iLink 协议封装 — 对应 PHP lib/ilink.php
* 使用 axios 替代 curl,API 接口完全一致
*/
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const config = require('../config');
class ILink {
/**
* @param {object} bot - bots 表行数据
*/
constructor(bot) {
this.bot = bot;
}
/** API 基址 */
base() {
return this.bot.base_url || config.ilink_base;
}
/** 生成随机 X-WECHAT-UIN */
uinHeader() {
const u = crypto.randomInt(0, 0xffffffff).toString();
return Buffer.from(u).toString('base64');
}
/** 构建请求头 */
headers(token = null) {
const h = {
'Content-Type': 'application/json',
'AuthorizationType': 'ilink_bot_token',
'X-WECHAT-UIN': this.uinHeader(),
};
if (token) {
h['Authorization'] = `Bearer ${token}`;
}
return h;
}
/** 请求体基础结构 */
body(extra = {}) {
return {
base_info: { channel_version: config.channel_version },
...extra,
};
}
/** POST 请求 */
async post(path, payload, token = null, base = null, timeout = 40) {
const url = (base || this.base()) + path;
try {
const res = await axios.post(url, payload, {
headers: this.headers(token),
timeout: (timeout + 5) * 1000,
});
const data = res.data;
// 补 ret 字段
if (!('ret' in data) && 'get_updates_buf' in data) {
data.ret = 0;
}
// 缺 ret/errcode 字段时补 ret:0(含空对象 {})。
// iLink 对 sendmessage(含媒体消息)有时返回 {},实际已送达。
// 之前跳过 {} 是为了防静默丢弃,但现均已配 client_id,{} 可视为成功。
if (!('ret' in data) && !('errcode' in data)) {
data.ret = 0;
}
return data;
} catch (err) {
if (err.response) {
return { ret: -2, http_code: err.response.status, raw: JSON.stringify(err.response.data) };
}
return { ret: -1, error: err.message };
}
}
/** GET 请求 */
async get(path, base = null, timeout = 40) {
const url = (base || this.base()) + path;
try {
const res = await axios.get(url, {
headers: this.headers(),
timeout: (timeout + 5) * 1000,
});
return res.data;
} catch (err) {
if (err.response) {
return { ret: -2, http_code: err.response.status, raw: JSON.stringify(err.response.data) };
}
return { ret: -1, error: err.message };
}
}
// ========== iLink API 方法 ==========
/** 获取登录二维码 */
async getQrcode() {
return this.get('/ilink/bot/get_bot_qrcode?bot_type=3');
}
/** 轮询扫码状态 */
async getQrcodeStatus(qrcode) {
const r = await this.get('/ilink/bot/get_qrcode_status?qrcode=' + encodeURIComponent(qrcode), null, 20);
if (!r || (!r.status && !r.data)) return r;
if (r.status) return r; // 已扁平
// 嵌套结构转扁平
const d = r.data;
const cred = d.credentials || {};
return {
status: d.status || null,
bot_token: cred.bot_token || null,
baseurl: d.baseurl || null,
};
}
/** 长轮询拉取消息 */
async getUpdates(buf, token, timeout = 40) {
return this.post('/ilink/bot/getupdates', this.body({ get_updates_buf: String(buf) }), token, null, timeout);
}
/** 机器人自身 id(bot_token 中 ":" 之前的部分) */
botUserId() {
const t = this.bot.bot_token || '';
return String(t.split(':')[0] || t);
}
/** 生成唯一 client_id(参照官方 openclaw-weixin:前缀 + 随机串,服务端用于去重/路由)
* 缺失 client_id 时 sendmessage 会返回 {}(HTTP 200)但静默丢弃消息——这是"第二条收不到"的根因 */
clientId() {
return 'ilink-bot-' + crypto.randomUUID().replace(/-/g, '').slice(0, 24);
}
/** 发送文本消息 */
async sendMessage(toUserId, contextToken, text) {
const token = this.bot.bot_token;
const payload = this.body({
msg: {
to_user_id: String(toUserId),
client_id: this.clientId(),
message_type: 2,
message_state: 2,
context_token: String(contextToken),
item_list: [{ type: 1, text_item: { text } }],
},
});
return this.post('/ilink/bot/sendmessage', payload, token);
}
// ========== 加密 CDN 上传管线(对齐官方 openclaw-weixin) ==========
// iLink 上传不是简单 multipart:需先 getuploadurl 拿地址 → AES-128-ECB 加密 →
// 上传密文到 CDN → 用返回的 encrypt_query_param + aes_key 作为 sendmessage 的 media
// 引用(普通 url 会被服务端静默丢弃,这是"多媒体发不出去"的真正根因)。
// media_type 映射(UploadMediaType):image=1 video=2 file=3 voice=4
/** AES-128-ECB 加密(PKCS7 填充,16 字节块) */
encryptAesEcb(plaintext, key) {
const c = crypto.createCipheriv('aes-128-ecb', key, null);
c.setAutoPadding(true);
return Buffer.concat([c.update(plaintext), c.final()]);
}
/** AES-128-ECB 密文长度(按 16 字节对齐的 PKCS7 填充) */
aesEcbPaddedSize(n) {
return Math.ceil((n + 1) / 16) * 16;
}
/** 由上传结果构造 sendmessage 的 media 引用(对齐官方 WeChat-iLinkBot 文档) */
buildMedia(desc) {
return {
encrypt_query_param: desc.downloadEncryptedQueryParam,
// 关键:aes_key = base64( hex字符串 的 UTF-8 字节 ),与官方文档一致。
// iLink 服务端按 hex 字符串匹配 getuploadurl 时上报的 aeskey;
// 若用 Buffer.from(hex,'hex') 会把 hex 解出原始字节再 base64,服务端无法匹配
// → 微信端解密失败、图片/文件收不到(但 sendmessage 仍返回 ret:0 假成功)。
aes_key: Buffer.from(desc.aeskeyHex, 'utf-8').toString('base64'),
encrypt_type: 1,
};
}
/** 完整上传管线:读文件→哈希→生成 aeskey→getuploadurl→加密上传 CDN→返回媒体描述 */
async uploadMediaToCdn(filePath, mediaType, toUserId) {
const token = this.bot.bot_token;
const plaintext = fs.readFileSync(filePath);
const rawsize = plaintext.length;
const rawfilemd5 = crypto.createHash('md5').update(plaintext).digest('hex');
const filesize = this.aesEcbPaddedSize(rawsize);
const filekey = crypto.randomBytes(16).toString('hex');
const aeskey = crypto.randomBytes(16); // 16 字节 = AES-128
const cdnBase = config.ilink_cdn || 'https://novac2c.cdn.weixin.qq.com/c2c';
// 1) 申请上传地址
const upBody = {
filekey,
media_type: mediaType,
to_user_id: String(toUserId),
rawsize,
rawfilemd5,
filesize,
aeskey: aeskey.toString('hex'),
};
// 官方文档:no_need_thumb 仅图片(media_type=1)需要,文件/视频不传
if (mediaType === 1) upBody.no_need_thumb = true;
const up = await this.post('/ilink/bot/getuploadurl', this.body(upBody), token);
const uploadFullUrl = (up.upload_full_url || '').trim();
const uploadParam = (up.upload_param || '').trim();
if (!uploadFullUrl && !uploadParam) {
throw new Error('getuploadurl 未返回上传地址: ' + JSON.stringify(up));
}
// 2) AES-128-ECB 加密后上传密文到 CDN
const ciphertext = this.encryptAesEcb(plaintext, aeskey);
// 官方文档:优先用 upload_param 拼接 CDN 上传地址
const cdnUrl = uploadParam
? `${cdnBase}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`
: uploadFullUrl;
let downloadParam = null;
try {
const cdnResp = await axios.post(cdnUrl, ciphertext, {
headers: { 'Content-Type': 'application/octet-stream' },
timeout: 60000,
maxBodyLength: Infinity,
});
downloadParam = cdnResp.headers['x-encrypted-param'] || null;
} catch (err) {
if (err.response) {
const em = err.response.headers['x-error-message'] || JSON.stringify(err.response.data);
throw new Error('CDN 上传失败 ' + err.response.status + ': ' + em);
}
throw new Error('CDN 上传网络错误: ' + err.message);
}
if (!downloadParam) throw new Error('CDN 上传响应缺少 x-encrypted-param 头');
return {
filekey,
downloadEncryptedQueryParam: downloadParam,
aeskeyHex: aeskey.toString('hex'),
fileSize: rawsize,
fileSizeCiphertext: filesize,
};
}
/** 发送图片消息(mediaDesc = uploadMediaToCdn 的返回值)
* 官方文档 image_item 仅需 media 对象,不需要 mid_size/thumb_media。 */
async sendImage(toUserId, contextToken, mediaDesc) {
const token = this.bot.bot_token;
return this.post('/ilink/bot/sendmessage', this.body({
msg: {
to_user_id: String(toUserId),
client_id: this.clientId(),
message_type: 2,
message_state: 2,
context_token: String(contextToken),
item_list: [{ type: 2, image_item: { media: this.buildMedia(mediaDesc) } }],
},
}), token);
}
/** 发送文件消息 */
async sendFile(toUserId, contextToken, mediaDesc, filename = '') {
const token = this.bot.bot_token;
const fileItem = { media: this.buildMedia(mediaDesc), file_name: filename, len: String(mediaDesc.fileSize) };
return this.post('/ilink/bot/sendmessage', this.body({
msg: {
to_user_id: String(toUserId),
client_id: this.clientId(),
message_type: 2,
message_state: 2,
context_token: String(contextToken),
item_list: [{ type: 4, file_item: fileItem }],
},
}), token);
}
/** 发送视频消息 */
async sendVideo(toUserId, contextToken, mediaDesc) {
const token = this.bot.bot_token;
return this.post('/ilink/bot/sendmessage', this.body({
msg: {
to_user_id: String(toUserId),
client_id: this.clientId(),
message_type: 2,
message_state: 2,
context_token: String(contextToken),
item_list: [{ type: 5, video_item: { media: this.buildMedia(mediaDesc), video_size: mediaDesc.fileSizeCiphertext } }],
},
}), token);
}
/** 发送语音消息(type=3,encode_type=5 即 AMR,微信原生语音编码)
* playtime 必须>0(秒),否则微信静默丢弃(sendmessage 仍 ret:0 假成功)。
* len 与 image/file 一致带上文件大小,避免服务端无法渲染语音气泡。 */
async sendVoice(toUserId, contextToken, mediaDesc, playtime = 0) {
const token = this.bot.bot_token;
// 官方 VoiceItem.playtime 单位是毫秒(非秒)。之前误传秒导致微信按毫秒理解成极短语音而拒绝渲染。
const voiceItem = {
media: this.buildMedia(mediaDesc),
encode_type: 6,
playtime: playtime > 0 ? Math.round(playtime * 1000) : 1000,
len: String(mediaDesc.fileSize || (mediaDesc.fileSizeCiphertext || 0)),
sample_rate: 24000,
bits_per_sample: 16,
};
const resp = await this.post('/ilink/bot/sendmessage', this.body({
msg: {
to_user_id: String(toUserId),
client_id: this.clientId(),
message_type: 2,
message_state: 2,
context_token: String(contextToken),
item_list: [{
type: 3,
voice_item: voiceItem,
}],
},
}), token);
console.log('[ilink][voice-debug] encode_type=5 playtime=', voiceItem.playtime, 'len=', voiceItem.len,
'hasMedia=', !!voiceItem.media.encrypt_query_param,
'resp=', JSON.stringify(resp).slice(0, 600));
return resp;
}
/** 获取 typing 票据(GetConfigResp.typing_ticket),需带 ilink_user_id */
async getConfig(ilinkUserId, contextToken) {
return this.post('/ilink/bot/getconfig', this.body({
ilink_user_id: ilinkUserId,
context_token: contextToken || undefined,
}), this.bot.bot_token);
}
/** 发送输入状态:status 1=输入中 2=取消。注意官方端点为 sendtyping(带 g) */
async sendTyping(ticket, status, ilinkUserId) {
return this.post('/ilink/bot/sendtyping', this.body({
ilink_user_id: ilinkUserId || this.botUserId(),
typing_ticket: ticket,
status,
}), this.bot.bot_token);
}
}
module.exports = ILink;