feat: 精简版本

This commit is contained in:
liyanyan 2026-03-15 21:50:45 +08:00
parent f3c16c7609
commit 7e98a8630f
10 changed files with 5781 additions and 286 deletions

1
.gitignore vendored
View File

@ -3,7 +3,6 @@ dist/
*.log *.log
.DS_Store .DS_Store
.env .env
.vscode/
.idea/ .idea/
# Vault - sensitive data # Vault - sensitive data

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"cSpell.words": [
"openclaw",
"vocechat"
],
"editor.formatOnSave": false
}

View File

@ -1,5 +1,38 @@
{ {
"id": "openclaw-vocechat",
"name": "openclaw-vocechat", "name": "openclaw-vocechat",
"version": "1.0.0",
"description": "VoceChat channel plugin for OpenClaw",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"serverUrl": {
"type": "string"
},
"botApiToken": {
"type": "string"
},
"wsServerUrl": {
"type": "string"
}
},
"uiHints": {
"serverUrl": {
"label": "VoceChat Server URL",
"placeholder": "https://your-vocechat-server.com"
},
"botApiToken": {
"sensitive": true,
"label": "Bot API Token",
"placeholder": "Your VoceChat Bot API token"
},
"wsServerUrl": {
"label": "VoceChat WebSocket Server URL",
"placeholder": "wss://your-vocechat-server.com/ws"
}
}
},
"openclaw": { "openclaw": {
"extensions": ["./src/index.ts"], "extensions": ["./src/index.ts"],
"channel": { "channel": {

View File

@ -2,7 +2,8 @@
"name": "openclaw-vocechat", "name": "openclaw-vocechat",
"version": "1.0.0", "version": "1.0.0",
"description": "VoceChat channel plugin for OpenClaw", "description": "VoceChat channel plugin for OpenClaw",
"main": "src/index.ts", "type": "module",
"main": "./dist/index.ts",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "tsc --watch" "dev": "tsc --watch"
@ -13,9 +14,12 @@
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/ws": "^8.5.0", "@types/ws": "^8.5.0",
"openclaw": "^2026.3.13",
"typescript": "^5.3.0" "typescript": "^5.3.0"
}, },
"openclaw": { "openclaw": {
"extensions": ["./src/index.ts"] "extensions": [
"./src/index.ts"
]
} }
} }

5587
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,5 @@
allowBuilds:
'@whiskeysockets/baileys': true
koffi: true
protobufjs: true
sharp: true

View File

@ -1,6 +1,8 @@
import type { PluginApi, ChannelAccount } from 'openclaw/plugin-sdk/core'; import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
import WebSocket from 'ws'; import WebSocket from 'ws';
import type { VoceChatAccount } from './types/index.js';
// VoceChat 消息类型定义(保留现有结构,待讨论后更新) // VoceChat 消息类型定义(保留现有结构,待讨论后更新)
interface VoceChatMessage { interface VoceChatMessage {
mid: number; mid: number;
@ -19,13 +21,6 @@ interface VoceChatMessage {
}>; }>;
} }
// VoceChat 账号配置类型
interface VoceChatAccount extends ChannelAccount {
serverUrl: string;
apiKey: string;
botName?: string;
}
/** /**
* *
* *
@ -37,16 +32,16 @@ interface VoceChatAccount extends ChannelAccount {
* 4. POST * 4. POST
*/ */
export async function startInbound( export async function startInbound(
api: PluginApi, api: OpenClawPluginApi,
account: VoceChatAccount, account: VoceChatAccount,
onMessage: (message: any) => Promise<void>, onMessage: (message: any) => Promise<void>,
onError: (error: Error) => void onError: (error: Error) => void
): Promise<{ stop: () => void }> { ): Promise<{ stop: () => void }> {
const { serverUrl, apiKey } = account; const { serverUrl, botApiToken } = account;
const accountId = account.accountId; const accountId = account.accountId;
if (!serverUrl || !apiKey) { if (!serverUrl || !botApiToken) {
throw new Error('VoceChat: serverUrl and apiKey are required'); throw new Error('VoceChat: serverUrl and botApiToken are required');
} }
// 注意:当前使用 WebSocket 连接,后续应改为 Webhook // 注意:当前使用 WebSocket 连接,后续应改为 Webhook
@ -57,7 +52,7 @@ export async function startInbound(
const ws = new WebSocket(wsUrl, { const ws = new WebSocket(wsUrl, {
headers: { headers: {
'Authorization': `Bearer ${apiKey}`, 'Authorization': `Bearer ${botApiToken}`,
}, },
}); });
@ -94,12 +89,12 @@ export async function startInbound(
await onMessage(message); await onMessage(message);
} catch (err) { } catch (err) {
api.logger.error('VoceChat: Failed to process message', err); api.logger.error('VoceChat: Failed to process message\n' + err);
} }
}); });
ws.on('error', (err) => { ws.on('error', (err) => {
api.logger.error(`VoceChat [${accountId}]: WebSocket error`, err); api.logger.error(`VoceChat [${accountId}]: WebSocket error\n` + err);
onError(err); onError(err);
}); });

