根因:dsh 0.1.5-rc.1 把 'user-questions/request' waterfall 分发到 per-fiber events 注册表,其他 fiber 的 ctx.on 监听(含本插件)永远收不到 分发(实测 root/global/agent 作用域监听全部落空)。 修复:包装 UserQuestionService.prototype.ask(类跨实例共享),无论哪个 fiber/实例发起的 ask 都能拦截到 agent+questions;TG 答案与原 ask 竞速, 保留 dual-path 语义;stop 时恢复原型,热重载不叠加。 功能(应用户要求): - 提问前显式发送预告文本消息 - 选项改用 TG 原生 inline keyboard 按钮(单选即点即答,多选带提交键) - 文本作答保留并与按钮作答合并,逐题累计、齐题自动提交 - 附带修复:Telegram API 调用加 15s 客户端超时(代理挂死会卡死通知队列) Co-authored-by: Shirobana Agent Bot <tg-bridge>
258 lines
7.5 KiB
TypeScript
258 lines
7.5 KiB
TypeScript
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'
|
|
|
|
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 TelegramPhotoSize {
|
|
file_id: string
|
|
file_unique_id: string
|
|
width: number
|
|
height: number
|
|
file_size?: number
|
|
}
|
|
|
|
export interface TelegramDocument {
|
|
file_id: string
|
|
file_unique_id: string
|
|
file_name?: string
|
|
mime_type?: string
|
|
file_size?: number
|
|
}
|
|
|
|
export interface TelegramMessage {
|
|
message_id: number
|
|
date: number
|
|
chat: TelegramChat
|
|
from?: TelegramUser
|
|
text?: string
|
|
/** Media caption (photos/documents); TG puts it here, not in `text`. */
|
|
caption?: string
|
|
photo?: TelegramPhotoSize[]
|
|
document?: TelegramDocument
|
|
/** Album grouping id: several photo updates share one media_group_id. */
|
|
media_group_id?: string
|
|
}
|
|
|
|
export interface TelegramFile {
|
|
file_id: string
|
|
file_unique_id: string
|
|
file_size?: number
|
|
file_path?: string
|
|
}
|
|
|
|
export interface TelegramCallbackQuery {
|
|
id: string
|
|
from: TelegramUser
|
|
message?: TelegramMessage
|
|
data?: string
|
|
}
|
|
|
|
export interface TelegramUpdate {
|
|
update_id: number
|
|
message?: TelegramMessage
|
|
callback_query?: TelegramCallbackQuery
|
|
}
|
|
|
|
export interface InlineKeyboardButton {
|
|
text: string
|
|
callback_data: string
|
|
}
|
|
|
|
export interface InlineKeyboardMarkup {
|
|
inline_keyboard: InlineKeyboardButton[][]
|
|
}
|
|
|
|
export interface TelegramBotCommand {
|
|
command: string
|
|
description: string
|
|
}
|
|
|
|
export interface TelegramInputRichMessage {
|
|
markdown: string
|
|
skip_entity_detection?: boolean
|
|
}
|
|
|
|
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,
|
|
replyMarkup?: InlineKeyboardMarkup,
|
|
): Promise<TelegramMessage>
|
|
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>
|
|
sendChatAction(chatId: number, action: string): Promise<boolean>
|
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
|
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
|
|
/** Resolve a file_id to a file_path (downloadable URL suffix). */
|
|
getFile(fileId: string): Promise<TelegramFile>
|
|
/** Download file bytes from the Bot API file endpoint (GET, proxy-aware). */
|
|
downloadFile(filePath: string): Promise<Uint8Array>
|
|
}
|
|
|
|
function resolveProxyUrl(): string | undefined {
|
|
return (
|
|
process.env.HTTPS_PROXY
|
|
|| process.env.HTTP_PROXY
|
|
|| process.env.https_proxy
|
|
|| process.env.http_proxy
|
|
|| undefined
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Node's global `fetch` ignores HTTP(S)_PROXY unless `NODE_USE_ENV_PROXY=1`.
|
|
* Undici's EnvHttpProxyAgent honors the env vars (and NO_PROXY).
|
|
*/
|
|
function createDefaultFetch(): typeof fetch {
|
|
if (!resolveProxyUrl()) return globalThis.fetch
|
|
const agent = new EnvHttpProxyAgent()
|
|
const proxied = ((input: RequestInfo | URL, init?: RequestInit) =>
|
|
undiciFetch(input as string | URL, {
|
|
...(init as Record<string, unknown>),
|
|
dispatcher: agent,
|
|
})) as unknown as typeof fetch
|
|
return proxied
|
|
}
|
|
|
|
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 ?? createDefaultFetch()
|
|
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>, timeoutMs = 15_000): Promise<T> {
|
|
const url = `${this.baseUrl}/bot${this.token}/${method}`
|
|
try {
|
|
const init: RequestInit = {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
// Without a client-side timeout a stalled proxy connection hangs the
|
|
// request forever — and the serialized notice queue wedges behind it,
|
|
// silently swallowing every later tool/ask notice. TimeoutError is
|
|
// caught below and surfaces as a normal (retryable) failure.
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
}
|
|
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', 'callback_query'],
|
|
}
|
|
if (offset !== undefined) {
|
|
body.offset = offset
|
|
}
|
|
// Long-poll: client-side abort must outlive the server-side hold.
|
|
return this.call<TelegramUpdate[]>('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000)
|
|
}
|
|
|
|
async sendMessage(
|
|
chatId: number,
|
|
text: string,
|
|
parseMode?: string,
|
|
replyMarkup?: InlineKeyboardMarkup,
|
|
): Promise<TelegramMessage> {
|
|
const body: Record<string, unknown> = { chat_id: chatId, text }
|
|
if (parseMode !== undefined) {
|
|
body.parse_mode = parseMode
|
|
}
|
|
if (replyMarkup !== undefined) {
|
|
body.reply_markup = replyMarkup
|
|
}
|
|
return this.call<TelegramMessage>('sendMessage', body)
|
|
}
|
|
|
|
async sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage> {
|
|
const body: Record<string, unknown> = {
|
|
chat_id: chatId,
|
|
rich_message: {
|
|
markdown,
|
|
skip_entity_detection: true,
|
|
},
|
|
}
|
|
return this.call<TelegramMessage>('sendRichMessage', body)
|
|
}
|
|
|
|
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
|
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
|
}
|
|
|
|
async answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean> {
|
|
const body: Record<string, unknown> = { callback_query_id: callbackQueryId }
|
|
if (text !== undefined) body.text = text
|
|
return this.call<boolean>('answerCallbackQuery', body)
|
|
}
|
|
|
|
async setMyCommands(commands: TelegramBotCommand[]): Promise<boolean> {
|
|
return this.call<boolean>('setMyCommands', { commands })
|
|
}
|
|
|
|
async getFile(fileId: string): Promise<TelegramFile> {
|
|
return this.call<TelegramFile>('getFile', { file_id: fileId })
|
|
}
|
|
|
|
async downloadFile(filePath: string): Promise<Uint8Array> {
|
|
const url = `${this.baseUrl}/file/bot${this.token}/${filePath}`
|
|
try {
|
|
const response = await this.fetchImpl(url)
|
|
if (!response.ok) {
|
|
throw new Error(`Telegram file download failed: HTTP ${String(response.status)}`)
|
|
}
|
|
return new Uint8Array(await response.arrayBuffer())
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
throw new Error(this.redact(message))
|
|
}
|
|
}
|
|
}
|