import asyncio
import os
from dataclasses import dataclass
from datetime import datetime
import pytz
from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.default import DefaultBotProperties
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, BotCommand
from aiogram.filters import CommandStart, Command, StateFilter
from aiogram.fsm.state import StatesGroup, State
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.memory import MemoryStorage
TOKEN = os.getenv("BOT_TOKEN", "8198963636:AAG_o5luzCC6t_rEve34T9FiBC4Z6xHTFiE")
FEEDBACK_CHANNEL_ID = int(os.getenv("FEEDBACK_CHANNEL_ID", "-4931351107"))
SUBMITTED: set[int] = set()
TZ = pytz.timezone("Europe/Moscow")
def has_submitted(user_id: int) -> bool:
return user_id in SUBMITTED
def mark_submitted(user_id: int) -> None:
SUBMITTED.add(user_id)
async def guard_single_feedback(m: Message) -> bool:
u = m.from_user
if not u:
return False
if has_submitted(u.id):
await m.answer("Ты уже оставил один отзыв. К сожалению, отправить его второй раз нельзя.")
return True
return False
RATES = {
"1": {"emoji": "😞", "label": "Плохо"},
"2": {"emoji": "😐", "label": "Нормально"},
"3": {"emoji": "🤩", "label": "Отлично"},
}
def rate_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="😞", callback_data="rate:1"),
InlineKeyboardButton(text="😐", callback_data="rate:2"),
InlineKeyboardButton(text="🤩", callback_data="rate:3"),
]])
def skip_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Пропустить", callback_data="skip")]])
class FB(StatesGroup):
rating = State()
liked = State()
disliked = State()
extra = State()
@dataclass
class Draft:
rate: str | None = None
liked: str | None = None
disliked: str | None = None
extra: str | None = None
router = Router()
@router.message(CommandStart())
async def start(m: Message, state: FSMContext):
await state.clear()
if await guard_single_feedback(m):
return
await state.update_data(
draft=Draft().__dict__,
user_id=m.from_user.id if m.from_user else None,
user_name=m.from_user.full_name if m.from_user else "—",
user_username=m.from_user.username if m.from_user else None
)
await state.set_state(FB.rating)
await m.answer(
"Привет! Прошло наше занятие — удели, пожалуйста, пару минут на обратную связь.\n\n"
"Как тебе занятие? Выбери смайлик:",
reply_markup=rate_kb()
)
@router.message(Command("feedback"))
async def feedback_cmd(m: Message, state: FSMContext):
if await guard_single_feedback(m):
return
await start(m, state)
@router.callback_query(F.data.startswith("rate:"), FB.rating)
async def choose_rate(q: CallbackQuery, state: FSMContext):
_, val = q.data.split(":", 1)
data = await state.get_data()
draft = data.get("draft") or {}
draft["rate"] = val
await state.update_data(draft=draft)
await state.set_state(FB.liked)
await q.message.answer("Что тебе понравилось?")
await q.answer()
@router.message(StateFilter(FB.liked), F.text)
async def got_liked(m: Message, state: FSMContext):
try:
data = await state.get_data()
draft = data.get("draft") or {}
draft["liked"] = (m.text or "").strip()
await state.update_data(draft=draft)
await state.set_state(FB.disliked)
await m.answer("Что тебе НЕ понравилось?")
except Exception:
await m.answer("Ошибка на шаге «что понравилось». Попробуй ещё раз: /feedback")
@router.message(StateFilter(FB.disliked), F.text)
async def got_disliked(m: Message, state: FSMContext):
try:
data = await state.get_data()
draft = data.get("draft") or {}
draft["disliked"] = (m.text or "").strip()
await state.update_data(draft=draft)
await state.set_state(FB.extra)
await m.answer("Если остались мысли — напиши. Это необязательно.", reply_markup=skip_kb())
except Exception:
await m.answer("Ошибка на шаге «что не понравилось». Попробуй ещё раз: /feedback")
@router.callback_query(F.data == "skip", FB.extra)
async def extra_skip(q: CallbackQuery, state: FSMContext):
data = await state.get_data()
draft = data.get("draft") or {}
draft["extra"] = ""
await state.update_data(draft=draft)
await finalize(q.message, state)
await q.answer()
@router.message(StateFilter(FB.extra), F.text)
async def got_extra(m: Message, state: FSMContext):
data = await state.get_data()
draft = data.get("draft") or {}
draft["extra"] = (m.text or "").strip()
await state.update_data(draft=draft)
await finalize(m, state)
async def finalize(msg: Message, state: FSMContext):
try:
data = await state.get_data()
d = Draft(**(data.get("draft") or {}))
user_id = data.get("user_id")
name = data.get("user_name", "—")
user_username = data.get("user_username")
username = f"@{user_username}" if user_username else f"id:{user_id if user_id else '—'}"
link = f"tg://user?id={user_id}" if user_id else "—"
rate = RATES.get(d.rate or "2", RATES["2"])
dt = datetime.now(TZ).strftime("%Y-%m-%d %H:%M")
liked = d.liked.strip() if d.liked else "—"
disliked = d.disliked.strip() if d.disliked else "—"
extra = d.extra.strip() if d.extra else "—"
text = (
"📝 Отзыв о занятии\n\n"
f"👤 Имя: {name}\n"
f"🔗 Профиль: {link}\n"
f"🆔 ID: {user_id if user_id else '—'}\n"
f"@username: {username}\n"
f"🗓 {dt} (МСК)\n"
f"🙂 Оценка: {rate['emoji']} {rate['label']}\n\n"
f"Что понравилось:\n{liked}\n\n"
f"Что не понравилось:\n{disliked}\n\n"
f"Дополнительно:\n{extra}"
)
try:
await msg.bot.send_message(chat_id=FEEDBACK_CHANNEL_ID, text=text)
except Exception:
await msg.answer("Не удалось отправить отзыв в канал.\n\n" + text)
else:
if user_id:
mark_submitted(user_id)
await msg.answer("Спасибо за обратную связь, до новых встреч!")
except Exception:
await msg.answer("Не удалось завершить отзыв. Попробуй снова: /feedback")
finally:
await state.clear()
async def on_startup(bot: Bot):
try:
await bot.delete_webhook(drop_pending_updates=True)
except Exception:
pass
try:
await bot.set_my_commands([
BotCommand(command="start", description="Начать опрос"),
BotCommand(command="feedback", description="Оставить отзыв"),
])
except Exception:
pass
async def main():
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=None))
dp = Dispatcher(storage=MemoryStorage())
dp.include_router(router)
try:
await on_startup(bot)
await dp.start_polling(bot)
finally:
await bot.session.close()
if __name__ == "__main__":
asyncio.run(main())