koishi-plugin-telegraph-image-manager 0.1.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2 - 2026-09-08
4
+
5
+ - 补充 Koishi 插件市场及 npm 包名安装、升级说明。
6
+ - 固定 npm 官方发布仓库和公开访问设置。
7
+ - 说明 GitHub 安装包的 npm 发布与市场自动收录流程。
8
+ - 沿用 0.1.1 的功能、配置和列表图片预览行为。
9
+
10
+ ## 0.1.1 - 2026-09-08
11
+
12
+ 首次 GitHub 公开发布。
13
+
14
+ - 列表、下一页和上一页默认附带图片预览,缺失原文件名时仍能根据预览识别图片。
15
+ - 新增 `listThumbnails` 配置,支持恢复纯文字列表;单张查看不受影响。
16
+ - 黑名单记录和非图片文件保留序号与文字,不自动预览。
17
+ - 补充分页、空页、群聊、私聊和预览开关测试。
18
+
19
+ ## 0.1.0 - 2026-09-08
20
+
21
+ 初始本地交付。
22
+
23
+ - 支持 Koishi 4.18.11 与 OneBot v11,通过同条消息或引用消息上传图片。
24
+ - 支持图库分页、图片查看、直链与 Markdown、重命名、收藏、黑白名单管理。
25
+ - 管理权限支持 Koishi 权限等级及用户标识白名单。
26
+ - 删除需要与用户及会话绑定的限时确认码,仅删除图床管理记录和短链接映射。
27
+ - 提供上传队列、网络超时、图片来源与内容校验,隔离管理和上传凭据。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Telegraph-Image Manager contributors
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,159 @@
1
+ # koishi-plugin-telegraph-image-manager
2
+
3
+ 在 Koishi 聊天指令中上传、浏览和管理现有 Telegraph-Image 图床。图片仍通过图床自身的上传接口存储,管理结果与网页后台共享。
4
+
5
+ 适配 Koishi 4.18.11;使用官方 OneBot v11 适配器接收 QQ 图片。支持同条消息附图、引用图片消息、图片预览、直链与 Markdown、分页、重命名、收藏、黑名单、白名单和删除确认。
6
+
7
+ ## 安装
8
+
9
+ 环境要求:Koishi 4.18.11 或兼容的 Koishi 4、Node.js 18.20 以上、已启用的 `http` 服务,以及正常工作的 OneBot v11 适配器。建议沿用现有 Koishi 的受支持 Node.js 版本。
10
+
11
+ ### 插件市场
12
+
13
+ 在 Koishi 控制台的「插件市场」中搜索 `telegraph-image-manager` 并安装。插件名为 `telegraph-image-manager`,npm 包名为 [`koishi-plugin-telegraph-image-manager`](https://www.npmjs.com/package/koishi-plugin-telegraph-image-manager)。新版本发布后,市场索引和所用 npm 镜像可能需要一段时间同步。
14
+
15
+ 也可以在 Koishi 项目目录中使用项目原有的包管理器安装:
16
+
17
+ ```sh
18
+ yarn add koishi-plugin-telegraph-image-manager
19
+ ```
20
+
21
+ 使用 npm 的项目执行 `npm install koishi-plugin-telegraph-image-manager`。不要混用两种包管理器。
22
+
23
+ ### 安装包
24
+
25
+ 也可从 [GitHub Releases](https://github.com/Lance0174/koishi-plugin-telegraph-image-manager/releases/latest) 下载 `.tgz` 安装包。每个版本同时提供源码压缩包和 SHA256 校验文件,变更记录见 [CHANGELOG.md](CHANGELOG.md)。
26
+
27
+ 将安装包放入服务器上的 Koishi 项目目录,即包含 `package.json`、`koishi.yml` 和现有依赖锁文件的目录。使用该项目原有的包管理器安装:
28
+
29
+ ```sh
30
+ yarn add ./koishi-plugin-telegraph-image-manager-0.1.2.tgz
31
+ ```
32
+
33
+ 使用 npm 的项目执行下面这一条,不要混用两种包管理器:
34
+
35
+ ```sh
36
+ npm install ./koishi-plugin-telegraph-image-manager-0.1.2.tgz
37
+ ```
38
+
39
+ Docker 部署需要把安装包放入挂载的 Koishi 项目目录,并在运行 Koishi 的容器内、该目录下执行安装。保留安装包和依赖变更所在的数据卷,确保重建容器后仍能安装依赖。
40
+
41
+ 安装后重启 Koishi,在控制台的「插件配置」中添加 `telegraph-image-manager` 并填写下列配置。
42
+
43
+ 从 0.1.0 或 0.1.1 的本地安装包升级时,在同一个 Koishi 项目内用上述命令改为按包名安装,或安装新版 `.tgz`,然后重启 Koishi。沿用原有插件配置即可,列表图片预览默认开启。
44
+
45
+ ## 连接与授权
46
+
47
+ | 配置项 | 填写内容 |
48
+ | --- | --- |
49
+ | `baseUrl` | 自己部署的 Telegraph-Image 地址,例如 `https://your-site.pages.dev` |
50
+ | `adminUser` | Cloudflare 中当前的 `BASIC_USER`,默认填 `admin` |
51
+ | `adminPassword` | 当前的 `BASIC_PASS`,控制台显示为密码字段 |
52
+ | `adminUsers` | 添加你自己的 `onebot:QQ号`,例如 `onebot:123456789` |
53
+ | `uploadUser` / `uploadPassword` | 公开上传时两项留空;启用上传认证时填写对应的专用凭据 |
54
+
55
+ 插件不使用 Telegram Bot Token,也不需要新建机器人或修改现有 OneBot 连接。后台密码不会被写进聊天回复、下载请求或公开上传请求。
56
+
57
+ 填写后启用插件。在 QQ 中发送 `图床 身份` 可以核对自己的管理员标识,再发送 `图床 状态` 检查公开配置、发送 `图床 列表` 验证后台认证。
58
+
59
+ 默认允许 Koishi 权限等级至少为 3 的用户,以及 `adminUsers` 中精确匹配的用户浏览和管理图库。两者满足其一即可;QQ 群管理员身份本身不等于 Koishi 3 级权限。没有数据库时,可直接使用 `adminUsers` 授权。
60
+
61
+ 默认普通用户可以上传。`uploadAuthority` 默认为 1;未使用数据库时,普通用户按 1 级处理。图床管理权限持有者始终可以上传。若只允许管理员上传,将 `uploadAuthority` 调为 5,并用 `adminUsers` 指定管理员。
62
+
63
+ 图库权限涵盖整个图床。群内查询的结果会显示在群中,管理操作建议在私聊中执行。列表序号与分页状态按机器人、用户和会话隔离,默认 10 分钟后失效,重启也会清空。
64
+
65
+ ## 指令
66
+
67
+ 下面的 `1` 表示当前列表里的序号,也可写 `#1`。管理指令还接受完整文件 ID 或当前图床的长链接。
68
+
69
+ | 指令 | 行为 |
70
+ | --- | --- |
71
+ | `图床` | 显示指令菜单 |
72
+ | `图床 身份` | 查看管理员标识和自身权限 |
73
+ | `图床 状态` | 检查存储、后台是否启用及上传认证要求 |
74
+ | `图床 上传` 加图片 | 上传同一条消息中的图片;也可引用已有图片消息后发送 |
75
+ | `图床 列表` | 获取带图片预览的第一页,刷新当前会话的序号 |
76
+ | `图床 下一页` / `图床 上一页` | 翻页并显示对应记录的图片预览 |
77
+ | `图床 查看 1` | 发送图片预览和直链 |
78
+ | `图床 链接 1` | 返回直链及 Markdown |
79
+ | `图床 重命名 1 新名字.png` | 修改后台显示名称,允许名称包含空格 |
80
+ | `图床 收藏 1` | 切换收藏状态 |
81
+ | `图床 拉黑 1` | 加入黑名单 |
82
+ | `图床 白名单 1` | 加入白名单,解除黑名单限制并信任该图片 |
83
+ | `图床 删除 1` | 生成 60 秒有效的删除确认码 |
84
+ | `图床 确认 ABC123` | 使用机器人刚返回的确认码执行删除 |
85
+ | `图床 取消` | 取消当前会话的删除申请 |
86
+
87
+ 如 Koishi 设置了指令前缀,需要带上原有前缀;群聊是否需要 @机器人由现有 Koishi 配置决定。可用 `command` 修改本插件的主指令名。
88
+
89
+ 列表按 Cloudflare KV 游标顺序返回,并非按上传时间排序;内部短链接记录可能造成空页,可以继续翻页。默认每页 5 条,可配置为 1 至 20 条。
90
+
91
+ 每条图片记录的序号和名称下方会附上预览图,即使没有原始文件名、名称仅为文件 ID,也能直接识别图片。QQ 客户端负责缩略显示,点击图片可查看大图。黑名单记录和非图片文件仅保留文字及序号。预览使用图床直链,OneBot 服务需要能够访问这些链接;未在图床创建额外的缩略图文件。
92
+
93
+ 插件配置中的 `listThumbnails` 默认为开启,关闭后列表恢复为纯文字,单张 `图床 查看 1` 不受影响。
94
+
95
+ 上传结果可以是图床短链接。管理接口需要完整文件 ID,因此短链接不能直接用于删除或重命名,需要先在列表中选择对应记录。
96
+
97
+ 删除确认绑定申请时选中的完整 ID,之后翻页不会改变删除目标。确认码只允许申请者在原会话使用一次。删除调用原项目的管理接口,移除 KV 管理记录及其短链接映射,**不会删除 Telegram 源文件**;原有长链接可能仍然可访问,访问还可能重新生成管理记录。不要将其视为源文件彻底删除。
98
+
99
+ Cloudflare KV 存在传播延迟,刚上传或修改的记录在其他地区不一定立即可见。
100
+
101
+ ## 网络与图片限制
102
+
103
+ - 默认每条指令最多上传 3 张图片,单张最大 20 MiB。允许 PNG、JPEG、GIF、WebP、BMP、AVIF;实际文件内容会参与格式校验。
104
+ - 下载来源默认允许 `*.qpic.cn`、`*.qq.com`、`*.qq.com.cn` 及图床域名。其他来源需要在 `imageHosts` 中添加可信域名;禁止本地文件、IP 地址、URL 内账号密码和非标准端口,重定向目标也要通过检查。
105
+ - OneBot 应提供图片的 HTTP(S) URL 或 base64 数据。仅提供缓存文件名、文件路径或合并转发外壳的消息无法直接上传。先从合并转发中取出图片,再附图或引用图片消息。
106
+ - 默认网络超时为 30 秒,包含读取响应;上传队列串行执行,上次上传结束后默认等待至少 3.5 秒再开始下一任务,最多排队 20 个任务。Telegram 存储建议保留该间隔。
107
+ - 使用 Koishi 的 `http` 服务及其代理配置。服务器需要能够访问 QQ 图片 CDN 和图床;OneBot 服务需要能够访问图床直链,才能发送预览。
108
+ - 本插件不会自动重试上传、收藏切换和其他写入请求。若发生超时,请先到网页后台检查结果,再决定是否重新上传或操作。
109
+
110
+ ## 修改后台密码
111
+
112
+ 在 Cloudflare Pages 项目的「设置」中修改生产环境的 `BASIC_PASS`,然后重新部署使其生效。用户名对应 `BASIC_USER`。同步修改 Koishi 插件的 `adminPassword` / `adminUser`,保存并重新启用插件。公开上传时保持 `uploadUser` / `uploadPassword` 为空。
113
+
114
+ ## 故障定位
115
+
116
+ | 现象 | 检查项 |
117
+ | --- | --- |
118
+ | 找不到插件 | 确认在实际 Koishi 项目中安装,并重启 Koishi;Docker 中确认目录已持久化 |
119
+ | 插件未启动 | 确认启用 `http` 服务,地址完整且账号密码已填写 |
120
+ | 没有管理权限 | 发送 `图床 身份`,把显示的完整标识加入 `adminUsers` |
121
+ | 认证失败 | 检查当前 `BASIC_USER` / `BASIC_PASS`;状态命令本身不验证后台密码 |
122
+ | 来源域名未被允许 | 检查 OneBot 提供的实际图片域名,按需加入 `imageHosts` |
123
+ | 有直链但图片预览失败 | 检查 OneBot 所在服务器能否访问图片直链,以及账号发送图片是否被限制 |
124
+ | 只有缓存文件名或引用取不到图片 | 检查 OneBot 的图片 URL 和 `get_msg` 支持;可尝试将图片与上传指令放在同条消息 |
125
+ | 列表序号失效 | 在当前会话重新发送 `图床 列表` |
126
+ | Dashboard 未启用 | 检查图床是否绑定 `img_url` KV,绑定后重新部署 |
127
+
128
+ ## 开发与验证
129
+
130
+ ```sh
131
+ git clone https://github.com/Lance0174/koishi-plugin-telegraph-image-manager.git
132
+ cd koishi-plugin-telegraph-image-manager
133
+ npm ci
134
+ npm test
135
+ npm pack
136
+ ```
137
+
138
+ 源码使用 TypeScript,安装包包含编译后的 CommonJS 和类型声明。`file-type` 通过动态导入加载,安装包无需在服务器上编译。
139
+
140
+ 测试使用 Koishi 4.18.11、官方 Mock 插件 2.6.6、官方 OneBot 适配器 6.9.4、本地 HTTP 图床测试服务和本地 WebSocket 协议端。覆盖权限、真实命令解析、图片内容上传、分页隔离、删除确认、认证隔离、超时、图片来源限制及 OneBot 双向消息转换。所有测试图片和管理操作只发生在本地测试服务中。
141
+
142
+ 接口基于 `cf-pages/Telegraph-Image` 的现有 `/upload`、`/api/config` 与 `/api/manage/*`。上游代码检查版本为 `ef69018df1967708ec026a671e3976f5524ff490`。
143
+
144
+ 本地协议测试不等于在你的服务器和实际 QQ 账号上验收;安装启用后,按“身份、状态、列表、上传一张图片、查看”的顺序完成实际连接检查。
145
+
146
+ ## 发布
147
+
148
+ 主分支和 Pull Request 会执行 CI。维护者更新 `package.json`、`package-lock.json`、变更记录和 `docs/releases/v版本号.md` 后,提交并推送与包版本一致的 `v版本号` 标签。
149
+
150
+ 标签工作流会验证版本、运行测试、构建安装包、归档标签对应的源码、计算 SHA256 并发布 GitHub Release。工作流只使用仓库的内置令牌,不需要图床或 Telegram 凭据。
151
+
152
+ npm 发布由维护者单独执行,GitHub 标签工作流不会自动发布到 npm。下载对应 GitHub Release 的 `.tgz` 并核对 `SHA256SUMS.txt`,在已登录 npm 的环境中先检查发布内容,再发布同一个安装包:
153
+
154
+ ```sh
155
+ npm publish ./koishi-plugin-telegraph-image-manager-0.1.2.tgz --dry-run --access public --registry https://registry.npmjs.org
156
+ npm publish ./koishi-plugin-telegraph-image-manager-0.1.2.tgz --access public --registry https://registry.npmjs.org
157
+ ```
158
+
159
+ `publishConfig` 固定使用 npm 官方仓库并公开发布。npm 账号认证仅放在本机 npm 配置或专用凭据管理中,不要提交到仓库。按 [Koishi 官方规范](https://koishi.chat/zh-CN/guide/develop/publish.html),满足命名和依赖要求的 npm 包会自动进入插件市场,通常在发布后 15 分钟内收录。
package/lib/api.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Context } from 'koishi';
2
+ import type { Config } from './config';
3
+ export interface ImageRecord {
4
+ id: string;
5
+ fileName: string;
6
+ fileSize: number;
7
+ timestamp: number;
8
+ liked: boolean;
9
+ listType: string;
10
+ }
11
+ export interface ImagePage {
12
+ items: ImageRecord[];
13
+ cursor?: string;
14
+ complete: boolean;
15
+ }
16
+ export interface SiteStatus {
17
+ ready: boolean;
18
+ storageProvider: string;
19
+ dashboard: string;
20
+ uploadRequiresAuth: boolean;
21
+ }
22
+ export declare function validId(value: string): boolean;
23
+ export declare class ImageApi {
24
+ private http;
25
+ private config;
26
+ private shutdown;
27
+ readonly base: URL;
28
+ constructor(http: Context['http'], config: Config, shutdown: AbortSignal);
29
+ private call;
30
+ status(): Promise<SiteStatus>;
31
+ list(cursor?: string): Promise<ImagePage>;
32
+ fileUrl(id: string): string;
33
+ parseId(reference: string): string;
34
+ upload(bytes: Uint8Array, mime: string, fileName: string): Promise<string>;
35
+ modify(action: 'editName' | 'toggleLike' | 'block' | 'white' | 'delete', id: string, newName?: string): Promise<unknown>;
36
+ }
package/lib/api.js ADDED
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ImageApi = void 0;
4
+ exports.validId = validId;
5
+ const http_1 = require("./http");
6
+ const idPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]{1,12}$/;
7
+ function validId(value) {
8
+ return value.length <= 512 && idPattern.test(value);
9
+ }
10
+ function object(value) {
11
+ return !!value && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+ function basic(user, password) {
14
+ return `Basic ${Buffer.from(`${user}:${password}`, 'utf8').toString('base64')}`;
15
+ }
16
+ class ImageApi {
17
+ http;
18
+ config;
19
+ shutdown;
20
+ base;
21
+ constructor(http, config, shutdown) {
22
+ this.http = http;
23
+ this.config = config;
24
+ this.shutdown = shutdown;
25
+ try {
26
+ this.base = new URL(config.baseUrl);
27
+ }
28
+ catch {
29
+ throw new http_1.UserError('图床地址必须是完整的 HTTP 或 HTTPS URL。');
30
+ }
31
+ if (!['https:', 'http:'].includes(this.base.protocol) || this.base.username || this.base.password || this.base.search || this.base.hash) {
32
+ throw new http_1.UserError('图床地址不能包含账号密码、查询参数或片段。');
33
+ }
34
+ this.base.pathname = `${this.base.pathname.replace(/\/+$/, '')}/`;
35
+ if (!config.adminUser || !config.adminPassword || config.adminUser.includes(':')) {
36
+ throw new http_1.UserError('请配置有效的后台账号和密码。');
37
+ }
38
+ if (!!config.uploadUser !== !!config.uploadPassword || config.uploadUser.includes(':')) {
39
+ throw new http_1.UserError('上传账号和密码必须同时填写或同时留空。');
40
+ }
41
+ }
42
+ async call(path, admin = false, method = 'GET', data, params) {
43
+ const url = new URL(path, this.base);
44
+ for (const [key, value] of Object.entries(params ?? {}))
45
+ url.searchParams.set(key, value);
46
+ const headers = { Accept: 'application/json' };
47
+ if (admin)
48
+ headers.Authorization = basic(this.config.adminUser, this.config.adminPassword);
49
+ else if (path === 'upload' && this.config.uploadUser)
50
+ headers.Authorization = basic(this.config.uploadUser, this.config.uploadPassword);
51
+ return (0, http_1.request)(this.http, url.href, this.config.timeout, this.shutdown, { method, data, headers }, async (response) => {
52
+ if (!response.ok) {
53
+ await response.body?.cancel();
54
+ (0, http_1.checkStatus)(response.status);
55
+ }
56
+ const text = new TextDecoder().decode(await (0, http_1.readLimited)(response, 2 * 1024 * 1024));
57
+ if (text.includes('Dashboard is disabled.'))
58
+ throw new http_1.UserError('图床后台未启用,请绑定 img_url KV 并重新部署。');
59
+ try {
60
+ return JSON.parse(text);
61
+ }
62
+ catch {
63
+ throw new http_1.UserError('图床返回格式不兼容,请检查地址是否指向 Telegraph-Image。');
64
+ }
65
+ });
66
+ }
67
+ async status() {
68
+ const data = await this.call('api/config');
69
+ if (!object(data) || typeof data.ready !== 'boolean' || !object(data.setup))
70
+ throw new http_1.UserError('图床配置接口版本不兼容。');
71
+ return {
72
+ ready: data.ready,
73
+ storageProvider: String(data.setup.storageProvider),
74
+ dashboard: String(data.setup.dashboard),
75
+ uploadRequiresAuth: !!data.uploadRequiresAuth,
76
+ };
77
+ }
78
+ async list(cursor) {
79
+ const params = { limit: String(this.config.pageSize) };
80
+ if (cursor)
81
+ params.cursor = cursor;
82
+ const data = await this.call('api/manage/list', true, 'GET', undefined, params);
83
+ if (!object(data) || !Array.isArray(data.keys) || typeof data.list_complete !== 'boolean')
84
+ throw new http_1.UserError('图库列表格式不兼容。');
85
+ if (!data.list_complete && (typeof data.cursor !== 'string' || !data.cursor || data.cursor === cursor))
86
+ throw new http_1.UserError('图库分页游标无效,请重新列出图库。');
87
+ const items = [];
88
+ for (const key of data.keys) {
89
+ if (!object(key) || typeof key.name !== 'string' || !validId(key.name))
90
+ continue;
91
+ const metadata = object(key.metadata) ? key.metadata : {};
92
+ items.push({
93
+ id: key.name,
94
+ fileName: typeof metadata.fileName === 'string' ? metadata.fileName.slice(0, 300) : key.name,
95
+ fileSize: typeof metadata.fileSize === 'number' ? metadata.fileSize : 0,
96
+ timestamp: typeof metadata.TimeStamp === 'number' ? metadata.TimeStamp : 0,
97
+ liked: metadata.liked === true,
98
+ listType: typeof metadata.ListType === 'string' ? metadata.ListType : 'None',
99
+ });
100
+ }
101
+ return { items, complete: data.list_complete, cursor: data.list_complete ? undefined : data.cursor };
102
+ }
103
+ fileUrl(id) {
104
+ if (!validId(id))
105
+ throw new http_1.UserError('图片 ID 无效;请使用列表序号或完整文件 ID。');
106
+ return new URL(`file/${encodeURIComponent(id)}`, this.base).href;
107
+ }
108
+ parseId(reference) {
109
+ let id = reference;
110
+ if (/^https?:/i.test(reference)) {
111
+ let url;
112
+ try {
113
+ url = new URL(reference);
114
+ }
115
+ catch {
116
+ throw new http_1.UserError('图片链接无效。');
117
+ }
118
+ const prefix = `${this.base.pathname}file/`;
119
+ if (url.origin !== this.base.origin || url.username || url.password || !url.pathname.startsWith(prefix))
120
+ throw new http_1.UserError('请使用当前图床的图片链接。');
121
+ try {
122
+ id = decodeURIComponent(url.pathname.slice(prefix.length));
123
+ }
124
+ catch {
125
+ throw new http_1.UserError('图片链接无效。');
126
+ }
127
+ }
128
+ if (!validId(id))
129
+ throw new http_1.UserError('请使用列表序号、完整文件 ID 或长链接;短链接请先在列表中选择对应记录。');
130
+ return id;
131
+ }
132
+ async upload(bytes, mime, fileName) {
133
+ const form = new FormData();
134
+ form.append('file', new Blob([Buffer.from(bytes)], { type: mime }), fileName);
135
+ const data = await this.call('upload', false, 'POST', form);
136
+ if (!Array.isArray(data) || !object(data[0]) || typeof data[0].src !== 'string')
137
+ throw new http_1.UserError('上传响应缺少图片链接,请在后台确认上传结果。');
138
+ const src = data[0].src;
139
+ if (!/^\/file\/[A-Za-z0-9_.-]+$/.test(src) || src.includes('..'))
140
+ throw new http_1.UserError('上传响应包含无效链接。');
141
+ return new URL(src.slice(1), this.base).href;
142
+ }
143
+ async modify(action, id, newName) {
144
+ if (!validId(id))
145
+ throw new http_1.UserError('图片 ID 无效。');
146
+ const result = await this.call(`api/manage/${action}/${encodeURIComponent(id)}`, true, 'POST', undefined, newName === undefined ? undefined : { newName });
147
+ if (action === 'delete' && result !== id)
148
+ throw new http_1.UserError('删除响应异常,请重新列出图库确认状态。');
149
+ if (action === 'toggleLike' && (!object(result) || typeof result.liked !== 'boolean'))
150
+ throw new http_1.UserError('收藏响应异常,请重新列出图库。');
151
+ if (action === 'editName' && (!object(result) || result.success !== true))
152
+ throw new http_1.UserError('重命名响应异常,请重新列出图库。');
153
+ if (['block', 'white'].includes(action) && !object(result))
154
+ throw new http_1.UserError('图片记录不存在。');
155
+ return result;
156
+ }
157
+ }
158
+ exports.ImageApi = ImageApi;
@@ -0,0 +1,21 @@
1
+ import { Schema } from 'koishi';
2
+ export interface Config {
3
+ baseUrl: string;
4
+ adminUser: string;
5
+ adminPassword: string;
6
+ uploadUser: string;
7
+ uploadPassword: string;
8
+ command: string;
9
+ adminUsers: string[];
10
+ managementAuthority: number;
11
+ uploadAuthority: number;
12
+ pageSize: number;
13
+ listThumbnails: boolean;
14
+ sessionMinutes: number;
15
+ timeout: number;
16
+ maxFileSizeMB: number;
17
+ maxBatch: number;
18
+ uploadInterval: number;
19
+ imageHosts: string[];
20
+ }
21
+ export declare const Config: Schema<Config>;
package/lib/config.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = void 0;
4
+ const koishi_1 = require("koishi");
5
+ exports.Config = koishi_1.Schema.intersect([
6
+ koishi_1.Schema.object({
7
+ baseUrl: koishi_1.Schema.string().role('link').required().description('Telegraph-Image 地址,例如 https://your-site.pages.dev。'),
8
+ adminUser: koishi_1.Schema.string().default('admin').description('后台 BASIC_USER。'),
9
+ adminPassword: koishi_1.Schema.string().role('secret').required().description('当前后台 BASIC_PASS;更改后台密码后需要同步更新。'),
10
+ uploadUser: koishi_1.Schema.string().default('').description('上传入口账号。公开上传时留空。'),
11
+ uploadPassword: koishi_1.Schema.string().role('secret').default('').description('上传入口密码。公开上传时留空。'),
12
+ }).description('图床连接'),
13
+ koishi_1.Schema.object({
14
+ command: koishi_1.Schema.string().pattern(/^[\p{L}\p{N}_-]+$/u).default('图床').description('主指令名称。'),
15
+ adminUsers: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('管理员标识,例如 onebot:123456789。发送“图床 身份”可查看自己的标识。'),
16
+ managementAuthority: koishi_1.Schema.number().min(1).max(5).step(1).default(3).description('允许浏览和管理图库的 Koishi 权限等级;管理员标识也可独立授权。'),
17
+ uploadAuthority: koishi_1.Schema.number().min(0).max(5).step(1).default(1).description('允许上传的最低权限等级。无数据库时普通用户按 1 级处理。'),
18
+ }).description('指令与权限'),
19
+ koishi_1.Schema.object({
20
+ pageSize: koishi_1.Schema.number().min(1).max(20).step(1).default(5).description('每页记录数。'),
21
+ listThumbnails: koishi_1.Schema.boolean().default(true).description('在列表和翻页回复中显示图片预览。关闭后仅显示文字;不影响单张查看。'),
22
+ sessionMinutes: koishi_1.Schema.number().min(1).max(60).step(1).default(10).description('列表序号和分页状态的有效期(分钟)。'),
23
+ timeout: koishi_1.Schema.number().min(1000).max(120000).step(1000).default(30000).role('ms').description('单次网络请求超时,包含读取响应。'),
24
+ maxFileSizeMB: koishi_1.Schema.number().min(1).max(20).default(20).description('单张图片大小上限(MiB)。'),
25
+ maxBatch: koishi_1.Schema.number().min(1).max(10).step(1).default(3).description('单条指令最多上传的图片数。'),
26
+ uploadInterval: koishi_1.Schema.number().min(0).max(60000).step(100).default(3500).role('ms').description('上次上传结束到下次任务开始的最小间隔。Telegram 存储建议保持默认值。'),
27
+ imageHosts: koishi_1.Schema.array(koishi_1.Schema.string()).default(['*.qpic.cn', '*.qq.com', '*.qq.com.cn']).description('允许下载图片的来源域名。默认覆盖 QQ CDN,自动包含图床域名;可按其他适配器需要添加精确域名或 *.example.com。'),
28
+ }).description('图片与网络'),
29
+ ]);
package/lib/http.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Context } from 'koishi';
2
+ export declare class UserError extends Error {
3
+ }
4
+ export declare function readLimited(response: Response, limit: number): Promise<Uint8Array>;
5
+ export declare function request<T>(http: Context['http'], url: string, timeout: number, shutdown: AbortSignal, options: {
6
+ method?: 'GET' | 'POST';
7
+ data?: unknown;
8
+ headers?: Record<string, string>;
9
+ redirect?: RequestRedirect;
10
+ }, decoder: (response: Response) => Promise<T>): Promise<T>;
11
+ export declare function checkStatus(status: number): void;
package/lib/http.js ADDED
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UserError = void 0;
4
+ exports.readLimited = readLimited;
5
+ exports.request = request;
6
+ exports.checkStatus = checkStatus;
7
+ class UserError extends Error {
8
+ }
9
+ exports.UserError = UserError;
10
+ async function readLimited(response, limit) {
11
+ const advertised = Number(response.headers.get('content-length'));
12
+ if (advertised > limit) {
13
+ await response.body?.cancel();
14
+ throw new UserError('文件或响应超过大小限制。');
15
+ }
16
+ if (!response.body)
17
+ throw new UserError('服务器返回了空响应。');
18
+ const reader = response.body.getReader();
19
+ const chunks = [];
20
+ let length = 0;
21
+ try {
22
+ while (true) {
23
+ const { value, done } = await reader.read();
24
+ if (done)
25
+ break;
26
+ length += value.byteLength;
27
+ if (length > limit)
28
+ throw new UserError('文件或响应超过大小限制。');
29
+ chunks.push(value);
30
+ }
31
+ }
32
+ catch (error) {
33
+ await reader.cancel().catch(() => { });
34
+ throw error;
35
+ }
36
+ finally {
37
+ reader.releaseLock();
38
+ }
39
+ const result = new Uint8Array(length);
40
+ let offset = 0;
41
+ for (const chunk of chunks) {
42
+ result.set(chunk, offset);
43
+ offset += chunk.byteLength;
44
+ }
45
+ return result;
46
+ }
47
+ async function request(http, url, timeout, shutdown, options, decoder) {
48
+ const controller = new AbortController();
49
+ const abort = () => controller.abort();
50
+ if (shutdown.aborted)
51
+ throw new UserError('插件已停止。');
52
+ shutdown.addEventListener('abort', abort, { once: true });
53
+ const timer = setTimeout(abort, timeout);
54
+ try {
55
+ const response = await http(url, {
56
+ ...options,
57
+ timeout,
58
+ signal: controller.signal,
59
+ redirect: options.redirect ?? 'error',
60
+ validateStatus: () => true,
61
+ responseType: decoder,
62
+ });
63
+ return response.data;
64
+ }
65
+ catch (error) {
66
+ if (error instanceof UserError)
67
+ throw error;
68
+ if (controller.signal.aborted)
69
+ throw new UserError('请求已取消或超时,请稍后重试。');
70
+ // HTTP errors may contain signed attachment URLs or request credentials.
71
+ throw new UserError('网络请求失败,请检查图床地址、HTTP 服务及代理配置。');
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ shutdown.removeEventListener('abort', abort);
76
+ }
77
+ }
78
+ function checkStatus(status) {
79
+ if (status >= 200 && status < 300)
80
+ return;
81
+ if (status === 401 || status === 403)
82
+ throw new UserError('图床认证失败,请检查对应的账号密码和访问策略。');
83
+ if (status === 429)
84
+ throw new UserError('请求过于频繁,请稍后重试。');
85
+ if (status === 404)
86
+ throw new UserError('图片记录或接口不存在。');
87
+ throw new UserError(`图床请求失败(HTTP ${status})。`);
88
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { Context, type Session } from 'koishi';
2
+ import { Config } from './config';
3
+ export { Config };
4
+ export declare const name = "telegraph-image-manager";
5
+ export declare const reusable = true;
6
+ export declare const inject: string[];
7
+ export declare const usage = "\u586B\u5199\u56FE\u5E8A\u5730\u5740\u53CA\u5F53\u524D\u540E\u53F0\u8D26\u53F7\u5BC6\u7801\u3002\u9ED8\u8BA4\u6307\u4EE4\u4E3A\u201C\u56FE\u5E8A\u201D\uFF1BOneBot \u7BA1\u7406\u5458\u6807\u8BC6\u683C\u5F0F\u4E3A onebot:QQ\u53F7\uFF0C\u4E5F\u53EF\u4F7F\u7528 Koishi 3 \u7EA7\u53CA\u4EE5\u4E0A\u6743\u9650\u3002\u56FE\u7247\u4E0A\u4F20\u652F\u6301\u540C\u6761\u6D88\u606F\u9644\u56FE\u6216\u5F15\u7528\u56FE\u7247\u6D88\u606F\u3002\u5220\u9664\u4EC5\u79FB\u9664\u7BA1\u7406\u8BB0\u5F55\uFF0C\u4E0D\u5220\u9664 Telegram \u6E90\u6587\u4EF6\u3002";
8
+ export declare function canManage(session: Pick<Session<'authority'>, 'platform' | 'userId' | 'user'>, config: Config): boolean;
9
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.usage = exports.inject = exports.reusable = exports.name = exports.Config = void 0;
4
+ exports.canManage = canManage;
5
+ exports.apply = apply;
6
+ const koishi_1 = require("koishi");
7
+ const config_1 = require("./config");
8
+ Object.defineProperty(exports, "Config", { enumerable: true, get: function () { return config_1.Config; } });
9
+ const api_1 = require("./api");
10
+ const http_1 = require("./http");
11
+ const media_1 = require("./media");
12
+ const state_1 = require("./state");
13
+ exports.name = 'telegraph-image-manager';
14
+ exports.reusable = true;
15
+ exports.inject = ['http'];
16
+ exports.usage = '填写图床地址及当前后台账号密码。默认指令为“图床”;OneBot 管理员标识格式为 onebot:QQ号,也可使用 Koishi 3 级及以上权限。图片上传支持同条消息附图或引用图片消息。删除仅移除管理记录,不删除 Telegram 源文件。';
17
+ function canManage(session, config) {
18
+ return config.adminUsers.includes(`${session.platform}:${session.userId}`)
19
+ || (session.user?.authority ?? 0) >= config.managementAuthority;
20
+ }
21
+ function size(bytes) {
22
+ if (!Number.isFinite(bytes) || bytes <= 0)
23
+ return '大小未知';
24
+ return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
25
+ }
26
+ function clean(text) { return text.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 300); }
27
+ function apply(ctx, config) {
28
+ const shutdown = new AbortController();
29
+ const api = new api_1.ImageApi(ctx.http, config, shutdown.signal);
30
+ const galleries = new state_1.ExpiringStore(config.sessionMinutes * 60000);
31
+ const deletions = new state_1.ExpiringStore(60000);
32
+ const busy = new Set();
33
+ const uploading = new Set();
34
+ const queue = new state_1.UploadQueue(config.uploadInterval, shutdown.signal);
35
+ const logger = ctx.logger(exports.name);
36
+ const prefix = config.command;
37
+ ctx.on('dispose', () => {
38
+ shutdown.abort();
39
+ galleries.clear();
40
+ deletions.clear();
41
+ busy.clear();
42
+ uploading.clear();
43
+ });
44
+ function fail(error) {
45
+ if (error instanceof http_1.UserError)
46
+ return koishi_1.h.text(error.message);
47
+ logger.warn('Unexpected command failure; request details omitted.');
48
+ return koishi_1.h.text('操作失败,请检查插件配置后重试。');
49
+ }
50
+ async function manage(session, callback) {
51
+ if (!session)
52
+ return;
53
+ if (!canManage(session, config))
54
+ return koishi_1.h.text('你没有图库管理权限。');
55
+ const key = (0, state_1.sessionKey)(session);
56
+ if (busy.has(key))
57
+ return koishi_1.h.text('当前会话还有操作正在执行,请稍后再试。');
58
+ busy.add(key);
59
+ try {
60
+ return await callback(session, key);
61
+ }
62
+ catch (error) {
63
+ return fail(error);
64
+ }
65
+ finally {
66
+ busy.delete(key);
67
+ }
68
+ }
69
+ function resolve(reference, key) {
70
+ if (!reference)
71
+ throw new http_1.UserError(`缺少图片参数,请使用“${prefix}”查看指令菜单。`);
72
+ if (/^#?\d+$/.test(reference)) {
73
+ const gallery = galleries.get(key);
74
+ if (!gallery)
75
+ throw new http_1.UserError(`列表序号已过期或不属于当前会话,请先发送“${prefix} 列表”。`);
76
+ const index = Number(reference.replace(/^#/, '')) - 1;
77
+ const record = gallery.page.items[index];
78
+ if (!Number.isSafeInteger(index) || !record)
79
+ throw new http_1.UserError('列表中没有这个序号。');
80
+ return { id: record.id, record };
81
+ }
82
+ const id = api.parseId(reference);
83
+ return { id, record: galleries.get(key)?.page.items.find((record) => record.id === id) };
84
+ }
85
+ function render(gallery) {
86
+ const elements = [koishi_1.h.text(`图库第 ${gallery.offset + gallery.index + 1} 页 · ${gallery.page.items.length} 条记录\n`)];
87
+ for (const [index, record] of gallery.page.items.entries()) {
88
+ const labels = [size(record.fileSize)];
89
+ if (record.liked)
90
+ labels.push('已收藏');
91
+ if (record.listType === 'Block')
92
+ labels.push('黑名单');
93
+ if (record.listType === 'White')
94
+ labels.push('白名单');
95
+ elements.push(koishi_1.h.text(`\n${index + 1}. ${clean(record.fileName)}\n ${labels.join(' · ')}\n`));
96
+ if (config.listThumbnails && record.listType !== 'Block' && /\.(?:jpe?g|png|gif|webp|bmp|avif|apng)$/i.test(record.id)) {
97
+ elements.push(koishi_1.h.image(api.fileUrl(record.id)), koishi_1.h.text('\n'));
98
+ }
99
+ }
100
+ const lines = [];
101
+ if (!gallery.page.items.length)
102
+ lines.push(gallery.page.complete ? '当前页没有图片记录。' : '当前页没有图片记录,可以继续翻页。');
103
+ lines.push(`${prefix} 查看 1 | ${prefix} 链接 1`);
104
+ if (gallery.index > 0)
105
+ lines.push(`${prefix} 上一页`);
106
+ if (!gallery.page.complete)
107
+ lines.push(`${prefix} 下一页`);
108
+ elements.push(koishi_1.h.text(lines.join('\n')));
109
+ return (0, koishi_1.h)('message', elements);
110
+ }
111
+ const root = ctx.command(prefix, 'Telegraph-Image 图床', { authority: 0 })
112
+ .action(() => koishi_1.h.text([
113
+ `${prefix} 上传 [附图](也支持引用图片消息)`,
114
+ `${prefix} 列表 / 下一页 / 上一页`,
115
+ `${prefix} 查看 <序号或ID>`,
116
+ `${prefix} 链接 <序号或ID>`,
117
+ `${prefix} 重命名 <序号或ID> <新名称>`,
118
+ `${prefix} 收藏 / 拉黑 / 白名单 / 删除 <序号或ID>`,
119
+ `${prefix} 状态 / 身份`,
120
+ ].join('\n')));
121
+ function command(decl, description) {
122
+ return root.subcommand(`.${decl}`, description, { authority: 0, captureQuote: false }).userFields(['authority']);
123
+ }
124
+ command('身份', '查看自己的管理员标识').action(({ session }) => {
125
+ if (!session)
126
+ return;
127
+ return koishi_1.h.text(`用户标识:${session.platform}:${session.userId}\nKoishi 权限:${session.user?.authority ?? '未使用数据库'}\n图库管理:${canManage(session, config) ? '已授权' : '未授权'}`);
128
+ });
129
+ command('状态', '检查图床连接和运行配置').action(async () => {
130
+ try {
131
+ const status = await api.status();
132
+ return koishi_1.h.text(`图床:${status.ready ? '就绪' : '配置不完整'}\n存储:${status.storageProvider}\n后台:${status.dashboard === 'ok' ? '已启用' : '未启用'}\n上传认证:${status.uploadRequiresAuth ? '需要' : '公开'}`);
133
+ }
134
+ catch (error) {
135
+ return fail(error);
136
+ }
137
+ });
138
+ command('上传 [content:text]', '上传附图或引用消息中的图片').action(async ({ session }) => {
139
+ if (!session)
140
+ return;
141
+ if (!canManage(session, config) && (session.user?.authority ?? 1) < config.uploadAuthority)
142
+ return koishi_1.h.text('你没有上传权限。');
143
+ const images = (0, media_1.attachments)(session);
144
+ if (!images.length)
145
+ return koishi_1.h.text(`请将图片与“${prefix} 上传”一起发送,或引用图片消息后发送该指令。`);
146
+ if (images.length > config.maxBatch)
147
+ return koishi_1.h.text(`单次最多上传 ${config.maxBatch} 张图片,本次未开始上传。`);
148
+ const key = (0, state_1.sessionKey)(session);
149
+ if (uploading.has(key))
150
+ return koishi_1.h.text('你的上一批图片仍在上传,请稍后重试。');
151
+ uploading.add(key);
152
+ try {
153
+ const results = [];
154
+ for (const [index, image] of images.entries()) {
155
+ try {
156
+ const uploaded = await queue.run(async () => {
157
+ const file = await (0, media_1.downloadImage)(ctx.http, image, config, shutdown.signal);
158
+ return { url: await api.upload(file.bytes, file.mime, file.fileName), fileName: file.fileName };
159
+ });
160
+ results.push(`${index + 1}. ${clean(uploaded.fileName)}\n${uploaded.url}\nMarkdown: ![](${uploaded.url})`);
161
+ }
162
+ catch (error) {
163
+ results.push(`${index + 1}. ${error instanceof http_1.UserError ? error.message : '上传失败,请检查图片与配置。'}`);
164
+ }
165
+ }
166
+ return koishi_1.h.text(results.join('\n\n'));
167
+ }
168
+ finally {
169
+ uploading.delete(key);
170
+ }
171
+ });
172
+ command('列表', '重新列出图库第一页').action(({ session }) => manage(session, async (_, key) => {
173
+ const gallery = { page: await api.list(), cursors: [undefined], index: 0, offset: 0 };
174
+ galleries.set(key, gallery);
175
+ return render(gallery);
176
+ }));
177
+ for (const direction of [1, -1]) {
178
+ command(direction === 1 ? '下一页' : '上一页', '翻页浏览图库').action(({ session }) => manage(session, async (_, key) => {
179
+ const gallery = galleries.get(key);
180
+ if (!gallery)
181
+ throw new http_1.UserError(`请先发送“${prefix} 列表”。`);
182
+ if (direction === 1 && gallery.page.complete)
183
+ return koishi_1.h.text('已经是最后一页。');
184
+ if (direction === -1 && gallery.index === 0)
185
+ return koishi_1.h.text(gallery.offset ? '更早的分页缓存已释放,请重新列出图库。' : '已经是第一页。');
186
+ const nextIndex = gallery.index + direction;
187
+ const cursor = direction === 1 ? gallery.page.cursor : gallery.cursors[nextIndex];
188
+ const page = await api.list(cursor);
189
+ if (direction === 1)
190
+ gallery.cursors.splice(nextIndex, gallery.cursors.length, cursor);
191
+ gallery.index = nextIndex;
192
+ gallery.page = page;
193
+ if (gallery.cursors.length > 100) {
194
+ gallery.cursors.shift();
195
+ gallery.index--;
196
+ gallery.offset++;
197
+ }
198
+ galleries.set(key, gallery);
199
+ return render(gallery);
200
+ }));
201
+ }
202
+ command('查看 <target>', '预览指定图片').action(({ session }, target) => manage(session, async (_, key) => {
203
+ const { id, record } = resolve(target, key);
204
+ const url = api.fileUrl(id);
205
+ if (record?.listType === 'Block')
206
+ return koishi_1.h.text(`此图片已列入黑名单。\n${url}`);
207
+ return (0, koishi_1.h)('message', [koishi_1.h.text(`${clean(record?.fileName ?? id)}\n${url}\n`), koishi_1.h.image(url)]);
208
+ }));
209
+ command('链接 <target>', '获取直链与 Markdown').action(({ session }, target) => manage(session, async (_, key) => {
210
+ const url = api.fileUrl(resolve(target, key).id);
211
+ return koishi_1.h.text(`${url}\nMarkdown: ![](${url})`);
212
+ }));
213
+ command('重命名 <target> <newName:text>', '修改图片的显示名称').action(({ session }, target, newName) => manage(session, async (_, key) => {
214
+ if (!newName?.trim() || newName.length > 240 || /[\x00-\x1f\x7f]/.test(newName))
215
+ throw new http_1.UserError('名称须为 1 至 240 个字符,且不包含控制字符。');
216
+ const { id, record } = resolve(target, key);
217
+ await api.modify('editName', id, newName.trim());
218
+ if (record)
219
+ record.fileName = newName.trim();
220
+ return koishi_1.h.text(`已重命名为:${newName.trim()}`);
221
+ }));
222
+ for (const [label, action] of [['收藏', 'toggleLike'], ['拉黑', 'block'], ['白名单', 'white']]) {
223
+ command(`${label} <target>`, label === '收藏' ? '切换收藏状态' : `将图片加入${action === 'block' ? '黑名单' : '白名单'}`).action(({ session }, target) => manage(session, async (_, key) => {
224
+ const { id, record } = resolve(target, key);
225
+ const result = await api.modify(action, id);
226
+ if (record) {
227
+ if (action === 'toggleLike')
228
+ record.liked = result.liked;
229
+ else
230
+ record.listType = action === 'block' ? 'Block' : 'White';
231
+ }
232
+ return koishi_1.h.text(action === 'toggleLike' ? (result.liked ? '已收藏。' : '已取消收藏。') : `已加入${action === 'block' ? '黑名单' : '白名单'}。`);
233
+ }));
234
+ }
235
+ command('删除 <target>', '申请删除一条图片管理记录').action(({ session }, target) => manage(session, async (_, key) => {
236
+ const { id, record } = resolve(target, key);
237
+ const pending = (0, state_1.deletion)(id, clean(record?.fileName ?? id));
238
+ deletions.set(key, pending);
239
+ return koishi_1.h.text(`将删除管理记录:${pending.label}\nID:${id}\nTelegram 源文件不会删除。\n60 秒内发送“${prefix} 确认 ${pending.token}”执行,或发送“${prefix} 取消”。`);
240
+ }));
241
+ command('确认 <token>', '确认删除指定记录').action(({ session }, token) => manage(session, async (_, key) => {
242
+ if (!token)
243
+ throw new http_1.UserError('缺少确认码,请使用删除申请中返回的确认码。');
244
+ const pending = deletions.get(key);
245
+ if (!pending || pending.token !== token.toUpperCase())
246
+ throw new http_1.UserError('确认码不正确、已过期或不属于当前会话。');
247
+ deletions.delete(key);
248
+ await api.modify('delete', pending.id);
249
+ galleries.delete(key);
250
+ return koishi_1.h.text(`已删除管理记录:${pending.label}\nTelegram 源文件保留。列表序号已清除,请重新列出图库。`);
251
+ }));
252
+ command('取消', '取消当前会话的删除申请').action(({ session }) => manage(session, async (_, key) => {
253
+ deletions.delete(key);
254
+ return koishi_1.h.text('已取消删除申请。');
255
+ }));
256
+ }
package/lib/media.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type Context, type Session } from 'koishi';
2
+ import type { Config } from './config';
3
+ export interface Attachment {
4
+ src: string;
5
+ name?: string;
6
+ }
7
+ export declare function attachments(session: Pick<Session, 'elements' | 'quote'>): Attachment[];
8
+ export declare function allowedImageUrl(source: string, hosts: string[]): URL;
9
+ export declare function downloadImage(http: Context['http'], attachment: Attachment, config: Config, shutdown: AbortSignal): Promise<{
10
+ bytes: Uint8Array<ArrayBufferLike>;
11
+ mime: import("file-type", { with: { "resolution-mode": "import" } }).MimeType;
12
+ fileName: string;
13
+ }>;
package/lib/media.js ADDED
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attachments = attachments;
4
+ exports.allowedImageUrl = allowedImageUrl;
5
+ exports.downloadImage = downloadImage;
6
+ const node_net_1 = require("node:net");
7
+ const node_path_1 = require("node:path");
8
+ const koishi_1 = require("koishi");
9
+ const http_1 = require("./http");
10
+ function attachments(session) {
11
+ const collect = (elements) => [...koishi_1.h.select(elements, 'img'), ...koishi_1.h.select(elements, 'image')]
12
+ .map((element) => ({ src: element.attrs.src || element.attrs.url, name: element.attrs.filename || element.attrs.name }))
13
+ .filter((item) => typeof item.src === 'string' && !!item.src);
14
+ let found = collect(session.elements ?? []);
15
+ if (!found.length && session.quote)
16
+ found = collect(session.quote.elements ?? koishi_1.h.parse(session.quote.content ?? ''));
17
+ return found.filter((item, index) => found.findIndex((other) => other.src === item.src) === index);
18
+ }
19
+ function allowedImageUrl(source, hosts) {
20
+ let url;
21
+ try {
22
+ url = new URL(source);
23
+ }
24
+ catch {
25
+ throw new http_1.UserError('图片来源地址无效。');
26
+ }
27
+ const host = url.hostname.toLowerCase();
28
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.port || (0, node_net_1.isIP)(host.replace(/^\[|\]$/g, ''))) {
29
+ throw new http_1.UserError('图片来源必须是允许域名下的 HTTP(S) 地址,不能使用本地文件或 IP 地址。');
30
+ }
31
+ const allowed = hosts.some((pattern) => {
32
+ pattern = pattern.trim().toLowerCase();
33
+ if (pattern.startsWith('*.')) {
34
+ const suffix = pattern.slice(2);
35
+ return !!suffix && (host === suffix || host.endsWith(`.${suffix}`));
36
+ }
37
+ return host === pattern;
38
+ });
39
+ if (!allowed)
40
+ throw new http_1.UserError('图片来源域名未被允许,请由管理员检查 imageHosts 配置。');
41
+ return url;
42
+ }
43
+ async function downloadImage(http, attachment, config, shutdown) {
44
+ const max = Math.floor(config.maxFileSizeMB * 1024 * 1024);
45
+ let bytes;
46
+ if (attachment.src.startsWith('data:') || attachment.src.startsWith('base64://')) {
47
+ const match = /^(?:data:image\/[a-z0-9.+-]+;base64,|base64:\/\/)([a-z0-9+/]*={0,2})$/i.exec(attachment.src);
48
+ if (!match || match[1].length > Math.ceil(max / 3) * 4)
49
+ throw new http_1.UserError('内嵌图片格式无效或超过大小限制。');
50
+ bytes = Buffer.from(match[1], 'base64');
51
+ if (bytes.length > max)
52
+ throw new http_1.UserError('图片超过大小限制。');
53
+ }
54
+ else {
55
+ const hosts = [...config.imageHosts, new URL(config.baseUrl).hostname];
56
+ let url = allowedImageUrl(attachment.src, hosts);
57
+ bytes = new Uint8Array();
58
+ for (let redirects = 0;; redirects++) {
59
+ const result = await (0, http_1.request)(http, url.href, config.timeout, shutdown, { redirect: 'manual' }, async (response) => {
60
+ if (response.status >= 300 && response.status < 400) {
61
+ await response.body?.cancel();
62
+ const location = response.headers.get('location');
63
+ if (!location || redirects >= 3)
64
+ throw new http_1.UserError('图片来源重定向次数过多或缺少目标地址。');
65
+ return allowedImageUrl(new URL(location, url).href, hosts);
66
+ }
67
+ if (!response.ok) {
68
+ await response.body?.cancel();
69
+ (0, http_1.checkStatus)(response.status);
70
+ }
71
+ return (0, http_1.readLimited)(response, max);
72
+ });
73
+ if (result instanceof URL)
74
+ url = result;
75
+ else {
76
+ bytes = result;
77
+ break;
78
+ }
79
+ }
80
+ }
81
+ if (!bytes.byteLength)
82
+ throw new http_1.UserError('收到的图片为空。');
83
+ const { fileTypeFromBuffer } = await import('file-type');
84
+ const type = await fileTypeFromBuffer(bytes).catch(() => undefined);
85
+ if (!type || !['png', 'jpg', 'gif', 'webp', 'bmp', 'avif', 'apng'].includes(type.ext)) {
86
+ throw new http_1.UserError('无法识别图片内容;支持 PNG、JPEG、GIF、WebP、BMP 和 AVIF。');
87
+ }
88
+ const original = typeof attachment.name === 'string' ? (0, node_path_1.basename)(attachment.name.replace(/\\/g, '/')) : 'image';
89
+ const stem = (0, node_path_1.parse)(original).name.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 180) || 'image';
90
+ return { bytes, mime: type.mime, fileName: `${stem}.${type.ext}` };
91
+ }
package/lib/state.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Session } from 'koishi';
2
+ import type { ImagePage } from './api';
3
+ export declare function sessionKey(session: Pick<Session, 'platform' | 'selfId' | 'userId' | 'channelId' | 'guildId'>): string;
4
+ export interface Gallery {
5
+ page: ImagePage;
6
+ cursors: (string | undefined)[];
7
+ index: number;
8
+ offset: number;
9
+ }
10
+ export interface Deletion {
11
+ id: string;
12
+ label: string;
13
+ token: string;
14
+ }
15
+ export declare class ExpiringStore<T> {
16
+ private ttl;
17
+ private now;
18
+ private entries;
19
+ constructor(ttl: number, now?: () => number);
20
+ get(key: string): T | undefined;
21
+ set(key: string, value: T): void;
22
+ delete(key: string): void;
23
+ clear(): void;
24
+ }
25
+ export declare function deletion(id: string, label: string): Deletion;
26
+ export declare class UploadQueue {
27
+ private interval;
28
+ private shutdown;
29
+ private tail;
30
+ private count;
31
+ private nextReady;
32
+ constructor(interval: number, shutdown: AbortSignal);
33
+ run<T>(callback: () => Promise<T>): Promise<T>;
34
+ }
package/lib/state.js ADDED
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UploadQueue = exports.ExpiringStore = void 0;
4
+ exports.sessionKey = sessionKey;
5
+ exports.deletion = deletion;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const http_1 = require("./http");
8
+ function sessionKey(session) {
9
+ return JSON.stringify([session.platform, session.selfId, session.userId, session.guildId, session.channelId]);
10
+ }
11
+ class ExpiringStore {
12
+ ttl;
13
+ now;
14
+ entries = new Map();
15
+ constructor(ttl, now = Date.now) {
16
+ this.ttl = ttl;
17
+ this.now = now;
18
+ }
19
+ get(key) {
20
+ const item = this.entries.get(key);
21
+ if (!item)
22
+ return;
23
+ if (item.until <= this.now()) {
24
+ this.entries.delete(key);
25
+ return;
26
+ }
27
+ return item.value;
28
+ }
29
+ set(key, value) {
30
+ for (const [key, item] of this.entries)
31
+ if (item.until <= this.now())
32
+ this.entries.delete(key);
33
+ this.entries.delete(key);
34
+ if (this.entries.size >= 256)
35
+ this.entries.delete(this.entries.keys().next().value);
36
+ this.entries.set(key, { value, until: this.now() + this.ttl });
37
+ }
38
+ delete(key) { this.entries.delete(key); }
39
+ clear() { this.entries.clear(); }
40
+ }
41
+ exports.ExpiringStore = ExpiringStore;
42
+ function deletion(id, label) {
43
+ return { id, label, token: (0, node_crypto_1.randomBytes)(3).toString('hex').toUpperCase() };
44
+ }
45
+ class UploadQueue {
46
+ interval;
47
+ shutdown;
48
+ tail = Promise.resolve();
49
+ count = 0;
50
+ nextReady = 0;
51
+ constructor(interval, shutdown) {
52
+ this.interval = interval;
53
+ this.shutdown = shutdown;
54
+ }
55
+ async run(callback) {
56
+ if (this.count >= 20)
57
+ throw new http_1.UserError('上传队列已满,请稍后重试。');
58
+ this.count++;
59
+ const job = this.tail.then(async () => {
60
+ if (this.shutdown.aborted)
61
+ throw new http_1.UserError('插件已停止。');
62
+ const delay = Math.max(0, this.nextReady - Date.now());
63
+ if (delay)
64
+ await new Promise((resolve, reject) => {
65
+ const abort = () => { clearTimeout(timer); reject(new http_1.UserError('插件已停止。')); };
66
+ const timer = setTimeout(() => { this.shutdown.removeEventListener('abort', abort); resolve(); }, delay);
67
+ this.shutdown.addEventListener('abort', abort, { once: true });
68
+ });
69
+ try {
70
+ return await callback();
71
+ }
72
+ finally {
73
+ this.nextReady = Date.now() + this.interval;
74
+ }
75
+ });
76
+ this.tail = job.then(() => { }, () => { });
77
+ try {
78
+ return await job;
79
+ }
80
+ finally {
81
+ this.count--;
82
+ }
83
+ }
84
+ }
85
+ exports.UploadQueue = UploadQueue;
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "koishi-plugin-telegraph-image-manager",
3
+ "version": "0.1.2",
4
+ "description": "Upload, browse and manage Telegraph-Image from Koishi and OneBot v11.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Lance0174/koishi-plugin-telegraph-image-manager.git"
8
+ },
9
+ "homepage": "https://github.com/Lance0174/koishi-plugin-telegraph-image-manager#readme",
10
+ "bugs": { "url": "https://github.com/Lance0174/koishi-plugin-telegraph-image-manager/issues" },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
13
+ "files": ["lib", "README.md", "CHANGELOG.md", "LICENSE"],
14
+ "license": "MIT",
15
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
16
+ "engines": { "node": ">=18.20" },
17
+ "keywords": ["koishi", "plugin", "telegraph-image", "onebot", "image-hosting"],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "npm run build && node --test --test-concurrency=1 test/*.test.cjs",
21
+ "prepack": "npm run build"
22
+ },
23
+ "peerDependencies": { "koishi": "^4.18.11" },
24
+ "dependencies": { "file-type": "^19.6.0" },
25
+ "devDependencies": {
26
+ "@koishijs/plugin-mock": "2.6.6",
27
+ "@types/node": "^18.19.0",
28
+ "koishi": "4.18.11",
29
+ "koishi-plugin-adapter-onebot": "6.9.4",
30
+ "typescript": "^5.9.0"
31
+ },
32
+ "koishi": {
33
+ "description": { "zh": "Telegraph-Image 图床上传、图库浏览和管理,支持 OneBot v11 图片与引用消息。" },
34
+ "service": { "required": ["http"] }
35
+ }
36
+ }