@dsh-plugin/dsh-synapse 0.0.0-stage → 0.1.0-dev.41044818627
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE.md +18 -0
- package/README.md +136 -2
- package/cordis.patch.yml +3 -0
- package/lib/bot.cjs +8314 -0
- package/lib/bot.cjs.map +7 -0
- package/lib/build.json +1 -0
- package/lib/client.js +46 -0
- package/lib/host/agent.d.ts +21 -0
- package/lib/host/agent.js +151 -0
- package/lib/host/agent.js.map +1 -0
- package/lib/host/bot.d.ts +17 -0
- package/lib/host/bot.js +158 -0
- package/lib/host/bot.js.map +1 -0
- package/lib/host/client.d.ts +2 -0
- package/lib/host/client.js +19 -0
- package/lib/host/client.js.map +1 -0
- package/lib/host/config.d.ts +24 -0
- package/lib/host/config.js +192 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/files.d.ts +29 -0
- package/lib/host/files.js +188 -0
- package/lib/host/files.js.map +1 -0
- package/lib/host/gateway.d.ts +33 -0
- package/lib/host/gateway.js +237 -0
- package/lib/host/gateway.js.map +1 -0
- package/lib/host/github.d.ts +47 -0
- package/lib/host/github.js +219 -0
- package/lib/host/github.js.map +1 -0
- package/lib/host/http.d.ts +4 -0
- package/lib/host/http.js +41 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.d.ts +32 -0
- package/lib/host/index.js +192 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/inference-worker.d.ts +1 -0
- package/lib/host/inference-worker.js +72 -0
- package/lib/host/inference-worker.js.map +1 -0
- package/lib/host/mcp.d.ts +138 -0
- package/lib/host/mcp.js +104 -0
- package/lib/host/mcp.js.map +1 -0
- package/lib/host/memory.d.ts +26 -0
- package/lib/host/memory.js +155 -0
- package/lib/host/memory.js.map +1 -0
- package/lib/host/panel.d.ts +1 -0
- package/lib/host/panel.html +174 -0
- package/lib/host/panel.js +388 -0
- package/lib/host/panel.js.map +1 -0
- package/lib/host/runtime.d.ts +21 -0
- package/lib/host/runtime.js +145 -0
- package/lib/host/runtime.js.map +1 -0
- package/lib/host/types.d.ts +112 -0
- package/lib/host/types.js +2 -0
- package/lib/host/types.js.map +1 -0
- package/package.json +94 -4
- package/scripts/build.mjs +90 -0
- package/scripts/check.mjs +13 -0
- package/scripts/dump-npm-latest.mjs +49 -0
- package/scripts/model-smoke.mjs +25 -0
- package/src/agent.ts +203 -0
- package/src/bot.ts +173 -0
- package/src/client.ts +20 -0
- package/src/config.ts +217 -0
- package/src/files.ts +206 -0
- package/src/gateway.ts +276 -0
- package/src/github.ts +263 -0
- package/src/http.ts +41 -0
- package/src/index.ts +227 -0
- package/src/inference-worker.ts +89 -0
- package/src/mcp.ts +112 -0
- package/src/memory.ts +178 -0
- package/src/panel.html +174 -0
- package/src/panel.ts +440 -0
- package/src/runtime.ts +164 -0
- package/src/types.ts +106 -0
- package/test/core.test.mjs +279 -0
- package/test/fixtures/mcp-server.mjs +14 -0
- package/test/integration.test.mjs +253 -0
- package/test/panel.test.mjs +73 -0
- package/tsconfig.json +18 -0
- package/vendor/gugle-chat-bot/LICENSE +661 -0
- package/vendor/gugle-chat-bot/package.json +57 -0
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -0
- package/vendor/gugle-chat-bot/src/api.json +4285 -0
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -0
- package/vendor/gugle-chat-bot/src/config/features.ts +548 -0
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -0
- package/vendor/gugle-chat-bot/src/config/manager.ts +357 -0
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -0
- package/vendor/gugle-chat-bot/src/custom.ts +548 -0
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -0
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -0
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -0
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -0
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -0
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -0
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +57 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -0
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -0
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -0
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -0
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -0
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -0
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -0
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +469 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -0
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -0
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -0
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -0
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -0
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -0
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -0
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -0
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -0
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -0
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -0
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -0
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -0
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -0
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -0
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -0
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -0
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +452 -0
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -0
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +78 -0
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +58 -0
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -0
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -0
- package/vendor/gugle-chat-bot/src/index.ts +695 -0
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -0
- package/vendor/gugle-chat-bot/src/result.json +359 -0
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -0
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -0
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -0
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -0
- package/vendor/gugle-chat-bot/tsconfig.json +46 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { ChildProcess } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { configStoreCount, reloadAllConfigStores } from '@/config/manager';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 管理员运维命令:/reboot、/reload、/shutdown。
|
|
9
|
+
*
|
|
10
|
+
* 设计要点:
|
|
11
|
+
* - 仅管理员可用(QQ 侧 = management operators,Discord 侧 = 服务器管理员),
|
|
12
|
+
* 权限校验由调用方(命令实现)在进入本模块前完成;
|
|
13
|
+
* - `/reboot` 采用「进程内自重启」:先拉起一个脱离父进程的新实例,确认其存活后再退出当前进程。
|
|
14
|
+
* 因此不依赖外部守护进程(本仓库无 pm2/Docker/systemd 配置);
|
|
15
|
+
* 若外部同时有守护进程拉起,会短暂出现两个实例抢同一 QQ 号,需避免混用;
|
|
16
|
+
* - 重启/关停前先发一条确认消息,并等待其发送完成,避免「刚回复就被杀」导致用户收不到回执;
|
|
17
|
+
* - 退出前留一小段缓冲,保证 QQ 侧消息已真正发出、且日志已落盘。
|
|
18
|
+
*
|
|
19
|
+
* 关于启动参数(2026-10-22 生产事故修复):
|
|
20
|
+
* 生产用 `tsx src/index.ts` 运行,tsx 的加载器参数位于 **process.execArgv**
|
|
21
|
+
* (`--require .../preflight.cjs --import .../loader.mjs`),而未在 process.argv 中。
|
|
22
|
+
* 早期实现只转发 process.argv,导致新进程变成裸 `node /data/src/index.ts`,
|
|
23
|
+
* `@/` 路径别名无法解析(ERR_MODULE_NOT_FOUND: Cannot find package '@/config'),
|
|
24
|
+
* 且父进程无条件退出 → 机器人直接离线。现在:
|
|
25
|
+
* 1. buildRestartCommand() 重建命令时必须带上 execArgv;
|
|
26
|
+
* 2. restartApplication() 在退出父进程前做健康检查,子进程秒退则回滚(父进程继续运行);
|
|
27
|
+
* 3. 子进程通过 QQBOT_START_DELAY_MS 延迟启动,避免与旧进程的 QQ 连接重叠互踢。
|
|
28
|
+
*
|
|
29
|
+
* 环境变量逃生口:若部署由外部托管(容器 restart policy / pm2 / systemd / shell 脚本),
|
|
30
|
+
* 可用 QQBOT_RESTART_COMMAND 指定重启命令(shell 执行),例如 `pm2 restart gugle-qq-bot`;
|
|
31
|
+
* 未设置时按 buildRestartCommand() 重建 argv(默认路径,已带 tsx 加载器)。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** 重启/关停前的缓冲时间:给发送队列留出发送 + 日志刷盘的时间 */
|
|
35
|
+
const SHUTDOWN_GRACE_MS = 800;
|
|
36
|
+
|
|
37
|
+
/** 重启时用于标记「本进程由 /reboot 拉起」,避免新进程继承同名环境变量造成误判 */
|
|
38
|
+
const RESTART_ENV_FLAG = 'QQBOT_RESTARTED_BY_COMMAND';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 自定义重启命令的环境变量名(逃生口)。
|
|
42
|
+
*
|
|
43
|
+
* 用法:某些部署由外部托管(容器 restart policy、pm2、systemd、一段 shell 脚本),
|
|
44
|
+
* 直接重建 argv 可能与宿主拉起方式不一致。设置该变量后改为用 shell 执行此命令串,例如:
|
|
45
|
+
* ```
|
|
46
|
+
* QQBOT_RESTART_COMMAND="pm2 restart gugle-qq-bot"
|
|
47
|
+
* QQBOT_RESTART_COMMAND="docker restart qq_bot"
|
|
48
|
+
* ```
|
|
49
|
+
* 未设置时走 buildRestartCommand() 重建 argv(默认路径)。
|
|
50
|
+
*/
|
|
51
|
+
const RESTART_COMMAND_ENV = 'QQBOT_RESTART_COMMAND';
|
|
52
|
+
|
|
53
|
+
/** 是否用了自定义重启命令(供日志/回执说明) */
|
|
54
|
+
export function customRestartCommand(): string | undefined {
|
|
55
|
+
const raw = process.env[RESTART_COMMAND_ENV];
|
|
56
|
+
const trimmed = raw?.trim();
|
|
57
|
+
return trimmed ? trimmed : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 子进程延迟启动时间的环境变量名(避免新旧进程 QQ 连接重叠) */
|
|
61
|
+
const START_DELAY_ENV = 'QQBOT_START_DELAY_MS';
|
|
62
|
+
|
|
63
|
+
/** 子进程延迟启动默认值(毫秒) */
|
|
64
|
+
const DEFAULT_START_DELAY_MS = 2500;
|
|
65
|
+
|
|
66
|
+
/** 新进程存活观察窗口默认值(毫秒) */
|
|
67
|
+
const DEFAULT_HEALTH_CHECK_MS = 2000;
|
|
68
|
+
|
|
69
|
+
/** 健康检查通过后,父进程退出前的延迟(毫秒) */
|
|
70
|
+
const DEFAULT_EXIT_DELAY_MS = 300;
|
|
71
|
+
|
|
72
|
+
/** 子进程日志保留的文件名前缀 */
|
|
73
|
+
const CHILD_LOG_PREFIX = 'reboot-child';
|
|
74
|
+
|
|
75
|
+
/** 读日志尾部的最大字节数 */
|
|
76
|
+
const CHILD_LOG_TAIL_BYTES = 4096;
|
|
77
|
+
|
|
78
|
+
/** 重启进行中标记:拒绝并发 /reboot */
|
|
79
|
+
let restartInProgress = false;
|
|
80
|
+
|
|
81
|
+
export interface ReloadReport {
|
|
82
|
+
/** 从磁盘重载的 ConfigStore 数量 */
|
|
83
|
+
configStores: number;
|
|
84
|
+
/** 是否成功重载事件数据(ban_list 等) */
|
|
85
|
+
eventData: boolean;
|
|
86
|
+
/** 是否成功重载 GitHub 绑定数据 */
|
|
87
|
+
githubBindings: boolean;
|
|
88
|
+
/** 额外重载的模块(按需由调用方追加说明) */
|
|
89
|
+
extras: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 重载配置与数据文件(/reload)。
|
|
94
|
+
*
|
|
95
|
+
* 覆盖范围:
|
|
96
|
+
* - 全部 ConfigStore(features/*.json、hermes.json、discord-bridge.json 等)→ 磁盘重读;
|
|
97
|
+
* - 事件数据(configs/event-data.json,含 ban_list 与版本跟踪记录);
|
|
98
|
+
* - GitHub 绑定数据(configs/github-bind.json);
|
|
99
|
+
* - 由调用方传入的额外重载动作(如 PeakValleyTimer 这类构造期读配置的模块)。
|
|
100
|
+
*
|
|
101
|
+
* 不覆盖:bot-config.json(进程启动时读取,改端口/token 需重启)。
|
|
102
|
+
*/
|
|
103
|
+
export async function reloadRuntime(options: {
|
|
104
|
+
eventData?: () => Promise<void>;
|
|
105
|
+
githubBindings?: () => void;
|
|
106
|
+
extra?: Array<{ name: string; run: () => void }>;
|
|
107
|
+
}): Promise<ReloadReport> {
|
|
108
|
+
const extras: string[] = [];
|
|
109
|
+
const configStores = reloadAllConfigStores();
|
|
110
|
+
|
|
111
|
+
let eventData = false;
|
|
112
|
+
try {
|
|
113
|
+
await options.eventData?.();
|
|
114
|
+
eventData = options.eventData !== undefined;
|
|
115
|
+
} catch {
|
|
116
|
+
eventData = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let githubBindings = false;
|
|
120
|
+
try {
|
|
121
|
+
options.githubBindings?.();
|
|
122
|
+
githubBindings = options.githubBindings !== undefined;
|
|
123
|
+
} catch {
|
|
124
|
+
githubBindings = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const item of options.extra ?? []) {
|
|
128
|
+
try {
|
|
129
|
+
item.run();
|
|
130
|
+
extras.push(item.name);
|
|
131
|
+
} catch {
|
|
132
|
+
// 单个模块重载失败不影响其余部分
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { configStores, eventData, githubBindings, extras };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 已登记的配置仓库数量(用于 /reload 回执展示) */
|
|
140
|
+
export function registeredConfigStoreCount(): number {
|
|
141
|
+
return configStoreCount();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 重建出的启动命令 */
|
|
145
|
+
export interface RestartCommand {
|
|
146
|
+
/** 可执行文件(通常是 node) */
|
|
147
|
+
command: string;
|
|
148
|
+
/** 传给可执行文件的参数(含 tsx 加载器等 node 选项) */
|
|
149
|
+
args: string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 需要从 execArgv 中剔除的「一次性的 eval 模式」选项:不能传递给子进程 */
|
|
153
|
+
const EVAL_ARG_FLAGS = new Set(['--eval', '-e', '--print', '-p']);
|
|
154
|
+
/** 这些选项带值,若以 `=` 形式出现也要识别(如 --eval=xxx) */
|
|
155
|
+
const EVAL_ARG_PREFIXES = ['--eval=', '-e=', '--print=', '-p='];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 过滤 execArgv 中与「当前进程以 eval 模式运行」相关的参数。
|
|
159
|
+
* 这类参数(`-e` / `--eval` / `-p` / `--print`)不可传递给子进程,
|
|
160
|
+
* 否则子进程会把入口路径当成 eval 的表达式。
|
|
161
|
+
*/
|
|
162
|
+
function filterEvalArgs(execArgv: string[]): string[] {
|
|
163
|
+
const result: string[] = [];
|
|
164
|
+
for (let index = 0; index < execArgv.length; index++) {
|
|
165
|
+
const arg = execArgv[index]!;
|
|
166
|
+
if (EVAL_ARG_FLAGS.has(arg)) {
|
|
167
|
+
// 跳过该 flag 及其值
|
|
168
|
+
index++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (EVAL_ARG_PREFIXES.some(prefix => arg.startsWith(prefix))) continue;
|
|
172
|
+
result.push(arg);
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 重建「重启自身」所需的启动命令(纯函数,便于测试)。
|
|
179
|
+
*
|
|
180
|
+
* 关键点:必须保留 process.execArgv —— 生产用 tsx 运行 TS 入口时,
|
|
181
|
+
* tsx 的加载器(`--require preflight.cjs` / `--import loader.mjs`)只存在于
|
|
182
|
+
* execArgv;遗漏会导致子进程以裸 node 执行 .ts 文件、`@/` 别名解析失败。
|
|
183
|
+
*
|
|
184
|
+
* @param execPath 当前 node 可执行文件路径(默认 process.execPath)
|
|
185
|
+
* @param execArgv 当前 node 选项(默认 process.execArgv)
|
|
186
|
+
* @param argv 当前进程参数(默认 process.argv;第 0 项为可执行文件、第 1 项为入口)
|
|
187
|
+
* @returns 可直接交给 spawn 的命令与参数
|
|
188
|
+
*/
|
|
189
|
+
export function buildRestartCommand(
|
|
190
|
+
execPath: string = process.execPath,
|
|
191
|
+
execArgv: string[] = process.execArgv,
|
|
192
|
+
argv: string[] = process.argv
|
|
193
|
+
): RestartCommand {
|
|
194
|
+
const scriptArgs = argv.slice(1);
|
|
195
|
+
if (scriptArgs.length === 0) throw new Error('无法获取当前启动命令,暂不能自重启');
|
|
196
|
+
return {
|
|
197
|
+
command: execPath,
|
|
198
|
+
args: [...filterEvalArgs(execArgv), ...scriptArgs]
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 重启结果 */
|
|
203
|
+
export interface RestartResult {
|
|
204
|
+
/** 新进程 pid */
|
|
205
|
+
pid: number;
|
|
206
|
+
/** 健康检查是否通过(false 表示子进程在观察窗口内退出,父进程未退出) */
|
|
207
|
+
alive: boolean;
|
|
208
|
+
/** 子进程日志尾部(失败时用于回报原因) */
|
|
209
|
+
logTail?: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** restartApplication 的可注入选项(测试用) */
|
|
213
|
+
export interface RestartOptions {
|
|
214
|
+
/** 自定义启动命令;字符串表示走 shell,未指定时按 buildRestartCommand 重建 */
|
|
215
|
+
restartCommand?: RestartCommand | { shell: string };
|
|
216
|
+
/** 子进程存活观察窗口(毫秒) */
|
|
217
|
+
healthCheckMs?: number;
|
|
218
|
+
/** 健康检查通过后父进程退出延迟(毫秒) */
|
|
219
|
+
exitDelayMs?: number;
|
|
220
|
+
/** 子进程启动延迟(毫秒),通过 env 传给子进程 */
|
|
221
|
+
startDelayMs?: number;
|
|
222
|
+
/** 注入退出函数(默认 process.exit) */
|
|
223
|
+
exit?: (code: number) => void;
|
|
224
|
+
/** 注入 spawn(默认 node:child_process.spawn) */
|
|
225
|
+
spawnImpl?: typeof spawn;
|
|
226
|
+
/** 注入日志目录(默认 <cwd>/logs) */
|
|
227
|
+
logDir?: string;
|
|
228
|
+
/** 子进程工作目录(默认 process.cwd();tsx 依赖 cwd 下的 tsconfig 解析 @/ 别名) */
|
|
229
|
+
cwd?: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 重启进行中(拒绝并发触发) */
|
|
233
|
+
export function isRestartInProgress(): boolean {
|
|
234
|
+
return restartInProgress;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 读取文件尾部文本(失败返回空串) */
|
|
238
|
+
function readTail(filePath: string, maxBytes: number = CHILD_LOG_TAIL_BYTES): string {
|
|
239
|
+
try {
|
|
240
|
+
const stat = fs.statSync(filePath);
|
|
241
|
+
const start = Math.max(0, stat.size - maxBytes);
|
|
242
|
+
const fd = fs.openSync(filePath, 'r');
|
|
243
|
+
try {
|
|
244
|
+
const buffer = Buffer.alloc(stat.size - start);
|
|
245
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
246
|
+
return buffer.toString('utf-8').trim();
|
|
247
|
+
} finally {
|
|
248
|
+
fs.closeSync(fd);
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
return '';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 进程内自重启:拉起新实例 → 健康检查 → 通过后才退出当前进程。
|
|
257
|
+
*
|
|
258
|
+
* 失败语义(重要):只要子进程在观察窗口内退出 / spawn 抛错,就**不退出父进程**,
|
|
259
|
+
* 返回 alive=false 与日志尾部,由调用方回报管理员。这样单次重启失败不会让机器人离线。
|
|
260
|
+
*
|
|
261
|
+
* @returns 新进程 pid 与健康检查结果
|
|
262
|
+
*/
|
|
263
|
+
export async function restartApplication(options: RestartOptions = {}): Promise<RestartResult> {
|
|
264
|
+
if (restartInProgress) throw new Error('已有重启正在进行中,请稍候');
|
|
265
|
+
restartInProgress = true;
|
|
266
|
+
|
|
267
|
+
const {
|
|
268
|
+
restartCommand: explicitRestartCommand,
|
|
269
|
+
healthCheckMs = DEFAULT_HEALTH_CHECK_MS,
|
|
270
|
+
exitDelayMs = DEFAULT_EXIT_DELAY_MS,
|
|
271
|
+
startDelayMs = DEFAULT_START_DELAY_MS,
|
|
272
|
+
exit = (code: number) => process.exit(code),
|
|
273
|
+
spawnImpl = spawn,
|
|
274
|
+
logDir = path.resolve(process.cwd(), 'logs'),
|
|
275
|
+
cwd = process.cwd()
|
|
276
|
+
} = options;
|
|
277
|
+
|
|
278
|
+
// 优先级:显式传入 > QQBOT_RESTART_COMMAND 环境变量 > 按 execArgv 重建
|
|
279
|
+
const shellCommand = customRestartCommand();
|
|
280
|
+
const restartCommand: RestartCommand | { shell: string } | undefined =
|
|
281
|
+
explicitRestartCommand ?? (shellCommand !== undefined ? { shell: shellCommand } : undefined);
|
|
282
|
+
|
|
283
|
+
// 子进程日志:detached + inherit 时父进程退出会关闭管道,子进程后续写入可能 EPIPE,
|
|
284
|
+
// 因此改为写入独立日志文件,同时保证启动失败的原因可查。
|
|
285
|
+
let logPath = '';
|
|
286
|
+
let logFd: number | undefined;
|
|
287
|
+
try {
|
|
288
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
289
|
+
logPath = path.join(logDir, `${CHILD_LOG_PREFIX}-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
|
|
290
|
+
logFd = fs.openSync(logPath, 'a');
|
|
291
|
+
} catch {
|
|
292
|
+
logFd = undefined; // 日志目录不可写时退化为忽略输出,健康检查仍生效
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const env: NodeJS.ProcessEnv = {
|
|
296
|
+
...process.env,
|
|
297
|
+
[RESTART_ENV_FLAG]: '1',
|
|
298
|
+
[START_DELAY_ENV]: String(Math.max(0, startDelayMs))
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** 清理日志 fd */
|
|
302
|
+
const closeLog = (): void => {
|
|
303
|
+
if (logFd !== undefined) {
|
|
304
|
+
try {
|
|
305
|
+
fs.closeSync(logFd);
|
|
306
|
+
} catch {
|
|
307
|
+
// 忽略
|
|
308
|
+
}
|
|
309
|
+
logFd = undefined;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
let child: ChildProcess;
|
|
314
|
+
try {
|
|
315
|
+
const stdio: Array<'ignore' | number> =
|
|
316
|
+
logFd !== undefined ? ['ignore', logFd, logFd] : ['ignore', 'ignore', 'ignore'];
|
|
317
|
+
if (typeof restartCommand === 'string') {
|
|
318
|
+
child = spawnImpl(restartCommand, { cwd, env, detached: true, stdio, shell: true } as never);
|
|
319
|
+
} else if (restartCommand !== undefined && 'shell' in restartCommand) {
|
|
320
|
+
child = spawnImpl(restartCommand.shell, { cwd, env, detached: true, stdio, shell: true } as never);
|
|
321
|
+
} else {
|
|
322
|
+
const command = restartCommand ?? buildRestartCommand();
|
|
323
|
+
child = spawnImpl(command.command, command.args, {
|
|
324
|
+
cwd,
|
|
325
|
+
env,
|
|
326
|
+
detached: true,
|
|
327
|
+
stdio
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
closeLog();
|
|
332
|
+
restartInProgress = false;
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const pid = child.pid;
|
|
337
|
+
if (pid === undefined) {
|
|
338
|
+
closeLog();
|
|
339
|
+
restartInProgress = false;
|
|
340
|
+
throw new Error('新进程启动失败(未获得 pid),已取消重启');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── 健康检查:观察窗口内退出 = 启动失败 ──
|
|
344
|
+
const alive = await new Promise<boolean>(resolve => {
|
|
345
|
+
let settled = false;
|
|
346
|
+
const finish = (value: boolean): void => {
|
|
347
|
+
if (settled) return;
|
|
348
|
+
settled = true;
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
child.removeListener('exit', onExit);
|
|
351
|
+
child.removeListener('error', onError);
|
|
352
|
+
resolve(value);
|
|
353
|
+
};
|
|
354
|
+
const onExit = (): void => finish(false);
|
|
355
|
+
const onError = (): void => finish(false);
|
|
356
|
+
const timer = setTimeout(() => finish(true), Math.max(0, healthCheckMs));
|
|
357
|
+
child.once('exit', onExit);
|
|
358
|
+
child.once('error', onError);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (!alive) {
|
|
362
|
+
const logTail = logPath ? readTail(logPath) : '';
|
|
363
|
+
closeLog();
|
|
364
|
+
restartInProgress = false;
|
|
365
|
+
return { pid, alive: false, ...(logTail ? { logTail } : {}) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 存活:脱离父进程后安排退出(关闭日志 fd 交给子进程继续持有写入)
|
|
369
|
+
try {
|
|
370
|
+
child.unref();
|
|
371
|
+
} catch {
|
|
372
|
+
// 忽略
|
|
373
|
+
}
|
|
374
|
+
setTimeout(() => {
|
|
375
|
+
// 生产语义:进程即将退出,标记无需释放;但若 exit 被注入(测试/自定义退出),
|
|
376
|
+
// 进程不会真的退出,必须释放标记,否则后续 /reboot 会被永久拒绝。
|
|
377
|
+
restartInProgress = false;
|
|
378
|
+
exit(0);
|
|
379
|
+
}, Math.max(0, exitDelayMs));
|
|
380
|
+
// 注意:不关闭 logFd —— 子进程仍在写同一个 fd
|
|
381
|
+
return { pid, alive: true };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 关闭应用:优雅停服后退出进程。
|
|
386
|
+
*
|
|
387
|
+
* @param stopBot 停止机器人(断开 WebSocket、触发 before-stop/after-stop)
|
|
388
|
+
* @param delayMs 退出前的缓冲时间
|
|
389
|
+
*/
|
|
390
|
+
export function shutdownApplication(stopBot: () => void, delayMs: number = SHUTDOWN_GRACE_MS): void {
|
|
391
|
+
try {
|
|
392
|
+
stopBot();
|
|
393
|
+
} finally {
|
|
394
|
+
// 无论停服是否抛错都要退出,否则 /shutdown 形同虚设
|
|
395
|
+
setTimeout(() => process.exit(0), delayMs);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 本进程是否由 /reboot 拉起(用于启动日志标注) */
|
|
400
|
+
export function isRestartedByCommand(): boolean {
|
|
401
|
+
return process.env[RESTART_ENV_FLAG] === '1';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 本进程被 /reboot 拉起时要求的启动延迟(毫秒)。
|
|
406
|
+
* 用于避免新旧进程同时连接 NapCat 造成同号互踢;非重启启动返回 0。
|
|
407
|
+
*/
|
|
408
|
+
export function restartStartDelayMs(): number {
|
|
409
|
+
if (!isRestartedByCommand()) return 0;
|
|
410
|
+
const raw = process.env[START_DELAY_ENV];
|
|
411
|
+
if (!raw) return 0;
|
|
412
|
+
const value = Number(raw);
|
|
413
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
414
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ViewData } from '@/type/bili';
|
|
2
|
+
import { Template, tryGenerateImage } from '@/image';
|
|
3
|
+
import { Logger } from 'winston';
|
|
4
|
+
import { convertDuration, convertStat, getPicB64 } from '@/features/bili/image';
|
|
5
|
+
import { QQBot } from '@/index';
|
|
6
|
+
import dayjs from 'dayjs';
|
|
7
|
+
|
|
8
|
+
export function videoHandler(bot: QQBot, viewData: ViewData, logger?: Logger): Promise<string> {
|
|
9
|
+
return new Promise<string>((resolve, reject) => {
|
|
10
|
+
Promise.all([
|
|
11
|
+
getPicB64(bot, viewData.pic).then(cover => {
|
|
12
|
+
viewData.pic = cover;
|
|
13
|
+
}),
|
|
14
|
+
getPicB64(bot, viewData.owner.face).then(cover => {
|
|
15
|
+
viewData.owner.face = cover;
|
|
16
|
+
})
|
|
17
|
+
]).then(() => {
|
|
18
|
+
const description = `<p>${viewData.desc.split('\n').join('</p>\n<p>')}</p>`;
|
|
19
|
+
const template = Template.load('video', 'src/features/bili/template')
|
|
20
|
+
.arg('title', viewData.title)
|
|
21
|
+
.arg('description', description)
|
|
22
|
+
.arg('cover', viewData.pic)
|
|
23
|
+
.arg('avatar', viewData.owner.face)
|
|
24
|
+
.arg('name', viewData.owner.name)
|
|
25
|
+
.arg('date', dayjs(viewData.ctime * 1000).format('YYYY-MM-DD HH:mm'))
|
|
26
|
+
.arg('duration', convertDuration(viewData.duration))
|
|
27
|
+
.arg('view', convertStat(viewData.stat.view))
|
|
28
|
+
.arg('like', convertStat(viewData.stat.like))
|
|
29
|
+
.arg('reply', convertStat(viewData.stat.reply));
|
|
30
|
+
const templateFile = template.file();
|
|
31
|
+
template
|
|
32
|
+
.handler()
|
|
33
|
+
.then(video => {
|
|
34
|
+
logger?.debug(`Start process video message...`);
|
|
35
|
+
tryGenerateImage(resolve, reject, video, 820, templateFile);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { QQBot } from '@/index';
|
|
2
|
+
import { ViewData } from '@/type/bili';
|
|
3
|
+
import { Logger } from 'winston';
|
|
4
|
+
import { videoHandler } from '@/features/bili/image/impl/video';
|
|
5
|
+
|
|
6
|
+
const B64_IMG_PREFIX = 'data:image/jpg;base64,';
|
|
7
|
+
|
|
8
|
+
export class BiliImage {
|
|
9
|
+
public static videoHandler(bot: QQBot, viewData: ViewData, logger?: Logger): Promise<string> {
|
|
10
|
+
return videoHandler(bot, viewData, logger);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getPicB64(bot: QQBot, url: string): Promise<string> {
|
|
15
|
+
return new Promise<string>((resolve, reject) => {
|
|
16
|
+
bot.axiosInstance
|
|
17
|
+
.get(url, { responseType: 'arraybuffer' })
|
|
18
|
+
.then(res => resolve(B64_IMG_PREFIX + Buffer.from(res.data, 'binary').toString('base64')))
|
|
19
|
+
.catch(reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function convertDuration(duration: number): string {
|
|
24
|
+
const hour = Math.floor(duration / 3600);
|
|
25
|
+
const min = Math.floor((duration % 3600) / 60);
|
|
26
|
+
const sec = Math.floor(duration % 60);
|
|
27
|
+
if (hour > 0) return `${hour}:${min}:${sec}`;
|
|
28
|
+
if (min > 0) return `${min}:${sec}`;
|
|
29
|
+
return `${sec}秒`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function convertStat(stat: number) {
|
|
33
|
+
if (Math.floor(stat / 10000) > 0) {
|
|
34
|
+
return `${Math.floor(stat / 10000)}.${Math.floor((stat % 10000) / 1000)}万`;
|
|
35
|
+
}
|
|
36
|
+
return `${stat}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { QQBot } from '@/index';
|
|
2
|
+
import { GroupMessageWSMSG, Message } from '@/type';
|
|
3
|
+
import { ViewData } from '@/type/bili';
|
|
4
|
+
import { BiliImage } from '@/features/bili/image';
|
|
5
|
+
|
|
6
|
+
const BV_REG_EXP = /^BV[a-zA-Z0-9]{10}$/;
|
|
7
|
+
const AV_REG_EXP = /^av(\d+)$/;
|
|
8
|
+
const B23_REG_EXP = /^(https:\/\/)?(b23.tv\/[a-zA-Z0-9]{7})$/;
|
|
9
|
+
|
|
10
|
+
export class Bili {
|
|
11
|
+
public static processMessage(bot: QQBot, msg: GroupMessageWSMSG, sentMessage: Message[]): Promise<void> {
|
|
12
|
+
const bv = msg.raw_message.match(BV_REG_EXP);
|
|
13
|
+
if (bv) {
|
|
14
|
+
return Bili.getVideoInfo(bot, msg, bv[0], sentMessage);
|
|
15
|
+
}
|
|
16
|
+
const av = msg.raw_message.match(AV_REG_EXP);
|
|
17
|
+
if (av) {
|
|
18
|
+
return Bili.getVideoInfo(bot, msg, av[0], sentMessage);
|
|
19
|
+
}
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static getVideoInfo(bot: QQBot, msg: GroupMessageWSMSG, vid: string, sentMessage: Message[]): Promise<void> {
|
|
24
|
+
return Bili.renderVideoCard(bot, vid).then(result => {
|
|
25
|
+
sentMessage.push({
|
|
26
|
+
type: 'image',
|
|
27
|
+
data: {
|
|
28
|
+
file: `data:image/png;base64, ${result}`
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 渲染视频信息卡片(供 AI 技能等程序化调用):支持 BV 号 / av 号 / b23.tv 短链,
|
|
36
|
+
* 返回 base64 图片;失败抛错由调用方统一处理。
|
|
37
|
+
*/
|
|
38
|
+
public static async renderVideoCard(bot: QQBot, input: string): Promise<string> {
|
|
39
|
+
let vid = input.trim();
|
|
40
|
+
const shortLink = vid.match(B23_REG_EXP);
|
|
41
|
+
if (shortLink) {
|
|
42
|
+
// b23 短链:跟随 302 跳转,从最终 URL 提取 BV/av 号
|
|
43
|
+
const url = `https://${shortLink[2]}`;
|
|
44
|
+
const res = await bot.axiosInstance.get(url);
|
|
45
|
+
const finalUrl: string = res.request?.res?.responseUrl ?? '';
|
|
46
|
+
const resolved = finalUrl.match(/BV[a-zA-Z0-9]{10}/) ?? finalUrl.match(/av\d+/);
|
|
47
|
+
if (!resolved) throw new Error(`无法从短链 ${shortLink[2]} 解析出视频`);
|
|
48
|
+
vid = resolved[0];
|
|
49
|
+
}
|
|
50
|
+
if (!BV_REG_EXP.test(vid) && !AV_REG_EXP.test(vid)) {
|
|
51
|
+
throw new Error(`无法识别的视频号:${input}(支持 BV 号 / av 号 / b23.tv 短链)`);
|
|
52
|
+
}
|
|
53
|
+
const params = vid.startsWith('BV') ? { bvid: vid } : { aid: vid.slice(2) };
|
|
54
|
+
const res = await bot.axiosInstance.get<{ code: number; message: string; data: ViewData }>(
|
|
55
|
+
'https://api.bilibili.com/x/web-interface/view',
|
|
56
|
+
{ params }
|
|
57
|
+
);
|
|
58
|
+
const { code, message, data } = res.data;
|
|
59
|
+
// B 站业务错误(如 -412 请求被拦截、-404 视频不存在)抛错,由调用方统一处理
|
|
60
|
+
if (code !== 0) {
|
|
61
|
+
throw new Error(`B 站接口返回错误(${code}):${message || '未知错误'}`);
|
|
62
|
+
}
|
|
63
|
+
return BiliImage.videoHandler(bot, data, bot.logger);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
* {
|
|
2
|
+
font-family: "Sarasa Fixed SC", sans-serif;
|
|
3
|
+
margin: 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
background-color: #00000000;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.container {
|
|
11
|
+
background-color: #F3F4F6;
|
|
12
|
+
width: 800px;
|
|
13
|
+
border-radius: 15px;
|
|
14
|
+
padding: 10px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.cover {
|
|
18
|
+
width: 800px;
|
|
19
|
+
border-radius: 10px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.up {
|
|
23
|
+
margin: 10px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.up-avatar {
|
|
27
|
+
display: inline-block;
|
|
28
|
+
position: absolute;
|
|
29
|
+
border: 4px solid #fff;
|
|
30
|
+
border-radius: 99999px;
|
|
31
|
+
width: 50px;
|
|
32
|
+
height: 50px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.up-name {
|
|
36
|
+
display: block;
|
|
37
|
+
width: 100%;
|
|
38
|
+
margin-left: 60px;
|
|
39
|
+
font-size: 20px;
|
|
40
|
+
font-weight: bold;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.up-date {
|
|
44
|
+
display: inline-block;
|
|
45
|
+
margin-left: 60px;
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
color: #999;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.up-date::after {
|
|
51
|
+
display: inline-block;
|
|
52
|
+
content: "·";
|
|
53
|
+
font-size: 14px;
|
|
54
|
+
color: #999;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.up-duration {
|
|
58
|
+
display: inline-block;
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
color: #999;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.title {
|
|
64
|
+
margin: 10px;
|
|
65
|
+
font-size: 24px;
|
|
66
|
+
font-weight: bold;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.stats {
|
|
70
|
+
color: #5A5F66;
|
|
71
|
+
font-size: 14px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.stats svg {
|
|
75
|
+
display: inline-block;
|
|
76
|
+
vertical-align: middle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.view {
|
|
80
|
+
display: inline-block;
|
|
81
|
+
margin-left: 30px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.like {
|
|
85
|
+
display: inline-block;
|
|
86
|
+
margin-left: 10px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.reply {
|
|
90
|
+
display: inline-block;
|
|
91
|
+
margin-left: 10px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.description {
|
|
95
|
+
margin: 20px;
|
|
96
|
+
font-size: 20px;
|
|
97
|
+
}
|