View File

@ -1,17 +1,14 @@
import type { PluginApi } from 'openclaw/plugin-sdk/core'; import type {
import { startInbound } from './inbound.js'; OpenClawPluginApi,
import { sendText, sendMarkdown, sendFile, replyToMessage } from './outbound.js'; OpenClawConfig,
} from "openclaw/plugin-sdk";
// import { startInbound } from './inbound.js';
import outbound from "./outbound.js";
// VoceChat 账号配置类型 import type { VoceChatAccount } from './types/index.js';
interface VoceChatAccount {
serverUrl: string;
apiKey: string;
botName?: string;
enabled?: boolean;
}
export default function register(api: PluginApi) { export default function register(api: OpenClawPluginApi) {
api.logger.info('VoceChat plugin loading...'); api.logger.info("VoceChat plugin loading...");
// 存储连接控制函数 // 存储连接控制函数
const connections = new Map<string, { stop: () => void }>(); const connections = new Map<string, { stop: () => void }>();
@ -19,157 +16,105 @@ export default function register(api: PluginApi) {
// 注册 VoceChat 频道 // 注册 VoceChat 频道
api.registerChannel({ api.registerChannel({
plugin: { plugin: {
id: 'vocechat', id: "vocechat",
meta: { meta: {
id: 'vocechat', id: "vocechat",
label: 'VoceChat', label: "VoceChat",
selectionLabel: 'VoceChat (API)', selectionLabel: "VoceChat (API)",
docsPath: '/channels/vocechat', docsPath: "/channels/vocechat",
blurb: 'VoceChat messaging channel.', blurb: "VoceChat messaging channel.",
aliases: ['voce'], aliases: ["voce"],
}, },
capabilities: { capabilities: {
chatTypes: ['direct', 'group'], chatTypes: ["direct", "group"],
media: { media: false, // TODO: 后续再实现媒体消息支持
images: true,
files: true,
},
}, },
config: { config: {
listAccountIds: (cfg: any) => { listAccountIds: (cfg: OpenClawConfig) => {
const accounts = cfg.channels?.vocechat?.accounts ?? {}; const accounts = cfg.channels?.vocechat?.accounts ?? {};
return Object.keys(accounts).filter( return Object.keys(accounts).filter(
(id) => accounts[id]?.enabled !== false (id) => accounts[id]?.enabled !== false,
); );
}, },
resolveAccount: (cfg: any, accountId?: string) => { resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const account = cfg.channels?.vocechat?.accounts?.[accountId ?? 'default']; const account =
cfg.channels?.vocechat?.accounts?.[accountId ?? "default"];
if (!account) { if (!account) {
return { accountId: accountId ?? 'default' }; return { accountId: accountId ?? "default" };
} }
return { return {
accountId: accountId ?? 'default', accountId: accountId ?? "default",
...account, ...account,
}; };
}, },
inspectAccount: (cfg: any, accountId?: string) => { inspectAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const account = cfg.channels?.vocechat?.accounts?.[accountId ?? 'default']; const account =
cfg.channels?.vocechat?.accounts?.[accountId ?? "default"];
if (!account) { if (!account) {
return { return {
accountId: accountId ?? 'default', accountId: accountId ?? "default",
enabled: false, enabled: false,
configured: false configured: false,
}; };
} }
return { return {
accountId: accountId ?? 'default', accountId: accountId ?? "default",
enabled: account.enabled !== false, enabled: account.enabled !== false,
configured: !!account.serverUrl && !!account.apiKey, configured:
!!account.serverUrl &&
!!account.botApiToken &&
!!account.wsServerUrl,
serverUrl: account.serverUrl, serverUrl: account.serverUrl,
botName: account.botName, wsServerUrl: account.wsServerUrl,
tokenStatus: account.apiKey ? 'available' : 'missing', tokenStatus: account.botApiToken ? "available" : "missing",
}; };
}, },
}, },
// 入站消息处理 // 入站消息处理
inbound: { // inbound: {
async start({ account, onMessage, onError }) { // async start({ account, onMessage, onError }) {
const connection = await startInbound( // const connection = await startInbound(
api, // api,
account as VoceChatAccount, // account as VoceChatAccount,
onMessage, // onMessage,
onError // onError,
); // );
connections.set(account.accountId, connection); // connections.set(account.accountId, connection);
return connection; // return connection;
}, // },
}, // },
// 出站消息处理 // 出站消息处理
outbound: { outbound,
deliveryMode: 'direct',
async sendText({ text, chat, account }) {
return sendText(api, text, chat, account as VoceChatAccount);
},
async sendMedia({ mediaUrl, chat, account, mimeType }) {
// TODO: 实现文件上传后再发送
// 1. 下载文件到本地
// 2. 调用 uploadFile 上传
// 3. 调用 sendFile 发送
api.logger.warn('VoceChat: sendMedia not fully implemented yet');
return { ok: false, error: 'Media sending not implemented' };
},
},
security: {
dmPolicy: 'pairing',
},
status: {
async check({ account }) {
const { serverUrl, apiKey } = account as VoceChatAccount;
try {
const res = await fetch(`${serverUrl}/api/bot`, {
headers: {
'x-api-key': apiKey,
},
});
if (res.ok) {
const data = await res.json();
return {
ok: true,
status: 'connected',
details: `Bot is active, ${data.length || 0} channels`,
};
} else {
return {
ok: false,
status: 'error',
details: `API returned ${res.status}`,
};
}
} catch (err) {
return {
ok: false,
status: 'disconnected',
details: String(err),
};
}
},
},
}, },
}); });
// 注册 CLI 命令 // 注册 CLI 命令
api.registerCli(({ program }) => { api.registerCli(({ program }) => {
program program
.command('vocechat:status') .command("vocechat:status")
.description('Check VoceChat connection status') .description("Check VoceChat connection status")
.action(async () => { .action(async () => {
console.log('VoceChat plugin status:'); console.log("VoceChat plugin status:");
console.log(` Active connections: ${connections.size}`); console.log(` Active connections: ${connections.size}`);
for (const [id, conn] of connections) { for (const [id] of connections) {
console.log(` - ${id}: active`); console.log(` - ${id}: active`);
} }
}); });
}); });
// 注册 Gateway RPC 方法 // 注册 Gateway RPC 方法
api.registerGatewayMethod('vocechat.status', ({ respond }) => { api.registerGatewayMethod("vocechat.status", ({ respond }) => {
respond(true, { respond(true, {
ok: true, ok: true,
connections: connections.size, connections: connections.size,
}); });
}); });
api.logger.info('VoceChat plugin loaded successfully'); api.logger.info("VoceChat plugin loaded successfully");
} }

