~/wiki / telegram-boty / telegram-bot-site-database-architecture

Как связать Telegram-бота, сайт и базу данных: архитектура бот пишет — сайт читает

Основной чат

Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.

$ cd раздел/ $ join vibe dev
Как связать Telegram-бота, сайт и базу данных: архитектура бот пишет — сайт читает - обложка

Типичный сценарий для вайбкодинг-проекта: бот принимает сообщения от пользователей — отзывы, заявки, посты в канал — а на сайте это всё отображается в виде публичной ленты. Звучит просто. На практике — три отдельных процесса, которые нужно правильно связать, чтобы данные не терялись, сайт не падал при нагрузке, а архитектура оставалась понятной через полгода.

В этой статье — разбор полной схемы: от структуры БД до реалтаймового обновления ленты на сайте. С кодом, схемами и объяснением, почему именно так, а не иначе.


Общая архитектура: три части системы

Система состоит из трёх независимых компонентов, которые общаются через общую базу данных и API.

plaintext
┌─────────────────────────────────────────────────────────┐
│                    TELEGRAM                             │
│  Пользователь → пишет боту → бот обрабатывает          │
└───────────────────────┬─────────────────────────────────┘
                        │ записывает
                        ▼
┌─────────────────────────────────────────────────────────┐
│              БОТ (отдельный процесс)                    │
│  Принимает сообщения, валидирует, пишет в PostgreSQL    │
└───────────────────────┬─────────────────────────────────┘
                        │ INSERT / UPDATE
                        ▼
┌─────────────────────────────────────────────────────────┐
│                  PostgreSQL                             │
│  posts, users, media — единственный источник истины     │
└───────────────────────┬─────────────────────────────────┘
                        │ SELECT
                        ▼
┌─────────────────────────────────────────────────────────┐
│              API-СЕРВЕР (отдельный процесс)             │
│  REST API: /api/posts, /api/feed — для сайта            │
└───────────────────────┬─────────────────────────────────┘
                        │ HTTP / SSE
                        ▼
┌─────────────────────────────────────────────────────────┐
│                    САЙТ (фронтенд)                      │
│  Публичная лента, фильтры, реалтайм-обновления          │
└─────────────────────────────────────────────────────────┘

Ключевой принцип: бот и API-сервер — это разные процессы. Не один монолит, где всё в одном файле. Разделение даёт:

  • независимый деплой и перезапуск
  • бот упал — сайт продолжает работать
  • разные права доступа к БД (бот пишет, API только читает)
  • горизонтальное масштабирование каждого отдельно

Структура базы данных

Начинаем с БД — она центральный элемент архитектуры.

