chore: bump version to 2.80.27 and update core features
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import type { ITelegramClient, TelegramClientError } from './telegramClient';
|
||||
import type { TelegramUpdate } from './types';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* TelegramBot — long-polling loop with explicit lifecycle.
|
||||
*
|
||||
* Why this is split from the HTTP client:
|
||||
* - The HTTP client knows how to make a single request; the bot knows how to
|
||||
* coordinate a polling loop, an offset cursor, error backoff, and a clean
|
||||
* shutdown via AbortController.
|
||||
* - This separation makes the bot mock-friendly — tests inject a stub client
|
||||
* that returns scripted update arrays without touching the network.
|
||||
*
|
||||
* Behavior:
|
||||
* - On `start()`: kicks off an async loop that calls `client.getUpdates()`
|
||||
* with a 25s timeout. The loop catches per-iteration errors so a single
|
||||
* network blip cannot tear down the bot.
|
||||
* - On `stop()`: aborts any in-flight request and exits the loop. Idempotent.
|
||||
* - On `aborted` errors during a normal shutdown: silently exits.
|
||||
* - On `no-token`/`api(401)` (token revoked / wrong): logs once, stops.
|
||||
* - On generic `network` errors: exponential backoff capped at 30s.
|
||||
*
|
||||
* The handler signature is `(text, chatId) => Promise<string | null>`. Returning
|
||||
* null suppresses the reply (e.g. for ignored messages).
|
||||
*/
|
||||
|
||||
export type TelegramMessageHandler = (text: string, chatId: number) => Promise<string | null>;
|
||||
|
||||
export interface TelegramBotDeps {
|
||||
client: ITelegramClient;
|
||||
handle: TelegramMessageHandler;
|
||||
/** Long-poll seconds passed to getUpdates. Default 25. */
|
||||
pollTimeoutSec?: number;
|
||||
/** Per-call wait when an error happens. Capped at maxBackoffMs. Default 1000ms. */
|
||||
initialBackoffMs?: number;
|
||||
/** Default 30000ms. */
|
||||
maxBackoffMs?: number;
|
||||
/** Optional sleep override for tests (defaults to setTimeout-based). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
// Avoid blocking node exit if the bot is the only thing keeping the loop alive.
|
||||
if (typeof t === 'object' && t && 'unref' in t) (t as any).unref();
|
||||
});
|
||||
|
||||
export interface EnrolledChat {
|
||||
chatId: number;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
}
|
||||
|
||||
export class TelegramBot {
|
||||
private _running = false;
|
||||
private _abort: AbortController | undefined;
|
||||
private _offset: number | undefined;
|
||||
private _loopPromise: Promise<void> | undefined;
|
||||
private _enrollPending: {
|
||||
resolve: (chat: EnrolledChat) => void;
|
||||
reject: (err: Error) => void;
|
||||
} | undefined;
|
||||
|
||||
constructor(private readonly _deps: TelegramBotDeps) {}
|
||||
|
||||
isRunning(): boolean { return this._running; }
|
||||
|
||||
/**
|
||||
* Wait for the next incoming message and resolve with its chat info.
|
||||
*
|
||||
* Used by the settings wizard: the user clicks "내 chat ID 자동 등록", we
|
||||
* arm this one-shot capture, and the very next incoming Telegram message
|
||||
* is intercepted (no AI reply, no handler call) and yielded back as the
|
||||
* captured chat. The bot keeps polling normally afterwards.
|
||||
*
|
||||
* Only one enrollment can be pending at a time — calling this while
|
||||
* already armed rejects the prior pending promise.
|
||||
*/
|
||||
enrollNextChat(timeoutMs: number = 120000): Promise<EnrolledChat> {
|
||||
if (this._enrollPending) {
|
||||
this._enrollPending.reject(new Error('Superseded by a new enrollment request.'));
|
||||
this._enrollPending = undefined;
|
||||
}
|
||||
return new Promise<EnrolledChat>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this._enrollPending && this._enrollPending.resolve === wrappedResolve) {
|
||||
this._enrollPending = undefined;
|
||||
reject(new Error(`No message received within ${Math.round(timeoutMs / 1000)}s.`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
if (typeof timer === 'object' && timer && 'unref' in timer) (timer as any).unref();
|
||||
|
||||
const wrappedResolve = (chat: EnrolledChat) => {
|
||||
clearTimeout(timer);
|
||||
resolve(chat);
|
||||
};
|
||||
const wrappedReject = (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
this._enrollPending = { resolve: wrappedResolve, reject: wrappedReject };
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel any pending enrollment without resolving it. */
|
||||
cancelEnrollment(): void {
|
||||
if (!this._enrollPending) return;
|
||||
this._enrollPending.reject(new Error('Enrollment cancelled.'));
|
||||
this._enrollPending = undefined;
|
||||
}
|
||||
|
||||
/** Idempotent: starts the long-poll loop if not already running. */
|
||||
start(): void {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
this._abort = new AbortController();
|
||||
this._loopPromise = this._loop().catch((e) => {
|
||||
logError('Telegram bot loop crashed unexpectedly.', { error: e?.message ?? String(e) });
|
||||
});
|
||||
logInfo('Telegram bot started.');
|
||||
}
|
||||
|
||||
/** Idempotent: aborts the in-flight call and waits for the loop to exit. */
|
||||
async stop(): Promise<void> {
|
||||
if (!this._running) return;
|
||||
this._running = false;
|
||||
try { this._abort?.abort(); } catch { /* noop */ }
|
||||
const p = this._loopPromise;
|
||||
this._abort = undefined;
|
||||
this._loopPromise = undefined;
|
||||
if (this._enrollPending) {
|
||||
this._enrollPending.reject(new Error('Bot stopped before enrollment completed.'));
|
||||
this._enrollPending = undefined;
|
||||
}
|
||||
if (p) {
|
||||
try { await p; } catch { /* swallow — already logged */ }
|
||||
}
|
||||
logInfo('Telegram bot stopped.');
|
||||
}
|
||||
|
||||
private async _loop(): Promise<void> {
|
||||
const { client, handle } = this._deps;
|
||||
const pollTimeoutSec = this._deps.pollTimeoutSec ?? 25;
|
||||
const initialBackoff = this._deps.initialBackoffMs ?? 1000;
|
||||
const maxBackoff = this._deps.maxBackoffMs ?? 30000;
|
||||
const sleep = this._deps.sleep ?? defaultSleep;
|
||||
|
||||
let backoff = initialBackoff;
|
||||
|
||||
while (this._running) {
|
||||
const signal = this._abort?.signal;
|
||||
try {
|
||||
const updates = await client.getUpdates({
|
||||
offset: this._offset,
|
||||
timeoutSec: pollTimeoutSec,
|
||||
signal,
|
||||
});
|
||||
backoff = initialBackoff; // reset on success
|
||||
|
||||
for (const update of updates) {
|
||||
if (!this._running) break;
|
||||
this._offset = update.update_id + 1;
|
||||
await this._processUpdate(update, handle);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!this._running) break;
|
||||
const err = e as TelegramClientError;
|
||||
if (err?.kind === 'aborted') break;
|
||||
if (err?.kind === 'no-token') {
|
||||
logError('Telegram bot stopping: token not configured.');
|
||||
this._running = false;
|
||||
break;
|
||||
}
|
||||
if (err?.kind === 'api' && (err.statusCode === 401 || err.statusCode === 404)) {
|
||||
logError('Telegram bot stopping: invalid token (HTTP 401/404).', { statusCode: err.statusCode });
|
||||
this._running = false;
|
||||
break;
|
||||
}
|
||||
// Generic network / api errors: log and back off.
|
||||
logError('Telegram poll error; backing off.', { backoff, error: e?.message ?? String(e) });
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, maxBackoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _processUpdate(update: TelegramUpdate, handle: TelegramMessageHandler): Promise<void> {
|
||||
const msg = update.message ?? update.edited_message;
|
||||
if (!msg) return;
|
||||
const text = msg.text?.trim();
|
||||
const chatId = msg.chat?.id;
|
||||
if (!text || typeof chatId !== 'number') return;
|
||||
|
||||
// Enrollment intercept: if the settings wizard armed enrollNextChat(),
|
||||
// hand this update off and skip the normal AI handler. We still send a
|
||||
// friendly acknowledgement so the user knows enrollment worked.
|
||||
if (this._enrollPending) {
|
||||
const pending = this._enrollPending;
|
||||
this._enrollPending = undefined;
|
||||
pending.resolve({
|
||||
chatId,
|
||||
username: msg.from?.username,
|
||||
firstName: msg.from?.first_name,
|
||||
});
|
||||
try {
|
||||
await this._deps.client.sendMessage({
|
||||
chatId,
|
||||
text: '✅ 채널이 등록되었습니다. 이제부터 메시지를 보낼 수 있어요.',
|
||||
signal: this._abort?.signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
logError('Telegram enrollment ack send failed.', { chatId, error: e?.message ?? String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let reply: string | null = null;
|
||||
try {
|
||||
reply = await handle(text, chatId);
|
||||
} catch (e: any) {
|
||||
logError('Telegram message handler threw.', { chatId, error: e?.message ?? String(e) });
|
||||
reply = `⚠️ Astra 처리 중 오류: ${e?.message ?? e}`;
|
||||
}
|
||||
|
||||
if (reply == null || !reply.trim()) return;
|
||||
try {
|
||||
await this._deps.client.sendMessage({
|
||||
chatId,
|
||||
text: reply,
|
||||
signal: this._abort?.signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Sending the reply failed — log and move on. Don't tear down the
|
||||
// loop because of a single send failure.
|
||||
logError('Telegram reply send failed.', { chatId, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
TelegramApiResponse,
|
||||
TelegramMessage,
|
||||
TelegramUpdate,
|
||||
TelegramUser,
|
||||
} from './types';
|
||||
import { TELEGRAM_MAX_TEXT_LENGTH } from './types';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Thin HTTP wrapper around the Telegram Bot API.
|
||||
*
|
||||
* Only the three endpoints the bot loop needs are exposed:
|
||||
* - getMe() — token validity probe (used by the "test connection" command)
|
||||
* - getUpdates() — long-polling driver
|
||||
* - sendMessage() — outbound replies
|
||||
*
|
||||
* Design notes:
|
||||
* - Uses native `fetch` so we don't pull axios in just for this integration.
|
||||
* - All errors are normalized to `TelegramClientError` so callers can branch
|
||||
* on `kind` (`network` / `api` / `aborted`) without inspecting raw fetch
|
||||
* internals.
|
||||
* - The `signal` parameter is honored on every call — long-polling depends on
|
||||
* this for clean shutdown when the bot is disabled or the extension
|
||||
* deactivates.
|
||||
* - Tokens are passed by reference (`getToken: () => string | undefined`)
|
||||
* instead of stored, so rotating the SecretStorage value takes effect on
|
||||
* the next request without rebuilding the client.
|
||||
*/
|
||||
|
||||
export type TelegramClientErrorKind = 'network' | 'api' | 'aborted' | 'no-token';
|
||||
|
||||
export class TelegramClientError extends Error {
|
||||
constructor(
|
||||
public readonly kind: TelegramClientErrorKind,
|
||||
message: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TelegramClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
chatId: number;
|
||||
text: string;
|
||||
/** Defaults to "Markdown" for clean formatting; pass null to disable. */
|
||||
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML' | null;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GetUpdatesOptions {
|
||||
offset?: number;
|
||||
/** Long-poll seconds. 0 = short poll. Telegram caps at 50; we default to 25. */
|
||||
timeoutSec?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ITelegramClient {
|
||||
getMe(signal?: AbortSignal): Promise<TelegramUser>;
|
||||
getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]>;
|
||||
sendMessage(opts: SendMessageOptions): Promise<TelegramMessage>;
|
||||
}
|
||||
|
||||
export interface TelegramHttpClientDeps {
|
||||
/** Returns the current bot token (or empty when not configured). */
|
||||
getToken: () => string | undefined;
|
||||
/** Optional fetch override for tests. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class TelegramHttpClient implements ITelegramClient {
|
||||
private readonly _fetch: typeof fetch;
|
||||
|
||||
constructor(private readonly _deps: TelegramHttpClientDeps) {
|
||||
this._fetch = _deps.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
async getMe(signal?: AbortSignal): Promise<TelegramUser> {
|
||||
return this._call<TelegramUser>('getMe', undefined, signal);
|
||||
}
|
||||
|
||||
async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
timeout: Math.max(0, Math.min(opts.timeoutSec ?? 25, 50)),
|
||||
};
|
||||
if (typeof opts.offset === 'number') body.offset = opts.offset;
|
||||
return this._call<TelegramUpdate[]>('getUpdates', body, opts.signal);
|
||||
}
|
||||
|
||||
async sendMessage(opts: SendMessageOptions): Promise<TelegramMessage> {
|
||||
const text = truncateForTelegram(opts.text);
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: opts.chatId,
|
||||
text,
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
const parseMode = opts.parseMode === undefined ? 'Markdown' : opts.parseMode;
|
||||
if (parseMode) body.parse_mode = parseMode;
|
||||
return this._call<TelegramMessage>('sendMessage', body, opts.signal);
|
||||
}
|
||||
|
||||
private async _call<T>(
|
||||
method: string,
|
||||
body: Record<string, unknown> | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const token = (this._deps.getToken() || '').trim();
|
||||
if (!token) {
|
||||
throw new TelegramClientError('no-token', 'Telegram bot token is not configured.');
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${token}/${method}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this._fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError' || signal?.aborted) {
|
||||
throw new TelegramClientError('aborted', 'Request aborted.');
|
||||
}
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('Telegram API network error.', { method, error: msg });
|
||||
throw new TelegramClientError('network', `Network error calling ${method}: ${msg}`);
|
||||
}
|
||||
|
||||
let parsed: TelegramApiResponse<T>;
|
||||
try {
|
||||
parsed = (await response.json()) as TelegramApiResponse<T>;
|
||||
} catch (e: any) {
|
||||
throw new TelegramClientError('api', `Telegram API returned non-JSON for ${method}: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
logError('Telegram API error response.', { method, error_code: parsed.error_code, description: parsed.description });
|
||||
throw new TelegramClientError('api', `${method} failed: ${parsed.description}`, parsed.error_code);
|
||||
}
|
||||
|
||||
logInfo('Telegram API call succeeded.', { method, status: response.status });
|
||||
return parsed.result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Truncate text to Telegram's 4096-char limit, preserving a trailing ellipsis hint. */
|
||||
export function truncateForTelegram(text: string): string {
|
||||
if (typeof text !== 'string') return '';
|
||||
if (text.length <= TELEGRAM_MAX_TEXT_LENGTH) return text;
|
||||
const ellipsis = '\n\n... (truncated)';
|
||||
return text.slice(0, TELEGRAM_MAX_TEXT_LENGTH - ellipsis.length) + ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Subset of the Telegram Bot API types we actually consume.
|
||||
*
|
||||
* Source: https://core.telegram.org/bots/api
|
||||
*
|
||||
* Only fields the bot reads or writes are typed — leaving the rest as `unknown`
|
||||
* keeps the surface narrow and the JSON parsing strict.
|
||||
*/
|
||||
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
is_bot: boolean;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface TelegramChat {
|
||||
id: number;
|
||||
type: 'private' | 'group' | 'supergroup' | 'channel' | string;
|
||||
title?: string;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
}
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
date: number;
|
||||
chat: TelegramChat;
|
||||
from?: TelegramUser;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
edited_message?: TelegramMessage;
|
||||
}
|
||||
|
||||
export interface TelegramApiSuccess<T> {
|
||||
ok: true;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export interface TelegramApiError {
|
||||
ok: false;
|
||||
error_code: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type TelegramApiResponse<T> = TelegramApiSuccess<T> | TelegramApiError;
|
||||
|
||||
/** Maximum bytes per Telegram message payload (the API caps text at 4096 chars). */
|
||||
export const TELEGRAM_MAX_TEXT_LENGTH = 4096;
|
||||
Reference in New Issue
Block a user