From bbc862273e4f703fc3aeeb9d55997ef29ff3192f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 13 Mar 2026 19:14:17 +0800 Subject: [PATCH] Initial commit: VoceChat plugin for OpenClaw --- .env.example | 8 + .gitignore | 7 + README.md | 74 +++++++++ openclaw.plugin.json | 20 +++ package.json | 21 +++ skills/vocechat/SKILL.md | 43 ++++++ src/index.ts | 318 +++++++++++++++++++++++++++++++++++++++ tsconfig.json | 19 +++ 8 files changed, 510 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 openclaw.plugin.json create mode 100644 package.json create mode 100644 skills/vocechat/SKILL.md create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37ac5ed --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# VoceChat Plugin Environment Variables + +Optional environment variables: + +- `VOCECHAT_SERVER_URL` - Default server URL +- `VOCECHAT_API_KEY` - Default API key + +These can be used instead of or to override config file settings. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c7db28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.vscode/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..11a61a8 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# OpenClaw VoceChat Plugin + +VoceChat channel plugin for OpenClaw. + +## Features + +- ✅ WebSocket 实时消息接收 +- ✅ 支持私聊和群聊 +- ✅ 支持文本、图片、文件消息 +- ✅ 配对模式安全控制 +- ✅ 多账号支持 + +## Installation + +### From Git + +```bash +git clone ~/.openclaw/extensions/vocechat +cd ~/.openclaw/extensions/vocechat +pnpm install +``` + +### Enable Plugin + +```bash +openclaw plugins enable vocechat +``` + +## Configuration + +Edit `~/.openclaw/openclaw.json`: + +```json5 +{ + channels: { + vocechat: { + enabled: true, + dmPolicy: "pairing", // "pairing" | "allowlist" | "open" + accounts: { + default: { + serverUrl: "https://your-vocechat-server.com", + apiKey: "your-api-key", + botName: "OpenClaw Bot", + }, + }, + }, + }, +} +``` + +## Usage + +1. Start the gateway: + ```bash + openclaw gateway + ``` + +2. Send a message to your VoceChat bot + +3. Approve pairing (if dmPolicy is "pairing"): + ```bash + openclaw pairing approve vocechat + ``` + +## Development + +```bash +pnpm install +pnpm dev +``` + +## License + +MIT \ No newline at end of file diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..bc42eca --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,20 @@ +{ + "name": "openclaw-vocechat", + "openclaw": { + "extensions": ["./src/index.ts"], + "channel": { + "id": "vocechat", + "label": "VoceChat", + "selectionLabel": "VoceChat (self-hosted)", + "docsPath": "/channels/vocechat", + "docsLabel": "vocechat", + "blurb": "Self-hosted chat via VoceChat API.", + "order": 70, + "aliases": ["voce"] + }, + "install": { + "localPath": "extensions/vocechat", + "defaultChoice": "local" + } + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e96120 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "openclaw-vocechat", + "version": "1.0.0", + "description": "VoceChat channel plugin for OpenClaw", + "main": "src/index.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "ws": "^8.16.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/ws": "^8.5.0", + "typescript": "^5.3.0" + }, + "openclaw": { + "extensions": ["./src/index.ts"] + } +} \ No newline at end of file diff --git a/skills/vocechat/SKILL.md b/skills/vocechat/SKILL.md new file mode 100644 index 0000000..675a798 --- /dev/null +++ b/skills/vocechat/SKILL.md @@ -0,0 +1,43 @@ +--- +name: vocechat +description: VoceChat channel integration for OpenClaw +--- + +# VoceChat Skill + +This skill provides integration with VoceChat self-hosted messaging platform. + +## Usage + +When users want to: +- Send messages via VoceChat +- Check VoceChat connection status +- Configure VoceChat settings + +Use the `vocechat` channel tools. + +## Configuration + +Configure in `~/.openclaw/openclaw.json`: + +```json5 +{ + channels: { + vocechat: { + enabled: true, + dmPolicy: "pairing", + accounts: { + default: { + serverUrl: "https://your-vocechat-server.com", + apiKey: "your-api-key", + botName: "OpenClaw Bot", + }, + }, + }, + }, +} +``` + +## Commands + +- `/vocechat:status` - Check connection status \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8592d8c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,318 @@ +import type { PluginApi } from 'openclaw/plugin-sdk/core'; +import WebSocket from 'ws'; + +// VoceChat 消息类型定义 +interface VoceChatMessage { + mid: number; + messageId: string; + fromUid: number; + fromName?: string; + channelId: number; + channelType: 'direct' | 'group'; + content: string; + createdAt: number; + contentType?: 'text' | 'image' | 'file'; + attachments?: Array<{ + name: string; + url: string; + size: number; + }>; +} + +// VoceChat 配置类型 +interface VoceChatAccount { + serverUrl: string; + apiKey: string; + botName?: string; + enabled?: boolean; +} + +export default function register(api: PluginApi) { + api.logger.info('VoceChat plugin loading...'); + + // 存储 WebSocket 连接 + const connections = new Map(); + + // 注册 VoceChat 频道 + api.registerChannel({ + plugin: { + id: 'vocechat', + meta: { + id: 'vocechat', + label: 'VoceChat', + selectionLabel: 'VoceChat (API)', + docsPath: '/channels/vocechat', + blurb: 'VoceChat messaging channel.', + aliases: ['voce'], + }, + + capabilities: { + chatTypes: ['direct', 'group'], + media: { + images: true, + files: true, + }, + }, + + config: { + listAccountIds: (cfg: any) => { + const accounts = cfg.channels?.vocechat?.accounts ?? {}; + return Object.keys(accounts).filter( + (id) => accounts[id]?.enabled !== false + ); + }, + + resolveAccount: (cfg: any, accountId?: string) => { + const account = cfg.channels?.vocechat?.accounts?.[accountId ?? 'default']; + if (!account) { + return { accountId: accountId ?? 'default' }; + } + return { + accountId: accountId ?? 'default', + ...account, + }; + }, + + // 只读检查(用于状态显示) + inspectAccount: (cfg: any, accountId?: string) => { + const account = cfg.channels?.vocechat?.accounts?.[accountId ?? 'default']; + if (!account) { + return { + accountId: accountId ?? 'default', + enabled: false, + configured: false + }; + } + return { + accountId: accountId ?? 'default', + enabled: account.enabled !== false, + configured: !!account.serverUrl && !!account.apiKey, + serverUrl: account.serverUrl, + botName: account.botName, + tokenStatus: account.apiKey ? 'available' : 'missing', + }; + }, + }, + + // 入站消息处理 + inbound: { + async start({ account, onMessage, onError }) { + const { serverUrl, apiKey } = account as VoceChatAccount; + const accountId = account.accountId; + + if (!serverUrl || !apiKey) { + throw new Error('VoceChat: serverUrl and apiKey are required'); + } + + // 构建 WebSocket URL + const wsUrl = serverUrl.replace(/^http/, 'ws') + '/ws'; + + api.logger.info(`VoceChat [${accountId}]: Connecting to ${wsUrl}`); + + const ws = new WebSocket(wsUrl, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + connections.set(accountId, ws); + + ws.on('open', () => { + api.logger.info(`VoceChat [${accountId}]: WebSocket connected`); + }); + + ws.on('message', async (data: WebSocket.Data) => { + try { + const event = JSON.parse(data.toString()); + + // 只处理消息事件 + if (event.type !== 'message' || !event.data) { + return; + } + + const msg: VoceChatMessage = event.data; + + // 转换为 OpenClaw 标准消息格式 + const message = { + id: msg.messageId || String(msg.mid), + text: msg.content, + sender: { + id: String(msg.fromUid), + name: msg.fromName || `User-${msg.fromUid}`, + }, + chat: { + id: String(msg.channelId), + type: msg.channelType, + }, + timestamp: msg.createdAt, + attachments: msg.attachments, + }; + + await onMessage(message); + } catch (err) { + api.logger.error('VoceChat: Failed to process message', err); + } + }); + + ws.on('error', (err) => { + api.logger.error(`VoceChat [${accountId}]: WebSocket error`, err); + onError(err); + }); + + ws.on('close', () => { + api.logger.info(`VoceChat [${accountId}]: WebSocket closed`); + connections.delete(accountId); + }); + + return { + stop: () => { + ws.close(); + connections.delete(accountId); + }, + }; + }, + }, + + // 出站消息处理 + outbound: { + deliveryMode: 'direct', + + async sendText({ text, chat, account }) { + const { serverUrl, apiKey } = account as VoceChatAccount; + + try { + const res = await fetch(`${serverUrl}/api/message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + target: chat.id, + content: text, + contentType: 'text', + }), + }); + + if (!res.ok) { + const error = await res.text(); + throw new Error(`VoceChat API error: ${res.status} ${error}`); + } + + return { ok: true }; + } catch (err) { + api.logger.error('VoceChat: Failed to send text', err); + return { ok: false, error: String(err) }; + } + }, + + async sendMedia({ mediaUrl, chat, account, mimeType }) { + const { serverUrl, apiKey } = account as VoceChatAccount; + + try { + // 先上传文件获取 URL + const uploadRes = await fetch(`${serverUrl}/api/file`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + url: mediaUrl, + }), + }); + + if (!uploadRes.ok) { + throw new Error('Failed to upload media'); + } + + const { url: fileUrl } = await uploadRes.json(); + + // 发送文件消息 + const res = await fetch(`${serverUrl}/api/message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + target: chat.id, + content: fileUrl, + contentType: mimeType?.startsWith('image/') ? 'image' : 'file', + }), + }); + + return { ok: res.ok }; + } catch (err) { + api.logger.error('VoceChat: Failed to send media', err); + return { ok: false, error: String(err) }; + } + }, + }, + + // 安全策略 + security: { + dmPolicy: 'pairing', // 默认配对模式 + }, + + // 状态检查 + status: { + async check({ account }) { + const { serverUrl, apiKey } = account as VoceChatAccount; + + try { + const res = await fetch(`${serverUrl}/api/user/me`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (res.ok) { + const user = await res.json(); + return { + ok: true, + status: 'connected', + details: `Logged in as ${user.name || user.uid}`, + }; + } else { + return { + ok: false, + status: 'error', + details: `API returned ${res.status}`, + }; + } + } catch (err) { + return { + ok: false, + status: 'disconnected', + details: String(err), + }; + } + }, + }, + }, + }); + + // 注册 CLI 命令 + api.registerCli(({ program }) => { + program + .command('vocechat:status') + .description('Check VoceChat connection status') + .action(async () => { + console.log('VoceChat plugin status:'); + console.log(` Active connections: ${connections.size}`); + for (const [id, ws] of connections) { + console.log(` - ${id}: ${ws.readyState === WebSocket.OPEN ? 'connected' : 'disconnected'}`); + } + }); + }); + + // 注册 Gateway RPC 方法 + api.registerGatewayMethod('vocechat.status', ({ respond }) => { + respond(true, { + ok: true, + connections: connections.size, + }); + }); + + api.logger.info('VoceChat plugin loaded successfully'); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eeac931 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file