refactor: split inbound/outbound into separate files

- Extract inbound message handling to inbound.ts (WebSocket, pending Webhook migration)
- Extract outbound message handling to outbound.ts with updated VoceChat API
- Update authentication to use x-api-key header
- Add support for text, markdown, file, and reply messages
- Add file upload helper functions
This commit is contained in:
root 2026-03-13 20:31:23 +08:00
parent bbc862273e
commit f3c16c7609
4 changed files with 356 additions and 171 deletions

5
.gitignore vendored
View File

@ -5,3 +5,8 @@ dist/
.env
.vscode/
.idea/
# Vault - sensitive data
.vault/
*.secret
*.key

115
src/inbound.ts Normal file
View File

@ -0,0 +1,115 @@
import type { PluginApi, ChannelAccount } 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 extends ChannelAccount {
serverUrl: string;
apiKey: string;
botName?: string;
}
/**
*
*
* TODO: 当前使用 WebSocket Webhook
* Webhook
* 1. HTTP Webhook
* 2. VoceChat Webhook URL
* 3. Webhook GET 200
* 4. POST
*/
export async function startInbound(
api: PluginApi,
account: VoceChatAccount,
onMessage: (message: any) => Promise<void>,
onError: (error: Error) => void
): Promise<{ stop: () => void }> {
const { serverUrl, apiKey } = account;
const accountId = account.accountId;
if (!serverUrl || !apiKey) {
throw new Error('VoceChat: serverUrl and apiKey are required');
}
// 注意:当前使用 WebSocket 连接,后续应改为 Webhook
const wsUrl = serverUrl.replace(/^http/, 'ws') + '/ws';
api.logger.info(`VoceChat [${accountId}]: Connecting to ${wsUrl}`);
api.logger.warn(`VoceChat [${accountId}]: 当前使用 WebSocket建议迁移到 Webhook 方案`);
const ws = new WebSocket(wsUrl, {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
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`);
});
return {
stop: () => {
ws.close();
},
};
}

View File

@ -1,25 +1,8 @@
import type { PluginApi } from 'openclaw/plugin-sdk/core';
import WebSocket from 'ws';
import { startInbound } from './inbound.js';
import { sendText, sendMarkdown, sendFile, replyToMessage } from './outbound.js';
// 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 配置类型
// VoceChat 账号配置类型
interface VoceChatAccount {
serverUrl: string;
apiKey: string;
@ -30,8 +13,8 @@ interface VoceChatAccount {
export default function register(api: PluginApi) {
api.logger.info('VoceChat plugin loading...');
// 存储 WebSocket 连接
const connections = new Map<string, WebSocket>();
// 存储连接控制函数
const connections = new Map<string, { stop: () => void }>();
// 注册 VoceChat 频道
api.registerChannel({
@ -73,7 +56,6 @@ export default function register(api: PluginApi) {
};
},
// 只读检查(用于状态显示)
inspectAccount: (cfg: any, accountId?: string) => {
const account = cfg.channels?.vocechat?.accounts?.[accountId ?? 'default'];
if (!account) {
@ -97,79 +79,14 @@ export default function register(api: PluginApi) {
// 入站消息处理
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);
},
};
const connection = await startInbound(
api,
account as VoceChatAccount,
onMessage,
onError
);
connections.set(account.accountId, connection);
return connection;
},
},
@ -178,100 +95,40 @@ export default function register(api: PluginApi) {
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) };
}
return sendText(api, text, chat, account as VoceChatAccount);
},
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) };
}
// 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', // 默认配对模式
dmPolicy: 'pairing',
},
// 状态检查
status: {
async check({ account }) {
const { serverUrl, apiKey } = account as VoceChatAccount;
try {
const res = await fetch(`${serverUrl}/api/user/me`, {
const res = await fetch(`${serverUrl}/api/bot`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'x-api-key': apiKey,
},
});
if (res.ok) {
const user = await res.json();
const data = await res.json();
return {
ok: true,
status: 'connected',
details: `Logged in as ${user.name || user.uid}`,
details: `Bot is active, ${data.length || 0} channels`,
};
} else {
return {
@ -300,8 +157,8 @@ export default function register(api: PluginApi) {
.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'}`);
for (const [id, conn] of connections) {
console.log(` - ${id}: active`);
}
});
});

208
src/outbound.ts Normal file
View File

@ -0,0 +1,208 @@
import type { PluginApi, ChannelAccount, ChannelChat, OutboundResult } from 'openclaw/plugin-sdk/core';
// VoceChat 账号配置类型
interface VoceChatAccount extends ChannelAccount {
serverUrl: string;
apiKey: string;
botName?: string;
}
/**
*
*/
export async function sendText(
api: PluginApi,
text: string,
chat: ChannelChat,
account: VoceChatAccount
): Promise<OutboundResult> {
const { serverUrl, apiKey } = account;
try {
// 根据聊天类型选择 API 端点
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': 'text/plain',
'x-api-key': apiKey,
},
body: text,
});
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 text', err);
return { ok: false, error: String(err) };
}
}
/**
* Markdown
*/
export async function sendMarkdown(
api: PluginApi,
markdown: 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': 'text/markdown',
'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,
});
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 reply', err);
return { ok: false, error: String(err) };
}
}
/**
*
*/
export async function uploadFile(
api: PluginApi,
fileBuffer: Buffer,
fileName: string,
account: VoceChatAccount
): Promise<{ ok: boolean; filePath?: string; error?: string }> {
const { serverUrl, apiKey } = account;
try {
// 1. 准备上传
const prepareRes = await fetch(`${serverUrl}/api/bot/file/prepare`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify({
content_type: 'application/octet-stream',
file_name: fileName,
}),
});
if (!prepareRes.ok) {
throw new Error('Failed to prepare file upload');
}
const { upload_url, file_path } = await prepareRes.json();
// 2. 上传文件
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) };
}
}