dsh-im-ops/src/channels/telegram/telegram-api.mjs
xmanrui 3741dd8fd9 Merge pull request #46 from zaakirio/feat/host-i18n-en
feat(i18n): add English support for host-side chat messages
2026-08-24 03:51:43 +08:00

401 lines
14 KiB
JavaScript

import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function requestSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function abortReason(signal) {
return signal?.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError');
}
function positiveTimeout(value, name) {
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
return value;
}
function preserveProviderMetadata(target, source) {
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
if (source?.retry_after !== undefined) {
target.retry_after = source.retry_after;
target.retryAfter = source.retry_after;
}
if (Number.isInteger(source?.status)) target.status = source.status;
return target;
}
function inputRichMessage(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('A Telegram rich message is required');
}
const formats = ['markdown', 'html', 'blocks'].filter((key) => value[key] !== undefined);
if (formats.length !== 1) {
throw new TypeError('A Telegram rich message requires exactly one format');
}
const selected = formats[0];
if ((selected === 'markdown' || selected === 'html')
&& (typeof value[selected] !== 'string' || !value[selected].trim())) {
throw new TypeError('Telegram rich message text is invalid');
}
if (selected === 'blocks' && !Array.isArray(value.blocks)) {
throw new TypeError('Telegram rich message blocks are invalid');
}
return value;
}
function telegramArtifactProviderError(cause, mediaLabel = 'document') {
const providerCode = Number(cause?.providerCode);
const status = Number(cause?.status);
const message = cleanString(cause?.message) ?? '';
let code = 'artifact-provider-rejected';
let summary = `Telegram rejected the ${mediaLabel}.`;
if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
code = 'artifact-permission-required';
summary = `Telegram denied permission to send the ${mediaLabel}.`;
} else if (providerCode === 413 || status === 413
|| /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
code = 'artifact-too-large';
summary = `The ${mediaLabel} exceeds Telegram's size limit.`;
} else if (providerCode === 429 || status === 429) {
code = 'artifact-rate-limited';
summary = `Telegram rate-limited ${mediaLabel} delivery.`;
} else if (providerCode >= 500 || status >= 500) {
code = 'artifact-delivery-uncertain';
summary = `Telegram ${mediaLabel} delivery result is uncertain.`;
}
const error = new Error(summary, { cause });
error.code = code;
return preserveProviderMetadata(error, cause);
}
function uncertainTelegramDelivery(cause, mediaLabel = 'document') {
const error = new Error(`Telegram ${mediaLabel} delivery result is uncertain`, { cause });
error.code = 'artifact-delivery-uncertain';
return preserveProviderMetadata(error, cause);
}
export function validTelegramToken(value) {
return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
}
const TELEGRAM_COMMAND_NAME = /^[a-z0-9_]{1,32}$/;
function validBotCommand(value) {
return Boolean(value)
&& typeof value === 'object' && !Array.isArray(value)
&& typeof value.command === 'string' && TELEGRAM_COMMAND_NAME.test(value.command)
&& typeof value.description === 'string' && value.description.length >= 1
&& value.description.length <= 256;
}
export const COMMANDS_MENU_BUTTON = Object.freeze({ type: 'commands' });
export class TelegramApi {
#token;
#fetch;
#baseUrl;
#fileUploadTimeoutMs;
constructor({
token,
fetchImpl = fetch,
baseUrl = DEFAULT_BASE_URL,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
}) {
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
this.#token = token.trim();
this.#fetch = fetchImpl;
this.#baseUrl = new URL(baseUrl);
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
}
async getMe(options = {}) {
return this.#call('getMe', {}, options);
}
async getWebhookInfo(options = {}) {
return this.#call('getWebhookInfo', {}, options);
}
async getUpdates({ offset, timeout = 25, signal } = {}) {
const payload = {
timeout,
limit: 100,
allowed_updates: ['message'],
...(Number.isSafeInteger(offset) ? { offset } : {}),
};
return this.#call('getUpdates', payload, {
signal,
timeoutMs: Math.max(10_000, (timeout + 10) * 1_000),
});
}
async getFile({ fileId, signal } = {}) {
const id = cleanString(fileId);
if (!id || !/^[A-Za-z0-9_-]{1,512}$/.test(id)) {
throw new TypeError('Telegram file id is invalid');
}
return this.#call('getFile', { file_id: id }, { signal });
}
async downloadFile({ fileId, signal, maxBytes } = {}) {
const url = await this.#downloadUrl(fileId, signal);
return fetchImageBuffer(url, {
fetchImpl: this.#fetch,
signal,
maxBytes,
allowedHosts: TELEGRAM_FILE_HOSTS,
});
}
async downloadFileStream({ fileId, signal } = {}) {
const url = await this.#downloadUrl(fileId, signal);
return fetchFileStream(url, {
fetchImpl: this.#fetch,
signal,
allowedHosts: TELEGRAM_FILE_HOSTS,
});
}
async #downloadUrl(fileId, signal) {
const file = await this.getFile({ fileId, signal });
const filePath = cleanString(file?.file_path);
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|| filePath.includes('?') || filePath.includes('#')) {
throw new Error('Telegram returned an invalid file path');
}
let decodedSegments;
try {
decodedSegments = filePath.split('/').map((segment) => decodeURIComponent(segment));
} catch {
throw new Error('Telegram returned an invalid file path');
}
if (decodedSegments.some((segment) => !segment || segment === '.' || segment === '..')) {
throw new Error('Telegram returned an invalid file path');
}
const url = new URL(this.#baseUrl);
url.pathname = `/file/bot${this.#token}/${filePath}`;
return url;
}
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
return this.#call('sendMessage', {
chat_id: chatId,
text,
link_preview_options: { is_disabled: true },
...(replyToMessageId ? {
reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
} : {}),
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
}, { signal });
}
async sendRichMessage({
chatId,
richMessage,
replyToMessageId,
messageThreadId,
signal,
}) {
return this.#call('sendRichMessage', {
chat_id: chatId,
rich_message: inputRichMessage(richMessage),
...(replyToMessageId ? {
reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
} : {}),
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
}, { signal });
}
async sendRichMessageDraft({ chatId, draftId, richMessage, messageThreadId, signal }) {
if (!Number.isSafeInteger(draftId) || draftId === 0) {
throw new TypeError('Telegram rich message draft id must be a non-zero integer');
}
return this.#call('sendRichMessageDraft', {
chat_id: chatId,
draft_id: draftId,
rich_message: inputRichMessage(richMessage),
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
}, { signal });
}
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
return this.#sendArtifact('sendDocument', 'document', 'document', {
chatId,
file,
replyToMessageId,
messageThreadId,
signal,
});
}
async sendPhoto({ chatId, file, replyToMessageId, messageThreadId, signal }) {
return this.#sendArtifact('sendPhoto', 'photo', 'photo', {
chatId,
file,
replyToMessageId,
messageThreadId,
signal,
});
}
async #sendArtifact(method, fieldName, mediaLabel, {
chatId,
file,
replyToMessageId,
messageThreadId,
signal,
}) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError(`A Telegram ${mediaLabel} is required`);
}
const payload = new FormData();
payload.append('chat_id', String(chatId));
payload.append(
fieldName,
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
file.fileName,
);
if (replyToMessageId) {
payload.append('reply_parameters', JSON.stringify({
message_id: replyToMessageId,
allow_sending_without_reply: true,
}));
}
if (messageThreadId) payload.append('message_thread_id', String(messageThreadId));
if (signal?.aborted) throw abortReason(signal);
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
try {
return await this.#call(method, payload, {
signal: uploadSignal,
timeoutMs: this.#fileUploadTimeoutMs,
multipart: true,
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
if (error?.deliveryOutcome === 'unknown') {
throw uncertainTelegramDelivery(error?.cause ?? error, mediaLabel);
}
if (error?.code?.startsWith?.('telegram-')) {
throw telegramArtifactProviderError(error, mediaLabel);
}
throw uncertainTelegramDelivery(error, mediaLabel);
}
}
async editMessageText({ chatId, messageId, text, richMessage, signal }) {
if ((text === undefined) === (richMessage === undefined)) {
throw new TypeError('Telegram message edit requires exactly one of text or richMessage');
}
return this.#call('editMessageText', {
chat_id: chatId,
message_id: messageId,
...(text === undefined ? { rich_message: inputRichMessage(richMessage) } : {
text,
link_preview_options: { is_disabled: true },
}),
}, { signal });
}
async sendChatAction({ chatId, messageThreadId, signal }) {
return this.#call('sendChatAction', {
chat_id: chatId,
action: 'typing',
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
}, { signal });
}
async setMyCommands({ commands, scope, languageCode, signal } = {}) {
if (!Array.isArray(commands) || commands.length === 0
|| commands.some((command) => !validBotCommand(command))) {
throw new TypeError('Telegram bot commands are invalid');
}
return this.#call('setMyCommands', {
commands,
...(scope ? { scope } : {}),
...(cleanString(languageCode) ? { language_code: cleanString(languageCode) } : {}),
}, { signal });
}
async setChatMenuButton({ menuButton = COMMANDS_MENU_BUTTON, signal } = {}) {
if (!menuButton || typeof menuButton !== 'object' || Array.isArray(menuButton)) {
throw new TypeError('Telegram menu button is invalid');
}
return this.#call('setChatMenuButton', { menu_button: menuButton }, { signal });
}
async #call(method, payload, { signal, timeoutMs = 15_000, multipart = false } = {}) {
if (signal?.aborted) throw abortReason(signal);
const url = new URL(this.#baseUrl);
url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
let response;
try {
response = await this.#fetch(url, {
method: 'POST',
...(multipart ? {} : { headers: { 'content-type': 'application/json' } }),
body: multipart ? payload : JSON.stringify(payload),
signal: requestSignal(signal, timeoutMs),
redirect: 'error',
});
} catch (error) {
const transport = new Error(`Telegram ${method} transport failed`, { cause: error });
transport.code = error?.name === 'TimeoutError'
? 'telegram-timeout'
: error?.name === 'AbortError'
? 'telegram-aborted-after-dispatch'
: 'telegram-transport-error';
transport.deliveryOutcome = 'unknown';
throw transport;
}
let body;
try {
body = await response.json();
} catch {
const error = new Error(`Telegram ${method} returned invalid JSON`);
error.status = response?.status;
error.code = 'telegram-response-invalid';
error.deliveryOutcome = 'unknown';
throw error;
}
if (!response.ok || body?.ok !== true) {
const description = cleanString(body?.description);
const error = new Error(description ?? `Telegram ${method} failed`);
error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
error.status = response.status;
if (Number.isInteger(body?.error_code)) error.providerCode = body.error_code;
const retryAfter = Number(body?.parameters?.retry_after);
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
error.retry_after = retryAfter;
error.retryAfter = retryAfter;
}
throw error;
}
return body.result;
}
}
export async function inspectTelegramToken(token, options = {}) {
const api = new TelegramApi({ token, ...options });
const bot = await api.getMe();
if (!bot?.id || bot?.is_bot !== true) throw new Error('Telegram token does not belong to a bot');
return {
platformId: String(bot.id),
name: cleanString([bot.first_name, bot.last_name].filter(Boolean).join(' ')) ?? t('Telegram机器人'),
username: cleanString(bot.username),
};
}