TG Army Dashboard: 9 багов, PostgreSQL и мульти-воркер

Когда продукт растёт быстрее, чем успеваешь за ним следить, рано или поздно накапливается технический долг, который начинает мешать работать прямо сейчас. Именно это произошло с TG Army — инструментом для автоматизации Telegram-аккаунтов. В один момент дашборд перестал показывать актуальные данные, вкладка Settings не реагировала на клики, а сохранение шаблонов молча проваливалось в никуда. Под капотом скрывалось сразу девять проблем — от отсутствующих CSS-классов до фундаментального несоответствия между базой данных и веб-панелью.
В этой статье разберём, как мы прошли путь от списка багов до работающей системы с PostgreSQL, Redis и поддержкой мульти-воркерной архитектуры.
Контекст проекта
TG Army — система для управления парком Telegram-аккаунтов. Она автоматизирует рассылку по воронке: аккаунты проходят через прогрев, отправляют последовательность сообщений, прикладывают медиафайлы и фиксируют статусы диалогов. Дашборд — это веб-панель на Flask, где оператор видит состояние всех аккаунтов, редактирует шаблоны и управляет воронкой.
Аккаунты живут в нескольких состояниях: active, frozen, warming, expired, deleted, banned, dead. Последнее — аккаунты, которые уже не восстановить. Проблема была в том, что дашборд знал только о части этих статусов, а остальные просто игнорировал.
Девять проблем, которые мы решали
Прежде чем писать код, мы составили исчерпывающий список того, что сломано:
- Отсутствуют CSS-классы
badge-frozen,badge-warming,badge-deleted— бейджи статусов рендерились без стилей status_colorsвapp.pyне знает о трёх новых статусах- API-эндпоинт смены статуса принимает только 4 значения вместо 7
- Dropdown на странице аккаунта не содержит
frozen,warming,deleted - Нет статистики по AI-диалогам на главной странице
- Нет статистики по прогреву
- Мёртвые аккаунты (
dead) не отделяются визуально от живых - Шаг 5 воронки отправляет кейсы как отдельные сообщения, а не как альбом
- Веб-панель читает данные из SQLite, хотя продакшн давно переехал на PostgreSQL
Последний пункт оказался самым критичным — он объяснял, почему изменения в шаблонах не сохранялись: они писались в пустой SQLite-файл, который никто не читал.
Фикс CSS и расширение статусной модели
Начали с самого видимого — визуальных стилей. В base.html добавили три новых класса бейджей:
.badge-frozen {
background-color: #0dcaf0;
color: #000;
}
.badge-warming {
background-color: #fd7e14;
color: #fff;
}
.badge-deleted {
background-color: #343a40;
color: #adb5bd;
border: 1px solid #6c757d;
}
tr.dead-account {
opacity: 0.5;
}Затем обновили status_colors в app.py — словарь, который маппит статус на CSS-класс:
# До
status_colors = {
'active': 'badge-success',
'expired': 'badge-warning',
'banned': 'badge-danger',
'dead': 'badge-secondary',
}
# После
status_colors = {
'active': 'badge-success',
'frozen': 'badge-frozen',
'warming': 'badge-warming',
'expired': 'badge-warning',
'banned': 'badge-danger',
'deleted': 'badge-deleted',
'dead': 'badge-secondary',
}В API-эндпоинте расширили valid_statuses с 4 до 7 значений. В шаблоне аккаунта обновили dropdown — теперь все 7 статусов. После рестарта сервиса проверили сортировку: active → frozen → expired → deleted, мёртвые аккаунты внизу с opacity: 0.5.
Статистика на дашборде
Добавили метод get_dashboard_stats() в database.py, который агрегирует данные по статусам, считает активные диалоги и аккаунты на прогреве:
async def get_dashboard_stats(self) -> dict:
rows = await self._fetchall(
"""SELECT status, COUNT(*) as cnt
FROM accounts GROUP BY status"""
)
stats = {row['status']: row['cnt'] for row in rows}
ai_dialogs = await self._fetchone(
"""SELECT COUNT(*) as cnt FROM dialogs
WHERE ai_enabled = true AND status = 'active'"""
)
return {
'active': stats.get('active', 0),
'frozen': stats.get('frozen', 0),
'warming': stats.get('warming', 0),
'total_ai_dialogs': ai_dialogs['cnt'] if ai_dialogs else 0,
**stats
}На дашборде появились 6 stat-карточек вместо 3.
Отправка кейсов альбомом
Шаг 5 воронки — отправка кейсов клиентов. Исторически каждая картинка отправлялась отдельным сообщением, что выглядело спамом. Telegram поддерживает медиагруппы — несколько файлов в одном сообщении.
Обновили sender.py:
async def _send_step_5(self, client, dialog):
cases_dir = Path('assets/cases')
case_files = sorted(cases_dir.glob('*.png'))
if not case_files:
return await client.send_message(dialog.peer, self.template_5)
# Отправляем все картинки как альбом — одно сообщение
await client.send_file(
dialog.peer,
list(case_files),
caption=self.template_5
)Четыре PNG-файла (case_01.png — case_04.png) скопировали в assets/cases/. Теперь шаг 5 отправляет один альбом с подписью.
Вкладка Settings не работала — проблема с Bootstrap CDN
После первых правок появился новый симптом: вкладки на странице Settings не переключались. HTML был корректным, JS-код выглядел правильным. Проблема оказалась в том, что Bootstrap JS загружался с cdn.jsdelivr.net — CDN, который нестабильно работает из России.
Если Bootstrap JS не загрузился — все интерактивные компоненты молча падают: табы, дропдауны, тосты. Никакой ошибки в консоли, просто ничего не происходит при клике.
Решение в два шага:
1. Скачали Bootstrap локально:
wget -O src/web/static/bootstrap.min.css \
https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css
wget -O src/web/static/bootstrap.bundle.min.js \
https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js2. Переключили base.html на локальные файлы:
<!-- До -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/..."
integrity="sha384-..." crossorigin="anonymous">
<!-- После -->
<link href="{{ url_for('static', filename='bootstrap.min.css') }}"
rel="stylesheet">
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>Добавили также vanilla JS fallback для табов — на случай если JS всё же не загрузится по какой-то причине:
document.addEventListener('DOMContentLoaded', function() {
// Fallback для Bootstrap tabs
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(function(tab) {
if (typeof bootstrap === 'undefined') {
tab.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('show', 'active'));
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t => t.classList.remove('active'));
target.classList.add('show', 'active');
this.classList.add('active');
});
}
});
});Главная проблема: веб-панель читала SQLite вместо PostgreSQL
Самый болезненный баг. Когда мы перевели продакшн на PostgreSQL, воркеры начали писать данные туда. Но функция _get_db() в app.py по-прежнему создавала SQLite-подключение:
# Было — всегда SQLite
def _get_db():
return Database('data/tg_army.db')Поэтому:
- Дашборд показывал данные из пустого SQLite (0 аккаунтов)
- Сохранение шаблонов писало в SQLite, который воркеры не читали
- Изменения в настройках пропадали
Переключили _get_db() на PgDatabase, которую разделяет с оркестратором:
# Стало — shared PostgreSQL
async def _get_db() -> PgDatabase:
return await get_shared_pg_database()Но это потянуло за собой цепочку несовместимостей.
Проблема 1: плейсхолдеры SQL
SQLite использует ? в параметризованных запросах, asyncpg — $1, $2 и т.д.:
# SQLite
await db.execute("UPDATE accounts SET status=? WHERE id=?", (status, account_id))
# asyncpg / PostgreSQL
await db.execute("UPDATE accounts SET status=$1 WHERE id=$2", status, account_id)Прошлись по всем raw SQL-запросам в app.py и обновили синтаксис.
Проблема 2: datetime как объект вместо строки
SQLite возвращал даты как строки ("2026-03-15T12:30:00"), PostgreSQL через asyncpg — как нативные datetime-объекты Python.
Во всех шаблонах Jinja2 были такие конструкции:
<!-- Работало с SQLite -->
{{ account.created_at[:16] }}
{{ account.last_active[11:16] }}С datetime-объектом срезы строк падали с TypeError. Заменили на Jinja2-фильтры:
<!-- Работает с PostgreSQL -->
{{ account.created_at | datetimeformat }}
{{ account.last_active.strftime('%H:%M') if account.last_active else '—' }}Аналогично в app.py убрали datetime.fromisoformat() там, где данные уже приходят как datetime:
# До
last_active = datetime.fromisoformat(account['last_active'])
# После
last_active = account['last_active'] # уже datetimePhase 0 и Phase 1: мульти-воркерная архитектура
Параллельно с фиксом дашборда готовили фундамент для следующего этапа — запуск нескольких воркеров одновременно. Для координации между ними нужны были Redis и единая схема в PostgreSQL.
10 задач, ~3100 строк нового кода, 19 файлов. Работа разбита на 4 батча с промежуточными чекпоинтами.
Batch 1: Redis, PG Schema, зависимости
Запустили Redis в Docker на порту 6380 (6379 уже был занят другим контейнером):
# docker-compose.yml
redis-tg-army:
image: redis:7-alpine
ports:
- "6380:6379"
restart: unless-stoppedСоздали схему tg_army в PostgreSQL — 15 таблиц с индексами и правами. Добавили зависимости:
asyncpg==0.31.0
redis==7.2.0
Batch 2: PgDatabase, миграция, RedisClient
Самая объёмная задача — PgDatabase: полный аналог SQLite Database с 47+ методами, но на asyncpg. Все методы возвращают те же структуры данных, что и SQLite-версия, чтобы не ломать совместимость с воркерами.
Миграция данных из SQLite в PostgreSQL:
12 таблиц перенесено
Row counts совпадают
57 тестов, 0 провалов
RedisClient — обёртка для координации воркеров: блокировки аккаунтов, очереди задач, pub/sub для оркестратора.
Результат
После всех правок система работает как единое целое:
- Дашборд отображает реальные данные из PostgreSQL: все 7 статусов с правильными цветами, мёртвые аккаунты визуально отделены
- Settings сохраняет изменения — они сразу подхватываются воркерами
- Шаг 5 воронки отправляет кейсы одним альбомом вместо спама отдельными сообщениями
- Bootstrap грузится локально — никакой зависимости от CDN
- 57 тестов проходят без единого провала
- Архитектурная база для мульти-воркеров готова: Redis, PG-схема, PgDatabase
Выводы
Главный урок этого проекта — несоответствие хранилищ данных убивает продукт тихо. Веб-панель и воркеры жили в параллельных реальностях: одни писали в PostgreSQL, другие читали из SQLite. Никакой явной ошибки — просто изменения пропадали. Если вы переходите с одной СУБД на другую, убедитесь, что все компоненты системы переключены одновременно.
Второй урок — CDN-зависимости в продакшне это риск, особенно если ваши пользователи или серверы находятся в регионах с непредсказуемой фильтрацией трафика. Bootstrap с jsdelivr.net — популярное решение, которое при блокировке CDN ломает весь интерактив на странице без единой ошибки в консоли. Держите критичные JS/CSS-ресурсы локально или хотя бы добавляйте fallback.
Третий урок — тип данных из базы имеет значение. Переходя с SQLite на PostgreSQL через asyncpg, нужно помнить: asyncpg возвращает нативные Python-типы (datetime, Decimal, UUID), а не строки. Весь код, который делал срезы строк [:16] или вызывал fromisoformat(), сломался. Хорошая практика — использовать Jinja2-фильтры для форматирования дат в шаблонах и не полагаться на строковые манипуляции.
Четвёртый урон — батчевая разработка с чекпоинтами работает. Разбить 10 задач на 4 батча с явными зависимостями между ними позволило запускать независимые задачи параллельно и проверять результат на каждом этапе, а не в самом конце. Когда задача Task 4 (1200 строк PgDatabase) заняла больше времени — остальные задачи батча уже были готовы. Итого: 3100 строк нового кода без регрессий в существующих тестах.
Полный стек проекта: Flask + asyncpg + PostgreSQL + Redis + Telethon для работы с Telegram API.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.
Связанный проект
TG Army →