朝花夕拾

A Development Engineer, a Life Liver, a Hope Holder

准备工作

  1. 微信公众号,这个无需多说,网上有比较多的教程,大家可以按照教程自行注册
  2. laf服务,可以通过laf平台购买服务,当然由于laf本身是开源项目,我们也可以自己搭建私有服务(注意,国内无法访问OpenAI,所以自建服务需要走代理或者直接使用国外服务器)。
    本教程使用laf平台服务laf

成品效果


开搞

登录laf平台,注册账号,申请(购买)应用。对于新注册用户,laf支持免费申请一个app,有效期是一个月。

点击开发进入函数编辑窗口,首先添加chatgpt等相关的依赖,具体操作如下

之后,创建一个云函数,名称随意即可。因为我是给微信工作号开发接口,所以我这里起名为wechat。在函数编辑区贴入如下代码

import * as crypto from "crypto";
import cloud from '@lafjs/cloud'
import { create } from 'xmlbuilder2'

// 加密校验微信token
function verifySignature(signature, timestamp, nonce, token) {
const arr = [token, timestamp, nonce].sort();
const str = arr.join('');
const sha1 = crypto.createHash('sha1');
sha1.update(str);
const calsignature = sha1.digest('hex');
return calsignature === signature;
}

/**
* 处理微信公众号的消息接收和回复
* @param ctx 请求上下文
* @returns 回复内容,字符串或XML格式
*/
export async function main(ctx: FunctionContext) {
console.log(ctx)
// 参数校验
if (!ctx || !ctx.body) {
return "Invalid event";
}
const { signature, timestamp, nonce, echostr } = ctx.query;
const token = "公众号TOKEN";
// 这个token与下边微信公众号中设置一致
if (!verifySignature(signature, timestamp, nonce, token)) {
// 验证失败
return "Invalid signature";
}

if (echostr) {
return echostr;
}

// 接收参数
const { fromusername, tousername, content, msgtype } = ctx.body.xml;

// 判断消息类型
if (msgtype[0] === 'text') {
// 文本消息
if (content[0]) {
try {
const { ChatGPTAPI } = await import('chatgpt')
// 创建ChatGPTAPI实例
const api = new ChatGPTAPI({ apiKey: cloud.env.CHAT_GPT_API_KEY })// 这个apikey要从openai官网获取
// 发送消息并获取回复
const response = await api.sendMessage(content[0])
const message = response.text.trim();
console.log(message)
const noSpaceStr = message.replace(/ /g, "\t");
// 构造回复的XML对象
const xmlObj = {
xml: {
ToUserName: { '#text': fromusername[0] },
FromUserName: { '#text': tousername[0] },
CreateTime: { '#text': new Date().getTime() },
MsgType: { '#text': 'text' },
Content: { '#text': message }
}
};
// 转换为XML字符串并返回
const xmlStr = create(xmlObj).end({ prettyPrint: true });
return xmlStr;
} catch (error) {
// 处理异常
console.error(error);
return "Sorry, something went wrong.";
}
}
} else {
// 其他消息类型,暂不处理
return "OK";
}
}

代码详解

鉴权函数

// 加密校验微信token
function verifySignature(signature, timestamp, nonce, token) {
const arr = [token, timestamp, nonce].sort();
const str = arr.join('');
const sha1 = crypto.createHash('sha1');
sha1.update(str);
const calsignature = sha1.digest('hex');
return calsignature === signature;
}

这个函数主要用来作微信鉴权和服务器地址有效性校验,根据公众号开发文档,微信公众号校验字符串的生成算法是:
1)将token、timestamp、nonce三个参数进行字典序排序
2)将三个参数字符串拼接成一个字符串进行sha1加密
校验规则是: 公众号传入的校验字符串与本地生成的校验字符串必须保持一致。这样上面的代码逻辑就比较清晰了。

函数上下文对象

export async function main(ctx: FunctionContext) {

}

通过阅读laf源码,可以看到FunctionContext对象定义如下:

/**
* ctx passed to function
*/
export interface FunctionContext {
files?: File[]
headers?: IncomingHttpHeaders
query?: any,
body?: any,
params?: any,
auth?: any,
requestId?: string,
method?: string,
response?: Response,
__function_name?: string
}

这个上下文的构造方式为:

const ctx: FunctionContext = {
query: req.query,
files: req.files as any,
body: req.body,
headers: req.headers,
method: isTrigger ? 'trigger' : req.method,
auth: req['auth'],
user: req.user,
requestId,
request: req,
response: res,
__function_name: func.name,
}

可以看到,上下文中的大部分属性都是通过req构造出来的,那么req是什么?我们继续往下挖

// 执行云函数 laf/runtimes/nodejs/src/handler/invoke-func.ts 
export async function handleInvokeFunction(req: IRequest, res: Response) {
// intercept the request, skip websocket request
if (false === req.method.startsWith('WebSocket:')) {
const passed = await invokeInterceptor(req, res)
if (passed === false) return
}

// laf/runtimes/nodejs/src/support/types.ts
import { Request } from 'express'
export interface IRequest extends Request {
user?: any
requestId?: string
[key: string]: any
}

如上所示,这个req本质上来源于express框架封装的请求对象。Express中的Request对象是一个表示HTTP请求的对象,它包含了请求的查询字符串,参数,内容,HTTP头部等属性
request对象有一些常用的属性和方法,例如:
req.app:访问express的实例。
req.baseUrl:获取路由当前安装的URL路径。
req.body:获取请求体。
req.cookies:获取请求中的cookie。
req.hostname:获取主机名。
req.method:获取请求方法(GET, POST等。
req.params:获取路由参数。
req.query:获取查询字符串参数。
req.url:获取请求的URL。
req.get(field):获取指定的HTTP请求头。
req.param(name):获取命名的路由参数或查询字符串参数。

其中对于req.query,指的是获取请求URL中的参数,例如:

// GET /search?q=tobi+ferret
console.dir(req.query.q)
// => 'tobi ferret'

回到云函数主体

const { signature, timestamp, nonce, echostr } = ctx.query;
const { fromusername, tousername, content, msgtype } = ctx.body.xml;

通过上面我们知道,ctx.query来自于公众号请求url中的附带参数,ctx.body是公众号请求过来的实际数据。那么参考公众号开发文档。服务器收到公众号的消息体格式如下:

<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[你好]]></Content>
</xml>

从上面分析也可以得到,微信公众号在与其他服务器交互时,鉴权信息会通过url带入,数据信息通过body带入。

调用openAI获取响应

// 创建ChatGPTAPI实例
const api = new ChatGPTAPI({ apiKey: cloud.env.CHAT_GPT_API_KEY })// 这个apikey要从openai官网获取
// 发送消息并获取回复
const response = await api.sendMessage(content[0])
const message = response.text.trim();

响应公众号

// 构造回复的XML对象
const xmlObj = {
xml: {
ToUserName: { '#text': fromusername[0] },
FromUserName: { '#text': tousername[0] },
CreateTime: { '#text': new Date().getTime() },
MsgType: { '#text': 'text' },
Content: { '#text': message }
}
};
// 转换为XML字符串并返回
const xmlStr = create(xmlObj).end({ prettyPrint: true });
return xmlStr;

服务部署和测试

  1. 发布laf函数
  2. 启用公众号服务器配置
  3. 到微信公众号发送消息,查看公众号是否可以正常响应

大家如果不想折腾,也可以关注我的公众号,上手体验一下效果