View File

@ -1,34 +1,29 @@
import type { PluginApi, ChannelAccount, ChannelChat, OutboundResult } from 'openclaw/plugin-sdk/core'; import type {
OpenClawPluginApi,
// VoceChat 账号配置类型 OpenClawConfig,
interface VoceChatAccount extends ChannelAccount { ChannelOutboundAdapter,
serverUrl: string; ChannelOutboundContext,
apiKey: string; } from "openclaw/plugin-sdk";
botName?: string;
}
/** /**
* *
*/ */
export async function sendText( export async function sendTextToVoceChat(
api: PluginApi, cfg: OpenClawConfig,
text: string, to?: string | null,
chat: ChannelChat, text?: string,
account: VoceChatAccount ) {
): Promise<OutboundResult> { const { serverUrl, apiKey } = cfg.channels?.vocechat|| {};
const { serverUrl, apiKey } = account;
try { try {
// 根据聊天类型选择 API 端点 // 根据聊天类型选择 API 端点
const endpoint = chat.type === 'direct' const endpoint = `${serverUrl}/api/bot/send_to_user/${to}`;
? `${serverUrl}/api/bot/send_to_user/${chat.id}`
: `${serverUrl}/api/bot/send_to_group/${chat.id}`;
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'text/plain', "Content-Type": "text/plain",
'x-api-key': apiKey, "x-api-key": apiKey,
}, },
body: text, body: text,
}); });
@ -38,110 +33,29 @@ export async function sendText(
throw new Error(`VoceChat API error: ${res.status} ${error}`); throw new Error(`VoceChat API error: ${res.status} ${error}`);
} }
const data = await res.json(); const data = await res.text();
return { ok: true, messageId: String(data) }; return { ok: true, messageId: String(data) };
} catch (err) { } catch (e) {}
api.logger.error('VoceChat: Failed to send text', err);
return { ok: false, error: String(err) };
}
} }
/** /**
* Markdown * Markdown
*/ */
export async function sendMarkdown( export async function sendMarkdown(
api: PluginApi, cfg: OpenClawConfig,
markdown: string, accountId?: string | null,
chat: ChannelChat, text?: string,
account: VoceChatAccount ) {
): Promise<OutboundResult> { const { serverUrl, apiKey } = cfg.channels?.vocechat || {};
const { serverUrl, apiKey } = account;
try { try {
const endpoint = chat.type === 'direct' const endpoint = `${serverUrl}/api/bot/send_to_user/${accountId}`;
? `${serverUrl}/api/bot/send_to_user/${chat.id}`
: `${serverUrl}/api/bot/send_to_group/${chat.id}`;
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'text/markdown', "Content-Type": "text/markdown",
'x-api-key': apiKey, "x-api-key": apiKey,
},
body: markdown,
});
if (!res.ok) {
const error = await res.text();
throw new Error(`VoceChat API error: ${res.status} ${error}`);
}
const data = await res.json();
return { ok: true, messageId: String(data) };
} catch (err) {
api.logger.error('VoceChat: Failed to send markdown', err);
return { ok: false, error: String(err) };
}
}
/**
*
* file_path
*/
export async function sendFile(
api: PluginApi,
filePath: string,
chat: ChannelChat,
account: VoceChatAccount
): Promise<OutboundResult> {
const { serverUrl, apiKey } = account;
try {
const endpoint = chat.type === 'direct'
? `${serverUrl}/api/bot/send_to_user/${chat.id}`
: `${serverUrl}/api/bot/send_to_group/${chat.id}`;
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'vocechat/file',
'x-api-key': apiKey,
},
body: JSON.stringify({ path: filePath }),
});
if (!res.ok) {
const error = await res.text();
throw new Error(`VoceChat API error: ${res.status} ${error}`);
}
const data = await res.json();
return { ok: true, messageId: String(data) };
} catch (err) {
api.logger.error('VoceChat: Failed to send file', err);
return { ok: false, error: String(err) };
}
}
/**
*
*/
export async function replyToMessage(
api: PluginApi,
text: string,
mid: string,
account: VoceChatAccount
): Promise<OutboundResult> {
const { serverUrl, apiKey } = account;
try {
const endpoint = `${serverUrl}/api/bot/reply/${mid}`;
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'x-api-key': apiKey,
}, },
body: text, body: text,
}); });
@ -151,58 +65,57 @@ export async function replyToMessage(
throw new Error(`VoceChat API error: ${res.status} ${error}`); throw new Error(`VoceChat API error: ${res.status} ${error}`);
} }
const data = await res.json(); const data = await res.text();
return { ok: true, messageId: String(data) }; return { ok: true, messageId: String(data) };
} catch (err) { } catch (e) {}
api.logger.error('VoceChat: Failed to reply', err);
return { ok: false, error: String(err) };
}
} }
/** /**
* *
*/ */
export async function uploadFile( export async function replyToMessage(
api: PluginApi, cfg: OpenClawConfig,
fileBuffer: Buffer, accountId?: string | null,
fileName: string, text?: string,
account: VoceChatAccount mid?: string | number,
): Promise<{ ok: boolean; filePath?: string; error?: string }> { ) {
const { serverUrl, apiKey } = account; const { serverUrl, apiKey } = cfg.channels?.vocechat || {};
try { try {
// 1. 准备上传 const endpoint = `${serverUrl}/api/bot/reply/${mid}`;
const prepareRes = await fetch(`${serverUrl}/api/bot/file/prepare`, {
method: 'POST', const res = await fetch(endpoint, {
method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "text/plain",
'x-api-key': apiKey, "x-api-key": apiKey,
}, },
body: JSON.stringify({ body: text,
content_type: 'application/octet-stream',
file_name: fileName,
}),
}); });
if (!prepareRes.ok) { if (!res.ok) {
throw new Error('Failed to prepare file upload'); const error = await res.text();
throw new Error(`VoceChat API error: ${res.status} ${error}`);
} }
const { upload_url, file_path } = await prepareRes.json(); const data = await res.text();
return { ok: true, messageId: String(data) };
// 2. 上传文件 } catch (err) {}
const uploadRes = await fetch(upload_url, {
method: 'PUT',
body: fileBuffer,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload file');
}
return { ok: true, filePath: file_path };
} catch (err) {
api.logger.error('VoceChat: Failed to upload file', err);
return { ok: false, error: String(err) };
}
} }
export default {
deliveryMode: "gateway",
sendText({ text, cfg, accountId, to, replyToId }: ChannelOutboundContext) {
if (!text) return;
return sendTextToVoceChat(cfg, to, text)
.then((result) => {
if (result?.ok) {
return {
channel: "vocechat",
messageId: result.messageId,
};
}
})
.catch((err) => {});
},
} as ChannelOutboundAdapter;

7
src/types/index.ts Normal file
View File

@ -0,0 +1,7 @@
// VoceChat 账号配置类型
export interface VoceChatAccount {
serverUrl: string;
botApiToken: string;
wsServerUrl: string;
enabled?: boolean;
}