From 6dda1c5e6ec5c824bcb23db73c12ba8660924c3d Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Thu, 23 Jul 2026 16:04:25 +0200 Subject: [PATCH] Group chats: report why a promised video never arrived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups get exactly one terminal 💥 line when a failure is terminal, isn't not-a-video, and either getInfo proved a real video or the link is on an always-expect-a-response host (instagram.com, reddit.com, redd.it). All other group silence is unchanged and pinned by tests. Backing this is a 3-way yt-dlp failure classifier (not-a-video / unavailable / transient); deterministic errors like a photo post or Instagram's empty-media response now fail in one attempt instead of burning the 3-attempt retry budget. Closes #17, closes #14. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DL3md3a3RoeRNStaDv1PDf --- src/download-video.ts | 58 ++++++----- src/handlers.ts | 46 ++++++++- src/log-message.ts | 7 +- test/download-video.test.ts | 62 +++++++++++ test/e2e.test.ts | 59 ++++++++++- test/handlers.test.ts | 200 ++++++++++++++++++++++++++++++++++-- test/simulate-bot-api.ts | 21 +++- 7 files changed, 402 insertions(+), 51 deletions(-) diff --git a/src/download-video.ts b/src/download-video.ts index 04abd34..c718451 100644 --- a/src/download-video.ts +++ b/src/download-video.ts @@ -68,43 +68,51 @@ export class YtdlpError extends Error { } } -// Failures a retry can't fix: the URL/extractor genuinely can't be handled. We -// assume the background updater keeps yt-dlp current (see updateYtdlp), so a -// fresh yt-dlp that still can't extract a URL means it's unsupported, not stale. -// Anything not listed (network blips, 5xx, 408/429, a transient fragment -// 404/403, unknown errors) is retryable: better to retry a lost cause a few -// times than drop a video a retry would have delivered. The one HTTP -// exception is a 403/404/410 on the *webpage* fetch: the URL is gone or the -// site refuses us outright, and neither changes within the retry window -// (a mid-download segment error isn't; that's why this is scoped to the webpage). -const PERMANENT_PATTERNS = [ +export type FailureKind = 'not-a-video' | 'unavailable' | 'transient'; + +const NOT_A_VIDEO_PATTERNS = [ /unsupported url/i, + // the extractor tag is required by each pattern: yt-dlp's f4m downloader emits an + // untagged "No media found" for a transient condition that must stay retryable + /\[reddit\] .+: no media found/i, + /\[instagram\] .+: there is no video in this post/i, +]; +const PERMANENT_PATTERNS = [ /unable to extract/i, /no video formats found/i, /is not a valid url/i, /private video/i, /video unavailable/i, + /empty media response/i, /no longer available/i, /has been removed/i, /members[- ]only/i, /sign in to confirm your age/i, + // scoped to the *webpage* fetch: a webpage 403/404/410 means the URL is gone + // or the site refuses us, but a mid-download segment 403 stays transient /unable to download webpage: http error (403|404|410)\b/i, ]; -// A signal kill (timeout/OOM) is always transient. For yt-dlp, match only its -// own `ERROR:` lines, not WARNINGs or echoed page text, which can contain the -// same phrases and would false-positive a retryable failure. -export const isPermanentError = (e: unknown): boolean => { + +export const classifyFailure = (e: unknown): FailureKind => { + if (!(e instanceof YtdlpError) || e.signalled) return 'transient'; + const errorLines = e.stderr + .split('\n') + .filter((line) => line.startsWith('ERROR:')); + if (errorLines.some((l) => NOT_A_VIDEO_PATTERNS.some((re) => re.test(l)))) + return 'not-a-video'; + if (errorLines.some((l) => PERMANENT_PATTERNS.some((re) => re.test(l)))) + return 'unavailable'; + return 'transient'; +}; + +// classifyFailure judges the video; Telegram errors are chat-level (blocked or +// gone), so they are handled here rather than there. +export const isPermanentError = ( + e: unknown, + kind: FailureKind = classifyFailure(e), +): boolean => { if (e instanceof YtdlpError) { - return ( - !e.signalled && - e.stderr - .split('\n') - .some( - (line) => - line.startsWith('ERROR:') && - PERMANENT_PATTERNS.some((re) => re.test(line)), - ) - ); + return kind !== 'transient'; } // Telegram 403 = the user blocked the bot, or it was kicked/deactivated; a // few 400s name a gone chat/peer/reply-target. All are permanent-by-policy: @@ -389,7 +397,7 @@ const execYtdlp = limit( stderr += text; if (stderr.length > 2 * STDERR_TAIL) { // trim on a line boundary so the cut never decapitates the `ERROR:` - // prefix that isPermanentError keys on + // prefix that classifyFailure keys on const nl = stderr.indexOf('\n', stderr.length - STDERR_TAIL); stderr = nl === -1 ? stderr.slice(-STDERR_TAIL) : stderr.slice(nl + 1); } diff --git a/src/handlers.ts b/src/handlers.ts index 12eb06a..14b869d 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -10,6 +10,7 @@ import { import { db } from './db'; import { calcDuration, + classifyFailure, downloadVideo, getInfo, isDownloaded, @@ -21,6 +22,7 @@ import { tooLargeMessage, tooLargeToSend, YtdlpError, + type FailureKind, type VideoInfo, } from './download-video'; import { @@ -192,6 +194,23 @@ export const processJob = async ( ? processUrlJob(telegram, job, attempt) : processConfirmedJob(telegram, job, attempt); +// a link to one of these is always an explicit ask, so a terminal failure earns +// its reason even in a group +const ALWAYS_RESPOND_HOSTS = ['instagram.com', 'reddit.com', 'redd.it']; +const isAlwaysRespondHost = (url: string): boolean => { + let host: string; + try { + // strip one trailing dot: a root-dot FQDN (reddit.com.) is the same host + host = new URL(url).hostname.replace(/\.$/, ''); + } catch { + return false; // an unparseable URL is simply not whitelisted + } + return ALWAYS_RESPOND_HOSTS.some((h) => host === h || host.endsWith('.' + h)); +}; + +const isTerminal = (e: unknown, attempt: number, kind?: FailureKind) => + isPermanentError(e, kind) || attempt >= MAX_ATTEMPTS; + const processUrlJob = async ( telegram: Telegram, job: UrlJob, @@ -301,7 +320,26 @@ const processUrlJob = async ( // yt-dlp failures: after a send failure the info is fine, and keeping it // guarantees the retry maps to the same blob key and reuses the bytes. if (info && e instanceof YtdlpError) removeCachedInfo(info); - await reportJobFailure(job, log, e, attempt, '\n'); + const kind = classifyFailure(e); + const terminal = isTerminal(e, attempt, kind); + // product policy: a not-a-video post (photo/article) never draws a group + // reply, whatever the host + const tellGroup = + chatType !== 'private' && + terminal && + kind !== 'not-a-video' && + (info != null || isAlwaysRespondHost(url)); + const reportLog = tellGroup + ? new LogMessage(telegram, logDestFor(job)) + : log; + await reportJobFailure( + job, + reportLog, + e, + attempt, + terminal, + tellGroup ? '' : '\n', + ); // reached only on a terminal failure (reportJobFailure rethrows retryable // ones, whose retry reuses the blob; a parked confirmation returned above). // Release the bytes this dead job downloaded. @@ -369,7 +407,8 @@ const processConfirmedJob = async ( // evict likely-expired cached info so the NEXT request re-scrapes; this // job's own retries can't benefit (the payload pins its info snapshot) if (e instanceof YtdlpError) removeCachedInfo(info); - await reportJobFailure(job, report(), e, attempt); + const terminal = isTerminal(e, attempt); + await reportJobFailure(job, report(), e, attempt, terminal); // terminal failure (retryable ones rethrew above and will reuse the blob): // release what this dead job owns await releaseAbandoned(info); @@ -390,10 +429,11 @@ const reportJobFailure = async ( log: LogMessage, e: any, attempt: number, + terminal: boolean, prefix = '', ) => { console.error(e); // log first: reporting to the user can itself fail - const retry = !isPermanentError(e) && attempt < MAX_ATTEMPTS; + const retry = !terminal; // The retry notice skips the reason (the streamed stderr above already shows // it, and the run isn't over) and trails a blank line to set off the next // attempt; the terminal report carries the reason. Groups see only that diff --git a/src/log-message.ts b/src/log-message.ts index d7543cb..9661bda 100644 --- a/src/log-message.ts +++ b/src/log-message.ts @@ -305,11 +305,8 @@ export class NoLog extends LogMessage { async flush() {} } -// The group-silence policy, in one place: url jobs log progress and failures to -// private chats only; a group would be spammed for every link anyone posts, -// so group destinations get a NoLog. (Confirmed jobs are the deliberate -// exception: an explicit confirm earns a group reply, so processConfirmedJob -// constructs its report LogMessage directly.) +// url-job progress logs to private chats only: a group would be spammed for +// every link posted. Terminal failure reports come from their own call sites. export const logFor = ( telegram: Telegram, chatType: string, diff --git a/test/download-video.test.ts b/test/download-video.test.ts index fb6bef7..f02af38 100644 --- a/test/download-video.test.ts +++ b/test/download-video.test.ts @@ -42,6 +42,7 @@ import { } from './test-utils'; import { abortDownloads, + classifyFailure, downloadVideo, getInfo, isPermanentError, @@ -841,6 +842,67 @@ describe('isPermanentError', () => { }); }); +describe('classifyFailure', () => { + it('classifies an Instagram empty-media response as unavailable', () => { + expect( + classifyFailure( + new YtdlpError( + 'failed', + 'ERROR: [Instagram] Da4FbMds5BU: Instagram sent an empty media response. Check if this post is accessible in your browser without being logged-in.', + ), + ), + ).toBe('unavailable'); + }); + + it.each([ + 'ERROR: Unsupported URL: https://example.com/article', + 'ERROR: [Reddit] 92dd8: No media found', + 'ERROR: [Instagram] DbHhjdBJT9O: There is no video in this post', + ])('classifies %j as not-a-video', (stderr) => { + expect(classifyFailure(new YtdlpError('failed', stderr))).toBe( + 'not-a-video', + ); + }); + + it('keeps the f4m downloader\'s untagged "No media found" retryable', () => { + expect( + classifyFailure(new YtdlpError('failed', 'ERROR: No media found')), + ).toBe('transient'); + }); + + it.each([ + 'ERROR: Private video. Sign in if you have access', + 'ERROR: Unable to download webpage: HTTP Error 410: Gone', + ])('classifies %j as unavailable', (stderr) => { + expect(classifyFailure(new YtdlpError('failed', stderr))).toBe('unavailable'); + }); + + it('not-a-video wins over unavailable when both patterns are present', () => { + const stderr = + 'ERROR: Video unavailable\nERROR: Unsupported URL: https://x'; + expect(classifyFailure(new YtdlpError('failed', stderr))).toBe( + 'not-a-video', + ); + }); + + it('classifies an Instagram rate-limit as transient (not unavailable)', () => { + expect( + classifyFailure( + new YtdlpError( + 'failed', + 'ERROR: [Instagram] xyz: Requested content is not available, rate-limit reached or login required', + ), + ), + ).toBe('transient'); + }); + + it('diverges from isPermanentError on a Telegram permanent error', () => { + const e = telegramError(403, 'Forbidden: bot was blocked by the user'); + expect(classifyFailure(e)).toBe('transient'); + expect(isPermanentError(e)).toBe(true); + }); +}); + describe('sendVideo', () => { const cachedFileId = () => getBlob(VideoInfo)?.file_id; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 942da99..fc2544e 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -28,6 +28,9 @@ spyMock(console, 'table'); const hiMessage = { text: 'hi' }; +const isFailureReport = (m: { text?: string }) => + !!m.text && m.text.includes('💥 Download failed:'); + const urlMessage = (url: string, verbose?: boolean) => ({ text: verbose ? `/verbose ${url}` : url, entities: [ @@ -100,9 +103,7 @@ describe.if(!!Bun.env.TEST_E2E)('message handler', async () => { waitUntil( () => api.sentMessages.length > 1 || - api.sentMessages.some(({ text }) => - text?.includes('💥 Download failed:'), - ), + api.sentMessages.some(isFailureReport), ms, ); @@ -127,6 +128,58 @@ describe.if(!!Bun.env.TEST_E2E)('message handler', async () => { }), 40_000, ); + + const groupChat = { id: -1000000000001, title: 'Test Group', type: 'supergroup' }; + + it( + 'stays silent for a not-a-video link in a group', + () => + withBotApi(async (api) => { + clearInMemoryCache(); + api.sendTextMessageToBot( + urlMessage('https://www.instagram.com/p/DbHhjdBJT9O/'), + groupChat, + ); + // gate on the job actually starting, else a never-started job passes this + // silence test vacuously + expect(await waitUntil(() => !jobsIdle(), 15_000)).toBe(true); + await waitUntil(jobsIdle, 25_000); + // a rate-limited scrape classifies 'unavailable' (whitelisted host => + // one terminal report), so tolerate that live-scrape degradation the + // same way the download tests tolerate a 💥 + if (api.sentMessages.length) { + const reports = api.sentMessages.filter(isFailureReport); + expect(reports).toHaveLength(1); + // pin the tolerated report to rate-limit/login wording: a broken + // not-a-video gate would instead report "There is no video in this + // post" and must still fail this test + expect(reports[0]!.text).toMatch(/rate.?limit|login required|empty media response/i); + } else { + expect(api.sentMessages).toEqual([]); + } + }), + 45_000, + ); + + it( + 'reports one 💥 for a whitelisted failing link in a group', + () => + withBotApi(async (api) => { + setRetryBaseMs(1); // don't sleep the real backoff between attempts + clearInMemoryCache(); + api.sendTextMessageToBot( + urlMessage('https://www.instagram.com/reel/C0aaaaaaaaa/'), + groupChat, + ); + await waitUntil(() => api.sentMessages.some(isFailureReport), 60_000); + const reports = api.sentMessages.filter(isFailureReport); + expect(reports).toHaveLength(1); + expect(reports[0]!.chat_id).toBe(groupChat.id); + // id 0 is the link message: the first update in this fresh api + expect((reports[0] as any).reply_parameters?.message_id).toBe(0); + }), + 90_000, + ); }); describe.todo('inline query handler'); diff --git a/test/handlers.test.ts b/test/handlers.test.ts index c36a5e6..d54b428 100644 --- a/test/handlers.test.ts +++ b/test/handlers.test.ts @@ -57,6 +57,35 @@ spyOn(logMessage, 'LogMessage').mockReturnValue(mockLog as never); spyOn(logMessage, 'logFor').mockImplementation((_tg, chatType) => chatType === 'private' ? (mockLog as never) : new logMessage.NoLog(), ); +const lastAppend = () => mockLog.append.mock.calls.map(([s]) => s).at(-1); + +const expectNoGroupReport = () => { + expect(logMessage.LogMessage).not.toHaveBeenCalled(); + expect(mockLog.append).not.toHaveBeenCalled(); +}; + +const expectGroupSilent = (ctx: any) => { + expectNoGroupReport(); + expect(ctx.telegram.sendMessage).not.toHaveBeenCalled(); +}; + +const expectGroupReport = (replyTo: number) => { + expect(logMessage.LogMessage).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ replyTo }), + ); + expect(lastAppend()).toMatch(/^💥 Download failed<\/b>:/); +}; + +const urlJob = { + kind: 'url' as const, + url: 'https://example.com', + chatId: 1, + chatType: 'private', + messageId: 2, + fromId: 3, + verbose: false, +}; // run enqueued jobs inline against the invoking ctx's telegram client, so // the handler tests below exercise the full enqueue→process flow @@ -1207,16 +1236,6 @@ describe('confirmed job oversize report', () => { }); describe('job retry classification', () => { - const urlJob = { - kind: 'url', - url: 'https://example.com', - chatId: 1, - chatType: 'private', - messageId: 2, - fromId: 3, - verbose: false, - }; - const lastAppend = () => mockLog.append.mock.calls.map(([s]) => s).at(-1); beforeEach(() => spyMock(console, 'error')); it('a shutdown abort rethrows silently, stashing the log pointer for the re-run', async () => { @@ -1457,3 +1476,164 @@ describe('job retry classification', () => { mockDownloadVideo.mockResolvedValue('downloaded'); }); }); + +describe('group terminal-failure feedback (issues #14/#17)', () => { + beforeEach(() => { + spyMock(console, 'error'); + // no duration field: these must not route through the long-video gate + mockGetInfo.mockImplementation( + memoize( + mock(async (_log: any, url: string) => ({ + webpage_url: url, + title: 'Test Video', + filename: 'video.mp4', + })), + ), + ); + }); + const ytdlp = (stderr: string) => + new downloadVideo.YtdlpError('yt-dlp exited with code 1', stderr); + + const groupUrlJob = (url: string) => ({ + ...urlJob, + url, + chatId: -100, + chatType: 'group', + }); + + const groupCtx = (url: string) => { + const ctx = createMockMessageCtx(false, { chat: groupChat }); + const msg = (ctx as any).message; + msg.text = url; + msg.entities = [{ type: 'url', offset: 0, length: url.length }]; + return ctx; + }; + + it('gives a not-a-video error exactly one attempt (no retry) in private chat', async () => { + const privateJob = { ...urlJob, url: 'https://reddit.com/r/x/comments/y' }; + mockGetInfo.mockRejectedValueOnce(ytdlp('ERROR: [Reddit] 92dd8: No media found')); + await expect( + processJob({} as any, privateJob as any, 1), + ).resolves.toBeUndefined(); + expect(lastAppend()).toMatch(/^\n💥 Download failed<\/b>:/); + expect(mockLog.append).not.toHaveBeenCalledWith( + expect.stringContaining('retrying'), + ); + }); + + it('stays silent on a getInfo failure for a non-whitelisted host', async () => { + const ctx = groupCtx('https://news.example.com/article'); + mockGetInfo.mockRejectedValueOnce(ytdlp('ERROR: Video unavailable')); + await handle(ctx as any); + expectGroupSilent(ctx); + }); + + it('reports one 💥 for a terminal non-not-a-video Instagram scrape failure', async () => { + mockGetInfo.mockRejectedValueOnce( + ytdlp( + 'ERROR: [Instagram] xyz: Requested content is not available, rate-limit reached or login required', + ), + ); + await expect( + processJob({} as any, groupUrlJob('https://www.instagram.com/p/xyz'), 3), + ).resolves.toBeUndefined(); + expectGroupReport(2); + }); + + it('whitelists a trailing-dot host (reddit.com.) for the terminal report', async () => { + mockGetInfo.mockRejectedValueOnce(ytdlp('ERROR: Video unavailable')); + await expect( + processJob( + {} as any, + groupUrlJob('https://reddit.com./r/x/comments/y'), + 1, + ), + ).resolves.toBeUndefined(); + expectGroupReport(2); + }); + + it.each([ + [ + 'https://www.instagram.com/p/DbHhjdBJT9O', + 'ERROR: [Instagram] DbHhjdBJT9O: There is no video in this post', + ], + ['https://www.reddit.com/r/x/comments/y', 'ERROR: [Reddit] 92dd8: No media found'], + ])('stays silent for a whitelisted not-a-video (%j)', async (url, stderr) => { + const ctx = groupCtx(url); + mockGetInfo.mockRejectedValueOnce(ytdlp(stderr)); + await handle(ctx as any); + expectGroupSilent(ctx); + }); + + it('treats an unparseable URL as not whitelisted (stays silent)', async () => { + mockGetInfo.mockRejectedValueOnce(ytdlp('ERROR: Video unavailable')); + const job = { ...groupUrlJob('https://') }; + await expect(processJob({} as any, job as any, 1)).resolves.toBeUndefined(); + expectNoGroupReport(); + }); + + it('reports one 💥 when info resolved then the download fails permanently', async () => { + const ctx = groupCtx('https://example.com/video'); + mockDownloadVideo.mockRejectedValueOnce(ytdlp('ERROR: Video unavailable')); + await handle(ctx as any); + expectGroupReport(1); + }); + + it('stays silent when info resolved but the download fails not-a-video', async () => { + const ctx = groupCtx('https://example.com/video'); + mockDownloadVideo.mockRejectedValueOnce( + ytdlp('ERROR: Unsupported URL: https://example.com/video/sub'), + ); + await handle(ctx as any); + expectGroupSilent(ctx); + }); + + it('stays silent through transient retries, then reports one terminal 💥', async () => { + const job = groupUrlJob('https://example.com/video'); + // one reject per processJob call this test drives (attempts 1 and 3), then + // the once-queue empties back to the base resolved value: no tail reset, so + // no rejecting mock leaks into a later test even if an assertion fails. + // Lazy throw, not mockRejectedValueOnce: bun test's runner flags the + // eagerly-built queued rejection as an unhandled error across the await + // gap between the two processJob calls (observed; plain bun scripts don't) + const fail = async () => { + throw ytdlp('ERROR: Unable to download webpage: HTTP Error 503'); + }; + mockDownloadVideo.mockImplementationOnce(fail).mockImplementationOnce(fail); + await expect(processJob({} as any, { ...job }, 1)).rejects.toThrow(); + expectNoGroupReport(); + await expect(processJob({} as any, { ...job }, 3)).resolves.toBeUndefined(); + expect(lastAppend()).toMatch(/^💥 Download failed<\/b>:/); + }); + + it('reports one 💥 when the send itself fails terminally (issue #17)', async () => { + const job = groupUrlJob('https://example.com/video'); + // one reject per processJob call this test drives (attempts 1 and 3), then + // the once-queue empties back to the base resolved value: no tail reset, and + // no rejecting mock leaks into a later test even if an assertion fails + // (lazy throw: see the transient-retries test above) + const fail = async () => { + throw new Error('fetch failed'); + }; + mockSendVideo.mockImplementationOnce(fail).mockImplementationOnce(fail); + await expect(processJob({} as any, { ...job }, 1)).rejects.toThrow( + 'fetch failed', + ); + expectNoGroupReport(); + await expect(processJob({} as any, { ...job }, 3)).resolves.toBeUndefined(); + expectGroupReport(2); + expect(mockLog.append).toHaveBeenCalledTimes(1); + }); + + it('stays silent on a too-large estimate in a group (no report leaks in)', async () => { + const ctx = groupCtx('https://www.instagram.com/p/huge'); + mockGetInfo.mockResolvedValueOnce({ + webpage_url: 'https://www.instagram.com/p/huge', + title: 'Huge', + filename: 'huge.mp4', + filesize: 3000 * 1024 * 1024, + } as any); + await handle(ctx as any); + expectGroupSilent(ctx); + }); +}); diff --git a/test/simulate-bot-api.ts b/test/simulate-bot-api.ts index ffe5b3f..cc6960a 100644 --- a/test/simulate-bot-api.ts +++ b/test/simulate-bot-api.ts @@ -55,6 +55,12 @@ export class MockBotApi { }[] = []; public answeredCallbacks: { callback_query_id: string; text?: string }[] = []; private date = 0; + // chats the bot may send to, mapped to the chat object the real server echoes + // back on a send; an unknown chat_id gets its "chat not found" + private knownChats = new Map< + number, + { id: number; type: string; [k: string]: any } + >([[MOCK_USER_ID, { ...this.user, type: 'private' }]]); private pathPrefix: string; private updates: Update[] = []; private watchers: Array<() => void> = []; @@ -88,6 +94,7 @@ export class MockBotApi { chatOverride?: { id: number; title?: string; type: string }, ) { const chat = chatOverride ?? { ...this.user, type: 'private' }; + if (chatOverride) this.knownChats.set(chatOverride.id, chatOverride); const message = { message_id: this.updates.length, from: { ...this.user, is_bot: false, language_code: 'en' }, @@ -190,15 +197,19 @@ export class MockBotApi { } } + private chatFor(id: number) { + return this.knownChats.get(id); + } + private messageResponse( - message: { text: string; [key: string]: any }, + message: { text: string; chat_id: number; [key: string]: any }, message_id: number, ) { return okResp({ ...message, message_id, from: this.bot, - chat: { ...this.user, type: 'private' }, + chat: this.chatFor(message.chat_id), date: this.date++, text: message.text.replaceAll(/<[^>]+>/g, ''), // strip html tags entities: [], // not needed for mocking @@ -212,7 +223,7 @@ export class MockBotApi { reply_parameters?: { message_id: number }; parse_mode?: string; }) { - if (data.chat_id !== this.user.id) { + if (!this.knownChats.has(data.chat_id)) { return errResp('Bad Request: chat not found'); } const err = this.replyOrParseError(data); @@ -335,7 +346,7 @@ export class MockBotApi { reply_parameters?: any; }) { const { chat_id, caption, video, reply_parameters, ...extra } = data; - if (chat_id !== this.user.id) { + if (!this.knownChats.has(chat_id)) { return errResp('Bad Request: chat not found'); } const err = this.replyOrParseError(data); @@ -375,7 +386,7 @@ export class MockBotApi { }, message_id: this.sentMessages.length, from: this.bot, - chat: { ...this.user, type: 'private' }, + chat: this.chatFor(chat_id), date: this.date++, reply_parameters, caption,