Pull to refresh

Асинхронный телеграм бот на bash, глазами C# программиста

Reading time7 min
Views3.9K

В интернете много статей о том, как создавать простых bash-телеграм ботов, которые бы выполняли, например, алертинг. Часто это сводиться к вечному циклу, который раз в несколько секунд дергает tg-api. А что, если у меня хотелок больше чем может предоставить такое решение? Хотелки:

  • Беседа ведется асинхронно в нескольких чатах

  • Чатов больше чем процессов в приложении

  • Бот помнит на каком этапе находится каждый разговор

  • Процесс написания бота должен хотя бы напоминать работу с популярными ООП языками

  • Бот должен легко масштабироваться на большее число пользователей

Это история, о попытке поизвращаться создать небольшой, но удобный инструмент для написания ботов, удовлетворяющих моим требованиям.

Зачем, а главное зачем?

Почему?
Почему?

Мной руководил спортивный интерес: "А можно ли, спроектировать инструмент так, чтобы он был максимально удобен мне, человеку который просто любит bash, но не работает на нем, плохо разбирается в bash-практиках программирования и вообще привык к C#"

Подготовка к написанию

Особенности языка

Для начала, я бы выделил те вещи в bash, которые, по моему субъективному мнению, могут создать трудности во время кодинга:

  • Нет поддержки многопоточности

  • Нет классов или структур

  • Всего две области видимости переменных, глобальные и локальные (внутри функций)

  • Не полная поддержка словарей

Вспомогательные фичи

Обработка ошибок

Перед написанием, я решил реализовать инструменты для удобной работы с ошибками.

Фичи которые я посчитал необходимыми:

  • Возможность получения текущего стектрейса в одну функцию

# Глобальная функция получения стектрейса.
trace_call() {
    local frame=0
    # Цикл с перебором всей глубины стека.
    while caller $frame; do
        local info=$(caller $frame)
        # Сохранение стека в одну строку через "->".
        stack_trace="$stack_trace -> $info"
        ((frame++))
    done

    echo "$stack_trace"
}
export -f trace_call 

# Пример использования.
trace_call
  • Упрощенное логирование

# Глобальная функция логирования.
default_logger(){
    # Получение сообщения и уровня важности через параметры.
    local message=$1
    local level=$2
    local current_time=$(date +"%Y-%m-%d %H:%M:%S")
    local stack_trace=`trace_call`
    # Сохранение в формате JSON
    local json=$(cat <<EOF
{
  "time": "$current_time",
  "message": "$message",
  "level": "$level",
  "stack_trace": "$stack_trace",
}
EOF
)
    echo $json >> $LOG_FILE
}
export -f default_logger 

# Пример использования
default_logger "Sum func error" "ERROR" 
  • Проверка на успешность выполнения какого-либо скрипта и последующий запуск обработчика (Аналог catch), тоже в одну функцию

# Глобальная функция обработки ошибок.
catch() {
    # Получение кода завершения предыдущей функции.
    local exit_code=$?
    local catch_function=$1

    # Проверка кода на успешность.
    if [ $exit_code -ne 0 ]; then
        # Запуск переданного скрипта обработки.
        eval $catch_function
    fi
}
export -f catch 

# Пример использовани.
sum_func
catch '
  # Код который запуститься в случае ошибки.
  default_logger "Sum func error" "ERROR" 
'

(Вероятно, вместо функции catch, можно использовать оператор ||, но catch был симпатичнее)

Автоматическая инициализация всех функций внутри проекта

Мне хотелось добиться того, чтобы во время написания проекта, всегда можно было использовать любую функцию из остальной части проекта. Например написать func1, и точно знать, что она уже инициализирована и никакой "func1: command not found" не появиться. Значит, мне требовалось создать механизм инициализации сразу всех функций из всех файлов перед запуском самого бота.

