From 8bb3d6f019ca950fb855401f56f5900b052055ba Mon Sep 17 00:00:00 2001 From: Pilgrim Lyieu <80107081+pilgrimlyieu@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:34:43 +0800 Subject: [PATCH] feat(bilibili): add messages routes (#20890) * feat(bilibili): add messages routes * fix(bilibili/message): do not display summary when no unread messages * fix(bilibili/message): update cache duration to use configurable routeExpire --- lib/routes/bilibili/message-at.ts | 146 ++++++++++++++ lib/routes/bilibili/message-like.ts | 163 ++++++++++++++++ lib/routes/bilibili/message-reply.ts | 164 ++++++++++++++++ lib/routes/bilibili/message-sessions.ts | 242 ++++++++++++++++++++++++ lib/routes/bilibili/message-system.ts | 143 ++++++++++++++ lib/routes/bilibili/message-unread.ts | 219 +++++++++++++++++++++ 6 files changed, 1077 insertions(+) create mode 100644 lib/routes/bilibili/message-at.ts create mode 100644 lib/routes/bilibili/message-like.ts create mode 100644 lib/routes/bilibili/message-reply.ts create mode 100644 lib/routes/bilibili/message-sessions.ts create mode 100644 lib/routes/bilibili/message-system.ts create mode 100644 lib/routes/bilibili/message-unread.ts diff --git a/lib/routes/bilibili/message-at.ts b/lib/routes/bilibili/message-at.ts new file mode 100644 index 000000000..c988a7fc5 --- /dev/null +++ b/lib/routes/bilibili/message-at.ts @@ -0,0 +1,146 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { DataItem, Route } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import cache from './cache'; + +export const route: Route = { + path: '/message/at/:uid', + categories: ['social-media'], + example: '/bilibili/message/at/2267573', + parameters: { uid: '用户 id' }, + features: { + requireConfig: [ + { + name: 'BILIBILI_COOKIE_*', + description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式: + 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8) + 2. 打开控制台,切换到 Network 面板,刷新 + 3. 点击 dynamic_new 请求,找到 Cookie + 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: '@我的', + maintainers: ['pilgrimlyieu'], + handler, + description: `:::warning + 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。 +:::`, +}; + +interface AtItem { + id: number; + user: { + mid: number; + fans: number; + nickname: string; + avatar: string; + mid_link: string; + follow: boolean; + }; + item: { + subject_id: number; + root_id: number; + source_id: number; + target_id: number; + type: string; + business_id: number; + business: string; + title: string; + desc: string; + image: string; + uri: string; + native_uri: string; + detail_title: string; + source_content: string; + at_details: unknown[]; + }; + at_time: number; +} + +interface AtResponse { + code: number; + message: string; + ttl: number; + data: { + cursor: { + is_end: boolean; + id: number; + time: number; + }; + items: AtItem[]; + }; +} + +async function handler(ctx) { + const uid = ctx.req.param('uid'); + const name = await cache.getUsernameFromUID(uid); + + const cookie = config.bilibili.cookies[uid]; + if (cookie === undefined) { + throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值'); + } + + const response = await ofetch('https://api.bilibili.com/x/msgfeed/at', { + query: { + platform: 'web', + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }); + + if (response.code !== 0) { + throw new Error(response.message ?? `Error code ${response.code}`); + } + + const items: DataItem[] = (response.data.items || []).map((item) => { + const atUser = item.user; + const atItem = item.item; + const sourceContent = atItem.source_content; + + let description = `

${atUser.nickname} @了你:

`; + description += `
${sourceContent}
`; + + if (atItem.image) { + description += `

`; + } + + description += `

来自:${atItem.business} - ${atItem.title}

`; + + // Generate link with root_id for direct navigation + let link = atItem.uri; + if (atItem.root_id && atItem.uri) { + link = `${atItem.uri}/#reply${atItem.root_id}`; + } else if (atItem.source_id && atItem.uri) { + link = `${atItem.uri}/#reply${atItem.source_id}`; + } + + return { + title: `${atUser.nickname} @了你:${sourceContent}`, + description, + link, + pubDate: parseDate(item.at_time * 1000), + author: atUser.nickname, + }; + }); + + return { + title: `${name} 的 B站消息 - @我的`, + link: 'https://message.bilibili.com/#/at', + description: `${name} 的 B站消息 - @我的`, + item: items, + allowEmpty: true, + }; +} diff --git a/lib/routes/bilibili/message-like.ts b/lib/routes/bilibili/message-like.ts new file mode 100644 index 000000000..fe164ae8b --- /dev/null +++ b/lib/routes/bilibili/message-like.ts @@ -0,0 +1,163 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { DataItem, Route } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import cache from './cache'; + +export const route: Route = { + path: '/message/like/:uid', + categories: ['social-media'], + example: '/bilibili/message/like/2267573', + parameters: { uid: '用户 id' }, + features: { + requireConfig: [ + { + name: 'BILIBILI_COOKIE_*', + description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式: + 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8) + 2. 打开控制台,切换到 Network 面板,刷新 + 3. 点击 dynamic_new 请求,找到 Cookie + 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: '收到的赞', + maintainers: ['pilgrimlyieu'], + handler, + description: `:::warning + 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。 +:::`, +}; + +interface LikeUser { + mid: number; + fans: number; + nickname: string; + avatar: string; + mid_link: string; + follow: boolean; +} + +interface LikeItem { + id: number; + users: LikeUser[]; + item: { + item_id: number; + pid: number; + type: string; + business: string; + business_id: number; + reply_business_id: number; + like_business_id: number; + title: string; + desc: string; + image: string; + uri: string; + detail_name: string; + native_uri: string; + ctime: number; + }; + counts: number; + like_time: number; + notice_state: number; +} + +interface LikeResponse { + code: number; + message: string; + ttl: number; + data: { + latest: { + items: LikeItem[]; + last_view_at: number; + }; + total: { + cursor: { + is_end: boolean; + id: number; + time: number; + }; + items: LikeItem[]; + }; + }; +} + +async function handler(ctx) { + const uid = ctx.req.param('uid'); + const name = await cache.getUsernameFromUID(uid); + + const cookie = config.bilibili.cookies[uid]; + if (cookie === undefined) { + throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值'); + } + + const response = await ofetch('https://api.bilibili.com/x/msgfeed/like', { + query: { + platform: 'web', + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }); + + if (response.code !== 0) { + throw new Error(response.message ?? `Error code ${response.code}`); + } + + const allItems = [...(response.data.latest?.items || []), ...(response.data.total?.items || [])]; + + // Deduplicate by id + const uniqueItems = allItems.filter((item, index, self) => index === self.findIndex((t) => t.id === item.id)); + + const items: DataItem[] = uniqueItems.map((item) => { + const likeUsers = item.users; + const likeItem = item.item; + const counts = item.counts; + + const userNames = likeUsers.map((u) => u.nickname).join('、'); + const displayNames = counts > likeUsers.length ? `${userNames} 等 ${counts} 人` : userNames; + + let description = `

${displayNames} 赞了你的${likeItem.business}:

`; + description += `

${likeItem.title}

`; + + if (likeItem.desc) { + description += `
${likeItem.desc}
`; + } + + if (likeItem.image) { + description += `

`; + } + + // Generate link based on type + let link = likeItem.uri; + if (likeItem.type === 'reply' && likeItem.item_id) { + link = `${likeItem.uri}/#reply${likeItem.item_id}`; + } + + return { + title: `${displayNames} 赞了你的${likeItem.business}「${likeItem.title}」`, + description, + link, + pubDate: parseDate(item.like_time * 1000), + author: userNames, + }; + }); + + return { + title: `${name} 的 B站消息 - 收到的赞`, + link: 'https://message.bilibili.com/#/love', + description: `${name} 的 B站消息 - 收到的赞`, + item: items, + allowEmpty: true, + }; +} diff --git a/lib/routes/bilibili/message-reply.ts b/lib/routes/bilibili/message-reply.ts new file mode 100644 index 000000000..8ad1a86c1 --- /dev/null +++ b/lib/routes/bilibili/message-reply.ts @@ -0,0 +1,164 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { DataItem, Route } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import cache from './cache'; + +export const route: Route = { + path: '/message/reply/:uid', + categories: ['social-media'], + example: '/bilibili/message/reply/2267573', + parameters: { uid: '用户 id' }, + features: { + requireConfig: [ + { + name: 'BILIBILI_COOKIE_*', + description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式: + 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8) + 2. 打开控制台,切换到 Network 面板,刷新 + 3. 点击 dynamic_new 请求,找到 Cookie + 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: '回复我的', + maintainers: ['pilgrimlyieu'], + handler, + description: `:::warning + 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。 +:::`, +}; + +interface ReplyItem { + id: number; + user: { + mid: number; + fans: number; + nickname: string; + avatar: string; + mid_link: string; + follow: boolean; + }; + item: { + subject_id: number; + root_id: number; + source_id: number; + target_id: number; + type: string; + business_id: number; + business: string; + title: string; + desc: string; + image: string; + uri: string; + native_uri: string; + detail_title: string; + root_reply_content: string; + source_content: string; + target_reply_content: string; + at_details: unknown[]; + hide_reply_button: boolean; + hide_like_button: boolean; + like_state: number; + danmu: unknown; + message: string; + }; + counts: number; + is_multi: number; + reply_time: number; +} + +interface ReplyResponse { + code: number; + message: string; + ttl: number; + data: { + cursor: { + is_end: boolean; + id: number; + time: number; + }; + items: ReplyItem[]; + last_view_at: number; + }; +} + +async function handler(ctx) { + const uid = ctx.req.param('uid'); + const name = await cache.getUsernameFromUID(uid); + + const cookie = config.bilibili.cookies[uid]; + if (cookie === undefined) { + throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值'); + } + + const response = await ofetch('https://api.bilibili.com/x/msgfeed/reply', { + query: { + platform: 'web', + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }); + + if (response.code !== 0) { + throw new Error(response.message ?? `Error code ${response.code}`); + } + + const items: DataItem[] = (response.data.items || []).map((item) => { + const replyUser = item.user; + const replyItem = item.item; + const sourceContent = replyItem.source_content; + const targetContent = replyItem.target_reply_content; + const rootContent = replyItem.root_reply_content; + + let description = `

${replyUser.nickname} 回复了你:

`; + description += `
${sourceContent}
`; + + if (targetContent) { + description += `

你的评论:

${targetContent}
`; + } else if (rootContent) { + description += `

你的评论:

${rootContent}
`; + } + + if (replyItem.image) { + description += `

`; + } + + description += `

来自:${replyItem.business} - ${replyItem.title}

`; + + // Generate comment link with root_id for direct navigation to the comment + let link = replyItem.uri; + if (replyItem.root_id && replyItem.uri) { + link = `${replyItem.uri}/#reply${replyItem.root_id}`; + } else if (replyItem.source_id && replyItem.uri) { + link = `${replyItem.uri}/#reply${replyItem.source_id}`; + } + + return { + title: `${replyUser.nickname} 回复了你:${sourceContent}`, + description, + link, + pubDate: parseDate(item.reply_time * 1000), + author: replyUser.nickname, + }; + }); + + return { + title: `${name} 的 B站消息 - 回复我的`, + link: 'https://message.bilibili.com/#/reply', + description: `${name} 的 B站消息 - 回复我的`, + item: items, + allowEmpty: true, + }; +} diff --git a/lib/routes/bilibili/message-sessions.ts b/lib/routes/bilibili/message-sessions.ts new file mode 100644 index 000000000..14844b927 --- /dev/null +++ b/lib/routes/bilibili/message-sessions.ts @@ -0,0 +1,242 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { DataItem, Route } from '@/types'; +import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import bilibiliCache from './cache'; + +export const route: Route = { + path: '/message/sessions/:uid', + categories: ['social-media'], + example: '/bilibili/message/sessions/2267573', + parameters: { uid: '用户 id' }, + features: { + requireConfig: [ + { + name: 'BILIBILI_COOKIE_*', + description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式: + 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8) + 2. 打开控制台,切换到 Network 面板,刷新 + 3. 点击 dynamic_new 请求,找到 Cookie + 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: '我的消息', + maintainers: ['pilgrimlyieu'], + handler, + description: `:::warning + 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。 +:::`, +}; + +interface SessionItem { + talker_id: number; + session_type: number; + at_seqno: number; + top_ts: number; + group_name: string; + group_cover: string; + is_follow: number; + is_dnd: number; + ack_seqno: number; + ack_ts: number; + session_ts: number; + unread_count: number; + last_msg: { + sender_uid: number; + receiver_type: number; + receiver_id: number; + msg_type: number; + content: string; + msg_seqno: number; + timestamp: number; + at_uids: number[] | null; + msg_key: number; + msg_status: number; + notify_code: string; + msg_source: number; + } | null; + group_type: number; + can_fold: number; + status: number; + max_seqno: number; + new_push_msg: number; + setting: number; + is_guardian: number; + is_intercept: number; + is_trust: number; + system_msg_type: number; + live_status: number; + biz_msg_unread_count: number; + user_label: unknown; +} + +interface SessionResponse { + code: number; + msg: string; + message: string; + ttl: number; + data: { + session_list: SessionItem[] | null; + has_more: number; + anti_disturb_cleaning: boolean; + is_address_list_empty: number; + system_msg: Record; + show_level: boolean; + }; +} + +interface UserInfo { + mid: string; + face: string; + name: string; +} + +interface UserCardsResponse { + code: number; + message: string; + ttl: number; + data: Record; +} + +/** + * Parse message content based on msg_type + * msg_type 1: text, 2: image, 5: recall, etc. + */ +function parseMessageContent(content: string, msgType: number): string { + try { + const parsed = JSON.parse(content); + switch (msgType) { + case 1: // Text message + return parsed.content || content; + case 2: // Image + return `[图片] ${parsed.url || ''}`; + case 5: // Recall + return '[消息已撤回]'; + case 6: // Sticker + return '[表情]'; + case 7: // Share + return `[分享] ${parsed.title || ''}`; + case 10: // System notification + return parsed.content || parsed.title || content; + case 11: // Video card + return `[视频] ${parsed.title || ''}`; + case 12: // Article card + return `[专栏] ${parsed.title || ''}`; + case 14: // Bangumi card + return `[番剧] ${parsed.title || ''}`; + default: + return parsed.content || parsed.title || content; + } + } catch { + return content; + } +} + +async function handler(ctx) { + const uid = ctx.req.param('uid'); + const name = await bilibiliCache.getUsernameFromUID(uid); + + const cookie = config.bilibili.cookies[uid]; + if (cookie === undefined) { + throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值'); + } + + const response = await ofetch('https://api.vc.bilibili.com/session_svr/v1/session_svr/get_sessions', { + query: { + session_type: 1, + group_fold: 1, + unfollow_fold: 0, + sort_rule: 2, + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }); + + if (response.code !== 0) { + throw new Error(response.message ?? response.msg ?? `Error code ${response.code}`); + } + + const sessionList = response.data.session_list || []; + const talkerIds = sessionList.filter((s) => s.session_type === 1).map((s) => s.talker_id); + + // Fetch user info for all talkers + let userCards: Record = {}; + if (talkerIds.length > 0) { + const userCardsResponse = await cache.tryGet( + `bilibili-user-cards-${talkerIds.join(',')}`, + async () => { + const res = await ofetch('https://api.bilibili.com/x/polymer/pc-electron/v1/user/cards', { + query: { + uids: talkerIds.join(','), + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }); + return res.data || {}; + }, + config.cache.routeExpire + ); + userCards = userCardsResponse as Record; + } + + const items: DataItem[] = sessionList + .filter((session) => session.last_msg) + .map((session) => { + const lastMsg = session.last_msg!; + const talkerId = session.talker_id; + const userInfo = userCards[String(talkerId)]; + const talkerName = userInfo?.name || `用户${talkerId}`; + const talkerFace = userInfo?.face || ''; + + const msgContent = parseMessageContent(lastMsg.content, lastMsg.msg_type); + const isSentByMe = lastMsg.sender_uid === Number(uid); + + let description = ''; + if (talkerFace) { + description += `

`; + } + + description += isSentByMe ? `

${talkerName} 说:

` : `

${talkerName} 说:

`; + description += `
${msgContent}
`; + + if (session.unread_count > 0) { + description += `

未读消息: ${session.unread_count} 条

`; + } + + const title = isSentByMe ? `你对 ${talkerName} 说:${msgContent}` : `${talkerName}:${msgContent}`; + + return { + title, + description, + link: `https://message.bilibili.com/#/whisper/mid${talkerId}`, + pubDate: parseDate(lastMsg.timestamp * 1000), + author: isSentByMe ? name : talkerName, + guid: `bilibili-session-${talkerId}-${lastMsg.msg_key}`, + }; + }); + + return { + title: `${name} 的 B站消息 - 我的消息`, + link: 'https://message.bilibili.com/#/whisper', + description: `${name} 的 B站消息 - 我的消息`, + item: items, + allowEmpty: true, + }; +} diff --git a/lib/routes/bilibili/message-system.ts b/lib/routes/bilibili/message-system.ts new file mode 100644 index 000000000..ab6d6dfc6 --- /dev/null +++ b/lib/routes/bilibili/message-system.ts @@ -0,0 +1,143 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { DataItem, Route } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import cache from './cache'; + +export const route: Route = { + path: '/message/system/:uid', + categories: ['social-media'], + example: '/bilibili/message/system/2267573', + parameters: { uid: '用户 id' }, + features: { + requireConfig: [ + { + name: 'BILIBILI_COOKIE_*', + description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式: + 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8) + 2. 打开控制台,切换到 Network 面板,刷新 + 3. 点击 dynamic_new 请求,找到 Cookie + 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: '系统通知', + maintainers: ['pilgrimlyieu'], + handler, + description: `:::warning + 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。 +:::`, +}; + +interface SystemNotifyItem { + id: number; + cursor: number; + publisher: { + name: string; + mid: number; + face: string; + }; + type: number; + title: string; + content: string; + source: { + name: string; + logo: string; + }; + time_at: string; + card_type: number; + card_brief: string; + card_msg_brief: string; + card_cover: string; + card_story_title: string; + card_link: string; + mc: string; + is_station: number; + is_send: number; + notify_cursor: number; +} + +interface SystemResponse { + code: number; + msg: string; + message: string; + ttl: number; + data: { + system_notify_list: SystemNotifyItem[]; + }; +} + +/** + * Parse bilibili message content with special link format + * Format: #{text}{"url"} -> text + */ +function parseMessageContent(content: string): string { + // Match pattern like #{text}{"url"} + const linkPattern = /#\{([^}]+)\}\{"([^"]+)"\}/g; + return content.replaceAll(linkPattern, '$1'); +} + +async function handler(ctx) { + const uid = ctx.req.param('uid'); + const name = await cache.getUsernameFromUID(uid); + + const cookie = config.bilibili.cookies[uid]; + if (cookie === undefined) { + throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值'); + } + + const response = await ofetch('https://message.bilibili.com/x/sys-msg/query_user_notify', { + query: { + page_size: 20, + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }); + + if (response.code !== 0) { + throw new Error(response.message ?? response.msg ?? `Error code ${response.code}`); + } + + const items: DataItem[] = (response.data.system_notify_list || []).map((item) => { + let description = `

${item.title}

`; + const parsedContent = parseMessageContent(item.content); + description += `

${parsedContent.replaceAll('\n', '
')}

`; + + if (item.source.logo) { + description += `

`; + } + + if (item.card_cover) { + description += `

`; + } + + const link = item.card_link || 'https://message.bilibili.com/#/system'; + + return { + title: item.title, + description, + link, + pubDate: parseDate(item.time_at), + guid: `bilibili-system-notify-${item.id}-${item.cursor}`, + }; + }); + + return { + title: `${name} 的 B站消息 - 系统通知`, + link: 'https://message.bilibili.com/#/system', + description: `${name} 的 B站消息 - 系统通知`, + item: items, + allowEmpty: true, + }; +} diff --git a/lib/routes/bilibili/message-unread.ts b/lib/routes/bilibili/message-unread.ts new file mode 100644 index 000000000..84cfa907f --- /dev/null +++ b/lib/routes/bilibili/message-unread.ts @@ -0,0 +1,219 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { DataItem, Route } from '@/types'; +import ofetch from '@/utils/ofetch'; + +import cache from './cache'; + +export const route: Route = { + path: '/message/unread/:uid', + categories: ['social-media'], + example: '/bilibili/message/unread/2267573', + parameters: { uid: '用户 id' }, + features: { + requireConfig: [ + { + name: 'BILIBILI_COOKIE_*', + description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式: + 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8) + 2. 打开控制台,切换到 Network 面板,刷新 + 3. 点击 dynamic_new 请求,找到 Cookie + 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: '未读消息', + maintainers: ['pilgrimlyieu'], + handler, + description: `:::warning + 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。 + + 此路由返回所有未读消息类型的汇总状态。 +:::`, +}; + +interface UnreadMsgResponse { + code: number; + message: string; + ttl: number; + data: { + at: number; + coin: number; + danmu: number; + favorite: number; + like: number; + recv_like: number; + recv_reply: number; + reply: number; + sys_msg: number; + sys_msg_style: number; + up: number; + }; +} + +interface UnreadSessionResponse { + code: number; + msg: string; + message: string; + ttl: number; + data: { + unfollow_unread: number; + follow_unread: number; + unfollow_push_msg: number; + dustbin_push_msg: number; + dustbin_unread: number; + biz_msg_unfollow_unread: number; + biz_msg_follow_unread: number; + custom_unread: number; + }; +} + +async function handler(ctx) { + const uid = ctx.req.param('uid'); + const name = await cache.getUsernameFromUID(uid); + + const cookie = config.bilibili.cookies[uid]; + if (cookie === undefined) { + throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值'); + } + + // Fetch message unread counts + const [msgUnread, sessionUnread] = await Promise.all([ + ofetch('https://api.vc.bilibili.com/x/im/web/msgfeed/unread', { + query: { + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }), + ofetch('https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread', { + query: { + unread_type: 0, + show_dustbin: 1, + build: 0, + mobi_app: 'web', + }, + headers: { + Referer: 'https://message.bilibili.com/', + Cookie: cookie, + }, + }), + ]); + + if (msgUnread.code !== 0) { + throw new Error(msgUnread.message ?? `Error code ${msgUnread.code}`); + } + + const msgData = msgUnread.data; + const sessionData = sessionUnread.data; + + const items: DataItem[] = []; + const now = new Date(); + + // 回复我的 + if (msgData.recv_reply > 0 || msgData.reply > 0) { + const replyCount = msgData.recv_reply || msgData.reply; + items.push({ + title: `回复我的:${replyCount} 条未读`, + description: `

你有 ${replyCount} 条未读回复消息

点击查看

`, + link: 'https://message.bilibili.com/#/reply', + pubDate: now, + guid: `bilibili-unread-reply-${uid}-${replyCount}`, + }); + } + + // @我的 + if (msgData.at > 0) { + items.push({ + title: `@我的:${msgData.at} 条未读`, + description: `

你有 ${msgData.at} 条未读@消息

点击查看

`, + link: 'https://message.bilibili.com/#/at', + pubDate: now, + guid: `bilibili-unread-at-${uid}-${msgData.at}`, + }); + } + + // 收到的赞 + if (msgData.recv_like > 0 || msgData.like > 0) { + const likeCount = msgData.recv_like || msgData.like; + items.push({ + title: `收到的赞:${likeCount} 条未读`, + description: `

你有 ${likeCount} 条未读点赞消息

点击查看

`, + link: 'https://message.bilibili.com/#/love', + pubDate: now, + guid: `bilibili-unread-like-${uid}-${likeCount}`, + }); + } + + // 系统通知 + if (msgData.sys_msg > 0) { + items.push({ + title: `系统通知:${msgData.sys_msg} 条未读`, + description: `

你有 ${msgData.sys_msg} 条未读系统通知

点击查看

`, + link: 'https://message.bilibili.com/#/system', + pubDate: now, + guid: `bilibili-unread-system-${uid}-${msgData.sys_msg}`, + }); + } + + // 私信 + const privateUnread = (sessionData?.follow_unread || 0) + (sessionData?.unfollow_unread || 0); + if (privateUnread > 0) { + items.push({ + title: `私信:${privateUnread} 条未读`, + description: `

你有 ${privateUnread} 条未读私信(已关注: ${sessionData?.follow_unread || 0},未关注: ${sessionData?.unfollow_unread || 0})

点击查看

`, + link: 'https://message.bilibili.com/#/whisper', + pubDate: now, + guid: `bilibili-unread-session-${uid}-${privateUnread}`, + }); + } + + // 投币 + if (msgData.coin > 0) { + items.push({ + title: `收到的投币:${msgData.coin} 条未读`, + description: `

你有 ${msgData.coin} 条未读投币消息

`, + link: 'https://message.bilibili.com/', + pubDate: now, + guid: `bilibili-unread-coin-${uid}-${msgData.coin}`, + }); + } + + // 收藏 + if (msgData.favorite > 0) { + items.push({ + title: `收到的收藏:${msgData.favorite} 条未读`, + description: `

你有 ${msgData.favorite} 条未读收藏消息

`, + link: 'https://message.bilibili.com/', + pubDate: now, + guid: `bilibili-unread-favorite-${uid}-${msgData.favorite}`, + }); + } + + // UP主助手 messages + if (msgData.up > 0) { + items.push({ + title: `UP主助手:${msgData.up} 条未读`, + description: `

你有 ${msgData.up} 条未读UP主助手消息

`, + link: 'https://message.bilibili.com/', + pubDate: now, + guid: `bilibili-unread-up-${uid}-${msgData.up}`, + }); + } + + return { + title: `${name} 的 B站未读消息`, + link: 'https://message.bilibili.com/', + description: `${name} 的 B站未读消息汇总`, + item: items, + allowEmpty: true, + }; +}