openclaw-xiaoyou 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/src/channel.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * xiaoyou ChannelPlugin 实现
3
+ *
4
+ * 运行在 OpenClaw Gateway 进程内,通过 gateway adapter 管理
5
+ * 与企业服务的 WebSocket 连接生命周期。
6
+ *
7
+ * 用户通过 OpenClaw 标准 channel 配置来设置企业服务地址等参数。
8
+ */
9
+
10
+ import type { ResolvedAccount } from "./types.js";
11
+ import { createEnterpriseClient, type EnterpriseClient } from "./enterprise-client.js";
12
+
13
+ // ─── 运行时状态 ──────────────────────────────────────
14
+
15
+ /** 每个 account 一个 EnterpriseClient */
16
+ const clients = new Map<string, EnterpriseClient>();
17
+
18
+ /** OpenClaw runtime API 引用,由 index.ts register() 注入 */
19
+ let _runtime: any = null;
20
+ export function setRuntime(rt: any) { _runtime = rt; }
21
+ export function getRuntime() { return _runtime; }
22
+
23
+ // ─── Config Adapter ──────────────────────────────────
24
+
25
+ function resolveAccount(cfg: any, accountId?: string | null): ResolvedAccount {
26
+ const section = cfg.channels?.["xiaoyou"];
27
+ if (!section) throw new Error("xiaoyou: channel config section not found");
28
+
29
+ // 多账号支持:accounts.<id> 覆盖顶层默认值
30
+ const acct = accountId && section.accounts?.[accountId]
31
+ ? { ...section, ...section.accounts[accountId] }
32
+ : section;
33
+
34
+ if (!acct.wsUrl) throw new Error("xiaoyou: wsUrl is required");
35
+
36
+ return {
37
+ accountId: accountId ?? acct.defaultAccount ?? null,
38
+ wsUrl: acct.wsUrl,
39
+ authToken: acct.authToken ?? "",
40
+ allowFrom: acct.allowFrom ?? [],
41
+ dmPolicy: acct.dmSecurity,
42
+ reconnectIntervalMs: acct.reconnectIntervalMs ?? 3000,
43
+ maxReconnectAttempts: acct.maxReconnectAttempts ?? 0,
44
+ heartbeatIntervalMs: acct.heartbeatIntervalMs ?? 30000,
45
+ heartbeatTimeoutMs: acct.heartbeatTimeoutMs ?? 10000,
46
+ };
47
+ }
48
+
49
+ function inspectAccount(cfg: any, _accountId?: string | null) {
50
+ const section = cfg.channels?.["xiaoyou"];
51
+ return {
52
+ enabled: Boolean(section?.wsUrl),
53
+ configured: Boolean(section?.wsUrl),
54
+ tokenStatus: section?.authToken ? "available" : "missing",
55
+ };
56
+ }
57
+
58
+ function listAccounts(cfg: any): string[] {
59
+ const section = cfg.channels?.["xiaoyou"];
60
+ if (!section?.wsUrl) return [];
61
+ if (section.accounts) return Object.keys(section.accounts);
62
+ return ["default"];
63
+ }
64
+
65
+ // ─── Channel Plugin ──────────────────────────────────
66
+
67
+ export const xiayouPlugin = {
68
+ id: "xiaoyou" as const,
69
+
70
+ meta: {
71
+ id: "xiaoyou",
72
+ label: "Xiaoyou",
73
+ selectionLabel: "Xiaoyou (enterprise WebSocket)",
74
+ docsPath: "/channels/xiaoyou",
75
+ docsLabel: "xiaoyou",
76
+ blurb: "Bridge OpenClaw to enterprise services via persistent WebSocket.",
77
+ order: 50,
78
+ },
79
+
80
+ capabilities: {
81
+ chatTypes: ["direct" as const, "group" as const],
82
+ media: true,
83
+ reply: true,
84
+ edit: false,
85
+ unsend: false,
86
+ polls: false,
87
+ reactions: false,
88
+ threads: false,
89
+ nativeCommands: false,
90
+ blockStreaming: false,
91
+ },
92
+
93
+ // ── Config(必须)──────────────────────────────────
94
+ config: {
95
+ resolveAccount,
96
+ inspectAccount,
97
+ listAccounts,
98
+ },
99
+
100
+ // ── DM 安全策略 ────────────────────────────────────
101
+ security: {
102
+ dm: {
103
+ channelKey: "xiaoyou",
104
+ resolvePolicy: (account: ResolvedAccount) => account.dmPolicy,
105
+ resolveAllowFrom: (account: ResolvedAccount) => account.allowFrom,
106
+ defaultPolicy: "allowlist",
107
+ },
108
+ },
109
+
110
+ // ── 回复线程模式 ───────────────────────────────────
111
+ threading: { topLevelReplyToMode: "reply" as const },
112
+
113
+ // ── Gateway 生命周期 ───────────────────────────────
114
+ gateway: {
115
+ start: async ({ account, config, logger }: any) => {
116
+ const resolved: ResolvedAccount =
117
+ typeof account.wsUrl === "string"
118
+ ? account
119
+ : resolveAccount(config, account.accountId);
120
+
121
+ const clientKey = resolved.accountId ?? "default";
122
+
123
+ // 断开已有连接
124
+ const existing = clients.get(clientKey);
125
+ if (existing) existing.disconnect();
126
+
127
+ const client = createEnterpriseClient({
128
+ account: resolved,
129
+ logger,
130
+ onMessage: async (msg) => {
131
+ const runtime = getRuntime();
132
+ if (!runtime) {
133
+ logger.error("[xiaoyou] runtime not available");
134
+ return;
135
+ }
136
+ await runtime.inbound.dispatch({
137
+ channelId: "xiaoyou",
138
+ accountId: resolved.accountId ?? "default",
139
+ senderId: msg.senderId,
140
+ senderName: msg.senderName ?? msg.senderId,
141
+ conversationId: msg.conversationId,
142
+ chatType: "direct",
143
+ text: msg.text ?? "",
144
+ attachments: msg.attachments?.map((a: any) => ({
145
+ kind: a.kind,
146
+ url: a.url,
147
+ mimeType: a.mimeType,
148
+ })),
149
+ metadata: msg.metadata,
150
+ });
151
+ },
152
+ });
153
+
154
+ client.connect();
155
+ clients.set(clientKey, client);
156
+ logger.info(`[xiaoyou] gateway started (account=${clientKey})`);
157
+ return client;
158
+ },
159
+
160
+ stop: async (client: EnterpriseClient) => {
161
+ client.disconnect();
162
+ for (const [key, c] of clients.entries()) {
163
+ if (c === client) { clients.delete(key); break; }
164
+ }
165
+ },
166
+ },
167
+
168
+ // ── Outbound 出站 ──────────────────────────────────
169
+ outbound: {
170
+ send: async ({ account, to, payload }: any) => {
171
+ const clientKey = account.accountId ?? "default";
172
+ const client = clients.get(clientKey);
173
+ if (!client || !client.isConnected()) {
174
+ return { ok: false, error: "xiaoyou: not connected" };
175
+ }
176
+
177
+ const baseReply = {
178
+ type: "reply" as const,
179
+ conversationId: to,
180
+ messageId: `xiaoyou-${Date.now()}`,
181
+ agentId: payload.agentId,
182
+ timestamp: Date.now(),
183
+ };
184
+
185
+ if (payload.kind === "text") {
186
+ client.sendReply({ ...baseReply, text: payload.text });
187
+ return { ok: true };
188
+ }
189
+ if (payload.kind === "image" || payload.kind === "file") {
190
+ client.sendReply({
191
+ ...baseReply,
192
+ text: payload.caption ?? "",
193
+ mediaUrls: [payload.url ?? payload.filePath],
194
+ });
195
+ return { ok: true };
196
+ }
197
+ return { ok: false, error: `Unsupported payload kind: ${payload.kind}` };
198
+ },
199
+ },
200
+
201
+ // ── Status 状态检查 ────────────────────────────────
202
+ status: {
203
+ describe: async ({ account }: any) => {
204
+ const clientKey = account.accountId ?? "default";
205
+ const client = clients.get(clientKey);
206
+ const issues: Array<{ severity: string; message: string }> = [];
207
+
208
+ if (!account.wsUrl) issues.push({ severity: "error", message: "wsUrl not configured" });
209
+ if (!account.authToken) issues.push({ severity: "warning", message: "authToken not set" });
210
+ if (!client || !client.isConnected()) issues.push({ severity: "error", message: "WebSocket not connected" });
211
+
212
+ return {
213
+ summary: issues.length === 0 ? "connected" : "error",
214
+ issues,
215
+ };
216
+ },
217
+ },
218
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 企业服务 WebSocket 客户端
3
+ *
4
+ * 由 gateway adapter 的 start() 创建,运行在 OpenClaw Gateway 进程内。
5
+ * 负责:连接企业 WebSocket Server、认证、心跳、自动重连、收发消息。
6
+ */
7
+
8
+ import WebSocket from "ws";
9
+ import type {
10
+ ResolvedAccount,
11
+ Frame,
12
+ InboundMessage,
13
+ OutboundReply,
14
+ PongFrame,
15
+ } from "./types.js";
16
+
17
+ export type Logger = {
18
+ info: (msg: string) => void;
19
+ warn: (msg: string) => void;
20
+ error: (msg: string) => void;
21
+ debug?: (msg: string) => void;
22
+ };
23
+
24
+ export type EnterpriseClientOptions = {
25
+ account: ResolvedAccount;
26
+ logger: Logger;
27
+ onMessage: (msg: InboundMessage) => Promise<void>;
28
+ };
29
+
30
+ export type EnterpriseClient = {
31
+ connect: () => void;
32
+ disconnect: () => void;
33
+ sendReply: (reply: OutboundReply) => boolean;
34
+ isConnected: () => boolean;
35
+ };
36
+
37
+ export function createEnterpriseClient(opts: EnterpriseClientOptions): EnterpriseClient {
38
+ const { account, logger, onMessage } = opts;
39
+
40
+ let ws: WebSocket | null = null;
41
+ let authenticated = false;
42
+ let reconnectAttempts = 0;
43
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
44
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
45
+ let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
46
+ let heartbeatSeq = 0;
47
+ let intentionalClose = false;
48
+
49
+ // ─── 心跳 ──────────────────────────────────────────
50
+
51
+ function startHeartbeat() {
52
+ stopHeartbeat();
53
+ heartbeatTimer = setInterval(() => {
54
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
55
+ heartbeatSeq++;
56
+ ws.send(JSON.stringify({ type: "ping", seq: heartbeatSeq, ts: Date.now() }));
57
+
58
+ heartbeatTimeoutTimer = setTimeout(() => {
59
+ logger.warn(`[xiaoyou] heartbeat timeout (seq=${heartbeatSeq}), reconnecting`);
60
+ ws?.close(4002, "heartbeat timeout");
61
+ }, account.heartbeatTimeoutMs);
62
+ }, account.heartbeatIntervalMs);
63
+ }
64
+
65
+ function stopHeartbeat() {
66
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
67
+ if (heartbeatTimeoutTimer) { clearTimeout(heartbeatTimeoutTimer); heartbeatTimeoutTimer = null; }
68
+ }
69
+
70
+ function handlePong(_pong: PongFrame) {
71
+ if (heartbeatTimeoutTimer) { clearTimeout(heartbeatTimeoutTimer); heartbeatTimeoutTimer = null; }
72
+ }
73
+
74
+ // ─── 认证 ──────────────────────────────────────────
75
+
76
+ function sendAuth() {
77
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
78
+ ws.send(JSON.stringify({
79
+ type: "auth",
80
+ token: account.authToken,
81
+ clientId: `openclaw-xiaoyou-${account.accountId ?? "default"}`,
82
+ clientVersion: "1.0.0",
83
+ }));
84
+ logger.info("[xiaoyou] auth sent, waiting for auth_result...");
85
+ }
86
+
87
+ // ─── 帧处理 ────────────────────────────────────────
88
+
89
+ function handleFrame(raw: string) {
90
+ let frame: Frame;
91
+ try { frame = JSON.parse(raw); } catch {
92
+ logger.warn(`[xiaoyou] invalid JSON: ${raw.slice(0, 200)}`);
93
+ return;
94
+ }
95
+
96
+ switch (frame.type) {
97
+ case "auth_result":
98
+ if (frame.ok) {
99
+ authenticated = true;
100
+ reconnectAttempts = 0;
101
+ logger.info(`[xiaoyou] authenticated (sessionId=${frame.sessionId ?? "n/a"})`);
102
+ startHeartbeat();
103
+ } else {
104
+ logger.error(`[xiaoyou] auth failed: ${frame.error ?? "unknown"}`);
105
+ ws?.close(4001, "auth failed");
106
+ }
107
+ break;
108
+
109
+ case "pong":
110
+ handlePong(frame);
111
+ break;
112
+
113
+ case "message":
114
+ if (!authenticated) { logger.warn("[xiaoyou] message before auth, ignoring"); return; }
115
+ onMessage(frame).catch((err) => {
116
+ logger.error(`[xiaoyou] inbound dispatch error: ${err}`);
117
+ });
118
+ break;
119
+
120
+ default:
121
+ logger.warn?.(`[xiaoyou] unknown frame type: ${(frame as any).type}`);
122
+ }
123
+ }
124
+
125
+ // ─── 重连 ──────────────────────────────────────────
126
+
127
+ function scheduleReconnect() {
128
+ if (intentionalClose) return;
129
+ if (account.maxReconnectAttempts > 0 && reconnectAttempts >= account.maxReconnectAttempts) {
130
+ logger.error(`[xiaoyou] max reconnect attempts (${account.maxReconnectAttempts}) reached`);
131
+ return;
132
+ }
133
+ const delay = Math.min(account.reconnectIntervalMs * Math.pow(2, reconnectAttempts), 60_000);
134
+ reconnectAttempts++;
135
+ logger.info(`[xiaoyou] reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
136
+ reconnectTimer = setTimeout(() => connectInternal(), delay);
137
+ }
138
+
139
+ // ─── 连接 ──────────────────────────────────────────
140
+
141
+ function connectInternal() {
142
+ if (ws && ws.readyState === WebSocket.OPEN) return;
143
+ logger.info(`[xiaoyou] connecting to ${account.wsUrl}`);
144
+ authenticated = false;
145
+
146
+ ws = new WebSocket(account.wsUrl, {
147
+ headers: { Authorization: `Bearer ${account.authToken}` },
148
+ handshakeTimeout: 10_000,
149
+ });
150
+
151
+ ws.on("open", () => { logger.info("[xiaoyou] connected"); sendAuth(); });
152
+ ws.on("message", (data) => handleFrame(data.toString()));
153
+ ws.on("close", (code, reason) => {
154
+ logger.info(`[xiaoyou] closed (code=${code}, reason=${reason.toString()})`);
155
+ stopHeartbeat(); authenticated = false; scheduleReconnect();
156
+ });
157
+ ws.on("error", (err) => { logger.error(`[xiaoyou] error: ${err.message}`); });
158
+ }
159
+
160
+ // ─── 公开 API ──────────────────────────────────────
161
+
162
+ function connect() {
163
+ intentionalClose = false;
164
+ reconnectAttempts = 0;
165
+ connectInternal();
166
+ }
167
+
168
+ function disconnect() {
169
+ intentionalClose = true;
170
+ stopHeartbeat();
171
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
172
+ if (ws) { ws.close(1000, "plugin shutdown"); ws = null; }
173
+ authenticated = false;
174
+ }
175
+
176
+ function sendReply(reply: OutboundReply): boolean {
177
+ if (!ws || ws.readyState !== WebSocket.OPEN || !authenticated) {
178
+ logger.warn("[xiaoyou] cannot send: not connected");
179
+ return false;
180
+ }
181
+ ws.send(JSON.stringify(reply));
182
+ return true;
183
+ }
184
+
185
+ function isConnected(): boolean {
186
+ return ws !== null && ws.readyState === WebSocket.OPEN && authenticated;
187
+ }
188
+
189
+ return { connect, disconnect, sendReply, isConnected };
190
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Onboarding Adapter —— openclaw channels add 交互式向导
3
+ *
4
+ * 当用户执行 `openclaw channels add --channel xiaoyou` 时,
5
+ * 此向导会引导用户填写必要配置项,并写入 OpenClaw 配置文件。
6
+ */
7
+
8
+ export const onboardingAdapter = {
9
+ steps: [
10
+ {
11
+ key: "wsUrl",
12
+ label: "Enterprise WebSocket URL",
13
+ prompt: "企业服务 WebSocket 地址 (如 wss://im.corp.example.com/ws):",
14
+ required: true,
15
+ validate: (value: string) => {
16
+ if (!value.startsWith("ws://") && !value.startsWith("wss://")) {
17
+ return "地址必须以 ws:// 或 wss:// 开头";
18
+ }
19
+ return true;
20
+ },
21
+ },
22
+ {
23
+ key: "authToken",
24
+ label: "Auth Token",
25
+ prompt: "企业服务认证 Token:",
26
+ required: true,
27
+ sensitive: true,
28
+ },
29
+ {
30
+ key: "dmSecurity",
31
+ label: "DM Security Policy",
32
+ prompt: "DM 安全策略 (open / allowlist):",
33
+ required: false,
34
+ default: "open",
35
+ },
36
+ {
37
+ key: "allowFrom",
38
+ label: "Allow List",
39
+ prompt: "白名单用户 ID (逗号分隔,留空表示不限制):",
40
+ required: false,
41
+ transform: (value: string) =>
42
+ value ? value.split(",").map((s) => s.trim()).filter(Boolean) : [],
43
+ },
44
+ {
45
+ key: "reconnectIntervalMs",
46
+ label: "Reconnect Interval",
47
+ prompt: "重连基础间隔 (毫秒):",
48
+ required: false,
49
+ default: "3000",
50
+ transform: (value: string) => parseInt(value, 10) || 3000,
51
+ },
52
+ {
53
+ key: "heartbeatIntervalMs",
54
+ label: "Heartbeat Interval",
55
+ prompt: "心跳间隔 (毫秒):",
56
+ required: false,
57
+ default: "30000",
58
+ transform: (value: string) => parseInt(value, 10) || 30000,
59
+ },
60
+ ],
61
+
62
+ /** 将向导收集的值组装为 channel 配置 */
63
+ buildConfig(values: Record<string, any>) {
64
+ return {
65
+ enabled: true,
66
+ wsUrl: values.wsUrl,
67
+ authToken: values.authToken,
68
+ dmSecurity: values.dmSecurity || "open",
69
+ allowFrom: values.allowFrom || [],
70
+ reconnectIntervalMs: values.reconnectIntervalMs || 3000,
71
+ maxReconnectAttempts: 0,
72
+ heartbeatIntervalMs: values.heartbeatIntervalMs || 30000,
73
+ heartbeatTimeoutMs: 10000,
74
+ };
75
+ },
76
+ };
package/src/types.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * xiaoyou 类型定义
3
+ *
4
+ * 企业服务端 ↔ xiaoyou 插件之间的 WebSocket 协议帧
5
+ */
6
+
7
+ // ─── 解析后的账号配置 ────────────────────────────────────
8
+
9
+ export type ResolvedAccount = {
10
+ accountId: string | null;
11
+ /** 企业 WebSocket 服务地址,如 wss://im.corp.example.com/ws */
12
+ wsUrl: string;
13
+ /** 连接企业服务的认证 token */
14
+ authToken: string;
15
+ /** DM 白名单 */
16
+ allowFrom: string[];
17
+ /** DM 安全策略 */
18
+ dmPolicy: string | undefined;
19
+ /** 重连基础间隔(毫秒) */
20
+ reconnectIntervalMs: number;
21
+ /** 最大重连次数,0 = 无限 */
22
+ maxReconnectAttempts: number;
23
+ /** 心跳间隔(毫秒) */
24
+ heartbeatIntervalMs: number;
25
+ /** 心跳超时(毫秒) */
26
+ heartbeatTimeoutMs: number;
27
+ };
28
+
29
+ // ─── 入站消息(企业服务 → 插件)─────────────────────────
30
+
31
+ export type InboundMessage = {
32
+ type: "message";
33
+ conversationId: string;
34
+ senderId: string;
35
+ senderName?: string;
36
+ text?: string;
37
+ attachments?: Attachment[];
38
+ metadata?: Record<string, unknown>;
39
+ };
40
+
41
+ export type Attachment = {
42
+ kind: "image" | "file" | "audio" | "video";
43
+ url: string;
44
+ mimeType?: string;
45
+ fileName?: string;
46
+ };
47
+
48
+ // ─── 出站回复(插件 → 企业服务)─────────────────────────
49
+
50
+ export type OutboundReply = {
51
+ type: "reply";
52
+ conversationId: string;
53
+ messageId: string;
54
+ text: string;
55
+ mediaUrls?: string[];
56
+ agentId?: string;
57
+ timestamp: number;
58
+ };
59
+
60
+ // ─── 控制帧 ─────────────────────────────────────────────
61
+
62
+ export type AuthFrame = {
63
+ type: "auth";
64
+ token: string;
65
+ clientId: string;
66
+ clientVersion: string;
67
+ };
68
+
69
+ export type AuthResultFrame = {
70
+ type: "auth_result";
71
+ ok: boolean;
72
+ error?: string;
73
+ sessionId?: string;
74
+ };
75
+
76
+ export type PingFrame = { type: "ping"; seq: number; ts: number };
77
+ export type PongFrame = { type: "pong"; seq: number; ts: number };
78
+
79
+ /** 所有帧类型的联合 */
80
+ export type Frame =
81
+ | InboundMessage
82
+ | OutboundReply
83
+ | AuthFrame
84
+ | AuthResultFrame
85
+ | PingFrame
86
+ | PongFrame;
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "rootDir": ".",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "sourceMap": true,
13
+ "resolveJsonModule": true
14
+ },
15
+ "include": ["*.ts", "src/**/*.ts"],
16
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
17
+ }