原来是想使用企业微信的机器人来集成 ChatGPT,但… 这玩意不支持收消息,只能推送消息,所以只能另寻他法。

思路

可以收消息的途径:企业应用。

可以发消息的:企业应用的推送,群机器人。

过程及重点代码

步骤如下:

新建企业应用

限制条件:必须是认证的企业,有认证过的相关域名。

新建一个企业微信的企业内部应用,并启用接收消息的 API 功能。

第一步必须有一个认证的过程,需要验证有效性,你必须把他的结果解密出来发给他。这个过程是他发送 GET 请求到你预定的 URL 的。

相关的验证代码如下:

const qiwei = {
  //计算签名,如果计算结果和他给的结果一致就是有效的
  computeSign: (token, timestamp, nonce, msg_encrypt) => {
    const tmpArr = [token, timestamp, nonce, msg_encrypt];
    return sha1(tmpArr.sort().join(''));
  },
  decode: (data, encodingAESKey) => {
    let aesKey = Buffer.from(encodingAESKey + '=', 'base64');
    let aesCipher = crypto.createDecipheriv("aes-256-cbc", aesKey, aesKey.subarray(0, 16));
    aesCipher.setAutoPadding(false);
    let decipheredBuff = Buffer.concat([aesCipher.update(data, 'base64'), aesCipher.final()]);
    decipheredBuff = PKCS7Decoder(decipheredBuff);
    let len_netOrder_corpid = decipheredBuff.subarray(16);
    let msg_len = len_netOrder_corpid.subarray(0, 4).readUInt32BE(0);
    const result = len_netOrder_corpid.subarray(4, msg_len + 4).toString();
    return result; 
  },
}


function sha1(str) {
  const md5sum = crypto.createHash('sha1');
  md5sum.update(str);
  const ciphertext = md5sum.digest('hex');
  return ciphertext;
}


function PKCS7Decoder(buff) {
  var pad = buff[buff.length - 1];
  if (pad < 1 || pad > 32) {
    pad = 0;
  }
  return buff.slice(0, buff.length - pad);
}


class QiWeiController extends Controller {

  async verifySignature() {
    const { ctx, app } = this;
    const { msg_signature, timestamp, nonce, echostr } = ctx.query;
    const { QIWEI_TOKEN, QIWEI_ENCODING_AES_KEY } = app.config;
    const mySign = computeSign(QIWEI_TOKEN, timestamp, nonce, echostr);
    if (mySign != msg_signature) {
      ctx.body = "Invalid signature.";
      return false;
    } else {
      const result = decode(echostr, QIWEI_ENCODING_AES_KEY);
      ctx.body = result;
    }
  }
}
  • 请注意,上述代码有可能并不在一个文件中。

接收消息

这个应用创建了之后,在客户端的工作台能看到这个应用,你可以和这个应用聊天,发给这个应用的消息都会被接受。发送的信息会被 Post 到你定义的 URL。

接收信息代码如下:

const qiwei = {
  decodeIncomingMsg: (encMsg, key) => {
    const ptToUserName = /<ToUserName><!\[CDATA\[(.*?)\]\]><\/ToUserName>/;
    const ptFromUserName = /<FromUserName><!\[CDATA\[(.*?)\]\]><\/FromUserName>/;
    const ptContent = /<Content><!\[CDATA\[(.*?)\]\]><\/Content>/;
    const realMsg = qiwei.decode(encMsg, key);

    const resMsg = {
      ToUserName: realMsg.match(ptToUserName)[1],
      FromUserName: realMsg.match(ptFromUserName)[1],
      Content: realMsg.match(ptContent)[1],
    };
    //这里可以修改到 MQ 中,并最终落盘
    qiwei.incomings.push(resMsg)
    return resMsg;
  },
}


class QiWeiController extends Controller {

  async incoming() {
    const { ctx, app } = this;
    const { msg_signature, timestamp, nonce } = ctx.query;
    const { QIWEI_TOKEN, QIWEI_ENCODING_AES_KEY } = app.config;
    const body = ctx.request.body;
    const ptEncMsg = /<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/;
    const found = body.match(ptEncMsg);
    if (found) {
      const mySign = computeSign(QIWEI_TOKEN, timestamp, nonce, found[1]);
      if (mySign == msg_signature) {
        decodeIncomingMsg(found[1], QIWEI_ENCODING_AES_KEY);
      }
    };
    ctx.body = "OK";
  }
}
  • 接收到信息之后,将信息解码出来,放到一个数组(队列) 中,供其他 deamon 方法调用。

请求 ChatGPT

使用了 chatpgt 这个 npm 包,调用非常简单。


