码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/lib/ilink.js
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;