How to moderate applications in the Telegram bot: queue, statuses, buttons
Main chat
A chat for vibe coders: news, guides, live cases, marketplace, and finding executors.
One of the most frequent freelance orders is a bot that accepts applications from users and sends them to the administrator for verification. Course, service, publication in the catalog, joining the community - everywhere you need the same mechanic: the application came, the moderator looked, clicked "accept" or "reject", the user received the answer.
This problem is ideally solved through vibcoding: the architecture is standard, Claude Code or Cursor know it perfectly, it remains only to correctly set the task and check the result.
In this article, we analyze the complete mechanics: how the queue is arranged, how statuses work, how to make buttons for the admin and how the whole system is held together.
How the system works: the whole scheme
Before you write code, you need to understand the flow of data. This is what happens from the time of application to publication:
User Bot Administrator
Objective
← /start → Completed the Form
► ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ► ♥
Save it to the BD versus
versus status = 'pending' versus
Objective
I sent you an application ─ ─ ─ ─ ─ ─ ►
versus + buttons [ Adopt]
versus [ Reject]
← ← [Read More]
Objective
versus versus pressing [ Take]
♥ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ .
Objective
versus status = 'approved' versus
► Perform the action versus
“Your application is approved!” (Publishing, access...) ♥
← ― ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ Object
Three participants, three areas of responsibility:
- **User ** - Fills out the form and waits for a response
- Database - Stores applications and their statuses
- Administrator - sees applications, makes decisions with buttons
Status of applications
It's the foundation of the system. Each application is always in exactly the same status:
| Статус | Значение | Что происходит |
|---|---|---|
pending |
Ожидает проверки | Заявка пришла, лежит в очереди |
approved |
Одобрена | Выполнено действие, пользователь уведомлён |
rejected |
Отклонена | Пользователь получил отказ с причиной |
revision |
На доработке | Пользователя попросили изменить заявку |
A simple rule of thumb: Never delete applications from DB. Just change your status. This gives you a complete history and the opportunity to return to any application.
Database: minimum structure
CREATE TABLE applications
id INTEGER PRIMARY KEY AUTOINCREMENT
user id BIGINT NOT NULL - Telegram user ID
username TEXT, -- @nickname (if any)
first name TEXT, -- Name
Application data (adapt to your case)
Title TEXT NOT NULL - Title / Topic
description TEXT - Description
Contact TEXT - Contact (website, email...)
- Moderation system.
status TEXT DEFAULT 'pending', - pending / approved / rejected / revision
admin comment TEXT - Administrator comment
- Telegram-specificity
admin message id INTEGER, - Message ID with the administrator (for editing)
- Time stamps.
created at TIMESTAMP DEFAULT CURRENT TIMESTAMP,
updated at TIMESTAMP DEFAULT CURRENT TIMESTAMP
);
admin_message_id is an important field. When the moderator presses the button, you need to update the message in his chat (remove the buttons, show the status). You need to know the ID of that message.
Python implementation (aiogram 3)
Aiogram is the most popular framework for Telegram bots in Python. It is well known Claude Code and Cursor, so it is the easiest to use it.
The configuration
#config. py
import
BOT TOKEN = os.environ['BOT TOKEN']
ADMIN CHAT ID = int(os.environ['ADMIN CHAT ID']) #ID of a chat or admin group
# For a group of moderators, use a negative group ID
# For one administrator - his personal Telegram ID
Initialization
# 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: step-by-step application collection
FSM (Finite State Machine) is a step-by-step dialogue mechanism. The user takes steps one by one:
# 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: подтверждение
Processors of the user part
# 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("Заявка отправлена!")
Sending an application to the administrator with buttons
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()
Administrator button handlers
# 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"✅ Правки запрошены, пользователь уведомлён.")
Viewing the queue by the administrator
The /queue team shows all applications waiting for:
@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 team - Status statistics:
@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)
Option on Node.js (Grammy)
If you use JavaScript, here is a similar architecture on the 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();
Nuances that are often forgotten
Verification of administrator rights
Anyone can send a message with /queue text to the bot, or even try to construct the right callback. Always check:
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
# ... остальная логика
Button limit and callback data length
Telegram limits callback_data to a maximum of 64 bytes admin_approve:12345 is 20 bytes, all right. But don't put long lines in there.
One row (row) - maximum 8 buttons. More than three buttons in a row is already unreadable. Optimal: 2-3 buttons in the first row, additional ones in a separate row.
What to do if the user has blocked the bot
When sending a notification, the user may receive a 403 Forbidden error, which means that the user has blocked the bot. Process explicitly:
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}")
Several administrators
If there are several moderators, use the Telegram group:
# 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
Any member of the group can press the button - it is convenient for team moderation.
Publication after approval
After status = 'approved', your business logic starts. What could it be
Addition to catalogue:
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']}"
)
** Issuance of access:**
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 часа."
)
Recording to external system:
async def publish application(app id: int):
app = get application(app id)
# Send to CRM, Notion, Airtable, Google Sheets...
Wait to send to crm(app)
Vibcoding proms
If you want to collect it through Claude Code or Cursor, here are the finished tasks:
** Basic system from scratch:**
Create a Telegram bot in Python (aiogram 3) with order moderation.
The user submits an application via /apply - 3 steps: name, description, contact.
The application is saved in SQLite with the following fields: user id, title, description, contact, status.
The bot sends the application to the ADMIN CHAT ID chat with buttons: Accept/Reject/Edit.
When you click the status is updated, the user receives a notification.
When rejected or edited, the bot asks for a reason/comment.
Configuration via .env: BOT TOKEN, ADMIN CHAT ID.
Add administrator commands:
Add to the existing bot commands for the administrator:
/queue – list of all applications with pending status
/stats - number of applications by status n
/app {id} - show a specific application with control buttons
Check that the commands are executed only by the user with ADMIN CHAT ID.
Add publication to the channel:
After approval of the application, add the publication in the Telegram channel CHANNEL ID.
Post format: name, description, contact.
After publication, save the ID of the posted message in the channel message id field.
Add the "Delete from the channel" button to the administrator's message after publication.
Outcome
The application moderation system is a standard pattern that is collected from several simple blocks: FSM for data collection, inline buttons for administrator actions, statuses in the database, notifications to the user.
Basic principles:
- Applications are never deleted, they only change status
- Administrator buttons carry
app_idincallback_data - Rights are always checked on the server, not only in the interface
- Error handling when notifying the user (could block the bot)
This is a task that Claude Code collects in one good propt — try to set the task clearly, and get a working prototype in minutes.