null

TelegramBot. Часть 2. Интеграция с GitLab

Оглавление

Часть 1. Создание простого бота + напоминания по cron-у

 

Предыстория

Царским велением пользователя бота с псевдонимом "Повелитель" мне была поставлена задача дополнить функциональность бота-напоминальщика и превратить его в инструмент для упрощения трека времени. По сути необходимо было выполнить следующее:

  1. Предоставить возможность пользователю бота трекать потраченное время через интерфейс бота, вместо того, чтобы делать это вручную.
  2. Предоставить жесткий формат заполнения "трека", чтобы избежать отклонений от правил ведения time-management-а

Таким образом, необходимо было решить проблему заполнения time-management-а как такового (рутинное раздражающее действие с треком времени в задаче time-management-а + одновременно в конкретной прикладной задаче), а также проблему правильного заполнения time-management-а (необходимо придерживаться точно заданного формата).

Ключом к решению задача стал перенос бота в облако Tune IT и его интеграция с GitLab.

 

Перенос в облако

Для начала рассмотрим вопрос, почему это вообще возможно было совершить без "закрытия" бота от внешнего мира. Все же хотелось сохранить возможность использовать бота без необходимости использования корпортивного VPN. Фишка в том, что Telegram поддерживает две модели интеграции бот-ов (standalone-приложений) с сервисами Telegram:

  • polling - приложение раз в n-ое время опрашивает сервисы Telegram о наличии непрочитанных сообщений, адресованных боту;
  • webhook - приложение регистрирует callback-url в сервисах Telegram и уже сами сервисы Telegram обращаются к REST API бот-а с информацией о непрочитанных сообщениях.

Очевидно, в контексте рассматриваемой задачи, каждый из подходов имеет свои "за" и "против". Например, подход с webhook обеспечивает большую производительность бот-а, выраженную в пропускной способности обработки входящих сообщений, однако он требует наличия публичного IP-адреса у бот-а. У альтернативного подхода все с точностью до наоборот.

Пропускная способность бот-а к гадалке не ходи никогда не будет узким местом, так как бот предназначен сугубо для внутреннего использования в компании, в то время как с заведением публичного IP-адреса, через который получится сделать маршрутизацию HTTP-трафика до бот-а, откровенно говоря, возится не хотелось. Поэтому выбор пал на вариант с опросом сервисов Telegram, а, значит, и с сохранением работоспособности кода, написанного в прошлой части "цикла".

Ну а далее, был получен доступ на ВМ в корпоративном облаке и развернуты приложения в виде docker-контейнеров при помощи docker-compose. Все "по классике", ничего необычного.

services:
  time-mgmt-telegram-bot:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: time-mgmt-telegram-bot
    environment:
      PERSONAL_NOTIFIER_TELEGRAM_BOT_TOKEN: paste-me # override token for dev launch with docker-compose.override.yml
    volumes:
      - ./volumes/data:/apps/data
    networks:
      time-mgmt-telegram-bot-network:
        ipv4_address: 172.30.0.2
  time-mgmt-telegram-bot-crond:
    build:
      context: .
      dockerfile: Dockerfile-crond
    container_name: time-mgmt-telegram-bot-crond
    environment:
      PERSONAL_NOTIFIER_TELEGRAM_BOT_TOKEN: paste-me # override token for dev launch with docker-compose.override.yml
    volumes:
      - ./volumes/data:/apps/data
    networks:
      time-mgmt-telegram-bot-network:
        ipv4_address: 172.30.0.3

networks:
  time-mgmt-telegram-bot-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.30.0.0/24
          gateway: 172.30.0.1

(Ответ на очевидный вопрос "зачем маяться с IP-адресами контейнеров" тут)

 

Интеграция с GitLab

Для нетерпеливых ссылка на REST API GitLab.

Как неудивительно, существуют GitLab-совместимые клиенты, написанные в виде библиотек для всех популярных языков. Однако мы пойдем путем боли и страданий использования "голого" REST API без прослоек в виде библиотек чисто из научного интереса.

В первую очередь нужно решить задачу авторизации запросов, которыми бот будет плеваться в API гитлаба. Согласно документации гитлаба имеются следующие варианты авторизации:

  • OAuth 2.0 (не подходит, так как выключено в корпоративного гитлабе + много головной боли);
  • Personal Access Token (PAT);
  • Project Access Token;
  • Group Access Token;
  • GitLab CI\CD job token;
  • Session cookie.

Коротко поясню выбор: Project\Group Access Token не подходит из-за ограничений области действия; ci\cd токен - мы пишем не ci\cd runner + у него много ограничений; session cookie - мы не браузер. Итого остается Personal Access Token. По сути это токен, привязанный к конкретному пользователю, он позволяет соврешать любые (доступные) действия от лица пользователя. Это как раз то, что нужно.

Итого алгоритм действий следующий:

  1. Пользователь создает PAT, используя веб-интерфейс GitLab;
  2. Пользователь скармливает PAT в бот-а, бот проверяет токен путем запроса username-а пользователя;
  3. Бот запоминает токен в том же файле, где он хранит информацию про подписанных на уведомления пользователей;
  4. Бот посылает с каждым запросом PAT подставляя его в заголовок Authorization (Bearer token).

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

 

Далее необходимо реализовать, собственно, фичу трека времени при помощи бот-а. Причем сделаем это в виде диалога с бот-а (а-ля заполнение формы) в формате "бот задает вопрос" - "пользователь отвечает". И вот тут же всплывают вкусности. Во-первых, последовательность обращений к REST API GitLab-а, во-вторых, использование библиотеки python-telegram-bot для работы с диалогами.

