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,