diff --git a/package.json b/package.json index d17826e5..60ca0271 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "eslint": "^9.8.0", "eslint-plugin-format": "^0.1.2", "rollup-plugin-cleanup": "^3.2.1", + "telegram-bot-api-types": "^7.9.1", "typescript": "^5.5.4", "vite": "^5.2.10", "vite-plugin-checker": "^0.7.2", diff --git a/src/config/context.ts b/src/config/context.ts index ae397300..517db2bb 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -1,4 +1,4 @@ -import type { TelegramChatType, TelegramMessage } from '../types/telegram'; +import type { Telegram } from '../types/telegram'; import { DATABASE, ENV, mergeEnvironment } from './env'; import type { AgentUserConfig } from './config'; @@ -14,14 +14,13 @@ export class ShareContext { groupAdminKey: string | null; usageKey: string; - chatType: TelegramChatType; + chatType: string; chatId: number; speakerId: number; - extraMessageContext: TelegramMessage | null = null; - allMemberAreAdmin: boolean = false; + extraMessageContext: Telegram.Message | null = null; - constructor(token: string, message: TelegramMessage) { + constructor(token: string, message: Telegram.Message) { const botId = Number.parseInt(token.split(':')[0]); const telegramIndex = ENV.TELEGRAM_AVAILABLE_TOKENS.indexOf(token); @@ -87,7 +86,6 @@ export class ShareContext { this.chatType = message.chat?.type; this.chatId = message.chat.id; this.speakerId = message.from?.id || message.chat.id; - this.allMemberAreAdmin = message?.chat.all_members_are_administrators || false; } } @@ -96,11 +94,10 @@ export class CurrentChatContext { message_id: number | null = null; // 当前发生的消息,用于后续编辑 reply_to_message_id: number | null; parse_mode: string | null = ENV.DEFAULT_PARSE_MODE; - reply_markup: any = null; allow_sending_without_reply: boolean | null = null; disable_web_page_preview: boolean | null = null; - constructor(message: TelegramMessage) { + constructor(message: Telegram.Message) { this.chat_id = message.chat.id; if (message.chat.type === 'group' || message.chat.type === 'supergroup') { this.reply_to_message_id = message.message_id; @@ -123,7 +120,7 @@ export class WorkerContext { this.SHARE_CONTEXT = SHARE_CONTEXT; } - static async from(token: string, message: TelegramMessage): Promise { + static async from(token: string, message: Telegram.Message): Promise { const SHARE_CONTEXT = new ShareContext(token, message); const CURRENT_CHAT_CONTEXT = new CurrentChatContext(message); const USER_CONFIG = Object.assign({}, ENV.USER_CONFIG); diff --git a/src/route/route.ts b/src/route/route.ts index 880c1694..826596c0 100644 --- a/src/route/route.ts +++ b/src/route/route.ts @@ -1,10 +1,10 @@ import { handleMessage } from '../telegram/handler'; import { API_GUARD, ENV } from '../config/env'; import { commandsBindScope, commandsDocument } from '../telegram/command'; -import { bindTelegramWebHook, setMyCommands } from '../telegram/api/telegram'; import type { RouterRequest } from '../utils/router'; import { Router } from '../utils/router'; -import type { TelegramWebhookRequest } from '../types/telegram'; +import type { Telegram } from '../types/telegram'; +import { TelegramBotAPI } from '../telegram/api/api'; import { errorToString, makeResponse200, renderHTML } from './utils'; const helpLink = 'https://github.com/TBXark/ChatGPT-Telegram-Workers/blob/master/doc/en/DEPLOY.md'; @@ -23,12 +23,13 @@ async function bindWebHookAction(request: RouterRequest): Promise { const hookMode = API_GUARD ? 'safehook' : 'webhook'; const scope = commandsBindScope(); for (const token of ENV.TELEGRAM_AVAILABLE_TOKENS) { + const api = new TelegramBotAPI(token); const url = `https://${domain}/telegram/${token.trim()}/${hookMode}`; const id = token.split(':')[0]; result[id] = {}; - result[id].webhook = await bindTelegramWebHook(token, url).then(res => res.json()).catch(e => errorToString(e)); + result[id].webhook = await api.setWebhook({ url }).then(res => res.json()).catch(e => errorToString(e)); for (const [s, data] of Object.entries(scope)) { - result[id][s] = await setMyCommands(data, token).then(res => res.json()).catch(e => errorToString(e)); + result[id][s] = await api.setMyCommands(data).then(res => res.json()).catch(e => errorToString(e)); } } let html = `

ChatGPT-Telegram-Workers

`; @@ -51,7 +52,7 @@ async function bindWebHookAction(request: RouterRequest): Promise { async function telegramWebhook(request: RouterRequest): Promise { try { const { token } = request.params as any; - const body = await request.json() as TelegramWebhookRequest; + const body = await request.json() as Telegram.Update; return makeResponse200(await handleMessage(token, body)); } catch (e) { console.error(e); diff --git a/src/telegram/api/api.ts b/src/telegram/api/api.ts new file mode 100644 index 00000000..481b4e97 --- /dev/null +++ b/src/telegram/api/api.ts @@ -0,0 +1,102 @@ +import type { Telegram } from '../../types/telegram'; + +export class TelegramBotAPI implements + Telegram.SendMessageRequest, + Telegram.EditMessageTextRequest, + Telegram.SendPhotoRequest, + Telegram.SendChatActionRequest, + Telegram.SetWebhookRequest, + Telegram.DeleteWebhookRequest, + Telegram.SetMyCommandsRequest, + Telegram.GetMeRequest, + Telegram.GetFileRequest, + Telegram.GetChatAdministratorsRequest, + Telegram.GetUpdatesRequest { + readonly token: string; + readonly baseURL: string = `https://api.telegram.org/`; + + constructor(token: string, baseURL?: string) { + this.token = token; + if (baseURL) { + this.baseURL = baseURL; + } + } + + static from(token: string, baseURL?: string): TelegramBotAPI { + return new TelegramBotAPI(token, baseURL); + } + + jsonRequest(method: Telegram.TelegramBotMethod, params: T): Promise { + return fetch(`${this.baseURL}bot${this.token}/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }); + } + + formDataRequest(method: Telegram.TelegramBotMethod, params: T): Promise { + const formData = new FormData(); + for (const key in params) { + const value = params[key]; + if (value instanceof File) { + formData.append(key, value, value.name); + } else if (value instanceof Blob) { + formData.append(key, value, 'blob'); + } else if (typeof value === 'string') { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + } + return fetch(`${this.baseURL}bot${this.token}/${method}`, { + method: 'POST', + body: formData, + }); + } + + sendMessage(params: Telegram.SendMessageParams): Promise { + return this.jsonRequest('sendMessage', params); + } + + editMessageText(params: Telegram.EditMessageTextParams): Promise { + return this.jsonRequest('editMessageText', params); + } + + sendPhoto(params: Telegram.SendPhotoParams): Promise { + return this.formDataRequest('sendPhoto', params); + } + + sendChatAction(params: Telegram.SendChatActionParams): Promise { + return this.jsonRequest('sendChatAction', params); + } + + setWebhook(params: Telegram.SetWebhookParams): Promise { + return this.jsonRequest('setWebhook', params); + } + + deleteWebhook(): Promise { + return this.jsonRequest('deleteWebhook', {}); + } + + setMyCommands(params: Telegram.SetMyCommandsParams): Promise { + return this.jsonRequest('setMyCommands', params); + } + + getMe(): Promise { + return this.jsonRequest('getMe', {}); + } + + getFile(params: Telegram.GetFileParams): Promise { + return this.jsonRequest('getFile', params); + } + + getChatAdministrators(params: Telegram.GetChatAdministratorsParams): Promise { + return this.jsonRequest('getChatAdministrators', params); + } + + getUpdates(params: Telegram.GetUpdatesParams): Promise { + return this.jsonRequest('getUpdates', params); + } +} diff --git a/src/telegram/api/telegram.ts b/src/telegram/api/telegram.ts deleted file mode 100644 index bb905bd3..00000000 --- a/src/telegram/api/telegram.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { ENV } from '../../config/env'; -import type { CurrentChatContext, WorkerContext } from '../../config/context'; -import type { TelegramWebhookRequest } from '../../types/telegram'; -import { escape } from '../utils/md2tgmd'; - -// Telegram函数 -// 1. 需要判断请求状态的返回Promise -// 2. 无需判断请求结果的返回Promise -// 3. 有具体数据处理需求的返回具体数据类型的Promise -// 4. 默认返回Promise - -async function sendTelegramRequest(method: string, token: string, body: FormData | object | null = null): Promise { - const headers: Record = {}; - if (!(body instanceof FormData)) { - headers['Content-Type'] = 'application/json'; - } - return fetch( - `${ENV.TELEGRAM_API_DOMAIN}/bot${token}/${method}`, - { - method: 'POST', - headers, - body: body && ((body instanceof FormData) ? body : JSON.stringify(body)), - }, - ); -} - -async function sendMessage(message: string, token: string, context: CurrentChatContext): Promise { - const body: Record = { - text: message, - }; - for (const [key, value] of Object.entries(context)) { - if (value !== undefined && value !== null) { - body[key] = value; - } - } - let method = 'sendMessage'; - if (context?.message_id) { - method = 'editMessageText'; - } - return sendTelegramRequest(method, token, body); -} - -export async function sendMessageToTelegram(message: string, token: string, context: CurrentChatContext): Promise { - const chatContext = context; - const originMessage = message; - const limit = 4096; - - if (chatContext.parse_mode === 'MarkdownV2') { - message = escape(message); - } - - if (message.length <= limit) { - const resp = await sendMessage(message, token, chatContext); - if (resp.status === 200) { - return resp; - } else { - message = originMessage; - // 可能格式错乱导致发送失败,使用纯文本格式发送 - chatContext.parse_mode = null; - return await sendMessage(message, token, chatContext); - } - } - message = originMessage; - // 拆分消息后可能导致markdown格式错乱,所以采用纯文本模式发送 - chatContext.parse_mode = null; - let lastMessageResponse = null; - for (let i = 0; i < message.length; i += limit) { - const msg = message.slice(i, Math.min(i + limit, message.length)); - if (i > 0) { - chatContext.message_id = null; - } - lastMessageResponse = await sendMessage(msg, token, chatContext); - if (lastMessageResponse.status !== 200) { - break; - } - } - if (lastMessageResponse === null) { - throw new Error('Send message failed'); - } - return lastMessageResponse; -} - -export async function sendPhotoToTelegram(photo: string | Blob, token: string, context: CurrentChatContext): Promise { - if (typeof photo === 'string') { - const body: Record = { - photo, - }; - for (const [key, value] of Object.entries(context)) { - if (value !== undefined && value !== null) { - body[key] = value; - } - } - return sendTelegramRequest('sendPhoto', token, body); - } else { - const body = new FormData(); - body.append('photo', photo, 'photo.png'); - for (const [key, value] of Object.entries(context)) { - if (value !== undefined && value !== null) { - body.append(key, `${value}`); - } - } - return sendTelegramRequest('sendPhoto', token, body); - } -} - -export async function sendChatActionToTelegram(action: string, token: string, chatId: number): Promise { - return sendTelegramRequest('sendChatAction', token, { - chat_id: chatId, - action, - }); -} - -export async function bindTelegramWebHook(token: string, url: string): Promise { - return sendTelegramRequest('setWebhook', token, { url }); -} - -export async function deleteTelegramWebHook(token: string): Promise { - return sendTelegramRequest('deleteWebhook', token); -} - -export async function getTelegramUpdates(token: string, offset: number): Promise<{ result: TelegramWebhookRequest[] }> { - return sendTelegramRequest('getUpdates', token, { offset }) - .then(res => res.json()) as any; -} - -export async function getChatAdministrators(chatId: number, token: string): Promise<{ result: any[] }> { - return sendTelegramRequest('getChatAdministrators', token, { chat_id: chatId }) - .then(res => res.json()).catch(() => null) as any; -} - -export async function getBotName(token: string): Promise { - const { result: { username } } = await sendTelegramRequest('getMe', token) - .then(res => res.json()) as any; - return username; -} - -export async function getFileLink(fileId: string, token: string): Promise { - try { - const { result: { file_path } } = await sendTelegramRequest('getFile', token, { file_id: fileId }) - .then(res => res.json()) as any; - return `https://api.telegram.org/file/bot${token}/${file_path}`; - } catch (e) { - console.error(e); - } - return ''; -} - -export async function setMyCommands(config: any, token: string): Promise { - return sendTelegramRequest('setMyCommands', token, config); -} - -export function sendMessageToTelegramWithContext(context: WorkerContext): (message: string) => Promise { - return async (message) => { - return sendMessageToTelegram(message, context.SHARE_CONTEXT.currentBotToken, context.CURRENT_CHAT_CONTEXT); - }; -} - -export function sendPhotoToTelegramWithContext(context: WorkerContext): (photo: string | Blob) => Promise { - return (photo) => { - return sendPhotoToTelegram(photo, context.SHARE_CONTEXT.currentBotToken, context.CURRENT_CHAT_CONTEXT); - }; -} - -export function sendChatActionToTelegramWithContext(context: WorkerContext): (action: string) => Promise { - return (action) => { - return sendChatActionToTelegram(action, context.SHARE_CONTEXT.currentBotToken, context.CURRENT_CHAT_CONTEXT.chat_id); - }; -} diff --git a/src/telegram/command/auth.ts b/src/telegram/command/auth.ts index 256027cc..838a567c 100644 --- a/src/telegram/command/auth.ts +++ b/src/telegram/command/auth.ts @@ -1,6 +1,7 @@ import type { WorkerContext } from '../../config/context'; import { DATABASE } from '../../config/env'; -import { getChatAdministrators } from '../api/telegram'; +import { TelegramBotAPI } from '../api/api'; +import type { Telegram, TelegramAPISuccess } from '../../types/telegram'; export async function loadChatRoleWithContext(context: WorkerContext): Promise { const { @@ -8,28 +9,24 @@ export async function loadChatRoleWithContext(context: WorkerContext): Promise res.json()).catch(() => null) as TelegramAPISuccess; if (result == null) { return null; } - groupAdmin = result; + groupAdmin = result.result; // 缓存120s await DATABASE.put( groupAdminKey, diff --git a/src/telegram/command/index.ts b/src/telegram/command/index.ts index 96e8453c..63babc39 100644 --- a/src/telegram/command/index.ts +++ b/src/telegram/command/index.ts @@ -2,8 +2,8 @@ import { CUSTOM_COMMAND, ENV, PLUGINS_COMMAND } from '../../config/env'; import type { WorkerContext } from '../../config/context'; import type { RequestTemplate } from '../../plugins/template'; import { executeRequest, formatInput } from '../../plugins/template'; -import type { TelegramMessage } from '../../types/telegram'; -import { sendMessageToTelegramWithContext, sendPhotoToTelegramWithContext } from '../api/telegram'; +import type { Telegram } from '../../types/telegram'; +import { sendMessageToTelegramWithContext, sendPhotoToTelegramWithContext } from '../utils/send'; import type { CommandHandler } from './type'; import { ClearEnvCommandHandler, @@ -35,7 +35,7 @@ const SYSTEM_COMMANDS: CommandHandler[] = [ new HelpCommandHandler(), ]; -async function handleSystemCommand(message: TelegramMessage, raw: string, command: CommandHandler, context: WorkerContext): Promise { +async function handleSystemCommand(message: Telegram.Message, raw: string, command: CommandHandler, context: WorkerContext): Promise { try { // 如果存在权限条件 if (command.needAuth) { @@ -62,7 +62,7 @@ async function handleSystemCommand(message: TelegramMessage, raw: string, comman } } -async function handlePluginCommand(message: TelegramMessage, command: string, raw: string, template: RequestTemplate, context: WorkerContext): Promise { +async function handlePluginCommand(message: Telegram.Message, command: string, raw: string, template: RequestTemplate, context: WorkerContext): Promise { try { const subcommand = raw.substring(command.length).trim(); const DATA = formatInput(subcommand, template.input?.type); @@ -92,7 +92,7 @@ async function handlePluginCommand(message: TelegramMessage, command: string, ra } } -export async function handleCommandMessage(message: TelegramMessage, context: WorkerContext): Promise { +export async function handleCommandMessage(message: Telegram.Message, context: WorkerContext): Promise { let text = (message.text || message.caption || '').trim(); if (CUSTOM_COMMAND[text]) { @@ -125,7 +125,7 @@ export async function handleCommandMessage(message: TelegramMessage, context: Wo return null; } -export function commandsBindScope(): Record { +export function commandsBindScope(): Record { const scopeCommandMap: Record = { all_private_chats: [], all_group_chats: [], diff --git a/src/telegram/command/system.ts b/src/telegram/command/system.ts index 893d0528..d97c12bd 100644 --- a/src/telegram/command/system.ts +++ b/src/telegram/command/system.ts @@ -1,4 +1,4 @@ -import type { TelegramChatType, TelegramMessage } from '../../types/telegram'; +import type { Telegram } from '../../types/telegram'; import type { WorkerContext } from '../../config/context'; import { CUSTOM_COMMAND, @@ -9,24 +9,24 @@ import { mergeEnvironment, } from '../../config/env'; import { - sendChatActionToTelegramWithContext, sendMessageToTelegramWithContext, sendPhotoToTelegramWithContext, -} from '../api/telegram'; +} from '../utils/send'; import { isTelegramChatTypeGroup } from '../utils/utils'; import type { HistoryItem, HistoryModifierResult } from '../../agent/types'; import { chatWithLLM } from '../handler/chat'; import { loadChatLLM, loadImageGen } from '../../agent/agents'; +import { TelegramBotAPI } from '../api/api'; import type { CommandHandler } from './type'; export const COMMAND_AUTH_CHECKER = { - default(chatType: TelegramChatType): string[] | null { + default(chatType: string): string[] | null { if (isTelegramChatTypeGroup(chatType)) { return ['administrator', 'creator']; } return null; }, - shareModeGroup(chatType: TelegramChatType): string[] | null { + shareModeGroup(chatType: string): string[] | null { if (isTelegramChatTypeGroup(chatType)) { // 每个人在群里有上下文的时候,不限制 if (!ENV.GROUP_CHAT_BOT_SHARE_MODE) { @@ -42,16 +42,20 @@ export class ImgCommandHandler implements CommandHandler { command = '/img'; help = () => ENV.I18N.command.help.img; scopes = ['all_private_chats', 'all_chat_administrators']; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { if (subcommand === '') { return sendMessageToTelegramWithContext(context)(ENV.I18N.command.help.img); } try { + const api = TelegramBotAPI.from(context.SHARE_CONTEXT.currentBotToken); const agent = loadImageGen(context.USER_CONFIG); if (!agent) { return sendMessageToTelegramWithContext(context)('ERROR: Image generator not found'); } - setTimeout(() => sendChatActionToTelegramWithContext(context)('upload_photo').catch(console.error), 0); + setTimeout(() => api.sendChatAction({ + chat_id: context.CURRENT_CHAT_CONTEXT.chat_id, + action: 'upload_photo', + }).catch(console.error), 0); const img = await agent.request(subcommand, context.USER_CONFIG); const resp = await sendPhotoToTelegramWithContext(context)(img); if (!resp.ok) { @@ -68,10 +72,10 @@ export class HelpCommandHandler implements CommandHandler { command = '/help'; help = () => ENV.I18N.command.help.help; scopes = ['all_private_chats', 'all_chat_administrators']; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { let helpMsg = `${ENV.I18N.command.help.summary}\n`; for (const [k, v] of Object.entries(ENV.I18N.command.help)) { - if (v === 'summary') { + if (k === 'summary') { continue; } helpMsg += `/${k}:${v}\n`; @@ -91,23 +95,27 @@ export class HelpCommandHandler implements CommandHandler { } class BaseNewCommandHandler { - static async handle(showID: boolean, message: TelegramMessage, subcommand: string, context: WorkerContext): Promise { + static async handle(showID: boolean, message: Telegram.Message, subcommand: string, context: WorkerContext): Promise { await DATABASE.delete(context.SHARE_CONTEXT.chatHistoryKey); const text = ENV.I18N.command.new.new_chat_start + (showID ? `(${context.CURRENT_CHAT_CONTEXT.chat_id})` : ''); + const params: Telegram.SendMessageParams = { + chat_id: context.CURRENT_CHAT_CONTEXT.chat_id, + text, + }; if (ENV.SHOW_REPLY_BUTTON && !isTelegramChatTypeGroup(context.SHARE_CONTEXT.chatType)) { - context.CURRENT_CHAT_CONTEXT.reply_markup = { + params.reply_markup = { keyboard: [[{ text: '/new' }, { text: '/redo' }]], selective: true, resize_keyboard: true, one_time_keyboard: false, }; } else { - context.CURRENT_CHAT_CONTEXT.reply_markup = { + params.reply_markup = { remove_keyboard: true, selective: true, }; } - return sendMessageToTelegramWithContext(context)(text); + return TelegramBotAPI.from(context.SHARE_CONTEXT.currentBotToken).sendMessage(params); } } @@ -115,14 +123,14 @@ export class NewCommandHandler extends BaseNewCommandHandler implements CommandH command = '/new'; help = () => ENV.I18N.command.help.new; scopes = ['all_private_chats', 'all_group_chats', 'all_chat_administrators']; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { return BaseNewCommandHandler.handle(false, message, subcommand, context); }; } export class StartCommandHandler extends BaseNewCommandHandler implements CommandHandler { command = '/start'; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { return BaseNewCommandHandler.handle(true, message, subcommand, context); }; } @@ -131,7 +139,7 @@ export class SetEnvCommandHandler implements CommandHandler { command = '/setenv'; help = () => ENV.I18N.command.help.setenv; needAuth = COMMAND_AUTH_CHECKER.shareModeGroup; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { const kv = subcommand.indexOf('='); if (kv === -1) { return sendMessageToTelegramWithContext(context)(ENV.I18N.command.help.setenv); @@ -167,7 +175,7 @@ export class SetEnvsCommandHandler implements CommandHandler { command = '/setenvs'; help = () => ENV.I18N.command.help.setenvs; needAuth = COMMAND_AUTH_CHECKER.shareModeGroup; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { try { const values = JSON.parse(subcommand); const configKeys = Object.keys(context.USER_CONFIG); @@ -202,7 +210,7 @@ export class DelEnvCommandHandler implements CommandHandler { command = '/delenv'; help = () => ENV.I18N.command.help.delenv; needAuth = COMMAND_AUTH_CHECKER.shareModeGroup; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { if (ENV.LOCK_USER_CONFIG_KEYS.includes(subcommand)) { const msg = `Key ${subcommand} is locked`; return sendMessageToTelegramWithContext(context)(msg); @@ -224,7 +232,7 @@ export class DelEnvCommandHandler implements CommandHandler { export class ClearEnvCommandHandler implements CommandHandler { command = '/clearenv'; needAuth = COMMAND_AUTH_CHECKER.shareModeGroup; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { try { await DATABASE.put( context.SHARE_CONTEXT.configStoreKey, @@ -242,7 +250,7 @@ export class VersionCommandHandler implements CommandHandler { command = '/version'; help = () => ENV.I18N.command.help.version; scopes = ['all_private_chats', 'all_chat_administrators']; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { const current = { ts: ENV.BUILD_TIMESTAMP, sha: ENV.BUILD_VERSION, @@ -269,7 +277,7 @@ export class SystemCommandHandler implements CommandHandler { command = '/system'; help = () => ENV.I18N.command.help.system; scopes = ['all_private_chats', 'all_chat_administrators']; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { const chatAgent = loadChatLLM(context.USER_CONFIG); const imageAgent = loadImageGen(context.USER_CONFIG); const agent = { @@ -308,7 +316,7 @@ export class RedoCommandHandler implements CommandHandler { command = '/redo'; help = () => ENV.I18N.command.help.redo; scopes = ['all_private_chats', 'all_group_chats', 'all_chat_administrators']; - handle = async (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { const mf = (history: HistoryItem[], text: string | null): HistoryModifierResult => { let nextText = text; if (!(history && Array.isArray(history) && history.length > 0)) { @@ -337,7 +345,7 @@ export class RedoCommandHandler implements CommandHandler { export class EchoCommandHandler implements CommandHandler { command = '/echo'; - handle = (message: TelegramMessage, subcommand: string, context: WorkerContext): Promise => { + handle = (message: Telegram.Message, subcommand: string, context: WorkerContext): Promise => { let msg = '
';
         msg += JSON.stringify({ message }, null, 2);
         msg += '
'; diff --git a/src/telegram/command/type.d.ts b/src/telegram/command/type.d.ts index ff84af1d..3a719055 100644 --- a/src/telegram/command/type.d.ts +++ b/src/telegram/command/type.d.ts @@ -1,10 +1,10 @@ -import type { TelegramChatType, TelegramMessage } from '../../types/telegram'; import type { WorkerContext } from '../../config/context'; +import type { Telegram } from '../../types/telegram'; export interface CommandHandler { command: string; help?: () => string; scopes?: string[]; - handle: (message: TelegramMessage, subcommand: string, context: WorkerContext) => Promise; - needAuth?: (chatType: TelegramChatType) => string[] | null; + handle: (message: Telegram.Message, subcommand: string, context: WorkerContext) => Promise; + needAuth?: (chatType: string) => string[] | null; } diff --git a/src/telegram/handler/chat.ts b/src/telegram/handler/chat.ts index a5bb81e2..7549d8af 100644 --- a/src/telegram/handler/chat.ts +++ b/src/telegram/handler/chat.ts @@ -4,9 +4,10 @@ import type { StreamResultHandler } from '../../agent/chat'; import { requestCompletionsFromLLM } from '../../agent/chat'; import type { HistoryModifier, LLMChatRequestParams } from '../../agent/types'; import type { WorkerContext } from '../../config/context'; -import { getFileLink, sendChatActionToTelegramWithContext, sendMessageToTelegramWithContext } from '../api/telegram'; -import type { TelegramMessage, TelegramPhoto } from '../../types/telegram'; +import { sendMessageToTelegramWithContext } from '../utils/send'; +import type { Telegram, TelegramAPISuccess } from '../../types/telegram'; import { uploadImageToTelegraph } from '../../utils/image'; +import { TelegramBotAPI } from '../api/api'; import type { MessageHandler } from './type'; export async function chatWithLLM(params: LLMChatRequestParams, context: WorkerContext, modifier: HistoryModifier | null): Promise { @@ -14,11 +15,14 @@ export async function chatWithLLM(params: LLMChatRequestParams, context: WorkerC try { const msg = await sendMessageToTelegramWithContext(context)('...').then(r => r.json()) as any; context.CURRENT_CHAT_CONTEXT.message_id = msg.result.message_id; - context.CURRENT_CHAT_CONTEXT.reply_markup = null; } catch (e) { console.error(e); } - setTimeout(() => sendChatActionToTelegramWithContext(context)('typing').catch(console.error), 0); + const api = TelegramBotAPI.from(context.SHARE_CONTEXT.currentBotToken); + setTimeout(() => api.sendChatAction({ + chat_id: context.CURRENT_CHAT_CONTEXT.chat_id, + action: 'typing', + }).catch(console.error), 0); let onStream: StreamResultHandler | null = null; const parseMode = context.CURRENT_CHAT_CONTEXT.parse_mode; let nextEnableTime: number | null = null; @@ -71,7 +75,7 @@ export async function chatWithLLM(params: LLMChatRequestParams, context: WorkerC } } -export function findPhotoFileID(photos: TelegramPhoto[], offset: number): string { +export function findPhotoFileID(photos: Telegram.PhotoSize[], offset: number): string { let sizeIndex = 0; if (offset >= 0) { sizeIndex = offset; @@ -83,7 +87,7 @@ export function findPhotoFileID(photos: TelegramPhoto[], offset: number): string } export class ChatHandler implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { const params: LLMChatRequestParams = { message: message.text || message.caption || '', }; @@ -96,11 +100,15 @@ export class ChatHandler implements MessageHandler { if (message.photo && message.photo.length > 0) { const id = findPhotoFileID(message.photo, ENV.TELEGRAM_PHOTO_SIZE_OFFSET); - let url = await getFileLink(id, context.SHARE_CONTEXT.currentBotToken); - if (ENV.TELEGRAPH_ENABLE) { - url = await uploadImageToTelegraph(url); + const api = TelegramBotAPI.from(context.SHARE_CONTEXT.currentBotToken); + const file = await api.getFile({ file_id: id }).then(res => res.json()) as TelegramAPISuccess; + let url = file.result.file_path; + if (url) { + if (ENV.TELEGRAPH_ENABLE) { + url = await uploadImageToTelegraph(url); + } + params.images = [url]; } - params.images = [url]; } return chatWithLLM(params, context, null); }; diff --git a/src/telegram/handler/group.ts b/src/telegram/handler/group.ts index 0e3b5915..291a3f13 100644 --- a/src/telegram/handler/group.ts +++ b/src/telegram/handler/group.ts @@ -1,11 +1,12 @@ -import type { TelegramMessage, TelegramMessageEntity } from '../../types/telegram'; +import type { User } from 'telegram-bot-api-types'; +import type { Telegram, TelegramAPISuccess } from '../../types/telegram'; import type { WorkerContext } from '../../config/context'; import { isTelegramChatTypeGroup } from '../utils/utils'; import { ENV } from '../../config/env'; -import { getBotName } from '../api/telegram'; +import { TelegramBotAPI } from '../api/api'; import type { MessageHandler } from './type'; -function checkMention(content: string, entities: TelegramMessageEntity[], botName: string, botId: number): { +function checkMention(content: string, entities: Telegram.MessageEntity[], botName: string, botId: number): { isMention: boolean; content: string; } { @@ -43,7 +44,7 @@ function checkMention(content: string, entities: TelegramMessageEntity[], botNam } export class GroupMention implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { // 非群组消息不作判断,交给下一个中间件处理 if (!isTelegramChatTypeGroup(context.SHARE_CONTEXT.chatType)) { return null; @@ -51,7 +52,7 @@ export class GroupMention implements MessageHandler { // 处理回复消息, 如果回复的是当前机器人的消息交给下一个中间件处理 if (message.reply_to_message) { - if (`${message.reply_to_message.from.id}` === `${context.SHARE_CONTEXT.currentBotId}`) { + if (`${message.reply_to_message.from?.id}` === `${context.SHARE_CONTEXT.currentBotId}`) { return null; } else if (ENV.EXTRA_MESSAGE_CONTEXT) { context.SHARE_CONTEXT.extraMessageContext = message.reply_to_message; @@ -61,7 +62,8 @@ export class GroupMention implements MessageHandler { // 处理群组消息,过滤掉AT部分 let botName = context.SHARE_CONTEXT.currentBotName; if (!botName) { - botName = await getBotName(context.SHARE_CONTEXT.currentBotToken); + const res = await TelegramBotAPI.from(context.SHARE_CONTEXT.currentBotToken).getMe().then(res => res.json()) as TelegramAPISuccess; + botName = res.result.username || null; context.SHARE_CONTEXT.currentBotName = botName; } if (!botName) { diff --git a/src/telegram/handler/handlers.ts b/src/telegram/handler/handlers.ts index c896bf37..f3eaae86 100644 --- a/src/telegram/handler/handlers.ts +++ b/src/telegram/handler/handlers.ts @@ -1,13 +1,13 @@ -import type { TelegramMessage } from '../../types/telegram'; +import type { Telegram } from '../../types/telegram'; import type { WorkerContext } from '../../config/context'; import { handleCommandMessage } from '../command'; import { DATABASE, ENV } from '../../config/env'; -import { sendMessageToTelegramWithContext } from '../api/telegram'; +import { sendMessageToTelegramWithContext } from '../utils/send'; import { isTelegramChatTypeGroup } from '../utils/utils'; import type { MessageHandler } from './type'; export class SaveLastMessage implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { if (!ENV.DEBUG_MODE) { return null; } @@ -18,7 +18,7 @@ export class SaveLastMessage implements MessageHandler { } export class OldMessageFilter implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { if (!ENV.SAFE_MODE) { return null; } @@ -43,7 +43,7 @@ export class OldMessageFilter implements MessageHandler { } export class EnvChecker implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { if (!DATABASE) { return sendMessageToTelegramWithContext(context)('DATABASE Not Set'); } @@ -52,7 +52,7 @@ export class EnvChecker implements MessageHandler { } export class WhiteListFilter implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { if (ENV.I_AM_A_GENEROUS_PERSON) { return null; } @@ -89,7 +89,7 @@ export class WhiteListFilter implements MessageHandler { export class MessageFilter implements MessageHandler { // eslint-disable-next-line unused-imports/no-unused-vars - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { if (message.text) { return null;// 纯文本消息 } @@ -104,7 +104,7 @@ export class MessageFilter implements MessageHandler { } export class CommandHandler implements MessageHandler { - handle = async (message: TelegramMessage, context: WorkerContext): Promise => { + handle = async (message: Telegram.Message, context: WorkerContext): Promise => { if (message.text || message.caption) { return await handleCommandMessage(message, context); } diff --git a/src/telegram/handler/index.ts b/src/telegram/handler/index.ts index 48ffb34f..07f688d8 100644 --- a/src/telegram/handler/index.ts +++ b/src/telegram/handler/index.ts @@ -1,5 +1,5 @@ import { WorkerContext } from '../../config/context'; -import type { TelegramMessage, TelegramWebhookRequest } from '../../types/telegram'; +import type { Telegram } from '../../types/telegram'; import { ChatHandler } from './chat'; import { GroupMention } from './group'; import type { MessageHandler } from './type'; @@ -12,11 +12,11 @@ import { WhiteListFilter, } from './handlers'; -function loadMessage(body: TelegramWebhookRequest): TelegramMessage { - if (body?.edited_message) { +function loadMessage(body: Telegram.Update): Telegram.Message { + if (body.edited_message) { throw new Error('Ignore edited message'); } - if (body?.message) { + if (body.message) { return body?.message; } else { throw new Error('Invalid message'); @@ -43,7 +43,7 @@ const SHARE_HANDLER: MessageHandler[] = [ new ChatHandler(), ]; -export async function handleMessage(token: string, body: TelegramWebhookRequest): Promise { +export async function handleMessage(token: string, body: Telegram.Update): Promise { const message = loadMessage(body); const context = await WorkerContext.from(token, message); diff --git a/src/telegram/handler/type.d.ts b/src/telegram/handler/type.d.ts index d8ac4799..03b5af7b 100644 --- a/src/telegram/handler/type.d.ts +++ b/src/telegram/handler/type.d.ts @@ -1,4 +1,4 @@ -import type { TelegramMessage } from '../../types/telegram'; +import type { Telegram } from '../../types/telegram'; import type { WorkerContext } from '../../config/context'; // 中间件定义 function (message: TelegramMessage, context: Context): Promise @@ -6,5 +6,5 @@ import type { WorkerContext } from '../../config/context'; // 2. 当函数返回 Response 对象时,结束消息处理,返回 Response 对象 // 3. 当函数返回 null 时,继续下一个中间件处理 export interface MessageHandler { - handle: (message: TelegramMessage, context: WorkerContext) => Promise; + handle: (message: Telegram.Message, context: WorkerContext) => Promise; } diff --git a/src/telegram/utils/send.ts b/src/telegram/utils/send.ts new file mode 100644 index 00000000..54f10f9e --- /dev/null +++ b/src/telegram/utils/send.ts @@ -0,0 +1,101 @@ +import type { CurrentChatContext, WorkerContext } from '../../config/context'; +import type { Telegram } from '../../types/telegram'; +import { TelegramBotAPI } from '../api/api'; + +async function sendMessage(api: TelegramBotAPI, message: string, token: string, context: CurrentChatContext): Promise { + if (context?.message_id) { + const params: Telegram.EditMessageTextParams = { + chat_id: context.chat_id, + message_id: context.message_id, + parse_mode: context.parse_mode || undefined, + text: message, + }; + if (context.disable_web_page_preview) { + params.link_preview_options = { + is_disabled: true, + }; + } + return api.editMessageText(params); + } else { + const params: Telegram.SendMessageParams = { + chat_id: context.chat_id, + parse_mode: context.parse_mode || undefined, + text: message, + }; + if (context.reply_to_message_id) { + params.reply_parameters = { + message_id: context.reply_to_message_id, + chat_id: context.chat_id, + allow_sending_without_reply: context.allow_sending_without_reply || undefined, + }; + } + if (context.disable_web_page_preview) { + params.link_preview_options = { + is_disabled: true, + }; + } + return api.sendMessage(params); + }; +} + +async function sendLongMessage(message: string, token: string, context: CurrentChatContext): Promise { + const chatContext = context; + const originMessage = message; + const limit = 4096; + const api = TelegramBotAPI.from(token); + + if (message.length <= limit) { + const resp = await sendMessage(api, message, token, chatContext); + if (resp.status === 200) { + return resp; + } else { + message = originMessage; + // 可能格式错乱导致发送失败,使用纯文本格式发送 + chatContext.parse_mode = null; + return await sendMessage(api, message, token, chatContext); + } + } + message = originMessage; + // 拆分消息后可能导致markdown格式错乱,所以采用纯文本模式发送 + chatContext.parse_mode = null; + let lastMessageResponse = null; + for (let i = 0; i < message.length; i += limit) { + const msg = message.slice(i, Math.min(i + limit, message.length)); + if (i > 0) { + chatContext.message_id = null; + } + lastMessageResponse = await sendMessage(api, msg, token, chatContext); + if (lastMessageResponse.status !== 200) { + break; + } + } + if (lastMessageResponse === null) { + throw new Error('Send message failed'); + } + return lastMessageResponse; +} + +export function sendMessageToTelegramWithContext(context: WorkerContext): (message: string) => Promise { + return async (message) => { + return sendLongMessage(message, context.SHARE_CONTEXT.currentBotToken, context.CURRENT_CHAT_CONTEXT); + }; +} + +export function sendPhotoToTelegramWithContext(context: WorkerContext): (photo: string | Blob) => Promise { + return async (photo) => { + const api = TelegramBotAPI.from(context.SHARE_CONTEXT.currentBotToken); + const chatContext = context.CURRENT_CHAT_CONTEXT; + const params: Telegram.SendPhotoParams = { + chat_id: chatContext.chat_id, + photo, + }; + if (chatContext.reply_to_message_id) { + params.reply_parameters = { + message_id: chatContext.reply_to_message_id, + chat_id: chatContext.chat_id, + allow_sending_without_reply: chatContext.allow_sending_without_reply || undefined, + }; + } + return api.sendPhoto(params); + }; +} diff --git a/src/telegram/utils/utils.ts b/src/telegram/utils/utils.ts index 4c35b58b..68383541 100644 --- a/src/telegram/utils/utils.ts +++ b/src/telegram/utils/utils.ts @@ -1,5 +1,3 @@ -import type { TelegramChatType } from '../../types/telegram'; - -export function isTelegramChatTypeGroup(type: TelegramChatType): boolean { +export function isTelegramChatTypeGroup(type: string): boolean { return type === 'group' || type === 'supergroup'; } diff --git a/src/types/telegram.d.ts b/src/types/telegram.d.ts new file mode 100644 index 00000000..bb55c8c8 --- /dev/null +++ b/src/types/telegram.d.ts @@ -0,0 +1,16 @@ +import * as Telegram from 'telegram-bot-api-types'; + +export { + Telegram, +}; + +export interface TelegramAPISuccess { + ok: true; + result: T; +} + +export interface TelegramAPIError { + ok: false; + error_code: number; + description: string; +} diff --git a/src/types/telegram.ts b/src/types/telegram.ts deleted file mode 100644 index c25d7c8b..00000000 --- a/src/types/telegram.ts +++ /dev/null @@ -1,87 +0,0 @@ -export interface TelegramBaseFile { - file_id: string; - file_unique_id: string; - file_size?: number; -} - -export interface TelegramPhoto extends TelegramBaseFile { - width: number; - height: number; -} - -export interface TelegramVoice extends TelegramBaseFile { - duration: number; - mime_type?: string; -} - -export interface TelegramUser { - id: number; - is_bot: boolean; - first_name: string; - last_name?: string; - username?: string; - language_code?: string; -} - -export type TelegramChatType = 'private' | 'group' | 'supergroup' | 'channel'; - -export interface TelegramChat { - id: number; - type: TelegramChatType; - is_forum: boolean; - all_members_are_administrators: boolean; -} - -export interface TelegramMessageEntity { - type: string; - offset: number; - length: number; - url?: string; - user?: TelegramUser; -} - -export interface TelegramMessage { - message_id: number; - from: TelegramUser; - chat: TelegramChat; - date: number; - text?: string; - caption?: string; - photo?: TelegramPhoto[]; - voice?: TelegramVoice; - entities?: TelegramMessageEntity[]; - caption_entities?: TelegramMessageEntity[]; - reply_to_message?: TelegramMessage; - is_topic_message: boolean; - message_thread_id: string | number; - reply_markup?: TelegramReplyKeyboardRemove | TelegramReplyKeyboardMarkup | null; -} - -export interface TelegramReplyKeyboardRemove { - remove_keyboard: true; - selective?: boolean; -} - -export interface TelegramReplyKeyboardMarkup { - keyboard: string[][]; - resize_keyboard?: boolean; - one_time_keyboard?: boolean; - selective?: boolean; -} - -export interface TelegramWebhookRequest { - update_id: number; - message: TelegramMessage; - edited_message: TelegramMessage; -} - -export interface TelegramAPISuccessResponse { - ok: true; - result: T; -} - -export interface TelegramAPIErrorResponse { - ok: false; - error_code: number; - description: string; -} diff --git a/yarn.lock b/yarn.lock index 290b27b7..d7093952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2867,6 +2867,11 @@ tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +telegram-bot-api-types@^7.9.1: + version "7.9.1" + resolved "https://registry.yarnpkg.com/telegram-bot-api-types/-/telegram-bot-api-types-7.9.1.tgz#2466ba6c3ff028a4b7a2ed3f9a21e86d35ba0a6a" + integrity sha512-aI1jKgB+zs3t9Mbt58tQNc3EGuafuQG/FwJIkBNgU5CYiBa2u4Ona9S6nQbP81isCMyiXvsam+sLpPoxEFULbg== + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"