sql
-- Пользователи (из Telegram)
CREATE TABLE users (
    id          SERIAL PRIMARY KEY,
    telegram_id BIGINT UNIQUE NOT NULL,
    username    VARCHAR(64),
    first_name  VARCHAR(128),
    is_banned   BOOLEAN DEFAULT FALSE,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- Посты/сообщения от бота
CREATE TABLE posts (
    id          SERIAL PRIMARY KEY,
    user_id     INTEGER REFERENCES users(id) ON DELETE SET NULL,
    text        TEXT NOT NULL,
    media_url   TEXT,              -- URL медиафайла (если есть)
    media_type  VARCHAR(16),       -- 'photo', 'video', 'document'
    status      VARCHAR(16) DEFAULT 'pending',
    -- pending → approved → published / rejected
    is_public   BOOLEAN DEFAULT FALSE,
    published_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- Индексы для быстрой публичной ленты
CREATE INDEX idx_posts_public ON posts (published_at DESC)
    WHERE is_public = TRUE AND status = 'published';

CREATE INDEX idx_posts_user ON posts (user_id, created_at DESC);

Зачем поле status

Не всё, что пишут в бота, должно сразу появляться на сайте. Типичный флоу:

plaintext
pending → (модератор одобрил) → approved → (cron/webhook) → published
                             ↘ (отклонил) → rejected

Для проектов без модерации: сразу ставим status = 'published' и is_public = TRUE при записи.


Бот: пишет в БД

Бот работает через вебхук (рекомендуется) или polling. Вебхук — Telegram сам присылает обновления на ваш HTTPS-эндпоинт; polling — бот сам опрашивает серверы Telegram каждые N секунд.

Для продакшена — только вебхук: меньше нагрузка, быстрее реакция, не расходуется лимит запросов.

Структура бота (Node.js + grammY)

bash
npm install grammy pg dotenv
javascript
// bot.js
const { Bot } = require('grammy');
const { Pool } = require('pg');
require('dotenv').config();

const bot = new Bot(process.env.BOT_TOKEN);
const db = new Pool({ connectionString: process.env.DATABASE_URL });

// Хелпер: найти или создать пользователя
async function upsertUser(telegramUser) {
    const { rows } = await db.query(`
        INSERT INTO users (telegram_id, username, first_name)
        VALUES ($1, $2, $3)
        ON CONFLICT (telegram_id) DO UPDATE
            SET username = EXCLUDED.username,
                first_name = EXCLUDED.first_name
        RETURNING id
    `, [telegramUser.id, telegramUser.username, telegramUser.first_name]);
    return rows[0].id;
}

// Обработка текстового сообщения
bot.on('message:text', async (ctx) => {
    const userId = await upsertUser(ctx.from);

    // Базовая валидация
    const text = ctx.message.text.trim();
    if (text.length < 10) {
        return ctx.reply('Сообщение слишком короткое — минимум 10 символов.');
    }
    if (text.length > 2000) {
        return ctx.reply('Сообщение слишком длинное — максимум 2000 символов.');
    }

    // Записываем в БД
    await db.query(`
        INSERT INTO posts (user_id, text, status, is_public, published_at)
        VALUES ($1, $2, 'published', TRUE, NOW())
    `, [userId, text]);

    await ctx.reply('✅ Опубликовано!');
});

// Обработка фото
bot.on('message:photo', async (ctx) => {
    const userId = await upsertUser(ctx.from);
    const caption = ctx.message.caption?.trim() || '';

    // Берём самое большое фото из массива (Telegram присылает несколько размеров)
    const photo = ctx.message.photo.at(-1);
    const fileId = photo.file_id;

    // Получаем URL файла через Telegram API
    const file = await ctx.api.getFile(fileId);
    const mediaUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${file.file_path}`;

    await db.query(`
        INSERT INTO posts (user_id, text, media_url, media_type, status, is_public, published_at)
        VALUES ($1, $2, $3, 'photo', 'published', TRUE, NOW())
    `, [userId, caption, mediaUrl]);

    await ctx.reply('✅ Фото опубликовано!');
});

// Запуск через вебхук
async function startWebhook() {
    await bot.api.setWebhook(`${process.env.WEBHOOK_URL}/bot`);
    console.log('Вебхук установлен');
}

module.exports = { bot, startWebhook };

Подключение вебхука к Express

javascript
// server.js
const express = require('express');
const { webhookCallback } = require('grammy');
const { bot, startWebhook } = require('./bot');

const app = express();
app.use(express.json());

// Эндпоинт для вебхука Telegram
app.post('/bot', webhookCallback(bot, 'express'));

app.listen(3000, async () => {
    console.log('Сервер запущен на порту 3000');
    await startWebhook();
});

API-сервер: читает из БД

API-сервер — отдельный процесс, который отдаёт данные сайту. Только чтение — SELECT. Никаких INSERT или UPDATE через публичный API (это защита от случайных дыр).

javascript
// api.js — отдельный файл/процесс
const express = require('express');
const { Pool } = require('pg');

const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });

// CORS для фронтенда
app.use((req, res, next) => {
    res.setHeader('Access-Control-Allow-Origin', process.env.FRONTEND_URL || '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET');
    next();
});

// Публичная лента: последние посты
app.get('/api/feed', async (req, res) => {
    const page = parseInt(req.query.page) || 1;
    const limit = Math.min(parseInt(req.query.limit) || 20, 100);
    const offset = (page - 1) * limit;

    const { rows } = await db.query(`
        SELECT
            p.id,
            p.text,
            p.media_url,
            p.media_type,
            p.published_at,
            u.username,
            u.first_name
        FROM posts p
        LEFT JOIN users u ON p.user_id = u.id
        WHERE p.is_public = TRUE
          AND p.status = 'published'
          AND (u.id IS NULL OR u.is_banned = FALSE)
        ORDER BY p.published_at DESC
        LIMIT $1 OFFSET $2
    `, [limit, offset]);

    // Общее количество для пагинации
    const { rows: countRows } = await db.query(`
        SELECT COUNT(*) FROM posts
        WHERE is_public = TRUE AND status = 'published'
    `);

    res.json({
        items: rows,
        total: parseInt(countRows[0].count),
        page,
        limit,
    });
});

// Один пост по ID
app.get('/api/posts/:id', async (req, res) => {
    const { rows } = await db.query(`
        SELECT p.*, u.username, u.first_name
        FROM posts p
        LEFT JOIN users u ON p.user_id = u.id
        WHERE p.id = $1 AND p.is_public = TRUE AND p.status = 'published'
    `, [req.params.id]);

    if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
    res.json(rows[0]);
});

app.listen(4000, () => console.log('API на порту 4000'));

Реалтайм: как сайт узнаёт о новых постах

Три варианта с разной сложностью.

Вариант 1: polling с фронтенда (просто, достаточно для большинства)

javascript
// frontend.js
let lastId = 0;

async function pollNewPosts() {
    const res = await fetch(`/api/feed?since_id=${lastId}`);
    const { items } = await res.json();

    if (items.length > 0) {
        lastId = items[0].id;
        items.reverse().forEach(post => prependPost(post));
    }
}

// Опрашиваем каждые 10 секунд
setInterval(pollNewPosts, 10_000);
javascript
// В API добавляем параметр since_id
app.get('/api/feed', async (req, res) => {
    const sinceId = parseInt(req.query.since_id) || 0;
    // ...
    const { rows } = await db.query(`
        SELECT ... FROM posts p
        WHERE p.is_public = TRUE
          AND p.status = 'published'
          AND ($1 = 0 OR p.id > $1)  -- только новее last_id
        ORDER BY p.published_at DESC
        LIMIT $2 OFFSET $3
    `, [sinceId, limit, offset]);
});

Вариант 2: Server-Sent Events (SSE) — реалтайм без WebSocket

SSE — это HTTP-соединение, которое сервер держит открытым и шлёт события. Проще WebSocket, подходит для потока «сервер → клиент».

javascript
// SSE-эндпоинт в API
const clients = new Set();

app.get('/api/feed/stream', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // Регистрируем клиента
    clients.add(res);

    // Убираем при отключении
    req.on('close', () => clients.delete(res));

    // Heartbeat — чтобы соединение не рвалось
    const heartbeat = setInterval(() => {
        res.write(': ping\n\n');
    }, 30_000);

    req.on('close', () => clearInterval(heartbeat));
});

// Эту функцию вызываем из бота после успешной записи в БД
function notifyNewPost(post) {
    const data = JSON.stringify(post);
    clients.forEach(client => {
        client.write(`event: new_post\ndata: ${data}\n\n`);
    });
}

module.exports = { notifyNewPost };
javascript
// bot.js — уведомляем SSE после записи
const { notifyNewPost } = require('./api');

// ... после INSERT:
const { rows } = await db.query(`
    INSERT INTO posts (...) VALUES (...) RETURNING *
`);
notifyNewPost(rows[0]);
javascript
// frontend.js — подписка на SSE
const evtSource = new EventSource('/api/feed/stream');

evtSource.addEventListener('new_post', (event) => {
    const post = JSON.parse(event.data);
    prependPost(post); // добавляем в начало ленты
});

evtSource.onerror = () => {
    // Браузер автоматически переподключается при разрыве
    console.log('SSE переподключение...');
};

Вариант 3: Redis Pub/Sub (если бот и API — разные серверы)

Если бот и API работают на разных машинах, SSE через общий объект clients не сработает — они в разных процессах. Решение — Redis как шина событий.

javascript
// Бот публикует событие в Redis после записи
const redis = require('redis');
const publisher = redis.createClient({ url: process.env.REDIS_URL });

async function publishNewPost(post) {
    await publisher.publish('new_post', JSON.stringify(post));
}
javascript
// API подписывается на Redis и рассылает через SSE
const subscriber = redis.createClient({ url: process.env.REDIS_URL });

await subscriber.subscribe('new_post', (message) => {
    const post = JSON.parse(message);
    clients.forEach(client => {
        client.write(`event: new_post\ndata: ${message}\n\n`);
    });
});

Кэширование: чтобы сайт не ддосил БД

Публичная лента — самый читаемый эндпоинт. При росте трафика каждый запрос к /api/feed — это SELECT к Postgres. Redis спасает.

javascript
const redis = require('redis');
const cache = redis.createClient({ url: process.env.REDIS_URL });

const CACHE_TTL = 30; // секунд

app.get('/api/feed', async (req, res) => {
    const cacheKey = `feed:page:${req.query.page || 1}`;

    // Проверяем кэш
    const cached = await cache.get(cacheKey);
    if (cached) {
        res.setHeader('X-Cache', 'HIT');
        return res.json(JSON.parse(cached));
    }

    // Идём в БД
    const data = await fetchFeedFromDB(req.query);

    // Кладём в кэш
    await cache.setEx(cacheKey, CACHE_TTL, JSON.stringify(data));

    res.setHeader('X-Cache', 'MISS');
    res.json(data);
});

// После записи нового поста — инвалидируем кэш
async function invalidateFeedCache() {
    const keys = await cache.keys('feed:*');
    if (keys.length) await cache.del(keys);
}

Инвалидировать кэш нужно в боте после каждой успешной записи в БД — иначе лента будет устаревшей.


Деплой: как запустить три компонента

На одном VPS — через PM2 или Docker Compose.

PM2 (проще для старта)

javascript
// ecosystem.config.js
module.exports = {
    apps: [
        {
            name: 'bot',
            script: './bot/server.js',
            env: { NODE_ENV: 'production', PORT: 3000 },
        },
        {
            name: 'api',
            script: './api/server.js',
            env: { NODE_ENV: 'production', PORT: 4000 },
        },
    ],
};
bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup  # автозапуск при перезагрузке

Docker Compose (для воспроизводимости)

yaml
# docker-compose.yml
version: '3.9'

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped

  bot:
    build: ./bot
    environment:
      DATABASE_URL: postgresql://myapp:${DB_PASSWORD}@postgres/myapp
      REDIS_URL: redis://redis:6379
      BOT_TOKEN: ${BOT_TOKEN}
      WEBHOOK_URL: ${WEBHOOK_URL}
    depends_on:
      - postgres
      - redis
    restart: unless-stopped

  api:
    build: ./api
    ports:
      - "4000:4000"
    environment:
      DATABASE_URL: postgresql://myapp:${DB_PASSWORD}@postgres/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      - postgres
      - redis
    restart: unless-stopped

volumes:
  pgdata:

Nginx как reverse proxy

nginx
server {
    server_name your-site.ru;

    # Статика фронтенда
    location / {
        root /var/www/frontend/dist;
        try_files $uri $uri/ /index.html;
    }

    # API
    location /api/ {
        proxy_pass http://localhost:4000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # SSE — важно: отключить буферизацию
    location /api/feed/stream {
        proxy_pass http://localhost:4000;
        proxy_buffering off;
        proxy_cache off;
        proxy_set_header Connection '';
        proxy_http_version 1.1;
        chunked_transfer_encoding on;
    }

    # Вебхук для бота
    location /bot {
        proxy_pass http://localhost:3000;
    }
}

Буферизацию для SSE-эндпоинта важно отключить — иначе Nginx будет копить события и отправлять пачками вместо реалтайма.


Права доступа к БД: минимум привилегий

Бот и API должны использовать разных пользователей БД с разными правами.

sql
-- Пользователь для бота: пишет
CREATE USER bot_user WITH PASSWORD 'strong_password_1';
GRANT CONNECT ON DATABASE myapp TO bot_user;
GRANT USAGE ON SCHEMA public TO bot_user;
GRANT INSERT, UPDATE ON posts TO bot_user;
GRANT INSERT, UPDATE ON users TO bot_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO bot_user;

-- Пользователь для API: только читает
CREATE USER api_user WITH PASSWORD 'strong_password_2';
GRANT CONNECT ON DATABASE myapp TO api_user;
GRANT USAGE ON SCHEMA public TO api_user;
GRANT SELECT ON posts, users TO api_user;

Если завтра найдут SQL-инъекцию в API (не дай бог) — api_user физически не сможет изменить данные.


Типичные ошибки и как их избежать

Ошибка Последствие Решение
Бот и API в одном процессе Упал бот — упал сайт Разделить на два процесса
SELECT * без WHERE is_public Приватные данные утекают в публичную ленту Явный фильтр в каждом запросе к ленте
Нет пагинации При росте базы — SELECT миллионов строк LIMIT + OFFSET или cursor-based пагинация
Один пользователь БД для всего Компрометация одного — компрометация всего Разные пользователи с минимумом прав
Нет валидации в боте Спам, огромные тексты, мусор в БД Проверка длины, базовая фильтрация до INSERT
Храним file_id вместо URL file_id Telegram устаревает Скачивать и хранить на своём S3
Нет индекса на published_at Лента медленно загружается при росте Индекс с условием WHERE is_public = TRUE

Хранение медиафайлов на своём S3

File_id из Telegram — временный. Для долгосрочного хранения нужно скачать файл и положить на свой S3 (Timeweb Cloud, Selectel, Yandex Cloud).

javascript
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const https = require('https');

const s3 = new S3Client({
    endpoint: process.env.S3_ENDPOINT,
    credentials: {
        accessKeyId: process.env.S3_KEY,
        secretAccessKey: process.env.S3_SECRET,
    },
    region: 'ru-1',
});

async function uploadTelegramPhoto(fileId, botToken) {
    // Получаем URL файла
    const file = await bot.api.getFile(fileId);
    const tgUrl = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
    const ext = file.file_path.split('.').pop();

    // Скачиваем
    const buffer = await new Promise((resolve, reject) => {
        https.get(tgUrl, (res) => {
            const chunks = [];
            res.on('data', c => chunks.push(c));
            res.on('end', () => resolve(Buffer.concat(chunks)));
            res.on('error', reject);
        });
    });

    // Загружаем в S3
    const key = `uploads/${Date.now()}.${ext}`;
    await s3.send(new PutObjectCommand({
        Bucket: process.env.S3_BUCKET,
        Key: key,
        Body: buffer,
        ContentType: `image/${ext}`,
        ACL: 'public-read',
    }));

    return `${process.env.S3_PUBLIC_URL}/${key}`;
}

Модерация: одобрение постов перед публикацией

Если нужна проверка перед публикацией — добавляем модераторский флоу прямо в боте.

javascript
const MODERATOR_CHAT_ID = process.env.MODERATOR_CHAT_ID;

// Новый пост — сначала pending
bot.on('message:text', async (ctx) => {
    const userId = await upsertUser(ctx.from);

    const { rows } = await db.query(`
        INSERT INTO posts (user_id, text, status, is_public)
        VALUES ($1, $2, 'pending', FALSE)
        RETURNING id
    `, [userId, ctx.message.text]);

    const postId = rows[0].id;

    // Шлём модератору
    await bot.api.sendMessage(MODERATOR_CHAT_ID,
        `📨 Новый пост #${postId} от @${ctx.from.username}:\n\n${ctx.message.text}`,
        {
            reply_markup: {
                inline_keyboard: [[
                    { text: '✅ Одобрить', callback_data: `approve:${postId}` },
                    { text: '❌ Отклонить', callback_data: `reject:${postId}` },
                ]],
            },
        }
    );

    await ctx.reply('Пост отправлен на модерацию.');
});

// Модератор нажимает кнопку
bot.on('callback_query:data', async (ctx) => {
    const [action, postId] = ctx.callbackQuery.data.split(':');

    if (action === 'approve') {
        await db.query(`
            UPDATE posts
            SET status = 'published', is_public = TRUE, published_at = NOW()
            WHERE id = $1
        `, [postId]);
        await ctx.editMessageText(`✅ Пост #${postId} опубликован.`);
    }

    if (action === 'reject') {
        await db.query(`
            UPDATE posts SET status = 'rejected' WHERE id = $1
        `, [postId]);
        await ctx.editMessageText(`❌ Пост #${postId} отклонён.`);
    }

    await ctx.answerCallbackQuery();
});

Структура проекта

plaintext
my-project/
├── bot/
│   ├── server.js      # Express + вебхук
│   ├── bot.js         # Логика бота
│   ├── db.js          # Pool подключений
│   └── package.json
├── api/
│   ├── server.js      # Express API
│   ├── routes/
│   │   └── feed.js    # /api/feed, /api/posts/:id
│   ├── middleware/
│   │   └── cache.js   # Redis-кэш
│   └── package.json
├── frontend/
│   ├── index.html
│   ├── feed.js        # Загрузка ленты, SSE
│   └── style.css
├── init.sql           # Схема БД
├── docker-compose.yml
├── ecosystem.config.js
└── .env

Чеклист архитектуры

plaintext
БД:
☐ Таблицы users и posts созданы с нужными индексами
☐ Индекс на published_at WHERE is_public = TRUE
☐ Два пользователя БД: bot_user (INSERT) и api_user (SELECT)
☐ Поле status для флоу pending → published / rejected

Бот:
☐ Работает через вебхук, не polling
☐ Валидация длины и содержимого до INSERT
☐ Медиафайлы загружаются на S3, не хранятся как file_id
☐ После записи: инвалидация кэша и уведомление SSE/Redis

API:
☐ Только SELECT, никаких INSERT/UPDATE в публичном API
☐ Пагинация на всех эндпоинтах
☐ Redis-кэш на ленту с инвалидацией
☐ CORS настроен на конкретный домен, не *

Реалтайм:
☐ Выбран способ: polling / SSE / Redis Pub/Sub
☐ Для SSE: Nginx с отключённой буферизацией

Деплой:
☐ Бот и API — разные процессы (PM2 или Docker)
☐ Nginx как reverse proxy
☐ HTTPS через Let's Encrypt
☐ Переменные среды в .env, не в коде

Итог

Архитектура «бот пишет — сайт читает» строится на трёх принципах: разделение процессов (бот и API независимы), единый источник истины (PostgreSQL), и минимум привилегий (разные пользователи БД с разными правами).

Начать можно с простого: один сервер, polling вместо вебхука, без кэша. Это работает. Потом — постепенно: добавить вебхук, Redis, SSE. Архитектура масштабируется без переписывания с нуля, потому что компоненты с самого начала разделены.

Самая частая ошибка — монолит, где бот, API и фронтенд — один процесс. Переписывать его потом больно. Разделение занимает те же 20 минут, но экономит дни в будущем.


Актуально для grammY 1.x, Node.js 22+, PostgreSQL 16, Redis 7. Июнь 2026.

$ cd ../ ← назад к Telegram-боты