Initial commit: VoceChat plugin for OpenClaw
This commit is contained in:
commit
bbc862273e
8
.env.example
Normal file
8
.env.example
Normal file
@ -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.
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
74
README.md
Normal file
74
README.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# OpenClaw VoceChat Plugin
|
||||||
|
|
||||||
|
VoceChat channel plugin for OpenClaw.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ WebSocket 实时消息接收
|
||||||
|
- ✅ 支持私聊和群聊
|
||||||
|
- ✅ 支持文本、图片、文件消息
|
||||||
|
- ✅ 配对模式安全控制
|
||||||
|
- ✅ 多账号支持
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url> ~/.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 <CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
20
openclaw.plugin.json
Normal file
20
openclaw.plugin.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
43
skills/vocechat/SKILL.md
Normal file
43
skills/vocechat/SKILL.md
Normal file
@ -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
|
||||||
318
src/index.ts
Normal file
318
src/index.ts
Normal file
@ -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<string, WebSocket>();
|
||||||
|
|
||||||
|
// 注册 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');
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user