@henryxiaoyang/wechat-access-unqclawed 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/auth/device-guid.ts +43 -0
- package/auth/environments.ts +29 -0
- package/auth/index.ts +19 -0
- package/auth/qclaw-api.ts +129 -0
- package/auth/state-store.ts +48 -0
- package/auth/types.ts +57 -0
- package/auth/utils.ts +14 -0
- package/auth/wechat-login.ts +238 -0
- package/common/agent-events.ts +49 -0
- package/common/message-context.ts +174 -0
- package/common/runtime.ts +35 -0
- package/http/README.md +138 -0
- package/http/callback-service.ts +73 -0
- package/http/crypto-utils.ts +96 -0
- package/http/http-utils.ts +81 -0
- package/http/index.ts +59 -0
- package/http/message-context.ts +4 -0
- package/http/message-handler.ts +560 -0
- package/http/types.ts +148 -0
- package/http/webhook.ts +278 -0
- package/index.ts +236 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +59 -0
- package/websocket/index.ts +40 -0
- package/websocket/message-adapter.ts +116 -0
- package/websocket/message-handler.ts +612 -0
- package/websocket/types.ts +290 -0
- package/websocket/websocket-client.ts +739 -0
- package/websocket.md +273 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { onAgentEvent as OnAgentEventType } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
|
|
4
|
+
|
|
5
|
+
export type AgentEventPayload = {
|
|
6
|
+
runId: string;
|
|
7
|
+
seq: number;
|
|
8
|
+
stream: AgentEventStream;
|
|
9
|
+
ts: number;
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
sessionKey?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// 动态导入,兼容 openclaw 未导出该函数的情况
|
|
15
|
+
let _onAgentEvent: typeof OnAgentEventType | undefined;
|
|
16
|
+
|
|
17
|
+
// SDK 加载完成的 Promise,确保只加载一次
|
|
18
|
+
const sdkReady: Promise<typeof OnAgentEventType | undefined> = (async () => {
|
|
19
|
+
try {
|
|
20
|
+
const sdk = await import("openclaw/plugin-sdk");
|
|
21
|
+
if (typeof sdk.onAgentEvent === "function") {
|
|
22
|
+
_onAgentEvent = sdk.onAgentEvent;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore
|
|
26
|
+
}
|
|
27
|
+
return _onAgentEvent;
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 注册 Agent 事件监听器。
|
|
32
|
+
*
|
|
33
|
+
* 修复了原版的时序问题:原版使用 loadOnAgentEvent().then() 异步注册,
|
|
34
|
+
* 导致在 dispatchReplyWithBufferedBlockDispatcher 调用之前注册的监听器
|
|
35
|
+
* 实际上在 Agent 开始产生事件时还未真正挂载,造成事件全部丢失。
|
|
36
|
+
*
|
|
37
|
+
* 新版通过 await sdkReady 确保 SDK 加载完成后再注册监听器,
|
|
38
|
+
* 调用方需要 await 此函数返回的 Promise,再调用 dispatchReply。
|
|
39
|
+
*/
|
|
40
|
+
export const onAgentEvent = async (
|
|
41
|
+
listener: Parameters<typeof OnAgentEventType>[0]
|
|
42
|
+
): Promise<() => boolean> => {
|
|
43
|
+
const fn = await sdkReady;
|
|
44
|
+
if (fn) {
|
|
45
|
+
const unsubscribe = fn(listener);
|
|
46
|
+
return unsubscribe;
|
|
47
|
+
}
|
|
48
|
+
return () => false;
|
|
49
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { FuwuhaoMessage } from "../http/types.js";
|
|
2
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
3
|
+
|
|
4
|
+
// ============================================
|
|
5
|
+
// 渠道来源标签
|
|
6
|
+
// ============================================
|
|
7
|
+
// 用于 ChannelSource,标识消息来自哪个微信渠道
|
|
8
|
+
// UI 侧可通过此字段区分不同来源,做差异化展示或交互限制
|
|
9
|
+
export const WECHAT_CHANNEL_LABELS = {
|
|
10
|
+
/** 微信服务号 */
|
|
11
|
+
serviceAccount: "serviceAccount",
|
|
12
|
+
/** 微信小程序 */
|
|
13
|
+
miniProgram: "miniProgram",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// ============================================
|
|
17
|
+
// 消息上下文构建
|
|
18
|
+
// ============================================
|
|
19
|
+
// 将微信服务号的原始消息转换为 OpenClaw 标准的消息上下文
|
|
20
|
+
// 包括路由解析、会话管理、消息格式化等核心功能
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 消息上下文返回类型
|
|
24
|
+
* @property ctx - OpenClaw 标准的消息上下文对象,包含所有必要的消息元数据
|
|
25
|
+
* @property route - 路由信息,用于确定消息应该发送到哪个 Agent
|
|
26
|
+
* @property storePath - 会话存储路径,用于持久化会话数据
|
|
27
|
+
*/
|
|
28
|
+
export interface MessageContext {
|
|
29
|
+
ctx: Record<string, unknown>;
|
|
30
|
+
route: {
|
|
31
|
+
sessionKey: string; // 会话唯一标识,用于关联同一用户的多轮对话
|
|
32
|
+
agentId: string; // Agent ID,标识处理此消息的 Agent
|
|
33
|
+
accountId: string; // 账号 ID,用于多账号场景
|
|
34
|
+
};
|
|
35
|
+
storePath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 构建消息上下文
|
|
40
|
+
* @param message - 微信服务号的原始消息对象
|
|
41
|
+
* @returns MessageContext 包含上下文、路由和存储路径的完整消息上下文
|
|
42
|
+
* @description
|
|
43
|
+
* 此函数是消息处理的核心,负责:
|
|
44
|
+
* 1. 提取和标准化消息字段(兼容多种格式)
|
|
45
|
+
* 2. 解析路由,确定消息应该发送到哪个 Agent
|
|
46
|
+
* 3. 获取会话存储路径,用于持久化对话历史
|
|
47
|
+
* 4. 格式化消息为 OpenClaw 标准格式
|
|
48
|
+
* 5. 构建完整的消息上下文对象
|
|
49
|
+
*
|
|
50
|
+
* 内部流程:
|
|
51
|
+
* - 从 runtime 获取配置
|
|
52
|
+
* - 提取用户 ID、消息 ID、内容等关键信息
|
|
53
|
+
* - 调用 routing.resolveAgentRoute 解析路由
|
|
54
|
+
* - 调用 session.resolveStorePath 获取存储路径
|
|
55
|
+
* - 调用 reply.formatInboundEnvelope 格式化消息
|
|
56
|
+
* - 调用 reply.finalizeInboundContext 构建最终上下文
|
|
57
|
+
*/
|
|
58
|
+
export const buildMessageContext = (message: FuwuhaoMessage): MessageContext => {
|
|
59
|
+
// 获取 OpenClaw 运行时实例
|
|
60
|
+
const runtime = getWecomRuntime();
|
|
61
|
+
// 加载全局配置(包含 Agent 配置、路由规则等)
|
|
62
|
+
const cfg = runtime.config.loadConfig();
|
|
63
|
+
|
|
64
|
+
// ============================================
|
|
65
|
+
// 1. 提取和标准化消息字段
|
|
66
|
+
// ============================================
|
|
67
|
+
// 兼容多种字段命名(FromUserName/userid)
|
|
68
|
+
const userId = message.FromUserName || message.userid || "unknown";
|
|
69
|
+
const toUser = message.ToUserName || "unknown";
|
|
70
|
+
// 确保消息 ID 唯一(用于去重和追踪)
|
|
71
|
+
const messageId = message.MsgId || message.msgid || `${Date.now()}`;
|
|
72
|
+
// TODO: 微信的 CreateTime 是秒级时间戳,需要转换为毫秒
|
|
73
|
+
// const timestamp = message.CreateTime ? message.CreateTime * 1000 : Date.now();
|
|
74
|
+
const timestamp = Date.now();
|
|
75
|
+
// 提取消息内容(兼容 Content 和 text.content 两种格式)
|
|
76
|
+
const content = message.Content || message.text?.content || "";
|
|
77
|
+
|
|
78
|
+
// ============================================
|
|
79
|
+
// 2. 解析路由 - 确定消息应该发送到哪个 Agent
|
|
80
|
+
// ============================================
|
|
81
|
+
// runtime.channel.routing.resolveAgentRoute 是 OpenClaw 的核心路由方法
|
|
82
|
+
// 根据频道、账号、对话类型等信息,决定使用哪个 Agent 处理消息
|
|
83
|
+
const frameworkRoute = runtime.channel.routing.resolveAgentRoute({
|
|
84
|
+
cfg, // 全局配置
|
|
85
|
+
channel: "wechat-access", // 频道标识
|
|
86
|
+
accountId: "default", // 账号 ID(支持多账号场景)
|
|
87
|
+
peer: {
|
|
88
|
+
kind: "dm", // 对话类型:dm=私聊,group=群聊
|
|
89
|
+
id: userId, // 对话对象 ID(用户 ID)
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
// 框架返回的 sessionKey 通常是 agent:main:main,与 PC 端默认 session 相同。
|
|
93
|
+
// 为了让 UI 能区分外部渠道消息,使用独立的 sessionKey 格式:
|
|
94
|
+
// agent:{agentId}:wechat-access:direct:{userId}
|
|
95
|
+
const channelSessionKey = `agent:${frameworkRoute.agentId}:wechat-access:direct:${userId}`;
|
|
96
|
+
const route = {
|
|
97
|
+
...frameworkRoute,
|
|
98
|
+
sessionKey: channelSessionKey,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ============================================
|
|
102
|
+
// 3. 获取消息格式化选项
|
|
103
|
+
// ============================================
|
|
104
|
+
// runtime.channel.reply.resolveEnvelopeFormatOptions 获取消息格式化配置
|
|
105
|
+
// 包括时间格式、前缀、后缀等显示选项
|
|
106
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
107
|
+
|
|
108
|
+
// ============================================
|
|
109
|
+
// 4. 获取会话存储路径
|
|
110
|
+
// ============================================
|
|
111
|
+
// runtime.channel.session.resolveStorePath 计算会话数据的存储路径
|
|
112
|
+
// 用于持久化对话历史、上下文等信息
|
|
113
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
114
|
+
agentId: route.agentId,
|
|
115
|
+
});
|
|
116
|
+
// 存储路径通常类似:/data/sessions/{agentId}/{sessionKey}.json
|
|
117
|
+
|
|
118
|
+
// ============================================
|
|
119
|
+
// 5. 读取上次会话时间
|
|
120
|
+
// ============================================
|
|
121
|
+
// runtime.channel.session.readSessionUpdatedAt 读取上次会话的更新时间
|
|
122
|
+
// 用于判断会话是否过期,是否需要重置上下文
|
|
123
|
+
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
|
124
|
+
storePath,
|
|
125
|
+
sessionKey: route.sessionKey,
|
|
126
|
+
});
|
|
127
|
+
// 如果距离上次会话时间过长,可能会清空历史上下文
|
|
128
|
+
|
|
129
|
+
// ============================================
|
|
130
|
+
// 6. 格式化入站消息
|
|
131
|
+
// ============================================
|
|
132
|
+
// runtime.channel.reply.formatInboundEnvelope 将原始消息格式化为标准格式
|
|
133
|
+
// 添加时间戳、发送者信息、格式化选项等
|
|
134
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
135
|
+
channel: "wechat-access", // 频道标识
|
|
136
|
+
from: userId, // 发送者 ID
|
|
137
|
+
timestamp, // 消息时间戳
|
|
138
|
+
body: content, // 消息内容
|
|
139
|
+
chatType: "direct", // 对话类型(direct=私聊)
|
|
140
|
+
sender: {
|
|
141
|
+
id: userId, // 发送者 ID
|
|
142
|
+
},
|
|
143
|
+
previousTimestamp, // 上次会话时间(用于判断是否需要添加时间分隔符)
|
|
144
|
+
envelope: envelopeOptions, // 格式化选项
|
|
145
|
+
});
|
|
146
|
+
// 返回格式化后的消息体,可能包含时间前缀、发送者名称等
|
|
147
|
+
|
|
148
|
+
// ============================================
|
|
149
|
+
// 7. 构建完整的消息上下文
|
|
150
|
+
// ============================================
|
|
151
|
+
// runtime.channel.reply.finalizeInboundContext 构建 OpenClaw 标准的消息上下文
|
|
152
|
+
// 这是 Agent 处理消息时使用的核心数据结构
|
|
153
|
+
const ctx = runtime.channel.reply.finalizeInboundContext({
|
|
154
|
+
Body: body, // 格式化后的消息体
|
|
155
|
+
RawBody: content, // 原始消息内容
|
|
156
|
+
CommandBody: content, // 命令体(用于解析命令)
|
|
157
|
+
From: `wechat-access:${userId}`, // 发送者标识(带频道前缀)
|
|
158
|
+
To: `wechat-access:${toUser}`, // 接收者标识
|
|
159
|
+
SessionKey: route.sessionKey, // 会话键
|
|
160
|
+
AccountId: route.accountId, // 账号 ID
|
|
161
|
+
ChatType: "direct" as const, // 对话类型
|
|
162
|
+
ChannelSource: WECHAT_CHANNEL_LABELS.serviceAccount, // 渠道来源标识(用于 UI 侧区分消息来源)
|
|
163
|
+
SenderId: userId, // 发送者 ID
|
|
164
|
+
Provider: "wechat-access", // 提供商标识
|
|
165
|
+
Surface: "wechat-access", // 界面标识
|
|
166
|
+
MessageSid: messageId, // 消息唯一标识
|
|
167
|
+
Timestamp: timestamp, // 时间戳
|
|
168
|
+
OriginatingChannel: "wechat-access" as const, // 原始频道
|
|
169
|
+
OriginatingTo: `wechat-access:${userId}`, // 原始接收者
|
|
170
|
+
});
|
|
171
|
+
// ctx 包含了 Agent 处理消息所需的所有信息
|
|
172
|
+
|
|
173
|
+
return { ctx, route, storePath };
|
|
174
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// ============================================
|
|
4
|
+
// Runtime 管理
|
|
5
|
+
// ============================================
|
|
6
|
+
// 用于存储和获取 OpenClaw 的运行时实例
|
|
7
|
+
// Runtime 提供了访问配置、会话、路由、事件等核心功能的接口
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 全局运行时实例
|
|
11
|
+
* 在插件初始化时由 OpenClaw 框架注入
|
|
12
|
+
*/
|
|
13
|
+
let runtime: PluginRuntime | null = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 设置微信企业号运行时实例
|
|
17
|
+
* @param next - OpenClaw 提供的运行时实例
|
|
18
|
+
* @description 此方法应在插件初始化时调用一次,用于注入运行时依赖
|
|
19
|
+
*/
|
|
20
|
+
export const setWecomRuntime = (next: PluginRuntime): void => {
|
|
21
|
+
runtime = next;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 获取微信企业号运行时实例
|
|
26
|
+
* @returns OpenClaw 运行时实例
|
|
27
|
+
* @throws 如果运行时未初始化则抛出错误
|
|
28
|
+
* @description 在需要访问 OpenClaw 核心功能时调用此方法
|
|
29
|
+
*/
|
|
30
|
+
export const getWecomRuntime = (): PluginRuntime => {
|
|
31
|
+
if (!runtime) {
|
|
32
|
+
throw new Error("WeCom runtime not initialized");
|
|
33
|
+
}
|
|
34
|
+
return runtime;
|
|
35
|
+
};
|
package/http/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Fuwuhao (微信服务号) 模块
|
|
2
|
+
|
|
3
|
+
## 📁 文件结构
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
src/
|
|
7
|
+
├── types.ts # 类型定义
|
|
8
|
+
├── crypto-utils.ts # 加密解密工具
|
|
9
|
+
├── http-utils.ts # HTTP 请求处理工具
|
|
10
|
+
├── callback-service.ts # 后置回调服务
|
|
11
|
+
├── message-context.ts # 消息上下文构建
|
|
12
|
+
├── message-handler.ts # 消息处理器(核心业务逻辑)
|
|
13
|
+
├── webhook.ts # Webhook 处理器(主入口)
|
|
14
|
+
├── runtime.ts # Runtime 配置
|
|
15
|
+
└── index.ts # 模块导出索引
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 📦 模块说明
|
|
19
|
+
|
|
20
|
+
### 1. `types.ts` - 类型定义
|
|
21
|
+
定义所有 TypeScript 类型和接口:
|
|
22
|
+
- `AgentEventPayload` - Agent 事件载荷
|
|
23
|
+
- `FuwuhaoMessage` - 服务号消息格式
|
|
24
|
+
- `SimpleAccount` - 账号配置
|
|
25
|
+
- `CallbackPayload` - 回调数据格式
|
|
26
|
+
- `StreamChunk` - 流式消息块
|
|
27
|
+
- `StreamCallback` - 流式回调函数类型
|
|
28
|
+
|
|
29
|
+
### 2. `crypto-utils.ts` - 加密解密工具
|
|
30
|
+
处理微信服务号的签名验证和消息加密解密:
|
|
31
|
+
- `verifySignature()` - 验证签名
|
|
32
|
+
- `decryptMessage()` - 解密消息
|
|
33
|
+
|
|
34
|
+
### 3. `http-utils.ts` - HTTP 工具
|
|
35
|
+
处理 HTTP 请求相关的工具方法:
|
|
36
|
+
- `parseQuery()` - 解析查询参数
|
|
37
|
+
- `readBody()` - 读取请求体
|
|
38
|
+
- `isFuwuhaoWebhookPath()` - 检查是否是服务号 webhook 路径
|
|
39
|
+
|
|
40
|
+
### 4. `callback-service.ts` - 后置回调服务
|
|
41
|
+
将处理结果发送到外部回调服务:
|
|
42
|
+
- `sendToCallbackService()` - 发送回调数据
|
|
43
|
+
|
|
44
|
+
### 5. `message-context.ts` - 消息上下文构建
|
|
45
|
+
构建消息处理所需的上下文信息:
|
|
46
|
+
- `buildMessageContext()` - 构建消息上下文(路由、会话、格式化等)
|
|
47
|
+
|
|
48
|
+
### 6. `message-handler.ts` - 消息处理器
|
|
49
|
+
核心业务逻辑,处理消息并调用 Agent:
|
|
50
|
+
- `handleMessage()` - 同步处理消息
|
|
51
|
+
- `handleMessageStream()` - 流式处理消息(SSE)
|
|
52
|
+
|
|
53
|
+
### 7. `webhook.ts` - Webhook 处理器
|
|
54
|
+
主入口,处理微信服务号的 webhook 请求:
|
|
55
|
+
- `handleSimpleWecomWebhook()` - 处理 GET/POST 请求,支持同步和流式返回
|
|
56
|
+
|
|
57
|
+
### 8. `runtime.ts` - Runtime 配置
|
|
58
|
+
获取 OpenClaw 运行时实例
|
|
59
|
+
|
|
60
|
+
### 9. `index.ts` - 模块导出
|
|
61
|
+
统一导出所有公共 API
|
|
62
|
+
|
|
63
|
+
## 🔄 数据流
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
微信服务号
|
|
67
|
+
↓
|
|
68
|
+
webhook.ts (入口)
|
|
69
|
+
↓
|
|
70
|
+
http-utils.ts (解析请求)
|
|
71
|
+
↓
|
|
72
|
+
crypto-utils.ts (验证签名/解密)
|
|
73
|
+
↓
|
|
74
|
+
message-context.ts (构建上下文)
|
|
75
|
+
↓
|
|
76
|
+
message-handler.ts (处理消息)
|
|
77
|
+
↓
|
|
78
|
+
OpenClaw Agent (AI 处理)
|
|
79
|
+
↓
|
|
80
|
+
callback-service.ts (后置回调)
|
|
81
|
+
↓
|
|
82
|
+
返回响应
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 🚀 使用示例
|
|
86
|
+
|
|
87
|
+
### 基本使用
|
|
88
|
+
```typescript
|
|
89
|
+
import { handleSimpleWecomWebhook } from "./src/webhook.js";
|
|
90
|
+
|
|
91
|
+
// 在 HTTP 服务器中使用
|
|
92
|
+
server.on("request", async (req, res) => {
|
|
93
|
+
const handled = await handleSimpleWecomWebhook(req, res);
|
|
94
|
+
if (!handled) {
|
|
95
|
+
// 处理其他路由
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 流式返回(SSE)
|
|
101
|
+
```typescript
|
|
102
|
+
// 客户端请求时添加 stream 参数
|
|
103
|
+
fetch("/fuwuhao?stream=true", {
|
|
104
|
+
headers: {
|
|
105
|
+
"Accept": "text/event-stream"
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 🔧 配置
|
|
111
|
+
|
|
112
|
+
### 环境变量
|
|
113
|
+
- `FUWUHAO_CALLBACK_URL` - 后置回调服务 URL(默认:`http://localhost:3001/api/fuwuhao/callback`)
|
|
114
|
+
|
|
115
|
+
### 账号配置
|
|
116
|
+
在 `webhook.ts` 中修改 `mockAccount` 对象:
|
|
117
|
+
```typescript
|
|
118
|
+
const mockAccount: SimpleAccount = {
|
|
119
|
+
token: "your_token_here",
|
|
120
|
+
encodingAESKey: "your_encoding_aes_key_here",
|
|
121
|
+
receiveId: "your_receive_id_here"
|
|
122
|
+
};
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## 📝 注意事项
|
|
126
|
+
|
|
127
|
+
1. **加密解密**:当前 `crypto-utils.ts` 中的加密解密方法是简化版,生产环境需要实现真实的加密逻辑
|
|
128
|
+
2. **签名验证**:同样需要在生产环境中实现真实的签名验证算法
|
|
129
|
+
3. **错误处理**:所有模块都包含完善的错误处理和日志记录
|
|
130
|
+
4. **类型安全**:所有模块都使用 TypeScript 严格类型检查
|
|
131
|
+
|
|
132
|
+
## 🎯 设计原则
|
|
133
|
+
|
|
134
|
+
- **单一职责**:每个文件只负责一个特定功能
|
|
135
|
+
- **低耦合**:模块之间通过明确的接口通信
|
|
136
|
+
- **高内聚**:相关功能集中在同一模块
|
|
137
|
+
- **可测试**:每个模块都可以独立测试
|
|
138
|
+
- **可扩展**:易于添加新功能或修改现有功能
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CallbackPayload } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// ============================================
|
|
4
|
+
// 后置回调服务
|
|
5
|
+
// ============================================
|
|
6
|
+
// 用于将消息处理结果发送到外部服务进行后续处理
|
|
7
|
+
// 例如:数据统计、日志记录、业务逻辑触发等
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 后置回调服务的 URL 地址
|
|
11
|
+
* @description
|
|
12
|
+
* 可通过环境变量 WECHAT_ACCESS_CALLBACK_URL 配置
|
|
13
|
+
* 默认值:http://localhost:3001/api/wechat-access/callback
|
|
14
|
+
*/
|
|
15
|
+
const CALLBACK_SERVICE_URL = process.env.WECHAT_ACCESS_CALLBACK_URL || "http://localhost:3001/api/wechat-access/callback";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 发送消息处理结果到后置回调服务
|
|
19
|
+
* @param payload - 回调数据载荷,包含用户消息、AI 回复、会话信息等
|
|
20
|
+
* @returns Promise<void> 异步执行,不阻塞主流程
|
|
21
|
+
* @description
|
|
22
|
+
* 后置回调的作用:
|
|
23
|
+
* 1. 记录消息处理日志
|
|
24
|
+
* 2. 统计用户交互数据
|
|
25
|
+
* 3. 触发业务逻辑(如积分、通知等)
|
|
26
|
+
* 4. 数据分析和监控
|
|
27
|
+
*
|
|
28
|
+
* 特点:
|
|
29
|
+
* - 异步执行,失败不影响主流程
|
|
30
|
+
* - 支持自定义认证(通过 Authorization header)
|
|
31
|
+
* - 自动处理错误,只记录日志
|
|
32
|
+
* @example
|
|
33
|
+
* await sendToCallbackService({
|
|
34
|
+
* userId: 'user123',
|
|
35
|
+
* messageId: 'msg456',
|
|
36
|
+
* userMessage: '你好',
|
|
37
|
+
* aiReply: '您好!有什么可以帮您?',
|
|
38
|
+
* success: true
|
|
39
|
+
* });
|
|
40
|
+
*/
|
|
41
|
+
export const sendToCallbackService = async (payload: CallbackPayload): Promise<void> => {
|
|
42
|
+
try {
|
|
43
|
+
console.log("[wechat-access] 发送后置回调:", {
|
|
44
|
+
url: CALLBACK_SERVICE_URL,
|
|
45
|
+
userId: payload.userId,
|
|
46
|
+
hasReply: !!payload.aiReply,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const response = await fetch(CALLBACK_SERVICE_URL, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
// 可以添加认证头
|
|
54
|
+
// "Authorization": `Bearer ${process.env.CALLBACK_AUTH_TOKEN}`,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify(payload),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
console.error("[wechat-access] 后置回调服务返回错误:", {
|
|
61
|
+
status: response.status,
|
|
62
|
+
statusText: response.statusText,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = await response.json().catch(() => ({}));
|
|
68
|
+
console.log("[wechat-access] 后置回调成功:", result);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// 后置回调失败不影响主流程,只记录日志
|
|
71
|
+
console.error("[wechat-access] 后置回调失败:", err);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// 加密解密工具
|
|
3
|
+
// ============================================
|
|
4
|
+
// 处理微信服务号的消息加密、解密和签名验证
|
|
5
|
+
// 微信使用 AES-256-CBC 加密算法和 SHA-1 签名算法
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 验证签名参数接口
|
|
9
|
+
* @property token - 微信服务号配置的 Token
|
|
10
|
+
* @property timestamp - 时间戳
|
|
11
|
+
* @property nonce - 随机数
|
|
12
|
+
* @property encrypt - 加密的消息内容
|
|
13
|
+
* @property signature - 微信生成的签名,用于验证消息来源
|
|
14
|
+
*/
|
|
15
|
+
export interface VerifySignatureParams {
|
|
16
|
+
token: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
nonce: string;
|
|
19
|
+
encrypt: string;
|
|
20
|
+
signature: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解密消息参数接口
|
|
25
|
+
* @property encodingAESKey - 微信服务号配置的 EncodingAESKey(43位字符)
|
|
26
|
+
* @property receiveId - 接收方 ID(通常是服务号的原始 ID)
|
|
27
|
+
* @property encrypt - 加密的消息内容(Base64 编码)
|
|
28
|
+
*/
|
|
29
|
+
export interface DecryptMessageParams {
|
|
30
|
+
encodingAESKey: string;
|
|
31
|
+
receiveId: string;
|
|
32
|
+
encrypt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 验证微信消息签名
|
|
37
|
+
* @param params - 签名验证参数
|
|
38
|
+
* @returns 签名是否有效
|
|
39
|
+
* @description
|
|
40
|
+
* 验证流程:
|
|
41
|
+
* 1. 将 token、timestamp、nonce、encrypt 按字典序排序
|
|
42
|
+
* 2. 拼接成字符串
|
|
43
|
+
* 3. 进行 SHA-1 哈希
|
|
44
|
+
* 4. 与微信提供的 signature 比对
|
|
45
|
+
*
|
|
46
|
+
* **注意:当前为简化实现,生产环境需要实现真实的 SHA-1 签名验证**
|
|
47
|
+
*/
|
|
48
|
+
export const verifySignature = (params: VerifySignatureParams): boolean => {
|
|
49
|
+
// TODO: 实现真实的签名验证逻辑
|
|
50
|
+
// 参考算法:
|
|
51
|
+
// const arr = [params.token, params.timestamp, params.nonce, params.encrypt].sort();
|
|
52
|
+
// const str = arr.join('');
|
|
53
|
+
// const hash = crypto.createHash('sha1').update(str).digest('hex');
|
|
54
|
+
// return hash === params.signature;
|
|
55
|
+
|
|
56
|
+
console.log("[wechat-access] 验证签名参数:", params);
|
|
57
|
+
return true; // 简化实现,直接返回 true
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解密微信消息
|
|
62
|
+
* @param params - 解密参数
|
|
63
|
+
* @returns 解密后的明文消息(JSON 字符串)
|
|
64
|
+
* @description
|
|
65
|
+
* 解密流程:
|
|
66
|
+
* 1. 将 Base64 编码的 encrypt 解码为二进制
|
|
67
|
+
* 2. 使用 AES-256-CBC 算法解密(密钥由 encodingAESKey 派生)
|
|
68
|
+
* 3. 去除填充(PKCS7)
|
|
69
|
+
* 4. 提取消息内容(格式:随机16字节 + 4字节消息长度 + 消息内容 + receiveId)
|
|
70
|
+
* 5. 验证 receiveId 是否匹配
|
|
71
|
+
*
|
|
72
|
+
* **注意:当前为简化实现,返回模拟数据,生产环境需要实现真实的 AES 解密**
|
|
73
|
+
*/
|
|
74
|
+
export const decryptMessage = (params: DecryptMessageParams): string => {
|
|
75
|
+
// TODO: 实现真实的解密逻辑
|
|
76
|
+
// 参考算法:
|
|
77
|
+
// const key = Buffer.from(params.encodingAESKey + '=', 'base64');
|
|
78
|
+
// const iv = key.slice(0, 16);
|
|
79
|
+
// const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
80
|
+
// decipher.setAutoPadding(false);
|
|
81
|
+
// let decrypted = Buffer.concat([decipher.update(params.encrypt, 'base64'), decipher.final()]);
|
|
82
|
+
// // 去除 PKCS7 填充
|
|
83
|
+
// const pad = decrypted[decrypted.length - 1];
|
|
84
|
+
// decrypted = decrypted.slice(0, decrypted.length - pad);
|
|
85
|
+
// // 提取消息内容
|
|
86
|
+
// const content = decrypted.slice(16);
|
|
87
|
+
// const msgLen = content.readUInt32BE(0);
|
|
88
|
+
// const message = content.slice(4, 4 + msgLen).toString('utf8');
|
|
89
|
+
// const receiveId = content.slice(4 + msgLen).toString('utf8');
|
|
90
|
+
// if (receiveId !== params.receiveId) throw new Error('receiveId mismatch');
|
|
91
|
+
// return message;
|
|
92
|
+
|
|
93
|
+
console.log("[wechat-access] 解密参数:", params);
|
|
94
|
+
// 返回模拟的解密结果(标准微信消息格式)
|
|
95
|
+
return '{"msgtype":"text","Content":"Hello from 服务号","MsgId":"123456","FromUserName":"user001","ToUserName":"gh_test","CreateTime":1234567890}';
|
|
96
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
|
|
3
|
+
// ============================================
|
|
4
|
+
// HTTP 工具方法
|
|
5
|
+
// ============================================
|
|
6
|
+
// 提供 HTTP 请求处理的通用工具函数
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 解析 URL 查询参数
|
|
10
|
+
* @param req - Node.js HTTP 请求对象
|
|
11
|
+
* @returns URLSearchParams 对象,可通过 get() 方法获取参数值
|
|
12
|
+
* @description
|
|
13
|
+
* 从请求 URL 中提取查询参数,例如:
|
|
14
|
+
* - /wechat-access?timestamp=123&nonce=abc
|
|
15
|
+
* - 可通过 params.get('timestamp') 获取值
|
|
16
|
+
* @example
|
|
17
|
+
* const query = parseQuery(req);
|
|
18
|
+
* const timestamp = query.get('timestamp');
|
|
19
|
+
*/
|
|
20
|
+
export const parseQuery = (req: IncomingMessage): URLSearchParams => {
|
|
21
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
22
|
+
return url.searchParams;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取 HTTP 请求体内容
|
|
27
|
+
* @param req - Node.js HTTP 请求对象
|
|
28
|
+
* @returns Promise<string> 请求体的完整内容(字符串格式)
|
|
29
|
+
* @description
|
|
30
|
+
* 异步读取请求体的所有数据块,适用于:
|
|
31
|
+
* - POST 请求的 JSON 数据
|
|
32
|
+
* - XML 格式的微信消息
|
|
33
|
+
* - 表单数据
|
|
34
|
+
*
|
|
35
|
+
* 内部实现:
|
|
36
|
+
* 1. 监听 'data' 事件,累积数据块
|
|
37
|
+
* 2. 监听 'end' 事件,返回完整内容
|
|
38
|
+
* 3. 监听 'error' 事件,处理读取错误
|
|
39
|
+
* @example
|
|
40
|
+
* const body = await readBody(req);
|
|
41
|
+
* const data = JSON.parse(body);
|
|
42
|
+
*/
|
|
43
|
+
export const readBody = async (req: IncomingMessage): Promise<string> => {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let body = "";
|
|
46
|
+
// 监听数据块事件,累积内容
|
|
47
|
+
req.on("data", (chunk) => {
|
|
48
|
+
body += chunk.toString();
|
|
49
|
+
});
|
|
50
|
+
// 监听结束事件,返回完整内容
|
|
51
|
+
req.on("end", () => {
|
|
52
|
+
resolve(body);
|
|
53
|
+
});
|
|
54
|
+
// 监听错误事件
|
|
55
|
+
req.on("error", reject);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 检查请求路径是否是服务号 webhook 路径
|
|
61
|
+
* @param url - 请求的完整 URL 或路径
|
|
62
|
+
* @returns 是否匹配服务号 webhook 路径
|
|
63
|
+
* @description
|
|
64
|
+
* 支持多种路径格式:
|
|
65
|
+
* - /wechat-access - 基础路径
|
|
66
|
+
* - /wechat-access/webhook - 标准 webhook 路径
|
|
67
|
+
* - /wechat-access/* - 任何以 /wechat-access/ 开头的路径
|
|
68
|
+
*
|
|
69
|
+
* 用于路由判断,确保只处理服务号相关的请求
|
|
70
|
+
* @example
|
|
71
|
+
* if (isFuwuhaoWebhookPath(req.url)) {
|
|
72
|
+
* // 处理服务号消息
|
|
73
|
+
* }
|
|
74
|
+
*/
|
|
75
|
+
export const isFuwuhaoWebhookPath = (url: string): boolean => {
|
|
76
|
+
const pathname = new URL(url, "http://localhost").pathname;
|
|
77
|
+
// 支持多种路径格式
|
|
78
|
+
return pathname === "/wechat-access" ||
|
|
79
|
+
pathname === "/wechat-access/webhook" ||
|
|
80
|
+
pathname.startsWith("/wechat-access/");
|
|
81
|
+
};
|