Мотивация к созданию библиотеки

Я пишу на 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 страницы с документацией и пытался найти паттерны, по которым парсер извлекает все методы, описания к ним, возвращаемые значения и тому подобное.

Странная группировка в боковом меню

Скриншот бокового меню
боковое меню документации Telegram

Вначале я хотел группировать все методы по их функциональной группе, как это указано в боком меню документации, но потом понял, что никаких паттернов и нет.

Например, в меню есть пункт 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:

  • URL query string

  • 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 полей ?

Ссылки

Исходный код

Опубликованный NPM пакет

Чат бот

Я знаю что это отдельная тема, но не могу написать про это в этой статье.

Захотел в эту библиотеку добавить поддержку чат бота, идея в том, чтобы запустить обработчик сообщений чат бота.

И, так как я говорил, что фанат Serverless решений, сделал HTML страницу на которой можно написать функцию обработки сообщений и запустить бота прямо в браузере (используется WebSockets + long pooling обновлений из Telegram Bot Api)

Комментарии (0)