Miao-Yunzai/lib/plugins/loader.js

865 lines
22 KiB
JavaScript
Raw Normal View History

2023-05-11 16:03:18 +08:00
import util from "node:util"
import fs from "node:fs"
import lodash from "lodash"
import cfg from "../config/config.js"
import plugin from "./plugin.js"
import schedule from "node-schedule"
import chokidar from "chokidar"
import moment from "moment"
import path from "node:path"
import common from "../common/common.js"
import Runtime from "./runtime.js"
/** 全局变量 plugin */
global.plugin = plugin
function toSegment(type, data) {
for (const i in data) {
if (data[i]) {
switch (typeof data[i]) {
case "string":
if ((i == "file" || data[i].match(/^file:\/\//)) && fs.existsSync(data[i].replace(/^file:\/\//, "")))
data[i] = `base64://${fs.readFileSync(data[i].replace(/^file:\/\//, "")).toString("base64")}`
break
case "object":
if (Buffer.isBuffer(data[i]))
data[i] = `base64://${data[i].toString("base64")}`
}
}
}
return { type, ...data }
}
global.segment = {
image: file => toSegment("image", { file }),
at: (qq, name) => toSegment("at", { qq, name }),
record: file => toSegment("record", { file }),
video: file => toSegment("video", { file }),
reply: (id, text, qq, time, seq) => toSegment("reply", { id, text, qq, time, seq }),
face: id => toSegment("face", { id }),
share: (url, title, content, image) => toSegment("share", { url, title, content, image }),
music: (type, id, url, audio, title) => toSegment("music", { type, id, url, audio, title }),
poke: qq => toSegment("poke", { qq }),
gift: (qq, id) => toSegment("gift", { qq, id }),
xml: (data, resid) => toSegment("xml", { data, resid }),
json: (data, resid) => toSegment("json", { data, resid }),
cardimage: (file, minwidth, minheight, maxwidth, maxheight, source, icon) => toSegment("cardimage", { file, minwidth, minheight, maxwidth, maxheight, source, icon }),
tts: text => toSegment("tts", { text }),
custom: toSegment,
}
/**
* 加载插件
*/
class PluginsLoader {
constructor() {
this.priority = []
this.task = []
this.dir = "./plugins"
/** 命令冷却cd */
this.groupCD = {}
this.singleCD = {}
/** 插件监听 */
this.watcher = {}
}
/**
* 监听事件加载
* @param isRefresh 是否刷新
*/
async load(isRefresh = false) {
this.delCount()
if (!lodash.isEmpty(this.priority) && !isRefresh) return
const files = this.getPlugins()
logger.info("-----------")
logger.info("加载插件中...")
let pluCount = 0
let packageErr = []
for (let File of files) {
try {
let tmp = await import(File.path)
if (tmp.apps) tmp = { ...tmp.apps }
let isAdd = false
lodash.forEach(tmp, (p, i) => {
if (!p.prototype) {
return
}
isAdd = true
/* eslint-disable new-cap */
let plugin = new p()
logger.debug(`载入插件 [${File.name}][${plugin.name}]`)
/** 执行初始化 */
this.runInit(plugin)
/** 初始化定时任务 */
this.collectTask(plugin.task)
this.priority.push({
class: p,
key: File.name,
name: plugin.name,
priority: plugin.priority
})
})
if (isAdd) pluCount++
} catch (error) {
if (error.stack.includes("Cannot find package")) {
packageErr.push({ error, File })
} else {
logger.error(`载入插件错误:${logger.red(File.name)}`)
logger.error(decodeURI(error.stack))
}
}
}
this.packageTips(packageErr)
this.creatTask()
logger.info(`加载定时任务[${this.task.length}个]`)
logger.info(`加载插件[${pluCount}个]`)
/** 优先级排序 */
this.priority = lodash.orderBy(this.priority, ["priority"], ["asc"])
// console.log(this.priority)
}
async runInit(plugin) {
plugin.init && plugin.init()
}
packageTips(packageErr) {
if (!packageErr || packageErr.length <= 0) return
logger.mark("--------插件载入错误--------")
packageErr.forEach(v => {
let pack = v.error.stack.match(/'(.+?)'/g)[0].replace(/'/g, "")
logger.mark(`${v.File.name} 缺少依赖:${logger.red(pack)}`)
logger.mark(`新增插件后请执行安装命令:${logger.red("pnpm install -P")} 安装依赖`)
logger.mark(`如安装后仍未解决可联系插件作者将 ${logger.red(pack)} 依赖添加至插件的package.json dependencies中或手工安装依赖`)
})
// logger.error("或者使用其他包管理工具安装依赖")
logger.mark("---------------------")
}
getPlugins() {
let ignore = ["index.js"]
let files = fs.readdirSync(this.dir, { withFileTypes: true })
let ret = []
for (let val of files) {
let filepath = "../../plugins/" + val.name
let tmp = {
name: val.name,
}
if (val.isFile()) {
if (!val.name.endsWith(".js")) continue
if (ignore.includes(val.name)) continue
tmp.path = filepath
ret.push(tmp)
continue
}
if (fs.existsSync(`${this.dir}/${val.name}/index.js`)) {
tmp.path = filepath + "/index.js"
ret.push(tmp)
continue
}
let apps = fs.readdirSync(`${this.dir}/${val.name}`, { withFileTypes: true })
for (let app of apps) {
if (!app.name.endsWith(".js")) continue
if (ignore.includes(app.name)) continue
ret.push({
name: `${val.name}/${app.name}`,
path: `../../plugins/${val.name}/${app.name}`
})
/** 监听热更新 */
this.watch(val.name, app.name)
}
}
return ret
}
/**
* 处理事件
*
* 参数文档 https://oicqjs.github.io/oicq/interfaces/GroupMessageEvent.html
* @param e icqq Events
*/
async deal(e) {
/** 检查黑白名单 */
if (!this.checkBlack(e)) return
/** 冷却 */
if (!this.checkLimit(e)) return
/** 处理消息 */
this.dealMsg(e)
/** 处理回复 */
this.reply(e)
/** 过滤事件 */
let priority = []
/** 注册runtime */
await Runtime.init(e)
this.priority.forEach(v => {
let p = new v.class(e)
p.e = e
/** 判断是否启用功能 */
if (!this.checkDisable(e, p)) return
/** 过滤事件 */
if (!this.filtEvent(e, p)) return
priority.push(p)
})
for (let plugin of priority) {
/** 上下文hook */
if (plugin.getContext) {
let context = plugin.getContext()
if (!lodash.isEmpty(context)) {
for (let fnc in context) {
plugin[fnc](context[fnc])
}
return
}
}
/** 群上下文hook */
if (plugin.getContextGroup) {
let context = plugin.getContextGroup()
if (!lodash.isEmpty(context)) {
for (let fnc in context) {
plugin[fnc](context[fnc])
}
return
}
}
}
/** 是否只关注主动at */
if (!this.onlyReplyAt(e)) return
/** accept */
for (let plugin of priority) {
/** accept hook */
if (plugin.accept) {
let res = plugin.accept(e)
if (util.types.isPromise(res)) res = await res
if (res === "return") return
if (res) break
}
}
/* eslint-disable no-labels */
a: for (let plugin of priority) {
/** 正则匹配 */
if (plugin.rule) {
for (let v of plugin.rule) {
/** 判断事件 */
if (v.event && !this.filtEvent(e, v)) continue
if (new RegExp(v.reg).test(e.msg)) {
e.logFnc = `[${plugin.name}][${v.fnc}]`
if (v.log !== false) {
logger.mark(`${e.logFnc}${e.logText} ${lodash.truncate(e.msg, { length: 80 })}`)
}
/** 判断权限 */
if (!this.filtPermission(e, v)) break a
try {
let res = plugin[v.fnc] && plugin[v.fnc](e)
let start = Date.now()
if (util.types.isPromise(res)) res = await res
if (res !== false) {
/** 设置冷却cd */
this.setLimit(e)
if (v.log !== false) {
logger.mark(`${e.logFnc} ${lodash.truncate(e.msg, { length: 80 })} 处理完成 ${Date.now() - start}ms`)
}
break a
}
} catch (error) {
logger.error(`${e.logFnc}`)
logger.error(error.stack)
break a
}
}
}
}
}
}
/** 过滤事件 */
filtEvent(e, v) {
let event = v.event.split(".")
let eventMap = {
message: ["post_type", "message_type", "sub_type"],
notice: ["post_type", "notice_type", "sub_type"],
request: ["post_type", "request_type", "sub_type"]
}
let newEvent = []
event.forEach((val, index) => {
if (val === "*") {
newEvent.push(val)
} else if (eventMap[e.post_type]) {
newEvent.push(e[eventMap[e.post_type][index]])
}
})
newEvent = newEvent.join(".")
if (v.event == newEvent) return true
return false
}
/** 判断权限 */
filtPermission(e, v) {
if (v.permission == "all" || !v.permission) return true
if (v.permission == "master") {
if (e.isMaster) {
return true
} else {
e.reply("暂无权限,只有主人才能操作")
return false
}
}
if (e.isGroup) {
if (!e.member?._info) {
e.reply("数据加载中,请稍后再试")
return false
}
if (v.permission == "owner") {
if (!e.member.is_owner) {
e.reply("暂无权限,只有群主才能操作")
return false
}
}
if (v.permission == "admin") {
if (!e.member.is_admin) {
e.reply("暂无权限,只有管理员才能操作")
return false
}
}
}
return true
}
/**
* 处理消息加入自定义字段
* @param e.msg 文本消息多行会自动拼接
* @param e.img 图片消息数组
* @param e.atBot 是否at机器人
* @param e.at 是否at多个at 以最后的为准
* @param e.file 接受到的文件
* @param e.isPrivate 是否私聊
* @param e.isGroup 是否群聊
* @param e.isMaster 是否管理员
* @param e.logText 日志用户字符串
* @param e.logFnc 日志方法字符串
* 频道
* @param e.isGuild 是否频道
* @param e.at 支持频道 tiny_id
* @param e.atBot 支持频道
*/
dealMsg(e) {
if (e.message) {
for (let val of e.message) {
switch (val.type) {
case "text":
/** 中文#转为英文 */
val.text = val.text.replace(/|井/g, "#").trim()
if (e.msg) {
e.msg += val.text
} else {
e.msg = val.text
}
break
case "image":
if (!e.img) {
e.img = []
}
e.img.push(val.url)
break
case "at":
if (val.qq == e.self_id || val.qq == e.self_tiny_id) {
e.atBot = true
} else {
/** 多个at 以最后的为准 */
e.at = val.qq
}
break
case "file":
e.file = { name: val.name, fid: val.fid }
break
}
}
}
e.logText = ""
if (e.message_type == "private" || e.notice_type == "friend") {
e.isPrivate = true
if (e.sender) {
e.sender.card = e.sender.nickname
} else {
e.sender = {
card: e.friend?.nickname,
nickname: e.friend?.nickname
}
}
e.logText = `[私聊][${e.sender.nickname}(${e.user_id})]`
}
if (e.message_type == "group" || e.notice_type == "group") {
e.isGroup = true
if (e.sender) {
e.sender.card = e.sender.card || e.sender.nickname
} else if (e.member) {
e.sender = {
card: e.member.card || e.member.nickname
}
} else if (e.nickname) {
e.sender = {
card: e.nickname,
nickname: e.nickname
}
} else {
e.sender = {
card: "",
nickname: ""
}
}
if (!e.group_name) e.group_name = e.group?.name
e.logText = `[${e.group_name}(${e.sender.card})]`
}
if (e.user_id && cfg.master[e.self_id]?.includes(String(e.user_id))) {
e.isMaster = true
}
/** 只关注主动at msg处理 */
if (e.msg && e.isGroup) {
let groupCfg = cfg.getGroup(e.group_id)
let alias = groupCfg.botAlias
if (!Array.isArray(alias)) {
alias = [alias]
}
for (let name of alias) {
if (e.msg.startsWith(name)) {
e.msg = lodash.trimStart(e.msg, name).trim()
e.hasAlias = true
break
}
}
}
}
/** 处理回复,捕获发送失败异常 */
reply(e) {
if (e.reply)
e.replyNew = e.reply
else
e.replyNew = msg => {
if (e.isGroup) {
if (e.group?.sendMsg) {
return e.group.sendMsg(msg)
} else {
return e.bot.pickGroup(e.group_id).sendMsg(msg)
}
} else {
if (e.friend?.sendMsg) {
return e.friend.sendMsg(msg)
} else {
return e.bot.pickFriend(e.user_id).sendMsg(msg)
}
}
}
/**
* @param msg 发送的消息
* @param quote 是否引用回复
* @param data.recallMsg 群聊是否撤回消息0-1200不撤回
* @param data.at 是否at用户
*/
e.reply = async (msg = "", quote = false, data = {}) => {
if (!msg) return false
let { recallMsg = 0, at = "" } = data
if (at && e.isGroup) {
let text = e.sender.card || e.sender.nickname
if (text) {
text = lodash.truncate(text, { length: 10 })
}
if (at === true) {
at = e.user_id
}
if (Array.isArray(msg)) {
msg = [segment.at(at, text), ...msg]
} else {
msg = [segment.at(at, text), msg]
}
}
if (quote) {
if (Array.isArray(msg)) {
msg = [segment.reply(e.message_id), ...msg]
} else {
msg = [segment.reply(e.message_id), msg]
}
}
let msgRes
try {
msgRes = await e.replyNew(msg)
} catch (err) {
if (typeof msg != "string") {
if (msg.type == "image" && Buffer.isBuffer(msg?.file))
msg.file = {}
msg = lodash.truncate(JSON.stringify(msg), { length: 300 })
}
logger.error(`发送消息错误:${msg}`)
logger.error(err)
}
if (recallMsg > 0 && msgRes?.message_id) {
if (e.isGroup) {
setTimeout(
() => e.group.recallMsg(msgRes.message_id),
recallMsg * 1000
)
} else if (e.friend) {
setTimeout(
() => e.friend.recallMsg(msgRes.message_id),
recallMsg * 1000
)
}
}
this.count(e, msg)
return msgRes
}
}
count(e, msg) {
let screenshot = false
if (msg && msg?.file && Buffer.isBuffer(msg?.file)) {
screenshot = true
}
this.saveCount("sendMsg")
if (screenshot) this.saveCount("screenshot")
if (e.group_id) {
this.saveCount("sendMsg", e.group_id)
if (screenshot) this.saveCount("screenshot", e.group_id)
}
}
saveCount(type, groupId = "") {
let key = "Yz:count:"
if (groupId) {
key += `group:${groupId}:`
}
let dayKey = `${key}${type}:day:${moment().format("MMDD")}`
let monthKey = `${key}${type}:month:${Number(moment().month()) + 1}`
let totalKey = `${key}${type}:total`
redis.incr(dayKey)
redis.incr(monthKey)
if (!groupId) redis.incr(totalKey)
redis.expire(dayKey, 3600 * 24 * 30)
redis.expire(monthKey, 3600 * 24 * 30)
}
delCount() {
let key = "Yz:count:"
redis.set(`${key}sendMsg:total`, "0")
redis.set(`${key}screenshot:total`, "0")
}
/** 收集定时任务 */
collectTask(task) {
if (Array.isArray(task)) {
task.forEach((val) => {
if (!val.cron) return
if (!val.name) throw new Error("插件任务名称错误")
this.task.push(val)
})
} else {
if (task.fnc && task.cron) {
if (!task.name) throw new Error("插件任务名称错误")
this.task.push(task)
}
}
}
/** 创建定时任务 */
creatTask() {
if (process.argv[1].includes("test")) return
this.task.forEach((val) => {
val.job = schedule.scheduleJob(val.cron, async () => {
try {
if (val.log === true) {
logger.mark(`开始定时任务:${val.name}`)
}
let res = val.fnc()
if (util.types.isPromise(res)) res = await res
if (val.log === true) {
logger.mark(`定时任务完成:${val.name}`)
}
} catch (error) {
logger.error(`定时任务报错:${val.name}`)
logger.error(error)
}
})
})
}
/** 检查命令冷却cd */
checkLimit(e) {
/** 禁言中 */
if (e.isGroup && e?.group?.mute_left > 0) return false
if (!e.message || e.isPrivate) return true
let config = cfg.getGroup(e.group_id)
if (config.groupCD && this.groupCD[e.group_id]) {
return false
}
if (config.singleCD && this.singleCD[`${e.group_id}.${e.user_id}`]) {
return false
}
return true
}
/** 设置冷却cd */
setLimit(e) {
if (!e.message || e.isPrivate) return
let config = cfg.getGroup(e.group_id)
if (config.groupCD) {
this.groupCD[e.group_id] = true
setTimeout(() => {
delete this.groupCD[e.group_id]
}, config.groupCD)
}
if (config.singleCD) {
let key = `${e.group_id}.${e.user_id}`
this.singleCD[key] = true
setTimeout(() => {
delete this.singleCD[key]
}, config.singleCD)
}
}
/** 是否只关注主动at */
onlyReplyAt(e) {
if (!e.message || e.isPrivate) return true
let groupCfg = cfg.getGroup(e.group_id)
if (groupCfg.onlyReplyAt != 1 || !groupCfg.botAlias) return true
/** at机器人 */
if (e.atBot) return true
/** 消息带前缀 */
if (e.hasAlias) return true
return false
}
/** 判断黑白名单 */
checkBlack(e) {
let other = cfg.getOther()
if (e.test) return true
/** 黑名单qq */
if (other.blackQQ && other.blackQQ.includes(Number(e.user_id) || String(e.user_id))) {
return false
}
if (e.group_id) {
/** 白名单群 */
if (other.whiteGroup) {
if (other.whiteGroup.includes(Number(e.group_id) || String(e.group_id))) return true
return false
}
/** 黑名单群 */
if (other.blackGroup && other.blackGroup.includes(Number(e.group_id) || String(e.group_id))) {
return false
}
}
return true
}
/** 判断是否启用功能 */
checkDisable(e, p) {
let groupCfg = cfg.getGroup(e.group_id)
if (!lodash.isEmpty(groupCfg.enable)) {
if (groupCfg.enable.includes(p.name)) {
return true
}
// logger.debug(`${e.logText}[${p.name}]功能已禁用`)
return false
}
if (!lodash.isEmpty(groupCfg.disable)) {
if (groupCfg.disable.includes(p.name)) {
// logger.debug(`${e.logText}[${p.name}]功能已禁用`)
return false
}
return true
}
return true
}
/** 监听热更新 */
watch(dirName, appName) {
this.watchDir(dirName)
if (this.watcher[`${dirName}.${appName}`]) return
let file = `./plugins/${dirName}/${appName}`
const watcher = chokidar.watch(file)
let key = `${dirName}/${appName}`
/** 监听修改 */
watcher.on("change", async path => {
logger.mark(`[修改插件][${dirName}][${appName}]`)
let tmp = {}
try {
tmp = await import(`../../plugins/${dirName}/${appName}?${moment().format("x")}`)
} catch (error) {
logger.error(`载入插件错误:${logger.red(dirName + "/" + appName)}`)
logger.error(decodeURI(error.stack))
return
}
if (tmp.apps) tmp = { ...tmp.apps }
lodash.forEach(tmp, (p) => {
/* eslint-disable new-cap */
let plugin = new p()
for (let i in this.priority) {
if (this.priority[i].key == key) {
this.priority[i].class = p
this.priority[i].priority = plugin.priority
}
}
})
this.priority = lodash.orderBy(this.priority, ["priority"], ["asc"])
})
/** 监听删除 */
watcher.on("unlink", async path => {
logger.mark(`[卸载插件][${dirName}][${appName}]`)
for (let i in this.priority) {
if (this.priority[i].key == key) {
this.priority.splice(i, 1)
/** 停止更新监听 */
this.watcher[`${dirName}.${appName}`].removeAllListeners("change")
break
}
}
})
this.watcher[`${dirName}.${appName}`] = watcher
}
/** 监听文件夹更新 */
watchDir(dirName) {
if (this.watcher[dirName]) return
let file = `./plugins/${dirName}/`
const watcher = chokidar.watch(file)
/** 热更新 */
setTimeout(() => {
/** 新增文件 */
watcher.on("add", async PluPath => {
let appName = path.basename(PluPath)
if (!appName.endsWith(".js")) return
if (!fs.existsSync(`${this.dir}/${dirName}/${appName}`)) return
let key = `${dirName}/${appName}`
this.watch(dirName, appName)
/** 太快了延迟下 */
await common.sleep(500)
logger.mark(`[新增插件][${dirName}][${appName}]`)
let tmp = {}
try {
tmp = await import(`../../plugins/${dirName}/${appName}?${moment().format("X")}`)
} catch (error) {
logger.error(`载入插件错误:${logger.red(dirName + "/" + appName)}`)
logger.error(decodeURI(error.stack))
return
}
if (tmp.apps) tmp = { ...tmp.apps }
lodash.forEach(tmp, (p) => {
if (!p.prototype) {
logger.error(`[载入失败][${dirName}][${appName}] 格式错误已跳过`)
return
}
/* eslint-disable new-cap */
let plugin = new p()
for (let i in this.priority) {
if (this.priority[i].key == key) {
return
}
}
this.priority.push({
class: p,
key,
name: plugin.name,
priority: plugin.priority
})
})
/** 优先级排序 */
this.priority = lodash.orderBy(this.priority, ["priority"], ["asc"])
})
}, 500)
this.watcher[dirName] = watcher
}
}
export default new PluginsLoader()