UNPKG

@zhin.js/adapter-onebot11

Version:

zhin adapter for onebot11

505 lines (467 loc) 16.3 kB
import WebSocket, { WebSocketServer } from 'ws'; import { EventEmitter } from "events"; import { Bot, Plugin, Adapter, usePlugin, Message, registerAdapter, MessageSegment, SendOptions, segment, useContext, SendContent } from 'zhin.js'; import type { Router } from '@zhin.js/http' import { IncomingMessage } from "http"; import { clearInterval } from "node:timers"; // 声明模块,注册 onebot11 适配器类型 declare module '@zhin.js/types' { interface RegisteredAdapters { 'onebot11': Adapter<OneBot11WsClient> 'onebot11.wss': Adapter<OneBot11WsServer> } } // ============================================================================ // OneBot11 配置和类型 // ============================================================================ export interface OneBot11Config extends Bot.Config { context: 'onebot11'; type: string access_token?: string; } export interface OneBot11WsClientConfig extends OneBot11Config { type: 'ws' url: string; reconnect_interval?: number; heartbeat_interval?: number; } export interface OneBot11WsServerConfig extends OneBot11Config { type: 'ws_reverse' path: string heartbeat_interval?: number; } export interface OneBot11HTTPConfig extends OneBot11Config { type: 'http_sse' port: number path: string } interface OneBot11Message { post_type: string; self_id: string message_type?: string; sub_type?: string; message_id: number; user_id: number; group_id?: number; message: Array<{ type: string; data: Record<string, any>; }>; raw_message: string; time: number; } interface ApiResponse<T = any> { status: string; retcode: number; data: T; echo?: string; } const plugin =usePlugin() // ============================================================================ // OneBot11 适配器实现 // ============================================================================ export class OneBot11WsClient extends EventEmitter implements Bot<OneBot11Message, OneBot11WsClientConfig> { $connected?: boolean private ws?: WebSocket; private reconnectTimer?: NodeJS.Timeout; private heartbeatTimer?: NodeJS.Timeout; private requestId = 0; private pendingRequests = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; }>(); constructor(public $config: OneBot11WsClientConfig) { super(); this.$connected = false } async $connect(): Promise<void> { return new Promise((resolve, reject) => { let wsUrl = this.$config.url; const headers: Record<string, string> = {}; if (this.$config.access_token) { headers['Authorization'] = `Bearer ${this.$config.access_token}`; } this.ws = new WebSocket(wsUrl, { headers }); this.ws.on('open', () => { this.$connected = true; if (!this.$config.access_token) plugin.logger.warn(`missing 'access_token', your OneBot protocol is not safely`) this.startHeartbeat(); resolve(); }); this.ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.handleWebSocketMessage(message); } catch (error) { this.emit('error', error) } }); this.ws.on('close', (code, reason) => { this.$connected = false reject({ code, reason }) this.scheduleReconnect(); }); this.ws.on('error', (error) => { reject(error); }); }); } async $disconnect(): Promise<void> { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } // 清理所有待处理的请求 for (const [id, request] of this.pendingRequests) { clearTimeout(request.timeout); request.reject(new Error('Connection closed')); } this.pendingRequests.clear(); if (this.ws) { this.ws.close(); this.ws = undefined; } } $formatMessage(onebotMsg: OneBot11Message) { const message = Message.from(onebotMsg, { $id: onebotMsg.message_id.toString(), $adapter: 'onebot11', $bot: `${this.$config.name}`, $sender: { id: onebotMsg.user_id.toString(), name: onebotMsg.user_id.toString() }, $channel: { id: (onebotMsg.group_id || onebotMsg.user_id).toString(), type: onebotMsg.group_id ? 'group' : 'private' }, $content: onebotMsg.message, $raw: onebotMsg.raw_message, $timestamp: onebotMsg.time, $recall: async () => { await this.$recallMessage(message.$id) }, $reply: async (content: MessageSegment[], quote?: boolean | string): Promise<string> => { if (quote) content.unshift({ type: 'reply', data: { message_id: message.$id } }) return await this.$sendMessage({ ...message.$channel, context: 'onebot11', bot: `${this.$config.name}`, content }) } }) return message } async $sendMessage(options: SendOptions): Promise<string> { options = await plugin.app.handleBeforeSend(options) const messageData: any = { message: options.content }; if (options.type === 'group') { const result = await this.callApi('send_group_msg', { group_id: parseInt(options.id), ...messageData }); plugin.logger.info(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`) return result.message_id.toString(); } else if (options.type === 'private') { const result = await this.callApi('send_private_msg', { user_id: parseInt(options.id), ...messageData }); plugin.logger.info(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`) return result.message_id.toString(); } else { throw new Error('Either group_id or user_id must be provided'); } return ''; } async $recallMessage(id: string): Promise<void> { await this.callApi('delete_msg', { message_id: parseInt(id) }); } private async callApi(action: string, params: any = {}): Promise<any> { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('WebSocket is not connected'); } const echo = `req_${++this.requestId}`; const message = { action, params, echo }; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(echo); reject(new Error(`API call timeout: ${action}`)); }, 30000); // 30秒超时 this.pendingRequests.set(echo, { resolve, reject, timeout }); this.ws!.send(JSON.stringify(message)); }); } private handleWebSocketMessage(message: any): void { // 处理API响应 if (message.echo && this.pendingRequests.has(message.echo)) { const request = this.pendingRequests.get(message.echo)!; this.pendingRequests.delete(message.echo); clearTimeout(request.timeout); const response = message as ApiResponse; if (response.status === 'ok') { return request.resolve(response.data); } return request.reject(new Error(`API error: ${response.retcode}`)); } // 处理事件消息 if (message.post_type === 'message') { this.handleOneBot11Message(message); } else if (message.post_type === 'meta_event' && message.meta_event_type === 'heartbeat') { // 心跳消息,暂时忽略 } } private handleOneBot11Message(onebotMsg: OneBot11Message): void { const message = this.$formatMessage(onebotMsg); plugin.dispatch('message.receive', message) plugin.logger.info(`${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`) plugin.dispatch(`message.${message.$channel.type}.receive`, message) } private startHeartbeat(): void { const interval = this.$config.heartbeat_interval || 30000; this.heartbeatTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.ping(); } }, interval); } private scheduleReconnect(): void { if (this.reconnectTimer) { return; } const interval = this.$config.reconnect_interval || 5000; this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = undefined; try { await this.$connect(); } catch (error) { this.emit('error', new Error(`Reconnection failed: ${error}`)); this.scheduleReconnect(); } }, interval); } } export class OneBot11WsServer extends EventEmitter implements Bot<OneBot11Message, OneBot11WsServerConfig> { $connected?: boolean #wss?: WebSocketServer #clientMap: Map<string, WebSocket> = new Map<string, WebSocket>() private heartbeatTimer?: NodeJS.Timeout; private requestId = 0; private pendingRequests = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; }>(); constructor(public router: Router, public $config: OneBot11WsServerConfig) { super(); this.$connected = false } async $connect(): Promise<void> { if (!this.$config.access_token) plugin.logger.warn(`missing 'access_token', your OneBot protocol is not safely`) this.#wss = this.router.ws(this.$config.path, { verifyClient: (info: { origin: string; secure: boolean; req: IncomingMessage }) => { const { req: { headers }, } = info; const authorization = headers['authorization'] || ''; if (this.$config.access_token && authorization !== `Bearer ${this.$config.access_token}`) { plugin.logger.error('鉴权失败'); return false; } return true; } }) this.$connected = true; plugin.logger.info(`ws server start at path:${this.$config.path}`) this.#wss.on('connection', (client, req) => { this.startHeartbeat(); plugin.logger.info(`已连接到协议端:${req.socket.remoteAddress}`); client.on('error', err => { plugin.logger.error('连接出错:', err); }); client.on('close', code => { plugin.logger.error(`与连接端(${req.socket.remoteAddress})断开,错误码:${code}`); for (const [key, value] of this.#clientMap) { if (client === value) this.#clientMap.delete(key) } }); client.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.handleWebSocketMessage(client, message); } catch (error) { this.emit('error', error) } }) }); } async $disconnect(): Promise<void> { this.#wss?.close(); if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer) delete this.heartbeatTimer; } } $formatMessage(onebotMsg: OneBot11Message) { const message = Message.from(onebotMsg, { $id: onebotMsg.message_id.toString(), $adapter: 'onebot11', $bot: `${this.$config.name}`, $sender: { id: onebotMsg.user_id.toString(), name: onebotMsg.user_id.toString() }, $channel: { id: [onebotMsg.self_id, (onebotMsg.group_id || onebotMsg.user_id)].join(':'), type: onebotMsg.group_id ? 'group' : 'private' }, $content: onebotMsg.message, $raw: onebotMsg.raw_message, $timestamp: onebotMsg.time, $recall: async () => { await this.$recallMessage(message.$id) }, $reply: async (content: SendContent, quote?: boolean | string): Promise<string> => { if (!Array.isArray(content)) content = [content]; if (quote) content.unshift({ type: 'reply', data: { message_id: message.$id } }) return await this.$sendMessage({ ...message.$channel, context: 'onebot11', bot: `${this.$config.name}`, content }) } }) return message } async $sendMessage(options: SendOptions): Promise<string> { options = await plugin.app.handleBeforeSend(options) const messageData: any = { message: options.content }; if (options.type === 'group') { const [self_id, id] = options.id.split(':') const result = await this.callApi(self_id, 'send_group_msg', { group_id: parseInt(id), ...messageData }); plugin.logger.info(`${this.$config.name} send ${options.type}(${id}):${segment.raw(options.content)}`) return result.message_id.toString(); } else if (options.type === 'private') { const [self_id, id] = options.id.split(':') const result = await this.callApi(self_id, 'send_private_msg', { user_id: parseInt(id), ...messageData }); plugin.logger.info(`${this.$config.name} send ${options.type}(${id}):${segment.raw(options.content)}`) return result.message_id.toString(); } else { throw new Error('Either group_id or user_id must be provided'); } return ''; } async $recallMessage(id: string): Promise<void> { const [self_id, message_id] = id.split(':') await this.callApi(self_id, 'delete_msg', { message_id: parseInt(message_id) }); } private async callApi(self_id: string, action: string, params: any = {}): Promise<any> { const client = this.#clientMap.get(self_id) if (!client || client.readyState !== WebSocket.OPEN) { throw new Error('WebSocket is not connected'); } const echo = `req_${++this.requestId}`; const message = { action, params, echo }; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(echo); reject(new Error(`API call timeout: ${action}`)); }, 30000); // 30秒超时 this.pendingRequests.set(echo, { resolve, reject, timeout }); client.send(JSON.stringify(message)); }); } private handleWebSocketMessage(client: WebSocket, message: any): void { // 处理API响应 if (message.echo && this.pendingRequests.has(message.echo)) { const request = this.pendingRequests.get(message.echo)!; this.pendingRequests.delete(message.echo); clearTimeout(request.timeout); const response = message as ApiResponse; if (response.status === 'ok') { return request.resolve(response.data); } return request.reject(new Error(`API error: ${response.retcode}`)); } switch (message.post_type) { case 'message': return this.handleMessage(message); case 'meta_event': return this.handleMetaEvent(client, message) } // 处理事件消息 if (message.post_type === 'message') { } else if (message.post_type === 'meta_event' && message.meta_event_type === 'heartbeat') { // 心跳消息,暂时忽略 } } private handleMetaEvent(client: WebSocket, message: any) { switch (message.sub_type) { case 'heartbeat': break; case 'connect': this.#clientMap.set(message.self_id, client); plugin.logger.info(`client ${message.self_id} of ${this.$config.name} by ${this.$config.context} connected`) break; } } private handleMessage(onebotMsg: OneBot11Message): void { const message = this.$formatMessage(onebotMsg); plugin.dispatch('message.receive', message) plugin.logger.info(`${this.$config.name} recv ${message.$channel.type}(${onebotMsg.group_id || onebotMsg.user_id}):${segment.raw(message.$content)}`) plugin.dispatch(`message.${message.$channel.type}.receive`, message) } private startHeartbeat(): void { const interval = this.$config.heartbeat_interval || 30000; this.heartbeatTimer = setInterval(() => { for (const client of this.#wss?.clients || []) { if (client && client.readyState === WebSocket.OPEN) { client.ping(); } } }, interval); } } registerAdapter(new Adapter('onebot11', OneBot11WsClient)) useContext('router', (router) => { registerAdapter(new Adapter('onebot11.wss', (c: OneBot11WsServerConfig) => new OneBot11WsServer(router, c))); })