import fs from 'node:fs' import karin, { type Contact, logger, Message, segment, withoutForwardCollect } from 'node-karin' import type { AxiosHeaders, AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'node-karin/axios' import { baseHeaders, Common, compressVideo, extractTotalBytesFromHeaders, getMediaDuration, Networks } from '@/module/utils' import { Config } from '@/module/utils/Config' // 撤回上一条面板 + 「发送中…」提示(见 QqPanel 的 recallLastPanel) import { recallLastPanel } from '@/module/utils/QqPanel' // 错误描述:适配器的 AggregateError 的 message 是空的,真正原因在 errors[] 里(见 describeError) import { describeError } from '@/module/utils/ParseSteps' import type { pushlistConfig } from '@/types/config/pushlist' import { AmagiBase } from './amagiClient' // 群文件阈值来自 Koishi 侧配置:Config 是上游 config.json 的 Proxy, // 取不到 qqGroupFileLimitMB 时会返回 {},Number({}) 就是 NaN —— 阈值失效的元凶 import { tryGetRuntime } from '../../../compat/runtime' // 超限转在线播放 / 在线播放不受 QQ 体积上限约束(见 src/player) import { effectivePlayerSizeLimitMB, isOnlinePlayerRequest, markOnlinePlayerOverride, shouldRedirectOversizeToPlayer, withinPlayerSizeLimit } from '../../../player' // 解析阶段(「下载进度」指令读的就是这里登记的状态) import { DOWNLOAD_STAGES, clearParseStage, updateDownloadStage } from './Network/Downloader' // 「没拿到消息 ID」这种失败不能重试(详见 sendGroupFile) import { isUnconfirmedSendError } from '../../../compat/sendError' /** 发送结果里把消息 ID 抠出来(不同适配器返回的形状不一样:对象带 messageId、或者就是字符串数组) */ const reportSent = (options: uploadFileOptions | undefined, sent: any): void => { if (!options?.onSent) return try { const id = typeof sent === 'string' ? sent : (sent?.messageId ?? (Array.isArray(sent) ? sent[0] : '')) if (id) options.onSent(String(id)) } catch { /* 拿不到 ID 不影响发送本身 */ } } type uploadFileOptions = { /** 是否使用群文件上传 */ useGroupFile?: boolean /** 消息ID,如果有,则将使用该消息ID制作回复元素 */ message_id?: string /** * 发出去了把消息 ID 交回来(可选)。 * * 互动视频用它:下一段播出来之后要**撤回上一段那条视频**,群里只留当前这一段 * (不然点几次剧情,群里就躺着一串视频)。 */ onSent?: (messageId: string) => void /** 是否为主动消息 */ active?: boolean /** 主动消息参数 */ activeOption?: { /** 机器人账号 */ uin: string /** 群号 */ group_id: string } } /** 最少都要传一个 */ type title = { /** 文件名:自定义 */ originTitle?: string /** 文件名:tmp + 时间戳 */ timestampTitle?: string } type downloadFileOptions = { /** 视频链接 */ video_url: string /** 文件名 */ title: title /** 下载文件类型,默认为'.mp4'。 */ filetype?: string /** 自定义请求头,将使用该请求头下载文件。 */ headers?: object } export type fileInfo = { /** 视频文件的绝对路径 */ filepath: string /** 视频文件大小 */ totalBytes: number /** 文件名:自定义 */ originTitle?: title['originTitle'] /** 文件名:tmp + 时间戳 */ timestampTitle?: title['timestampTitle'] } /** * 表示HTTP请求方法的请求头类型 * @remarks * 这是一个部分类型,将HTTP方法映射到对应的AxiosHeaders类型 * @property [method] - 每个HTTP方法(小写)对应的请求头 * @property common - 通用请求头 */ type MethodsHeaders = Partial< { [Key in Method as Lowercase]: AxiosHeaders } & { common: AxiosHeaders } > /** * 下载文件的配置选项接口 * @interface * @property title - 文件名 * @property [headers] - 用于下载文件的请求头 * @defaultValue headers - {} */ export type downLoadFileOptions = { /** 文件名 */ title: string /** * 将使用该请求头下载文件。 * @default {} */ headers?: (RawAxiosRequestHeaders & MethodsHeaders) | AxiosHeaders /** 文件保存路径 */ filepath?: string /** * 备用直链(按顺序尝试)。 * * B站 dash 的 `backup_url` 就是干这个用的:`base_url` 常常指向 * `*.mcdn.bilivideo.cn` 这类 PCDN 节点,连得上却不吐数据; * 把镜像地址带上,下载器就能在坏源上自动换过去。 */ backupUrls?: string[] /** * 进度回调(可选)。 * * 在线播放页的互动视频「点一段 → 显示加载进度」就靠它:播放页没法读终端的进度条, * 只能把这个数字通过接口转出去。 */ onProgress?: (bytes: number, total: number) => void } /** * 基础基类 * @remarks 提供事件上下文与通用请求头,继承自 `AmagiBase` 以复用 Amagi 客户端。 */ export class Base extends AmagiBase { /** 事件对象 */ e: Message /** 请求头 */ headers: AxiosRequestConfig['headers'] /** * 构造函数:初始化事件与请求头 * @param e 消息事件对象 */ constructor(e: Message) { super() this.e = e this.headers = baseHeaders } } /** 统计每个平台使用最多的机器人ID和使用次数 */ type PlatformBotStats = { /** 机器人ID */ botId: string /** 使用次数 */ count: number } /** * 统计每个平台使用最多的机器人ID和使用次数 * @param pushList - 推送列表配置 * @returns */ export const statBotId = (pushList: pushlistConfig): { douyin: PlatformBotStats; bilibili: PlatformBotStats } => { const platformBotCount = { douyin: new Map(), bilibili: new Map() } // 统计抖音平台机器人使用次数 pushList.douyin.forEach((item) => { item.group_id.forEach((gid) => { const botId = gid.split(':')[1] platformBotCount.douyin.set(botId, (platformBotCount.douyin.get(botId) ?? 0) + 1) }) }) // 统计B站平台机器人使用次数 pushList.bilibili.forEach((item) => { item.group_id.forEach((gid) => { const botId = gid.split(':')[1] platformBotCount.bilibili.set(botId, (platformBotCount.bilibili.get(botId) ?? 0) + 1) }) }) // 获取抖音平台使用最多的机器人 let douyinMaxCount = 0 let douyinMostFrequentBot = '' platformBotCount.douyin.forEach((count, botId) => { if (count > douyinMaxCount) { douyinMaxCount = count douyinMostFrequentBot = botId } }) // 获取B站平台使用最多的机器人 let biliMaxCount = 0 let biliMostFrequentBot = '' platformBotCount.bilibili.forEach((count, botId) => { if (count > biliMaxCount) { biliMaxCount = count biliMostFrequentBot = botId } }) return { douyin: { botId: douyinMostFrequentBot, count: douyinMaxCount }, bilibili: { botId: biliMostFrequentBot, count: biliMaxCount } } } /** 过万整除 */ export const Count = (count: number): string => { if (count >= 100000000) { return (count / 100000000).toFixed(1) + '亿' } else if (count >= 10000) { return (count / 10000).toFixed(1) + '万' } else { return count?.toString() ?? '无法获取' } } /** * 上传视频文件 * @param event - 消息事件 * @param file - 包含本地视频文件信息的对象。 * @param videoUrl 视频直链,无则传空字符串 * @param options 上传参数 * @returns */ export const uploadFile = async (event: Message, file: fileInfo, videoUrl: string, options?: uploadFileOptions): Promise => { /** * 上传阶段:压缩、base64、真正推给平台都算「正在发送」。 * 以前这里没有任何登记,用户点「下载进度」只能看到「当前没有正在进行的下载」, * 而实际上机器人正在上传几十 MB 的文件。 */ updateDownloadStage(DOWNLOAD_STAGES.sending) let sendStatus: boolean = true let File: string let newFileSize = file.totalBytes let selfId: string let contact: Contact if (options?.active) { selfId = options?.activeOption?.uin as string contact = karin.contactGroup(options?.activeOption?.group_id as string) } else { selfId = event.selfId contact = event.contact } // 判断是否需要压缩后再上传 if (Config.app.compress && file.totalBytes > Config.app.compresstrigger) { const Duration = await getMediaDuration(file.filepath) /** * ⚠️ 读不出时长就别压了。 * * ffprobe 解不动这个编码时(日志里就是 `[hevc …] Unknown profile bitstream`) * `Duration` 是 NaN,算出来的目标码率也是 NaN,ffmpeg 必然失败 —— * 而压缩产物一旦不存在,后面拿它去发就变成「上传文件不存在」, * 用户的观感是「视频压根发不出去」。解不动的时候老实发原文件。 */ if (!Number.isFinite(Duration) || Duration <= 0) { logger.warn('读不出视频时长(ffmpeg 解不动这个编码),放弃压缩,直接发原文件') } else { logger.warn( logger.yellow( `视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.app.compresstrigger} MB),正在进行压缩至${Config.app.compressvalue} MB...` ) ) const message = [ segment.text( `视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.app.compresstrigger} MB),正在进行压缩至${Config.app.compressvalue} MB...` ), options?.message_id ? segment.reply(options.message_id) : segment.text('') ] // 「正在压缩」属于过程提示:不进合并转发 const msg1 = await withoutForwardCollect(() => karin.sendMsg(selfId, contact, message)) // 计算目标视频平均码率 const targetBitrate = Common.calculateBitrate(Config.app.compresstrigger, Duration) * 0.75 // 执行压缩 const startTime = Date.now() const outputPath = `${Common.tempDri.video}tmp_${Date.now()}.mp4` const compressed = await compressVideo({ inputPath: file.filepath, outputPath, targetBitrate }) const endTime = Date.now() /** * ⚠️ 压缩失败必须**接着用原文件**,不能直接把 filepath 换成那个不存在的产物。 * * 以前这里不看 `compressVideo` 的返回值,`file.filepath = outputPath` 一律执行, * 于是 ffmpeg 一失败(编码不认识 / 缺少解码器)就变成「上传文件不存在」—— * 视频本来是好的,却一条都发不出去。 */ if (!compressed) { logger.warn('视频压缩失败,改用原文件发送(体积可能超限,必要时关掉「视频压缩」)') const message2 = [ segment.text('视频压缩失败,改为发送原文件'), segment.reply(msg1.messageId) ] await withoutForwardCollect(() => karin.sendMsg(selfId, contact, message2)) } else { file.filepath = outputPath // 再次检查大小 newFileSize = await Common.getVideoFileSize(file.filepath) logger.debug( `原始视频大小为: ${file.totalBytes.toFixed(1)} MB, ${logger.green(`经 FFmpeg 压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,原视频文件已删除`)}` ) const message2 = [ segment.text(`压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,压缩耗时:${((endTime - startTime) / 1000).toFixed(1)} 秒`), segment.reply(msg1.messageId) ] await withoutForwardCollect(() => karin.sendMsg(selfId, contact, message2)) } } } /** * 判断是否使用群文件上传。 * * QQ 单独一套阈值(配置项 `qqGroupFileLimitMB`,默认 30):超过就按群文件发。 * 原因是 QQ 对富媒体视频会压缩、改名甚至丢掉后缀,几十 MB 的视频走「视频」通道基本没法看; * 其它平台仍旧按上游的「群文件上传」开关 + `groupfilevalue` 走。 */ // 平台名要和插件其它地方保持一致:适配器真实平台在 event.bot.bot.platform 上 // (只写 event.platform 会取到空值,导致判定成「不是 QQ」而永远走不到群文件分支) const platform = String( (event as any)?.bot?.bot?.platform ?? (event as any)?.platform ?? (event as any)?.bot?.platform ?? '' ) const isQqPlatform = /qqguild|qqbot|^qq$/i.test(platform) || /qq/i.test(platform) || /official/i.test(platform) const rawQqLimit = (() => { try { return (tryGetRuntime()?.config as any)?.qqGroupFileLimitMB } catch { return undefined } })() const parsedQqLimit = Number(rawQqLimit) const qqLimit = rawQqLimit === undefined || rawQqLimit === null || rawQqLimit === '' ? 30 : (Number.isFinite(parsedQqLimit) && parsedQqLimit >= 0 ? parsedQqLimit : 30) const useGroupFile = isQqPlatform && qqLimit > 0 ? newFileSize > qqLimit : Config.app.usegroupfile && newFileSize > Config.app.groupfilevalue logger.info( '[视频发送] 平台=' + (platform || '(未知)') + ' 体积=' + newFileSize.toFixed(1) + 'MB' + (isQqPlatform ? ' QQ阈值=' + qqLimit + 'MB' : ' 上游阈值=' + Config.app.groupfilevalue + 'MB') + ' 走群文件=' + useGroupFile ) if (options) options.useGroupFile = useGroupFile if (Config.app.videoSendMode === 'base64' && !useGroupFile) { const videoBuffer = fs.readFileSync(file.filepath) File = `base64://${videoBuffer.toString('base64')}` logger.mark(`已开启视频文件 base64转换 正在进行${logger.yellow('base64转换中')}...`) } else File = useGroupFile ? file.filepath : `file://${file.filepath}` /** * 发送前:撤掉上一条「收到请求,开始下载」,并回一句「发送中…」。 * 大文件上传(几十 MB 走文件通道)要等好几秒,没有任何反馈的话用户会以为机器人卡住了。 * * 超过 QQ 富媒体限制、要走群文件的那一档,在这里说清楚(表格里不再做文件标记)。 */ let sendingTipId: string | undefined try { if (!options?.active) { await recallLastPanel(event) const tipText = useGroupFile ? '发送中…(超过 QQ 限制,将以群文件发送)' : '发送中…' // 「发送中…」是过程提示:不进合并转发(视频本体才进) const tip: any = await withoutForwardCollect(() => event.reply(tipText)) sendingTipId = tip?.messageId } } catch (error) { logger.debug('发送中提示失败: ' + String(error)) } /** * 发群文件。名字被 QQ 打回时,换一个「绝对合法」的临时名再试一次。 * * 为什么要重试:群文件名是这条链路上**唯一因文件而异**的入参,QQ 的 `upload_prepare` * 会因为名字不合法(换行、超长、非法字符)直接返回参数错误,而这时候文件本身、网络、 * 体积都没问题 —— 换个短名就能过(详见 {@link groupFileName})。 * @param bot 用哪个 bot 发(主动消息与被动消息不是同一个来源) */ const sendGroupFile = async (bot: any): Promise => { const name = groupFileName(file) try { return await bot.uploadFile(contact, File, name) } catch (error) { /** * 换文件名重试**只适用于「名字不合法」这类失败**(QQ 的 upload_prepare 会因为 * 换行 / 超长 / 非法字符直接参数错误,而文件本身没问题)。 * * ⚠️ 但「没拿到消息 ID」**绝对不能重试**:它只说明没确认,不说明没发出去 —— * LLOneBot 发视频就是不回 `message_id` 的,文件其实已经进了群。 * 以前这里一视同仁地重试,结果用户一条指令收到**两条一模一样的视频**, * 还额外收到一张「发送失败」的错误卡片。 */ if (isUnconfirmedSendError(error)) throw error logger.warn( `群文件名「${name}」发送失败,改用临时文件名重试一次:` + describeError(error) ) return await bot.uploadFile(contact, File, fallbackGroupFileName()) } } try { // 是主动消息 if (options?.active) { if (useGroupFile) { // 是群文件 const bot = karin.getBot(String(options.activeOption?.uin))! logger.mark(`${logger.blue('主动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('bot.uploadFile')}回复...`) const sent: any = await sendGroupFile(bot) reportSent(options, sent) } else { // 不是群文件 logger.mark(`${logger.blue('主动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('karin.sendMsg')}回复...`) // 兼容层已经把「没拿到消息 ID」当成失败抛出(见 compat/sendError),所以这里没有异常就是发出去了 const sent: any = await karin.sendMsg(selfId, contact, [segment.video(File)], { retryCount: 0 }) reportSent(options, sent) sendStatus = true } } else { // 不是主动消息 if (useGroupFile) { // 是文件 logger.mark(`${logger.blue('被动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('e.bot.uploadFile')}回复...`) const sent: any = await sendGroupFile(event.bot) reportSent(options, sent) } else { // 不是文件 logger.mark(`${logger.blue('被动消息:')} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow('e.reply')}回复...`) // 同上:异常才是失败(没拿到消息 ID 已经由兼容层抛出来了) const sent: any = await event.reply(segment.video(File) || videoUrl) reportSent(options, sent) sendStatus = true } } // 发成功了就把「发送中…」收掉,群里只留下真正的视频 if (sendingTipId) { try { await (event.bot as any)?.recallMsg?.(sendingTipId, String(event.contact?.peer ?? event.channelId ?? '')) } catch { /* 撤回失败无所谓 */ } } return sendStatus } catch (error) { if (options && options.active === false) { await event.reply('视频文件上传失败' + JSON.stringify(error, null, 2)) } logger.error('视频文件上传错误,' + describeError(error)) throw error // 重新抛出错误,让 wrapWithErrorHandler 能够捕获 } finally { // 发送阶段结束(成功失败都要清,否则「下载进度」会一直卡在「正在发送」) clearParseStage() const filePath = file.filepath const previewInfo = Common.registerVideoPreview(filePath, Config.app.removeCache, 30 * 60 * 1000) // 预览地址用随机令牌寻址,不暴露磁盘文件名 logger.mark(`临时预览地址:http://localhost:${previewPort()}/kkk/ssr/video/${previewInfo.token}`) if (Config.app.removeCache) { logger.info(`文件 ${filePath} 将在 30 分钟后删除`) } setTimeout( async () => { const removed = await Common.removeFile(filePath) if (removed) { Common.markVideoPreviewRemoved(filePath) } }, 30 * 60 * 1000 ) } } /** * 只把视频下到本地,**不发送**。 * * 解析流程改成「先下载视频、再渲染卡片」之后,下载这一步会被提前调用: * 下载是最慢的一步,早点开始、失败了也不影响后面的卡片/评论照常发, * 最后再由调用方统一报错(见 ParseSteps)。 * @param event 事件(体积超限时要回一句话) * @param downloadOpt 下载参数 * @returns 下载结果(含文件名信息);超出体积上限时返回 null,并已经回过提示 */ export const downloadVideoFile = async (event: Message, downloadOpt: downloadFileOptions): Promise => { /** 获取文件大小 */ const fileHeaders = await new Networks({ url: downloadOpt.video_url, headers: downloadOpt.headers ?? baseHeaders }).getHeaders() const fileSizeContent = extractTotalBytesFromHeaders(fileHeaders) const fileSizeInMB = (fileSizeContent / (1024 * 1024)).toFixed(2) const fileSize = parseInt(parseFloat(fileSizeInMB).toFixed(2)) if (fileSizeContent > 0 && Config.app.usefilelimit && fileSize > Config.app.filelimit) { /** * 上限口径(两条): * 1. 「在线播放最大文件」(playerMaxFileMB)显式填了就用它;留空 / 0 = 跟随全局 * (就是上面这个 filelimit);全局没开限制就是不限制; * 2. **在线播放不要 QQ 那套限制**:面板上的「在线看」/「弹幕」都不把视频发到 QQ, * 用户是在播放页里看的,所以「QQ 单个视频 200MB」在这里没有意义 —— * 批量超限的画质档因此也能点(只受第 1 条那个上限约束,判定依旧早于搬文件)。 */ const playerLimitMB = effectivePlayerSizeLimitMB(Number(Config.app.filelimit)) const playerAccepts = withinPlayerSizeLimit(parseFloat(fileSizeInMB), Number(Config.app.filelimit)) const redirectOn = shouldRedirectOversizeToPlayer() const onlinePlayerNow = isOnlinePlayerRequest() const oversizeNote = (onlinePlayerNow || redirectOn) && !playerAccepts ? `(超过在线播放的体积上限 ${Math.round(playerLimitMB)}MB,按原来的方式处理)` : '' /** 超限时的统一动作:回一句说明并放弃这次下载(调用方按「没下到视频」继续) */ const rejectOversize = async (): Promise => { const message = segment.text( `视频:「${ downloadOpt.title.originTitle ?? 'Error: 文件名获取失败' }」大小 (${fileSizeInMB} MB) 超出最大限制(设定值:${Config.app.filelimit} MB),已取消上传${oversizeNote}` ) const selfId = event.selfId || (uploadOpt?.activeOption?.uin as string) const contact = event.contact || karin.contactGroup(uploadOpt?.activeOption?.group_id as string) || karin.contactFriend(selfId) await karin.sendMsg(selfId, contact, message) return null } if (onlinePlayerNow) { // 在线播放:QQ 的上限不拦它,但如果连在线播放自己的上限也超了,就一点都不下(免得塞满磁盘) if (!playerAccepts) { logger.info(`[在线播放] 视频 ${fileSizeInMB}MB 超过在线播放上限 ${Math.round(playerLimitMB)}MB,不转到播放器,按原来的方式处理`) return await rejectOversize() } logger.mark(`[在线播放] 视频 ${fileSizeInMB}MB 超过 QQ 上限 ${Config.app.filelimit}MB,但这次走在线播放(不发到 QQ),继续下载`) } else if (redirectOn && playerAccepts) { /** * 「超限转在线播放」:视频还是**照常下载**(只是改由播放页提供,不再发到群里)。 * * 这里只负责把本次解析标记成在线播放 —— 平台 handler 后面读 isOnlinePlayerRequest() * 就知道该登记播放会话、回链接,而不是调 uploadFile。 */ markOnlinePlayerOverride() logger.mark(`[在线播放] 视频 ${fileSizeInMB}MB 超过全局上限 ${Config.app.filelimit}MB,按「超限转在线播放」继续下载`) /** * **一定要给用户一句话**(用户实测反馈:「为什么用在线地址?没有超过上限大小的提示」)。 * * 这条路径以前是**完全静默**的:视频照样下载,然后 handler 那边看到「在线播放」标记就登记 * 播放会话、回一条链接 —— 用户只拿到一个播放地址,完全不知道为什么视频没发到群里。 * 提示属于「过程提示」,用 withoutForwardCollect 包住,别进合并转发。 */ try { await withoutForwardCollect(() => event.reply( `视频 ${fileSizeInMB}MB 超过设定的最大上传大小 ${Config.app.filelimit}MB,` + '已按「超限转在线播放」改为在线播放:视频不发到群里,稍后给你播放链接' )) } catch (error: any) { logger.debug('[在线播放] 超限提示发送失败: ' + String(error?.message ?? error)) } } else { // 转播开着但这一档连在线播放上限都超了:明确说一句「按原来的方式处理」,别让人以为是坏了 if (redirectOn && !playerAccepts) { logger.info(`[在线播放] 视频 ${fileSizeInMB}MB 超过在线播放上限 ${Math.round(playerLimitMB)}MB,不转播,按原来的方式处理`) } return await rejectOversize() } } // 下载文件,视频URL,标题和自定义headers let res = await downloadFile(downloadOpt.video_url, { title: Config.app.removeCache ? (downloadOpt.title.timestampTitle as string) : processFilename(downloadOpt.title.originTitle!, 50), headers: downloadOpt.headers ?? baseHeaders }) res = { ...res, ...downloadOpt.title } // 将下载的文件大小转换为MB并保留两位小数 res.totalBytes = Number((res.totalBytes / (1024 * 1024)).toFixed(2)) return res } /** * 下载视频并上传到群。 * @param event 事件 * @param downloadOpt 下载参数 * @param uploadOpt 上传参数 * @returns */ export const downloadVideo = async (event: Message, downloadOpt: downloadFileOptions, uploadOpt?: uploadFileOptions): Promise => { const res = await downloadVideoFile(event, downloadOpt) if (!res) return false /** 上传视频 */ return await uploadFile(event, res, downloadOpt.video_url, uploadOpt) } /** * 异步下载文件的函数。 * @param videoUrl 下载地址。 * @param opt 配置选项,包括标题、请求头等。 * @returns 返回一个包含文件路径和总字节数的对象。 */ export const downloadFile = async (videoUrl: string, opt: downLoadFileOptions): Promise => { // 记录开始时间 const startTime = Date.now() // 从配置中读取限速设置 const uploadConfig = Config.app const throttleConfig = { enabled: uploadConfig.downloadThrottle ?? false, maxSpeed: (uploadConfig.downloadMaxSpeed ?? 10) * 1024 * 1024, // MB/s -> bytes/s autoReduceRatio: uploadConfig.downloadAutoReduce ? 0.6 : 1, // 降速比例 minSpeed: (uploadConfig.downloadMinSpeed ?? 1) * 1024 * 1024 // MB/s -> bytes/s } /** * 速度采样点。速度必须按「两次回调之间的增量」算,不能按「累计字节 ÷ 总耗时」: * 后者把重试的退避等待也算进分母,断点续传时分子还会被重置回续传点, * 于是明明在正常下载也会显示成 `0.0 MB/s 剩余: 661min`(用户实测反馈)。 */ let lastSampleBytes = 0 let lastSampleAt = startTime /** 平滑后的速度(byte/s) */ let smoothSpeed = 0 try { // 使用 networks 类进行文件下载,并通过回调函数实时更新下载进度 const { filepath, totalBytes } = await new Networks({ url: videoUrl, headers: opt.headers ?? baseHeaders, filepath: opt.filepath ?? Common.tempDri.video + opt.title, timeout: 60000, // 空闲超时(连续 60s 没有新数据才算超时),传输途中会不断续期 maxRetries: 3, // 增加重试次数 throttle: throttleConfig, backupUrls: opt.backupUrls }).downloadStream((downloadedBytes, totalBytes) => { /** 把进度交出去(在线播放页的分段加载进度用;终端进度条不受影响) */ try { opt.onProgress?.(downloadedBytes, totalBytes) } catch { /* 回调出错不能连累下载 */ } // 定义进度条长度及生成进度条字符串的函数 const barLength = 45 const generateProgressBar = (progressPercentage: number) => { const clampedPercentage = Math.min(100, Math.max(0, progressPercentage)) const filledLength = Math.floor((clampedPercentage / 100) * barLength) const emptyLength = Math.max(0, barLength - filledLength) return `[${'\u2588'.repeat(filledLength)}${'\u2591'.repeat(emptyLength)}]` } // 计算当前下载进度百分比 const progressPercentage = totalBytes > 0 ? Math.min(100, (downloadedBytes / totalBytes) * 100) : 0 // 计算动态 RGB 颜色 const red = Math.floor(255 - (255 * progressPercentage) / 100) // 红色分量随进度减少 const coloredPercentage = logger.chalk.rgb(red, 255, 0)(`${progressPercentage.toFixed(1)}%`) // 计算下载速度(MB/s):增量法 + 一点平滑,避免数字乱跳 const sampleAt = Date.now() const elapsedSinceSample = (sampleAt - lastSampleAt) / 1000 if (elapsedSinceSample >= 0.2) { // 断点续传时 downloadedBytes 会往回退,增量按 0 处理 const instant = Math.max(0, downloadedBytes - lastSampleBytes) / elapsedSinceSample smoothSpeed = smoothSpeed > 0 ? smoothSpeed * 0.6 + instant * 0.4 : instant lastSampleBytes = downloadedBytes lastSampleAt = sampleAt } const speed = smoothSpeed const formattedSpeed = (speed / 1048576).toFixed(1) + ' MB/s' // 计算剩余时间(速度还没出来时不瞎猜) const remainingBytes = totalBytes - downloadedBytes // 剩余字节数 const remainingTime = speed > 1024 ? remainingBytes / speed : Number.POSITIVE_INFINITY const formattedRemainingTime = !Number.isFinite(remainingTime) ? '--' : remainingTime > 60 ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s` : `${remainingTime.toFixed(0)}s` // 计算已下载和总下载的文件大小(MB) const downloadedSizeMB = (downloadedBytes / 1048576).toFixed(1) const totalSizeMB = (totalBytes / 1048576).toFixed(1) // 打印下载进度、速度和剩余时间 console.log( `⬇️ ${opt.title ?? (opt.filepath && opt.filepath.split('/').pop()) ?? '未知文件'} ${generateProgressBar(progressPercentage)} ${coloredPercentage} ${downloadedSizeMB}/${totalSizeMB} MB | ${formattedSpeed} 剩余: ${formattedRemainingTime}\r` ) }) return { filepath, totalBytes } } catch (error) { // 检查是否为网络环境变化或服务器风控导致的错误 const errorMessage = error instanceof Error ? error.message : String(error) const isNetworkChangeError = /ECONNRESET|ETIMEDOUT|ECONNABORTED|aborted|timeout|network|连接被重置|连接超时|连接中止/i.test( errorMessage ) if (isNetworkChangeError) { logger.error('下载失败,可能是由于网络环境变化(如代理切换、VPN切换)或服务器风控导致') logger.error(`文件: ${opt.title}`) logger.error(`错误详情: ${errorMessage}`) if (!uploadConfig.downloadThrottle) { logger.error('提示: 如果频繁出现此错误,建议在配置中开启「下载限速」功能') } } throw error } } /** * 群文件名主体允许的最大字符数(不含扩展名)。 * * 取 60 是个折中:QQ 侧对 `upload_prepare` 的 `file_name` 有长度上限,但**没有公开的准确值**, * 而作品标题动辄几百字,所以必须截断;60 个中文标题还能看清是哪条视频。 */ const GROUP_FILE_NAME_MAX = 60 /** * 生成一个能过 QQ 群文件接口校验的文件名。 * * ## 为什么需要这个(用户实测踩的坑) * * 快手作品的 `originTitle` 就是**作品标题原文**(`kuaishou.ts` 里 * `originTitle: \`${work.photo.caption}.mp4\``),而快手标题常常是带换行的多行文案, * 长度也经常几百字。这条链路上 `originTitle` 以前是**原样**拼上 `.mp4` 当 `file_name` * 发给 QQ 的 `upload_prepare`,于是: * - 名字里有 `\n`(或其它非法字符)→ QQ 返回参数错误 → **整个视频一个字节都没发出去**; * - 标题本身已经带 `.mp4` 时还会拼成 `xxx.mp4.mp4`。 * * 这个问题只在「发到 QQ」时才暴露:磁盘上的文件名有 {@link processFilename} 兜着, * 所以本地一切正常,只有群文件那一步静默失败。 * * ## 处理规则 * * 1. 去掉调用方普遍已经自带的 `.mp4`(避免 `xxx.mp4.mp4`); * 2. 把 `\ / : * ? " < > |`、换行、制表符、控制字符统一换成空格并压缩连续空白; * 3. 去掉开头的点(`.mp4` 这种「文件名只剩扩展名」的情况会变成空串); * 4. 按**字符数**截断到 {@link GROUP_FILE_NAME_MAX}; * 5. 标题为空(或清完什么都不剩)时退回 `filepath` 的文件名, * 连文件名都拿不到才退化成 `kkk_<时间戳>.mp4`。 * * @param file 视频文件信息(优先 `originTitle`,拿不到就退回 `filepath` 的文件名) * @returns 带 `.mp4` 扩展名的安全文件名 */ export const groupFileName = (file: Pick): string => { // 退回 filepath 时只取文件名那一段 —— 整个路径会被下面的「非法字符」规则切成空格 const raw = String(file.originTitle ?? '').trim() || String(file.filepath ?? '').split(/[\\/]/).pop() || '' const cleaned = raw .replace(/\.mp4$/i, '') // eslint-disable-next-line no-control-regex .replace(/[\\/:*?"<>|\r\n\t\v\f\u0000-\u001f]+/g, ' ') .replace(/\s+/g, ' ') .replace(/^[.\s]+/, '') .trim() const base = cleaned.length > GROUP_FILE_NAME_MAX ? cleaned.slice(0, GROUP_FILE_NAME_MAX).trim() : cleaned return base ? `${base}.mp4` : fallbackGroupFileName() } /** * 兜底文件名:纯 ASCII、短、只用时间戳,几乎不可能被 QQ 打回。 * * 两个地方用它:标题清完之后什么都不剩(见 {@link groupFileName}), * 以及群文件发送失败后换个名字重试一次(见 {@link uploadFile})。 */ export const fallbackGroupFileName = (): string => `kkk_${Date.now()}.mp4` /** * 「临时预览地址」里的端口。 * * 以前直接读 `process.env.HTTP_PORT` —— 那是**上游 karin 的环境变量**,Koishi 侧根本没有, * 于是日志里一直是 `http://localhost:undefined/kkk/ssr/video/…`(点不开, * 也让人以为插件坏了)。Koishi 的端口在 `ctx.config.port` 上,按它取; * 拿不到再退回环境变量 / 7777(和 `qrlogin.ts` 里的兜底保持一致)。 */ const previewPort = (): string => { const fromKoishi = (tryGetRuntime()?.ctx as any)?.config?.port if (fromKoishi !== undefined && fromKoishi !== null && String(fromKoishi).trim()) return String(fromKoishi) return process.env.HTTP_PORT || '7777' } /** * 处理文件名长度,保留文件扩展名 * @param filename 原始文件名 * @param maxLength 最大长度(不包括扩展名) * @returns 处理后的文件名 */ const processFilename = (filename: string, maxLength: number = 50): string => { // 提取文件扩展名 const lastDotIndex = filename.lastIndexOf('.') const hasExtension = lastDotIndex > 0 && lastDotIndex < filename.length - 1 if (!hasExtension) { // 没有扩展名,直接截取并清理特殊字符 return filename.substring(0, maxLength).replace(/[\\/:*?"<>|\r\n\s]/g, ' ') } // 分离文件名主体和扩展名 const nameWithoutExt = filename.substring(0, lastDotIndex) const extension = filename.substring(lastDotIndex) // 对文件名主体进行长度限制和特殊字符清理 const processedName = nameWithoutExt.substring(0, maxLength).replace(/[\\/:*?"<>|\r\n\s]/g, ' ') // 重新拼接文件名和扩展名 return processedName + '...' + extension }