Как сделать модерацию заявок в Telegram-боте: очередь, статусы, кнопки
Основной чат
Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.
Один из самых частых заказов на фрилансе — бот, который принимает заявки от пользователей и отправляет их администратору на проверку. Курс, услуга, публикация в каталоге, вступление в сообщество — везде нужна одна и та же механика: пришла заявка, модератор посмотрел, нажал «принять» или «отклонить», пользователь получил ответ.
Эта задача идеально решается через вайбкодинг: архитектура стандартная, Claude Code или Cursor знают её отлично, остаётся только грамотно поставить задачу и проверить результат.
В этой статье разбираем полную механику: как устроена очередь, как работают статусы, как сделать кнопки для админа и как вся система держится вместе.
Как работает система: схема целиком
Прежде чем писать код — нужно понять поток данных. Вот что происходит от момента заявки до публикации:
Пользователь Бот Администратор
│ │ │
│ /start → заполнил форму │ │
│ ────────────────────────►│ │
│ │ Сохранил в БД │
│ │ status = 'pending' │
│ │ │
│ │ Переслал заявку ─────────►│
│ │ + кнопки [✅ Принять] │
│ │ [❌ Отклонить] │
│ │ [✏️ Правки] │
│ │ │
│ │ Нажал [✅ Принять]│
│ │◄──────────────────────────│
│ │ │
│ │ status = 'approved' │
│ │ Выполнить действие │
│ «Ваша заявка одобрена!» │ (публикация, доступ...) │
│◄─────────────────────────│ │
Три участника, три зоны ответственности:
- Пользователь — заполняет форму и ждёт ответа
- База данных — хранит заявки и их статусы
- Администратор — видит заявки, принимает решения кнопками
Статусы заявок
Это фундамент системы. Каждая заявка всегда находится ровно в одном статусе:
| Статус | Значение | Что происходит |
|---|---|---|
pending |
Ожидает проверки | Заявка пришла, лежит в очереди |
approved |
Одобрена | Выполнено действие, пользователь уведомлён |
rejected |
Отклонена | Пользователь получил отказ с причиной |
revision |
На доработке | Пользователя попросили изменить заявку |
Простое правило: никогда не удаляйте заявки из БД. Только меняйте статус. Это даёт полную историю и возможность вернуться к любой заявке.
База данных: минимальная структура
CREATE TABLE applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id BIGINT NOT NULL, -- Telegram ID пользователя
username TEXT, -- @никнейм (если есть)
first_name TEXT, -- Имя
-- Данные заявки (адаптируйте под свой кейс)
title TEXT NOT NULL, -- Название / тема
description TEXT, -- Описание
contact TEXT, -- Контакт (сайт, email...)
-- Система модерации
status TEXT DEFAULT 'pending', -- pending / approved / rejected / revision
admin_comment TEXT, -- Комментарий администратора
-- Telegram-специфика
admin_message_id INTEGER, -- ID сообщения у администратора (для редактирования)
-- Временные метки
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
admin_message_id — важное поле. Когда модератор нажимает кнопку, нужно обновить сообщение в его чате (убрать кнопки, показать статус). Для этого нужно знать ID того сообщения.
Реализация на Python (aiogram 3)
Aiogram — самый популярный фреймворк для Telegram-ботов на Python. Его хорошо знают Claude Code и Cursor, поэтому вайбкодить на нём проще всего.
Конфигурация
# config.py
import os
BOT_TOKEN = os.environ['BOT_TOKEN']
ADMIN_CHAT_ID = int(os.environ['ADMIN_CHAT_ID']) # ID чата или группы администраторов
# Для группы модераторов — используйте отрицательный ID группы
# Для одного администратора — его личный Telegram ID
Инициализация
# bot.py
import asyncio
import sqlite3
from aiogram import Bot, Dispatcher, F
from aiogram.types import (
Message, CallbackQuery,
InlineKeyboardMarkup, InlineKeyboardButton
)
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from config import BOT_TOKEN, ADMIN_CHAT_ID
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
# Инициализация БД
def init_db():
conn = sqlite3.connect('applications.db')
conn.execute('''
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id BIGINT NOT NULL,
username TEXT,
first_name TEXT,
title TEXT NOT NULL,
description TEXT,
contact TEXT,
status TEXT DEFAULT 'pending',
admin_comment TEXT,
admin_message_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
FSM: пошаговый сбор заявки
FSM (Finite State Machine) — механизм для пошаговых диалогов. Пользователь проходит шаги один за другим:
# states.py
from aiogram.fsm.state import State, StatesGroup
class ApplicationForm(StatesGroup):
waiting_title = State() # Шаг 1: название
waiting_description = State() # Шаг 2: описание
waiting_contact = State() # Шаг 3: контакт
waiting_confirm = State() # Шаг 4: подтверждение
Обработчики пользовательской части
# handlers/user.py
@dp.message(Command('start'))
async def cmd_start(message: Message):
await message.answer(
"👋 Привет! Это бот для подачи заявок.\n\n"
"Нажмите /apply чтобы подать заявку."
)
@dp.message(Command('apply'))
async def cmd_apply(message: Message, state: FSMContext):
await state.set_state(ApplicationForm.waiting_title)
await message.answer(
"📝 Начнём заполнение заявки.\n\n"
"<b>Шаг 1/3.</b> Введите название вашего проекта:"
)
@dp.message(ApplicationForm.waiting_title)
async def process_title(message: Message, state: FSMContext):
if len(message.text) < 3:
await message.answer("Название слишком короткое. Попробуйте ещё раз:")
return
await state.update_data(title=message.text)
await state.set_state(ApplicationForm.waiting_description)
await message.answer(
"<b>Шаг 2/3.</b> Опишите проект подробнее:\n"
"(что делает, для кого, на каком этапе)"
)
@dp.message(ApplicationForm.waiting_description)
async def process_description(message: Message, state: FSMContext):
await state.update_data(description=message.text)
await state.set_state(ApplicationForm.waiting_contact)
await message.answer(
"<b>Шаг 3/3.</b> Оставьте контакт для связи:\n"
"(сайт, email, @никнейм, номер телефона)"
)
@dp.message(ApplicationForm.waiting_contact)
async def process_contact(message: Message, state: FSMContext):
await state.update_data(contact=message.text)
data = await state.get_data()
# Показываем итог и просим подтвердить
await state.set_state(ApplicationForm.waiting_confirm)
confirm_kb = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Отправить", callback_data="submit_confirm"),
InlineKeyboardButton(text="🔄 Начать заново", callback_data="submit_cancel"),
]
])
await message.answer(
f"📋 <b>Проверьте заявку:</b>\n\n"
f"<b>Название:</b> {data['title']}\n"
f"<b>Описание:</b> {data['description']}\n"
f"<b>Контакт:</b> {data['contact']}\n\n"
"Всё верно?",
reply_markup=confirm_kb
)
@dp.callback_query(F.data == "submit_cancel")
async def cancel_submission(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("Заявка отменена. Нажмите /apply чтобы начать заново.")
await callback.answer()
@dp.callback_query(F.data == "submit_confirm")
async def confirm_submission(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
user = callback.from_user
# Сохраняем в БД
conn = sqlite3.connect('applications.db')
cursor = conn.execute(
'''INSERT INTO applications (user_id, username, first_name, title, description, contact)
VALUES (?, ?, ?, ?, ?, ?)''',
(user.id, user.username, user.first_name,
data['title'], data['description'], data['contact'])
)
app_id = cursor.lastrowid
conn.commit()
conn.close()
# Отправляем администратору
await send_to_admin(app_id, user, data)
# Подтверждаем пользователю
await callback.message.edit_text(
f"✅ <b>Заявка #{app_id} отправлена!</b>\n\n"
"Мы рассмотрим её в течение 24 часов и сообщим результат."
)
await callback.answer("Заявка отправлена!")
Отправка заявки администратору с кнопками
async def send_to_admin(app_id: int, user, data: dict):
"""Отправляет заявку в чат администратора с кнопками модерации."""
username_str = f"@{user.username}" if user.username else "нет никнейма"
text = (
f"🆕 <b>Новая заявка #{app_id}</b>\n\n"
f"👤 <b>От:</b> {user.first_name} ({username_str})\n"
f"🔗 <b>Ссылка:</b> tg://user?id={user.id}\n\n"
f"📌 <b>Название:</b>\n{data['title']}\n\n"
f"📝 <b>Описание:</b>\n{data['description']}\n\n"
f"📬 <b>Контакт:</b>\n{data['contact']}\n\n"
f"⏰ Статус: <i>ожидает проверки</i>"
)
# Кнопки для модератора
admin_kb = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Принять", callback_data=f"admin_approve:{app_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"admin_reject:{app_id}"),
],
[
InlineKeyboardButton(text="✏️ Запросить правки", callback_data=f"admin_revision:{app_id}"),
]
])
msg = await bot.send_message(ADMIN_CHAT_ID, text, reply_markup=admin_kb)
# Сохраняем ID сообщения у администратора — понадобится для обновления
conn = sqlite3.connect('applications.db')
conn.execute(
'UPDATE applications SET admin_message_id = ? WHERE id = ?',
(msg.message_id, app_id)
)
conn.commit()
conn.close()
Обработчики кнопок администратора
# handlers/admin.py
@dp.callback_query(F.data.startswith("admin_approve:"))
async def admin_approve(callback: CallbackQuery):
app_id = int(callback.data.split(":")[1])
# Обновляем статус в БД
conn = sqlite3.connect('applications.db')
app = conn.execute(
'SELECT user_id, title FROM applications WHERE id = ?', (app_id,)
).fetchone()
conn.execute(
'UPDATE applications SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
('approved', app_id)
)
conn.commit()
conn.close()
if not app:
await callback.answer("Заявка не найдена!", show_alert=True)
return
user_id, title = app
# Обновляем сообщение у администратора (убираем кнопки, добавляем статус)
await callback.message.edit_text(
callback.message.text + f"\n\n✅ <b>ОДОБРЕНО</b> — {callback.from_user.first_name}",
reply_markup=None
)
# Уведомляем пользователя
await bot.send_message(
user_id,
f"🎉 <b>Ваша заявка одобрена!</b>\n\n"
f"<b>«{title}»</b> прошла модерацию и будет опубликована.\n\n"
"Спасибо за участие!"
)
# TODO: здесь ваша бизнес-логика после одобрения
# Например: добавить в каталог, выдать доступ, опубликовать...
await publish_application(app_id)
await callback.answer("Заявка одобрена!")
@dp.callback_query(F.data.startswith("admin_reject:"))
async def admin_reject(callback: CallbackQuery, state: FSMContext):
app_id = int(callback.data.split(":")[1])
# Просим ввести причину отказа
await state.update_data(rejecting_app_id=app_id, admin_msg_id=callback.message.message_id)
await state.set_state(AdminStates.waiting_reject_reason)
await callback.message.answer(
f"❌ Отклонение заявки #{app_id}\n\n"
"Введите причину отказа (будет отправлена пользователю):"
)
await callback.answer()
@dp.callback_query(F.data.startswith("admin_revision:"))
async def admin_revision(callback: CallbackQuery, state: FSMContext):
app_id = int(callback.data.split(":")[1])
await state.update_data(revision_app_id=app_id)
await state.set_state(AdminStates.waiting_revision_comment)
await callback.message.answer(
f"✏️ Запрос правок для заявки #{app_id}\n\n"
"Что нужно исправить? (сообщение будет отправлено автору заявки):"
)
await callback.answer()
# FSM для ввода причины отказа
class AdminStates(StatesGroup):
waiting_reject_reason = State()
waiting_revision_comment = State()
@dp.message(AdminStates.waiting_reject_reason)
async def process_reject_reason(message: Message, state: FSMContext):
data = await state.get_data()
app_id = data['rejecting_app_id']
reason = message.text
await state.clear()
conn = sqlite3.connect('applications.db')
app = conn.execute(
'SELECT user_id, title, admin_message_id FROM applications WHERE id = ?', (app_id,)
).fetchone()
conn.execute(
'''UPDATE applications
SET status = 'rejected', admin_comment = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?''',
(reason, app_id)
)
conn.commit()
conn.close()
user_id, title, admin_msg_id = app
# Обновляем сообщение у администратора
await bot.edit_message_text(
chat_id=ADMIN_CHAT_ID,
message_id=admin_msg_id,
text=f"❌ Заявка #{app_id} отклонена\n\nПричина: {reason}",
reply_markup=None
)
# Уведомляем пользователя
await bot.send_message(
user_id,
f"😔 <b>Заявка не прошла модерацию</b>\n\n"
f"<b>«{title}»</b>\n\n"
f"<b>Причина:</b> {reason}\n\n"
"Вы можете подать новую заявку через /apply"
)
await message.answer(f"✅ Заявка #{app_id} отклонена, пользователь уведомлён.")
@dp.message(AdminStates.waiting_revision_comment)
async def process_revision_comment(message: Message, state: FSMContext):
data = await state.get_data()
app_id = data['revision_app_id']
comment = message.text
await state.clear()
conn = sqlite3.connect('applications.db')
app = conn.execute(
'SELECT user_id, title, admin_message_id FROM applications WHERE id = ?', (app_id,)
).fetchone()
conn.execute(
'''UPDATE applications
SET status = 'revision', admin_comment = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?''',
(comment, app_id)
)
conn.commit()
conn.close()
user_id, title, admin_msg_id = app
await bot.edit_message_text(
chat_id=ADMIN_CHAT_ID,
message_id=admin_msg_id,
text=f"✏️ Заявка #{app_id} отправлена на доработку\n\nКомментарий: {comment}",
reply_markup=None
)
await bot.send_message(
user_id,
f"✏️ <b>Нужны правки в заявке</b>\n\n"
f"<b>«{title}»</b>\n\n"
f"<b>Комментарий:</b> {comment}\n\n"
"Подайте исправленную заявку через /apply"
)
await message.answer(f"✅ Правки запрошены, пользователь уведомлён.")
Просмотр очереди администратором
Команда /queue показывает все заявки в ожидании:
@dp.message(Command('queue'))
async def cmd_queue(message: Message):
# Только для администратора
if message.chat.id != ADMIN_CHAT_ID and message.from_user.id != ADMIN_CHAT_ID:
return
conn = sqlite3.connect('applications.db')
pending = conn.execute(
'''SELECT id, first_name, username, title, created_at
FROM applications
WHERE status = 'pending'
ORDER BY created_at ASC''',
).fetchall()
conn.close()
if not pending:
await message.answer("✅ Очередь пуста, все заявки обработаны.")
return
text = f"📋 <b>Заявок в очереди: {len(pending)}</b>\n\n"
for app in pending:
app_id, first_name, username, title, created_at = app
username_str = f"@{username}" if username else first_name
# Форматируем дату
date_str = created_at[:16].replace('T', ' ')
text += f"#{app_id} · {username_str} · <i>{date_str}</i>\n{title}\n\n"
await message.answer(text)
Команда /stats — статистика по статусам:
@dp.message(Command('stats'))
async def cmd_stats(message: Message):
if message.chat.id != ADMIN_CHAT_ID and message.from_user.id != ADMIN_CHAT_ID:
return
conn = sqlite3.connect('applications.db')
stats = conn.execute(
'''SELECT status, COUNT(*) as cnt
FROM applications
GROUP BY status'''
).fetchall()
conn.close()
labels = {
'pending': '⏳ Ожидают',
'approved': '✅ Одобрено',
'rejected': '❌ Отклонено',
'revision': '✏️ На доработке',
}
total = sum(row[1] for row in stats)
text = f"📊 <b>Статистика заявок (всего: {total})</b>\n\n"
for status, count in stats:
label = labels.get(status, status)
text += f"{label}: {count}\n"
await message.answer(text)
Вариант на Node.js (Grammy)
Если вы вайбкодите на JavaScript — вот аналогичная архитектура на Grammy:
// bot.js
import { Bot, InlineKeyboard, session } from 'grammy';
import { conversations, createConversation } from '@grammyjs/conversations';
import Database from 'better-sqlite3';
const bot = new Bot(process.env.BOT_TOKEN);
const ADMIN_CHAT_ID = Number(process.env.ADMIN_CHAT_ID);
const db = new Database('applications.db');
// Инициализация БД
db.exec(`
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT,
first_name TEXT,
title TEXT NOT NULL,
description TEXT,
contact TEXT,
status TEXT DEFAULT 'pending',
admin_comment TEXT,
admin_message_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Conversation: пошаговый сбор заявки
async function applyConversation(conversation, ctx) {
await ctx.reply('📝 <b>Шаг 1/3.</b> Введите название проекта:');
const titleCtx = await conversation.wait();
const title = titleCtx.message?.text;
await ctx.reply('📝 <b>Шаг 2/3.</b> Опишите проект:');
const descCtx = await conversation.wait();
const description = descCtx.message?.text;
await ctx.reply('📝 <b>Шаг 3/3.</b> Контакт для связи:');
const contactCtx = await conversation.wait();
const contact = contactCtx.message?.text;
// Подтверждение
const keyboard = new InlineKeyboard()
.text('✅ Отправить', 'submit_confirm')
.text('🔄 Начать заново', 'submit_cancel');
await ctx.reply(
`📋 <b>Проверьте заявку:</b>\n\n`+
`<b>Название:</b> ${title}\n`+
`<b>Описание:</b> ${description}\n`+
`<b>Контакт:</b> ${contact}`,
{ reply_markup: keyboard }
);
const confirmCtx = await conversation.waitForCallbackQuery(['submit_confirm', 'submit_cancel']);
if (confirmCtx.callbackQuery.data === 'submit_cancel') {
await confirmCtx.editMessageText('Заявка отменена. Нажмите /apply чтобы начать заново.');
return;
}
// Сохраняем в БД
const user = ctx.from;
const result = db.prepare(
`INSERT INTO applications (user_id, username, first_name, title, description, contact)
VALUES (?, ?, ?, ?, ?, ?)`
).run(user.id, user.username, user.first_name, title, description, contact);
const appId = result.lastInsertRowid;
// Отправляем администратору
const adminKeyboard = new InlineKeyboard()
.text('✅ Принять', `admin_approve:${appId}`)
.text('❌ Отклонить', `admin_reject:${appId}`)
.row()
.text('✏️ Запросить правки', `admin_revision:${appId}`);
const adminMsg = await bot.api.sendMessage(
ADMIN_CHAT_ID,
`🆕 <b>Новая заявка #${appId}</b>\n\n`+
`👤 <b>От:</b> ${user.first_name} ${user.username ? '@'+user.username : ''}\n\n`+
`📌 <b>Название:</b> ${title}\n\n`+
`📝 <b>Описание:</b> ${description}\n\n`+
`📬 <b>Контакт:</b> ${contact}`,
{ reply_markup: adminKeyboard }
);
// Сохраняем ID сообщения администратора
db.prepare('UPDATE applications SET admin_message_id = ? WHERE id = ?')
.run(adminMsg.message_id, appId);
await confirmCtx.editMessageText(
`✅ <b>Заявка #${appId} отправлена!</b>\n\nОжидайте ответа в течение 24 часов.`
);
}
// Подключаем conversations
bot.use(session({ initial: () => ({}) }));
bot.use(conversations());
bot.use(createConversation(applyConversation, 'apply'));
// Команды
bot.command('apply', ctx => ctx.conversation.enter('apply'));
// Обработчики кнопок администратора
bot.callbackQuery(/^admin_approve:(\d+)$/, async ctx => {
const appId = parseInt(ctx.match[1]);
const app = db.prepare('SELECT * FROM applications WHERE id = ?').get(appId);
db.prepare(`UPDATE applications SET status = 'approved' WHERE id = ?`).run(appId);
await ctx.editMessageText(
ctx.callbackQuery.message.text + `\n\n✅ ОДОБРЕНО — ${ctx.from.first_name}`,
{ reply_markup: undefined }
);
await bot.api.sendMessage(
app.user_id,
`🎉 <b>Ваша заявка одобрена!</b>\n\n«${app.title}» прошла модерацию.`
);
await ctx.answerCallbackQuery('Заявка одобрена!');
});
bot.start();
Нюансы которые часто забывают
Проверка прав администратора
Любой может отправить боту сообщение с текстом /queue или даже попытаться сконструировать нужный callback. Всегда проверяйте:
def is_admin(user_id: int) -> bool:
return user_id == ADMIN_CHAT_ID
# Или: return user_id in ADMIN_IDS (список нескольких)
@dp.callback_query(F.data.startswith("admin_"))
async def admin_action(callback: CallbackQuery):
if not is_admin(callback.from_user.id):
await callback.answer("⛔ Нет прав", show_alert=True)
return
# ... остальная логика
Лимит кнопок и длина callback_data
Telegram ограничивает callback_data — максимум 64 байта. admin_approve:12345 — это 20 байт, всё в порядке. Но не кладите туда длинные строки.
Один ряд (row) — максимум 8 кнопок. Больше трёх кнопок в ряд — уже нечитаемо. Оптимально: 2-3 кнопки в первом ряду, дополнительные — отдельным рядом.
Что делать если пользователь заблокировал бота
При отправке уведомления пользователю может прийти ошибка 403 Forbidden — это значит, пользователь заблокировал бота. Обрабатывайте явно:
try:
await bot.send_message(user_id, text)
except TelegramForbiddenError:
# Пользователь заблокировал бота — просто логируем
print(f"User {user_id} blocked the bot")
except TelegramBadRequest as e:
print(f"Bad request: {e}")
Несколько администраторов
Если модераторов несколько — используйте группу Telegram:
# ADMIN_CHAT_ID = -100123456789 (отрицательный ID группы)
# Проверка: пользователь является членом группы
async def is_admin(user_id: int) -> bool:
try:
member = await bot.get_chat_member(ADMIN_CHAT_ID, user_id)
return member.status in ('administrator', 'creator', 'member')
except:
return False
Кнопку нажать сможет любой участник группы — это удобно для командной модерации.
Публикация после одобрения
После status = 'approved' запускается ваша бизнес-логика. Что это может быть:
Добавление в каталог:
async def publish_application(app_id: int):
app = get_application(app_id)
# Публикуем в канал
await bot.send_message(
CHANNEL_ID,
f"📌 <b>{app['title']}</b>\n\n"
f"{app['description']}\n\n"
f"📬 Контакт: {app['contact']}"
)
Выдача доступа:
async def publish_application(app_id: int):
app = get_application(app_id)
# Создаём invite link в закрытую группу
link = await bot.create_chat_invite_link(
PRIVATE_GROUP_ID,
member_limit=1,
expire_date=datetime.now() + timedelta(days=1)
)
await bot.send_message(
app['user_id'],
f"🔗 Ваша персональная ссылка для вступления:\n{link.invite_link}\n\n"
"Действует 24 часа."
)
Запись в внешнюю систему:
async def publish_application(app_id: int):
app = get_application(app_id)
# Отправить в CRM, Notion, Airtable, Google Sheets...
await send_to_crm(app)
Промты для вайбкодинга
Если хотите собрать это через Claude Code или Cursor — вот готовые задачи:
Базовая система с нуля:
Создай Telegram-бота на Python (aiogram 3) с модерацией заявок.
Пользователь подаёт заявку через /apply — 3 шага: название, описание, контакт.
Заявка сохраняется в SQLite с полями: user_id, title, description, contact, status.
Бот пересылает заявку в чат ADMIN_CHAT_ID с кнопками: Принять / Отклонить / Правки.
При нажатии — статус обновляется, пользователь получает уведомление.
При отклонении или правках — бот просит ввести причину/комментарий.
Конфигурация через .env: BOT_TOKEN, ADMIN_CHAT_ID.
Добавить команды администратора:
Добавь в существующего бота команды для администратора:
/queue — список всех заявок со статусом pending
/stats — количество заявок по статусам
/app {id} — показать конкретную заявку с кнопками управления
Проверяй что команды выполняет только пользователь с ADMIN_CHAT_ID.
Добавить публикацию в канал:
После одобрения заявки добавь публикацию в Telegram-канал CHANNEL_ID.
Формат поста: название, описание, контакт.
После публикации — сохрани ID опубликованного сообщения в поле channel_message_id.
Добавь кнопку "Удалить из канала" в сообщение администратора после публикации.
Итог
Система модерации заявок — стандартный паттерн, который собирается из нескольких простых блоков: FSM для сбора данных, inline-кнопки для действий администратора, статусы в базе данных, уведомления пользователю.
Главные принципы:
- Заявки никогда не удаляются — только меняют статус
- Кнопки администратора несут
app_idвcallback_data - Права всегда проверяются на сервере, не только в интерфейсе
- Обработка ошибок при уведомлении пользователя (мог заблокировать бота)
Это задача, которую Claude Code собирает за один хороший промт — попробуйте поставить задачу чётко, и получите рабочий прототип за минуты.