koishi-plugin-kkk 1.0.8 → 1.0.9
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 +14 -0
- package/lib/karin/module/utils/Base.js +19 -9
- package/lib/qqFields.json +10 -0
- package/lib/webui.js +7 -11
- package/package.json +1 -1
- package/src/karin/module/utils/Base.ts +506 -497
- package/src/qqFields.json +10 -0
- package/src/webui.ts +6 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 1.0.9
|
|
4
|
+
|
|
5
|
+
### 变更
|
|
6
|
+
- **面板不再从独立 URL 进**:`/kkk`、`/kkk/edit` 以及 `/kkk/assets/*` 在未授权时直接返回 404
|
|
7
|
+
(以前是一个「需要先登录」的提示页)。面板只从控制台侧边栏「kkk 配置」进,
|
|
8
|
+
凭据由控制台页面通过 RPC 换取的面板 token 交付。
|
|
9
|
+
- **QQ 平台单独的视频体积阈值**:新增配置项 `qqGroupFileLimitMB`(QQ 适配器分组,默认 30)——
|
|
10
|
+
视频超过该体积时改用「群文件」发送,不再受上游 `usegroupfile` 开关影响;
|
|
11
|
+
其它平台仍按上游的「群文件上传」+ `groupfilevalue` 判断。
|
|
12
|
+
|
|
13
|
+
### 修复
|
|
14
|
+
- 之前 30MB~100MB 的视频没有按群文件发送:运行时配置里 `groupfilevalue` 是 100(不是 30),
|
|
15
|
+
这一区间会走视频通道,被 QQ 压缩/改名。现在 QQ 用上面的独立阈值判断。
|
|
16
|
+
|
|
3
17
|
## 1.0.8
|
|
4
18
|
|
|
5
19
|
### 修复
|
|
@@ -179,17 +179,27 @@ const uploadFile = async (event, file, videoUrl, options) => {
|
|
|
179
179
|
];
|
|
180
180
|
await node_karin_1.default.sendMsg(selfId, contact, message2);
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
/**
|
|
183
|
+
* 判断是否使用群文件上传。
|
|
184
|
+
*
|
|
185
|
+
* QQ 单独一套阈值(配置项 `qqGroupFileLimitMB`,默认 30):超过就按群文件发。
|
|
186
|
+
* 原因是 QQ 对富媒体视频会压缩、改名甚至丢掉后缀,几十 MB 的视频走「视频」通道基本没法看;
|
|
187
|
+
* 其它平台仍旧按上游的「群文件上传」开关 + `groupfilevalue` 走。
|
|
188
|
+
*/
|
|
189
|
+
const platform = String(event?.platform ?? event?.bot?.platform ?? '');
|
|
190
|
+
const qqLimit = Number(Config_1.Config.qqGroupFileLimitMB ?? 30);
|
|
191
|
+
const useGroupFile = /qq/i.test(platform) && qqLimit > 0
|
|
192
|
+
? newFileSize > qqLimit
|
|
193
|
+
: Config_1.Config.app.usegroupfile && newFileSize > Config_1.Config.app.groupfilevalue;
|
|
194
|
+
if (options)
|
|
195
|
+
options.useGroupFile = useGroupFile;
|
|
196
|
+
if (Config_1.Config.app.videoSendMode === 'base64' && !useGroupFile) {
|
|
187
197
|
const videoBuffer = node_fs_1.default.readFileSync(file.filepath);
|
|
188
198
|
File = `base64://${videoBuffer.toString('base64')}`;
|
|
189
199
|
node_karin_1.logger.mark(`已开启视频文件 base64转换 正在进行${node_karin_1.logger.yellow('base64转换中')}...`);
|
|
190
200
|
}
|
|
191
201
|
else
|
|
192
|
-
File =
|
|
202
|
+
File = useGroupFile ? file.filepath : `file://${file.filepath}`;
|
|
193
203
|
/**
|
|
194
204
|
* 发送前:撤掉上一条「收到请求,开始下载」,并回一句「发送中…」。
|
|
195
205
|
* 大文件上传(几十 MB 走文件通道)要等好几秒,没有任何反馈的话用户会以为机器人卡住了。
|
|
@@ -200,7 +210,7 @@ const uploadFile = async (event, file, videoUrl, options) => {
|
|
|
200
210
|
try {
|
|
201
211
|
if (!options?.active) {
|
|
202
212
|
await (0, QqPanel_1.recallLastPanel)(event);
|
|
203
|
-
const tipText =
|
|
213
|
+
const tipText = useGroupFile
|
|
204
214
|
? '发送中…(超过 QQ 限制,将以群文件发送)'
|
|
205
215
|
: '发送中…';
|
|
206
216
|
const tip = await event.reply(tipText);
|
|
@@ -213,7 +223,7 @@ const uploadFile = async (event, file, videoUrl, options) => {
|
|
|
213
223
|
try {
|
|
214
224
|
// 是主动消息
|
|
215
225
|
if (options?.active) {
|
|
216
|
-
if (
|
|
226
|
+
if (useGroupFile) {
|
|
217
227
|
// 是群文件
|
|
218
228
|
const bot = node_karin_1.default.getBot(String(options.activeOption?.uin));
|
|
219
229
|
node_karin_1.logger.mark(`${node_karin_1.logger.blue('主动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${node_karin_1.logger.yellow('bot.uploadFile')}回复...`);
|
|
@@ -228,7 +238,7 @@ const uploadFile = async (event, file, videoUrl, options) => {
|
|
|
228
238
|
}
|
|
229
239
|
else {
|
|
230
240
|
// 不是主动消息
|
|
231
|
-
if (
|
|
241
|
+
if (useGroupFile) {
|
|
232
242
|
// 是文件
|
|
233
243
|
node_karin_1.logger.mark(`${node_karin_1.logger.blue('被动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${node_karin_1.logger.yellow('e.bot.uploadFile')}回复...`);
|
|
234
244
|
await event.bot.uploadFile(event.contact, File, file.originTitle ? `${file.originTitle}.mp4` : `${File.split('/').pop()}`);
|
package/lib/qqFields.json
CHANGED
|
@@ -71,5 +71,15 @@
|
|
|
71
71
|
"label": "卡片文字识别密钥",
|
|
72
72
|
"secret": true,
|
|
73
73
|
"description": "群里转发过来的分享卡片没有链接,只能靠截图识别出标题和作者再去搜索。填自己的免费密钥更稳定:https://ocr.space/ocrapi 。留空会用公共测试密钥,容易被限流。"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"key": "qqGroupFileLimitMB",
|
|
77
|
+
"group": "视频发送",
|
|
78
|
+
"type": "number",
|
|
79
|
+
"default": 30,
|
|
80
|
+
"min": 0,
|
|
81
|
+
"max": 2000,
|
|
82
|
+
"label": "群文件发送阈值(MB)",
|
|
83
|
+
"description": "视频体积超过这个值时,改用「群文件」发送。QQ 对富媒体视频会压缩、改名甚至丢掉后缀,走群文件才能原样发出去;填 0 表示不启用。"
|
|
74
84
|
}
|
|
75
85
|
]
|
package/lib/webui.js
CHANGED
|
@@ -406,16 +406,10 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
406
406
|
return false;
|
|
407
407
|
if (await consoleAuthed(response))
|
|
408
408
|
return false;
|
|
409
|
-
|
|
410
|
-
response.
|
|
411
|
-
response.
|
|
412
|
-
|
|
413
|
-
'<div style="font-family:system-ui;padding:40px;line-height:1.8">',
|
|
414
|
-
'<h2>需要先登录 Koishi 控制台</h2>',
|
|
415
|
-
'<p>配置面板已开启登录校验(插件配置里的 <code>webUiAuth</code>)。</p>',
|
|
416
|
-
'<p><a href="/">去登录控制台</a>,登录后再回到本页面。</p>',
|
|
417
|
-
'</div>',
|
|
418
|
-
].join('');
|
|
409
|
+
// 直接当成「没有这个页面」:面板只从控制台侧边栏进,独立 URL 不给任何提示
|
|
410
|
+
response.status = 404;
|
|
411
|
+
response.type = 'text/plain; charset=utf-8';
|
|
412
|
+
response.body = 'Not Found';
|
|
419
413
|
return true;
|
|
420
414
|
};
|
|
421
415
|
/**
|
|
@@ -475,7 +469,9 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
475
469
|
});
|
|
476
470
|
}
|
|
477
471
|
/* ---------------- 简易编辑页(改插件配置) ---------------- */
|
|
478
|
-
server.get('/kkk/edit', (response) => {
|
|
472
|
+
server.get('/kkk/edit', async (response) => {
|
|
473
|
+
if (await pageDenied(response))
|
|
474
|
+
return;
|
|
479
475
|
let body;
|
|
480
476
|
try {
|
|
481
477
|
body = node_fs_1.default.readFileSync(node_path_1.default.join(pluginRoot, 'assets', 'webui.html'), 'utf-8');
|
package/package.json
CHANGED
|
@@ -1,497 +1,506 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
|
|
3
|
-
import karin, { type Contact, logger, Message, segment } from 'node-karin'
|
|
4
|
-
import type { AxiosHeaders, AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'node-karin/axios'
|
|
5
|
-
|
|
6
|
-
import { baseHeaders, Common, compressVideo, extractTotalBytesFromHeaders, getMediaDuration, Networks } from '@/module/utils'
|
|
7
|
-
import { Config } from '@/module/utils/Config'
|
|
8
|
-
// 撤回上一条面板 + 「发送中…」提示(见 QqPanel 的 recallLastPanel)
|
|
9
|
-
import { recallLastPanel } from '@/module/utils/QqPanel'
|
|
10
|
-
import type { pushlistConfig } from '@/types/config/pushlist'
|
|
11
|
-
|
|
12
|
-
import { AmagiBase } from './amagiClient'
|
|
13
|
-
|
|
14
|
-
type uploadFileOptions = {
|
|
15
|
-
/** 是否使用群文件上传 */
|
|
16
|
-
useGroupFile?: boolean
|
|
17
|
-
/** 消息ID,如果有,则将使用该消息ID制作回复元素 */
|
|
18
|
-
message_id?: string
|
|
19
|
-
/** 是否为主动消息 */
|
|
20
|
-
active?: boolean
|
|
21
|
-
/** 主动消息参数 */
|
|
22
|
-
activeOption?: {
|
|
23
|
-
/** 机器人账号 */
|
|
24
|
-
uin: string
|
|
25
|
-
/** 群号 */
|
|
26
|
-
group_id: string
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 最少都要传一个 */
|
|
31
|
-
type title = {
|
|
32
|
-
/** 文件名:自定义 */
|
|
33
|
-
originTitle?: string
|
|
34
|
-
/** 文件名:tmp + 时间戳 */
|
|
35
|
-
timestampTitle?: string
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
type downloadFileOptions = {
|
|
39
|
-
/** 视频链接 */
|
|
40
|
-
video_url: string
|
|
41
|
-
/** 文件名 */
|
|
42
|
-
title: title
|
|
43
|
-
/** 下载文件类型,默认为'.mp4'。 */
|
|
44
|
-
filetype?: string
|
|
45
|
-
/** 自定义请求头,将使用该请求头下载文件。 */
|
|
46
|
-
headers?: object
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type fileInfo = {
|
|
50
|
-
/** 视频文件的绝对路径 */
|
|
51
|
-
filepath: string
|
|
52
|
-
/** 视频文件大小 */
|
|
53
|
-
totalBytes: number
|
|
54
|
-
/** 文件名:自定义 */
|
|
55
|
-
originTitle?: title['originTitle']
|
|
56
|
-
/** 文件名:tmp + 时间戳 */
|
|
57
|
-
timestampTitle?: title['timestampTitle']
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 表示HTTP请求方法的请求头类型
|
|
62
|
-
* @remarks
|
|
63
|
-
* 这是一个部分类型,将HTTP方法映射到对应的AxiosHeaders类型
|
|
64
|
-
* @property [method] - 每个HTTP方法(小写)对应的请求头
|
|
65
|
-
* @property common - 通用请求头
|
|
66
|
-
*/
|
|
67
|
-
type MethodsHeaders = Partial<
|
|
68
|
-
{
|
|
69
|
-
[Key in Method as Lowercase<Key>]: AxiosHeaders
|
|
70
|
-
} & { common: AxiosHeaders }
|
|
71
|
-
>
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* 下载文件的配置选项接口
|
|
75
|
-
* @interface
|
|
76
|
-
* @property title - 文件名
|
|
77
|
-
* @property [headers] - 用于下载文件的请求头
|
|
78
|
-
* @defaultValue headers - {}
|
|
79
|
-
*/
|
|
80
|
-
export type downLoadFileOptions = {
|
|
81
|
-
/** 文件名 */
|
|
82
|
-
title: string
|
|
83
|
-
/**
|
|
84
|
-
* 将使用该请求头下载文件。
|
|
85
|
-
* @default {}
|
|
86
|
-
*/
|
|
87
|
-
headers?: (RawAxiosRequestHeaders & MethodsHeaders) | AxiosHeaders
|
|
88
|
-
/** 文件保存路径 */
|
|
89
|
-
filepath?: string
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 基础基类
|
|
94
|
-
* @remarks 提供事件上下文与通用请求头,继承自 `AmagiBase` 以复用 Amagi 客户端。
|
|
95
|
-
*/
|
|
96
|
-
export class Base extends AmagiBase {
|
|
97
|
-
/** 事件对象 */
|
|
98
|
-
e: Message
|
|
99
|
-
/** 请求头 */
|
|
100
|
-
headers: AxiosRequestConfig['headers']
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 构造函数:初始化事件与请求头
|
|
104
|
-
* @param e 消息事件对象
|
|
105
|
-
*/
|
|
106
|
-
constructor(e: Message) {
|
|
107
|
-
super()
|
|
108
|
-
this.e = e
|
|
109
|
-
this.headers = baseHeaders
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** 统计每个平台使用最多的机器人ID和使用次数 */
|
|
114
|
-
type PlatformBotStats = {
|
|
115
|
-
/** 机器人ID */
|
|
116
|
-
botId: string
|
|
117
|
-
/** 使用次数 */
|
|
118
|
-
count: number
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 统计每个平台使用最多的机器人ID和使用次数
|
|
123
|
-
* @param pushList - 推送列表配置
|
|
124
|
-
* @returns
|
|
125
|
-
*/
|
|
126
|
-
export const statBotId = (pushList: pushlistConfig): { douyin: PlatformBotStats; bilibili: PlatformBotStats } => {
|
|
127
|
-
const platformBotCount = {
|
|
128
|
-
douyin: new Map<string, number>(),
|
|
129
|
-
bilibili: new Map<string, number>()
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// 统计抖音平台机器人使用次数
|
|
133
|
-
pushList.douyin.forEach((item) => {
|
|
134
|
-
item.group_id.forEach((gid) => {
|
|
135
|
-
const botId = gid.split(':')[1]
|
|
136
|
-
platformBotCount.douyin.set(botId, (platformBotCount.douyin.get(botId) ?? 0) + 1)
|
|
137
|
-
})
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
// 统计B站平台机器人使用次数
|
|
141
|
-
pushList.bilibili.forEach((item) => {
|
|
142
|
-
item.group_id.forEach((gid) => {
|
|
143
|
-
const botId = gid.split(':')[1]
|
|
144
|
-
platformBotCount.bilibili.set(botId, (platformBotCount.bilibili.get(botId) ?? 0) + 1)
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// 获取抖音平台使用最多的机器人
|
|
149
|
-
let douyinMaxCount = 0
|
|
150
|
-
let douyinMostFrequentBot = ''
|
|
151
|
-
platformBotCount.douyin.forEach((count, botId) => {
|
|
152
|
-
if (count > douyinMaxCount) {
|
|
153
|
-
douyinMaxCount = count
|
|
154
|
-
douyinMostFrequentBot = botId
|
|
155
|
-
}
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
// 获取B站平台使用最多的机器人
|
|
159
|
-
let biliMaxCount = 0
|
|
160
|
-
let biliMostFrequentBot = ''
|
|
161
|
-
platformBotCount.bilibili.forEach((count, botId) => {
|
|
162
|
-
if (count > biliMaxCount) {
|
|
163
|
-
biliMaxCount = count
|
|
164
|
-
biliMostFrequentBot = botId
|
|
165
|
-
}
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
return {
|
|
169
|
-
douyin: {
|
|
170
|
-
botId: douyinMostFrequentBot,
|
|
171
|
-
count: douyinMaxCount
|
|
172
|
-
},
|
|
173
|
-
bilibili: {
|
|
174
|
-
botId: biliMostFrequentBot,
|
|
175
|
-
count: biliMaxCount
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** 过万整除 */
|
|
181
|
-
export const Count = (count: number): string => {
|
|
182
|
-
if (count >= 100000000) {
|
|
183
|
-
return (count / 100000000).toFixed(1) + '亿'
|
|
184
|
-
} else if (count >= 10000) {
|
|
185
|
-
return (count / 10000).toFixed(1) + '万'
|
|
186
|
-
} else {
|
|
187
|
-
return count?.toString() ?? '无法获取'
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* 上传视频文件
|
|
193
|
-
* @param event - 消息事件
|
|
194
|
-
* @param file - 包含本地视频文件信息的对象。
|
|
195
|
-
* @param videoUrl 视频直链,无则传空字符串
|
|
196
|
-
* @param options 上传参数
|
|
197
|
-
* @returns
|
|
198
|
-
*/
|
|
199
|
-
export const uploadFile = async (event: Message, file: fileInfo, videoUrl: string, options?: uploadFileOptions): Promise<boolean> => {
|
|
200
|
-
let sendStatus: boolean = true
|
|
201
|
-
let File: string
|
|
202
|
-
let newFileSize = file.totalBytes
|
|
203
|
-
let selfId: string
|
|
204
|
-
let contact: Contact
|
|
205
|
-
|
|
206
|
-
if (options?.active) {
|
|
207
|
-
selfId = options?.activeOption?.uin as string
|
|
208
|
-
contact = karin.contactGroup(options?.activeOption?.group_id as string)
|
|
209
|
-
} else {
|
|
210
|
-
selfId = event.selfId
|
|
211
|
-
contact = event.contact
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// 判断是否需要压缩后再上传
|
|
215
|
-
if (Config.app.compress && file.totalBytes > Config.app.compresstrigger) {
|
|
216
|
-
const Duration = await getMediaDuration(file.filepath)
|
|
217
|
-
logger.warn(
|
|
218
|
-
logger.yellow(
|
|
219
|
-
`视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.app.compresstrigger} MB),正在进行压缩至${Config.app.compressvalue} MB...`
|
|
220
|
-
)
|
|
221
|
-
)
|
|
222
|
-
const message = [
|
|
223
|
-
segment.text(
|
|
224
|
-
`视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.app.compresstrigger} MB),正在进行压缩至${Config.app.compressvalue} MB...`
|
|
225
|
-
),
|
|
226
|
-
options?.message_id ? segment.reply(options.message_id) : segment.text('')
|
|
227
|
-
]
|
|
228
|
-
|
|
229
|
-
const msg1 = await karin.sendMsg(selfId, contact, message)
|
|
230
|
-
// 计算目标视频平均码率
|
|
231
|
-
const targetBitrate = Common.calculateBitrate(Config.app.compresstrigger, Duration) * 0.75
|
|
232
|
-
// 执行压缩
|
|
233
|
-
const startTime = Date.now()
|
|
234
|
-
const outputPath = `${Common.tempDri.video}tmp_${Date.now()}.mp4`
|
|
235
|
-
await compressVideo({ inputPath: file.filepath, outputPath, targetBitrate })
|
|
236
|
-
file.filepath = outputPath
|
|
237
|
-
const endTime = Date.now()
|
|
238
|
-
// 再次检查大小
|
|
239
|
-
newFileSize = await Common.getVideoFileSize(file.filepath)
|
|
240
|
-
logger.debug(
|
|
241
|
-
`原始视频大小为: ${file.totalBytes.toFixed(1)} MB, ${logger.green(`经 FFmpeg 压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,原视频文件已删除`)}`
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
const message2 = [
|
|
245
|
-
segment.text(`压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,压缩耗时:${((endTime - startTime) / 1000).toFixed(1)} 秒`),
|
|
246
|
-
segment.reply(msg1.messageId)
|
|
247
|
-
]
|
|
248
|
-
await karin.sendMsg(selfId, contact, message2)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
*
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
*
|
|
389
|
-
*/
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
//
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
//
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import karin, { type Contact, logger, Message, segment } from 'node-karin'
|
|
4
|
+
import type { AxiosHeaders, AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'node-karin/axios'
|
|
5
|
+
|
|
6
|
+
import { baseHeaders, Common, compressVideo, extractTotalBytesFromHeaders, getMediaDuration, Networks } from '@/module/utils'
|
|
7
|
+
import { Config } from '@/module/utils/Config'
|
|
8
|
+
// 撤回上一条面板 + 「发送中…」提示(见 QqPanel 的 recallLastPanel)
|
|
9
|
+
import { recallLastPanel } from '@/module/utils/QqPanel'
|
|
10
|
+
import type { pushlistConfig } from '@/types/config/pushlist'
|
|
11
|
+
|
|
12
|
+
import { AmagiBase } from './amagiClient'
|
|
13
|
+
|
|
14
|
+
type uploadFileOptions = {
|
|
15
|
+
/** 是否使用群文件上传 */
|
|
16
|
+
useGroupFile?: boolean
|
|
17
|
+
/** 消息ID,如果有,则将使用该消息ID制作回复元素 */
|
|
18
|
+
message_id?: string
|
|
19
|
+
/** 是否为主动消息 */
|
|
20
|
+
active?: boolean
|
|
21
|
+
/** 主动消息参数 */
|
|
22
|
+
activeOption?: {
|
|
23
|
+
/** 机器人账号 */
|
|
24
|
+
uin: string
|
|
25
|
+
/** 群号 */
|
|
26
|
+
group_id: string
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 最少都要传一个 */
|
|
31
|
+
type title = {
|
|
32
|
+
/** 文件名:自定义 */
|
|
33
|
+
originTitle?: string
|
|
34
|
+
/** 文件名:tmp + 时间戳 */
|
|
35
|
+
timestampTitle?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type downloadFileOptions = {
|
|
39
|
+
/** 视频链接 */
|
|
40
|
+
video_url: string
|
|
41
|
+
/** 文件名 */
|
|
42
|
+
title: title
|
|
43
|
+
/** 下载文件类型,默认为'.mp4'。 */
|
|
44
|
+
filetype?: string
|
|
45
|
+
/** 自定义请求头,将使用该请求头下载文件。 */
|
|
46
|
+
headers?: object
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type fileInfo = {
|
|
50
|
+
/** 视频文件的绝对路径 */
|
|
51
|
+
filepath: string
|
|
52
|
+
/** 视频文件大小 */
|
|
53
|
+
totalBytes: number
|
|
54
|
+
/** 文件名:自定义 */
|
|
55
|
+
originTitle?: title['originTitle']
|
|
56
|
+
/** 文件名:tmp + 时间戳 */
|
|
57
|
+
timestampTitle?: title['timestampTitle']
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 表示HTTP请求方法的请求头类型
|
|
62
|
+
* @remarks
|
|
63
|
+
* 这是一个部分类型,将HTTP方法映射到对应的AxiosHeaders类型
|
|
64
|
+
* @property [method] - 每个HTTP方法(小写)对应的请求头
|
|
65
|
+
* @property common - 通用请求头
|
|
66
|
+
*/
|
|
67
|
+
type MethodsHeaders = Partial<
|
|
68
|
+
{
|
|
69
|
+
[Key in Method as Lowercase<Key>]: AxiosHeaders
|
|
70
|
+
} & { common: AxiosHeaders }
|
|
71
|
+
>
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 下载文件的配置选项接口
|
|
75
|
+
* @interface
|
|
76
|
+
* @property title - 文件名
|
|
77
|
+
* @property [headers] - 用于下载文件的请求头
|
|
78
|
+
* @defaultValue headers - {}
|
|
79
|
+
*/
|
|
80
|
+
export type downLoadFileOptions = {
|
|
81
|
+
/** 文件名 */
|
|
82
|
+
title: string
|
|
83
|
+
/**
|
|
84
|
+
* 将使用该请求头下载文件。
|
|
85
|
+
* @default {}
|
|
86
|
+
*/
|
|
87
|
+
headers?: (RawAxiosRequestHeaders & MethodsHeaders) | AxiosHeaders
|
|
88
|
+
/** 文件保存路径 */
|
|
89
|
+
filepath?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 基础基类
|
|
94
|
+
* @remarks 提供事件上下文与通用请求头,继承自 `AmagiBase` 以复用 Amagi 客户端。
|
|
95
|
+
*/
|
|
96
|
+
export class Base extends AmagiBase {
|
|
97
|
+
/** 事件对象 */
|
|
98
|
+
e: Message
|
|
99
|
+
/** 请求头 */
|
|
100
|
+
headers: AxiosRequestConfig['headers']
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 构造函数:初始化事件与请求头
|
|
104
|
+
* @param e 消息事件对象
|
|
105
|
+
*/
|
|
106
|
+
constructor(e: Message) {
|
|
107
|
+
super()
|
|
108
|
+
this.e = e
|
|
109
|
+
this.headers = baseHeaders
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 统计每个平台使用最多的机器人ID和使用次数 */
|
|
114
|
+
type PlatformBotStats = {
|
|
115
|
+
/** 机器人ID */
|
|
116
|
+
botId: string
|
|
117
|
+
/** 使用次数 */
|
|
118
|
+
count: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 统计每个平台使用最多的机器人ID和使用次数
|
|
123
|
+
* @param pushList - 推送列表配置
|
|
124
|
+
* @returns
|
|
125
|
+
*/
|
|
126
|
+
export const statBotId = (pushList: pushlistConfig): { douyin: PlatformBotStats; bilibili: PlatformBotStats } => {
|
|
127
|
+
const platformBotCount = {
|
|
128
|
+
douyin: new Map<string, number>(),
|
|
129
|
+
bilibili: new Map<string, number>()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 统计抖音平台机器人使用次数
|
|
133
|
+
pushList.douyin.forEach((item) => {
|
|
134
|
+
item.group_id.forEach((gid) => {
|
|
135
|
+
const botId = gid.split(':')[1]
|
|
136
|
+
platformBotCount.douyin.set(botId, (platformBotCount.douyin.get(botId) ?? 0) + 1)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// 统计B站平台机器人使用次数
|
|
141
|
+
pushList.bilibili.forEach((item) => {
|
|
142
|
+
item.group_id.forEach((gid) => {
|
|
143
|
+
const botId = gid.split(':')[1]
|
|
144
|
+
platformBotCount.bilibili.set(botId, (platformBotCount.bilibili.get(botId) ?? 0) + 1)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// 获取抖音平台使用最多的机器人
|
|
149
|
+
let douyinMaxCount = 0
|
|
150
|
+
let douyinMostFrequentBot = ''
|
|
151
|
+
platformBotCount.douyin.forEach((count, botId) => {
|
|
152
|
+
if (count > douyinMaxCount) {
|
|
153
|
+
douyinMaxCount = count
|
|
154
|
+
douyinMostFrequentBot = botId
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// 获取B站平台使用最多的机器人
|
|
159
|
+
let biliMaxCount = 0
|
|
160
|
+
let biliMostFrequentBot = ''
|
|
161
|
+
platformBotCount.bilibili.forEach((count, botId) => {
|
|
162
|
+
if (count > biliMaxCount) {
|
|
163
|
+
biliMaxCount = count
|
|
164
|
+
biliMostFrequentBot = botId
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
douyin: {
|
|
170
|
+
botId: douyinMostFrequentBot,
|
|
171
|
+
count: douyinMaxCount
|
|
172
|
+
},
|
|
173
|
+
bilibili: {
|
|
174
|
+
botId: biliMostFrequentBot,
|
|
175
|
+
count: biliMaxCount
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 过万整除 */
|
|
181
|
+
export const Count = (count: number): string => {
|
|
182
|
+
if (count >= 100000000) {
|
|
183
|
+
return (count / 100000000).toFixed(1) + '亿'
|
|
184
|
+
} else if (count >= 10000) {
|
|
185
|
+
return (count / 10000).toFixed(1) + '万'
|
|
186
|
+
} else {
|
|
187
|
+
return count?.toString() ?? '无法获取'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 上传视频文件
|
|
193
|
+
* @param event - 消息事件
|
|
194
|
+
* @param file - 包含本地视频文件信息的对象。
|
|
195
|
+
* @param videoUrl 视频直链,无则传空字符串
|
|
196
|
+
* @param options 上传参数
|
|
197
|
+
* @returns
|
|
198
|
+
*/
|
|
199
|
+
export const uploadFile = async (event: Message, file: fileInfo, videoUrl: string, options?: uploadFileOptions): Promise<boolean> => {
|
|
200
|
+
let sendStatus: boolean = true
|
|
201
|
+
let File: string
|
|
202
|
+
let newFileSize = file.totalBytes
|
|
203
|
+
let selfId: string
|
|
204
|
+
let contact: Contact
|
|
205
|
+
|
|
206
|
+
if (options?.active) {
|
|
207
|
+
selfId = options?.activeOption?.uin as string
|
|
208
|
+
contact = karin.contactGroup(options?.activeOption?.group_id as string)
|
|
209
|
+
} else {
|
|
210
|
+
selfId = event.selfId
|
|
211
|
+
contact = event.contact
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 判断是否需要压缩后再上传
|
|
215
|
+
if (Config.app.compress && file.totalBytes > Config.app.compresstrigger) {
|
|
216
|
+
const Duration = await getMediaDuration(file.filepath)
|
|
217
|
+
logger.warn(
|
|
218
|
+
logger.yellow(
|
|
219
|
+
`视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.app.compresstrigger} MB),正在进行压缩至${Config.app.compressvalue} MB...`
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
const message = [
|
|
223
|
+
segment.text(
|
|
224
|
+
`视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.app.compresstrigger} MB),正在进行压缩至${Config.app.compressvalue} MB...`
|
|
225
|
+
),
|
|
226
|
+
options?.message_id ? segment.reply(options.message_id) : segment.text('')
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
const msg1 = await karin.sendMsg(selfId, contact, message)
|
|
230
|
+
// 计算目标视频平均码率
|
|
231
|
+
const targetBitrate = Common.calculateBitrate(Config.app.compresstrigger, Duration) * 0.75
|
|
232
|
+
// 执行压缩
|
|
233
|
+
const startTime = Date.now()
|
|
234
|
+
const outputPath = `${Common.tempDri.video}tmp_${Date.now()}.mp4`
|
|
235
|
+
await compressVideo({ inputPath: file.filepath, outputPath, targetBitrate })
|
|
236
|
+
file.filepath = outputPath
|
|
237
|
+
const endTime = Date.now()
|
|
238
|
+
// 再次检查大小
|
|
239
|
+
newFileSize = await Common.getVideoFileSize(file.filepath)
|
|
240
|
+
logger.debug(
|
|
241
|
+
`原始视频大小为: ${file.totalBytes.toFixed(1)} MB, ${logger.green(`经 FFmpeg 压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,原视频文件已删除`)}`
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const message2 = [
|
|
245
|
+
segment.text(`压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,压缩耗时:${((endTime - startTime) / 1000).toFixed(1)} 秒`),
|
|
246
|
+
segment.reply(msg1.messageId)
|
|
247
|
+
]
|
|
248
|
+
await karin.sendMsg(selfId, contact, message2)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 判断是否使用群文件上传。
|
|
253
|
+
*
|
|
254
|
+
* QQ 单独一套阈值(配置项 `qqGroupFileLimitMB`,默认 30):超过就按群文件发。
|
|
255
|
+
* 原因是 QQ 对富媒体视频会压缩、改名甚至丢掉后缀,几十 MB 的视频走「视频」通道基本没法看;
|
|
256
|
+
* 其它平台仍旧按上游的「群文件上传」开关 + `groupfilevalue` 走。
|
|
257
|
+
*/
|
|
258
|
+
const platform = String((event as any)?.platform ?? (event as any)?.bot?.platform ?? '')
|
|
259
|
+
const qqLimit = Number((Config as any).qqGroupFileLimitMB ?? 30)
|
|
260
|
+
const useGroupFile = /qq/i.test(platform) && qqLimit > 0
|
|
261
|
+
? newFileSize > qqLimit
|
|
262
|
+
: Config.app.usegroupfile && newFileSize > Config.app.groupfilevalue
|
|
263
|
+
if (options) options.useGroupFile = useGroupFile
|
|
264
|
+
|
|
265
|
+
if (Config.app.videoSendMode === 'base64' && !useGroupFile) {
|
|
266
|
+
const videoBuffer = fs.readFileSync(file.filepath)
|
|
267
|
+
File = `base64://${videoBuffer.toString('base64')}`
|
|
268
|
+
logger.mark(`已开启视频文件 base64转换 正在进行${logger.yellow('base64转换中')}...`)
|
|
269
|
+
} else File = useGroupFile ? file.filepath : `file://${file.filepath}`
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 发送前:撤掉上一条「收到请求,开始下载」,并回一句「发送中…」。
|
|
273
|
+
* 大文件上传(几十 MB 走文件通道)要等好几秒,没有任何反馈的话用户会以为机器人卡住了。
|
|
274
|
+
*
|
|
275
|
+
* 超过 QQ 富媒体限制、要走群文件的那一档,在这里说清楚(表格里不再做文件标记)。
|
|
276
|
+
*/
|
|
277
|
+
let sendingTipId: string | undefined
|
|
278
|
+
try {
|
|
279
|
+
if (!options?.active) {
|
|
280
|
+
await recallLastPanel(event)
|
|
281
|
+
const tipText = useGroupFile
|
|
282
|
+
? '发送中…(超过 QQ 限制,将以群文件发送)'
|
|
283
|
+
: '发送中…'
|
|
284
|
+
const tip: any = await event.reply(tipText)
|
|
285
|
+
sendingTipId = tip?.messageId
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
logger.debug('发送中提示失败: ' + String(error))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// 是主动消息
|
|
293
|
+
if (options?.active) {
|
|
294
|
+
if (useGroupFile) {
|
|
295
|
+
// 是群文件
|
|
296
|
+
const bot = karin.getBot(String(options.activeOption?.uin))!
|
|
297
|
+
logger.mark(`${logger.blue('主动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('bot.uploadFile')}回复...`)
|
|
298
|
+
await bot.uploadFile(contact, File, file.originTitle ? `${file.originTitle}.mp4` : `${File.split('/').pop()}`)
|
|
299
|
+
} else {
|
|
300
|
+
// 不是群文件
|
|
301
|
+
logger.mark(`${logger.blue('主动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('karin.sendMsg')}回复...`)
|
|
302
|
+
const status = await karin.sendMsg(selfId, contact, [segment.video(File)], { retryCount: 0 })
|
|
303
|
+
sendStatus = status.messageId ? true : false
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
// 不是主动消息
|
|
307
|
+
if (useGroupFile) {
|
|
308
|
+
// 是文件
|
|
309
|
+
logger.mark(`${logger.blue('被动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('e.bot.uploadFile')}回复...`)
|
|
310
|
+
await event.bot.uploadFile(event.contact, File, file.originTitle ? `${file.originTitle}.mp4` : `${File.split('/').pop()}`)
|
|
311
|
+
} else {
|
|
312
|
+
// 不是文件
|
|
313
|
+
logger.mark(`${logger.blue('被动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('e.reply')}回复...`)
|
|
314
|
+
const status = await event.reply(segment.video(File) || videoUrl)
|
|
315
|
+
sendStatus = status.messageId ? true : false
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// 发成功了就把「发送中…」收掉,群里只留下真正的视频
|
|
319
|
+
if (sendingTipId) {
|
|
320
|
+
try {
|
|
321
|
+
await (event.bot as any)?.recallMsg?.(sendingTipId, String(event.contact?.peer ?? event.channelId ?? ''))
|
|
322
|
+
} catch { /* 撤回失败无所谓 */ }
|
|
323
|
+
}
|
|
324
|
+
return sendStatus
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (options && options.active === false) {
|
|
327
|
+
await event.reply('视频文件上传失败' + JSON.stringify(error, null, 2))
|
|
328
|
+
}
|
|
329
|
+
logger.error('视频文件上传错误,' + String(error))
|
|
330
|
+
throw error // 重新抛出错误,让 wrapWithErrorHandler 能够捕获
|
|
331
|
+
} finally {
|
|
332
|
+
const filePath = file.filepath
|
|
333
|
+
Common.registerVideoPreview(filePath, Config.app.removeCache, 30 * 60 * 1000)
|
|
334
|
+
logger.mark(
|
|
335
|
+
`临时预览地址:http://localhost:${process.env.HTTP_PORT!}/kkk/ssr/video/${encodeURIComponent(filePath.split('/').pop() ?? '')}`
|
|
336
|
+
)
|
|
337
|
+
if (Config.app.removeCache) {
|
|
338
|
+
logger.info(`文件 ${filePath} 将在 30 分钟后删除`)
|
|
339
|
+
}
|
|
340
|
+
setTimeout(
|
|
341
|
+
async () => {
|
|
342
|
+
const removed = await Common.removeFile(filePath)
|
|
343
|
+
if (removed) {
|
|
344
|
+
Common.markVideoPreviewRemoved(filePath)
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
30 * 60 * 1000
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 下载视频并上传到群
|
|
354
|
+
* @param event 事件
|
|
355
|
+
* @param downloadOpt 下载参数
|
|
356
|
+
* @param uploadOpt 上传参数
|
|
357
|
+
* @returns
|
|
358
|
+
*/
|
|
359
|
+
export const downloadVideo = async (event: Message, downloadOpt: downloadFileOptions, uploadOpt?: uploadFileOptions): Promise<boolean> => {
|
|
360
|
+
/** 获取文件大小 */
|
|
361
|
+
const fileHeaders = await new Networks({
|
|
362
|
+
url: downloadOpt.video_url,
|
|
363
|
+
headers: downloadOpt.headers ?? baseHeaders
|
|
364
|
+
}).getHeaders()
|
|
365
|
+
const fileSizeContent = extractTotalBytesFromHeaders(fileHeaders)
|
|
366
|
+
const fileSizeInMB = (fileSizeContent / (1024 * 1024)).toFixed(2)
|
|
367
|
+
const fileSize = parseInt(parseFloat(fileSizeInMB).toFixed(2))
|
|
368
|
+
if (fileSizeContent > 0 && Config.app.usefilelimit && fileSize > Config.app.filelimit) {
|
|
369
|
+
const message = segment.text(
|
|
370
|
+
`视频:「${
|
|
371
|
+
downloadOpt.title.originTitle ?? 'Error: 文件名获取失败'
|
|
372
|
+
}」大小 (${fileSizeInMB} MB) 超出最大限制(设定值:${Config.app.filelimit} MB),已取消上传`
|
|
373
|
+
)
|
|
374
|
+
const selfId = event.selfId || (uploadOpt?.activeOption?.uin as string)
|
|
375
|
+
const contact = event.contact || karin.contactGroup(uploadOpt?.activeOption?.group_id as string) || karin.contactFriend(selfId)
|
|
376
|
+
|
|
377
|
+
await karin.sendMsg(selfId, contact, message)
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 下载文件,视频URL,标题和自定义headers
|
|
382
|
+
let res = await downloadFile(downloadOpt.video_url, {
|
|
383
|
+
title: Config.app.removeCache ? (downloadOpt.title.timestampTitle as string) : processFilename(downloadOpt.title.originTitle!, 50),
|
|
384
|
+
headers: downloadOpt.headers ?? baseHeaders
|
|
385
|
+
})
|
|
386
|
+
res = { ...res, ...downloadOpt.title }
|
|
387
|
+
// 将下载的文件大小转换为MB并保留两位小数
|
|
388
|
+
res.totalBytes = Number((res.totalBytes / (1024 * 1024)).toFixed(2))
|
|
389
|
+
/** 上传视频 */
|
|
390
|
+
return await uploadFile(event, res, downloadOpt.video_url, uploadOpt)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 异步下载文件的函数。
|
|
395
|
+
* @param videoUrl 下载地址。
|
|
396
|
+
* @param opt 配置选项,包括标题、请求头等。
|
|
397
|
+
* @returns 返回一个包含文件路径和总字节数的对象。
|
|
398
|
+
*/
|
|
399
|
+
export const downloadFile = async (videoUrl: string, opt: downLoadFileOptions): Promise<fileInfo> => {
|
|
400
|
+
// 记录开始时间
|
|
401
|
+
const startTime = Date.now()
|
|
402
|
+
|
|
403
|
+
// 从配置中读取限速设置
|
|
404
|
+
const uploadConfig = Config.app
|
|
405
|
+
const throttleConfig = {
|
|
406
|
+
enabled: uploadConfig.downloadThrottle ?? false,
|
|
407
|
+
maxSpeed: (uploadConfig.downloadMaxSpeed ?? 10) * 1024 * 1024, // MB/s -> bytes/s
|
|
408
|
+
autoReduceRatio: uploadConfig.downloadAutoReduce ? 0.6 : 1, // 降速比例
|
|
409
|
+
minSpeed: (uploadConfig.downloadMinSpeed ?? 1) * 1024 * 1024 // MB/s -> bytes/s
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
// 使用 networks 类进行文件下载,并通过回调函数实时更新下载进度
|
|
414
|
+
const { filepath, totalBytes } = await new Networks({
|
|
415
|
+
url: videoUrl,
|
|
416
|
+
headers: opt.headers ?? baseHeaders,
|
|
417
|
+
filepath: opt.filepath ?? Common.tempDri.video + opt.title,
|
|
418
|
+
timeout: 60000, // 增加超时时间
|
|
419
|
+
maxRetries: 3, // 增加重试次数
|
|
420
|
+
throttle: throttleConfig
|
|
421
|
+
}).downloadStream((downloadedBytes, totalBytes) => {
|
|
422
|
+
// 定义进度条长度及生成进度条字符串的函数
|
|
423
|
+
const barLength = 45
|
|
424
|
+
const generateProgressBar = (progressPercentage: number) => {
|
|
425
|
+
const clampedPercentage = Math.min(100, Math.max(0, progressPercentage))
|
|
426
|
+
const filledLength = Math.floor((clampedPercentage / 100) * barLength)
|
|
427
|
+
const emptyLength = Math.max(0, barLength - filledLength)
|
|
428
|
+
return `[${'\u2588'.repeat(filledLength)}${'\u2591'.repeat(emptyLength)}]`
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 计算当前下载进度百分比
|
|
432
|
+
const progressPercentage = totalBytes > 0 ? Math.min(100, (downloadedBytes / totalBytes) * 100) : 0
|
|
433
|
+
|
|
434
|
+
// 计算动态 RGB 颜色
|
|
435
|
+
const red = Math.floor(255 - (255 * progressPercentage) / 100) // 红色分量随进度减少
|
|
436
|
+
const coloredPercentage = logger.chalk.rgb(red, 255, 0)(`${progressPercentage.toFixed(1)}%`)
|
|
437
|
+
|
|
438
|
+
// 计算下载速度(MB/s)
|
|
439
|
+
const elapsedTime = (Date.now() - startTime) / 1000
|
|
440
|
+
const speed = downloadedBytes / elapsedTime
|
|
441
|
+
const formattedSpeed = (speed / 1048576).toFixed(1) + ' MB/s'
|
|
442
|
+
|
|
443
|
+
// 计算剩余时间
|
|
444
|
+
const remainingBytes = totalBytes - downloadedBytes // 剩余字节数
|
|
445
|
+
const remainingTime = remainingBytes / speed // 剩余时间(秒)
|
|
446
|
+
const formattedRemainingTime =
|
|
447
|
+
remainingTime > 60 ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s` : `${remainingTime.toFixed(0)}s`
|
|
448
|
+
|
|
449
|
+
// 计算已下载和总下载的文件大小(MB)
|
|
450
|
+
const downloadedSizeMB = (downloadedBytes / 1048576).toFixed(1)
|
|
451
|
+
const totalSizeMB = (totalBytes / 1048576).toFixed(1)
|
|
452
|
+
|
|
453
|
+
// 打印下载进度、速度和剩余时间
|
|
454
|
+
console.log(
|
|
455
|
+
`⬇️ ${opt.title ?? (opt.filepath && opt.filepath.split('/').pop()) ?? '未知文件'} ${generateProgressBar(progressPercentage)} ${coloredPercentage} ${downloadedSizeMB}/${totalSizeMB} MB | ${formattedSpeed} 剩余: ${formattedRemainingTime}\r`
|
|
456
|
+
)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
return { filepath, totalBytes }
|
|
460
|
+
} catch (error) {
|
|
461
|
+
// 检查是否为网络环境变化或服务器风控导致的错误
|
|
462
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
463
|
+
const isNetworkChangeError = /ECONNRESET|ETIMEDOUT|ECONNABORTED|aborted|timeout|network|连接被重置|连接超时|连接中止/i.test(
|
|
464
|
+
errorMessage
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if (isNetworkChangeError) {
|
|
468
|
+
logger.error('下载失败,可能是由于网络环境变化(如代理切换、VPN切换)或服务器风控导致')
|
|
469
|
+
logger.error(`文件: ${opt.title}`)
|
|
470
|
+
logger.error(`错误详情: ${errorMessage}`)
|
|
471
|
+
|
|
472
|
+
if (!uploadConfig.downloadThrottle) {
|
|
473
|
+
logger.error('提示: 如果频繁出现此错误,建议在配置中开启「下载限速」功能')
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
throw error
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 处理文件名长度,保留文件扩展名
|
|
483
|
+
* @param filename 原始文件名
|
|
484
|
+
* @param maxLength 最大长度(不包括扩展名)
|
|
485
|
+
* @returns 处理后的文件名
|
|
486
|
+
*/
|
|
487
|
+
const processFilename = (filename: string, maxLength: number = 50): string => {
|
|
488
|
+
// 提取文件扩展名
|
|
489
|
+
const lastDotIndex = filename.lastIndexOf('.')
|
|
490
|
+
const hasExtension = lastDotIndex > 0 && lastDotIndex < filename.length - 1
|
|
491
|
+
|
|
492
|
+
if (!hasExtension) {
|
|
493
|
+
// 没有扩展名,直接截取并清理特殊字符
|
|
494
|
+
return filename.substring(0, maxLength).replace(/[\\/:*?"<>|\r\n\s]/g, ' ')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 分离文件名主体和扩展名
|
|
498
|
+
const nameWithoutExt = filename.substring(0, lastDotIndex)
|
|
499
|
+
const extension = filename.substring(lastDotIndex)
|
|
500
|
+
|
|
501
|
+
// 对文件名主体进行长度限制和特殊字符清理
|
|
502
|
+
const processedName = nameWithoutExt.substring(0, maxLength).replace(/[\\/:*?"<>|\r\n\s]/g, ' ')
|
|
503
|
+
|
|
504
|
+
// 重新拼接文件名和扩展名
|
|
505
|
+
return processedName + '...' + extension
|
|
506
|
+
}
|
package/src/qqFields.json
CHANGED
|
@@ -71,5 +71,15 @@
|
|
|
71
71
|
"label": "卡片文字识别密钥",
|
|
72
72
|
"secret": true,
|
|
73
73
|
"description": "群里转发过来的分享卡片没有链接,只能靠截图识别出标题和作者再去搜索。填自己的免费密钥更稳定:https://ocr.space/ocrapi 。留空会用公共测试密钥,容易被限流。"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"key": "qqGroupFileLimitMB",
|
|
77
|
+
"group": "视频发送",
|
|
78
|
+
"type": "number",
|
|
79
|
+
"default": 30,
|
|
80
|
+
"min": 0,
|
|
81
|
+
"max": 2000,
|
|
82
|
+
"label": "群文件发送阈值(MB)",
|
|
83
|
+
"description": "视频体积超过这个值时,改用「群文件」发送。QQ 对富媒体视频会压缩、改名甚至丢掉后缀,走群文件才能原样发出去;填 0 表示不启用。"
|
|
74
84
|
}
|
|
75
85
|
]
|
package/src/webui.ts
CHANGED
|
@@ -414,16 +414,10 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
414
414
|
const pageDenied = async (response: any): Promise<boolean> => {
|
|
415
415
|
if (!authRequired()) return false
|
|
416
416
|
if (await consoleAuthed(response)) return false
|
|
417
|
-
|
|
418
|
-
response.
|
|
419
|
-
response.
|
|
420
|
-
|
|
421
|
-
'<div style="font-family:system-ui;padding:40px;line-height:1.8">',
|
|
422
|
-
'<h2>需要先登录 Koishi 控制台</h2>',
|
|
423
|
-
'<p>配置面板已开启登录校验(插件配置里的 <code>webUiAuth</code>)。</p>',
|
|
424
|
-
'<p><a href="/">去登录控制台</a>,登录后再回到本页面。</p>',
|
|
425
|
-
'</div>',
|
|
426
|
-
].join('')
|
|
417
|
+
// 直接当成「没有这个页面」:面板只从控制台侧边栏进,独立 URL 不给任何提示
|
|
418
|
+
response.status = 404
|
|
419
|
+
response.type = 'text/plain; charset=utf-8'
|
|
420
|
+
response.body = 'Not Found'
|
|
427
421
|
return true
|
|
428
422
|
}
|
|
429
423
|
|
|
@@ -482,7 +476,8 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
482
476
|
|
|
483
477
|
/* ---------------- 简易编辑页(改插件配置) ---------------- */
|
|
484
478
|
|
|
485
|
-
server.get('/kkk/edit', (response: any) => {
|
|
479
|
+
server.get('/kkk/edit', async (response: any) => {
|
|
480
|
+
if (await pageDenied(response)) return
|
|
486
481
|
let body: string
|
|
487
482
|
try {
|
|
488
483
|
body = fs.readFileSync(path.join(pluginRoot, 'assets', 'webui.html'), 'utf-8')
|