UNPKG

koibot

Version:

KoiBot - 一个基于 node-napcat-ts 的 QQ 机器人

424 lines (387 loc) 13.7 kB
import type {EventHandleMap} from "node-napcat-ts"; import {NCWebsocket} from "node-napcat-ts"; import {join} from "path"; import {existsSync, mkdirSync, readFileSync, writeFileSync} from "fs"; import * as winston from "winston"; import {parse} from "yaml"; import axios from "axios"; import {createJiti} from "jiti" import {readdirSync} from "node:fs"; import * as cron from "node-cron"; // Config export function getConfig(): Config { initConfig() return parse(readFileSync(join(process.cwd(), "config.yaml"), "utf-8")) } export function initConfig(): void { const configPath = join(process.cwd(), "config.yaml") if (!existsSync(configPath)) { writeFileSync(configPath, defaultConfig, "utf-8"); } } const defaultConfig = `\ napcat: baseUrl: 'ws://localhost:3001' accessToken: '' throwPromise: false # 是否需要在触发 socket.error 时抛出错误,默认关闭 reconnection: enable: true attempts: 10 delay: 5000 # 单位可能是毫秒(ms) debug: false self: master: [1447007223] # 这一项是一个数字数组的首项包含了单个数字“1447007223” \ ` export interface Config { napcat: { baseUrl: string, accessToken: string, throwPromise: boolean reconnection: { enable: boolean, attempts: number delay: number }, debug: boolean }, self: { master: Array<number> } } // Index const logo = ` _______________#########_______________________ ______________############_____________________ ______________#############____________________ _____________##__###########___________________ ____________###__######_#####__________________ ____________###_#######___####_________________ ___________###__##########_####________________ __________####__###########_####_______________ ________#####___###########__#####_____________ _______######___###_########___#####___________ _______#####___###___########___######_________ ______######___###__###########___######_______ _____######___####_##############__######______ ____#######__#####################_#######_____ ____#######__##############################____ ___#######__######_#################_#######___ ___#######__######_######_#########___######___ ___#######____##__######___######_____######___ ___#######________######____#####_____#####____ ____######________#####_____#####_____####_____ _____#####________####______#####_____###______ ______#####______;###________###______#________ ________##_______####________####______________ KoiBot 一个基于 node-napcat-ts 的 QQ 机器人 参考: kivibot@viki && Abot@takayama @auther: Admsec github: https://github.com/Admsec\ ` const logPath = join(process.cwd(), "log") if (!existsSync(logPath)) mkdirSync(logPath); const logFileName = join(logPath, new Date().toLocaleString().split(" ")[0].replace(/\//g, "-") + ".log") const alignColorsAndTime = winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: "YY-MM-DD HH:mm:ss" }), winston.format.colorize({ all: true, }), winston.format.printf( info => `${info.level} ${info.timestamp} ${info.message}` ) ); export const log = winston.createLogger({ level: "info", transports: [ new (winston.transports.Console)({ format: winston.format.combine(winston.format.colorize(), alignColorsAndTime) }), new winston.transports.File({filename: logFileName}) // 文件输出 ], }); export class Bot { private bot: NCWebsocket; private config: Config; private pluginManager: PluginManager; private plugins: {} | null; constructor() { this.config = getConfig(); this.bot = new NCWebsocket({ "baseUrl": this.config.napcat.baseUrl, "accessToken": this.config.napcat.accessToken, "reconnection": { "enable": this.config.napcat.reconnection.enable, "attempts": this.config.napcat.reconnection.attempts, "delay": this.config.napcat.reconnection.delay } }, this.config.napcat.debug); this.pluginManager = new PluginManager(this.bot, this.config); this.plugins = null; } async start() { log.info(logo) this.bot.on("socket.open", (ctx) => { log.info("[*]开始连接: " + this.config.napcat.baseUrl) }) this.bot.on("socket.error", (ctx) => { log.error("[-]websocket 连接错误: " + ctx.error_type) }) this.bot.on("socket.close", (ctx) => { log.error("[-]websocket 连接关闭: " + ctx.code) }) this.bot.on("meta_event.lifecycle", (ctx) => { if (ctx.sub_type == "connect") log.info(`[+]连接成功: ${this.config.napcat.baseUrl}`) }) this.bot.on("meta_event.heartbeat", (ctx) => { log.info(`[*]心跳包♥`) }) this.bot.on("message", (ctx) => { log.info("[*]receive message: " + ctx.raw_message) }) this.bot.on("api.response.failure", (ctx) => { log.error(`[-]ApiError, status: ${ctx.status}, message: ${ctx.message}`) }) this.bot.on("api.preSend", (ctx) => { log.info(`[*]${ctx.action}: ${JSON.stringify(ctx.params)}`) }) this.plugins = await this.pluginManager.init() await this.bot.connect() } } // Plugin export function definePlugin(plugin: KoiPlugin): KoiPlugin { return plugin; } interface PluginInfo { version: string, description: string setup: { enable: boolean, listeners: Array<listener>; cron: Array<any>; } } interface listener { event: keyof EventHandleMap, fn: any; } interface pluginUtil { getPlugins: () => Map<string, PluginInfo>; onPlugin: (pluginName: string) => string; offPlugin: (pluginName: string) => string; reloadPlugin: (pluginName: string) => Promise<string>; getPluginsFromDir: () => string[]; loadPlugin: (pluginName: string) => Promise<string>; } interface KoiPluginContext { config: Config; /** axios 实例 */ http: typeof axios; bot: NCWebsocket; plugin: pluginUtil; /** cron 定时任务 */ cron: ( expression: string, func: () => any ) => any; /** 注册事件处理器 */ handle: <EventName extends keyof EventHandleMap>( eventName: EventName, handler: EventHandleMap[EventName] ) => any; /** 是否为主人 */ isMaster: ( id: | number | { sender: { user_id: number; }; } ) => boolean; } interface KoiPlugin { /** 插件 ID */ name: string; /** 插件版本 */ version?: string; /** 插件描述 */ description?: string; /** 插件初始化,可返回一个函数用于清理 */ setup?: (ctx: KoiPluginContext) => any; } export class PluginManager { public plugins: Map<string, PluginInfo>; public bot: NCWebsocket; public ctx: KoiPluginContext; private tempListener: Array<listener>; private tempCronJob: Array<any>; private jiti: any; constructor(bot: NCWebsocket, config: Config) { this.plugins = new Map<string, PluginInfo>(); // @ts-ignore this.jiti = createJiti(import.meta.url, {moduleCache: false}) this.bot = bot; this.tempListener = []; this.tempCronJob = []; this.ctx = { config: config, http: axios, bot: this.bot, cron: (expression, func) => { if(!cron.validate(expression)){ this.tempCronJob.push(false) } this.tempCronJob.push(cron.schedule(expression, func, { scheduled: false })) }, plugin: { getPlugins: () => { return this.getPlugins(); }, onPlugin: (pluginName: string) => { return this.onPlugin(pluginName) }, offPlugin: (pluginName: string) => { return this.offPlugin(pluginName) }, reloadPlugin: (pluginName: string): Promise<string> => { return this.reloadPlugin(pluginName) }, getPluginsFromDir: (): string[] => { return this.getPluginsFromDir(); }, loadPlugin: (pluginName: string): Promise<any> => { return this.loadPlugin(pluginName); } }, handle: (eventName: any, func) => { const obj = { event: eventName, fn: func } this.tempListener.push(obj) }, isMaster: (e) => { if (typeof e === 'number' && !isNaN(e)) { return this.ctx.config.self.master.includes(e) } // 检查 e 是否是对象并获取 user_id if (typeof e === 'object' && e.sender && typeof e.sender.user_id === 'number') { return this.ctx.config.self.master.includes(e.sender.user_id); } return false; // 如果都不是,返回 false } }; } async init() { const pluginList = this.getPluginsFromDir(); let success = 0, fail = 0; for (const p of pluginList) { const pluginPath = join(process.cwd(), "plugins", p, "index.ts"); try { await this.loadPlugin(pluginPath); success++; } catch (err) { log.error(`[-]插件${p}导入失败: ${err}`); fail++; } } log.info( `[+]插件加载完毕, 一共导入${ success + fail }个插件, 成功: ${success}, 失败: ${fail}` ); return this.plugins; } getPluginsFromDir(): string[] { const path_ = join(process.cwd(), "plugins"); if (!existsSync(path_)) { log.warn(`[-]插件文件夹不存在, 自动创建插件文件夹: plugins`); mkdirSync(path_); } return readdirSync(path_) } async loadPlugin(pluginPath: string): Promise<any> { const plugin = await this.jiti.import(pluginPath) try { plugin.default.setup(this.ctx) this.plugins.set(plugin.default.name, { version: plugin.default.version || "0.1.0", description: plugin.default.description || "", setup: { enable: false, listeners: this.tempListener, cron: this.tempCronJob } }) log.info(this.onPlugin(plugin.default.name)) this.tempListener = []; this.tempCronJob = []; return plugin; } catch (err) { log.error(`[-]插件${pluginPath}导入失败, 原因: ${err}`) return false } } getPlugins() { return this.plugins } offPlugin(pluginName: string) { const map = this.plugins.get(pluginName) as PluginInfo; if (!this.plugins.has(pluginName)) { return "[-]该插件不存在" } if (!map?.setup && map.setup?.enable) { return "[-]该插件没有启用" } for (const p of map.setup.listeners) { this.bot.off(p.event, p.fn) } for (const p of map.setup.cron) { p.stop() } map.setup.enable = false; return `[+]插件${pluginName}已禁用` } onPlugin(pluginName: string) { const map = this.plugins.get(pluginName) as PluginInfo; if (!this.plugins.has(pluginName)) { return "[-]该插件不存在" } if (map?.setup && map.setup?.enable) { return "[-]该插件没有被禁用" } // 插件函数 for (const p of map.setup.listeners) { this.bot.on(p.event, p.fn) } // 定时任务 for (const p of map.setup.cron) { if(!p){ return `[-]插件${pluginName}的定时任务启动出错, 请检查一下cron表达式` } p.start() } map.setup.enable = true; return `[+]插件${pluginName}已启用` } async reloadPlugin(pluginName: string): Promise<any> { const pluginPath = join(process.cwd(), "plugins", pluginName, "index.ts"); if (!this.plugins.has(pluginName) && !existsSync(pluginPath)) { return "[-]该插件不存在" } const map = this.plugins.get(pluginName) as PluginInfo; // 如果缓存有 // 如果插件目前是开启的 if (map?.setup && map.setup?.enable) { log.info(this.offPlugin(pluginName)); } return await this.loadPlugin(pluginPath) } }