From 4a9a41484eedb54a744b8f1884382ca27de6f462 Mon Sep 17 00:00:00 2001 From: owo <17620121969@163.com> Date: Fri, 14 Aug 2026 16:08:12 +0800 Subject: [PATCH] feat: add Telegram Bot API fetch client with token redaction Co-authored-by: Cursor --- src/client.ts | 109 +++++++++++++++++++++++++++++++++++++++++++ tests/client.test.ts | 35 ++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/client.ts create mode 100644 tests/client.test.ts diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..5c13327 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,109 @@ +export interface TelegramUser { + id: number + is_bot?: boolean + first_name?: string + username?: string +} + +export interface TelegramChat { + id: number + type: string + title?: string + username?: string +} + +export interface TelegramMessage { + message_id: number + date: number + chat: TelegramChat + from?: TelegramUser + text?: string +} + +export interface TelegramUpdate { + update_id: number + message?: TelegramMessage +} + +export interface TelegramClientOptions { + fetch?: typeof fetch + baseUrl?: string + pollingTimeoutSec?: number +} + +export interface TelegramClientLike { + getMe(): Promise + getUpdates(offset?: number): Promise + sendMessage(chatId: number, text: string, parseMode?: string): Promise + sendChatAction(chatId: number, action: string): Promise +} + +export class TelegramClient implements TelegramClientLike { + private readonly token: string + private readonly fetchImpl: typeof fetch + private readonly baseUrl: string + private readonly pollingTimeoutSec: number + + constructor(token: string, options: TelegramClientOptions = {}) { + if (!token) { + throw new Error('bot token is required') + } + this.token = token + this.fetchImpl = options.fetch ?? fetch + this.baseUrl = options.baseUrl ?? 'https://api.telegram.org' + this.pollingTimeoutSec = options.pollingTimeoutSec ?? 30 + } + + private redact(message: string): string { + return message.split(this.token).join('***') + } + + private async call(method: string, body?: Record): Promise { + const url = `${this.baseUrl}/bot${this.token}/${method}` + try { + const init: RequestInit = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + } + if (body !== undefined) { + init.body = JSON.stringify(body) + } + const response = await this.fetchImpl(url, init) + const json = (await response.json()) as { ok: boolean; result: T; description?: string } + if (!json.ok) { + throw new Error(json.description ?? 'Telegram API error') + } + return json.result + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(this.redact(message)) + } + } + + async getMe(): Promise { + return this.call('getMe') + } + + async getUpdates(offset?: number): Promise { + const body: Record = { + timeout: this.pollingTimeoutSec, + allowed_updates: ['message'], + } + if (offset !== undefined) { + body.offset = offset + } + return this.call('getUpdates', body) + } + + async sendMessage(chatId: number, text: string, parseMode?: string): Promise { + const body: Record = { chat_id: chatId, text } + if (parseMode !== undefined) { + body.parse_mode = parseMode + } + return this.call('sendMessage', body) + } + + async sendChatAction(chatId: number, action: string): Promise { + return this.call('sendChatAction', { chat_id: chatId, action }) + } +} diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..472e9b7 --- /dev/null +++ b/tests/client.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { TelegramClient } from '../src/client.ts' + +test('getUpdates posts timeout and optional offset', async () => { + const calls: Array<{ url: string; body: unknown }> = [] + const fetchImpl: typeof fetch = async (input, init) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }) + return new Response(JSON.stringify({ ok: true, result: [] }), { status: 200 }) + } + const client = new TelegramClient('SECRET-TOKEN', { fetch: fetchImpl, pollingTimeoutSec: 12 }) + await client.getUpdates(9) + assert.match(calls[0]!.url, /\/botSECRET-TOKEN\/getUpdates$/) + assert.deepEqual(calls[0]!.body, { + timeout: 12, + allowed_updates: ['message'], + offset: 9, + }) +}) + +test('errors redact token', async () => { + const fetchImpl: typeof fetch = async () => { + throw new Error('boom SECRET-TOKEN leaked') + } + const client = new TelegramClient('SECRET-TOKEN', { fetch: fetchImpl }) + await assert.rejects(async () => client.getMe(), (err: Error) => { + assert.equal(err.message.includes('SECRET-TOKEN'), false) + assert.match(err.message, /\*\*\*/) + return true + }) +}) + +test('empty token throws in constructor', () => { + assert.throws(() => new TelegramClient(''), /token/) +})