Оказалось, существует простая реализация. Я ввел себе за правило писать исключительно весь код только внутри функций, а также разделил проект на некоторые "пакеты". Пакеты являлись директориями, в которых лежали .sh файлы с набором функций. Тогда, для подключения "пакета" мне было достаточно запустить каждый файл внутри директории в любом порядке.

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

Ниже представлена функция, которая получает адрес директории и рекурсивно запускает каждый из .sh файлов внутри нее.

using() {
    local directory=$1

    for path in "$directory"/*
    do
        if [[ -f "$path" && "$path" == *.sh ]]; then
            source "$path"
        elif [[ -d "$path" ]]; then
            using "$path"
        fi
    done
}
export -f using 

В будущем оказалось, что решение писать весь код только внутри функций было правильно и по другим причинам:

  • Больше не засорялась глобальная область видимости локальными переменными

  • Прочитав название функции, всегда можно было понять, что делает конкретный кусочек кода

  • Код стал чаще переиспользоваться

Упрощенная генерация фоновых процессов

Мне хотелось быстро добавлять цикличные фоновые процессы, а также автоматически их чистить по завершении программы.

Были реализованы функции add_job и job_runner_start. Одна из которых добавляет переданную ей функцию и параметры в массив JOBS_LIST, а вторая запускает функции внутри JOBS_LIST, как отдельные процессы.

Были написаны add_new_pid_for_killing и process_killer_start для автоматического удаления всех дочерних процессов, после закрытия приложения. Функция add_new_pid_for_killing добавляет PID в массив CHILD_PIDS. А process_killer_start подключает обработчик сигналов завершения, который будет убивать процессы из массива.

JOBS_LIST=()

add_job(){
    # Получение функции которая должна стать отдельным процессом.
    local function="$1"
    # Информация о том, как много одновременных процессов нужно.
    local num_of_process=$2
    # Таймаут в секундах, между перезапусками функции.
    local timeout=$3

    # Запись в массив всей переданной информации с разделителем ":".
    combined_record="$function:$num_of_process:$timeout"
    JOBS_LIST+=("$combined_record")
}


job_runner_start(){
    for job in "${JOBS_LIST[@]}"
    do
        IFS=':'
        read -r func num_of_process timeout <<< "$job"
        
        for ((i=1; i<=num_of_process; i++))
        do
            # Запуск функции как отдельного процесса.
            job_start "$func" $timeout &
            catch 'default_logger "error of run job: $func" "ERROR"'
            local pid=$!
            # Добавление дочернего процесса в список тех, кого нужно отключать.
            add_new_pid_for_killing $pid
            catch 'default_logger "error of write pid" "ERROR"'
        done
    done
}

job_start(){
    func="$1"
    timeout=$2

    while true; do
        $func
        catch 'default_logger "error of start of $func" "ERROR"'
        sleep $timeout
    done
}
CHILD_PIDS=()

add_new_pid_for_killing(){
    local pid=$1
    CHILD_PIDS+=("$pid")
}
export -f add_new_pid_for_killing

cleanup(){
    for pid in "${CHILD_PIDS[@]}"
    do
        kill $pid
    done
}

# Подключение обработчика события EXIT.
process_killer_start(){   
  trap cleanup EXIT
} 

Архитектура решения

Для обработки сообщений я выдвинул следующие условия.

  1. В рамках одного чата не должен нарушаться порядок обработки сообщений

  2. Каждый чат должен храниться в виде двух структур: FIFO канала с сообщениями и некоторого текущего состояния чата

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

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

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

Взаимодействие процессов
Взаимодействие процессов

Алгоритм работы Message-Reader process:

  1. Получил список новых сообщений из API

  2. Распределил новые сообщения в FIFO файлы конкретных чатов

  3. Бросил балансировщику уведомления о каждом новом сообщении

Алгоритм работы балансировщика:

  1. Получил уведомление о новом сообщениии или о освобождении какого-либо обработчика

  2. Случайно выбрал ожидающий обработки чат (Если такие есть)

  3. Случайно выбрал свободного обработчика (Если такие есть)

  4. Передал команду об обработке

Алгоритм работы воркера:

  1. Ожидание команды от балансировщика

  2. Получение id чата

  3. Считывание текущего состояния чата и последнего необработанного сообщения

  4. Выполнение бизнес-логики, сохранение нового состояния

  5. Отправка уведомления балансировщику, о том, что обработчик свободен

Процесс написания

К сожалению, я не рискнул вставлять сразу весь код и разбирать все детали реализации, однако, я упомяну самые значимые моменты. Также, существует ссылка на GitHub проекта, где присутствует весь код и инструкция для запуска.

Метод балансировки:

balance() {
# Перебор существующих workers
# WORKER_FIFO_LIST это словарь вида worker_pid:current_chat_id.
  for i in "${!WORKER_FIFO_LIST[@]}"; do
      IFS=":" read -r worker_pid chat_id <<< "${WORKER_FIFO_LIST[i]}"

      # Проверка, привязан ли какой либо чат.
      if [[ "$chat_id" == "0" ]]; then

          # Перебор текущих чатов
          # CHAT_DICTIONARY словарь вида chat_id:num_of_new_messages
          for current_chat_id in "${!CHAT_DICTIONARY[@]}"; do

              # Поиск чата с необработанными сообщениями,
              # который на данный момент еще не обрабатывается.
              if [[ "${CHAT_DICTIONARY[$current_chat_id]}" -gt 0
                        && ! "${WORKER_FIFO_LIST[*]}" =~ "$current_chat_id" ]]; then
                  echo "$worker_pid:$current_chat_id"
                  WORKER_FIFO_LIST[i]="$worker_pid:$current_chat_id"

                  ((CHAT_DICTIONARY[$current_chat_id]--))

                  if [[ "${CHAT_DICTIONARY[$current_chat_id]}" -le 0 ]]; then
                      unset CHAT_DICTIONARY[$current_chat_id]
                  fi

                  # Отправка команды.
                  local fifo="$WORKERS_FIFO_DIR/$worker_pid"
                  echo "$current_chat_id" > "$fifo"
                  echo "Назначен chat_id $current_chat_id воркеру $worker_pid"
                  break
              fi
          done
      fi
  done
} 

Тривиальная обработка сообщений, в которой существует три состояния:

  1. Чат не начат

  2. "SOME_STATE"

  3. "OTHER_STATE"

process_message_base() {
    local chat_id=$1
    local state_file="$CHAR_STATES_DIR/${chat_id}.json"
    local fifo_file="$BASE_FIFO_DIR/$chat_id"
    local state=()

    # Попытка получения состояния.
    if [[ -f "$state_file" ]]; then
        readarray -t state < <(jq -r '.[]' "$state_file")
    else
        state=("SOME_STATE")
        // Сохранение нового состояния.
        echo "$(jq -n --argjson arr "$(printf '%s\n' "${state[@]}" | jq -R . | jq -s .)" '$arr')" > "$state_file"
        // Some Logic
    fi

    if [[ "${state[0]}" == "SOME_STATE"* ]]; then
        // Some logic
    fi

    if [[ "${state[0]}" == "OTHER_STATE"* ]]; then
        // Other logic
    fi
} 

Выводы

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

Также, этому проекту есть куда расти. В будущем можно заменить файлы и fifo каналы на Kafka или RabbitMQ, хранить состояния чатов можно в Redis. Еще можно подумать над распараллеливанием работы балансировщика.

Но есть нюанс.

В процессе написания я узнал много особенностей языка Bash, которые действительно очень затрудняют спокойное написание кода (например, что словарь нельзя вернуть из функции). По этой причине, даже используя текущий инструмент, написание сложной бизнес логики будет занимать непростительно много времени. Асинхронный телеграм бот на bash стал для меня немного доступнее, но все еще остается в разделе "поизвращаться".
Но это было увлекательно! =)

Tags:
Hubs:
+20
Comments14

Articles