Мотивация к созданию библиотеки
Я пишу на TypeScript и периодически появляется необходимость написать какой-нибудь несложный чат бот для Telegram, или отправить в чат что-нибудь от имени бота.
И еще я фанат Serverless архитектуры. Люблю разрабатывать решения, которые работают где и когда угодно (по требованию). Арендовать сервер и платить за его простой - не интересно ?
От простого TypeScript клиента я хочу совсем немного:
клиент должен быть типизирован, на уровне кода хочется видеть какие есть API методы, ответы, и какие параметры нужно/можно указывать
клиент должен создавать запросы и парсить ответы от Telegram Bot API и предоставлять типизированные данные. Например, на
sendMessage
ответ должен бытьMessage
, как описано в документацииклиент должен иметь весь список доступных методов, которые поддерживает Telegram Bot API
И кстати, чат боты нужны далеко не всегда и не всем.
Старый подход
Когда-то я использовал вот это - node-telegram-bot-api, точнее только TypeScript типы (.d.ts), обычно я брал нативный fetch
и посылал HTTP запросы.
Но такой подход был источником рутинного кода, так как нужно было все равно писать код, чтобы по нужному API методу подсовывать правильный JSON.
Переодически приходилось заходить на эту страницу, и выбирать из огромного списка доступных методов - нужный. Хоть TypeScript декларации и помогали в создании правильной структуры запроса, все равно это не избавляло от бесконечного переключение окон между IDE и браузером, в котором открыта документация.
Так не должно повторяться
Как-то не так давно мне потребовалось написать чат бот для Telegram. Старый подход мне не нравился с каждым нравился все меньше и меньше.
Я решил подойти к этой проблеме серьезно и комплексно, и написать библиотеку, которая покроет мои скромные "хотелки" ?
План
создать список всех методов, запросов и ответов к ним, которые указаны в документации Telegram
создать TypeScript типы и интерфейсы, которые дал предыдущий шаг
создать функцию, которая будет посылать HTTP запросы и соотносить с правильным TypeScript типом. В общем обертка над HTTP клиентом, только с удобным/типизированным API.
собрать код/типы и опубликовать в
NPM
Первый пункт плана, пишем парсер
Наверное это самая сложная задача во всей этой истории.
Source of truth
Telegram не предоставляет источник такого как sdk
или OpenAPI
спецификации. Они пишут все на одной огромной HTML странице. Я не смог найти других источников. В целом эта HTML страница является источником правды и опираться нужно на нее, когда используем Telegram Bot API.
Единственное, на мой взгляд, решение к этой проблеме, это скраппинг (парсинг страницы) и кодогенерация, так как вручную я не собирался все это переписывать ?
Есть проекты, например этот, которые парсят HTML страницу и создают OpenAPI
артефакт. Но эта сгенерированная спецификация мне показалась не очень удобной и корректной. Не все типы по JSON схеме были правильно сконструированы, да и исходный Python код не вселял уверенности в то, что автор что-то не пропустил и не сгладил где-то углы когда, соотносил типы в документации с JSON схемой.
Да и
OpenAPI
не особо поможет, нужно ведь сгенерировать TypeScript типы. Была мысль пойти по длинной цепочке: генерировать OpenAPI, а потом генерировать TypeScript изOpenAPI
используя, например, это. Но это ощущалось как переусложнение и совсем не давало уверенности, что итоговый TypeScript код будет хорошим.
Поэтому я решил пойти другим, но очень похожим путем ?
Решил написать свой парсер HTML страницы и кодогенератор, для того, чтобы сразу сгенерировать TypeScript типы и интерфейсы.
Интересные моменты в разработке парсера
Это был интересный опыт, когда я смотрел структуру HTML страницы с документацией и пытался найти паттерны, по которым парсер извлекает все методы, описания к ним, возвращаемые значения и тому подобное.
Странная группировка в боковом меню
Скриншот бокового меню
Вначале я хотел группировать все методы по их функциональной группе, как это указано в боком меню документации, но потом понял, что никаких паттернов и нет.
Например, в меню есть пункт Available types, Available Methods, Updating Messages, и несколько других. Разве метод editMessageText не "Available method"? Или в Payments есть типы описывающие объекты по транзакциям, они не "Available types"? ?
По итогу, это меню никак не систематизирует документацию и для парсера оно не нужно. Поэтому, логика работы парсера опирается на основное содержимое страницы.
Type VS Method
В документации Telegram есть две категории: тип и метод.
Понятно, что тип, это описание структуры данных, которая приходит от Telegram API, а метод это тот же самый тип, но с URL путем, по которому нужно отослать данные в Telegram API и получить ответ определенного типа.
Разница между ними минимальна:
имя метода с маленькой буквы / в типе с большой.
Пример:sendMessage
/Message
метод дополнительно имеет возвращаемый тип / в типе нет
Ну а в остальном они одинаковы, поэтому в парсере это объединено в один тип Entity.
export type ExtractedEntityShape = {
entityName: string
entityDescription: {
lines: string[]
returns: NormalTypeShape | undefined
},
type: NormalType | EntityFields
groupName?: string
}
Все типы/методы начинаются с <h4>
тега, это позволило легко, одним CSS селектором, проходиться по всем нужным HTML нодам и парсить информацию.
Свойства и у методов, и у типов указаны чуть ниже описания, в таблице.
В методах таблица состоит из Parameter, Type, Required, Description.
В типах таблица состоит из Field, Type, Description. Столбец "Required" отсутствует, но это указано в столбце "Description" ?
Были еще некоторые неясные моменты в документации, если хотите, можно в исходном коде посмотреть.
Второй пункт плана. Пишем TypeScript
Сделал парсер страницы, вытащил все типы и методы, написал unit тесты для частных случаев. И перешел к следующему этапу.
Маппинг псевдотипов
Самая главная задача была сгенерировать правильные TypeScript типы из "псевдо" типов указанных в документации.
Например, в методе sendMessage
нужно указывать chat_id
. В документации написан "псевдо" тип: Integer or String
. На TypeScript это будет обычный Union тип: number | string
Еще примеры:
Array of MessageEntity
=>MessageEntity[]
InlineKeyboardMarkup or ReplyKeyboardMarkup
=>InlineKeyboardMarkup | ReplyKeyboardMarkup
Сложно было работать с типами, которые являются ограниченным множеством (union) и значения этих множеств указаны в "description" столбце.
Например, chat_type
это String
но в "description" написано:
Optional. Type of the chat from which the inline query was sent. Can be either “sender” for a private chat with the inline query sender, “private”, “group”, “supergroup”, or “channel”. The chat type should be always known for requests sent from official clients and most third-party clients, unless the request was sent from a secret chat
Получается, что String
это слишком широкий тип, и на языке TypeScript это будет Union из строковых литералов: "private" | "group" | "supergroup" | "channel"
В Telegram есть еще похожие типы-перечисления, хорошо что их не много. Вот, например, у MessageEntity есть поле type, которое может быть "mention" | "hashtag" | "email" ...
Или, например, ReactionTyprEmojj должно быть таким TypeScript типом:
export interface ReactionTypeEmoji {
type: "emoji";
emoji: "?" | "?" | "❤"; // оставил только три, но их больше
}
Discriminated union
В TypeScript есть такой тип, который позволяет объединять типы разных структур, но имеющих одно общее поле.
Была проблема с тем, что большинство типов в документации имело поле "type", но не все.
Просто мысль: Я думаю, что все описанные типы в Telegram, должны иметь "type" хотя бы ради консистенции. Не понял принцип, по которому в одних типах это поле есть, а в других его нет.
А у тех, у кого было поле "type", оно описывало широкий тип String
вместо строкового литерала.
Например, у InputMediaPhoto в "description" указано: "Type of the result, must be photo".
На TypeScript это будет просто литеральный тип "photo"
Создание TypeScript файлов
Используя библиотеку ts-morph, кодогенератор создает два больших TypeScript файла:
В api.ts описан контракт Telegram Bot Api, то есть метод, его входные данные, и тип результата.
Я решил заменить camelCase
названий методов на аналог snake_case
// api.ts
import * as T from "./types.js";
export interface Api {
add_sticker_to_set(_: AddStickerToSetInput): boolean;
answer_callback_query(_: AnswerCallbackQueryInput): boolean;
answer_inline_query(_: AnswerInlineQueryInput): boolean;
// .... и еще куча методов
}
в types.ts написаны все выходные типы
// types.ts
export interface AffiliateInfo {
commission_per_mille: number;
amount: number;
// ....
}
export interface Animation {
file_id: string;
// ...ype?: string;
}
// и еще куча сущностей
Третий пункт плана. Создаем HTTP клиент
На этом этапе я выдохнул, потому что самое сложное уже позади. Есть полный список всех TypeScript типов и интерфейсов. Осталось написать обертку, использующую все эти типы.
Я решил остановиться на таком виде работы с клиентом:
import { makeTgBotClient } from "@effect-ak/tg-bot-client"
const client = makeTgBotClient({
bot_token: "" //your token taken from bot father
});
await client.execute("send_dice", {
chat_id: "???", // replace ??? with the chat number
emoji: "?"
});
Метод/функция клиента execute
принимает два аргумента, название и тело метода.
execute
типизирован, и в зависимости от имени метода меняется тип его тела.
Сигнатура этой функции примерно такая:
declare function execute<M extends keyof Api>(
method: M,
input: Parameters<Api[M]>[0]
): Promise<ReturnType<Api[M]>>
Посылка http запросов в Telegram API
В документации написано следующее:
We support GET and POST HTTP methods. We support four ways of passing parameters in Bot API requests:
application/x-www-form-urlencoded
application/json (except for uploading files)
multipart/form-data (use to upload files)
Я решил использовать multipart/form-data
, потому что есть некоторые методы в документации, которые просят данные сериализованные в JSON. Таким образом этот метод передачи тела самый универсальный.
Например, sendMessage
содержит поле entities
:
A JSON-serialized list of special entities that appear in message text, which can be specified instead of parse_mode
Маппинг ответов
TypeScript это крутой язык, позволяющий создавать типы из других типов, например используя технику Mapped types
Так как кодогенератор создал полноценный TypeScript интерфейс (в api.ts), то это не составило труда соотнести типы ответов с названиями методов.
Четвертый и последний пункт плана. Публикуем библиотеку
Я потестил библиотеку локально, подключал ее в другие проекты и полет был отличный. Настало время опубликовать в NPM.
Мне нравится tsup, отлично подходит для сборки TypeScript проектов. Этот инструмент позволяет собрать один JS файл со всем кодом, из коробки он работает с TypeScript path alias
. И еще он генерит файлы с декларациями (.d.ts), тоже одни файлом ?.
Короткая вставка: есть много разных иструментов для сборки TypeScript проектов, родной
tsc
,esbuild
,rollup
, дажеBun
теперь ворвался в эту гонку ?Для сборок TypeScript библиотек отлично подходит
tsup
Создал в корне конфигурацию tsup
и запустил в терминале.
tsup.config.json
{
"$schema": "https://cdn.jsdelivr.net/npm/tsup/schema.json",
"entry": ["src/index.ts"],
"outDir": "dist",
"format": ["esm", "cjs"],
"splitting": false,
"sourcemap": false,
"noExternal": [
"effect"
],
"minify": true,
"clean": true,
"dts": true
}
Подведем итоги
Я считаю что все "хотелки" реализовал полностью ?
-
библиотека поддерживает все методы, и типы, описанные в Telegram Bot Api
большую часть пакета занимают декларации TypeScript (.d.ts)
-
клиент не будет устаревать и будет up to date.
кодогенерация позволяет одной кнопкой создавать новые типы, например, в случае новой версии Telegram Bot Api (на текущий момент это 8.2).
пока не нашел очевидных ошибок в сгенерированных типах TypeScript, вначале я их видел и быстро чинил. Но, если потребуется доработка по типам, то сделать это будет так же не сложно.
-
клиент полностью типизирован и приятно его использовать в других проектах
я вот например не знал, что Message состоит из 84 полей ?
Ссылки
Чат бот
Я знаю что это отдельная тема, но не могу написать про это в этой статье.
Захотел в эту библиотеку добавить поддержку чат бота, идея в том, чтобы запустить обработчик сообщений чат бота.
И, так как я говорил, что фанат Serverless решений, сделал HTML страницу на которой можно написать функцию обработки сообщений и запустить бота прямо в браузере (используется WebSockets + long pooling обновлений из Telegram Bot Api)