const qiwei={

  chatdeamon: async (app, paimon) => {
    while (true) {
      const tarMsg = qiwei.incomings.shift();
      // tarMsg && console.log(tarMsg);
      if (tarMsg) {
        conversationId = tarMsg.ToUserName;
        let chatOpts = {
          timeoutMs: 2 * 60 * 1000,
          conversationId
        };
        if (qiwei.parentMessageId) {
          chatOpts.parentMessageId = parentMessageId;
        };
        console.log("输入:", tarMsg.Content, "堆积量:", qiwei.incomings.length);
        const result = await paimon.sendMessage(tarMsg.Content, chatOpts);
        parentMessageId = result.id;
        console.log("投送结果:", result.text);
        await qiwei.sendToPerson(app, result.text, tarMsg.FromUserName);
        await qiwei.paimonSendToRoom(app, tarMsg.Content, result.text, tarMsg.FromUserName);
      } else {
        await sleep(1000);
      }
    }
  },
}


function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

  • 写了死循环处理消息,这里 的 app 是 eggjs 的上下文的,用于取配置。
  • ChatGPT 的 API 经常限流,这里处理成了同步调用,一条一条往下进行。
  • 代码中 paimon 是一个 ChatGPT 实例。

分发消息

上面的代码可以看到拿到结果后分别分发到了个人和机器人。

发送到个人可以在直接在单聊对话中看到结果,同时可以让企业微信机器人分发到群里。

分发到个人的代码如下:

const qiwei = {
  getAccessToken: async (app) => {
    if (qiwei.access_token.hasOwnProperty("access_token") && qiwei.access_token.hasOwnProperty("expire_time")) {
      const expire_time = qiwei.access_token.expire_time;
      const current = Math.floor(Date.now() / 1000);
      const isExpire = expire_time < current ? true : false
      if (!isExpire) {
        return qiwei.access_token.access_token;
      }
    }

    let access_response = await app.curl(`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${app.config.QIWEI_CORP_ID}&corpsecret=${app.config.QIWEI_APP_SECRET}`, {
      dataType: 'json'
    });
    let { data: {
      access_token, expires_in
    } } = access_response;

    if (access_token) {
      qiwei.access_token = {
        access_token,
        expire_time: Math.floor(Date.now() / 1000) + expires_in
      };
      return access_token;
    } else {
      console.log(`获取 access_token 失败`);
      console.log(access_response.data.toString());
      return null;
    }
  },
  sendToPerson: async (app, a, to) => {
    const access_token = await qiwei.getAccessToken(app);
    const url = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${access_token}`;
    const msg = {
      touser: to,
      msgtype: "text",
      agentid: app.config.QIWEI_APP_AGENT_ID,
      text: {
        content: a
      }
    };

    const res = await app.curl(url, {
      method: 'POST',
      contentType: 'json',
      data: msg,
      dataType: 'json',
    });
    console.log("个人投送完成!");
  },
}

  • 直接调用 信息发送 API 完成。

使用群机器人完成发送

const qiwei = {

  paimonSendToRoom: async (app, q, a, to) => {
    const msg = {
      msgtype: "markdown",
      markdown: {
        content: `${to}: **${q}**
---
<font color="warning">${a}</font>`,
      }
    };
    await app.curl(app.config.QIWEI_BOT_WEBHOOK, {
      method: 'POST',
      contentType: 'json',
      data: msg,
      dataType: 'json',
    });
    console.log("机器人投送完成!");

  },
}
  • 这个 API 很简单,直接把消息组装好丢给 webhook 就行了。表扬一下这个 API。

总结一下

就不画时序图了。

整体的过程是:

  • 用户给企业微信 APP 发信息。
  • APP 收到信息丢入队列中,包含了文本,发送人等信息。
  • 另一个进程获取从队列中获取一个记录,调用 openai API 得到结果。
  • 使用企业微信的消息发送 API 将结果发给问问题的人。
  • 使用群机器人的 WebHook 将结果发送给相应的群。

效果图如下:左侧是和 APP 聊天,右侧是一个群聊机器人。

企业微信效果图

吐槽

1 隔壁的钉钉和飞书的机器人都能直接收发消息,为啥你不行。

2 企业微信的 API 设计,有 XML 的,有 Json 的,API 调用方式也是千奇百怪。收消息的那个签名验证搞那么复杂,示例和 SDK 还不全。

3 文档真乱,要到处去找。

4 哈哈,我的机器人名字叫 派萌/pimon。


参考链接:

https://developer.work.weixin.qq.com/document/path/91770

https://developer.work.weixin.qq.com/document/10514

https://www.npmjs.com/package/chatgpt

https://developer.work.weixin.qq.com/document/path/90236

https://github.com/WecomTeam/InnerAppCodeSample/tree/main/server