feat: add WeChat QR code login and AGP WebSocket channel plugin
- Auth module: WeChat OAuth2 scan-to-login flow with terminal QR code - Token persistence to ~/.openclaw/wechat-access-auth.json (chmod 600) - Token resolution: config > saved state > interactive login - Invite code verification (configurable bypass) - Production/test environment support - AGP WebSocket client with heartbeat, reconnect, wake detection - Message handler: Agent dispatch with streaming text and tool calls - Random device GUID generation (persisted, no real machine ID)
This commit is contained in:
278
http/webhook.ts
Normal file
278
http/webhook.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { FuwuhaoMessage, SimpleAccount } from "./types.js";
|
||||
import { verifySignature, decryptMessage } from "./crypto-utils.js";
|
||||
import { parseQuery, readBody, isFuwuhaoWebhookPath } from "./http-utils.js";
|
||||
import { handleMessage, handleMessageStream } from "./message-handler.js";
|
||||
|
||||
// ============================================
|
||||
// 账号配置
|
||||
// ============================================
|
||||
// 微信服务号的账号配置信息
|
||||
// 生产环境应从环境变量或配置文件中读取
|
||||
|
||||
/**
|
||||
* 模拟账号存储
|
||||
* @description
|
||||
* 生产环境建议:
|
||||
* 1. 从环境变量读取:process.env.FUWUHAO_TOKEN 等
|
||||
* 2. 从配置文件读取:config.json
|
||||
* 3. 从数据库读取:支持多账号场景
|
||||
* 4. 使用密钥管理服务:如 AWS Secrets Manager
|
||||
*/
|
||||
const mockAccount: SimpleAccount = {
|
||||
token: "your_token_here", // 微信服务号配置的 Token
|
||||
encodingAESKey: "your_encoding_aes_key_here", // 消息加密密钥(43位字符)
|
||||
receiveId: "your_receive_id_here" // 服务号的原始 ID
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Webhook 处理器(主入口)
|
||||
// ============================================
|
||||
/**
|
||||
* 处理微信服务号的 Webhook 请求
|
||||
* @param req - Node.js HTTP 请求对象
|
||||
* @param res - Node.js HTTP 响应对象
|
||||
* @returns Promise<boolean> 是否处理了此请求(true=已处理,false=交给其他处理器)
|
||||
* @description
|
||||
* 此函数是微信服务号集成的主入口,负责:
|
||||
* 1. 路径匹配:检查是否是服务号 webhook 路径
|
||||
* 2. GET 请求:处理 URL 验证(微信服务器验证)
|
||||
* 3. POST 请求:处理用户消息
|
||||
* - 支持加密消息(验证签名 + 解密)
|
||||
* - 支持明文消息(测试用)
|
||||
* - 支持同步返回和流式返回(SSE)
|
||||
*
|
||||
* 请求流程:
|
||||
* - GET /wechat-access?signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx
|
||||
* → 验证签名 → 解密 echostr → 返回明文
|
||||
* - POST /wechat-access (同步)
|
||||
* → 验证签名 → 解密消息 → 调用 Agent → 返回 JSON
|
||||
* - POST /wechat-access?stream=true (流式)
|
||||
* → 验证签名 → 解密消息 → 调用 Agent → 返回 SSE 流
|
||||
*/
|
||||
export const handleSimpleWecomWebhook = async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
): Promise<boolean> => {
|
||||
// ============================================
|
||||
// 1. 路径匹配检查
|
||||
// ============================================
|
||||
// 检查请求路径是否匹配服务号 webhook 路径
|
||||
// 支持:/wechat-access、/wechat-access/webhook、/wechat-access/*
|
||||
if (!isFuwuhaoWebhookPath(req.url || "")) {
|
||||
return false; // 不是我们的路径,交给其他处理器
|
||||
}
|
||||
|
||||
console.log(`[wechat-access] 收到请求: ${req.method} ${req.url}`);
|
||||
|
||||
try {
|
||||
// ============================================
|
||||
// 2. 解析查询参数
|
||||
// ============================================
|
||||
// 微信服务器会在 URL 中附加验证参数
|
||||
const query = parseQuery(req);
|
||||
const timestamp = query.get("timestamp") || ""; // 时间戳
|
||||
const nonce = query.get("nonce") || ""; // 随机数
|
||||
const signature = query.get("msg_signature") || query.get("signature") || ""; // 签名
|
||||
|
||||
// ============================================
|
||||
// 3. 处理 GET 请求 - URL 验证
|
||||
// ============================================
|
||||
// 微信服务器在配置 webhook 时会发送 GET 请求验证 URL
|
||||
// 请求格式:GET /wechat-access?signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx
|
||||
if (req.method === "GET") {
|
||||
const echostr = query.get("echostr") || "";
|
||||
|
||||
// 验证签名(确保请求来自微信服务器)
|
||||
const isValid = verifySignature({
|
||||
token: mockAccount.token,
|
||||
timestamp,
|
||||
nonce,
|
||||
encrypt: echostr,
|
||||
signature
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("签名验证失败");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 解密 echostr 并返回(微信服务器会验证返回值)
|
||||
try {
|
||||
const decrypted = decryptMessage({
|
||||
encodingAESKey: mockAccount.encodingAESKey,
|
||||
receiveId: mockAccount.receiveId,
|
||||
encrypt: echostr
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(decrypted);
|
||||
return true;
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("解密失败");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. 处理 POST 请求 - 用户消息
|
||||
// ============================================
|
||||
// 微信服务器会将用户发送的消息通过 POST 请求转发过来
|
||||
// 请求格式:POST /wechat-access?signature=xxx×tamp=xxx&nonce=xxx
|
||||
// 请求体:加密的 JSON 或 XML 格式消息
|
||||
if (req.method === "POST") {
|
||||
// 读取请求体
|
||||
const body = await readBody(req);
|
||||
|
||||
let message: FuwuhaoMessage;
|
||||
|
||||
// ============================================
|
||||
// 4.1 解析和解密消息
|
||||
// ============================================
|
||||
// 尝试解析 JSON 格式
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const encrypt = data.encrypt || data.Encrypt || "";
|
||||
|
||||
if (encrypt) {
|
||||
// ============================================
|
||||
// 加密消息处理流程
|
||||
// ============================================
|
||||
// 1. 验证签名(确保消息来自微信服务器)
|
||||
const isValid = verifySignature({
|
||||
token: mockAccount.token,
|
||||
timestamp,
|
||||
nonce,
|
||||
encrypt,
|
||||
signature
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("签名验证失败");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 解密消息
|
||||
const decrypted = decryptMessage({
|
||||
encodingAESKey: mockAccount.encodingAESKey,
|
||||
receiveId: mockAccount.receiveId,
|
||||
encrypt
|
||||
});
|
||||
message = JSON.parse(decrypted);
|
||||
} else {
|
||||
// ============================================
|
||||
// 明文消息(用于测试)
|
||||
// ============================================
|
||||
// 直接使用 JSON 数据,无需解密
|
||||
message = data;
|
||||
}
|
||||
} catch {
|
||||
// ============================================
|
||||
// XML 格式处理(简化版)
|
||||
// ============================================
|
||||
// 可能是 XML 格式,简单处理
|
||||
console.log("[wechat-access] 收到非JSON格式数据,尝试简单解析");
|
||||
message = {
|
||||
msgtype: "text",
|
||||
Content: body,
|
||||
FromUserName: "unknown",
|
||||
MsgId: `${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4.2 检查是否请求流式返回(SSE)
|
||||
// ============================================
|
||||
// 客户端可以通过以下方式请求流式返回:
|
||||
// 1. Accept: text/event-stream header
|
||||
// 2. ?stream=true 查询参数
|
||||
// 3. ?stream=1 查询参数
|
||||
const acceptHeader = req.headers.accept || "";
|
||||
const wantsStream = acceptHeader.includes("text/event-stream") ||
|
||||
query.get("stream") === "true" ||
|
||||
query.get("stream") === "1";
|
||||
console.log('adam-sssss-markoint===wantsStreamwantsStreamwantsStream', wantsStream)
|
||||
if (wantsStream) {
|
||||
// ============================================
|
||||
// 流式返回模式(Server-Sent Events)
|
||||
// ============================================
|
||||
// SSE 是一种服务器向客户端推送实时数据的技术
|
||||
// 适用于:实时显示 AI 生成过程、工具调用状态等
|
||||
console.log("[wechat-access] 使用流式返回模式 (SSE)");
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); // SSE 标准格式
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform"); // 禁用缓存
|
||||
res.setHeader("Connection", "keep-alive"); // 保持连接
|
||||
res.setHeader("X-Accel-Buffering", "no"); // 禁用 nginx 缓冲
|
||||
res.setHeader("Access-Control-Allow-Origin", "*"); // 允许跨域
|
||||
res.flushHeaders(); // 立即发送 headers,建立 SSE 连接
|
||||
|
||||
// 发送初始连接确认事件
|
||||
const connectedEvent = `data: ${JSON.stringify({ type: "connected", timestamp: Date.now() })}\n\n`;
|
||||
console.log("[wechat-access] SSE 发送连接确认:", connectedEvent.trim());
|
||||
res.write(connectedEvent);
|
||||
|
||||
try {
|
||||
// 调用流式消息处理器
|
||||
// handleMessageStream 会通过回调函数实时推送数据
|
||||
await handleMessageStream(message, (chunk) => {
|
||||
// SSE 数据格式:data: {JSON}\n\n
|
||||
const sseData = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
console.log("[wechat-access] SSE 发送数据:", chunk.type, chunk.text?.slice(0, 50));
|
||||
res.write(sseData);
|
||||
|
||||
// 如果是完成或错误,关闭连接
|
||||
if (chunk.type === "done" || chunk.type === "error") {
|
||||
console.log("[wechat-access] SSE 连接关闭:", chunk.type);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
} catch (streamErr) {
|
||||
// 流式处理异常,发送错误事件
|
||||
console.error("[wechat-access] SSE 流式处理异常:", streamErr);
|
||||
const errorData = `data: ${JSON.stringify({ type: "error", text: String(streamErr), timestamp: Date.now() })}\n\n`;
|
||||
res.write(errorData);
|
||||
res.end();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4.3 普通同步返回模式
|
||||
// ============================================
|
||||
// 等待 Agent 处理完成后一次性返回结果
|
||||
// 适用于:简单问答、不需要实时反馈的场景
|
||||
const reply = await handleMessage(message);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
reply: reply || "消息已接收,正在处理中..."
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// ============================================
|
||||
// 5. 异常处理
|
||||
// ============================================
|
||||
// 捕获所有未处理的异常,返回 500 错误
|
||||
console.error("[wechat-access] Webhook 处理异常:", error);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("服务器内部错误");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user