feat: add Telegram Bot API fetch client with token redaction
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+109
@@ -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<TelegramUser>
|
||||||
|
getUpdates(offset?: number): Promise<TelegramUpdate[]>
|
||||||
|
sendMessage(chatId: number, text: string, parseMode?: string): Promise<TelegramMessage>
|
||||||
|
sendChatAction(chatId: number, action: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(method: string, body?: Record<string, unknown>): Promise<T> {
|
||||||
|
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<TelegramUser> {
|
||||||
|
return this.call<TelegramUser>('getMe')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpdates(offset?: number): Promise<TelegramUpdate[]> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
timeout: this.pollingTimeoutSec,
|
||||||
|
allowed_updates: ['message'],
|
||||||
|
}
|
||||||
|
if (offset !== undefined) {
|
||||||
|
body.offset = offset
|
||||||
|
}
|
||||||
|
return this.call<TelegramUpdate[]>('getUpdates', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(chatId: number, text: string, parseMode?: string): Promise<TelegramMessage> {
|
||||||
|
const body: Record<string, unknown> = { chat_id: chatId, text }
|
||||||
|
if (parseMode !== undefined) {
|
||||||
|
body.parse_mode = parseMode
|
||||||
|
}
|
||||||
|
return this.call<TelegramMessage>('sendMessage', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
||||||
|
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user