koishi-plugin-cnb-bot 0.1.0
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 +21 -0
- package/README.md +107 -0
- package/lib/client.d.ts +33 -0
- package/lib/client.js +382 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +1246 -0
- package/lib/storage.d.ts +24 -0
- package/lib/storage.js +172 -0
- package/lib/types.d.ts +49 -0
- package/lib/types.js +2 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 harcochen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Koishi CNB 报障助手
|
|
2
|
+
|
|
3
|
+
Koishi 插件版 CNB 报障助手。用户发送 `debug [故障描述]` 后上传一个 `.zip` 或 `.log` 文件;插件把原始文件上传到 CNB、创建 Issue、请求配置好的 NPC 分析,并把已验证的分析结果发回会话。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
需要 Node.js 18 或更高版本。在 Koishi 项目目录运行 `npm install ../cnb-bot-koishi`(按本机目录调整路径),然后在 Koishi 配置中加载 `koishi-plugin-cnb-bot`。确保已经启用数据库服务(例如 `@koishijs/plugin-database-sqlite`),默认无需指令前缀,直接发送 `debug`;如果自定义了 Koishi 的全局指令前缀,请按实际配置调用。
|
|
8
|
+
|
|
9
|
+
本插件需要 Koishi v4 和支持文件消息的聊天适配器。QQ OneBot 用户需要确认适配器能接收群文件/文件消息,并支持合并转发;不支持合并转发的平台会收到普通文本结果。
|
|
10
|
+
|
|
11
|
+
### Docker 挂载安装
|
|
12
|
+
|
|
13
|
+
挂载源码目录并通过 `portal:` 安装时,需要先在插件目录执行 `npm install` 和 `npm run build`,确保挂载目录包含 `lib/index.js`。`package.json` 的入口指向这个构建文件,Koishi 不会直接加载 `src/index.ts`。安装依赖时请使用与容器兼容的 Node.js 环境。
|
|
14
|
+
|
|
15
|
+
如果控制台提示“此插件未声明配置项”,请先确认容器中实际加载的入口存在且导出了 `Config`;加载失败也可能出现这一提示。本插件已声明配置 Schema。补齐构建产物后,重载 Koishi 应用(或重启 Koishi 容器),让控制台重新读取插件配置。
|
|
16
|
+
|
|
17
|
+
## 配置
|
|
18
|
+
|
|
19
|
+
| 配置 | 说明 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `cnb_repository` | CNB 仓库路径,例如 `group/repo`。必填 |
|
|
22
|
+
| `cnb_token` | CNB 令牌,需要 `repo-issue:rw`、`repo-notes:r`、`repo-notes:rw` 权限 |
|
|
23
|
+
| `group_whitelist` | 启用功能的群号;留空时所有群都不启用 |
|
|
24
|
+
| `private_whitelist` | 私信用户白名单;留空时允许所有人私信报障 |
|
|
25
|
+
| `npc_mention` | Issue 评论中用于触发 NPC 的提及文本,默认 `@CodeBuddy` |
|
|
26
|
+
| `npc_author_ids` / `npc_author_usernames` | NPC 评论作者白名单。至少配置一项,并核对仓库中实际的作者 ID 或 username |
|
|
27
|
+
| `assistant_name` | 在聊天中显示的分析助手名称 |
|
|
28
|
+
| `log_location_hint` | 开始报障时附加的日志位置说明 |
|
|
29
|
+
| `log_wait_minutes` | 等待日志上传时间,默认 10 分钟 |
|
|
30
|
+
| `analysis_wait_minutes` | 等待 NPC 分析时间,默认 20 分钟 |
|
|
31
|
+
| `recovery_confirm_minutes` | 等待用户确认问题是否解决,默认 30 分钟 |
|
|
32
|
+
| `max_log_file_mib` | 单个日志文件大小上限,默认 20 MiB |
|
|
33
|
+
| `poll_interval_seconds` | NPC 评论轮询间隔,默认 10 秒 |
|
|
34
|
+
| `issue_check_interval_seconds` | 用户确认阶段的 Issue 检查间隔,默认 30 秒 |
|
|
35
|
+
| `cnb_api_endpoint` / `cnb_web_endpoint` | CNB API 和网页地址,必须使用 HTTPS |
|
|
36
|
+
| `file_url_host_allowlist` | 可选的日志下载域名白名单;即使留空,也会拒绝内网和保留地址 |
|
|
37
|
+
|
|
38
|
+
其余配置用于控制结果发送重试次数、发送超时和历史保留天数。
|
|
39
|
+
|
|
40
|
+
## 指令
|
|
41
|
+
|
|
42
|
+
| 指令 | 作用 |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `debug [描述]` | 开始报障,描述作为 Issue 标题 |
|
|
45
|
+
| `debug status` | 查看当前报障进度 |
|
|
46
|
+
| `debug analyze` | 补充信息后请求 NPC 重新分析 |
|
|
47
|
+
| `debug resolve` | 确认问题已解决并关闭 Issue |
|
|
48
|
+
| `debug cancel` | 取消当前报障;已创建的 Issue 保留 |
|
|
49
|
+
| `debug help` | 查看指令说明 |
|
|
50
|
+
|
|
51
|
+
Issue 创建后,在群里 @机器人并附上文字,或在私信中直接发送文字,可以把补充说明写入 Issue 评论。每个用户在同一个群或私信会话中同时只能有一个未结束的报障。
|
|
52
|
+
|
|
53
|
+
## 隐私与文件处理
|
|
54
|
+
|
|
55
|
+
上传的日志原样提交给 CNB。插件不解压、不读取、不扫描,也不脱敏日志;故障描述作为 Issue 标题,用户主动发送的补充文字会作为评论提交。开始报障时会提醒用户确认文件不含隐私内容。
|
|
56
|
+
|
|
57
|
+
日志下载仅接受 `.zip` 和 `.log`,受大小上限约束。HTTP(S) 下载会检查每次重定向、域名白名单和解析到的 IP 地址,拒绝本机、内网及保留地址。临时文件存放在 Koishi 项目目录下的 `data/koishi-plugin-cnb-bot/`,成功上传或报障结束后会删除。
|
|
58
|
+
|
|
59
|
+
报障状态存储在 Koishi 配置的数据库中,其中包括平台、机器人、群/频道、用户、Issue 编号和处理状态。插件不读取群聊历史;只有发起报障后用户主动提交的描述、日志附件和补充文字会进入 CNB。插件重启后会恢复未完成的流程。CNB Issue 创建请求若因连接中断而无法确认,插件会暂停并提示管理员按追踪编号核对,避免重复创建。
|
|
60
|
+
|
|
61
|
+
## 开发
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npm install
|
|
65
|
+
npm run build
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## GitHub Actions 打包和发布
|
|
69
|
+
|
|
70
|
+
提交分支或创建 PR 会触发 `ci.yml`:通过 `npm ci` 安装锁定依赖并构建,检查插件入口、配置 Schema 和发布文件,再把 `.tgz` 安装包保存到 Actions 的 `npm-package` artifact。
|
|
71
|
+
|
|
72
|
+
推送 `v版本号` 标签会触发 `publish.yml`,自动构建、检查、打包并发布到 npm。标签必须与 `package.json` 的版本一致,例如版本 `0.1.1` 对应标签 `v0.1.1`。正式版本使用 `latest`,包含预发布标识的版本使用 `next`。
|
|
73
|
+
|
|
74
|
+
### 首次配置
|
|
75
|
+
|
|
76
|
+
本工作流使用 [npm Trusted Publisher](https://docs.npmjs.com/trusted-publishers/),通过 GitHub OIDC 认证,无需配置 npm Token Secret。
|
|
77
|
+
|
|
78
|
+
如果 npm 上还没有 `koishi-plugin-cnb-bot`,先在本机登录 npm 并发布第一个版本:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm login --registry=https://registry.npmjs.org
|
|
82
|
+
npm publish --access public --registry=https://registry.npmjs.org
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
然后打开 npm 包的 Settings,在 Trusted Publisher 中选择 GitHub Actions,填写:
|
|
86
|
+
|
|
87
|
+
| 字段 | 值 |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| Organization or user | `HarcoChen` |
|
|
90
|
+
| Repository | `cnb-bot-koishi` |
|
|
91
|
+
| Workflow filename | `publish.yml`,只填写文件名 |
|
|
92
|
+
| Environment name | 留空 |
|
|
93
|
+
| Allowed actions(如显示) | 允许直接执行 `npm publish` |
|
|
94
|
+
|
|
95
|
+
保存后,把工作流和 `package-lock.json` 提交到 GitHub。首次发布已占用的版本号不能重复发布。
|
|
96
|
+
|
|
97
|
+
### 发布后续版本
|
|
98
|
+
|
|
99
|
+
确认当前工作区的代码已经提交,再执行:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
npm version patch
|
|
103
|
+
git push origin main
|
|
104
|
+
git push origin v0.1.1
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`npm version patch` 会同时更新 `package.json` 和锁文件、创建版本提交及 `v版本号` 标签。上面的 `v0.1.1` 是从 `0.1.0` 升级的示例,请替换为命令实际生成的标签;推送后,在 GitHub 的 Actions 页面查看发布结果。发布成功后可以从 npm 安装,也可以下载 Actions 保存的 `.tgz` 文件。
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare class CNBAPIError extends Error {
|
|
2
|
+
readonly status?: number | undefined;
|
|
3
|
+
constructor(message: string, status?: number | undefined);
|
|
4
|
+
}
|
|
5
|
+
export declare class CNBNetworkError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface Asset {
|
|
9
|
+
asset_link: string;
|
|
10
|
+
download_url?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class CNBClient {
|
|
14
|
+
private token;
|
|
15
|
+
readonly api: string;
|
|
16
|
+
readonly web: string;
|
|
17
|
+
readonly repository: string;
|
|
18
|
+
constructor(apiEndpoint: string, webEndpoint: string, repository: string, token: string);
|
|
19
|
+
private repoPath;
|
|
20
|
+
private json;
|
|
21
|
+
issueUrl(number: string): string;
|
|
22
|
+
uploadAttachment(filePath: string, filename: string, size: number): Promise<Asset>;
|
|
23
|
+
createIssue(title: string, body: string): Promise<any>;
|
|
24
|
+
getIssue(number: string): Promise<any>;
|
|
25
|
+
closeIssue(number: string): Promise<any>;
|
|
26
|
+
createComment(number: string, body: string): Promise<any>;
|
|
27
|
+
listComments(number: string): Promise<any[]>;
|
|
28
|
+
}
|
|
29
|
+
export declare function stageFile(sourceUrl: string, filename: string, destination: string, maxBytes: number, allowlist: ReadonlySet<string>): Promise<{
|
|
30
|
+
name: string;
|
|
31
|
+
size: number;
|
|
32
|
+
suffix: string;
|
|
33
|
+
}>;
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CNBClient = exports.CNBNetworkError = exports.CNBAPIError = void 0;
|
|
4
|
+
exports.stageFile = stageFile;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const promises_1 = require("node:dns/promises");
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
|
+
const node_https_1 = require("node:https");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const node_stream_1 = require("node:stream");
|
|
11
|
+
const promises_2 = require("node:stream/promises");
|
|
12
|
+
const node_net_1 = require("node:net");
|
|
13
|
+
const node_url_1 = require("node:url");
|
|
14
|
+
class CNBAPIError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
constructor(message, status) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.name = 'CNBAPIError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.CNBAPIError = CNBAPIError;
|
|
23
|
+
class CNBNetworkError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'CNBNetworkError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.CNBNetworkError = CNBNetworkError;
|
|
30
|
+
class CNBClient {
|
|
31
|
+
token;
|
|
32
|
+
api;
|
|
33
|
+
web;
|
|
34
|
+
repository;
|
|
35
|
+
constructor(apiEndpoint, webEndpoint, repository, token) {
|
|
36
|
+
this.token = token;
|
|
37
|
+
this.api = endpoint(apiEndpoint, 'CNB API');
|
|
38
|
+
this.web = endpoint(webEndpoint, 'CNB 网页');
|
|
39
|
+
this.repository = repository.trim().replace(/^\/+|\/+$/g, '');
|
|
40
|
+
if (!this.repository || this.repository.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
41
|
+
throw new TypeError('CNB 仓库路径格式无效。');
|
|
42
|
+
}
|
|
43
|
+
if (!token.trim())
|
|
44
|
+
throw new TypeError('请先在插件配置中填写 CNB 访问令牌。');
|
|
45
|
+
this.token = token.trim();
|
|
46
|
+
}
|
|
47
|
+
repoPath() {
|
|
48
|
+
return this.repository.split('/').map(encodeURIComponent).join('/');
|
|
49
|
+
}
|
|
50
|
+
async json(method, path, body, query) {
|
|
51
|
+
const url = new URL(`${this.api}/${path.replace(/^\//, '')}`);
|
|
52
|
+
if (query)
|
|
53
|
+
for (const [key, value] of Object.entries(query))
|
|
54
|
+
url.searchParams.set(key, String(value));
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = await fetch(url, {
|
|
58
|
+
method,
|
|
59
|
+
headers: {
|
|
60
|
+
Accept: 'application/json',
|
|
61
|
+
Authorization: `Bearer ${this.token}`,
|
|
62
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
63
|
+
'User-Agent': 'Koishi-CNB-Report/0.1',
|
|
64
|
+
},
|
|
65
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
66
|
+
signal: AbortSignal.timeout(30_000),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new CNBNetworkError(`CNB API 网络请求结果不确定:${errorMessage(error)}`);
|
|
71
|
+
}
|
|
72
|
+
let raw = '';
|
|
73
|
+
if (response.body) {
|
|
74
|
+
const reader = response.body.getReader();
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let total = 0;
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
const { done, value } = await reader.read();
|
|
80
|
+
if (done)
|
|
81
|
+
break;
|
|
82
|
+
total += value.byteLength;
|
|
83
|
+
if (total > 4 * 1024 * 1024) {
|
|
84
|
+
await reader.cancel();
|
|
85
|
+
throw new CNBAPIError('CNB API 响应超过 4 MiB 限制。', response.status);
|
|
86
|
+
}
|
|
87
|
+
chunks.push(value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
reader.releaseLock();
|
|
92
|
+
}
|
|
93
|
+
raw = Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8');
|
|
94
|
+
}
|
|
95
|
+
let data = {};
|
|
96
|
+
if (raw) {
|
|
97
|
+
try {
|
|
98
|
+
data = JSON.parse(raw);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new CNBAPIError('CNB API 返回了无法解析的 JSON。', response.status);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const reason = data?.errmsg || data?.message || response.statusText;
|
|
106
|
+
throw new CNBAPIError(`CNB API 请求失败(HTTP ${response.status}):${reason}`, response.status);
|
|
107
|
+
}
|
|
108
|
+
return data;
|
|
109
|
+
}
|
|
110
|
+
issueUrl(number) {
|
|
111
|
+
return `${this.web}/${this.repoPath()}/-/issues/${encodeURIComponent(number)}`;
|
|
112
|
+
}
|
|
113
|
+
async uploadAttachment(filePath, filename, size) {
|
|
114
|
+
const name = filename.split(/[\\/]/).pop() || 'log.zip';
|
|
115
|
+
const contentType = (0, node_path_1.extname)(name).toLowerCase() === '.log' ? 'text/plain' : 'application/zip';
|
|
116
|
+
const created = await this.json('POST', `/${this.repoPath()}/-/issues/asset-groups`, {
|
|
117
|
+
file_assets: [{ name, size, content_type: contentType }],
|
|
118
|
+
});
|
|
119
|
+
const asset = (created?.file_upload_urls || created?.assets || [])[0];
|
|
120
|
+
if (!asset?.upload_url || !asset?.asset_link) {
|
|
121
|
+
throw new CNBAPIError('CNB 创建附件组时没有返回文件上传地址。');
|
|
122
|
+
}
|
|
123
|
+
const uploadUrl = new URL(asset.upload_url);
|
|
124
|
+
if (uploadUrl.protocol !== 'https:' || uploadUrl.username || uploadUrl.password) {
|
|
125
|
+
throw new CNBAPIError('CNB 返回的附件上传 URL 不是有效 HTTPS 地址。');
|
|
126
|
+
}
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
const stream = (0, node_fs_1.createReadStream)(filePath);
|
|
130
|
+
response = await fetch(uploadUrl, {
|
|
131
|
+
method: 'PUT',
|
|
132
|
+
headers: { 'Content-Type': contentType, 'Content-Length': String(size) },
|
|
133
|
+
body: stream,
|
|
134
|
+
duplex: 'half',
|
|
135
|
+
signal: AbortSignal.timeout(120_000),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new CNBNetworkError('CNB 附件上传结果不确定,请检查 CNB 外部状态。');
|
|
140
|
+
}
|
|
141
|
+
if (!response.ok)
|
|
142
|
+
throw new CNBAPIError(`CNB 附件上传失败(HTTP ${response.status})。`, response.status);
|
|
143
|
+
return { asset_link: String(asset.asset_link), download_url: String(asset.download_url || ''), name };
|
|
144
|
+
}
|
|
145
|
+
async createIssue(title, body) {
|
|
146
|
+
const result = await this.json('POST', `/${this.repoPath()}/-/issues`, { title, body });
|
|
147
|
+
if (!result || !result.number)
|
|
148
|
+
throw new CNBAPIError('CNB 创建 Issue 响应缺少 Issue 编号。');
|
|
149
|
+
return { ...result, html_url: result.html_url || this.issueUrl(String(result.number)) };
|
|
150
|
+
}
|
|
151
|
+
getIssue(number) {
|
|
152
|
+
return this.json('GET', `/${this.repoPath()}/-/issues/${encodeURIComponent(number)}`);
|
|
153
|
+
}
|
|
154
|
+
closeIssue(number) {
|
|
155
|
+
return this.json('PATCH', `/${this.repoPath()}/-/issues/${encodeURIComponent(number)}`, {
|
|
156
|
+
state: 'closed', state_reason: 'completed',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
createComment(number, body) {
|
|
160
|
+
return this.json('POST', `/${this.repoPath()}/-/issues/${encodeURIComponent(number)}/comments`, { body });
|
|
161
|
+
}
|
|
162
|
+
async listComments(number) {
|
|
163
|
+
const comments = [];
|
|
164
|
+
for (let page = 1; page <= 20; page++) {
|
|
165
|
+
const result = await this.json('GET', `/${this.repoPath()}/-/issues/${encodeURIComponent(number)}/comments`, undefined, {
|
|
166
|
+
page, page_size: 100,
|
|
167
|
+
});
|
|
168
|
+
const batch = Array.isArray(result) ? result : result?.data;
|
|
169
|
+
if (!Array.isArray(batch))
|
|
170
|
+
throw new CNBAPIError('CNB 评论列表响应格式异常。');
|
|
171
|
+
comments.push(...batch);
|
|
172
|
+
if (batch.length < 100)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return comments;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
exports.CNBClient = CNBClient;
|
|
179
|
+
function endpoint(value, label) {
|
|
180
|
+
let url;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(value);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
throw new TypeError(`${label}地址无效,必须使用 HTTPS。`);
|
|
186
|
+
}
|
|
187
|
+
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search || url.hash) {
|
|
188
|
+
throw new TypeError(`${label}地址无效,必须使用 HTTPS。`);
|
|
189
|
+
}
|
|
190
|
+
return url.href.replace(/\/$/, '');
|
|
191
|
+
}
|
|
192
|
+
async function stageFile(sourceUrl, filename, destination, maxBytes, allowlist) {
|
|
193
|
+
const name = filename.replace(/\\/g, '/').split('/').pop() || 'log.zip';
|
|
194
|
+
const suffix = (0, node_path_1.extname)(name).toLowerCase();
|
|
195
|
+
if (suffix !== '.zip' && suffix !== '.log')
|
|
196
|
+
throw new Error('只接受 .zip 或 .log 日志文件,请重新上传。');
|
|
197
|
+
const size = sourceUrl.startsWith('file:')
|
|
198
|
+
? await stageLocalFile(sourceUrl, destination, maxBytes)
|
|
199
|
+
: await downloadSafe(sourceUrl, destination, maxBytes, allowlist);
|
|
200
|
+
if (!size) {
|
|
201
|
+
await node_fs_1.promises.rm(destination, { force: true }).catch(() => { });
|
|
202
|
+
throw new Error('上传的日志文件为空。');
|
|
203
|
+
}
|
|
204
|
+
return { name, size, suffix };
|
|
205
|
+
}
|
|
206
|
+
async function stageLocalFile(sourceUrl, destination, maxBytes) {
|
|
207
|
+
const url = new URL(sourceUrl);
|
|
208
|
+
if (url.host && url.host !== 'localhost')
|
|
209
|
+
throw new Error('附件本地路径无效。');
|
|
210
|
+
let path;
|
|
211
|
+
try {
|
|
212
|
+
path = (0, node_url_1.fileURLToPath)(url);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new Error('附件本地路径无效。');
|
|
216
|
+
}
|
|
217
|
+
const info = await node_fs_1.promises.lstat(path).catch(() => undefined);
|
|
218
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
219
|
+
throw new Error('无法读取这个日志文件,请重新上传。');
|
|
220
|
+
if (info.size > maxBytes)
|
|
221
|
+
throw sizeError(maxBytes);
|
|
222
|
+
try {
|
|
223
|
+
const size = await copyLimited((0, node_fs_1.createReadStream)(path), destination, maxBytes);
|
|
224
|
+
return size;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
await node_fs_1.promises.rm(destination, { force: true }).catch(() => { });
|
|
228
|
+
throw new Error('读取日志文件失败,请重新上传。');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function downloadSafe(initialUrl, destination, maxBytes, allowlist) {
|
|
232
|
+
let current = initialUrl;
|
|
233
|
+
const endAt = Date.now() + 120_000;
|
|
234
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
235
|
+
const url = new URL(current);
|
|
236
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) {
|
|
237
|
+
throw new Error('附件下载链接无效。');
|
|
238
|
+
}
|
|
239
|
+
const address = await validateHost(url.hostname, allowlist);
|
|
240
|
+
const remaining = endAt - Date.now();
|
|
241
|
+
if (remaining <= 0)
|
|
242
|
+
throw new Error('下载日志文件超时,请重新上传。');
|
|
243
|
+
const response = await requestPinned(url, address, remaining);
|
|
244
|
+
const status = response.statusCode || 0;
|
|
245
|
+
if (status >= 300 && status < 400) {
|
|
246
|
+
const rawLocation = response.headers.location;
|
|
247
|
+
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
248
|
+
response.destroy();
|
|
249
|
+
if (!location || redirects === 5)
|
|
250
|
+
throw new Error('附件下载重定向无效或次数过多。');
|
|
251
|
+
current = new URL(location, url).href;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (status < 200 || status >= 300) {
|
|
255
|
+
response.destroy();
|
|
256
|
+
throw new Error('下载日志文件失败,文件链接可能已过期,请重新上传。');
|
|
257
|
+
}
|
|
258
|
+
const length = Number(response.headers['content-length'] || 0);
|
|
259
|
+
if (length > maxBytes) {
|
|
260
|
+
response.destroy();
|
|
261
|
+
throw sizeError(maxBytes);
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return await copyLimited(response, destination, maxBytes);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
await node_fs_1.promises.rm(destination, { force: true }).catch(() => { });
|
|
268
|
+
if (Date.now() >= endAt)
|
|
269
|
+
throw new Error('下载日志文件超时,请重新上传。');
|
|
270
|
+
if (error instanceof Error && error.message.includes('大小上限'))
|
|
271
|
+
throw error;
|
|
272
|
+
throw new Error('下载日志文件失败,文件链接可能已过期,请重新上传。');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw new Error('附件下载重定向次数过多。');
|
|
276
|
+
}
|
|
277
|
+
async function copyLimited(source, destination, maxBytes) {
|
|
278
|
+
let total = 0;
|
|
279
|
+
const limiter = new node_stream_1.Transform({
|
|
280
|
+
transform(chunk, _encoding, callback) {
|
|
281
|
+
total += chunk.length;
|
|
282
|
+
if (total > maxBytes)
|
|
283
|
+
callback(sizeError(maxBytes));
|
|
284
|
+
else
|
|
285
|
+
callback(null, chunk);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
await (0, promises_2.pipeline)(source, limiter, (0, node_fs_1.createWriteStream)(destination, { flags: 'wx', mode: 0o600 }));
|
|
289
|
+
return total;
|
|
290
|
+
}
|
|
291
|
+
async function validateHost(hostname, allowlist) {
|
|
292
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
293
|
+
if (host === 'localhost' || host.endsWith('.local'))
|
|
294
|
+
throw new Error('拒绝访问本机或本地域名附件地址。');
|
|
295
|
+
const entries = [...allowlist].map(item => item.trim().toLowerCase().replace(/^\.|\.$/g, '')).filter(Boolean);
|
|
296
|
+
if (entries.length && !entries.some(item => host === item || host.endsWith(`.${item}`))) {
|
|
297
|
+
throw new Error('附件下载域名不在配置的白名单中。');
|
|
298
|
+
}
|
|
299
|
+
let addresses;
|
|
300
|
+
try {
|
|
301
|
+
const family = (0, node_net_1.isIP)(host);
|
|
302
|
+
addresses = family ? [{ address: host, family }] : await (0, promises_1.lookup)(host, { all: true, verbatim: true });
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
throw new Error('无法解析附件下载域名。');
|
|
306
|
+
}
|
|
307
|
+
if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) {
|
|
308
|
+
throw new Error('拒绝访问内网或保留地址附件链接。');
|
|
309
|
+
}
|
|
310
|
+
return addresses[0];
|
|
311
|
+
}
|
|
312
|
+
function requestPinned(url, address, timeout) {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const options = {
|
|
315
|
+
method: 'GET',
|
|
316
|
+
agent: false,
|
|
317
|
+
headers: { Host: url.host },
|
|
318
|
+
signal: AbortSignal.timeout(timeout),
|
|
319
|
+
// Pin the socket's DNS result to the public IP that was validated above.
|
|
320
|
+
// The URL hostname remains unchanged for Host and TLS certificate checks.
|
|
321
|
+
lookup: (_hostname, lookupOptions, callback) => {
|
|
322
|
+
if (lookupOptions?.all)
|
|
323
|
+
callback(null, [address]);
|
|
324
|
+
else
|
|
325
|
+
callback(null, address.address, address.family);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const tlsHost = url.hostname.replace(/^\[|\]$/g, '');
|
|
329
|
+
if (url.protocol === 'https:' && !(0, node_net_1.isIP)(tlsHost))
|
|
330
|
+
options.servername = tlsHost;
|
|
331
|
+
const onResponse = (response) => resolve(response);
|
|
332
|
+
const request = url.protocol === 'https:'
|
|
333
|
+
? (0, node_https_1.request)(url, options, onResponse)
|
|
334
|
+
: (0, node_http_1.request)(url, options, onResponse);
|
|
335
|
+
request.once('error', reject);
|
|
336
|
+
request.end();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
function isPublicAddress(address) {
|
|
340
|
+
const ip = address.toLowerCase().split('%')[0];
|
|
341
|
+
if (ip.includes(':')) {
|
|
342
|
+
if (ip === '::' || ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe8') || ip.startsWith('fe9') || ip.startsWith('fea') || ip.startsWith('feb') || ip.startsWith('ff'))
|
|
343
|
+
return false;
|
|
344
|
+
if (ip.startsWith('::ffff:'))
|
|
345
|
+
return isPublicAddress(ip.slice(7));
|
|
346
|
+
if (ip.startsWith('2001:db8:'))
|
|
347
|
+
return false;
|
|
348
|
+
return /^[23]/.test(ip);
|
|
349
|
+
}
|
|
350
|
+
const n = ip.split('.').map(Number);
|
|
351
|
+
if (n.length !== 4 || n.some(x => !Number.isInteger(x) || x < 0 || x > 255))
|
|
352
|
+
return false;
|
|
353
|
+
const [a, b, c] = n;
|
|
354
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224)
|
|
355
|
+
return false;
|
|
356
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
357
|
+
return false;
|
|
358
|
+
if (a === 169 && b === 254)
|
|
359
|
+
return false;
|
|
360
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
361
|
+
return false;
|
|
362
|
+
if (a === 192 && (b === 168 || (b === 0 && c === 0) || (b === 0 && c === 2)))
|
|
363
|
+
return false;
|
|
364
|
+
if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
|
|
365
|
+
return false;
|
|
366
|
+
if (a === 203 && b === 0 && c === 113)
|
|
367
|
+
return false;
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
function sizeError(maxBytes) {
|
|
371
|
+
return new Error(`日志文件超过大小上限(${formatBytes(maxBytes)}),请压缩或只保留相关日志后重新上传。`);
|
|
372
|
+
}
|
|
373
|
+
function formatBytes(size) {
|
|
374
|
+
if (size >= 1024 * 1024)
|
|
375
|
+
return `${(size / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MiB`;
|
|
376
|
+
if (size >= 1024)
|
|
377
|
+
return `${(size / 1024).toFixed(1).replace(/\.0$/, '')} KiB`;
|
|
378
|
+
return `${size} B`;
|
|
379
|
+
}
|
|
380
|
+
function errorMessage(error) {
|
|
381
|
+
return error instanceof Error ? error.message : String(error);
|
|
382
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import type { Config as PluginConfig } from './types';
|
|
3
|
+
export declare const name = "cnb-bot";
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
export type Config = PluginConfig;
|
|
6
|
+
export declare const Config: Schema<Config>;
|
|
7
|
+
export declare function apply(ctx: Context, input: PluginConfig): void;
|