Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
1510 lines
41 KiB
TypeScript
1510 lines
41 KiB
TypeScript
/**
|
|
* Telegram API transport helpers
|
|
* Zones: telegram transport, filesystem, runtime diagnostics
|
|
*
|
|
* Wraps bot API calls, file uploads/downloads (including voice messages),
|
|
* multipart sending, runtime transport binding, and Telegram temp-file lifecycle.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { createWriteStream, openAsBlob } from "node:fs";
|
|
import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
import { request as requestHttps } from "node:https";
|
|
import { join } from "node:path";
|
|
import { resolveTelegramTempDir } from "./paths.ts";
|
|
import { Readable, Transform } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
|
|
export const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
|
|
export const TELEGRAM_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
|
|
export function getTelegramInboundFileByteLimitFromEnv(
|
|
env: NodeJS.ProcessEnv,
|
|
names: string[],
|
|
defaultValue = TELEGRAM_FILE_MAX_BYTES,
|
|
): number {
|
|
for (const name of names) {
|
|
const rawValue = env[name]?.trim();
|
|
if (!rawValue) continue;
|
|
const parsed = Number(rawValue);
|
|
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
function getTelegramApiTempDir(): string {
|
|
return resolveTelegramTempDir();
|
|
}
|
|
const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
|
|
process.env,
|
|
["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
|
|
TELEGRAM_FILE_MAX_BYTES,
|
|
);
|
|
|
|
export type TelegramNetworkFamilyPolicy =
|
|
"auto" | "ipv4" | "ipv6" | "ipv4-fallback";
|
|
|
|
const TELEGRAM_NETWORK_FAMILY_ENV = "PI_TELEGRAM_NETWORK_FAMILY";
|
|
const TELEGRAM_NETWORK_FAMILY_VALUES = new Set<TelegramNetworkFamilyPolicy>([
|
|
"auto",
|
|
"ipv4",
|
|
"ipv6",
|
|
"ipv4-fallback",
|
|
]);
|
|
type TelegramNetworkFamily = 4 | 6;
|
|
|
|
export interface TelegramUser {
|
|
id: number;
|
|
is_bot: boolean;
|
|
first_name: string;
|
|
username?: string;
|
|
}
|
|
|
|
export interface TelegramChat {
|
|
id: number;
|
|
type: string;
|
|
}
|
|
|
|
export interface TelegramPhotoSize {
|
|
file_id: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramDocument {
|
|
file_id: string;
|
|
file_name?: string;
|
|
mime_type?: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramVideo {
|
|
file_id: string;
|
|
file_name?: string;
|
|
mime_type?: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramAudio {
|
|
file_id: string;
|
|
file_name?: string;
|
|
mime_type?: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramVoice {
|
|
file_id: string;
|
|
mime_type?: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramAnimation {
|
|
file_id: string;
|
|
file_name?: string;
|
|
mime_type?: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramSticker {
|
|
file_id: string;
|
|
emoji?: string;
|
|
}
|
|
|
|
export interface TelegramRichMessage {
|
|
blocks?: unknown[];
|
|
is_rtl?: boolean;
|
|
}
|
|
|
|
export interface TelegramMessage {
|
|
message_id: number;
|
|
chat: TelegramChat;
|
|
from?: TelegramUser;
|
|
text?: string;
|
|
caption?: string;
|
|
rich_message?: TelegramRichMessage;
|
|
media_group_id?: string;
|
|
photo?: TelegramPhotoSize[];
|
|
document?: TelegramDocument;
|
|
video?: TelegramVideo;
|
|
audio?: TelegramAudio;
|
|
voice?: TelegramVoice;
|
|
animation?: TelegramAnimation;
|
|
sticker?: TelegramSticker;
|
|
}
|
|
|
|
export interface TelegramCallbackQuery {
|
|
id: string;
|
|
from: TelegramUser;
|
|
message?: TelegramMessage;
|
|
data?: string;
|
|
}
|
|
|
|
export interface TelegramReactionTypeEmoji {
|
|
type: "emoji";
|
|
emoji: string;
|
|
}
|
|
|
|
export interface TelegramReactionTypeCustomEmoji {
|
|
type: "custom_emoji";
|
|
custom_emoji_id: string;
|
|
}
|
|
|
|
export interface TelegramReactionTypePaid {
|
|
type: "paid";
|
|
}
|
|
|
|
export type TelegramReactionType =
|
|
| TelegramReactionTypeEmoji
|
|
| TelegramReactionTypeCustomEmoji
|
|
| TelegramReactionTypePaid;
|
|
|
|
export interface TelegramMessageReactionUpdated {
|
|
chat: TelegramChat;
|
|
message_id: number;
|
|
user?: TelegramUser;
|
|
actor_chat?: TelegramChat;
|
|
old_reaction: TelegramReactionType[];
|
|
new_reaction: TelegramReactionType[];
|
|
date: number;
|
|
}
|
|
|
|
export interface TelegramGuestMessage {
|
|
message_id: number;
|
|
from?: TelegramUser;
|
|
chat: TelegramChat;
|
|
date: number;
|
|
text?: string;
|
|
caption?: string;
|
|
rich_message?: TelegramRichMessage;
|
|
guest_query_id: string;
|
|
guest_bot_caller_user?: TelegramUser;
|
|
guest_bot_caller_chat?: TelegramChat;
|
|
reply_to_message?: TelegramMessage;
|
|
}
|
|
|
|
export interface TelegramUpdate {
|
|
update_id: number;
|
|
message?: TelegramMessage;
|
|
edited_message?: TelegramMessage;
|
|
callback_query?: TelegramCallbackQuery;
|
|
message_reaction?: TelegramMessageReactionUpdated;
|
|
guest_message?: TelegramGuestMessage;
|
|
deleted_business_messages?: { message_ids?: unknown };
|
|
}
|
|
|
|
export interface TelegramSentMessage {
|
|
message_id: number;
|
|
}
|
|
|
|
export interface TelegramReplyParameters {
|
|
message_id: number;
|
|
allow_sending_without_reply?: boolean;
|
|
chat_id?: number;
|
|
message_thread_id?: number;
|
|
}
|
|
|
|
export type TelegramSendMessageBody = Record<string, unknown> & {
|
|
chat_id: number;
|
|
text: string;
|
|
parse_mode?: "HTML";
|
|
reply_markup?: unknown;
|
|
reply_parameters?: TelegramReplyParameters;
|
|
};
|
|
|
|
export type TelegramInputRichMessage =
|
|
| {
|
|
markdown: string;
|
|
html?: never;
|
|
is_rtl?: boolean;
|
|
skip_entity_detection?: boolean;
|
|
}
|
|
| {
|
|
html: string;
|
|
markdown?: never;
|
|
is_rtl?: boolean;
|
|
skip_entity_detection?: boolean;
|
|
};
|
|
|
|
export type TelegramSendRichMessageBody = Record<string, unknown> & {
|
|
chat_id: number;
|
|
rich_message: TelegramInputRichMessage;
|
|
reply_markup?: unknown;
|
|
reply_parameters?: TelegramReplyParameters;
|
|
};
|
|
|
|
export type TelegramInputRichMessageContent = {
|
|
rich_message: TelegramInputRichMessage;
|
|
};
|
|
|
|
export type TelegramEditMessageTextBody = Record<string, unknown> & {
|
|
chat_id: number;
|
|
message_id: number;
|
|
text?: string;
|
|
rich_message?: TelegramInputRichMessage;
|
|
parse_mode?: "HTML";
|
|
reply_markup?: unknown;
|
|
};
|
|
|
|
export type TelegramSendMessageDraftBody = Record<string, unknown> & {
|
|
chat_id: number;
|
|
draft_id: number;
|
|
text?: string;
|
|
parse_mode?: string;
|
|
entities?: unknown[];
|
|
message_thread_id?: number;
|
|
};
|
|
|
|
export type TelegramSendRichMessageDraftBody = Record<string, unknown> & {
|
|
chat_id: number;
|
|
draft_id: number;
|
|
rich_message: TelegramInputRichMessage;
|
|
message_thread_id?: number;
|
|
};
|
|
|
|
interface TelegramApiResponse<T> {
|
|
ok: boolean;
|
|
result?: T;
|
|
description?: string;
|
|
error_code?: number;
|
|
parameters?: { retry_after?: number };
|
|
}
|
|
|
|
export interface TelegramApiCallOptions {
|
|
signal?: AbortSignal;
|
|
maxAttempts?: number;
|
|
retrySafety?: "safe" | "non-idempotent";
|
|
retryBaseDelayMs?: number;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
}
|
|
|
|
interface TelegramGetFileResult {
|
|
file_path: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramFileDownloadOptions {
|
|
signal?: AbortSignal;
|
|
maxFileSizeBytes?: number;
|
|
}
|
|
|
|
export type TelegramGuestCachedMediaResult =
|
|
| {
|
|
type: "document";
|
|
id: string;
|
|
title: string;
|
|
document_file_id: string;
|
|
caption?: string;
|
|
parse_mode?: string;
|
|
}
|
|
| {
|
|
type: "photo";
|
|
id: string;
|
|
photo_file_id: string;
|
|
caption?: string;
|
|
parse_mode?: string;
|
|
}
|
|
| {
|
|
type: "audio";
|
|
id: string;
|
|
audio_file_id: string;
|
|
caption?: string;
|
|
parse_mode?: string;
|
|
}
|
|
| {
|
|
type: "voice";
|
|
id: string;
|
|
voice_file_id: string;
|
|
title: string;
|
|
caption?: string;
|
|
parse_mode?: string;
|
|
};
|
|
|
|
export interface TelegramAnswerGuestQueryOptions {
|
|
parseMode?: string;
|
|
richMessage?: TelegramInputRichMessage;
|
|
result?: TelegramGuestCachedMediaResult;
|
|
}
|
|
|
|
export interface TelegramAnswerCallbackQueryOptions {
|
|
recordRuntimeEvent?: (
|
|
kind: "api",
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
}
|
|
|
|
export interface TelegramApiClient {
|
|
call: <TResponse>(
|
|
method: string,
|
|
body: Record<string, unknown>,
|
|
options?: TelegramApiCallOptions,
|
|
) => Promise<TResponse>;
|
|
callMultipart: <TResponse>(
|
|
method: string,
|
|
fields: Record<string, string>,
|
|
fileField: string,
|
|
filePath: string,
|
|
fileName: string,
|
|
options?: TelegramApiCallOptions,
|
|
) => Promise<TResponse>;
|
|
downloadFile: (
|
|
fileId: string,
|
|
suggestedName: string,
|
|
tempDir: string,
|
|
options?: TelegramFileDownloadOptions,
|
|
) => Promise<string>;
|
|
answerCallbackQuery: (
|
|
callbackQueryId: string,
|
|
text?: string,
|
|
) => Promise<void>;
|
|
answerGuestQuery?: (
|
|
guestQueryId: string,
|
|
text?: string,
|
|
options?: TelegramAnswerGuestQueryOptions,
|
|
) => Promise<void>;
|
|
}
|
|
|
|
export interface TelegramBridgeApiRuntimeDeps {
|
|
client: TelegramApiClient;
|
|
tempDir: string;
|
|
maxFileSizeBytes: number;
|
|
tempFileMaxAgeMs: number;
|
|
recordRuntimeEvent: (
|
|
kind: "api" | "multipart" | "download",
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
}
|
|
|
|
export interface TelegramBridgeApiRuntime {
|
|
call: <TResponse>(
|
|
method: string,
|
|
body: Record<string, unknown>,
|
|
options?: TelegramApiCallOptions,
|
|
) => Promise<TResponse>;
|
|
callMultipart: <TResponse>(
|
|
method: string,
|
|
fields: Record<string, string>,
|
|
fileField: string,
|
|
filePath: string,
|
|
fileName: string,
|
|
options?: TelegramApiCallOptions,
|
|
) => Promise<TResponse>;
|
|
downloadFile: (fileId: string, suggestedName: string) => Promise<string>;
|
|
deleteWebhook: (signal?: AbortSignal) => Promise<boolean>;
|
|
getUpdates: (
|
|
body: Record<string, unknown>,
|
|
signal?: AbortSignal,
|
|
) => Promise<TelegramUpdate[]>;
|
|
setMyCommands: (
|
|
commands: readonly { command: string; description: string }[],
|
|
) => Promise<boolean>;
|
|
sendChatAction: (
|
|
chatId: number,
|
|
action: string,
|
|
options?: { message_thread_id?: number },
|
|
) => Promise<boolean>;
|
|
sendTypingAction: (
|
|
chatId: number,
|
|
options?: { message_thread_id?: number },
|
|
) => Promise<unknown>;
|
|
sendRecordVoiceAction: (
|
|
chatId: number,
|
|
options?: { message_thread_id?: number },
|
|
) => Promise<unknown>;
|
|
sendMessageDraft: (
|
|
chatId: number,
|
|
draftId: number,
|
|
text?: string,
|
|
options?: {
|
|
parse_mode?: string;
|
|
entities?: unknown[];
|
|
message_thread_id?: number;
|
|
},
|
|
) => Promise<boolean>;
|
|
sendMessage: (body: TelegramSendMessageBody) => Promise<TelegramSentMessage>;
|
|
sendRichMessage: (
|
|
body: TelegramSendRichMessageBody,
|
|
) => Promise<TelegramSentMessage>;
|
|
sendRichMessageDraft: (
|
|
body: TelegramSendRichMessageDraftBody,
|
|
) => Promise<boolean>;
|
|
editMessageText: (
|
|
body: TelegramEditMessageTextBody,
|
|
) => Promise<"edited" | "unchanged">;
|
|
answerCallbackQuery: (
|
|
callbackQueryId: string,
|
|
text?: string,
|
|
) => Promise<void>;
|
|
answerGuestQuery: (
|
|
guestQueryId: string,
|
|
text?: string,
|
|
options?: TelegramAnswerGuestQueryOptions,
|
|
) => Promise<void>;
|
|
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
prepareTempDir: () => Promise<number>;
|
|
}
|
|
|
|
function sanitizeFileName(name: string): string {
|
|
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
}
|
|
|
|
class TelegramApiMalformedSuccessError extends Error {
|
|
constructor(method: string, detail: string) {
|
|
super(`Telegram API ${method} ${detail}`);
|
|
this.name = "TelegramApiMalformedSuccessError";
|
|
}
|
|
}
|
|
|
|
export class TelegramApiCommitUnknownError extends Error {
|
|
readonly kind = "commit-unknown" as const;
|
|
readonly method: string;
|
|
override readonly cause: unknown;
|
|
|
|
constructor(method: string, cause: unknown) {
|
|
super(`Telegram API ${method} may have committed before transport failed.`);
|
|
this.name = "TelegramApiCommitUnknownError";
|
|
this.method = method;
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
export function isTelegramApiCommitUnknownError(
|
|
error: unknown,
|
|
): error is TelegramApiCommitUnknownError {
|
|
return error instanceof TelegramApiCommitUnknownError;
|
|
}
|
|
|
|
class TelegramApiHttpError extends Error {
|
|
readonly status: number | undefined;
|
|
readonly retryAfterSeconds: number | undefined;
|
|
constructor(
|
|
message: string,
|
|
status: number | undefined,
|
|
retryAfterSeconds: number | undefined,
|
|
) {
|
|
super(message);
|
|
this.status = status;
|
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
}
|
|
}
|
|
|
|
export function isTelegramMessageNotModifiedError(error: unknown): boolean {
|
|
return (
|
|
error instanceof Error && error.message.includes("message is not modified")
|
|
);
|
|
}
|
|
|
|
const TELEGRAM_RETRY_SAFE_METHODS = new Set([
|
|
"answerCallbackQuery",
|
|
"closeForumTopic",
|
|
"deleteForumTopic",
|
|
"deleteMessage",
|
|
"deleteWebhook",
|
|
"editForumTopic",
|
|
"editMessageCaption",
|
|
"editMessageReplyMarkup",
|
|
"editMessageText",
|
|
"getChat",
|
|
"getFile",
|
|
"getMe",
|
|
"getUpdates",
|
|
"sendChatAction",
|
|
"sendMessageDraft",
|
|
"sendRichMessageDraft",
|
|
"setMyCommands",
|
|
]);
|
|
|
|
export function isTelegramApiMethodRetrySafe(method: string): boolean {
|
|
return TELEGRAM_RETRY_SAFE_METHODS.has(method);
|
|
}
|
|
|
|
function isRetryableTelegramApiError(error: unknown): boolean {
|
|
return (
|
|
error instanceof TelegramApiHttpError &&
|
|
(error.status === 429 ||
|
|
(error.status !== undefined && error.status >= 500))
|
|
);
|
|
}
|
|
|
|
function getTelegramRetryDelayMs(
|
|
error: unknown,
|
|
attempt: number,
|
|
baseDelayMs: number,
|
|
): number {
|
|
if (
|
|
error instanceof TelegramApiHttpError &&
|
|
error.retryAfterSeconds !== undefined
|
|
) {
|
|
return Math.max(0, error.retryAfterSeconds * 1000);
|
|
}
|
|
return Math.max(0, baseDelayMs * 2 ** attempt);
|
|
}
|
|
|
|
function sleepTelegramRetry(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function assertTelegramFileSizeWithinLimit(
|
|
size: number | undefined,
|
|
maxFileSizeBytes: number | undefined,
|
|
): void {
|
|
if (size === undefined || maxFileSizeBytes === undefined) return;
|
|
if (size <= maxFileSizeBytes) return;
|
|
throw new Error(
|
|
`Telegram file exceeds size limit (${size} bytes > ${maxFileSizeBytes} bytes)`,
|
|
);
|
|
}
|
|
|
|
function createTelegramDownloadLimitTransform(
|
|
maxFileSizeBytes: number | undefined,
|
|
): Transform {
|
|
let downloadedBytes = 0;
|
|
return new Transform({
|
|
transform(chunk: Buffer, _encoding, callback) {
|
|
downloadedBytes += chunk.byteLength;
|
|
try {
|
|
assertTelegramFileSizeWithinLimit(downloadedBytes, maxFileSizeBytes);
|
|
callback(undefined, chunk);
|
|
} catch (error) {
|
|
callback(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
async function writeTelegramDownloadResponse(
|
|
response: Response,
|
|
targetPath: string,
|
|
maxFileSizeBytes: number | undefined,
|
|
): Promise<void> {
|
|
if (!response.body) {
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
assertTelegramFileSizeWithinLimit(buffer.byteLength, maxFileSizeBytes);
|
|
await writeFile(targetPath, buffer, { mode: 0o600 });
|
|
return;
|
|
}
|
|
await pipeline(
|
|
Readable.from(response.body, { objectMode: false }),
|
|
createTelegramDownloadLimitTransform(maxFileSizeBytes),
|
|
createWriteStream(targetPath, { mode: 0o600 }),
|
|
);
|
|
}
|
|
|
|
async function removeTelegramPartialDownload(path: string): Promise<void> {
|
|
try {
|
|
await unlink(path);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function parseTelegramApiResponse<TResponse>(
|
|
response: Response,
|
|
method: string,
|
|
): Promise<TelegramApiResponse<TResponse>> {
|
|
let data: TelegramApiResponse<TResponse> | undefined;
|
|
try {
|
|
if (typeof response.text === "function") {
|
|
const text = await response.text();
|
|
data = text
|
|
? (JSON.parse(text) as TelegramApiResponse<TResponse>)
|
|
: undefined;
|
|
} else {
|
|
data = (await response.json()) as TelegramApiResponse<TResponse>;
|
|
}
|
|
} catch {
|
|
data = undefined;
|
|
}
|
|
if (response.ok === false) {
|
|
const status = `HTTP ${response.status}`;
|
|
const description = data?.description ? `: ${data.description}` : "";
|
|
const retryAfterHeader = response.headers?.get("retry-after");
|
|
const retryAfterSeconds =
|
|
data?.parameters?.retry_after ??
|
|
(retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined);
|
|
throw new TelegramApiHttpError(
|
|
`Telegram API ${method} failed: ${status}${description}`,
|
|
response.status,
|
|
Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
|
|
);
|
|
}
|
|
if (!data) {
|
|
throw new TelegramApiMalformedSuccessError(method, "returned invalid JSON");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function unwrapTelegramApiResult<TResponse>(
|
|
method: string,
|
|
data: TelegramApiResponse<TResponse>,
|
|
): TResponse {
|
|
if (data.ok && data.result === undefined) {
|
|
throw new TelegramApiMalformedSuccessError(method, "returned no result");
|
|
}
|
|
if (!data.ok) {
|
|
throw new Error(data.description || `Telegram API ${method} failed`);
|
|
}
|
|
return data.result as TResponse;
|
|
}
|
|
|
|
function getTelegramNetworkFamilyPolicy(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): TelegramNetworkFamilyPolicy {
|
|
const value = env[TELEGRAM_NETWORK_FAMILY_ENV]?.trim().toLowerCase();
|
|
if (
|
|
TELEGRAM_NETWORK_FAMILY_VALUES.has(value as TelegramNetworkFamilyPolicy)
|
|
) {
|
|
return value as TelegramNetworkFamilyPolicy;
|
|
}
|
|
return "ipv4-fallback";
|
|
}
|
|
|
|
function getTelegramNetworkFamily(
|
|
policy: TelegramNetworkFamilyPolicy,
|
|
): TelegramNetworkFamily | undefined {
|
|
if (policy === "ipv4") return 4;
|
|
if (policy === "ipv6") return 6;
|
|
return undefined;
|
|
}
|
|
|
|
function isTelegramTransportFailure(error: unknown): boolean {
|
|
if (!(error instanceof Error)) return false;
|
|
if (error.name === "AbortError") return false;
|
|
if (error instanceof TypeError && /fetch failed/i.test(error.message)) {
|
|
return true;
|
|
}
|
|
if (error instanceof AggregateError) return true;
|
|
const code = getErrorCode(error);
|
|
if (
|
|
code === "ECONNREFUSED" ||
|
|
code === "ETIMEDOUT" ||
|
|
code === "ENETUNREACH" ||
|
|
code === "EHOSTUNREACH" ||
|
|
code === "ECONNRESET" ||
|
|
code === "EAI_AGAIN"
|
|
) {
|
|
return true;
|
|
}
|
|
return isTelegramTransportFailure(error.cause);
|
|
}
|
|
|
|
function getTelegramRequestBodyBuffer(
|
|
body: BodyInit | null | undefined,
|
|
): Buffer | undefined {
|
|
if (body === undefined || body === null) return undefined;
|
|
if (typeof body === "string") return Buffer.from(body);
|
|
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
throw new Error("Unsupported Telegram HTTPS request body");
|
|
}
|
|
|
|
async function buildTelegramMultipartBody(
|
|
fields: Record<string, string>,
|
|
fileField: string,
|
|
fileBlob: Blob,
|
|
fileName: string,
|
|
): Promise<{ body: Buffer; contentType: string }> {
|
|
const boundary = `pi-telegram-${randomUUID()}`;
|
|
const chunks: Buffer[] = [];
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
chunks.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`,
|
|
),
|
|
);
|
|
}
|
|
chunks.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="${fileField}"; filename="${fileName}"\r\nContent-Type: ${fileBlob.type || "application/octet-stream"}\r\n\r\n`,
|
|
),
|
|
Buffer.from(await fileBlob.arrayBuffer()),
|
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
);
|
|
return {
|
|
body: Buffer.concat(chunks),
|
|
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
};
|
|
}
|
|
|
|
async function telegramHttpsFetch(
|
|
input: string | URL | Request,
|
|
init: RequestInit,
|
|
family: TelegramNetworkFamily,
|
|
): Promise<Response> {
|
|
const url = new URL(
|
|
typeof input === "string" || input instanceof URL ? input : input.url,
|
|
);
|
|
const body = getTelegramRequestBodyBuffer(init.body);
|
|
const headers = new Headers(init.headers);
|
|
if (body && !headers.has("content-length")) {
|
|
headers.set("content-length", String(body.byteLength));
|
|
}
|
|
return new Promise<Response>((resolve, reject) => {
|
|
const req = requestHttps(
|
|
url,
|
|
{
|
|
method: init.method ?? "GET",
|
|
family,
|
|
headers: Object.fromEntries(headers.entries()),
|
|
},
|
|
(res) => {
|
|
const responseHeaders = new Headers();
|
|
for (const [key, value] of Object.entries(res.headers)) {
|
|
if (Array.isArray(value)) responseHeaders.set(key, value.join(", "));
|
|
else if (value !== undefined) responseHeaders.set(key, String(value));
|
|
}
|
|
resolve(
|
|
new Response(Readable.toWeb(res) as ReadableStream<Uint8Array>, {
|
|
status: res.statusCode ?? 200,
|
|
statusText: res.statusMessage,
|
|
headers: responseHeaders,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
if (init.signal) {
|
|
if (init.signal.aborted)
|
|
req.destroy(new DOMException("Aborted", "AbortError"));
|
|
else {
|
|
init.signal.addEventListener(
|
|
"abort",
|
|
() => req.destroy(new DOMException("Aborted", "AbortError")),
|
|
{ once: true },
|
|
);
|
|
}
|
|
}
|
|
req.end(body);
|
|
});
|
|
}
|
|
|
|
let telegramHttpsFetchForTesting: typeof telegramHttpsFetch | undefined;
|
|
|
|
export function setTelegramApiHttpsFetchForTesting(
|
|
fetchImpl: typeof telegramHttpsFetch | undefined,
|
|
): () => void {
|
|
const previous = telegramHttpsFetchForTesting;
|
|
telegramHttpsFetchForTesting = fetchImpl;
|
|
return () => {
|
|
telegramHttpsFetchForTesting = previous;
|
|
};
|
|
}
|
|
|
|
async function telegramFetch(
|
|
input: string | URL | Request,
|
|
init: RequestInit = {},
|
|
family?: TelegramNetworkFamily,
|
|
): Promise<Response> {
|
|
if (!family) return fetch(input, init);
|
|
return (telegramHttpsFetchForTesting ?? telegramHttpsFetch)(
|
|
input,
|
|
init,
|
|
family,
|
|
);
|
|
}
|
|
|
|
async function callTelegramTransportRequest(
|
|
request: (family?: TelegramNetworkFamily) => Promise<Response>,
|
|
allowFallback = true,
|
|
): Promise<Response> {
|
|
const policy = getTelegramNetworkFamilyPolicy();
|
|
if (policy === "auto") return request();
|
|
const family = getTelegramNetworkFamily(policy);
|
|
if (family) return request(family);
|
|
if (!allowFallback) return request();
|
|
try {
|
|
return await request();
|
|
} catch (error) {
|
|
if (!isTelegramTransportFailure(error)) throw error;
|
|
return request(4);
|
|
}
|
|
}
|
|
|
|
function getErrorCode(error: Error): string | undefined {
|
|
const maybeCode = (error as { code?: unknown }).code;
|
|
return typeof maybeCode === "string" ? maybeCode : undefined;
|
|
}
|
|
|
|
function getErrorAddress(error: Error): string | undefined {
|
|
const maybeAddress = (error as { address?: unknown }).address;
|
|
return typeof maybeAddress === "string" ? maybeAddress : undefined;
|
|
}
|
|
|
|
function getErrorPort(error: Error): number | undefined {
|
|
const maybePort = (error as { port?: unknown }).port;
|
|
return typeof maybePort === "number" ? maybePort : undefined;
|
|
}
|
|
|
|
function getErrorFamily(error: Error): number | string | undefined {
|
|
const maybeFamily = (error as { family?: unknown }).family;
|
|
if (typeof maybeFamily === "number" || typeof maybeFamily === "string") {
|
|
return maybeFamily;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function describeTelegramErrorSummary(error: Error): {
|
|
name: string;
|
|
message: string;
|
|
code?: string;
|
|
} {
|
|
return {
|
|
name: error.name,
|
|
message: error.message,
|
|
...(getErrorCode(error) ? { code: getErrorCode(error) } : {}),
|
|
};
|
|
}
|
|
|
|
function describeTelegramTransportAttempt(error: Error): {
|
|
name: string;
|
|
code?: string;
|
|
address?: string;
|
|
port?: number;
|
|
family?: number | string;
|
|
} {
|
|
return {
|
|
name: error.name,
|
|
...(getErrorCode(error) ? { code: getErrorCode(error) } : {}),
|
|
...(getErrorAddress(error) ? { address: getErrorAddress(error) } : {}),
|
|
...(getErrorPort(error) ? { port: getErrorPort(error) } : {}),
|
|
...(getErrorFamily(error) ? { family: getErrorFamily(error) } : {}),
|
|
};
|
|
}
|
|
|
|
function describeTelegramTransportError(error: unknown):
|
|
| {
|
|
error: { name: string; message: string; code?: string };
|
|
cause?: { name: string; message: string; code?: string };
|
|
attempts?: Array<{
|
|
name: string;
|
|
code?: string;
|
|
address?: string;
|
|
port?: number;
|
|
family?: number | string;
|
|
}>;
|
|
}
|
|
| undefined {
|
|
if (!isTelegramTransportFailure(error) || !(error instanceof Error)) {
|
|
return undefined;
|
|
}
|
|
const cause = error.cause instanceof Error ? error.cause : undefined;
|
|
const aggregate =
|
|
error instanceof AggregateError
|
|
? error
|
|
: cause instanceof AggregateError
|
|
? cause
|
|
: undefined;
|
|
const attempts = aggregate?.errors
|
|
.filter((attempt): attempt is Error => attempt instanceof Error)
|
|
.map(describeTelegramTransportAttempt);
|
|
return {
|
|
error: describeTelegramErrorSummary(error),
|
|
...(cause ? { cause: describeTelegramErrorSummary(cause) } : {}),
|
|
...(attempts && attempts.length > 0 ? { attempts } : {}),
|
|
};
|
|
}
|
|
|
|
function withTelegramTransportDiagnostics(
|
|
error: unknown,
|
|
details: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const transport = describeTelegramTransportError(error);
|
|
return transport ? { ...details, transport } : details;
|
|
}
|
|
|
|
async function callTelegramWithRetry<TResponse>(
|
|
method: string,
|
|
request: (family?: TelegramNetworkFamily) => Promise<Response>,
|
|
options: TelegramApiCallOptions | undefined,
|
|
): Promise<TResponse> {
|
|
const retrySafe =
|
|
options?.retrySafety === "safe" ||
|
|
(options?.retrySafety !== "non-idempotent" &&
|
|
isTelegramApiMethodRetrySafe(method));
|
|
const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
|
|
const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
|
|
const sleep = options?.sleep ?? sleepTelegramRetry;
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
return unwrapTelegramApiResult(
|
|
method,
|
|
await parseTelegramApiResponse<TResponse>(
|
|
await callTelegramTransportRequest(request, retrySafe),
|
|
method,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
const retryable = isRetryableTelegramApiError(error);
|
|
if (!retrySafe) {
|
|
if (error instanceof TelegramApiHttpError && error.status === 429) {
|
|
if (attempt >= maxAttempts - 1) throw error;
|
|
await sleep(
|
|
getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
|
|
);
|
|
continue;
|
|
}
|
|
if (
|
|
error instanceof TelegramApiMalformedSuccessError ||
|
|
isTelegramTransportFailure(error) ||
|
|
(error instanceof TelegramApiHttpError &&
|
|
error.status !== undefined &&
|
|
error.status >= 500)
|
|
) {
|
|
throw new TelegramApiCommitUnknownError(method, error);
|
|
}
|
|
throw error;
|
|
}
|
|
if (attempt >= maxAttempts - 1 || !retryable) throw error;
|
|
await sleep(getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs));
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function cleanupTelegramTempFiles(
|
|
tempDir: string,
|
|
maxAgeMs: number,
|
|
now = Date.now(),
|
|
): Promise<number> {
|
|
let removedCount = 0;
|
|
let entries: Array<{ isFile(): boolean; name: string }>;
|
|
try {
|
|
entries = await readdir(tempDir, { withFileTypes: true });
|
|
} catch {
|
|
return 0;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
const path = join(tempDir, entry.name);
|
|
try {
|
|
const stats = await stat(path);
|
|
if (now - stats.mtimeMs <= maxAgeMs) continue;
|
|
await unlink(path);
|
|
removedCount += 1;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return removedCount;
|
|
}
|
|
|
|
export async function prepareTelegramTempDir(
|
|
tempDir: string,
|
|
maxAgeMs: number,
|
|
): Promise<number> {
|
|
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
return cleanupTelegramTempFiles(tempDir, maxAgeMs);
|
|
}
|
|
|
|
function assertTelegramBotTokenConfigured(
|
|
botToken: string | undefined,
|
|
): string {
|
|
if (!botToken) throw new Error("Telegram bot token is not configured");
|
|
return botToken;
|
|
}
|
|
|
|
export async function callTelegram<TResponse>(
|
|
botToken: string | undefined,
|
|
method: string,
|
|
body: Record<string, unknown>,
|
|
options?: TelegramApiCallOptions,
|
|
): Promise<TResponse> {
|
|
const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
|
|
return callTelegramWithRetry(
|
|
method,
|
|
async (family) =>
|
|
telegramFetch(
|
|
`${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: options?.signal,
|
|
},
|
|
family,
|
|
),
|
|
options,
|
|
);
|
|
}
|
|
|
|
export type TelegramBotIdentityResponse = Pick<
|
|
TelegramApiResponse<TelegramUser>,
|
|
"ok" | "result" | "description"
|
|
>;
|
|
|
|
export async function fetchTelegramBotIdentity(
|
|
botToken: string,
|
|
fetchImpl: typeof fetch = fetch,
|
|
): Promise<TelegramBotIdentityResponse> {
|
|
const url = `${TELEGRAM_API_BASE}/bot${botToken}/getMe`;
|
|
const response = await callTelegramTransportRequest((family) =>
|
|
fetchImpl === fetch ? telegramFetch(url, {}, family) : fetchImpl(url),
|
|
);
|
|
return response.json() as Promise<TelegramBotIdentityResponse>;
|
|
}
|
|
|
|
/**
|
|
* Low-level helper to send a multipart/form-data request to the Telegram Bot API.
|
|
* This is the core implementation used for uploading voice messages, photos,
|
|
* documents, animations, etc. It handles FormData construction, retry logic
|
|
* (via callTelegramWithRetry), and error recording under the "multipart" category.
|
|
*/
|
|
export async function callTelegramMultipart<TResponse>(
|
|
botToken: string | undefined,
|
|
method: string,
|
|
fields: Record<string, string>,
|
|
fileField: string,
|
|
filePath: string,
|
|
fileName: string,
|
|
options?: TelegramApiCallOptions,
|
|
): Promise<TResponse> {
|
|
const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
|
|
const fileBlob = await openAsBlob(filePath);
|
|
return callTelegramWithRetry(
|
|
method,
|
|
async (family) => {
|
|
if (family) {
|
|
const multipart = await buildTelegramMultipartBody(
|
|
fields,
|
|
fileField,
|
|
fileBlob,
|
|
fileName,
|
|
);
|
|
return telegramFetch(
|
|
`${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": multipart.contentType },
|
|
body: multipart.body as unknown as BodyInit,
|
|
signal: options?.signal,
|
|
},
|
|
family,
|
|
);
|
|
}
|
|
const form = new FormData();
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
form.set(key, value);
|
|
}
|
|
form.set(fileField, fileBlob, fileName);
|
|
return telegramFetch(
|
|
`${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
|
|
{
|
|
method: "POST",
|
|
body: form,
|
|
signal: options?.signal,
|
|
},
|
|
);
|
|
},
|
|
options,
|
|
);
|
|
}
|
|
|
|
export async function downloadTelegramFile(
|
|
botToken: string | undefined,
|
|
fileId: string,
|
|
suggestedName: string,
|
|
tempDir: string,
|
|
options?: TelegramFileDownloadOptions,
|
|
): Promise<string> {
|
|
const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
|
|
const file = await callTelegram<TelegramGetFileResult>(
|
|
configuredBotToken,
|
|
"getFile",
|
|
{ file_id: fileId },
|
|
{ signal: options?.signal },
|
|
);
|
|
assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
|
|
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
const targetPath = join(
|
|
tempDir,
|
|
`${randomUUID()}-${sanitizeFileName(suggestedName)}`,
|
|
);
|
|
const response = await callTelegramTransportRequest((family) =>
|
|
telegramFetch(
|
|
`${TELEGRAM_API_BASE}/file/bot${configuredBotToken}/${file.file_path}`,
|
|
{ signal: options?.signal },
|
|
family,
|
|
),
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download Telegram file: ${response.status}`);
|
|
}
|
|
const contentLength = response.headers?.get("content-length");
|
|
assertTelegramFileSizeWithinLimit(
|
|
contentLength ? Number.parseInt(contentLength, 10) : undefined,
|
|
options?.maxFileSizeBytes,
|
|
);
|
|
try {
|
|
await writeTelegramDownloadResponse(
|
|
response,
|
|
targetPath,
|
|
options?.maxFileSizeBytes,
|
|
);
|
|
} catch (error) {
|
|
await removeTelegramPartialDownload(targetPath);
|
|
throw error;
|
|
}
|
|
return targetPath;
|
|
}
|
|
|
|
export async function answerTelegramCallbackQuery(
|
|
botToken: string | undefined,
|
|
callbackQueryId: string,
|
|
text?: string,
|
|
options: TelegramAnswerCallbackQueryOptions = {},
|
|
): Promise<void> {
|
|
try {
|
|
await callTelegram<boolean>(
|
|
botToken,
|
|
"answerCallbackQuery",
|
|
text
|
|
? { callback_query_id: callbackQueryId, text }
|
|
: { callback_query_id: callbackQueryId },
|
|
);
|
|
} catch (error) {
|
|
options.recordRuntimeEvent?.(
|
|
"api",
|
|
error,
|
|
withTelegramTransportDiagnostics(error, {
|
|
method: "answerCallbackQuery",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function deleteTelegramMessage(
|
|
botToken: string | undefined,
|
|
chatId: number,
|
|
messageId: number,
|
|
): Promise<void> {
|
|
try {
|
|
await callTelegram<boolean>(botToken, "deleteMessage", {
|
|
chat_id: chatId,
|
|
message_id: messageId,
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
export function createTelegramChatActionSender<TAction extends string>(
|
|
sendChatAction: (
|
|
chatId: number,
|
|
action: TAction,
|
|
options?: { message_thread_id?: number },
|
|
) => Promise<unknown>,
|
|
action: TAction,
|
|
): (
|
|
chatId: number,
|
|
options?: { message_thread_id?: number },
|
|
) => Promise<unknown> {
|
|
return (chatId, options) => sendChatAction(chatId, action, options);
|
|
}
|
|
|
|
export function createTelegramNativeMarkdownDraftSender(deps: {
|
|
sendMessageDraft: TelegramBridgeApiRuntime["sendMessageDraft"];
|
|
sendRichMessageDraft: TelegramBridgeApiRuntime["sendRichMessageDraft"];
|
|
}): TelegramBridgeApiRuntime["sendMessageDraft"] {
|
|
return (chatId, draftId, text, options) => {
|
|
if (text === undefined) {
|
|
return deps.sendMessageDraft(chatId, draftId, text, options);
|
|
}
|
|
return deps.sendRichMessageDraft({
|
|
chat_id: chatId,
|
|
draft_id: draftId,
|
|
rich_message: { markdown: text, skip_entity_detection: true },
|
|
...(options?.message_thread_id !== undefined
|
|
? { message_thread_id: options.message_thread_id }
|
|
: {}),
|
|
});
|
|
};
|
|
}
|
|
|
|
export function createTelegramAssistantDraftSender(deps: {
|
|
getAssistantRenderingMode: () => "rich" | "html";
|
|
renderMarkdownToHtmlDraft: (markdown: string) => string;
|
|
sendMessageDraft: TelegramBridgeApiRuntime["sendMessageDraft"];
|
|
sendRichMessageDraft: TelegramBridgeApiRuntime["sendRichMessageDraft"];
|
|
}): TelegramBridgeApiRuntime["sendMessageDraft"] {
|
|
const sendNativeDraft = createTelegramNativeMarkdownDraftSender(deps);
|
|
return (chatId, draftId, text, options) => {
|
|
if (text === undefined || deps.getAssistantRenderingMode() === "rich") {
|
|
return sendNativeDraft(chatId, draftId, text, options);
|
|
}
|
|
return deps.sendMessageDraft(
|
|
chatId,
|
|
draftId,
|
|
deps.renderMarkdownToHtmlDraft(text),
|
|
{
|
|
...options,
|
|
parse_mode: "HTML",
|
|
},
|
|
);
|
|
};
|
|
}
|
|
|
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
getBotToken: () => string | undefined;
|
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
|
}): TelegramBridgeApiRuntime {
|
|
return createTelegramBridgeApiRuntime({
|
|
client: createTelegramApiClient(deps.getBotToken, {
|
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
}),
|
|
tempDir: getTelegramApiTempDir(),
|
|
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
});
|
|
}
|
|
|
|
export function createTelegramBridgeApiRuntime(
|
|
deps: TelegramBridgeApiRuntimeDeps,
|
|
): TelegramBridgeApiRuntime {
|
|
const callRecorded = async <TResponse>(
|
|
method: string,
|
|
body: Record<string, unknown>,
|
|
options?: TelegramApiCallOptions,
|
|
): Promise<TResponse> => {
|
|
try {
|
|
return await deps.client.call<TResponse>(method, body, options);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent(
|
|
"api",
|
|
error,
|
|
withTelegramTransportDiagnostics(error, { method }),
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
return {
|
|
call: callRecorded,
|
|
|
|
/**
|
|
* Sends a multipart/form-data request (used for sending voice messages,
|
|
* photos, documents, animations, etc.).
|
|
* Errors are recorded under the "multipart" category for diagnostics.
|
|
*/
|
|
callMultipart: async (
|
|
method,
|
|
fields,
|
|
fileField,
|
|
filePath,
|
|
fileName,
|
|
options,
|
|
) => {
|
|
try {
|
|
return await deps.client.callMultipart(
|
|
method,
|
|
fields,
|
|
fileField,
|
|
filePath,
|
|
fileName,
|
|
options,
|
|
);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent(
|
|
"multipart",
|
|
error,
|
|
withTelegramTransportDiagnostics(error, { method, fileName }),
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Downloads a file from the Telegram servers into the local temp directory.
|
|
* Used for inbound voice messages, photos, documents, etc.
|
|
*/
|
|
downloadFile: async (fileId, suggestedName) => {
|
|
try {
|
|
return await deps.client.downloadFile(
|
|
fileId,
|
|
suggestedName,
|
|
deps.tempDir,
|
|
{
|
|
maxFileSizeBytes: deps.maxFileSizeBytes,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent(
|
|
"download",
|
|
error,
|
|
withTelegramTransportDiagnostics(error, { suggestedName }),
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
deleteWebhook: (signal) =>
|
|
callRecorded<boolean>(
|
|
"deleteWebhook",
|
|
{ drop_pending_updates: false },
|
|
{ signal },
|
|
),
|
|
getUpdates: (body, signal) =>
|
|
callRecorded<TelegramUpdate[]>("getUpdates", body, { signal }),
|
|
setMyCommands: (commands) =>
|
|
callRecorded<boolean>("setMyCommands", { commands }),
|
|
sendChatAction: (chatId, action, options) =>
|
|
callRecorded<boolean>("sendChatAction", {
|
|
chat_id: chatId,
|
|
action,
|
|
...(options?.message_thread_id !== undefined
|
|
? { message_thread_id: options.message_thread_id }
|
|
: {}),
|
|
}),
|
|
sendTypingAction: createTelegramChatActionSender(
|
|
(chatId, action, options) =>
|
|
callRecorded<boolean>("sendChatAction", {
|
|
chat_id: chatId,
|
|
action,
|
|
...(options?.message_thread_id !== undefined
|
|
? { message_thread_id: options.message_thread_id }
|
|
: {}),
|
|
}),
|
|
"typing",
|
|
),
|
|
sendRecordVoiceAction: createTelegramChatActionSender(
|
|
(chatId, action, options) =>
|
|
callRecorded<boolean>("sendChatAction", {
|
|
chat_id: chatId,
|
|
action,
|
|
...(options?.message_thread_id !== undefined
|
|
? { message_thread_id: options.message_thread_id }
|
|
: {}),
|
|
}),
|
|
"record_voice",
|
|
),
|
|
sendMessageDraft: (chatId, draftId, text, options) => {
|
|
const body: Record<string, unknown> = {
|
|
chat_id: chatId,
|
|
draft_id: draftId,
|
|
};
|
|
if (text !== undefined) body.text = text;
|
|
if (options?.parse_mode !== undefined)
|
|
body.parse_mode = options.parse_mode;
|
|
if (options?.entities !== undefined) body.entities = options.entities;
|
|
if (options?.message_thread_id !== undefined)
|
|
body.message_thread_id = options.message_thread_id;
|
|
return callRecorded<boolean>("sendMessageDraft", body);
|
|
},
|
|
sendMessage: (body) =>
|
|
callRecorded<TelegramSentMessage>("sendMessage", body),
|
|
sendRichMessage: (body) =>
|
|
callRecorded<TelegramSentMessage>("sendRichMessage", body),
|
|
sendRichMessageDraft: (body) =>
|
|
callRecorded<boolean>("sendRichMessageDraft", body),
|
|
editMessageText: async (body) => {
|
|
try {
|
|
await deps.client.call("editMessageText", body);
|
|
return "edited";
|
|
} catch (error) {
|
|
if (isTelegramMessageNotModifiedError(error)) return "unchanged";
|
|
deps.recordRuntimeEvent(
|
|
"api",
|
|
error,
|
|
withTelegramTransportDiagnostics(error, {
|
|
method: "editMessageText",
|
|
}),
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
answerCallbackQuery: async (callbackQueryId, text) => {
|
|
try {
|
|
await deps.client.answerCallbackQuery(callbackQueryId, text);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent(
|
|
"api",
|
|
error,
|
|
withTelegramTransportDiagnostics(error, {
|
|
method: "answerCallbackQuery",
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
answerGuestQuery: (
|
|
guestQueryId: string,
|
|
text: string | undefined,
|
|
options: TelegramAnswerGuestQueryOptions | undefined,
|
|
) => {
|
|
const body: Record<string, unknown> = { guest_query_id: guestQueryId };
|
|
if (options?.result) {
|
|
body.result = options.result;
|
|
} else if (text !== undefined || options?.richMessage) {
|
|
const inputContent: Record<string, unknown> = options?.richMessage
|
|
? { rich_message: options.richMessage }
|
|
: { message_text: text };
|
|
if (!options?.richMessage && options?.parseMode) {
|
|
inputContent.parse_mode = options.parseMode;
|
|
}
|
|
body.result = {
|
|
type: "article",
|
|
id: "1",
|
|
title: "Response",
|
|
input_message_content: inputContent,
|
|
};
|
|
}
|
|
return callRecorded<void>("answerGuestQuery", body);
|
|
},
|
|
prepareTempDir: () =>
|
|
prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
|
|
deleteMessage: (chatId, messageId) =>
|
|
callRecorded<boolean>("deleteMessage", {
|
|
chat_id: chatId,
|
|
message_id: messageId,
|
|
}).then(() => {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a low-level Telegram Bot API client.
|
|
* This is the main entry point for all direct Bot API communication
|
|
* (both JSON calls and multipart uploads for files/voice).
|
|
*/
|
|
export function createTelegramApiClient(
|
|
getBotToken: () => string | undefined,
|
|
options: TelegramAnswerCallbackQueryOptions = {},
|
|
): TelegramApiClient {
|
|
return {
|
|
call: async (method, body, options) => {
|
|
return callTelegram(getBotToken(), method, body, options);
|
|
},
|
|
callMultipart: async (
|
|
method,
|
|
fields,
|
|
fileField,
|
|
filePath,
|
|
fileName,
|
|
options,
|
|
) => {
|
|
return callTelegramMultipart(
|
|
getBotToken(),
|
|
method,
|
|
fields,
|
|
fileField,
|
|
filePath,
|
|
fileName,
|
|
options,
|
|
);
|
|
},
|
|
downloadFile: async (fileId, suggestedName, tempDir, options) => {
|
|
return downloadTelegramFile(
|
|
getBotToken(),
|
|
fileId,
|
|
suggestedName,
|
|
tempDir,
|
|
options,
|
|
);
|
|
},
|
|
answerCallbackQuery: async (callbackQueryId, text) => {
|
|
await answerTelegramCallbackQuery(
|
|
getBotToken(),
|
|
callbackQueryId,
|
|
text,
|
|
options,
|
|
);
|
|
},
|
|
};
|
|
}
|