UNPKG

koishi-plugin-bilibili-notify

Version:

Bilibili 动态推送、直播通知 Koishi 插件

1,207 lines (1,198 loc) 50.1 kB
import { createRequire } from "node:module"; import { resolve } from "node:path"; import { DataService } from "@koishijs/plugin-console"; import "@koishijs/plugin-notifier"; import { Schema, Service, h } from "koishi"; import { BiliLoginStatus, BilibiliAPI } from "@bilibili-notify/api"; import { isDeepStrictEqual } from "node:util"; import { BILIBILI_NOTIFY_TOKEN } from "@bilibili-notify/internal"; import { BilibiliPush } from "@bilibili-notify/push"; import { StorageManager } from "@bilibili-notify/storage"; import { SubscriptionManager } from "@bilibili-notify/subscription"; import QRCode from "qrcode"; //#region \0rolldown/runtime.js var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __require = /* @__PURE__ */ createRequire(import.meta.url); //#endregion //#region src/config.ts const BilibiliNotifyConfigSchema = Schema.object({ advancedSub: Schema.boolean().default(false).description("这个开关决定是否使用高级订阅功能喔~如果主人想要超级灵活的订阅内容,就请开启并安装 bilibili-notify-advanced-subscription 呀 (๑•̀ㅂ•́)و♡"), subs: Schema.array(Schema.object({ name: Schema.string().required().description("UP昵称"), uid: Schema.string().required().description("UID"), dynamic: Schema.boolean().default(true).description("动态"), dynamicAtAll: Schema.boolean().default(false).description("动态@全体"), live: Schema.boolean().default(true).description("直播"), liveAtAll: Schema.boolean().default(true).description("直播@全体"), liveEnd: Schema.boolean().default(true).description("下播通知"), liveGuardBuy: Schema.boolean().default(false).description("上舰消息"), superchat: Schema.boolean().default(false).description("SC消息"), wordcloud: Schema.boolean().default(true).description("弹幕词云"), liveSummary: Schema.boolean().default(true).description("直播总结"), platform: Schema.string().required().description("平台名"), target: Schema.string().required().description("群号/频道号") })).role("table").description("在这里填写主人的订阅信息~UP 昵称、UID、roomid、平台、群号都要填正确,不然女仆会迷路哒 (;>_<)如果多个群聊/频道,请用英文逗号分隔哦~女仆会努力送到每一个地方的!"), logLevel: Schema.number().min(1).max(3).step(1).default(1).description("这里可以设置日志等级喔~3 是最详细的调试信息,1 是只显示错误信息。主人可以根据需要选择合适的等级,让女仆更好地为您服务 (๑•̀ㅂ•́)و✧"), userAgent: Schema.string().description("这里可以设置请求头的 User-Agent 哦~如果请求出现了 -352 的奇怪错误,主人可以试着在这里换一个看看 (;>_<)"), loginHealthCheckMinutes: Schema.number().min(5).max(180).step(1).default(30).description("登录状态周期检测的间隔(分钟)。女仆会按这个频率悄悄帮主人确认账号还在线哦~如果发现失效会立刻汇报呢 (๑•̀ㅂ•́)و✧"), master: Schema.intersect([Schema.object({ enable: Schema.boolean().default(false).description("要不要让笨笨女仆开启主人账号功能呢?(>﹏<)如果机器人遭遇了奇怪的小错误,女仆会立刻跑来向主人报告的!不、不过……如果没有私聊权限的话,女仆就联系不到主人了……请不要打开这个开关喔 (;´д`)ゞ") }).description("主人的特别区域……女仆会乖乖侍奉的!(>///<)"), Schema.union([Schema.object({ enable: Schema.const(true).required(), platform: Schema.union([ "qq", "qqguild", "onebot", "discord", "red", "telegram", "satori", "chronocat", "lark" ]).description("主人想让女仆在哪个平台伺候您呢?请从这里选一个吧~(〃´-`〃)♡女仆会乖乖待在主人选的地方哒!"), masterAccount: Schema.string().role("secret").required().description("请主人把自己的账号告诉女仆嘛……不然女仆会找不到主人哒 (つ﹏⊂)在 Q 群的话用 QQ 号就可以了~其他平台请用 inspect 插件告诉女仆主人的 ID 哦 (´。• ᵕ •。`) ♡"), masterAccountGuildId: Schema.string().role("secret").description("如果是在 QQ 频道、Discord 这种地方……主人的群组 ID 也要告诉女仆喔 (;>_<)不然女仆会迷路找不到主人……请用 inspect 插件带女仆去看看嘛~(〃ノωノ)") }), Schema.object({})])]) }); //#endregion //#region src/data-server.ts var BilibiliNotifyDataServer = class extends DataService { biliData = { status: BiliLoginStatus.LOADING_LOGIN_INFO, msg: "正在加载登录信息..." }; constructor(ctx) { super(ctx, "bilibili-notify"); ctx.on("bilibili-notify/login-status-report", (data) => { this.biliData = data; this.refresh(); }); } async get() { return this.biliData; } }; (/* @__PURE__ */ __commonJSMin(((exports, module) => { var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var index_exports = {}; __export(index_exports, { Config: () => Config, apply: () => apply, name: () => name }); module.exports = __toCommonJS(index_exports); var import_koishi = __require("koishi"); var zh_CN_default = { commands: { help: { description: "显示帮助信息", shortcuts: { help: "帮助" }, options: { help: "显示此信息", authority: "显示权限设置", showHidden: "查看隐藏的选项和指令" }, messages: { "not-found": "指令未找到。", "hint-authority": "括号内为对应的最低权限等级", "hint-subcommand": "标有星号的表示含有子指令", "command-title": "指令:{0}", "command-aliases": "别名:{0}。", "command-examples": "使用示例:", "command-authority": "最低权限:{0} 级。", "subcommand-prolog": "可用的子指令有{0}:", "global-prolog": "当前可用的指令有{0}:", "global-epilog": "输入“{0}help 指令名”查看特定指令的语法和使用示例。", "available-options": "可用的选项有:", "available-options-with-authority": "可用的选项有(括号内为额外要求的权限等级):" } } } }; var en_US_default = { commands: { help: { description: "Show help", shortcuts: { help: "<></>" }, options: { help: "show this message", authority: "show authority requirements", showHidden: "show hidden options and commands" }, messages: { "not-found": "Command not found.", "hint-authority": "this minimum authority is marked in parentheses", "hint-subcommand": "those marked with an asterisk have subcommands", "command-title": "Command: {0}", "command-aliases": "Aliases: {0}.", "command-examples": "Examples:", "command-authority": "Minimal authority: {0}.", "subcommand-prolog": "Available subcommands{0}:", "global-prolog": "Available commands{0}:", "global-epilog": "Type \"{0}help <command>\" to see syntax and examples for a specific command.", "available-options": "Available options:", "available-options-with-authority": "Available options (parentheses indicate additional authority requirement):" } } } }; var Config = import_koishi.Schema.object({ shortcut: import_koishi.Schema.boolean().default(true).description("是否启用快捷调用。"), options: import_koishi.Schema.boolean().default(true).description("是否为每个指令添加 `-h, --help` 选项。") }); function executeHelp(session, name2) { if (!session.app.$commander.get("help")) return; return session.execute({ name: "help", args: [name2] }); } __name(executeHelp, "executeHelp"); var name = "help"; function apply(ctx, config) { ctx.i18n.define("zh-CN", zh_CN_default); ctx.i18n.define("en-US", en_US_default); function enableHelp(command) { command[import_koishi.Context.current] = ctx; command.option("help", "-h", { hidden: true, notUsage: true, descPath: "commands.help.options.help" }); } __name(enableHelp, "enableHelp"); ctx.schema.extend("command", import_koishi.Schema.object({ hideOptions: import_koishi.Schema.boolean().description("是否隐藏所有选项。").default(false).hidden(), hidden: import_koishi.Schema.computed(import_koishi.Schema.boolean()).description("在帮助菜单中隐藏指令。").default(false), params: import_koishi.Schema.any().description("帮助信息的本地化参数。").hidden() }), 900); ctx.schema.extend("command-option", import_koishi.Schema.object({ hidden: import_koishi.Schema.computed(import_koishi.Schema.boolean()).description("在帮助菜单中隐藏选项。").default(false), params: import_koishi.Schema.any().description("帮助信息的本地化参数。").hidden() }), 900); if (config.options !== false) { ctx.$commander._commandList.forEach(enableHelp); ctx.on("command-added", enableHelp); } ctx.before("command/execute", (argv) => { const { command, options, session } = argv; if (options["help"] && command._options.help) return executeHelp(session, command.name); if (command["_actions"].length) return; return executeHelp(session, command.name); }); const $ = ctx.$commander; function findCommand(target, session) { const command = $.resolve(target, session); if (command?.ctx.filter(session)) return command; const data = ctx.i18n.find("commands.(name).shortcuts.(variant)", target).map((item) => ({ ...item, command: $.resolve(item.data.name, session) })).filter((item) => item.command?.match(session)); const perfect = data.filter((item) => item.similarity === 1); if (!perfect.length) return data; return perfect[0].command; } __name(findCommand, "findCommand"); const createCollector = /* @__PURE__ */ __name((key) => (argv, fields) => { const { args: [target], session } = argv; const result = findCommand(target, session); if (!Array.isArray(result)) { session.collect(key, { ...argv, command: result, args: [], options: { help: true } }, fields); return; } for (const { command } of result) session.collect(key, { ...argv, command, args: [], options: { help: true } }, fields); }, "createCollector"); async function inferCommand(target, session) { const result = findCommand(target, session); if (!Array.isArray(result)) return result; const expect = $.available(session).filter((name3) => { return name3 && session.app.i18n.compare(name3, target); }); for (const item of result) { if (expect.includes(item.data.name)) continue; expect.push(item.data.name); } const cache = /* @__PURE__ */ new Map(); const name2 = await session.suggest({ expect, prefix: session.text(".not-found"), suffix: session.text("internal.suggest-command"), filter: /* @__PURE__ */ __name((name3) => { const command = $.resolve(name3, session); if (!command) return false; return ctx.permissions.test(`command:${command.name}`, session, cache); }, "filter") }); return $.resolve(name2, session); } __name(inferCommand, "inferCommand"); const cmd = ctx.command("help [command:string]", { authority: 0, ...config }).userFields(["authority"]).userFields(createCollector("user")).channelFields(createCollector("channel")).option("showHidden", "-H").action(async ({ session, options }, target) => { if (!target) { const prefix = session.resolve(session.app.koishi.config.prefix)[0] ?? ""; const output = await formatCommands(".global-prolog", session, $._commandList.filter((cmd2) => cmd2.parent === null), options); const epilog = session.text(".global-epilog", [prefix]); if (epilog) output.push(epilog); return output.filter(Boolean).join("\n"); } const command = await inferCommand(target, session); if (!command) return; if (!await ctx.permissions.test(`command:${command.name}`, session)) return session.text("internal.low-authority"); return showHelp(command, session, options); }); if (config.shortcut !== false) cmd.shortcut("help", { i18n: true, fuzzy: true }); } __name(apply, "apply"); function* getCommands(session, commands, showHidden = false) { for (const command of commands) { if (!showHidden && session.resolve(command.config.hidden)) continue; if (command.match(session) && Object.keys(command._aliases).length) yield command; else yield* getCommands(session, command.children, showHidden); } } __name(getCommands, "getCommands"); async function formatCommands(path, session, children, options) { const cache = /* @__PURE__ */ new Map(); children = Array.from(getCommands(session, children, options.showHidden)); children = (await Promise.all(children.map(async (command) => { return [command, await session.app.permissions.test(`command:${command.name}`, session, cache)]; }))).filter(([, result]) => result).map(([command]) => command); children.sort((a, b) => a.displayName > b.displayName ? 1 : -1); if (!children.length) return []; const prefix = session.resolve(session.app.koishi.config.prefix)[0] ?? ""; const output = children.map(({ name: name2, displayName, config }) => { let output2 = " " + prefix + displayName.replace(/\./g, " "); output2 += " " + session.text([`commands.${name2}.description`, ""], config.params); return output2; }); const hints = []; const hintText = hints.length ? session.text("general.paren", [hints.join(session.text("general.comma"))]) : ""; output.unshift(session.text(path, [hintText])); return output; } __name(formatCommands, "formatCommands"); function getOptionVisibility(option, session) { if (session.user && option.authority > session.user.authority) return false; return !session.resolve(option.hidden); } __name(getOptionVisibility, "getOptionVisibility"); function getOptions(command, session, config) { if (command.config.hideOptions && !config.showHidden) return []; if (!(config.showHidden ? Object.values(command._options) : Object.values(command._options).filter((option) => getOptionVisibility(option, session))).length) return []; const output = []; Object.values(command._options).forEach((option) => { function pushOption(option2, name2) { if (!config.showHidden && !getOptionVisibility(option2, session)) return; let line = `${import_koishi.h.escape(option2.syntax)}`; const description = session.text(option2.descPath ?? [`commands.${command.name}.options.${name2}`, ""], option2.params); if (description) line += " " + description; line = command.ctx.chain("help/option", line, option2, command, session); output.push(" " + line); } __name(pushOption, "pushOption"); if (!("value" in option)) pushOption(option, option.name); for (const value in option.variants) pushOption(option.variants[value], `${option.name}.${value}`); }); if (!output.length) return []; output.unshift(session.text(".available-options")); return output; } __name(getOptions, "getOptions"); async function showHelp(command, session, config) { const output = [session.text(".command-title", [command.displayName.replace(/\./g, " ") + command.declaration])]; const description = session.text([`commands.${command.name}.description`, ""], command.config.params); if (description) output.push(description); if (session.app.database) { const argv = { command, args: [], options: { help: true } }; const userFields = session.collect("user", argv); await session.observeUser(userFields); if (!session.isDirect) { const channelFields = session.collect("channel", argv); await session.observeChannel(channelFields); } } if (Object.keys(command._aliases).length > 1) output.push(session.text(".command-aliases", [Array.from(Object.keys(command._aliases).slice(1)).join(",")])); session.app.emit(session, "help/command", output, command, session); if (command._usage) output.push(typeof command._usage === "string" ? command._usage : await command._usage(session)); else { const text = session.text([`commands.${command.name}.usage`, ""], command.config.params); if (text) output.push(text); } output.push(...getOptions(command, session, config)); if (command._examples.length) output.push(session.text(".command-examples"), ...command._examples.map((example) => " " + example)); else { const text = session.text([`commands.${command.name}.examples`, ""], command.config.params); if (text) output.push(session.text(".command-examples"), ...text.split("\n").map((line) => " " + line)); } output.push(...await formatCommands(".subcommand-prolog", session, command.children, config)); return output.filter(Boolean).join("\n"); } __name(showHelp, "showHelp"); })))(); function biliCommands() { const biliCom = this.ctx.command("bili", "bilibili-notify 插件相关指令", { permissions: ["authority:3"] }); biliCom.subcommand(".list", "展示订阅对象").usage("展示订阅对象").example("bili list").action(() => this.subList()); biliCom.subcommand(".private", "向管理员账号发送一条测试消息", { hidden: true }).usage("向管理员账号发送一条测试消息").example("bili private").action(async ({ session }) => { const internals = this.getInternals(BILIBILI_NOTIFY_TOKEN); if (!internals) return "插件尚未就绪"; await internals.push.sendPrivateMsg("测试消息"); await session?.send("已发送测试消息。如果未收到,可能是机器人不支持发送私聊消息或配置信息有误"); }); biliCom.subcommand(".ll", "展示当前正在直播的订阅对象").usage("展示当前正在直播的订阅对象").example("bili ll").action(async () => { const internals = this.getInternals(BILIBILI_NOTIFY_TOKEN); if (!internals) return "插件尚未就绪"; const subMap = this.subManager; const liveUsers = (await internals.api.getTheUserWhoIsLiveStreaming())?.data?.live_users?.items ?? []; const liveUidSet = new Set(liveUsers.map((u) => String(u.mid))); let table = ""; for (const [uid, sub] of subMap) { const onLive = sub.live && liveUidSet.has(uid); table += `[UID:${uid}] 「${sub.uname}」 ${onLive ? "正在直播" : "未开播"}\n`; } return table || "没有订阅任何UP"; }); } //#endregion //#region src/commands/status.ts function statusCommands() { const statusCom = this.ctx.command("status", "插件状态相关指令", { permissions: ["authority:5"] }); statusCom.subcommand(".auth", "查看登录状态").usage("查看登录状态").example("status auth").action(() => { const snap = this.getAuthSnapshot(); return `登录状态:${BiliLoginStatus[snap.status] ?? `unknown(${snap.status})`}\n信息:${snap.msg || "(无)"}`; }); statusCom.subcommand(".dyn", "查看动态监测运行状态").usage("查看动态监测运行状态").example("status dyn").action(() => { if (this.ctx.get("bilibili-notify-dynamic")) return "动态监测正在运行"; return "动态插件未运行(请检查是否已安装并启用 koishi-plugin-bilibili-notify-dynamic)"; }); statusCom.subcommand(".live", "查看直播监测运行状态").usage("查看直播监测运行状态").example("status live").action(() => { if (this.ctx.get("bilibili-notify-live")) return "直播监测正在运行"; return "直播插件未运行(请检查是否已安装并启用 koishi-plugin-bilibili-notify-live)"; }); statusCom.subcommand(".sm", "查看订阅管理对象").usage("查看订阅管理对象").example("status sm").action(() => { this.ctx.logger.info("[status]", this.subManager); return "查看控制台"; }); statusCom.subcommand(".bot", "查询当前拥有的机器人信息", { hidden: true }).usage("查询当前拥有的机器人信息").example("status bot").action(() => { this.ctx.logger.debug("[status] 开始输出BOT信息"); for (const bot of this.ctx.bots) { this.ctx.logger.debug("[status] --------------------------------"); this.ctx.logger.debug(`[status] 平台:${bot.platform}`); this.ctx.logger.debug(`[status] 名称:${bot.user?.name}`); this.ctx.logger.debug("[status] --------------------------------"); } }); statusCom.subcommand(".env", "查询当前环境的信息", { hidden: true }).usage("查询当前环境的信息").example("status env").action(async ({ session }) => { await session?.send(`Guild ID:${session.event.guild?.id}`); await session?.send(`Channel ID: ${session.event.channel?.id}`); }); } //#endregion //#region src/commands/sys.ts function sysCommands() { const sysCom = this.ctx.command("bn", "bilibili-notify 插件运行相关指令", { permissions: ["authority:5"] }); sysCom.subcommand(".restart", "重启插件(重新加载订阅并通知 live/dynamic 插件)").usage("重启插件").example("bn restart").action(async () => { if (await this.restartPlugin()) return "主人~女仆成功重启插件啦~乖乖继续为主人服务呢 (>ω<)♡"; return "主人呜呜 (;>_<) 女仆重启插件失败啦~请主人检查一下再试哦 (>ω<)♡"; }); sysCom.subcommand(".stop", "停止插件").usage("停止插件").example("bn stop").action(() => { if (this.disposePlugin()) return "主人~女仆已经停止插件啦~休息一下先 (>ω<)♡"; return "主人呜呜 (;>_<) 女仆停止插件失败啦~请主人检查一下再试哦 (>ω<)♡"; }); sysCom.subcommand(".start", "启动插件").usage("启动插件").example("bn start").action(async () => { if (await this.registerPlugin()) return "主人~女仆成功启动插件啦~准备好乖乖为主人工作呢 (>ω<)♡"; return "主人呜呜 (;>_<) 女仆启动插件失败啦~请主人检查一下再试哦 (>ω<)♡"; }); } //#endregion //#region src/login-status.ts const MESSAGES = { loading: "正在加载登录信息...", notLogin: "账号未登录,请点击「扫码登录」", keyReset: "密钥已重置,cookie 已清除,请重新扫码登录", authLost: "账号登录已失效,请在控制台重新扫码登录", loggedIn: "已登录", loginJustSucceeded: "登录成功,正在加载订阅...", fetchAccountFailed: "账号已登录,但获取个人信息失败,请检查", waitScan: "尚未扫码,请扫码", waitConfirm: "已扫码,但尚未确认,请确认", qrFetchFailed: "获取二维码失败,请重试", qrRenderFailed: "生成二维码失败", qrExpired: "二维码已超时(3分钟),请重新登录", qrInvalidated: "二维码已失效,请重新登录", noCookieAfterLogin: "登录成功但未获取到 cookie,请重试", genericLoginFail: "登录失败,请重试" }; /** 类型守卫:snapshot.data 是否长得像 UserCardInfo。 */ function looksLikeCardData(data) { return typeof data === "object" && data !== null && "card" in data; } /** * 集中管理登录态:所有变更都经过这里,再以 `bilibili-notify/login-status-report` * 推到前端。心跳定时器在登录态下定期 probe,发现失效会同时广播 * `bilibili-notify/auth-lost`,恢复时广播 `bilibili-notify/auth-restored`。 */ var LoginStatusController = class { snapshot = { status: BiliLoginStatus.LOADING_LOGIN_INFO, msg: MESSAGES.loading }; healthTimer; /** * 标记"曾从已登录态掉线",下一次成功登录时翻转为已登录后才发 auth-restored。 * 之所以不再用"上一帧 status === NOT_LOGIN"判断:失效后用户走扫码流程,会经过 * LOGIN_QR / LOGGING_QR 等中间态,直接基于上一帧会漏掉这条恢复路径。 */ needsRestore = false; constructor(ctx, options) { this.ctx = ctx; this.options = options; } current() { return { ...this.snapshot }; } attachHealthCheck() { this.detachHealthCheck(); if (this.options.healthCheckMs <= 0) return; this.healthTimer = this.ctx.setInterval(() => void this.runHealthCheck(), this.options.healthCheckMs); } detachHealthCheck() { this.healthTimer?.(); this.healthTimer = void 0; } reportLoggedIn(card, reasonKey = "loggedIn") { const wasLoggedIn = this.snapshot.status === BiliLoginStatus.LOGGED_IN; const fallback = looksLikeCardData(this.snapshot.data) ? this.snapshot.data : void 0; this.transition({ status: BiliLoginStatus.LOGGED_IN, msg: MESSAGES[reasonKey], data: card ?? fallback }); if (!wasLoggedIn && this.needsRestore) { this.needsRestore = false; this.ctx.emit("bilibili-notify/auth-restored"); } } reportLoggedOut(reasonKey = "notLogin") { const wasLoggedIn = this.snapshot.status === BiliLoginStatus.LOGGED_IN; this.transition({ status: BiliLoginStatus.NOT_LOGIN, msg: MESSAGES[reasonKey] }); if (wasLoggedIn) { this.needsRestore = true; this.ctx.emit("bilibili-notify/auth-lost"); } } /** Dispatch on `getMyselfInfo` result code. */ reportLoginCheck(code, card) { if (code === 0) this.reportLoggedIn(card); else if (code === -101) this.reportLoggedOut("authLost"); else this.reportTransientFailure(`code=${code}`); } /** Keep current status, only refresh msg. Logs at warn level. */ reportTransientFailure(detail) { this.options.logger.warn(`[auth] 瞬时失败:${detail}`); if (this.snapshot.status !== BiliLoginStatus.LOGGED_IN) return; this.transition({ ...this.snapshot, msg: MESSAGES.fetchAccountFailed }); } reportQrReady(base64) { this.transition({ status: BiliLoginStatus.LOGIN_QR, msg: "", data: base64 }); } reportQrPending(reasonKey) { this.transition({ status: BiliLoginStatus.LOGGING_QR, msg: MESSAGES[reasonKey] }); } reportQrFailure(reasonKey) { this.transition({ status: BiliLoginStatus.LOGIN_FAILED, msg: MESSAGES[reasonKey] }); } /** Emit only when (status, msg, data) changes. */ transition(next) { if (this.snapshot.status === next.status && this.snapshot.msg === next.msg && this.snapshot.data === next.data) return; this.snapshot = next; this.ctx.emit("bilibili-notify/login-status-report", next); } async runHealthCheck() { if (this.snapshot.status === BiliLoginStatus.LOGIN_QR || this.snapshot.status === BiliLoginStatus.LOGGING_QR || this.snapshot.status === BiliLoginStatus.NOT_LOGIN) return; try { const res = await this.options.probe(); this.reportLoginCheck(res.code); } catch (e) { this.options.logger.warn(`[auth] 心跳异常(保持当前状态):${e}`); } } }; //#endregion //#region src/server-manager.ts const SERVICE_NAME = "bilibili-notify"; const LIVE_MASTER_KEYS = [ "live", "liveAtAll", "liveEnd", "liveGuardBuy", "superchat", "wordcloud", "liveSummary" ]; const LIVE_CUSTOM_KEYS = [ "customCardStyle", "customLiveMsg", "customGuardBuy", "customLiveSummary", "customSpecialDanmakuUsers", "customSpecialUsersEnterTheRoom", "specialUsers" ]; /** Diff two SubItem snapshots and return a typed SubChange array. */ function diffSubItems(prev, next) { const result = []; const liveChange = { scope: "live" }; for (const key of LIVE_MASTER_KEYS) if (prev[key] !== next[key]) liveChange[key] = next[key]; if (prev.uname !== next.uname) liveChange.uname = next.uname; if (prev.roomId !== next.roomId) liveChange.roomId = next.roomId; for (const key of LIVE_CUSTOM_KEYS) if (!isDeepStrictEqual(prev[key], next[key])) liveChange[key] = next[key]; if (Object.keys(liveChange).length > 1) result.push(liveChange); const dynamicChange = { scope: "dynamic" }; if (prev.dynamic !== next.dynamic) dynamicChange.dynamic = next.dynamic; if (prev.dynamicAtAll !== next.dynamicAtAll) dynamicChange.dynamicAtAll = next.dynamicAtAll; if (Object.keys(dynamicChange).length > 1) result.push(dynamicChange); if (!isDeepStrictEqual(prev.target, next.target)) result.push({ scope: "target", target: next.target }); return result; } var BilibiliNotifyServerManager = class extends Service { static [Service.provide] = SERVICE_NAME; serverLogger = this.ctx.logger(SERVICE_NAME); selfCtx; api = null; push = null; subMgr = null; loginTimer; subNotifier; running = false; storageMgr; currentSubs = null; auth; authLostNotifiedAt = 0; constructor(ctx, config) { super(ctx, SERVICE_NAME); this.selfCtx = ctx; this.config = config; this.serverLogger.level = config.logLevel; } /** For commands */ get subManager() { return this.subMgr?.subManager ?? /* @__PURE__ */ new Map(); } /** For commands: read the current login snapshot. */ getAuthSnapshot() { return this.auth.current(); } subList() { const map = this.subManager; if (!map.size) return "没有订阅任何UP"; let table = ""; for (const [uid, sub] of map) { const flags = [sub.dynamic ? "已订阅动态" : "", sub.live ? "已订阅直播" : ""].filter(Boolean).join(" "); table += `[UID:${uid}] 「${sub.uname}」 ${flags}\n`; } return table.trim(); } async start() { this.serverLogger.info("[start] 正在启动中..."); this.storageMgr = new StorageManager(this.ctx.baseDir, this.ctx); await this.storageMgr.init(); this.auth = new LoginStatusController(this.selfCtx, { healthCheckMs: this.config.loginHealthCheckMinutes * 6e4, logger: this.serverLogger, probe: () => { if (!this.api) throw new Error("api not initialized"); return this.api.getMyselfInfo(); } }); this.ctx.on("bilibili-notify/cookies-refreshed", async (data) => { try { await this.storageMgr.cookieStore.save(data); this.serverLogger.debug("[cookie] Cookie 已自动刷新并保存"); } catch (e) { this.serverLogger.error(`[cookie] 保存刷新后的 cookie 失败:${e}`); } }); this.ctx.on("bilibili-notify/plugin-error", (source, message) => { this.serverLogger.warn(`[${source}] ${message}`); }); sysCommands.call(this); if (!await this.registerPlugin()) this.serverLogger.error("[module] 启动模块失败,请检查配置后重试"); } stop() { this.disposePlugin(); } /** * 向持有 BILIBILI_NOTIFY_TOKEN 的友好插件暴露 api / push / subs 实例。 * 第三方插件无法获取此令牌,因此无法访问内部实例。 */ getInternals(token) { if (token !== BILIBILI_NOTIFY_TOKEN || !this.api || !this.push) return null; return { api: this.api, push: this.push, subs: this.currentSubs, addSub: (p) => this.addSub(p), removeSub: (uid) => this.removeSub(uid), updateSub: (p) => this.updateSub(p) }; } async addSub(params) { if (this.config.advancedSub) return "订阅失败:高级订阅模式下不支持通过 AI 管理订阅,操作未执行"; if (!this.subMgr) return "订阅失败:插件未就绪,操作未执行"; const existing = this.config.subs?.find((s) => s.uid.split(",")[0].trim() === params.uid); if (existing) return `订阅失败:UID ${params.uid} 已在订阅列表中(昵称:${existing.name})`; const item = { name: params.name, uid: params.uid, dynamic: params.dynamic ?? true, dynamicAtAll: params.dynamicAtAll ?? false, live: params.live ?? true, liveAtAll: params.liveAtAll ?? false, liveEnd: params.liveEnd ?? true, liveGuardBuy: params.liveGuardBuy ?? false, superchat: params.superchat ?? false, wordcloud: params.wordcloud ?? true, liveSummary: params.liveSummary ?? true, platform: params.platform, target: params.target }; const addedSub = await this.subMgr.addEntry(item); if (!addedSub) return `订阅失败:${params.name}(UID: ${params.uid})操作未执行,请查看日志`; const newConfig = { ...this.config, subs: [...this.config.subs ?? [], item] }; this.config = newConfig; this.selfCtx.emit("bilibili-notify/update-config", newConfig); this.syncCurrentSubs(); this.updateSubNotifier(); this.selfCtx.emit("bilibili-notify/subscription-changed", [{ type: "add", sub: addedSub }]); this.serverLogger.info(`[subscribe] 已添加订阅:${params.name}(UID: ${params.uid})`); return `已成功订阅 ${params.name}(UID: ${params.uid})`; } async updateSub(params) { if (this.config.advancedSub) return "更新订阅失败:高级订阅模式下不支持通过 AI 管理订阅,操作未执行"; if (!this.subMgr) return "更新订阅失败:插件未就绪,操作未执行"; const flatSubs = this.config.subs ?? []; const idx = flatSubs.findIndex((s) => s.uid.split(",")[0].trim() === params.uid); if (idx === -1) return `更新订阅失败:未找到 UID 为 ${params.uid} 的订阅,操作未执行`; const existing = flatSubs[idx]; const rawPrev = this.subMgr.subManager.get(params.uid); const prevSub = rawPrev ? structuredClone(rawPrev) : null; const updatedItem = { ...existing, ...params.dynamic !== void 0 && { dynamic: params.dynamic }, ...params.dynamicAtAll !== void 0 && { dynamicAtAll: params.dynamicAtAll }, ...params.live !== void 0 && { live: params.live }, ...params.liveAtAll !== void 0 && { liveAtAll: params.liveAtAll }, ...params.liveEnd !== void 0 && { liveEnd: params.liveEnd }, ...params.liveGuardBuy !== void 0 && { liveGuardBuy: params.liveGuardBuy }, ...params.superchat !== void 0 && { superchat: params.superchat }, ...params.wordcloud !== void 0 && { wordcloud: params.wordcloud }, ...params.liveSummary !== void 0 && { liveSummary: params.liveSummary } }; const nextSub = this.subMgr.updateEntry(updatedItem); if (!nextSub) return `更新订阅失败:UID ${params.uid} 不在运行中的订阅管理器内,操作未执行`; const newFlatSubs = [...flatSubs]; newFlatSubs[idx] = updatedItem; const newConfig = { ...this.config, subs: newFlatSubs }; this.config = newConfig; this.selfCtx.emit("bilibili-notify/update-config", newConfig); this.syncCurrentSubs(); this.updateSubNotifier(); if (prevSub) { const changes = diffSubItems(prevSub, nextSub); if (changes.length) this.selfCtx.emit("bilibili-notify/subscription-changed", [{ type: "update", uid: params.uid, changes }]); } this.serverLogger.info(`[update] 已更新订阅:${existing.name}(UID: ${params.uid})`); return `已成功更新 ${existing.name}(UID: ${params.uid})的订阅设置`; } removeSub(uid) { if (this.config.advancedSub) return "取消订阅失败:高级订阅模式下不支持通过 AI 管理订阅,操作未执行"; if (!this.subMgr) return "取消订阅失败:插件未就绪,操作未执行"; const flatItem = this.config.subs?.find((s) => s.uid.split(",")[0].trim() === uid); if (!flatItem) return `取消订阅失败:未找到 UID 为 ${uid} 的订阅,操作未执行`; const removedSub = this.subMgr.removeEntry(uid); if (!removedSub) return `取消订阅失败:UID ${uid} 不在运行中的订阅管理器内,操作未执行`; const newConfig = { ...this.config, subs: (this.config.subs ?? []).filter((s) => s !== flatItem) }; this.config = newConfig; this.selfCtx.emit("bilibili-notify/update-config", newConfig); this.syncCurrentSubs(); this.updateSubNotifier(); this.selfCtx.emit("bilibili-notify/subscription-changed", [{ type: "delete", uid }]); this.serverLogger.info(`[unsubscribe] 已移除订阅:${removedSub.uname}(UID: ${uid})`); return `已成功取消订阅 ${removedSub.uname}(UID: ${uid})`; } /** Rebuild currentSubs from the subManager (uid-keyed). */ syncCurrentSubs() { if (!this.subMgr?.subManager.size) { this.currentSubs = null; return; } const result = {}; for (const [uid, sub] of this.subMgr.subManager) result[uid] = sub; this.currentSubs = result; } /** Compute ops by diffing two subManager snapshots. Used for advanced-sub full reload. */ diffSubManagers(prev, next) { const ops = []; for (const [uid] of prev) if (!next.has(uid)) ops.push({ type: "delete", uid }); for (const [uid, sub] of next) { const prevSub = prev.get(uid); if (!prevSub) ops.push({ type: "add", sub }); else { const changes = diffSubItems(prevSub, sub); if (changes.length) ops.push({ type: "update", uid, changes }); } } return ops; } async registerPlugin() { if (this.running) return false; try { this.api = new BilibiliAPI(this.selfCtx, { logLevel: this.config.logLevel, userAgent: this.config.userAgent }, { onCookiesRefreshed: (data) => { this.selfCtx.emit("bilibili-notify/cookies-refreshed", data); }, onAuthLost: () => { this.handleAuthLost(); } }); this.push = new BilibiliPush(this.selfCtx, { logLevel: this.config.logLevel, master: this.config.master }); await this.api.start(); this.serverLogger.debug("[module] BilibiliAPI 启动完成"); this.push.start(); this.serverLogger.debug("[module] BilibiliPush 启动完成"); this.subMgr = new SubscriptionManager(this.api, this.push, this.selfCtx); this.running = true; this.registerConsoleEvents(); biliCommands.call(this); statusCommands.call(this); await this.initCookies(); this.serverLogger.debug(`[cookie] Cookie 加载完成,登录状态:${this.isLoggedIn() ? "已登录" : "未登录"}`); if (!this.isLoggedIn()) { this.serverLogger.info("[login] 账号未登录,请在控制台扫码登录"); this.auth.reportLoggedOut("notLogin"); return true; } await this.reportAccountInfo(); await this.loadInitialSubscriptions(); } catch (e) { this.serverLogger.error(`[module] 注册模块失败:${e}`); return false; } return true; } disposePlugin() { if (!this.running && !this.api && !this.push) return false; this.serverLogger.debug("[stop] 正在清理插件资源..."); this.running = false; this.clearLoginTimer(); this.auth?.detachHealthCheck(); if (this.subNotifier) { this.subNotifier.dispose(); this.subNotifier = void 0; } this.push?.stop(); this.api?.stop(); this.push = null; this.api = null; this.subMgr = null; this.currentSubs = null; this.serverLogger.debug("[stop] 插件资源清理完成"); return true; } async restartPlugin() { if (!this.running) { this.serverLogger.warn("[restart] 插件目前没有运行,请使用 bn start 启动插件"); return false; } this.disposePlugin(); return new Promise((resolve) => { this.selfCtx.setTimeout(() => { this.registerPlugin().then(resolve).catch((e) => { this.serverLogger.error(`[restart] 重启插件失败:${e}`); resolve(false); }); }, 1e3); }); } async initCookies() { if (!this.api) return; this.serverLogger.debug("[cookie] 正在从磁盘加载 Cookie..."); let cookieData = null; try { cookieData = await this.storageMgr.cookieStore.load(); } catch (e) { this.serverLogger.warn(`[cookie] 读取 cookie 文件失败: ${e}`); } if (cookieData) { this.serverLogger.debug("[cookie] 找到 Cookie 文件,正在写入 jar..."); await this.api.loadCookies(cookieData); } else { this.serverLogger.debug("[cookie] 未找到 Cookie 文件,标记为待登录状态"); this.api.markLoginInfoLoaded(); } } isLoggedIn() { const cookiesJson = this.api?.getCookiesJson(); if (!cookiesJson || cookiesJson === "[]") return false; try { return JSON.parse(cookiesJson).some((c) => c.key === "bili_jct"); } catch { return false; } } clearLoginTimer() { if (this.loginTimer) { this.loginTimer(); this.loginTimer = void 0; } } async reportAccountInfo() { if (!this.api) return; let personalInfo; try { personalInfo = await this.api.getMyselfInfo(); } catch (e) { this.serverLogger.warn(`[account] 获取个人信息异常: ${e}`); this.auth.reportTransientFailure(e); this.auth.attachHealthCheck(); return; } if (personalInfo.code !== 0) { this.auth.reportLoginCheck(personalInfo.code); if (personalInfo.code !== -101) this.auth.attachHealthCheck(); return; } let card; try { card = (await this.api.getUserCardInfo(personalInfo.data.mid.toString(), true)).data; } catch (e) { this.serverLogger.warn(`[account] 获取用户卡片失败: ${e}`); } this.auth.reportLoggedIn(card); this.auth.attachHealthCheck(); } async handleAuthLost() { this.auth.reportLoggedOut("authLost"); const now = Date.now(); if (now - this.authLostNotifiedAt < 6e4) return; this.authLostNotifiedAt = now; try { await this.push?.sendPrivateMsg("账号登录已失效,请在控制台重新扫码登录"); } catch (e) { this.serverLogger.warn(`[auth] 失效通知私信失败:${e}`); } } async loadInitialSubscriptions() { if (this.config.advancedSub) { this.serverLogger.info("[sub] 开启高级订阅,等待接收订阅配置..."); this.selfCtx.emit("bilibili-notify/ready-to-receive"); } else if (this.config.subs?.length) { this.serverLogger.debug(`[sub] 从配置加载 ${this.config.subs.length} 个订阅项`); const subs = SubscriptionManager.fromFlatConfig(this.config.subs); if (!this.subMgr) return; await this.subMgr.loadSubscriptions(subs, { isReload: false }); this.syncCurrentSubs(); this.updateSubNotifier(); const ops = [...this.subMgr.subManager.values()].map((sub) => ({ type: "add", sub })); if (ops.length) this.selfCtx.emit("bilibili-notify/subscription-changed", ops); } else this.serverLogger.info("[sub] 初始化完毕,但未添加任何订阅"); } updateSubNotifier() { if (!this.subMgr) return; if (this.subNotifier) this.subNotifier.dispose(); const subInfo = this.subList(); if (subInfo === "没有订阅任何UP") this.subNotifier = this.selfCtx.notifier.create(subInfo); else { const lines = subInfo.split("\n").filter(Boolean); const content = h(h.Fragment, [h("p", "当前订阅对象:"), h("ul", lines.map((str) => h("li", str)))]); this.subNotifier = this.selfCtx.notifier.create(content); } } registerConsoleEvents() { this.selfCtx.on("bilibili-notify/subscription-changed", async (_ops) => { await this.selfCtx.sleep(5e3); if (this.currentSubs) await this.warnMissingPlugins(this.currentSubs); }); this.selfCtx.console.addListener("bilibili-notify/start-login", async () => { this.serverLogger.info("[login] 触发登录事件"); await this.startLoginFlow(); }); this.selfCtx.console.addListener("bilibili-notify/reset-key", async () => { this.serverLogger.info("[login] 触发重置密钥事件"); try { await this.storageMgr.cookieStore.resetKey(); this.auth.reportLoggedOut("keyReset"); } catch (e) { this.serverLogger.error(`[login] 重置密钥失败:${e}`); } }); this.selfCtx.console.addListener("bilibili-notify/request-cors", async (url) => { let parsed; try { parsed = new URL(url); } catch { throw new Error("无效的 URL"); } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("仅支持 http/https 协议"); const host = parsed.hostname.toLowerCase(); if (!(host === "bilibili.com" || host === "hdslb.com" || host.endsWith(".bilibili.com") || host.endsWith(".hdslb.com"))) throw new Error("仅允许 bilibili.com / hdslb.com 域名"); const buffer = await (await fetch(url)).arrayBuffer(); return `data:image/png;base64,${Buffer.from(buffer).toString("base64")}`; }); if (this.config.advancedSub) this.selfCtx.on("bilibili-notify/advanced-sub", async (subs) => { if (!Object.keys(subs).length) { this.serverLogger.info("[sub] 订阅加载完毕,但未添加任何订阅"); return; } if (!this.subMgr) return; const prevSubManager = new Map(this.subMgr.subManager); await this.subMgr.loadSubscriptions(subs, { isReload: prevSubManager.size > 0 }); this.syncCurrentSubs(); this.updateSubNotifier(); const ops = this.diffSubManagers(prevSubManager, this.subMgr.subManager); if (ops.length) this.selfCtx.emit("bilibili-notify/subscription-changed", ops); }); } async startLoginFlow() { if (!this.api) return; let qrContent; try { qrContent = await this.api.getLoginQRCode(); } catch (e) { this.serverLogger.error(`[login] 获取登录二维码失败:${e}`); return; } if (qrContent.code !== 0) { this.auth.reportQrFailure("qrFetchFailed"); return; } QRCode.toBuffer(qrContent.data.url, { errorCorrectionLevel: "H", type: "png", margin: 1, color: { dark: "#000000", light: "#FFFFFF" } }, (err, buffer) => { if (err) { this.serverLogger.error(`[login] 生成二维码失败:${err}`); this.auth.reportQrFailure("qrRenderFailed"); return; } this.auth.reportQrReady(`data:image/png;base64,${Buffer.from(buffer).toString("base64")}`); }); this.clearLoginTimer(); let polling = true; this.loginTimer = this.selfCtx.setInterval(async () => { if (!polling) return; polling = false; try { await this.pollLoginStatus(qrContent.data.qrcode_key); } finally { polling = true; } }, 1e3); this.selfCtx.setTimeout(() => { if (!this.loginTimer) return; this.clearLoginTimer(); this.auth.reportQrFailure("qrExpired"); }, 180 * 1e3); } async pollLoginStatus(qrcodeKey) { if (!this.api) return; let loginContent; try { loginContent = await this.api.getLoginStatus(qrcodeKey); } catch (e) { this.serverLogger.error(`[login] 获取登录状态失败:${e}`); return; } const code = loginContent?.data?.code; if (code === 86101) { this.auth.reportQrPending("waitScan"); return; } if (code === 86090) { this.auth.reportQrPending("waitConfirm"); return; } if (code === 86038) { this.clearLoginTimer(); this.auth.reportQrFailure("qrInvalidated"); return; } if (code === 0) { this.clearLoginTimer(); const cookiesJson = this.api.getCookiesJson(); if (!cookiesJson || cookiesJson === "[]") { this.serverLogger.error("[login] 登录成功但未获取到任何 cookie,放弃保存"); this.auth.reportQrFailure("noCookieAfterLogin"); return; } try { const refreshToken = loginContent.data.refresh_token ?? ""; await this.storageMgr.cookieStore.save({ cookiesJson, refreshToken }); } catch (e) { this.serverLogger.error(`[login] 保存 cookie 失败:${e}`); } this.auth.reportLoggedIn(void 0, "loginJustSucceeded"); await this.reportAccountInfo(); await this.loadInitialSubscriptions(); return; } if (loginContent?.code !== 0) { this.clearLoginTimer(); this.auth.reportQrFailure("genericLoginFail"); } } async warnMissingPlugins(subs) { if (!this.push) return; const needDynamic = Object.values(subs).some((s) => s.dynamic); const needLive = Object.values(subs).some((s) => s.live); if (needDynamic && !this.selfCtx.get("bilibili-notify-dynamic")) { const msg = "[bilibili-notify] 警告:有订阅开启了动态通知,但动态插件(koishi-plugin-bilibili-notify-dynamic)未运行,请检查是否已安装并启用该插件。"; this.serverLogger.warn(`[warn] ${msg}`); await this.push.sendPrivateMsg(msg); } if (needLive && !this.selfCtx.get("bilibili-notify-live")) { const msg = "[bilibili-notify] 警告:有订阅开启了直播通知,但直播插件(koishi-plugin-bilibili-notify-live)未运行,请检查是否已安装并启用该插件。"; this.serverLogger.warn(`[warn] ${msg}`); await this.push.sendPrivateMsg(msg); } } }; //#endregion //#region src/index.ts const inject = { required: ["notifier", "console"], optional: ["bilibili-notify-dynamic", "bilibili-notify-live"] }; const name = "bilibili-notify"; const usage = ` <h1>Bilibili-Notify</h1> <p>使用问题请加群咨询 801338523</p> --- 主人好呀~我是笨笨女仆小助手哒 (〃∀〃)♡ 专门帮主人管理 B 站订阅和直播推送的! 女仆虽然笨笨的,但是会尽力不出错哦~ 主人,只要按照女仆的提示一步一步设置,女仆就可以乖乖帮您工作啦! 首先呢~请主人仔细阅读订阅相关的 subs 的填写说明 (>ω<)b 【主人账号部分非必填】然后再告诉女仆您的 主人账号 (///▽///),并选择您希望女仆服务的平台~ 接着,请认真填写 主人的 ID 和 群组 ID,确保信息完全正确~ 这样女仆才能顺利找到您并准确汇报动态呢 (≧▽≦) 不用着急,女仆会一直在这里陪着您,一步一步完成设置~ 主人只要乖乖填好这些信息,就能让女仆变得超级听话、超级勤快啦 (>///<)♡ 想要重新登录的话,只需要点击控制台左侧的「扫码登录」哦~ 主人~注意事项要仔细看呀 (>_<)♡ - 如果主人使用的是 onebot 机器人,平台名请填写 onebot,而不是 qq 哦~ - 如果需要更灵活的订阅配置,请安装 bilibili-notify-advanced-subscription 插件 乖乖遵守这些规则,女仆才能顺利帮主人工作呢 (*>ω<)b --- `; function apply(ctx, config) { ctx.plugin(BilibiliNotifyDataServer); ctx.plugin(BilibiliNotifyServerManager, config); ctx.on("bilibili-notify/update-config", (newConfig) => { ctx.scope.update(newConfig, false); }); ctx.console.addEntry({ dev: resolve(__dirname, "../client/index.ts"), prod: resolve(__dirname, "../dist") }); } const Config = BilibiliNotifyConfigSchema; //#endregion export { BilibiliNotifyConfigSchema, Config, apply, inject, name, usage };