import fs from 'node:fs' import { BiliUserDynamic, type BilibiliLiveRoomInfoResponse, type BilibiliUserLiveStatusResponse, BilibiliUserCardResponse, BilibiliVideoStreamResponse, DynamicType, MajorType } from '@ikenxuan/amagi' import type { BilibiliForwardOriginalContentProps } from '@template/template/bilibili/dynamic/types' import type { BilibiliUserListData } from '@template/template/bilibili/userlist/components/types' import type { AdapterType, ImageElement, Message, SendMsgResults } from 'node-karin' import karin, { common, logger, segment } from 'node-karin' import { Base, baseHeaders, bilibiliDB, buildGoogleMotionPhoto, cleanOldDynamicCache, Common, Count, downloadFile, downLoadFileOptions, fileInfo, loopVideoWithTransition, mergeVideoAudio, processLocalImageFile, processImageUrl, Render, uploadFile } from '@/module' import { bilibiliFetcher } from '@/module/utils/amagiClient' import { parseCommandActions } from '@/module/utils/QqPanel' import { Config } from '@/module/utils/Config' import { bilibiliProcessVideos, buildDashUrlCandidates, generateDecorationCard, getvideosize, parseAdditionalCard, TimeFormatter } from '@/platform/bilibili' import { buildBilibiliArticleRichText, buildBilibiliDynamicRichText, buildBilibiliRichTextForwardMessage, buildVideoDescRichText, getUsernameMetadata } from '@/platform/bilibili/dynamic-text' import { buildBilibiliLiveSessionId, parseBilibiliLiveStartedAt } from '@/platform/bilibili/live-status' import type { bilibiliPushItem, BilibiliPushType } from '@/types/config/pushlist' /** BilibiliPushType 到 DynamicType 的映射 */ const pushTypeToDynamicType: Record = { video: DynamicType.AV, draw: DynamicType.DRAW, word: DynamicType.WORD, live: DynamicType.LIVE_RCMD, forward: DynamicType.FORWARD, article: DynamicType.ARTICLE } /** 所有支持的 BilibiliPushType */ const allBilibiliPushTypes: BilibiliPushType[] = ['video', 'draw', 'word', 'live', 'forward', 'article'] /** * 空间动态列表里的一条动态。 * * 用 amagi 手写快照树(`BiliUserDynamic`)而不是 `BilibiliUserDynamicListResponse`:生成树这条 * 响应没有按动态类型分形状(`type` 只是 `string`、各支共用同一套 `modules`,样本里没有的字段 * 还常被记成 `null`),而推送链路要按类型读各自的字段(图文读 `opus`、视频读 `archive`、 * 转发读 `orig`……)。快照树正是这个用法需要的判别联合,仍是 amagi 在导出的 v6 兼容面。 */ type DataItem = BiliUserDynamic['data']['items'][number] type BilibiliPushItemMap = { [T in DynamicType]: { /** 该UP主的昵称 */ remark: string /** UP主UID */ host_mid: number /** 动态发布时间 */ create_time: number /** 要推送到的群组和机器人ID */ targets: Array<{ groupId: string; botId: string }> /** 动态详情信息 */ Dynamic_Data: Extract /** UP主头像url */ avatar_img: string /** 动态类型 */ dynamic_type: T } } /** 每个推送项的类型定义 */ export type BilibiliPushItem = BilibiliPushItemMap[DynamicType] /** 推送列表的类型定义 */ type WillBePushList = Record const bilibiliBaseHeaders: downLoadFileOptions['headers'] = { ...baseHeaders, Referer: 'https://www.bilibili.com', Cookie: Config.amagi.cookies.bilibili } export class Bilibilipush extends Base { private force = false constructor(e = {} as Message, force: boolean = false) { super(e) this.force = force } private injectBotToEventForRender(targets: Array<{ groupId: string; botId: string }>): void { const targetBotId = targets.find((item) => item.botId)?.botId if (!targetBotId) return const bot = karin.getBot(targetBotId) as AdapterType | undefined if (!bot) return const eventWithBot = this.e as Message & { bot?: AdapterType; selfId?: string } eventWithBot.bot = bot eventWithBot.selfId = eventWithBot.selfId ?? targetBotId } /** * 检查并补全配置文件中缺失的字段 * @param pushList 推送配置列表 */ private ensureConfigFields(pushList: bilibiliPushItem[]): void { if (!pushList || pushList.length === 0) return let hasChanges = false for (const item of pushList) { // 检查并补全 pushTypes 字段 if (!item.pushTypes?.length) { item.pushTypes = [...allBilibiliPushTypes] hasChanges = true logger.info( `[koishi-plugin-kkk][v2.28.0] 为UP主 ${item.remark ?? item.host_mid} 自动补全推送类型:投稿视频、图文动态、纯文动态、直播动态、转发动态、投稿专栏` ) } // 检查并补全 switch 字段 if (item.switch === undefined) { item.switch = true hasChanges = true } } // 如果有修改,保存到配置文件 if (hasChanges) { Config.Modify('pushlist', 'bilibili', pushList) logger.info('已自动补全B站推送配置文件中缺失的字段并保存') } } /** * 执行主要的操作流程 */ async action() { await this.syncConfigToDatabase() // 检查并补全配置文件中缺失的字段 this.ensureConfigFields(Config.pushlist.bilibili) // 清理旧的动态缓存记录 const deletedCount = await cleanOldDynamicCache('bilibili') if (deletedCount > 0) { logger.info(`已清理 ${deletedCount} 条过期的B站动态缓存记录`) } // 获取已注册的 bot 列表,过滤未注册的 bot const registeredBotIds = karin.getAllBotID() const filteredPushList = this.filterPushListByRegisteredBots(Config.pushlist.bilibili, registeredBotIds) if (filteredPushList.length === 0) { // 推送列表本来就是空的属正常情况,别刷 warn(用户反馈日志一直被它刷屏) if ((Config.pushlist as any).bilibili?.length) { logger.warn('推送列表里的机器人都不在线,本次跳过B站推送') } else { logger.debug('B站推送列表为空,跳过') } return true } const data = await this.getDynamicList(filteredPushList) const pushdata = await this.excludeAlreadyPushed(data.willbepushlist) if (Object.keys(pushdata).length === 0) return true if (this.force) { return await this.forcepush(pushdata) } else { return await this.getdata(pushdata) } } /** * 根据已注册的 bot 列表过滤推送配置 * @param pushList 原始推送配置列表 * @param registeredBotIds 已注册的 bot ID 列表 * @returns 过滤后的推送配置列表 */ private filterPushListByRegisteredBots(pushList: bilibiliPushItem[], registeredBotIds: string[]): bilibiliPushItem[] { if (!pushList || pushList.length === 0) return [] const registeredSet = new Set(registeredBotIds) const filteredList: bilibiliPushItem[] = [] for (const item of pushList) { // 过滤 group_id 中未注册的 bot const filteredGroupIds = item.group_id.filter((groupWithBot) => { const botId = groupWithBot.split(':')[1] const isRegistered = registeredSet.has(botId) if (!isRegistered) { logger.debug(`Bot ${botId} 未注册,跳过群组 ${groupWithBot.split(':')[0]} 的推送`) } return isRegistered }) // 如果过滤后还有有效的群组,则保留该订阅项 if (filteredGroupIds.length > 0) { filteredList.push({ ...item, group_id: filteredGroupIds }) } else { logger.debug(`UP主 ${item.remark ?? item.host_mid} 的所有推送目标 bot 均未注册,跳过`) } } return filteredList } /** 将 UID 直播状态接口的结果接入原有动态推送列表。 */ private async getDirectLivePushItems( userList: bilibiliPushItem[] ): Promise<{ handledUids: Set; willBePushList: WillBePushList }> { const handledUids = new Set() const willBePushList: WillBePushList = {} const liveSubscriptions = userList.filter((item) => item.switch !== false && (item.pushTypes || allBilibiliPushTypes).includes('live')) for (const item of liveSubscriptions) { let liveStatus: BilibiliUserLiveStatusResponse['data'] try { const liveStatusResult = await this.amagi.bilibili.fetcher.fetchUserLiveStatus({ host_mid: item.host_mid }) liveStatus = liveStatusResult.data.data } catch (error) { logger.warn( `[Bilibili 推送] UP主 ${item.remark}(${item.host_mid})直播状态直查失败,本轮停止直查并让剩余订阅回退到直播动态检测:${formatErrorMessage(error)}` ) break } if (liveStatus.roomStatus !== 1 || liveStatus.liveStatus !== 1 || liveStatus.roomid <= 0) { handledUids.add(item.host_mid) continue } try { const liveInfoResult = await this.amagi.bilibili.fetcher.fetchLiveRoomInfo({ room_id: String(liveStatus.roomid) }) const liveInfo = liveInfoResult.data.data /** 两个直播接口状态不一致时,以直播间详情为准。 */ if (liveInfo.live_status !== 1) { handledUids.add(item.host_mid) continue } const sessionId = buildBilibiliLiveSessionId(item.host_mid, liveInfo.room_id, liveInfo.live_time) const liveStartedAt = parseBilibiliLiveStartedAt(liveInfo.live_time) if (!sessionId || !liveStartedAt) { throw new Error(`直播间 ${liveInfo.room_id} 未返回可用于场次去重的开播时间`) } const dynamic = createLiveDynamicItem(sessionId, liveStartedAt, liveInfo, liveStatus) willBePushList[sessionId] = { remark: item.remark, host_mid: item.host_mid, create_time: dynamic.modules.module_author.pub_ts, targets: item.group_id.map((groupWithBot) => { const [groupId, botId] = groupWithBot.split(':') return { groupId, botId } }), Dynamic_Data: dynamic, avatar_img: '', dynamic_type: DynamicType.LIVE_RCMD } as BilibiliPushItem handledUids.add(item.host_mid) } catch (error) { logger.warn( `[Bilibili 推送] UP主 ${item.remark}(${item.host_mid})直播场次信息不完整,本轮回退到直播动态检测:${formatErrorMessage(error)}` ) } } return { handledUids, willBePushList } } /** * 为降级路径中的直播动态解析与直查路径一致的场次缓存键。 * * @param dynamic B站空间动态中的直播推荐项。 * @param hostMid UP 主 UID。 * @returns 可用时返回直播场次键,否则退回原始动态ID。 */ private async resolveLiveDynamicCacheId(dynamic: Extract, hostMid: number): Promise { try { const liveData = JSON.parse(dynamic.modules.module_dynamic.major.live_rcmd.content) const roomId = Number(liveData.live_play_info.room_id) const liveInfo = await this.amagi.bilibili.fetcher.fetchLiveRoomInfo({ room_id: String(roomId) }) const cacheId = buildBilibiliLiveSessionId(hostMid, liveInfo.data.data.room_id, liveInfo.data.data.live_time) return cacheId || dynamic.id_str } catch (error) { logger.warn(`[Bilibili 推送] 直播动态 ${dynamic.id_str} 无法解析统一场次键,将使用动态ID去重:${formatErrorMessage(error)}`) return dynamic.id_str } } /** * 同步配置文件中的订阅信息到数据库 */ async syncConfigToDatabase() { // 如果配置文件中没有B站推送列表,直接返回 if (!Config.pushlist.bilibili || Config.pushlist.bilibili.length === 0) { return } await bilibiliDB.syncConfigSubscriptions(Config.pushlist.bilibili) } /** * 异步获取数据并根据动态类型处理和发送动态信息。 * @param data 包含动态相关信息的对象。 */ async getdata(data: WillBePushList) { for (const dynamicId in data) { logger.mark(` ${logger.blue('开始处理并渲染B站动态图片')} ${logger.cyan('UP')}: ${logger.green(data[dynamicId].remark)} ${logger.cyan('动态id')}:${logger.yellow(dynamicId)} ${logger.cyan('访问地址')}:${logger.green('https://t.bilibili.com/' + dynamicId)}`) let skip = await skipDynamic(data[dynamicId]) if (skip) { logger.warn(`动态 https://t.bilibili.com/${dynamicId} 已被处理,跳过`) } let send_video = true let img: ImageElement[] = [] let articleForwardPayload: { body: ReturnType title?: string summary?: string shareUrl?: string } | null = null this.injectBotToEventForRender(data[dynamicId].targets) if (!skip) { const userINFO = await this.amagi.bilibili.fetcher.fetchUserCard({ host_mid: data[dynamicId].host_mid }) switch (data[dynamicId].dynamic_type) { /** 处理图文动态 */ case DynamicType.DRAW: { if ( data[dynamicId].Dynamic_Data.modules.module_dynamic.topic !== null && data[dynamicId].Dynamic_Data.modules.module_dynamic && data[dynamicId].Dynamic_Data.modules.module_dynamic.topic !== null ) { const name = data[dynamicId].Dynamic_Data.modules.module_dynamic.topic!.name data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus.summary.rich_text_nodes.unshift({ orig_text: name, text: name, type: 'topic', rid: data[dynamicId].Dynamic_Data.modules.module_dynamic.topic!.id.toString() }) data[dynamicId].Dynamic_Data.modules.module_dynamic.major!.opus.summary.text = `${name}\n\n` + data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.summary?.text } img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_DRAW', { image_url: Object.values(data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.pics ?? []) .filter((item): item is { url: string } => typeof item?.url === 'string') .map((item) => ({ image_src: item.url })), title: data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.title ?? undefined, text: buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.summary?.text ?? '', data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.summary?.rich_text_nodes ?? [] ), dianzan: Count(data[dynamicId].Dynamic_Data.modules.module_stat.like.count), pinglun: Count(data[dynamicId].Dynamic_Data.modules.module_stat.comment.count), share: Count(data[dynamicId].Dynamic_Data.modules.module_stat.forward.count), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.modules.module_author.pub_ts), avatar_url: data[dynamicId].Dynamic_Data.modules.module_author.face, frame: data[dynamicId].Dynamic_Data.modules.module_author.pendant.image, share_url: 'https://t.bilibili.com/' + data[dynamicId].Dynamic_Data.id_str, usernameMeta: getUsernameMetadata(userINFO.data.data.card), fans: Count(userINFO.data.data.follower), user_shortid: data[dynamicId].host_mid, total_favorited: Count(userINFO.data.data.like_num), following_count: Count(userINFO.data.data.card.attention), decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.modules.module_author.decorate_card), render_time: TimeFormatter.now(), imageLayout: Config.bilibili.imageLayout, additional: parseAdditionalCard(data[dynamicId].Dynamic_Data.modules.module_dynamic.additional), dynamicTYPE: '图文动态推送', dynamic_id: dynamicId }) break } /** 处理纯文动态 */ case DynamicType.WORD: { // 处理话题 // Koishi 移植修正:原实现这里读的是 `Dynamic_Data.orig.modules...`, // 但纯文动态没有 orig(那是转发动态才有的字段),一旦命中就抛 // "Cannot read properties of undefined (reading 'modules')"。 // 按原意改为检查主动态自身的话题结构。 if ( data[dynamicId].Dynamic_Data.modules.module_dynamic.topic != null && data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.summary != null ) { const md = data[dynamicId].Dynamic_Data.modules.module_dynamic const { name } = md.topic as unknown as { name: string; id: number } const summary = md.major!.opus.summary summary.rich_text_nodes ??= [] summary.rich_text_nodes.unshift({ orig_text: name, text: name, type: 'topic', rid: (md.topic as unknown as { id: number }).id.toString() }) summary.text = summary.text ? `${name}\n\n${summary.text}` : name } const text = buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.summary?.text ?? '', data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.summary?.rich_text_nodes ?? [] ) img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_WORD', { text, dianzan: Count(data[dynamicId].Dynamic_Data.modules.module_stat.like.count), pinglun: Count(data[dynamicId].Dynamic_Data.modules.module_stat.comment.count), share: Count(data[dynamicId].Dynamic_Data.modules.module_stat.forward.count), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.modules.module_author.pub_ts), avatar_url: data[dynamicId].Dynamic_Data.modules.module_author.face, frame: data[dynamicId].Dynamic_Data.modules.module_author.pendant.image, share_url: 'https://t.bilibili.com/' + data[dynamicId].Dynamic_Data.id_str, usernameMeta: getUsernameMetadata(userINFO.data.data.card), fans: Count(userINFO.data.data.follower), user_shortid: data[dynamicId].host_mid, total_favorited: Count(userINFO.data.data.like_num), following_count: Count(userINFO.data.data.card.attention), decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.modules.module_author.decorate_card), render_time: TimeFormatter.now(), additional: parseAdditionalCard(data[dynamicId].Dynamic_Data.modules.module_dynamic.additional), dynamicTYPE: '纯文动态推送', dynamic_id: dynamicId }) break } /** 处理视频动态 */ case DynamicType.AV: { if (data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.type === 'MAJOR_TYPE_ARCHIVE') { const bvid = data[dynamicId].Dynamic_Data?.modules.module_dynamic.major?.archive?.bvid ?? '' const INFODATA = await bilibiliFetcher.fetchVideoInfo({ bvid }) /** 特殊字段,只有番剧和影视才会有,如果是该类型视频,默认不发送 */ if (INFODATA.data.data.redirect_url) { send_video = false logger.debug( `UP主:${INFODATA.data.data.owner.name} 的该动态类型为${logger.yellow('番剧或影视')},默认跳过不下载,直达:${logger.green(INFODATA.data.data.redirect_url)}` ) } else { // const noCkData = await getBilibiliData('单个视频下载信息数据', { avid: Number(aid), cid: INFODATA.data.data.cid }) } // 处理共创者信息 let staff = undefined if (INFODATA.data.data.staff && Array.isArray(INFODATA.data.data.staff)) { const currentMid = data[dynamicId].host_mid // 提取共创者信息 staff = INFODATA.data.data.staff.map((member: any) => ({ mid: member.mid, title: member.title, name: member.name, face: member.face, follower: member.follower })) // 如果当前动态发布者是共创者之一,将其排到最前面 const currentUserIndex = staff.findIndex((member: any) => member.mid === currentMid) if (currentUserIndex > 0) { const currentUser = staff.splice(currentUserIndex, 1)[0] staff.unshift(currentUser) } } // 处理话题 const md = data[dynamicId].Dynamic_Data.modules.module_dynamic if (md.topic) { const { name } = md.topic md.desc ??= { rich_text_nodes: [], text: '' } md.desc.rich_text_nodes.unshift({ orig_text: name, jump_url: '', text: name, type: 'topic' }) md.desc.text = md.desc.text ? `${name}\n\n${md.desc.text}` : name } const dynamicText = buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.modules.module_dynamic.desc?.text ?? '', data[dynamicId].Dynamic_Data.modules.module_dynamic.desc?.rich_text_nodes ?? [] ) img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', { image_url: INFODATA.data.data.pic, text: buildBilibiliDynamicRichText(INFODATA.data.data.title, []), desc: buildVideoDescRichText(INFODATA.data.data.desc_v2, INFODATA.data.data.desc), dynamic_text: dynamicText, dianzan: Count(INFODATA.data.data.stat.like), pinglun: Count(INFODATA.data.data.stat.reply), share: Count(INFODATA.data.data.stat.share), view: Count(INFODATA.data.data.stat.view), coin: Count(INFODATA.data.data.stat.coin), duration_text: data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.archive?.duration_text ?? '0:00', page_length: INFODATA.data.data.pages.length, create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.modules.module_author.pub_ts), avatar_url: userINFO.data.data.card.face, frame: data[dynamicId].Dynamic_Data.modules.module_author.pendant.image, share_url: 'https://www.bilibili.com/video/' + bvid, usernameMeta: getUsernameMetadata(userINFO.data.data.card), fans: Count(userINFO.data.data.follower), user_shortid: data[dynamicId].host_mid, total_favorited: Count(userINFO.data.data.like_num), following_count: Count(userINFO.data.data.card.attention), render_time: TimeFormatter.now(), dynamicTYPE: '视频动态推送', dynamic_id: dynamicId, staff }) } break } /** 处理直播动态 */ case DynamicType.LIVE_RCMD: { const liveInfo = JSON.parse(data[dynamicId].Dynamic_Data.modules.module_dynamic.major.live_rcmd.content) img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_LIVE_RCMD', { image_url: liveInfo.live_play_info.cover, text: buildBilibiliDynamicRichText(liveInfo.live_play_info.title, []), liveinf: br(`${liveInfo.live_play_info.area_name} | 房间号: ${liveInfo.live_play_info.room_id}`), usernameMeta: getUsernameMetadata(userINFO.data.data.card), avatar_url: userINFO.data.data.card.face, frame: data[dynamicId].Dynamic_Data.modules.module_author.pendant.image, fans: Count(userINFO.data.data.follower), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.modules.module_author.pub_ts), now_time: TimeFormatter.now(), share_url: 'https://live.bilibili.com/' + liveInfo.live_play_info.room_id, dynamicTYPE: '直播动态推送' }) break } /** 处理转发动态 */ case DynamicType.FORWARD: { // 处理话题 const md = data[dynamicId].Dynamic_Data.modules.module_dynamic if (md.topic) { const { name } = md.topic const desc = md.desc! desc.rich_text_nodes ??= [] desc.rich_text_nodes.unshift({ orig_text: name, jump_url: '', text: name, type: 'topic' }) desc.text = desc.text ? `${name}\n\n${desc.text}` : name } const text = buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.modules.module_dynamic.desc!.text, data[dynamicId].Dynamic_Data.modules.module_dynamic.desc!.rich_text_nodes ) /** 富文本节点:查看图片 */ const imgList = [] if (data[dynamicId].Dynamic_Data.modules.module_dynamic.desc) { for (const richTxtItem of data[dynamicId].Dynamic_Data.modules.module_dynamic.desc.rich_text_nodes) { if (richTxtItem.type === 'RICH_TEXT_NODE_TYPE_VIEW_PICTURE') { for (const pic of richTxtItem.pics) { imgList.push(pic.src) } } } } let original_content: BilibiliForwardOriginalContentProps['original_content'] = {} switch (data[dynamicId].Dynamic_Data.orig.type) { case DynamicType.AV: { const desc = data[dynamicId].Dynamic_Data.orig.modules.module_dynamic?.desc || { text: '', rich_text_nodes: [] } original_content = { DYNAMIC_TYPE_AV: { usernameMeta: getUsernameMetadata(data[dynamicId].Dynamic_Data.orig.modules.module_author), avatar_url: data[dynamicId].Dynamic_Data.orig.modules.module_author.face, duration_text: data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.archive?.duration_text, text: buildBilibiliDynamicRichText(desc.text, desc.rich_text_nodes), title: buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.archive?.title ?? '', [] ), danmaku: data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.archive?.stat.danmaku, play: data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.archive?.stat.play, cover: data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.archive?.cover, create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.orig.modules.module_author.pub_ts), decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.orig.modules.module_author.decorate_card), frame: data[dynamicId].Dynamic_Data.orig.modules.module_author.pendant.image } } break } case DynamicType.DRAW: { // 处理话题 const origMd = data[dynamicId].Dynamic_Data.orig.modules.module_dynamic if (origMd.topic) { const { name } = origMd.topic as { name: string; id: number } const summary = origMd.major?.opus?.summary if (summary) { summary.rich_text_nodes ??= [] summary.rich_text_nodes.unshift({ orig_text: name, text: name, type: 'topic', rid: (origMd.topic as { id: number }).id.toString(), jump_url: '114514', style: { '114514': '1919810' } }) summary.text = summary.text ? `${name}\n${summary.text}` : name } } original_content = { DYNAMIC_TYPE_DRAW: { title: data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major?.opus?.title ?? undefined, usernameMeta: getUsernameMetadata(data[dynamicId].Dynamic_Data.orig.modules.module_author), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.orig.modules.module_author.pub_ts), avatar_url: data[dynamicId].Dynamic_Data.orig.modules.module_author.face, text: buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.opus.summary.text, data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.opus.summary.rich_text_nodes ), image_url: Object.values(data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.opus.pics) .filter((item): item is { url: string } => typeof item?.url === 'string') .map((item) => ({ image_src: item.url })), decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.orig.modules.module_author.decorate_card), frame: data[dynamicId].Dynamic_Data.orig.modules.module_author.pendant.image } } break } case DynamicType.WORD: { // 处理话题 const origMd = data[dynamicId].Dynamic_Data.orig.modules.module_dynamic if (origMd.topic) { const { name } = origMd.topic as { name: string; id: number } const summary = origMd.major?.opus?.summary if (summary) { summary.rich_text_nodes ??= [] summary.rich_text_nodes.unshift({ orig_text: name, text: name, type: 'topic', rid: (origMd.topic as { id: number }).id.toString() }) summary.text = summary.text ? `${name}\n${summary.text}` : name } } original_content = { DYNAMIC_TYPE_WORD: { usernameMeta: getUsernameMetadata(data[dynamicId].Dynamic_Data.orig.modules.module_author), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.orig.modules.module_author.pub_ts), avatar_url: data[dynamicId].Dynamic_Data.orig.modules.module_author.face, text: buildBilibiliDynamicRichText( data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.opus.summary.text, data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.opus.summary.rich_text_nodes ), decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.orig.modules.module_author.decorate_card), frame: data[dynamicId].Dynamic_Data.orig.modules.module_author.pendant.image } } break } case DynamicType.LIVE_RCMD: { const liveData = JSON.parse(data[dynamicId].Dynamic_Data.orig.modules.module_dynamic.major.live_rcmd.content) original_content = { DYNAMIC_TYPE_LIVE_RCMD: { usernameMeta: getUsernameMetadata(data[dynamicId].Dynamic_Data.orig.modules.module_author), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.orig.modules.module_author.pub_ts), avatar_url: data[dynamicId].Dynamic_Data.orig.modules.module_author.face, decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.orig.modules.module_author.decorate_card), frame: data[dynamicId].Dynamic_Data.orig.modules.module_author.pendant.image, cover: liveData.live_play_info.cover, text_large: liveData.live_play_info.watched_show.text_large, area_name: liveData.live_play_info.area_name, title: buildBilibiliDynamicRichText(liveData.live_play_info.title, []), online: liveData.live_play_info.online } } break } default: { const key = dynamicId logger.warn( `UP主:${data[key].remark}的${logger.green('转发动态')}转发的原动态类型为「${logger.yellow(data[key].Dynamic_Data.orig?.type)}」暂未支持解析` ) break } } img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_FORWARD', { text, dianzan: Count(data[dynamicId].Dynamic_Data.modules.module_stat.like.count), pinglun: Count(data[dynamicId].Dynamic_Data.modules.module_stat.comment.count), share: Count(data[dynamicId].Dynamic_Data.modules.module_stat.forward.count), create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.modules.module_author.pub_ts), avatar_url: data[dynamicId].Dynamic_Data.modules.module_author.face, frame: data[dynamicId].Dynamic_Data.modules.module_author.pendant.image, share_url: 'https://t.bilibili.com/' + data[dynamicId].Dynamic_Data.id_str, usernameMeta: getUsernameMetadata(userINFO.data.data.card), fans: Count(userINFO.data.data.follower), user_shortid: data[dynamicId].Dynamic_Data.modules.module_author.mid, total_favorited: Count(userINFO.data.data.like_num), following_count: Count(userINFO.data.data.card.attention), dynamicTYPE: '转发动态推送', decoration_card: generateDecorationCard(data[dynamicId].Dynamic_Data.modules.module_author.decorate_card), render_time: TimeFormatter.now(), original_content, imgList: imgList.length > 0 ? imgList : null, dynamic_id: dynamicId }) break } /** 文章/专栏动态 */ case DynamicType.ARTICLE: { const articleInfoBase = await this.amagi.bilibili.fetcher.fetchArticleInfo({ id: data[dynamicId].Dynamic_Data.basic.rid_str }) const articleInfo = await this.amagi.bilibili.fetcher.fetchArticleContent({ id: data[dynamicId].Dynamic_Data.basic.rid_str }) // 提取专栏基本信息 const articleData = articleInfoBase.data.data // 提取专栏正文内容(生成类型里 `data` 可空:拿不到正文就没东西可推,按未支持类型一样跳过) const articleContent = articleInfo.data.data if (!articleContent) { skip = true logger.warn(`UP主:${data[dynamicId].remark} 的专栏 ${data[dynamicId].Dynamic_Data.basic.rid_str} 未返回正文,跳过本次推送`) break } const body = buildBilibiliArticleRichText(articleContent.opus, articleContent.content, Common.useDarkTheme()) const shareUrl = articleContent.dyn_id_str ? `https://www.bilibili.com/opus/${articleContent.dyn_id_str}` : `https://www.bilibili.com/read/cv${articleContent.id}` articleForwardPayload = { body, title: articleData.title, summary: articleData.summary, shareUrl } // 构建渲染数据 img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_ARTICLE', { // 用户信息 usernameMeta: getUsernameMetadata(data[dynamicId].Dynamic_Data.modules.module_author), avatar_url: data[dynamicId].Dynamic_Data.modules.module_author.face, frame: data[dynamicId].Dynamic_Data.modules.module_author.pendant.image, create_time: TimeFormatter.toDateTime(data[dynamicId].Dynamic_Data.modules.module_author.pub_ts), user_shortid: data[dynamicId].host_mid, fans: Count(userINFO.data.data.follower), total_favorited: Count(userINFO.data.data.like_num), following_count: Count(userINFO.data.data.card.attention), // 专栏内容信息 title: articleData.title, summary: articleData.summary, banner_url: articleData.banner_url || (articleData.image_urls && articleData.image_urls[0]) || '', categories: articleData.categories || [], words: articleData.words || 0, // 专栏正文内容(richtext 格式) body, // 统计信息 stats: articleData.stats, render_time: TimeFormatter.now(), // 分享链接 share_url: shareUrl, dynamicTYPE: '专栏动态推送' }) break } /** 未处理的动态类型 */ default: { skip = true const key = dynamicId logger.warn( `UP主:${data[key].remark}「${data[key].dynamic_type}」动态类型的暂未支持推送\n动态地址:${'https://t.bilibili.com/' + data[key].Dynamic_Data.id_str}\n可通过 https://github.com/ikenxuan/karin-plugin-kkk/issues/new/choose 提交反馈` ) break } } } // 遍历 targets 数组,并发送消息 for (const target of data[dynamicId].targets) { let status: SendMsgResults | null = null if (!skip) { const { groupId, botId } = target const bot = karin.getBot(botId) as AdapterType const Contact = karin.contactGroup(groupId) // 为当前目标注入 bot,后续解析下载沿用该 bot 身份 const eventWithBot = this.e as Message & { bot?: AdapterType; selfId?: string } eventWithBot.bot = bot eventWithBot.selfId = botId const pushImg = img ?? [] /** * 卡片末尾追加「解析」入口:点一下按这条动态 / 视频的地址解析。 * * 以前用的是 `segment.button`(旧式回调按钮),而且只认 `adapter.name === 'QQ Official Bot'`, * 结果很多部署上根本不出按钮。现在和卡片「提取图」按钮同一套写法: * QQ / QQ 频道给 markdown 按钮,其它平台给「引用这条消息发送指令」的文字提示。 */ const parseUrl = buildParseUrl(data[dynamicId]) const parseButton = parseCommandActions({ bot }, parseUrl) status = await karin.sendMsg(botId, Contact, [...pushImg, ...parseButton]) const shouldParseDynamic = Config.bilibili.push.parsedynamic && Config.bilibili.push.parseDynamicTypes.includes( data[dynamicId].dynamic_type as 'DYNAMIC_TYPE_AV' | 'DYNAMIC_TYPE_DRAW' | 'DYNAMIC_TYPE_ARTICLE' ) if (shouldParseDynamic && status.messageId) { switch (data[dynamicId].dynamic_type) { case 'DYNAMIC_TYPE_AV': { if (send_video) { let correctList!: { accept_description: string[] videoList: BilibiliVideoStreamResponse['data']['dash']['video'] } let videoSize = '' const videoInfo = await this.amagi.bilibili.fetcher.fetchVideoInfo({ bvid: data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.bvid }) const playUrlData = await this.amagi.bilibili.fetcher.fetchVideoStreamUrl({ avid: parseInt(data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.aid), cid: videoInfo.data.data.cid }) /** 提取出视频流信息对象,并排除清晰度重复的视频流 */ const simplify = playUrlData.data.data.dash.video.filter((item, index: any, self: any[]) => { return ( self.findIndex((t: { id: any }) => { return t.id === item.id }) === index ) }) /** 替换原始的视频信息对象 */ playUrlData.data.data.dash.video = simplify /** 没有音频流(如纯视频稿件)时拿不到音频地址,按只统计视频流大小处理 */ const audioUrl = playUrlData.data.data.dash.audio?.[0]?.base_url /** 给视频信息对象删除不符合条件的视频流 */ correctList = await bilibiliProcessVideos( { accept_description: playUrlData.data.data.accept_description, bvid: data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.bvid, qn: Config.bilibili.push.pushVideoQuality, maxAutoVideoSize: Config.bilibili.push.pushMaxAutoVideoSize }, simplify, audioUrl ) playUrlData.data.data.dash.video = correctList.videoList playUrlData.data.data.accept_description = correctList.accept_description /** 获取第一个视频流的大小 */ videoSize = await getvideosize( correctList.videoList[0].base_url, audioUrl, data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.bvid ) if (Config.app.usefilelimit && Number(videoSize) > Number(Config.app.filelimit) && !Config.app.compress) { await karin.sendMsg(botId, Contact, [ segment.text( `设定的最大上传大小为 ${Config.app.filelimit}MB\n当前解析到的视频大小为 ${Number(videoSize)}MB\n视频太大了,还是去B站看吧~` ), segment.reply(status.messageId) ]) break } logger.mark(`当前处于自动推送状态,解析到的视频大小为 ${logger.yellow(Number(videoSize))} MB`) const infoData = await this.amagi.bilibili.fetcher.fetchVideoInfo({ bvid: data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.bvid }) /** 直链候选:base_url(可能是 mcdn/PCDN 节点)+ backup_url 镜像,坏源由下载器自动换 */ const videoUrls = buildDashUrlCandidates(playUrlData.data?.data?.dash?.video[0]) const audioUrls = buildDashUrlCandidates(playUrlData.data?.data?.dash?.audio?.[0]) if (videoUrls.length === 0) break const mp4File = await downloadFile(videoUrls[0], { title: `Bil_V_${infoData.data.data.bvid}.mp4`, headers: bilibiliBaseHeaders, backupUrls: videoUrls.slice(1) }) const mp3File = audioUrls.length > 0 ? await downloadFile(audioUrls[0], { title: `Bil_A_${infoData.data.data.bvid}.mp3`, headers: bilibiliBaseHeaders, backupUrls: audioUrls.slice(1) }) : undefined if (mp4File.filepath && (!mp3File || mp3File.filepath)) { /** 没有音频流时无从合成,直接发送视频流 */ const resultPath = mp3File ? Common.tempDri.video + `Bil_Result_${infoData.data.data.bvid}.mp4` : mp4File.filepath const success = mp3File ? await mergeVideoAudio(mp4File.filepath, mp3File.filepath, resultPath) : true if (success) { const filePath = Common.tempDri.video + `tmp_${Date.now()}.mp4` fs.renameSync(resultPath, filePath) logger.mark(`视频文件重命名完成: ${resultPath.split('/').pop()} -> ${filePath.split('/').pop()}`) logger.mark('正在尝试删除缓存文件') if (fs.existsSync(mp4File.filepath)) await Common.removeFile(mp4File.filepath, true) if (mp3File) await Common.removeFile(mp3File.filepath, true) const stats = fs.statSync(filePath) const fileSizeInMB = Number((stats.size / (1024 * 1024)).toFixed(2)) if (fileSizeInMB > Config.app.groupfilevalue) { // 使用文件上传 await uploadFile( this.e, { filepath: filePath, totalBytes: fileSizeInMB, originTitle: `${infoData.data.data.desc.substring(0, 50).replace(/[\\/:\\*\\?"<>\\|\r\n\s]/g, ' ')}` }, '', { useGroupFile: true, active: true, activeOption: { group_id: groupId, uin: botId } } ) } else { /** 因为本地合成,没有视频直链 */ await uploadFile(this.e, { filepath: filePath, totalBytes: fileSizeInMB }, '', { active: true, activeOption: { group_id: groupId, uin: botId } }) } } else { await Common.removeFile(mp4File.filepath, true) if (mp3File) await Common.removeFile(mp3File.filepath, true) } } } break } case 'DYNAMIC_TYPE_DRAW': { const imgArray = [] const temp: fileInfo[] = [] let hasGeneratedLivePhoto = false // 标记是否生成了实况图 const title = data[dynamicId].Dynamic_Data.modules.module_dynamic.major?.opus?.title || 'bilibili_dynamic' const images = (data[dynamicId].Dynamic_Data.modules.module_dynamic.major && data[dynamicId].Dynamic_Data.modules.module_dynamic?.major?.draw?.items) || data[dynamicId].Dynamic_Data.modules.module_dynamic?.major?.opus.pics if (images.length === 0) break for (const [index, img2] of images.entries()) { const imageSrc = img2.src ?? img2.url if (img2.live_url && imageSrc) { const livePhoto = await downloadFile(img2.live_url, { title: `Bilibili_tmp_V_${Date.now()}_${index}.mp4`, headers: bilibiliBaseHeaders }) if (livePhoto.filepath) { const outputPath = Common.tempDri.video + `Bilibili_Live_${Date.now()}_${index}.mp4` const staticImg = await downloadFile(imageSrc, { title: `Bilibili_static_${Date.now()}_${index}.jpg`, headers: bilibiliBaseHeaders, filepath: Common.tempDri.images + `Bilibili_static_${Date.now()}_${index}.jpg` }) if (staticImg.filepath) { temp.push({ filepath: staticImg.filepath, totalBytes: 0 }) } // 根据 livePhotoMode 配置决定处理方式 const livePhotoMode = Config.app.livePhotoMode ?? 'video_and_livephoto' const shouldGenerateVideo = livePhotoMode === 'video_and_livephoto' || livePhotoMode === 'video_only' const shouldGenerateLivePhoto = livePhotoMode === 'video_and_livephoto' || livePhotoMode === 'livephoto_only' const loopCount = 3 if (!staticImg.filepath) { await Common.removeFile(livePhoto.filepath, true) continue } // 生成视频 if (shouldGenerateVideo) { const result = await loopVideoWithTransition({ inputPath: livePhoto.filepath, outputPath, loopCount, staticImagePath: staticImg.filepath, transitionEnabled: loopCount > 1 }) const success = result.success if (success) { const filePath = Common.tempDri.video + `tmp_${Date.now()}.mp4` fs.renameSync(outputPath, filePath) logger.mark(`视频文件重命名完成: ${outputPath.split('/').pop()} -> ${filePath.split('/').pop()}`) temp.push({ filepath: filePath, totalBytes: 0 }) const videoPath = Config.app.videoSendMode === 'base64' ? `base64://${fs.readFileSync(filePath).toString('base64')}` : `file://${filePath}` imgArray.push(segment.video(videoPath)) } } // 生成实况图 if (shouldGenerateLivePhoto) { let hasPushedMotionPhotoCover = false if (staticImg.filepath) { const motionPhotoCoverPath = Common.tempDri.images + `MVIMG_${Date.now()}_${index}.jpg` const motionPhotoCreated = await buildGoogleMotionPhoto({ imagePath: staticImg.filepath, videoPath: livePhoto.filepath, outputPath: motionPhotoCoverPath }) if (motionPhotoCreated) { temp.push({ filepath: motionPhotoCoverPath, totalBytes: 0 }) const motionPhotoCover = processLocalImageFile(motionPhotoCoverPath) imgArray.push(segment.image(motionPhotoCover)) hasPushedMotionPhotoCover = true } } if (!hasPushedMotionPhotoCover) { const imageUrl = await processImageUrl(imageSrc, title, index) imgArray.push(segment.image(imageUrl)) } else { hasGeneratedLivePhoto = true // 标记已生成实况图 } } logger.mark('正在尝试删除缓存文件') await Common.removeFile(livePhoto.filepath, true) continue } } if (imageSrc) { const imageUrl = await processImageUrl(imageSrc, title, index) imgArray.push(segment.image(imageUrl)) } } // 如果生成了实况图,添加提示文字 if (hasGeneratedLivePhoto) { const tipImg = await Render(this.e, 'other/live-photo-tip', { title: '实况照片已生成', description: '保存原图到相册即可识别为实况图' }) imgArray.push(...tipImg) } const forwardMsg = common.makeForward(imgArray, botId, bot.account.name) try { await bot.sendForwardMsg(Contact, forwardMsg, { source: '图片合集', summary: `查看${imgArray.length}张图片消息`, prompt: 'B站图文动态解析结果', news: [{ text: '点击查看解析结果' }] }) } finally { for (const item of temp) { await Common.removeFile(item.filepath, true) } } break } case 'DYNAMIC_TYPE_ARTICLE': { let payload = articleForwardPayload if (!payload) { const articleInfoBase = await this.amagi.bilibili.fetcher.fetchArticleInfo({ id: data[dynamicId].Dynamic_Data.basic.rid_str }) const articleInfo = await this.amagi.bilibili.fetcher.fetchArticleContent({ id: data[dynamicId].Dynamic_Data.basic.rid_str }) // 生成类型里 `data` 可空,没正文就没什么可转发的 const articleContent = articleInfo.data.data if (!articleContent) { logger.warn(`专栏 ${data[dynamicId].Dynamic_Data.basic.rid_str} 未返回正文,跳过解析`) break } payload = { body: buildBilibiliArticleRichText(articleContent.opus, articleContent.content, Common.useDarkTheme()), title: articleInfoBase.data.data.title, summary: articleInfoBase.data.data.summary, shareUrl: articleContent.dyn_id_str ? `https://www.bilibili.com/opus/${articleContent.dyn_id_str}` : `https://www.bilibili.com/read/cv${articleContent.id}` } } const title = payload.title || 'bilibili_article' const messageElements = await buildBilibiliRichTextForwardMessage(payload.body, { title: payload.title, summary: payload.summary, shareUrl: payload.shareUrl, imageResolver: (src, index) => processImageUrl(src, title, index) }) if (messageElements.length > 0) { const forwardMsg = common.makeForward(messageElements, botId, bot.account.name) await bot.sendForwardMsg(Contact, forwardMsg, { source: '专栏内容', summary: `查看${messageElements.length}条专栏内容`, prompt: 'B站专栏动态解析结果', news: [{ text: '点击查看解析结果' }] }) } } } } } if (skip || (status && status?.messageId)) { // 使用新的数据库API添加动态缓存 await bilibiliDB.addDynamicCache(dynamicId, data[dynamicId].host_mid, target.groupId, data[dynamicId].dynamic_type) } } } } /** * 根据配置文件获取UP当天的动态列表。 * @param userList B站订阅列表。 * @returns */ async getDynamicList(userList: bilibiliPushItem[]) { const directLiveItems = await this.getDirectLivePushItems(userList) const willbepushlist: WillBePushList = { ...directLiveItems.willBePushList } try { /** 过滤掉不启用的订阅项 */ const filteredUserList = userList.filter((item) => item.switch !== false) for (const item of filteredUserList) { const pushTypes = item.pushTypes || allBilibiliPushTypes const allowedDynamicTypes = new Set(pushTypes.map((pt) => pushTypeToDynamicType[pt])) if (directLiveItems.handledUids.has(item.host_mid)) { allowedDynamicTypes.delete(DynamicType.LIVE_RCMD) } if (allowedDynamicTypes.size === 0) continue await common.sleep(2000) logger.debug(`[Bilibili 推送] 开始获取UP: ${item.remark}(${item.host_mid}) 的动态列表`) const dynamic_list = await this.amagi.bilibili.fetcher.fetchUserDynamicList({ host_mid: item.host_mid }) // 生成树这条响应没按动态类型分形状,推送链路要的是判别联合(见 DataItem 的说明),这里换一次 const dynamicItems = dynamic_list.data.data.items as unknown as DataItem[] if (dynamicItems.length > 0) { // 遍历接口返回的视频列表 for (const dynamic of dynamicItems) { const nowSeconds = Math.floor(Date.now() / 1000) // 当前时间戳(秒) const createTime = dynamic.modules.module_author.pub_ts // 发布时间戳(秒) const timeDifference = nowSeconds - createTime // 时间差(秒) const is_top = dynamic.modules.module_tag?.text === '置顶' // 是否为置顶 let shouldPush = false // 是否列入推送数组 const timeDiffHours = Math.round((timeDifference / 3600) * 100) / 100 // 保留2位小数 // 条件判断,以下任何一项成立都将进行推送:如果是置顶且发布时间在一天内 || 如果是置顶作品且有新的群组且发布时间在一天内 || 如果有新的群组且发布时间在一天内 logger.trace(` 前期获取该动态基本信息: UP主:${dynamic.modules.module_author.name} 动态ID:${dynamic.id_str} 发布时间:${TimeFormatter.toDateTime(createTime)} 发布时间戳(s):${createTime} 当前时间戳(s):${nowSeconds} 时间差(s):${timeDifference}s (${timeDiffHours}h) 是否置顶:${is_top} 是否在一天内:${timeDifference < 86400 ? logger.green('true') : logger.red('false')} `) if ((is_top && timeDifference < 86400) || timeDifference < 86400) { shouldPush = true logger.trace( logger.green(`根据以上判断,shoulPush 为 true,将对该动态纳入当天推送列表:https://t.bilibili.com/${dynamic.id_str}\n`) ) } else logger.trace(logger.yellow(`根据以上判断,shoulPush 为 false,跳过该动态:https://t.bilibili.com/${dynamic.id_str}\n`)) // 如果 shouldPush 为 true,或该作品距现在的时间差小于一天,则将该动态添加到 willbepushlist 中 if (timeDifference < 86400 || shouldPush) { // 根据推送类型过滤 if (!allowedDynamicTypes.has(dynamic.type as DynamicType)) { logger.debug( `UP主 ${item.remark}(${item.host_mid})的动态 ${dynamic.id_str} 类型为「${dynamic.type}」,不在推送类型配置中,跳过` ) continue } // 将群组ID和机器人ID分离 const targets = item.group_id.map((groupWithBot) => { const [groupId, botId] = groupWithBot.split(':') return { groupId, botId } }) const pushId = dynamic.type === DynamicType.LIVE_RCMD ? await this.resolveLiveDynamicCacheId(dynamic as Extract, item.host_mid) : dynamic.id_str // 确保 willbepushlist[pushId] 是一个对象 if (!willbepushlist[pushId]) { willbepushlist[pushId] = { remark: item.remark, host_mid: item.host_mid, create_time: dynamic.modules.module_author.pub_ts, targets, Dynamic_Data: dynamic, // 存储 dynamic 对象 avatar_img: dynamic.modules.module_author.face, dynamic_type: dynamic.type as DynamicType } as BilibiliPushItem } } } } else { } } } catch (error) { throw new Error(`获取动态列表失败: ${error}`) } return { willbepushlist } } /** * 排除已推送过的群组并返回更新后的推送列表 * @param willBePushList 将要推送的列表 * @returns 更新后的推送列表 */ async excludeAlreadyPushed(willBePushList: WillBePushList): Promise { // 遍历推送列表中的作品ID for (const dynamicId in willBePushList) { const pushItem = willBePushList[dynamicId] const newTargets: Array<{ groupId: string; botId: string }> = [] // 遍历作品对应的目标群组 for (const target of pushItem.targets) { // 检查该动态是否已经推送给该群组 const isPushed = await bilibiliDB.isDynamicPushed(dynamicId, pushItem.host_mid, target.groupId) // 如果未被推送过,则保留此目标 if (!isPushed) { newTargets.push(target) } } // 更新作品的目标数组 if (newTargets.length > 0) { pushItem.targets = newTargets } else { // 如果没有剩余目标,移除该作品 delete willBePushList[dynamicId] } } return willBePushList } /** * 设置或更新特定 host_mid 的群组信息。 * @param data 包含 card 对象。 * @returns 操作成功或失败的消息字符串。 */ async setting(data: BilibiliUserCardResponse): Promise { const groupInfo = await this.e.bot.getGroupInfo('groupId' in this.e && this.e.groupId ? this.e.groupId : '') const host_mid = Number(data.data.card.mid) const config = Config.pushlist // 读取配置文件 const groupId = 'groupId' in this.e && this.e.groupId ? this.e.groupId : '' const botId = this.e.selfId // 初始化或确保 bilibilipushlist 数组存在 if (!config.bilibili) { config.bilibili = [] } // 检查是否存在相同的 host_mid const existingItem = config.bilibili.find((item: { host_mid: number }) => item.host_mid === host_mid) // 检查该群组是否已订阅该UP主 const isSubscribed = await bilibiliDB.isSubscribed(host_mid, groupId) if (existingItem) { // 如果已经存在相同的 host_mid,则检查是否存在相同的 group_id let has = false let groupIndexToRemove = -1 // 用于记录要删除的 group_id 对象的索引 for (let index = 0; index < existingItem.group_id.length; index++) { const item = existingItem.group_id[index] const existingGroupId = item.split(':')[0] if (existingGroupId === String(groupId)) { has = true groupIndexToRemove = index break } } if (has) { // 如果已存在相同的 group_id,则删除它 existingItem.group_id.splice(groupIndexToRemove, 1) // 在数据库中取消订阅 if (isSubscribed) { await bilibiliDB.unsubscribeBilibiliUser(groupId, host_mid) } // 如果删除后 group_id 数组为空,则删除整个属性 if (existingItem.group_id.length === 0) { const index = config.bilibili.indexOf(existingItem) config.bilibili.splice(index, 1) } // 保存配置到文件 Config.Modify('pushlist', 'bilibili', config.bilibili) await this.e.reply(`群:${groupInfo.groupName}(${groupId})\n删除成功!${data.data.card.name}\nUID:${host_mid}`) logger.info(`\n删除成功!${data.data.card.name}\nUID:${host_mid}`) } else { // 在数据库中添加订阅 await bilibiliDB.subscribeBilibiliUser(groupId, botId, host_mid, data.data.card.name) // 将新的 group_id 添加到该 host_mid 对应的数组中 existingItem.group_id.push(`${groupId}:${botId}`) // 确保 pushTypes 字段存在,如果不存在则补全为全部推送类型 if (!existingItem.pushTypes || existingItem.pushTypes.length === 0) { existingItem.pushTypes = [...allBilibiliPushTypes] } // 保存配置到文件 Config.Modify('pushlist', 'bilibili', config.bilibili) await this.e.reply(`群:${groupInfo.groupName}(${groupId})\n添加成功!${data.data.card.name}\nUID:${host_mid}`) if (Config.bilibili.push.switch === false) await this.e.reply('请发送「#设置B站推送开启」以进行推送') logger.info(`\n设置成功!${data.data.card.name}\nUID:${host_mid}`) } } else { // 在数据库中添加订阅 await bilibiliDB.subscribeBilibiliUser(groupId, botId, host_mid, data.data.card.name) // 不存在相同的 host_mid,新增一个配置项 config.bilibili.push({ switch: true, host_mid, group_id: [`${groupId}:${botId}`], remark: data.data.card.name, pushTypes: [...allBilibiliPushTypes] }) // 保存配置到文件 Config.Modify('pushlist', 'bilibili', config.bilibili) await this.e.reply(`群:${groupInfo.groupName}(${groupId})\n添加成功!${data.data.card.name}\nUID:${host_mid}`) if (Config.bilibili.push.switch === false) await this.e.reply('请发送「#设置B站推送开启」以进行推送') logger.info(`\n设置成功!${data.data.card.name}\nUID:${host_mid}`) } // 渲染状态图片 await this.renderPushList() } /** * 检查并更新配置文件中指定用户的备注信息。 * 该函数会遍历配置文件中的用户列表,对于没有备注或备注为空的用户,会从外部数据源获取其备注信息,并更新到配置文件中。 */ async checkremark() { // 读取配置文件内容 const config = Config.pushlist const abclist: { host_mid: number }[] | { host_mid: number; group_id: string[] }[] = [] if (Config.pushlist.bilibili === null || Config.pushlist.bilibili.length === 0) return true // 遍历配置文件中的用户列表,收集需要更新备注信息的用户 for (const i of Config.pushlist.bilibili) { const remark = i.remark const group_id = i.group_id const host_mid = i.host_mid if (remark === undefined || remark === '') { abclist.push({ host_mid, group_id }) } } // 如果有需要更新备注的用户,则逐个获取备注信息并更新到配置文件中 if (abclist.length > 0) { for (const i of abclist) { // 从外部数据源获取用户备注信息 const resp = await this.amagi.bilibili.fetcher.fetchUserCard({ host_mid: i.host_mid }) const remark = resp.data.data.card.name // 在配置文件中找到对应的用户,并更新其备注信息 const matchingItemIndex = config.bilibili.findIndex((item) => item.host_mid === i.host_mid) if (matchingItemIndex !== -1) { config.bilibili[matchingItemIndex].remark = remark } } // 将更新后的配置文件内容写回文件 Config.Modify('pushlist', 'bilibili', config.bilibili) } } /** * 强制推送 * @param data 处理完成的推送列表 */ async forcepush(data: WillBePushList) { const currentGroupId = 'groupId' in this.e && this.e.groupId ? this.e.groupId : '' const currentBotId = this.e.selfId // 如果不是全部强制推送,需要过滤数据 if (!this.e.msg.includes('全部')) { // 获取当前群组订阅的所有UP主 const subscriptions = await bilibiliDB.getGroupSubscriptions(currentGroupId) const subscribedUids = subscriptions.map((sub) => sub.host_mid) /** 创建一个新的推送列表,只包含当前群组订阅的UP主的动态 */ const filteredData: WillBePushList = {} for (const dynamicId in data) { // 检查该动态的UP主是否被当前群组订阅 if (subscribedUids.includes(data[dynamicId].host_mid)) { // 复制该动态到过滤后的列表,并将目标设置为当前群组 filteredData[dynamicId] = { ...data[dynamicId], targets: [ { groupId: currentGroupId, botId: currentBotId } ] } as BilibiliPushItem } } // 使用过滤后的数据进行推送 await this.getdata(filteredData) } else { // 全部强制推送,保持原有逻辑 await this.getdata(data) } } /** 渲染推送列表图片 */ async renderPushList() { await this.syncConfigToDatabase() const groupInfo = await this.e.bot.getGroupInfo('groupId' in this.e && this.e.groupId ? this.e.groupId : '') // 获取当前群组的所有订阅 const subscriptions = await bilibiliDB.getGroupSubscriptions(groupInfo.groupId) if (subscriptions.length === 0) { await this.e.reply( `当前群:${groupInfo.groupName}(${groupInfo.groupId})\n没有设置任何B站UP推送!\n可使用「#设置B站推送 + UP主UID」进行设置` ) return } /** 用户的今日动态列表 */ const renderOpt: BilibiliUserListData['renderOpt'] = [] // 获取所有订阅UP主的信息 for (const subscription of subscriptions) { const host_mid = subscription.host_mid const userInfo = await this.amagi.bilibili.fetcher.fetchUserCard({ host_mid }) // 查找配置文件中对应的全局开关状态 const configItem = Config.pushlist.bilibili?.find((item: bilibiliPushItem) => item.host_mid === host_mid) const switchStatus = configItem?.switch !== false // 默认为 true const pushTypes = configItem?.pushTypes || allBilibiliPushTypes renderOpt.push({ avatar_img: userInfo.data.data.card.face, username: userInfo.data.data.card.name, host_mid: userInfo.data.data.card.mid, fans: Count(userInfo.data.data.follower), total_favorited: Count(userInfo.data.data.like_num), following_count: Count(userInfo.data.data.card.attention), switch: switchStatus, pushTypes }) } const img = await Render(this.e, 'bilibili/userlist', { renderOpt, groupInfo: { groupId: groupInfo.groupId || '', groupName: groupInfo.groupName || '', groupAvatar: groupInfo.avatar || '' } }) await this.e.reply(img) } } /** * 把直播状态接口的数据适配为现有直播动态分支实际读取的字段。 * * 后续过滤、渲染、发送和缓存均继续使用统一的动态推送链路。 */ const createLiveDynamicItem = ( sessionId: string, liveStartedAt: string, liveInfo: BilibiliLiveRoomInfoResponse['data'], liveStatus: BilibiliUserLiveStatusResponse['data'] ): Extract => { const content = JSON.stringify({ live_play_info: { area_name: liveInfo.area_name, cover: liveInfo.user_cover || liveStatus.cover, room_id: liveInfo.room_id, title: liveInfo.title || liveStatus.title } }) return { id_str: sessionId, type: DynamicType.LIVE_RCMD, modules: { module_author: { face: '', pendant: { image: '' }, pub_ts: Math.floor(Date.parse(liveStartedAt) / 1000) }, module_dynamic: { additional: null, desc: null, major: { live_rcmd: { content, reserve_type: 0 }, type: MajorType.LIVE_RCMD }, topic: null } } } as unknown as Extract } /** 将未知异常转换为适合业务日志展示的文本。 */ const formatErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)) /** * 将换行符替换为HTML的
标签。 * @param data 需要进行换行符替换的字符串。 * @returns 替换后的字符串,其中的换行符\n被
替换。 */ const br = (data: string): string => { // 使用正则表达式将所有换行符替换为
return (data = data.replace(/\n/g, '
')) } /** 动态里那些「不是作品、解析了也没用」的链接 */ const NON_WORK_URL = /t\.bilibili\.com|space\.bilibili\.com|bilibili\.com\/read|live\.bilibili\.com|bilibili\.com\/opus/i /** 能解析的作品链接(B站视频 / 短链,以及 UP 主偶尔分享的站外作品) */ const WORK_URL = /bilibili\.com\/(video|bangumi|list)|b23\.tv|bili2233|\bBV[0-9A-Za-z]{10}\b|douyin|iesdouyin|kuaishou|xiaohongshu|xhslink/i /** * 从一堆链接里挑第一条**能解析的作品链接**。 * * 动态正文里的跳转链接什么都有:话题页、@某人、个人主页、专栏、直播间…… * 这些点了解析也解析不出东西(插件只认作品链接),所以这里过一遍,只留作品。 * @param urls 候选链接 * @returns 第一条作品链接;一个都没有就是空串 */ const firstParseableUrl = (urls: string[]): string => { for (const url of urls) { if (!/^https?:\/\//i.test(url)) continue if (NON_WORK_URL.test(url)) continue if (WORK_URL.test(url)) return url } return '' } /** * 动态里「值得解析」的那条地址 —— **不是这条动态本身**。 * * 推送卡片已经把这条动态整个渲染出来了,再点「解析」去解析同一条动态, * 等于把刚才那张卡片原样再发一遍,没有任何意义。用户点它是想要**动态里面带的那个作品** * (比如 UP 主分享的视频)。所以: * - 视频动态 → 里面的 BV; * - **转发动态** → 被转发的那条内容(递归一层,转发本身就是个壳); * - 其它动态 → 翻正文富文本 / 网页卡片里的跳转链接,取第一条作品链接。 * 什么都没带(纯文字、纯图文、没链接的转发)→ 返回空串,**这时干脆不给按钮**。 * @param item 一条动态(转发时递归传 `orig`) * @param depth 递归层数,防一手自引用 * @returns 解析地址;没有可解析的内容时是空串 */ const extractDynamicParseUrl = (item: any, depth = 0): string => { if (!item || depth > 2) return '' const md = item.modules?.module_dynamic ?? {} // 转发:要点的是被转发的那条内容,不是这条转发本身 if (item.orig) { const inner = extractDynamicParseUrl(item.orig, depth + 1) if (inner) return inner } // 视频(自己的视频动态 / 被转发的视频) const bvid = md.major?.archive?.bvid if (bvid) return `https://www.bilibili.com/video/${bvid}` // 正文富文本里的跳转链接:图文 / 纯文字走 `opus.summary`,转发走 `desc` const candidates: string[] = [] const collect = (nodes: any) => { for (const node of nodes ?? []) { const url = String(node?.jump_url ?? '') if (url) candidates.push(url) } } collect(md.desc?.rich_text_nodes) collect(md.major?.opus?.summary?.rich_text_nodes) // 网页卡片(MAJOR_TYPE_COMMON,比如分享站外作品) const commonUrl = String(md.major?.common?.jump_url ?? '') if (commonUrl) candidates.push(commonUrl) return firstParseableUrl(candidates) } /** * 构造「解析」按钮的地址。 * * 见 {@link extractDynamicParseUrl}:**解析的是动态里面的作品,不是动态本身** —— * 推送卡片已经把动态渲染出来了,重复解析同一条动态没有意义。 * 直播推荐没有作品可解析,返回空串(不追加按钮)。 * @param PushItem 推送项 * @returns 解析地址,没有可解析的内容时返回空字符串 */ const buildParseUrl = (PushItem: BilibiliPushItem): string => { if (PushItem.dynamic_type === DynamicType.LIVE_RCMD) return '' return extractDynamicParseUrl(PushItem.Dynamic_Data) } /** * 判断标题是否有屏蔽词或屏蔽标签 * @param PushItem 推送项 * @returns 是否应该跳过推送 */ const skipDynamic = async (PushItem: BilibiliPushItem): Promise => { const tags: string[] = [] // 提取标签 if (PushItem.Dynamic_Data.modules.module_dynamic?.desc?.rich_text_nodes) { for (const node of PushItem.Dynamic_Data.modules.module_dynamic.desc.rich_text_nodes) { if (node.type === 'topic') { if (node.orig_text) { tags.push(node.orig_text) } } } } // 检查转发的原动态标签 if (PushItem.Dynamic_Data.type === DynamicType.FORWARD && 'orig' in PushItem.Dynamic_Data) { if ( PushItem.Dynamic_Data.orig.modules.module_dynamic.major.type === MajorType.DRAW || PushItem.Dynamic_Data.orig.modules.module_dynamic.major.type === MajorType.OPUS || PushItem.Dynamic_Data.orig.modules.module_dynamic.major.type === MajorType.LIVE_RCMD ) { for (const node of PushItem.Dynamic_Data.orig.modules.module_dynamic.major.opus.summary.rich_text_nodes) { if (node.type === 'topic') { tags.push(node.orig_text) } } } } logger.debug(`检查动态是否需要过滤:https://t.bilibili.com/${PushItem.Dynamic_Data.id_str}`) const shouldFilter = await bilibiliDB.shouldFilter(PushItem, tags) return shouldFilter }