From 2e98c430d45ed44e31d3c781e8ceffb0ba51c059 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 19 Jun 2026 18:45:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Bilibili=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E4=BF=A1=E6=81=AF=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/config/bilibili-card-config.ts | 69 +++++++ .../src/domain/bilibili-card.types.ts | 65 +++++++ .../src/domain/bilibili-reply-formatter.ts | 85 +++++++++ .../integration/bilibili-card-host.ts | 108 +++++++++++ .../integration/bilibili-video-client.ts | 142 +++++++++++++++ .../bilibili-video-client.spec.ts | 169 ++++++++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts create mode 100644 test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts diff --git a/src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts b/src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts new file mode 100644 index 0000000..c2b7e93 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts @@ -0,0 +1,69 @@ +import type { + BilibiliCardPluginHost, + BilibiliCardRuntimeConfig, +} from '../domain/bilibili-card.types'; + +const CONFIG_RULES = { + dedupeTtlMs: { + defaultValue: 600000, + key: 'QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS', + max: 3600000, + min: 0, + }, + descMaxLength: { + defaultValue: 80, + key: 'QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH', + max: 300, + min: 0, + }, + httpTimeoutMs: { + defaultValue: 6000, + key: 'QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS', + max: 15000, + min: 1000, + }, + maxRedirects: { + defaultValue: 5, + key: 'QQBOT_BILIBILI_CARD_MAX_REDIRECTS', + max: 10, + min: 0, + }, +} as const; + +/** + * Reads Bilibili card runtime config from the package host and clamps unsafe values. + * @param host - Package-local host facade backed by the worker config snapshot. + * @returns Runtime config used by redirect resolution, API requests, dedupe and reply text. + */ +export function readBilibiliCardRuntimeConfig( + host: BilibiliCardPluginHost, +): BilibiliCardRuntimeConfig { + return { + dedupeTtlMs: readClampedInteger(host, CONFIG_RULES.dedupeTtlMs), + descMaxLength: readClampedInteger(host, CONFIG_RULES.descMaxLength), + httpTimeoutMs: readClampedInteger(host, CONFIG_RULES.httpTimeoutMs), + maxRedirects: readClampedInteger(host, CONFIG_RULES.maxRedirects), + }; +} + +/** + * Reads one numeric config value and constrains it to the allowed runtime range. + * @param host - Package-local host facade that exposes manifest config values. + * @param rule - Config key, fallback and inclusive bounds for one Bilibili setting. + * @returns Integer value clamped between the rule's minimum and maximum. + */ +function readClampedInteger( + host: BilibiliCardPluginHost, + rule: { + defaultValue: number; + key: string; + max: number; + min: number; + }, +) { + const value = Number(host.getConfig(rule.key)); + const normalized = Number.isFinite(value) + ? Math.trunc(value) + : rule.defaultValue; + return Math.min(rule.max, Math.max(rule.min, normalized)); +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts index 6c3eac1..1d60cb8 100644 --- a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts @@ -17,3 +17,68 @@ export type BilibiliUrlExtractionInput = { rawEvent?: Record; rawMessage?: string; }; + +export type BilibiliCardRuntimeConfig = { + dedupeTtlMs: number; + descMaxLength: number; + httpTimeoutMs: number; + maxRedirects: number; +}; + +export type BilibiliCardHostJsonRequest = { + context: string; + failureMessage: (statusCode: number) => string; + invalidJsonMessage: string; + method?: string; + timeoutMessage: string; + timeoutMs: number; + url: URL; +}; + +export type BilibiliCardRedirectRequest = { + context?: string; + maxRedirects: number; + timeoutMessage?: string; + timeoutMs: number; + url: string; +}; + +export type BilibiliCardRedirectResult = { + finalUrl: string; + redirects: string[]; +}; + +export type BilibiliCardPluginHost = { + getBoundEventPluginKeys: (selfId: string) => Promise; + getConfig: (key: string) => T | undefined; + requestJson: ( + request: BilibiliCardHostJsonRequest, + ) => Promise; + resolveRedirect: ( + request: BilibiliCardRedirectRequest, + ) => Promise; + sendText: (input: { + channelId?: string; + guildId?: string; + message: string; + selfId: string; + targetId: string; + targetType: string; + }) => Promise; + warn?: (message: string) => void; +}; + +export type BilibiliVideoInfo = { + aid: number; + bvid: string; + desc: string; + duration: number; + ownerName: string; + pic: string; + stat: { + danmaku: number; + like: number; + view: number; + }; + title: string; +}; diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts new file mode 100644 index 0000000..25877f9 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts @@ -0,0 +1,85 @@ +import type { + BilibiliCardRuntimeConfig, + BilibiliVideoInfo, +} from './bilibili-card.types'; + +/** + * Formats a Bilibili video summary as plain text for QQBot replies. + * @param video - Normalized video info returned by the package-local Bilibili client. + * @param config - Runtime config that controls the maximum displayed description length. + * @returns Concise plain text reply with a canonical Bilibili video URL. + */ +export function formatBilibiliVideoReply( + video: BilibiliVideoInfo, + config: BilibiliCardRuntimeConfig, +) { + const lines = [ + 'Bilibili 视频解析', + `标题:${video.title || '未知标题'}`, + `UP:${video.ownerName || '未知UP主'}`, + `时长:${formatBilibiliDuration(video.duration)}`, + `播放:${formatBilibiliStat(video.stat.view)} 弹幕:${formatBilibiliStat( + video.stat.danmaku, + )} 点赞:${formatBilibiliStat(video.stat.like)}`, + `链接:${buildCanonicalBilibiliVideoUrl(video)}`, + ]; + const desc = truncateBilibiliDescription(video.desc, config.descMaxLength); + if (desc) lines.push(`简介:${desc}`); + return lines.join('\n'); +} + +/** + * Builds the canonical public Bilibili video URL used in replies. + * @param video - Normalized video info containing either a BV id or an av id fallback. + * @returns Public Bilibili video URL that does not echo short links. + */ +function buildCanonicalBilibiliVideoUrl(video: BilibiliVideoInfo) { + const videoId = video.bvid || `av${video.aid}`; + return `https://www.bilibili.com/video/${videoId}`; +} + +/** + * Formats Bilibili stat counters using compact Chinese units. + * @param value - Raw counter from the Bilibili video API response. + * @returns Counter text such as `7890` or `12.3万`. + */ +function formatBilibiliStat(value: number) { + const normalized = Math.max(0, Math.floor(value || 0)); + if (normalized < 10000) return `${normalized}`; + const wan = normalized / 10000; + const formatted = wan >= 100 ? `${Math.round(wan)}` : wan.toFixed(1); + return `${formatted.replace(/\.0$/, '')}万`; +} + +/** + * Formats a duration in seconds as `mm:ss` or `hh:mm:ss`. + * @param seconds - Duration seconds from the Bilibili video API response. + * @returns Padded duration suitable for compact message replies. + */ +function formatBilibiliDuration(seconds: number) { + const normalized = Math.max(0, Math.floor(seconds || 0)); + const hours = Math.floor(normalized / 3600); + const minutes = Math.floor((normalized % 3600) / 60); + const restSeconds = normalized % 60; + if (hours > 0) { + return [hours, minutes, restSeconds] + .map((item) => `${item}`.padStart(2, '0')) + .join(':'); + } + return [minutes, restSeconds] + .map((item) => `${item}`.padStart(2, '0')) + .join(':'); +} + +/** + * Converts multi-line descriptions into one compact line and applies the configured limit. + * @param desc - Raw Bilibili description text from the video API response. + * @param maxLength - Maximum number of visible characters configured for this plugin. + * @returns Trimmed description, with an ellipsis when content was shortened. + */ +function truncateBilibiliDescription(desc: string, maxLength: number) { + const normalized = desc.replace(/\s+/gu, ' ').trim(); + if (!normalized || maxLength <= 0) return ''; + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength)}…`; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts new file mode 100644 index 0000000..a47acf8 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts @@ -0,0 +1,108 @@ +import type { + BilibiliCardHostJsonRequest, + BilibiliCardPluginHost, +} from '../../domain/bilibili-card.types'; + +export type BilibiliCardGenericHost = Record; + +/** + * Creates the package-local host adapter over the generic worker host facade. + * @param host - Generic worker host object supplied by the plugin platform runtime. + * @param configSnapshot - Startup config values captured by the worker runtime. + * @returns Package-local host contract used by Bilibili card domain and integration code. + */ +export function createBilibiliCardGenericHostAdapter( + host: BilibiliCardGenericHost, + configSnapshot: Record, +): BilibiliCardPluginHost { + return { + /** + * Reads event plugin bindings through the generic host bridge. + * @param selfId - QQBot account id from a normalized message event. + * @returns Bound event plugin keys for the account. + */ + getBoundEventPluginKeys: async (selfId) => + await callBilibiliCardGenericHost(host, 'getBoundEventPluginKeys', selfId), + /** + * Reads Bilibili card config from the worker startup snapshot. + * @param key - Runtime config key declared by the Bilibili card manifest. + * @returns Snapshot value cast to the requested config type. + */ + getConfig: (key: string) => + configSnapshot[key] as T | undefined, + /** + * Performs JSON HTTP requests through the generic host bridge. + * @param request - Package-local HTTP request options from `BilibiliVideoClient`. + * @returns Parsed JSON payload returned by the host HTTP client. + */ + requestJson: async (request) => + await callBilibiliCardGenericHost( + host, + 'requestJson', + serializeBilibiliCardJsonRequest(request), + ), + /** + * Resolves short-link redirects through the generic host bridge. + * @param request - Redirect request containing URL, timeout and max redirect budget. + * @returns Final URL and redirect chain returned by the host HTTP client. + */ + resolveRedirect: async (request) => + await callBilibiliCardGenericHost(host, 'resolveRedirect', request), + /** + * Sends a plain text QQBot reply through the generic host bridge. + * @param input - Target conversation and message text produced by the plugin. + * @returns Host send result. + */ + sendText: async (input) => + await callBilibiliCardGenericHost(host, 'sendText', input), + /** + * Emits a package warning through the generic host when available. + * @param message - Warning message safe for platform logs. + */ + warn: (message) => { + const warn = host.warn; + if (typeof warn === 'function') warn(message); + }, + }; +} + +/** + * Calls one generic host method and fails with a package-owned error when absent. + * @param host - Generic worker host facade supplied by the platform runtime. + * @param method - Host capability required by the Bilibili card adapter. + * @param args - Positional arguments accepted by the host facade method. + * @returns Host method result cast to the requested package-local type. + */ +export async function callBilibiliCardGenericHost( + host: BilibiliCardGenericHost, + method: string, + ...args: unknown[] +): Promise { + const fn = host[method]; + if (typeof fn !== 'function') { + throw new Error(`Bilibili Card generic host 缺少 ${method}`); + } + return (await fn(...args)) as TResult; +} + +/** + * Converts package-local JSON request options to worker-safe generic host request data. + * @param request - HTTP request containing URL and function-based failure message. + * @returns Serializable request data accepted by the generic host bridge. + */ +export function serializeBilibiliCardJsonRequest( + request: BilibiliCardHostJsonRequest, +) { + const statusPlaceholder = 599; + return { + context: request.context, + failureMessageTemplate: request + .failureMessage(statusPlaceholder) + .replaceAll(`${statusPlaceholder}`, '{statusCode}'), + invalidJsonMessage: request.invalidJsonMessage, + method: request.method, + timeoutMessage: request.timeoutMessage, + timeoutMs: request.timeoutMs, + url: request.url.toString(), + }; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts new file mode 100644 index 0000000..91d67f0 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts @@ -0,0 +1,142 @@ +import type { + BilibiliCardPluginHost, + BilibiliCardRuntimeConfig, + BilibiliVideoInfo, + BilibiliVideoReference, +} from '../../domain/bilibili-card.types'; + +type BilibiliViewResponse = { + code?: number; + data?: { + aid?: unknown; + bvid?: unknown; + desc?: unknown; + duration?: unknown; + owner?: { + name?: unknown; + }; + pic?: unknown; + stat?: { + danmaku?: unknown; + like?: unknown; + view?: unknown; + }; + title?: unknown; + }; + message?: unknown; +}; + +export class BilibiliVideoClient { + /** + * Initializes the host-mediated Bilibili video client. + * @param host - Package-local host facade that owns all external HTTP access. + */ + constructor(private readonly host: BilibiliCardPluginHost) {} + + /** + * Fetches and normalizes one Bilibili video through the official view endpoint. + * @param reference - Parsed BV or av reference produced by the URL parser or redirect resolver. + * @param config - Runtime config containing the host request timeout budget. + * @returns Normalized video info safe for reply formatting. + */ + async fetchVideo( + reference: BilibiliVideoReference, + config: Pick, + ): Promise { + const response = await this.host.requestJson({ + context: 'Bilibili 视频信息获取', + /** + * Builds a readable HTTP failure message for host-mediated Bilibili API requests. + * @param statusCode - HTTP status code reported by the generic host HTTP client. + * @returns Chinese error text with the status code preserved. + */ + failureMessage: (statusCode) => + `Bilibili 视频信息获取失败:HTTP ${statusCode}`, + invalidJsonMessage: 'Bilibili 视频信息返回不是合法 JSON', + method: 'GET', + timeoutMessage: 'Bilibili 视频信息获取超时', + timeoutMs: config.httpTimeoutMs, + url: buildBilibiliViewUrl(reference), + }); + + if (response.code !== 0) { + throw new Error( + `Bilibili 视频信息获取失败:${normalizeBilibiliMessage( + response.message, + )}`, + ); + } + + return normalizeBilibiliVideoInfo(response.data, reference); + } +} + +/** + * Builds the official Bilibili web-interface view endpoint URL for a video reference. + * @param reference - Parsed BV or av reference produced by URL parsing. + * @returns Official Bilibili API URL with either `bvid` or `aid` query parameter. + */ +function buildBilibiliViewUrl(reference: BilibiliVideoReference) { + const url = new URL('https://api.bilibili.com/x/web-interface/view'); + url.searchParams.set(reference.kind === 'bvid' ? 'bvid' : 'aid', reference.value); + return url; +} + +/** + * Normalizes the Bilibili API payload into the package-local video info contract. + * @param data - Raw `data` object returned by Bilibili's view endpoint. + * @param reference - Original parsed reference used as a fallback for missing identifiers. + * @returns Video info with safe fallback strings and numbers. + */ +function normalizeBilibiliVideoInfo( + data: BilibiliViewResponse['data'], + reference: BilibiliVideoReference, +): BilibiliVideoInfo { + return { + aid: readNumber(data?.aid, reference.kind === 'aid' ? Number(reference.value) : 0), + bvid: readText(data?.bvid, reference.kind === 'bvid' ? reference.value : ''), + desc: readText(data?.desc), + duration: readNumber(data?.duration), + ownerName: readText(data?.owner?.name, '未知UP主'), + pic: readText(data?.pic), + stat: { + danmaku: readNumber(data?.stat?.danmaku), + like: readNumber(data?.stat?.like), + view: readNumber(data?.stat?.view), + }, + title: readText(data?.title, '未知标题'), + }; +} + +/** + * Converts arbitrary API text values to stable strings. + * @param value - Unknown text-like field from the Bilibili API response. + * @param fallback - Domain fallback used when the API field is absent or blank. + * @returns Trimmed string or the supplied fallback. + */ +function readText(value: unknown, fallback = '') { + const text = typeof value === 'string' ? value.trim() : ''; + return text || fallback; +} + +/** + * Converts arbitrary API number values to non-negative integers. + * @param value - Unknown numeric field from the Bilibili API response. + * @param fallback - Domain fallback used when the API field is missing or invalid. + * @returns Non-negative integer suitable for duration, id or stat counters. + */ +function readNumber(value: unknown, fallback = 0) { + const numberValue = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numberValue) && numberValue > 0 + ? Math.floor(numberValue) + : fallback; +} + +/** + * Converts Bilibili API error payloads to readable Chinese messages. + * @param message - Raw `message` field returned by the Bilibili API. + * @returns Trimmed message or a stable fallback. + */ +function normalizeBilibiliMessage(message: unknown) { + return readText(message, '未知错误'); +} diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts new file mode 100644 index 0000000..6ecd55e --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts @@ -0,0 +1,169 @@ +import { readBilibiliCardRuntimeConfig } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config'; +import type { BilibiliCardPluginHost } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types'; +import { formatBilibiliVideoReply } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter'; +import { BilibiliVideoClient } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client'; + +describe('Bilibili video client', () => { + it('fetches a BV video through the plugin host and normalizes the response', async () => { + const host = createHost({ + requestJson: jest.fn().mockResolvedValue({ + code: 0, + data: { + aid: 170001, + bvid: 'BV1xx411c7mD', + desc: '第一行\n第二行', + duration: 125, + owner: { name: 'UP主' }, + pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg', + stat: { + danmaku: 456, + like: 7890, + view: 123456, + }, + title: '夏祭', + }, + }), + }); + const client = new BilibiliVideoClient(host); + + await expect( + client.fetchVideo( + { + canonicalVideoId: 'BV1xx411c7mD', + kind: 'bvid', + sourceUrl: 'https://www.bilibili.com/video/BV1xx411c7mD', + value: 'BV1xx411c7mD', + }, + readBilibiliCardRuntimeConfig(host), + ), + ).resolves.toMatchObject({ + aid: 170001, + bvid: 'BV1xx411c7mD', + duration: 125, + ownerName: 'UP主', + title: '夏祭', + }); + + expect(host.requestJson).toHaveBeenCalledTimes(1); + const request = (host.requestJson as jest.Mock).mock.calls[0][0]; + expect(request.url.toString()).toBe( + 'https://api.bilibili.com/x/web-interface/view?bvid=BV1xx411c7mD', + ); + expect(request.timeoutMs).toBe(6000); + expect(request.context).toBe('Bilibili 视频信息获取'); + expect(request.failureMessage(502)).toBe( + 'Bilibili 视频信息获取失败:HTTP 502', + ); + expect(request.invalidJsonMessage).toBe( + 'Bilibili 视频信息返回不是合法 JSON', + ); + }); + + it('throws a readable error when Bilibili returns an error code', async () => { + const host = createHost({ + requestJson: jest.fn().mockResolvedValue({ + code: -404, + message: '啥都木有', + }), + }); + const client = new BilibiliVideoClient(host); + + await expect( + client.fetchVideo( + { + canonicalVideoId: 'av170001', + kind: 'aid', + sourceUrl: 'https://m.bilibili.com/video/av170001', + value: '170001', + }, + readBilibiliCardRuntimeConfig(host), + ), + ).rejects.toThrow('Bilibili 视频信息获取失败:啥都木有'); + + const request = (host.requestJson as jest.Mock).mock.calls[0][0]; + expect(request.url.toString()).toBe( + 'https://api.bilibili.com/x/web-interface/view?aid=170001', + ); + }); + + it('formats a concise text reply without echoing short links', () => { + expect( + formatBilibiliVideoReply( + { + aid: 170001, + bvid: 'BV1xx411c7mD', + desc: '第一行\n第二行', + duration: 125, + ownerName: 'UP主', + pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg', + stat: { + danmaku: 456, + like: 7890, + view: 123456, + }, + title: '夏祭', + }, + { + dedupeTtlMs: 600000, + descMaxLength: 6, + httpTimeoutMs: 6000, + maxRedirects: 5, + }, + ), + ).toBe( + [ + 'Bilibili 视频解析', + '标题:夏祭', + 'UP:UP主', + '时长:02:05', + '播放:12.3万 弹幕:456 点赞:7890', + '链接:https://www.bilibili.com/video/BV1xx411c7mD', + '简介:第一行 第二…', + ].join('\n'), + ); + }); + + it('clamps runtime config values from the plugin host', () => { + const host = createHost({ + /** + * Reads test config values by Bilibili card runtime key. + * @param key - Runtime config key requested by `readBilibiliCardRuntimeConfig`. + * @returns String value supplied by the current clamp scenario. + */ + getConfig: ((key: string) => { + return { + QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS: '9999999', + QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH: '-1', + QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS: '10', + QQBOT_BILIBILI_CARD_MAX_REDIRECTS: '99', + }[key] as T | undefined; + }) as BilibiliCardPluginHost['getConfig'], + }); + + expect(readBilibiliCardRuntimeConfig(host)).toEqual({ + dedupeTtlMs: 3600000, + descMaxLength: 0, + httpTimeoutMs: 1000, + maxRedirects: 10, + }); + }); +}); + +/** + * Builds the package-local Bilibili host contract used by client and config tests. + * @param overrides - Test doubles for host methods involved in the current assertion. + * @returns Host object with harmless defaults for unused plugin capabilities. + */ +function createHost( + overrides: Partial = {}, +): BilibiliCardPluginHost { + return { + getBoundEventPluginKeys: jest.fn().mockResolvedValue([]), + getConfig: jest.fn(), + requestJson: jest.fn(), + resolveRedirect: jest.fn(), + sendText: jest.fn().mockResolvedValue(undefined), + warn: jest.fn(), + ...overrides, + }; +}