openclaw-wap-channel 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ca11back
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # OpenClaw WAP Channel
2
+
3
+ OpenClaw AI 助手的微信消息通道插件,接收来自 WAuxiliary 插件的消息并调用 AI 处理。
4
+
5
+ ## 📦 安装
6
+
7
+ ```bash
8
+ openclaw plugins install openclaw-wap-channel
9
+ ```
10
+
11
+ ## ⚙️ 配置
12
+
13
+ 编辑 OpenClaw 配置文件 `~/.openclaw/openclaw.json`,添加 WAP channel 配置:
14
+
15
+ ```json
16
+ {
17
+ "channels": {
18
+ "wap": {
19
+ "enabled": true,
20
+ "port": 8765,
21
+ "authToken": "your-secret-token-here",
22
+ "whitelist": [
23
+ "wxid_example1",
24
+ "wxid_example2"
25
+ ]
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### 配置说明
32
+
33
+ | 字段 | 类型 | 必填 | 说明 |
34
+ |------|------|------|------|
35
+ | `enabled` | boolean | 是 | 是否启用此 channel |
36
+ | `port` | number | 是 | WebSocket 服务器端口 |
37
+ | `authToken` | string | 是 | 认证 Token(需与 WAP 插件配置一致) |
38
+ | `whitelist` | string[] | 否 | 白名单用户列表(为空则不限制) |
39
+
40
+ ## 🚀 使用
41
+
42
+ 安装并配置后,插件会:
43
+
44
+ 1. 启动 WebSocket 服务器监听指定端口
45
+ 2. 验证来自 WAP 插件的连接 Token
46
+ 3. 接收微信消息并转发给 OpenClaw AI
47
+ 4. 将 AI 回复通过 WebSocket 发送回插件
48
+
49
+ ## 📡 协议
50
+
51
+ ### 接收消息(from WAP plugin)
52
+
53
+ ```json
54
+ {
55
+ "type": "message",
56
+ "data": {
57
+ "msg_id": 12345678,
58
+ "talker": "wxid_xxx",
59
+ "content": "用户消息",
60
+ "timestamp": 1706600000000,
61
+ "is_private": true
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### 发送回复(to WAP plugin)
67
+
68
+ ```json
69
+ {
70
+ "type": "send_text",
71
+ "data": {
72
+ "talker": "wxid_xxx",
73
+ "content": "AI 回复内容"
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## 🔧 开发与测试
79
+
80
+ ```bash
81
+ # 安装依赖
82
+ npm install
83
+
84
+ # 运行测试服务器
85
+ npm run test:server
86
+
87
+ # 运行模拟客户端
88
+ npm run test:client
89
+ ```
90
+
91
+ ## 📝 许可
92
+
93
+ MIT License
package/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { wapPlugin } from "./src/channel.js";
4
+ import { startWsService, stopWsService, setWapRuntime } from "./src/ws-server.js";
5
+
6
+ const plugin = {
7
+ id: "wap",
8
+ name: "WeChat (WAP)",
9
+ description: "WeChat channel via WAuxiliary plugin",
10
+ configSchema: emptyPluginConfigSchema(),
11
+ register(api: OpenClawPluginApi) {
12
+ setWapRuntime(api);
13
+ api.registerChannel({ plugin: wapPlugin });
14
+ api.registerService({
15
+ id: "wap-ws-server",
16
+ start: () => startWsService(api),
17
+ stop: () => stopWsService(),
18
+ });
19
+ },
20
+ };
21
+
22
+ export default plugin;
23
+
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "wap",
3
+ "channels": [
4
+ "wap"
5
+ ],
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {}
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "openclaw-wap-channel",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw WAP channel plugin for WeChat message integration via WebSocket",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "keywords": [
8
+ "openclaw",
9
+ "wechat",
10
+ "wauxiliary",
11
+ "websocket",
12
+ "channel"
13
+ ],
14
+ "author": "ca11back",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Ca11back/openclaw_wap_channel.git"
19
+ },
20
+ "homepage": "https://github.com/Ca11back/openclaw_wap_channel#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/Ca11back/openclaw_wap_channel/issues"
23
+ },
24
+ "files": [
25
+ "index.ts",
26
+ "src/",
27
+ "README.md",
28
+ "LICENSE",
29
+ "openclaw.plugin.json"
30
+ ],
31
+ "openclaw": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
35
+ },
36
+ "scripts": {
37
+ "test:server": "tsx test/standalone-server.ts",
38
+ "test:client": "tsx test/mock-client.ts"
39
+ },
40
+ "dependencies": {
41
+ "ws": "^8.18.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/ws": "^8.5.10",
45
+ "tsx": "^4.7.0"
46
+ },
47
+ "peerDependencies": {
48
+ "openclaw": "*"
49
+ }
50
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,201 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk";
2
+ import { sendToClient, getClientCount, getWapRuntime, getClientStats } from "./ws-server.js";
3
+
4
+ // ============================================================
5
+ // 账户配置类型
6
+ // ============================================================
7
+
8
+ interface WapAccountConfig {
9
+ port?: number;
10
+ authToken?: string;
11
+ allowFrom?: string[];
12
+ enabled?: boolean;
13
+ name?: string;
14
+ dmPolicy?: "open" | "pairing" | "closed";
15
+ }
16
+
17
+ interface WapAccount {
18
+ accountId: string;
19
+ enabled: boolean;
20
+ name?: string;
21
+ config: WapAccountConfig;
22
+ }
23
+
24
+ // ============================================================
25
+ // 配置 Schema
26
+ // ============================================================
27
+
28
+ const WapConfigSchema = {
29
+ type: "object" as const,
30
+ additionalProperties: false,
31
+ properties: {
32
+ enabled: { type: "boolean" as const },
33
+ port: { type: "number" as const },
34
+ authToken: { type: "string" as const },
35
+ accounts: {
36
+ type: "object" as const,
37
+ additionalProperties: {
38
+ type: "object" as const,
39
+ properties: {
40
+ enabled: { type: "boolean" as const },
41
+ name: { type: "string" as const },
42
+ allowFrom: {
43
+ type: "array" as const,
44
+ items: { type: "string" as const },
45
+ },
46
+ dmPolicy: {
47
+ type: "string" as const,
48
+ enum: ["open", "pairing", "closed"],
49
+ },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ };
55
+
56
+ // ============================================================
57
+ // Channel 插件定义
58
+ // ============================================================
59
+
60
+ export const wapPlugin: ChannelPlugin<WapAccount> = {
61
+ id: "wap",
62
+
63
+ meta: {
64
+ id: "wap",
65
+ label: "WeChat (WAP)",
66
+ selectionLabel: "WeChat via WAuxiliary",
67
+ docsPath: "/channels/wap",
68
+ blurb: "WeChat messaging via WAuxiliary Android plugin.",
69
+ aliases: ["wechat", "wx"],
70
+ },
71
+
72
+ capabilities: {
73
+ chatTypes: ["direct", "group"],
74
+ media: false, // 暂不支持媒体消息
75
+ threads: false,
76
+ reactions: false,
77
+ nativeCommands: false,
78
+ },
79
+
80
+ // 配置变更时触发重载
81
+ reload: { configPrefixes: ["channels.wap"] },
82
+
83
+ // 配置 Schema
84
+ configSchema: WapConfigSchema,
85
+
86
+ config: {
87
+ listAccountIds: (cfg) => {
88
+ const wapCfg = cfg.channels?.wap as { accounts?: Record<string, unknown> } | undefined;
89
+ const accounts = wapCfg?.accounts;
90
+ if (accounts && Object.keys(accounts).length > 0) {
91
+ return Object.keys(accounts);
92
+ }
93
+ return ["default"];
94
+ },
95
+
96
+ resolveAccount: (cfg, accountId) => {
97
+ const id = accountId ?? "default";
98
+ const wapCfg = cfg.channels?.wap as
99
+ | { accounts?: Record<string, WapAccountConfig> }
100
+ | undefined;
101
+ const accountCfg = wapCfg?.accounts?.[id] ?? {};
102
+ return {
103
+ accountId: id,
104
+ enabled: accountCfg.enabled ?? true,
105
+ name: accountCfg.name,
106
+ config: accountCfg,
107
+ };
108
+ },
109
+
110
+ isConfigured: (account) => {
111
+ // 检查是否有连接的客户端
112
+ const clientCount = getClientCount();
113
+ return clientCount > 0;
114
+ },
115
+
116
+ describeAccount: (account) => ({
117
+ accountId: account.accountId,
118
+ name: account.name,
119
+ enabled: account.enabled,
120
+ configured: getClientCount() > 0,
121
+ connectedClients: getClientCount(),
122
+ }),
123
+ },
124
+
125
+ security: {
126
+ resolveDmPolicy: ({ account }) => ({
127
+ policy: account.config.dmPolicy ?? ("open" as const),
128
+ allowFrom: account.config.allowFrom ?? [],
129
+ policyPath: `channels.wap.accounts.${account.accountId}.dmPolicy`,
130
+ allowFromPath: `channels.wap.accounts.${account.accountId}.allowFrom`,
131
+ }),
132
+ },
133
+
134
+ outbound: {
135
+ deliveryMode: "direct",
136
+ textChunkLimit: 4000,
137
+
138
+ sendText: async ({ to, text, accountId }) => {
139
+ const runtime = getWapRuntime();
140
+ const effectiveAccountId = accountId ?? "default";
141
+
142
+ const sent = sendToClient(
143
+ {
144
+ type: "send_text",
145
+ data: { talker: to, content: text },
146
+ },
147
+ effectiveAccountId
148
+ );
149
+
150
+ if (!sent) {
151
+ runtime?.logger.warn(
152
+ `WAP sendText failed: no connected clients for account ${effectiveAccountId}`
153
+ );
154
+ return {
155
+ ok: false,
156
+ error: "No connected WAP clients",
157
+ channel: "wap",
158
+ };
159
+ }
160
+
161
+ runtime?.logger.debug(`WAP sendText to ${to}: ${text.substring(0, 50)}...`);
162
+ return { ok: true, channel: "wap" };
163
+ },
164
+ },
165
+
166
+ status: {
167
+ defaultRuntime: {
168
+ accountId: "default",
169
+ running: false,
170
+ lastStartAt: null,
171
+ lastStopAt: null,
172
+ lastError: null,
173
+ },
174
+
175
+ buildAccountSnapshot: ({ account, runtime }) => {
176
+ const clients = getClientStats();
177
+ const accountClients = clients.filter((c) => c.accountId === account.accountId);
178
+
179
+ return {
180
+ accountId: account.accountId,
181
+ name: account.name,
182
+ enabled: account.enabled,
183
+ configured: accountClients.length > 0,
184
+ running: runtime?.running ?? (accountClients.length > 0),
185
+ connectedClients: accountClients.length,
186
+ lastStartAt: runtime?.lastStartAt ?? null,
187
+ lastStopAt: runtime?.lastStopAt ?? null,
188
+ lastError: runtime?.lastError ?? null,
189
+ };
190
+ },
191
+
192
+ buildChannelSummary: ({ snapshot }) => ({
193
+ configured: snapshot.configured ?? false,
194
+ running: snapshot.running ?? false,
195
+ connectedClients: (snapshot as { connectedClients?: number }).connectedClients ?? 0,
196
+ lastStartAt: snapshot.lastStartAt ?? null,
197
+ lastStopAt: snapshot.lastStopAt ?? null,
198
+ lastError: snapshot.lastError ?? null,
199
+ }),
200
+ },
201
+ };
@@ -0,0 +1,66 @@
1
+ // ============================================================
2
+ // 上行消息 (Android → Server)
3
+ // ============================================================
4
+
5
+ export interface WapMessageData {
6
+ msg_id: number;
7
+ msg_type: number;
8
+ talker: string; // 会话 ID (wxid 或群 ID)
9
+ sender: string; // 发送者 wxid
10
+ content: string; // 消息内容
11
+ timestamp: number; // 毫秒时间戳
12
+ is_private: boolean;
13
+ is_group: boolean;
14
+ }
15
+
16
+ export interface WapMessagePayload {
17
+ type: "message";
18
+ data: WapMessageData;
19
+ }
20
+
21
+ export interface WapHeartbeatPayload {
22
+ type: "heartbeat";
23
+ }
24
+
25
+ export type WapUpstreamMessage = WapMessagePayload | WapHeartbeatPayload;
26
+
27
+ // ============================================================
28
+ // 下行指令 (Server → Android)
29
+ // ============================================================
30
+
31
+ export interface WapSendTextCommand {
32
+ type: "send_text";
33
+ data: {
34
+ talker: string; // Android 端期望 'talker' 字段
35
+ content: string;
36
+ };
37
+ }
38
+
39
+ export interface WapPongCommand {
40
+ type: "pong";
41
+ }
42
+
43
+ // 预留接口:图片消息
44
+ export interface WapSendImageCommand {
45
+ type: "send_image";
46
+ data: {
47
+ talker: string;
48
+ image_url: string;
49
+ };
50
+ }
51
+
52
+ // 预留接口:语音消息
53
+ export interface WapSendVoiceCommand {
54
+ type: "send_voice";
55
+ data: {
56
+ talker: string;
57
+ voice_url: string;
58
+ duration: number;
59
+ };
60
+ }
61
+
62
+ export type WapDownstreamCommand =
63
+ | WapSendTextCommand
64
+ | WapPongCommand
65
+ | WapSendImageCommand
66
+ | WapSendVoiceCommand;
@@ -0,0 +1,162 @@
1
+ // Type declarations for openclaw plugin SDK
2
+ // This is a minimal shim since openclaw doesn't export types separately
3
+
4
+ declare module "openclaw/plugin-sdk" {
5
+ export interface OpenClawPluginApi {
6
+ id: string;
7
+ logger: {
8
+ debug: (msg: string) => void;
9
+ info: (msg: string) => void;
10
+ warn: (msg: string) => void;
11
+ error: (msg: string) => void;
12
+ };
13
+ config: OpenClawConfig;
14
+ runtime: PluginRuntime;
15
+ registerChannel: (opts: { plugin: ChannelPlugin<unknown> }) => void;
16
+ registerService: (opts: {
17
+ id: string;
18
+ start: () => void | Promise<void>;
19
+ stop: () => void | Promise<void>;
20
+ }) => void;
21
+ }
22
+
23
+ export interface OpenClawConfig {
24
+ channels?: Record<string, unknown>;
25
+ session?: {
26
+ store?: string;
27
+ };
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface PluginRuntime {
32
+ version: string;
33
+ config: {
34
+ loadConfig: () => OpenClawConfig;
35
+ writeConfigFile: (cfg: OpenClawConfig) => void;
36
+ };
37
+ system: {
38
+ enqueueSystemEvent: (text: string, opts?: { sessionKey?: string; contextKey?: string }) => void;
39
+ };
40
+ channel: {
41
+ text: {
42
+ resolveTextChunkLimit: (cfg: OpenClawConfig, channel: string, accountId?: string, opts?: { fallbackLimit?: number }) => number;
43
+ resolveChunkMode: (cfg: OpenClawConfig, channel: string, accountId?: string) => string;
44
+ chunkMarkdownTextWithMode: (text: string, limit: number, mode: string) => string[];
45
+ hasControlCommand: (text: string, cfg: OpenClawConfig) => boolean;
46
+ };
47
+ reply: {
48
+ dispatchReplyFromConfig: (opts: {
49
+ ctx: unknown;
50
+ cfg: OpenClawConfig;
51
+ dispatcher: unknown;
52
+ replyOptions?: unknown;
53
+ }) => Promise<{ queuedFinal?: boolean; counts?: Record<string, number> }>;
54
+ createReplyDispatcherWithTyping: (opts: {
55
+ responsePrefix?: string;
56
+ responsePrefixContextProvider?: () => Record<string, string>;
57
+ humanDelay?: unknown;
58
+ deliver: (payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] }) => Promise<void>;
59
+ onError?: (err: unknown, info: { kind: string }) => void;
60
+ onReplyStart?: () => void;
61
+ }) => {
62
+ dispatcher: unknown;
63
+ replyOptions: unknown;
64
+ markDispatchIdle: () => void;
65
+ };
66
+ resolveHumanDelayConfig: (cfg: OpenClawConfig, agentId?: string) => unknown;
67
+ formatInboundEnvelope: (opts: {
68
+ channel: string;
69
+ from: string;
70
+ timestamp?: number;
71
+ body: string;
72
+ chatType: string;
73
+ sender?: { name: string; id: string };
74
+ senderLabel?: string;
75
+ }) => string;
76
+ finalizeInboundContext: (ctx: Record<string, unknown>) => Record<string, unknown>;
77
+ };
78
+ routing: {
79
+ resolveAgentRoute: (opts: {
80
+ cfg: OpenClawConfig;
81
+ channel: string;
82
+ accountId: string;
83
+ teamId?: string;
84
+ peer: { kind: "dm" | "group" | "channel"; id: string };
85
+ }) => {
86
+ sessionKey: string;
87
+ mainSessionKey: string;
88
+ accountId: string;
89
+ agentId: string;
90
+ };
91
+ };
92
+ activity: {
93
+ record: (opts: { channel: string; accountId: string; direction: "inbound" | "outbound" }) => void;
94
+ };
95
+ session: {
96
+ resolveStorePath: (store: string | undefined, opts: { agentId?: string }) => string;
97
+ updateLastRoute: (opts: {
98
+ storePath: string;
99
+ sessionKey: string;
100
+ deliveryContext: { channel: string; to: string; accountId: string };
101
+ }) => Promise<void>;
102
+ };
103
+ };
104
+ logging: {
105
+ shouldLogVerbose: () => boolean;
106
+ getChildLogger: (bindings?: Record<string, unknown>) => {
107
+ debug?: (msg: string) => void;
108
+ info: (msg: string) => void;
109
+ warn: (msg: string) => void;
110
+ error: (msg: string) => void;
111
+ };
112
+ };
113
+ state: {
114
+ resolveStateDir: (cfg: OpenClawConfig) => string;
115
+ };
116
+ }
117
+
118
+ export interface ChannelPlugin<TAccount> {
119
+ id: string;
120
+ meta: {
121
+ id: string;
122
+ label: string;
123
+ selectionLabel?: string;
124
+ docsPath?: string;
125
+ blurb?: string;
126
+ aliases?: string[];
127
+ };
128
+ capabilities: {
129
+ chatTypes: Array<"direct" | "group" | "channel">;
130
+ };
131
+ configSchema?: unknown;
132
+ reload?: {
133
+ configPrefixes?: string[];
134
+ };
135
+ config: {
136
+ listAccountIds: (cfg: OpenClawConfig) => string[];
137
+ isConfigured?: (cfg: OpenClawConfig, accountId?: string) => boolean;
138
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string) => TAccount;
139
+ describeAccount?: (account: TAccount, opts?: { snapshot?: unknown }) => Record<string, unknown>;
140
+ };
141
+ outbound: {
142
+ deliveryMode: "direct" | "buffered";
143
+ sendText: (opts: { to: string; text: string; accountId?: string }) => Promise<{ ok: boolean; channel?: string }>;
144
+ };
145
+ gateway?: {
146
+ startAccount?: (opts: {
147
+ api: OpenClawPluginApi;
148
+ accountId: string;
149
+ abortSignal?: AbortSignal;
150
+ statusSink?: (status: Record<string, unknown>) => void;
151
+ }) => void | Promise<void>;
152
+ stopAccount?: (opts: { accountId: string }) => void | Promise<void>;
153
+ };
154
+ status?: {
155
+ buildConfiguredLabel?: (opts: { cfg: OpenClawConfig; account: TAccount }) => string;
156
+ buildChannelSummary?: (opts: { cfg: OpenClawConfig; account: TAccount; snapshot?: unknown }) => Record<string, unknown>;
157
+ };
158
+ }
159
+
160
+ // Re-export common types
161
+ export type ChannelPluginConfig<T> = ChannelPlugin<T>["config"];
162
+ }
@@ -0,0 +1,420 @@
1
+ import { WebSocketServer, WebSocket } from "ws";
2
+ import type { IncomingMessage } from "http";
3
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+ import type { WapUpstreamMessage, WapSendTextCommand, WapMessageData } from "./protocol.js";
5
+
6
+ let wss: WebSocketServer | null = null;
7
+ let runtime: OpenClawPluginApi | null = null;
8
+
9
+ // 客户端管理:按 accountId 隔离
10
+ interface ClientInfo {
11
+ ws: WebSocket;
12
+ accountId: string;
13
+ ip: string;
14
+ connectedAt: Date;
15
+ messageCount: number;
16
+ lastMessageAt: number;
17
+ }
18
+ const clients = new Map<string, ClientInfo>();
19
+
20
+ // 安全配置
21
+ const DEFAULT_PORT = 8765;
22
+ const AUTH_TOKEN_ENV = "WAP_AUTH_TOKEN"; // 环境变量名称,不是 token 值!
23
+ const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
24
+ const RATE_LIMIT_WINDOW_MS = 1000; // 1 秒
25
+ const RATE_LIMIT_MAX_MESSAGES = 10; // 每秒最多 10 条消息
26
+
27
+ const wechatContextHint = `
28
+ [WeChat Context]
29
+ 尽可能保持简洁(单条 < 300 字)
30
+ 禁止使用MarkDown
31
+ `;
32
+
33
+ export function setWapRuntime(api: OpenClawPluginApi) {
34
+ runtime = api;
35
+ }
36
+
37
+ export function getWapRuntime(): OpenClawPluginApi | null {
38
+ return runtime;
39
+ }
40
+
41
+ export function startWsService(api: OpenClawPluginApi) {
42
+ // 调试:输出完整配置
43
+ api.logger.debug(`WAP channels config: ${JSON.stringify(api.config.channels)}`);
44
+
45
+ const wapConfig = api.config.channels?.wap as
46
+ | { port?: number; authToken?: string; whitelist?: string[] }
47
+ | undefined;
48
+
49
+ api.logger.debug(`WAP wapConfig parsed: ${JSON.stringify(wapConfig)}`);
50
+
51
+ const port = wapConfig?.port ?? DEFAULT_PORT;
52
+ const authToken = process.env[AUTH_TOKEN_ENV] ?? wapConfig?.authToken;
53
+
54
+ // 【安全】强制要求配置 token
55
+ if (!authToken) {
56
+ api.logger.error(
57
+ "WAP WebSocket server NOT started: authToken is required. " +
58
+ "Set WAP_AUTH_TOKEN env or channels.wap.authToken in config."
59
+ );
60
+ return;
61
+ }
62
+
63
+ wss = new WebSocketServer({
64
+ port,
65
+ maxPayload: MAX_MESSAGE_SIZE, // 【安全】限制消息大小
66
+ });
67
+ api.logger.info(`WAP WebSocket server started on port ${port}`);
68
+
69
+ // 日志:白名单配置
70
+ const whitelist = wapConfig?.whitelist ?? [];
71
+ api.logger.info(`WAP whitelist configured: ${whitelist.length > 0 ? whitelist.join(", ") : "(empty - all allowed)"}`);
72
+
73
+ wss.on("connection", (ws, req) => {
74
+ const clientId = handleConnection(ws, req, authToken, api, whitelist);
75
+ if (!clientId) return;
76
+
77
+ ws.on("message", (data) => handleMessage(clientId, data, api));
78
+ ws.on("close", () => handleDisconnect(clientId, api));
79
+ ws.on("error", (err) => api.logger.error(`WebSocket error: ${err.message}`));
80
+ });
81
+ }
82
+
83
+ export function stopWsService() {
84
+ if (wss) {
85
+ wss.close();
86
+ wss = null;
87
+ }
88
+ clients.clear();
89
+ runtime?.logger.info("WAP WebSocket server stopped");
90
+ }
91
+
92
+ function handleConnection(
93
+ ws: WebSocket,
94
+ req: IncomingMessage,
95
+ authToken: string,
96
+ api: OpenClawPluginApi,
97
+ whitelist: string[]
98
+ ): string | null {
99
+ // 获取客户端 IP
100
+ const ip = req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim()
101
+ ?? req.socket.remoteAddress
102
+ ?? "unknown";
103
+
104
+ // 【安全】Token 认证(已强制要求)
105
+ const auth = req.headers.authorization;
106
+ if (auth !== `Bearer ${authToken}`) {
107
+ api.logger.warn(`WAP connection rejected from ${ip}: invalid token`);
108
+ ws.close(4001, "Unauthorized");
109
+ return null;
110
+ }
111
+
112
+ // 从 query 或 header 获取 accountId(可选,默认 "default")
113
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
114
+ const accountId = url.searchParams.get("accountId")
115
+ ?? req.headers["x-wap-account-id"]?.toString()
116
+ ?? "default";
117
+
118
+ const clientId = `wap-${accountId}-${Date.now()}`;
119
+ clients.set(clientId, {
120
+ ws,
121
+ accountId,
122
+ ip,
123
+ connectedAt: new Date(),
124
+ messageCount: 0,
125
+ lastMessageAt: 0,
126
+ });
127
+
128
+ api.logger.info(`WAP client connected: ${clientId} from ${ip} (account: ${accountId})`);
129
+
130
+ // 下发配置(白名单等)
131
+ const configMessage = {
132
+ type: "config",
133
+ data: {
134
+ whitelist: whitelist,
135
+ },
136
+ };
137
+ ws.send(JSON.stringify(configMessage));
138
+ api.logger.debug(`WAP config sent to ${clientId}: whitelist=${whitelist.length} items`);
139
+
140
+ return clientId;
141
+ }
142
+
143
+ async function handleMessage(
144
+ clientId: string,
145
+ data: Buffer | ArrayBuffer | Buffer[],
146
+ api: OpenClawPluginApi
147
+ ) {
148
+ const client = clients.get(clientId);
149
+ if (!client) return;
150
+
151
+ // 【安全】速率限制
152
+ const now = Date.now();
153
+ if (now - client.lastMessageAt < RATE_LIMIT_WINDOW_MS) {
154
+ client.messageCount++;
155
+ if (client.messageCount > RATE_LIMIT_MAX_MESSAGES) {
156
+ api.logger.warn(`WAP rate limit exceeded for ${clientId}, dropping message`);
157
+ return;
158
+ }
159
+ } else {
160
+ client.messageCount = 1;
161
+ client.lastMessageAt = now;
162
+ }
163
+
164
+ try {
165
+ const text = Buffer.isBuffer(data)
166
+ ? data.toString()
167
+ : Array.isArray(data)
168
+ ? Buffer.concat(data).toString()
169
+ : Buffer.from(data).toString();
170
+
171
+ // 【安全】先解析 JSON
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(text);
175
+ } catch {
176
+ api.logger.warn(`WAP invalid JSON from ${clientId}`);
177
+ return;
178
+ }
179
+
180
+ // 【安全】验证消息结构
181
+ const msg = validateUpstreamMessage(parsed);
182
+ if (!msg) {
183
+ api.logger.warn(`WAP invalid message structure from ${clientId}`);
184
+ return;
185
+ }
186
+
187
+ if (msg.type === "heartbeat") {
188
+ client.ws.send(JSON.stringify({ type: "pong" }));
189
+ return;
190
+ }
191
+
192
+ if (msg.type === "message") {
193
+ api.logger.debug(
194
+ `WAP message from ${msg.data.sender}: ${msg.data.content.substring(0, 50)}`
195
+ );
196
+
197
+ // 使用 OpenClaw 的正确 API 处理入站消息
198
+ await processWapInboundMessage(api, client.accountId, msg.data, client.ws);
199
+ }
200
+ } catch (err) {
201
+ api.logger.error(`Failed to handle WAP message: ${err}`);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 处理 WAP 入站消息,使用 OpenClaw 的 dispatchReplyFromConfig
207
+ */
208
+ async function processWapInboundMessage(
209
+ api: OpenClawPluginApi,
210
+ accountId: string,
211
+ msgData: WapMessageData,
212
+ ws: WebSocket
213
+ ) {
214
+ const core = api.runtime;
215
+ const cfg = api.config;
216
+
217
+ // 确定消息类型
218
+ const kind: "dm" | "group" = msgData.is_group ? "group" : "dm";
219
+ const chatType = kind === "dm" ? "direct" : "group";
220
+
221
+ // 解析路由
222
+ const route = core.channel.routing.resolveAgentRoute({
223
+ cfg,
224
+ channel: "wap",
225
+ accountId,
226
+ peer: {
227
+ kind,
228
+ id: msgData.talker,
229
+ },
230
+ });
231
+
232
+ const sessionKey = route.sessionKey;
233
+ const bodyText = msgData.content.trim();
234
+
235
+ if (!bodyText) return;
236
+
237
+ // 记录 channel activity
238
+ core.channel.activity.record({
239
+ channel: "wap",
240
+ accountId,
241
+ direction: "inbound",
242
+ });
243
+
244
+ // 构建 from 标签
245
+ const fromLabel = kind === "dm"
246
+ ? msgData.sender
247
+ : `${msgData.sender} in ${msgData.talker}`;
248
+
249
+ // 格式化入站消息
250
+ const body = core.channel.reply.formatInboundEnvelope({
251
+ channel: "WeChat",
252
+ from: fromLabel,
253
+ timestamp: msgData.timestamp,
254
+ body: bodyText + "\n\n" + wechatContextHint,
255
+ chatType,
256
+ sender: { name: msgData.sender, id: msgData.sender },
257
+ });
258
+
259
+ // 构建 context payload
260
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
261
+ Body: body,
262
+ RawBody: bodyText,
263
+ CommandBody: bodyText,
264
+ From: kind === "dm" ? `wap:${msgData.sender}` : `wap:group:${msgData.talker}`,
265
+ To: msgData.talker,
266
+ SessionKey: sessionKey,
267
+ AccountId: route.accountId,
268
+ ChatType: chatType,
269
+ ConversationLabel: fromLabel,
270
+ GroupSubject: kind !== "dm" ? msgData.talker : undefined,
271
+ SenderName: msgData.sender,
272
+ SenderId: msgData.sender,
273
+ Provider: "wap" as const,
274
+ Surface: "wap" as const,
275
+ MessageSid: String(msgData.msg_id),
276
+ Timestamp: msgData.timestamp,
277
+ WasMentioned: undefined,
278
+ CommandAuthorized: true, // WAP 默认授权(已通过 token 认证)
279
+ OriginatingChannel: "wap" as const,
280
+ OriginatingTo: msgData.talker,
281
+ });
282
+
283
+ // 获取文本分块限制
284
+ const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "wap", accountId, {
285
+ fallbackLimit: 4000,
286
+ });
287
+
288
+ // 创建回复分发器
289
+ const { dispatcher, replyOptions, markDispatchIdle } =
290
+ core.channel.reply.createReplyDispatcherWithTyping({
291
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
292
+ deliver: async (payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] }) => {
293
+ const replyText = payload.text ?? "";
294
+ if (!replyText) return;
295
+
296
+ // 分块发送
297
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "wap", accountId);
298
+ const chunks = core.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
299
+
300
+ for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
301
+ if (!chunk) continue;
302
+
303
+ // 通过 WebSocket 发送回复
304
+ const command: WapSendTextCommand = {
305
+ type: "send_text",
306
+ data: {
307
+ talker: msgData.talker,
308
+ content: chunk,
309
+ },
310
+ };
311
+
312
+ if (ws.readyState === WebSocket.OPEN) {
313
+ ws.send(JSON.stringify(command));
314
+ api.logger.debug(`WAP reply sent to ${msgData.talker}: ${chunk.substring(0, 50)}...`);
315
+ } else {
316
+ api.logger.warn(`WAP WebSocket not open, cannot send reply to ${msgData.talker}`);
317
+ }
318
+ }
319
+ },
320
+ onError: (err: unknown, info: { kind: string }) => {
321
+ api.logger.error(`WAP ${info.kind} reply failed: ${String(err)}`);
322
+ },
323
+ });
324
+
325
+ // 调用 OpenClaw 的核心回复处理
326
+ await core.channel.reply.dispatchReplyFromConfig({
327
+ ctx: ctxPayload,
328
+ cfg,
329
+ dispatcher,
330
+ replyOptions,
331
+ });
332
+
333
+ markDispatchIdle();
334
+ }
335
+
336
+ // 【安全】验证上行消息结构
337
+ function validateUpstreamMessage(data: unknown): WapUpstreamMessage | null {
338
+ if (typeof data !== "object" || data === null) return null;
339
+
340
+ const obj = data as Record<string, unknown>;
341
+
342
+ if (obj.type === "heartbeat") {
343
+ return { type: "heartbeat" };
344
+ }
345
+
346
+ if (obj.type === "message") {
347
+ const msgData = obj.data;
348
+ if (typeof msgData !== "object" || msgData === null) return null;
349
+
350
+ const d = msgData as Record<string, unknown>;
351
+
352
+ // 验证必须字段
353
+ if (
354
+ typeof d.msg_id !== "number" ||
355
+ typeof d.talker !== "string" ||
356
+ typeof d.sender !== "string" ||
357
+ typeof d.content !== "string" ||
358
+ typeof d.timestamp !== "number" ||
359
+ typeof d.is_private !== "boolean" ||
360
+ typeof d.is_group !== "boolean"
361
+ ) {
362
+ return null;
363
+ }
364
+
365
+ return {
366
+ type: "message",
367
+ data: {
368
+ msg_id: d.msg_id,
369
+ msg_type: typeof d.msg_type === "number" ? d.msg_type : 0,
370
+ talker: d.talker,
371
+ sender: d.sender,
372
+ content: d.content,
373
+ timestamp: d.timestamp,
374
+ is_private: d.is_private,
375
+ is_group: d.is_group,
376
+ },
377
+ };
378
+ }
379
+
380
+ return null;
381
+ }
382
+
383
+ function handleDisconnect(clientId: string, api: OpenClawPluginApi) {
384
+ const client = clients.get(clientId);
385
+ clients.delete(clientId);
386
+ api.logger.info(
387
+ `WAP client disconnected: ${clientId}` +
388
+ (client ? ` (was connected for ${Date.now() - client.connectedAt.getTime()}ms)` : "")
389
+ );
390
+ }
391
+
392
+ // 发送消息到指定 accountId 的客户端(而非广播)
393
+ export function sendToClient(command: WapSendTextCommand, accountId?: string): boolean {
394
+ let sent = false;
395
+ const targetAccountId = accountId ?? "default";
396
+
397
+ for (const [, client] of clients) {
398
+ if (client.accountId === targetAccountId && client.ws.readyState === WebSocket.OPEN) {
399
+ client.ws.send(JSON.stringify(command));
400
+ sent = true;
401
+ break; // 只发送给第一个匹配的客户端
402
+ }
403
+ }
404
+ return sent;
405
+ }
406
+
407
+ // 获取当前连接的客户端数量
408
+ export function getClientCount(): number {
409
+ return clients.size;
410
+ }
411
+
412
+ // 获取客户端状态(用于调试)
413
+ export function getClientStats(): Array<{ clientId: string; accountId: string; ip: string; connectedAt: Date }> {
414
+ return Array.from(clients.entries()).map(([clientId, info]) => ({
415
+ clientId,
416
+ accountId: info.accountId,
417
+ ip: info.ip,
418
+ connectedAt: info.connectedAt,
419
+ }));
420
+ }