koibot
Version:
KoiBot - 一个基于 node-napcat-ts 的 QQ 机器人
424 lines (387 loc) • 13.7 kB
text/typescript
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
: 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)
}
}