Начнем с первого. Последовательность обращений к API гитлаба следующая:

  1. Получение списка доступных проектов
    Здесь важно отметить, что по сути список доступных проектов это список label-ов, которые можно использовать в проекте с тикетами по time-management-у. Реализуем получение списка доступных label-ов при помощи следующего запроса:
     
    GET https://gitlab.example.com/projects/{TIME_MANAGEMENT_PROJECT_ID}/labels?page=1&per_page=1000
    
    Authorization: Bearer <USER_PAT_HERE>
    (Далее Authorization заголовок будет опускаться ради краткости). Что примечательно: пагинация у гитлаба обязательная, причем есть ограничение на максимальный размер страницы - 100 элементов. Все что больше - обрезается на 100.
    Также учтем, что некоторые label-ы могут быть "не проектными", поэтому условимся, что в описании "непроектных" label-ов необходимо прописать следующую строковую константу: 
     
    TELEGRAM_BOT_MARK_NOT_PROJECT
  2. Получение идентификатора задачи по учету времени
    Идентификатор нужен, чтобы понять, в какую задачу time-management-а затрекать время. Определим актуальную задачу пользователя при помощи milestone-ов, благо у них есть дата начала и дата конца.
     
    GET https://gitlab.example.com/projects/{TIME_MANAGEMENT_PROJECT_ID}/milestones?page=1&per_page=50

    После определения актуального milestone-а на основе текущей даты и полей start_date и due_date, получим идентификатор задачи. Причем важно оговориться, что согласно правилам, для каждого milestone на каждого сотрудника заводится только один тикет.

    GET https://gitlab.example.com/projects/{TIME_MANAGEMENT_PROJECT_ID}/issues?page=1&per_page=1&assignee_username=qwerty&milestone_id=Started


    Примечание об идентификаторах. Некоторые REST-ресурсы помимо id имеют также iid. Первый - глобально уникальный идентификатор. Последний - идентификатор, уникальный в пределах родительского ресурса.
     

  3. Создание комментария в задаче с потраченным временем
     

    POST https://gitlab.example.com/projects/{TIME_MANAGEMENT_PROJECT_ID}/issues/{ACTUAL_ISSUE_ID}/notes
    
    {
        "body": "@qwerty 3h https://gitlab.example.com/projects/1/issues/1 делал вот эту маленькую фигнюшку\n/spend 3h 2021-01-01"
    }

 

Теперь что касается использования библиотеки python-telegram-bot для построения диалогов. Документация по этой части библиотеки оставляет желать лучшего. Для понимания диалогов нужно вспомнить тот факт, что мы как люди, сохраняем контекст диалога во время переписки, значит и бот должен делать то же самое. Благо контекст библиотека хранит за нас в памяти бот-а (вроде как может даже персистетно хранить, но не пробовал) при помощи свой "библиотечной магии". Для нас как разработчика важно понять, что библиотека по сути реализует паттерн "автомат состояний". При подключении к библиотеки наших обработчиков разных этапов диалога мы по сути как раз и описываем этот конечный автомат (возможные состояния и переходы между ними).

def conversation_handler() -> ConversationHandler:
    return ConversationHandler(
        entry_points=[
            CommandHandler("track", _track_command_handler)
        ],
        states={
            TrackConversationStates.CHOOSE_PROJECT: [
                CallbackQueryHandler(_choose_project_handler)
            ],
            TrackConversationStates.PROVIDE_TASK_LINK: [
                MessageHandler(filters.ALL, _task_link_handler)
            ],
            TrackConversationStates.PROVIDE_TIME: [
                MessageHandler(filters.ALL, _time_handler)
            ],
            TrackConversationStates.PROVIDE_SPENT_AT: [
                MessageHandler(filters.ALL, _spend_at_handler)
            ],
            TrackConversationStates.PROVIDE_COMMENT: [
                MessageHandler(filters.ALL, _comment_handler)
            ],
            TrackConversationStates.CONFIRMATION: [
                CallbackQueryHandler(_confirmation_handler)
            ]
        },
        fallbacks=[
            MessageHandler(filters.TEXT & ~filters.COMMAND, _fallback_handler)
        ],
        conversation_timeout=5*60  # in seconds
    )

Причем библиотека также из коробки поддерживает timeout, через который при без действии пользователя будет очищено хранимое состояние диалога. В приведенном коде выше мы описали возможные состоянии и их обработчики. В самих же обработчиков помимо полезной логики по обработке ответов пользователя прописываются также возможные переходы из того состояния, которое хендлит обработчик.

class TrackConversationStates(enum.Enum):
    CHOOSE_PROJECT = 0
    PROVIDE_TASK_LINK = 1
    PROVIDE_TIME = 2
    PROVIDE_SPENT_AT = 3
    PROVIDE_COMMENT = 4
    CONFIRMATION = 5

async def _track_command_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Union[TrackConversationStates, int]:
    (is_user_found, author_user) = search_author_user_template(
        update=update,
    )

    if not is_user_found:
        await update.message.reply_text(text="Хто ты, путник? Напиши '/start', чтобы начать")
        return ConversationHandler.END

    await update.message.reply_text(
        text="Выбери интересующий проект",
        reply_markup=_create_projects_inline_keyboard(author_user)
    )
    return TrackConversationStates.CHOOSE_PROJECT

 

Вуа-ля! Интеграция готова.

 

Бот доступен всем сотрудникам Tune IT для использования по ссылке: https://t.me/TuneITPersonalNotifierBot

Вперед