diff --git a/.gitignore b/.gitignore index 2c7db28..da7226d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,9 @@ dist/ .DS_Store .env .vscode/ -.idea/ \ No newline at end of file +.idea/ + +# Vault - sensitive data +.vault/ +*.secret +*.key \ No newline at end of file diff --git a/src/inbound.ts b/src/inbound.ts new file mode 100644 index 0000000..a0fb86d --- /dev/null +++ b/src/inbound.ts @@ -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, + 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(); + }, + }; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8592d8c..4357086 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); + // 存储连接控制函数 + const connections = new Map 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`); } }); }); diff --git a/src/outbound.ts b/src/outbound.ts new file mode 100644 index 0000000..d7c70d0 --- /dev/null +++ b/src/outbound.ts @@ -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 { + 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 { + 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 { + 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 { + 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) }; + } +} \ No newline at end of file