chore: bump version to 2.80.27 and update core features

This commit is contained in:
g1nation
2026-05-09 01:16:12 +09:00
parent 5ffb472d22
commit 3220a126fd
41 changed files with 4457 additions and 72 deletions
+240
View File
@@ -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) });
}
}
}
+154
View File
@@ -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;
}
+54
View File
@@ -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;