-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhandlers.py
More file actions
535 lines (453 loc) · 25.5 KB
/
handlers.py
File metadata and controls
535 lines (453 loc) · 25.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile, BufferedInputFile
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.utils.keyboard import InlineKeyboardBuilder
import os
import io
import logging
import asyncio
import csv
import re
from contextlib import asynccontextmanager
from db import add_user, get_user_status, get_paid_seats, confirm_payment, get_user_by_id, update_user_state, has_user_applied, mark_user_applied, log_message, get_all_logs
from config import (
MAX_SEATS, ADMIN_ID, USDT_WALLET_TRC20, USDT_WALLET_BEP20,
PROMPTPAY_NUMBER, PROMPTPAY_QR_PATH, VIP_CHAT_LINK, PROMO_VIDEO_FILE_ID
)
from ai_service import generate_response, analyze_slip
from crypto_service import verify_transaction
router = Router()
class PaymentStates(StatesGroup):
waiting_for_txid_trc20 = State()
waiting_for_txid_bep20 = State()
waiting_for_slip = State()
class RefactoringStates(StatesGroup):
waiting_for_url = State()
waiting_for_pain = State()
@asynccontextmanager
async def typing_loop(bot: Bot, chat_id: int):
"""Loop to keep sending typing action while long tasks (like AI) are running."""
async def loop():
while True:
try:
await bot.send_chat_action(chat_id=chat_id, action="typing")
await asyncio.sleep(4)
except asyncio.CancelledError:
break
except Exception:
pass
task = asyncio.create_task(loop())
try:
yield
finally:
task.cancel()
def get_checkout_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.add(InlineKeyboardButton(text="💳 Оплатить 3000 THB", callback_data="checkout"))
return builder.as_markup()
def get_main_kb() -> InlineKeyboardMarkup:
"""Main action buttons - shown after AI responses and on /start."""
builder = InlineKeyboardBuilder()
builder.button(text="🌐 Оставить заявку на разбор", callback_data="refactoring")
builder.button(text="❓ Что это за мероприятие?", callback_data="q_what")
builder.button(text="👨💻 Покажите реальные проекты", callback_data="q_projects")
builder.button(text="💳 Оплатить 3000 THB", callback_data="checkout")
builder.adjust(1)
return builder.as_markup()
def get_no_website_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="❌ У меня пока нет сайта", callback_data="no_website")
return builder.as_markup()
@router.message(Command("start"))
async def cmd_start(message: Message, state: FSMContext):
await state.clear()
await add_user(tg_id=message.from_user.id, username=message.from_user.username)
name = message.from_user.first_name or "друг"
welcome_text = (
f"Привет, {name}! 👋\n\n"
"Я — ИИ-ассистент Архитектора Евгения (@elcrypto777).\n\n"
"🗓 **7 апреля, 18:00** — закрытый IT-практикум на Пхукете.\n"
"За один вечер вы соберёте премиальный сайт с помощью 5 ИИ-агентов.\n\n"
"🔥 **Бонус:** Евгений лично проведёт рефакторинг сайта одного из участников в реальном времени!\n\n"
"Выберите, что вас интересует 👇\n"
"Или просто напишите любой вопрос — я постараюсь ответить!"
)
if PROMO_VIDEO_FILE_ID:
await message.answer_video(
video=PROMO_VIDEO_FILE_ID,
caption=welcome_text,
reply_markup=get_main_kb(),
parse_mode="Markdown"
)
else:
# Fallback to text if video is not configured
await message.answer(welcome_text, reply_markup=get_main_kb(), parse_mode="Markdown")
@router.message(Command("logs"))
async def cmd_logs(message: Message):
if message.from_user.id != ADMIN_ID:
return
logs = await get_all_logs()
if not logs:
await message.answer("БД пуста, логов пока нет.")
return
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Timestamp", "User_ID", "Role", "Message"])
for row in logs:
writer.writerow(row)
csv_data = output.getvalue().encode('utf-8')
document = BufferedInputFile(csv_data, filename="chat_logs.csv")
await message.answer_document(document, caption=f"Выгрузка логов. Всего записей: {len(logs)}")
@router.callback_query(F.data == "checkout")
async def process_checkout(callback: CallbackQuery, state: FSMContext):
tg_id = callback.from_user.id
# Check if already paid
status = await get_user_status(tg_id)
if status == 'paid':
await callback.message.answer(f"Твое место уже оплачено и забронировано! Вступай в VIP-чат:\n{VIP_CHAT_LINK}")
await callback.answer()
return
paid_seats = await get_paid_seats()
if paid_seats >= MAX_SEATS:
await callback.message.answer("🔴 Извините, все 12 мест проданы (Sold Out). Вы добавлены в лист ожидания.")
await callback.answer()
return
# Guard: Require application filled
has_applied = await has_user_applied(tg_id)
if not has_applied:
await state.set_state(RefactoringStates.waiting_for_url)
msg = (
"❗ **Перед оплатой, пожалуйста, заполните небольшую анкету.**\n\n"
"Евгений проведёт рефакторинг сайта одного из участников в прямом эфире.\n"
"📎 **Отправьте ссылку на ваш текущий сайт:**\n\n"
"(Если у вас его ещё нет — нажмите кнопку ниже)"
)
await callback.message.answer(msg, reply_markup=get_no_website_kb(), parse_mode="Markdown")
await callback.answer()
return
await show_payment_methods(callback.message, callback.from_user.id)
await callback.answer()
async def show_payment_methods(message, tg_id):
builder = InlineKeyboardBuilder()
builder.button(text="🇹🇭 PromptPay (THB)", callback_data="pay_promptpay")
builder.button(text="📱 QR Code (THB)", callback_data="pay_qr")
builder.button(text="🪙 USDT TRC20", callback_data="pay_usdt_trc20")
builder.button(text="🪙 USDT BEP20", callback_data="pay_usdt_bep20")
builder.adjust(2)
await message.answer(
"💰 Выберите способ оплаты:\n\n"
"🇹🇭 **PromptPay** — 1,000 THB\n"
"🪙 **USDT** — 30 USDT (TRC20 или BEP20)",
reply_markup=builder.as_markup(),
parse_mode="Markdown"
)
# --- USDT TRC20 ---
@router.callback_query(F.data == "pay_usdt_trc20")
async def process_pay_usdt_trc20(callback: CallbackQuery, state: FSMContext):
await update_user_state(callback.from_user.id, "waiting_for_txid_trc20")
await state.set_state(PaymentStates.waiting_for_txid_trc20)
msg = (
"🪙 Оплата в USDT (TRC20)\n\n"
f"Адрес кошелька:\n`{USDT_WALLET_TRC20}`\n\n"
"Сеть: **TRON (TRC20)**\n"
"Сумма: строго **30 USDT**.\n\n"
"⚠️ Убедитесь, что отправляете именно по сети TRC20!\n\n"
"После перевода отправьте мне сюда TxID (хеш транзакции)."
)
await callback.message.answer(msg, parse_mode="Markdown")
await callback.answer()
# --- USDT BEP20 ---
@router.callback_query(F.data == "pay_usdt_bep20")
async def process_pay_usdt_bep20(callback: CallbackQuery, state: FSMContext):
await update_user_state(callback.from_user.id, "waiting_for_txid_bep20")
await state.set_state(PaymentStates.waiting_for_txid_bep20)
msg = (
"🪙 Оплата в USDT (BEP20)\n\n"
f"Адрес кошелька:\n`{USDT_WALLET_BEP20}`\n\n"
"Сеть: **BSC (BEP20)**\n"
"Сумма: строго **30 USDT**.\n\n"
"⚠️ Убедитесь, что отправляете именно по сети BEP20!\n\n"
"После перевода отправьте мне сюда TxID (хеш транзакции) или скриншот."
)
await callback.message.answer(msg, parse_mode="Markdown")
await callback.answer()
# --- PromptPay (number) ---
@router.callback_query(F.data == "pay_promptpay")
async def process_pay_promptpay(callback: CallbackQuery, state: FSMContext):
await update_user_state(callback.from_user.id, "waiting_for_slip")
await state.set_state(PaymentStates.waiting_for_slip)
msg = (
"🇹🇭 Оплата через PromptPay\n\n"
f"Номер PromptPay: `{PROMPTPAY_NUMBER}`\n"
"Сумма: строго **1,000 THB**.\n\n"
"После перевода отправьте мне сюда фото чека (bank slip)."
)
await callback.message.answer(msg, parse_mode="Markdown")
await callback.answer()
# --- QR Code ---
@router.callback_query(F.data == "pay_qr")
async def process_pay_qr(callback: CallbackQuery, state: FSMContext):
await update_user_state(callback.from_user.id, "waiting_for_slip")
await state.set_state(PaymentStates.waiting_for_slip)
msg = (
"🇹🇭 Оплата через QR Code (PromptPay)\n\n"
"Сумма: строго **1,000 THB**.\n"
"Отсканируйте QR-код ниже в вашем банковском приложении.\n\n"
"После перевода отправьте мне сюда фото чека (bank slip)."
)
if os.path.exists(PROMPTPAY_QR_PATH):
qr_photo = FSInputFile(PROMPTPAY_QR_PATH)
await callback.message.answer_photo(photo=qr_photo, caption=msg, parse_mode="Markdown")
else:
# Fallback if QR image not found
await callback.message.answer(
msg + f"\n\n📱 Номер PromptPay: `{PROMPTPAY_NUMBER}`",
parse_mode="Markdown"
)
await callback.answer()
# --- TRC20 TxID processing ---
@router.message(PaymentStates.waiting_for_txid_trc20, F.text)
async def process_txid_trc20(message: Message, state: FSMContext):
txid = message.text.strip()
if len(txid) < 60:
await message.answer("⚠️ Это не похоже на TxID сети TRON. Пожалуйста, отправьте корректный хеш транзакции.")
return
await message.answer("⏳ Сканирую блокчейн Tron...")
is_valid = await verify_transaction(txid)
if is_valid:
paid_seats = await get_paid_seats()
if paid_seats >= MAX_SEATS:
await message.answer("🔴 Оплата найдена, но к сожалению все места уже проданы! Администратор свяжется с вами для возврата.")
return
await confirm_payment(message.from_user.id)
await state.clear()
await message.answer(f"✅ Оплата успешно подтверждена!\n\nДобро пожаловать в VIP-группу:\n{VIP_CHAT_LINK}")
# Notify Admin
username = f"@{message.from_user.username}" if message.from_user.username else f"ID: {message.from_user.id}"
try:
await message.bot.send_message(ADMIN_ID, f"🎉 Новая оплата 30 USDT (TRC20) от {username}!\nTxID: `{txid}`", parse_mode="Markdown")
except Exception as e:
logging.error(f"Failed to notify admin: {e}")
else:
await message.answer("❌ Транзакция не найдена в сети или сумма неверна. Проверьте TxID и отправьте еще раз, или подождите минуту, если перевод был только что.")
# --- BEP20 TxID processing (manual verification via admin) ---
@router.message(PaymentStates.waiting_for_txid_bep20, F.text)
async def process_txid_bep20(message: Message, state: FSMContext):
txid = message.text.strip()
if len(txid) < 10:
await message.answer("⚠️ Это не похоже на TxID. Пожалуйста, отправьте корректный хеш транзакции.")
return
username = f"@{message.from_user.username}" if message.from_user.username else f"ID: {message.from_user.id}"
paid_seats = await get_paid_seats()
builder = InlineKeyboardBuilder()
builder.button(text="✅ Подтвердить", callback_data=f"approve_{message.from_user.id}")
builder.button(text="❌ Отклонить", callback_data=f"reject_{message.from_user.id}")
builder.adjust(2)
try:
await message.bot.send_message(
ADMIN_ID,
f"⚠️ Новая оплата USDT (BEP20) от {username}\n"
f"TxID: `{txid}`\n"
f"Проверьте на bscscan.com\n"
f"Оплачено мест: {paid_seats}/{MAX_SEATS}",
reply_markup=builder.as_markup(),
parse_mode="Markdown"
)
await message.answer("✅ TxID получен и отправлен на проверку Администратору. Пожалуйста, подождите подтверждения.")
await state.clear()
except Exception as e:
logging.error(f"Failed to send BEP20 txid to admin: {e}")
await message.answer("Произошла ошибка. Пожалуйста, обратитесь напрямую к @elcrypto777.")
# --- BEP20: also handle photo (screenshot) ---
@router.message(PaymentStates.waiting_for_txid_bep20, F.photo)
async def process_bep20_screenshot(message: Message, state: FSMContext):
username = f"@{message.from_user.username}" if message.from_user.username else f"ID: {message.from_user.id}"
photo = message.photo[-1]
paid_seats = await get_paid_seats()
builder = InlineKeyboardBuilder()
builder.button(text="✅ Подтвердить", callback_data=f"approve_{message.from_user.id}")
builder.button(text="❌ Отклонить", callback_data=f"reject_{message.from_user.id}")
builder.adjust(2)
try:
await message.bot.send_photo(
ADMIN_ID,
photo=photo.file_id,
caption=f"⚠️ Скриншот оплаты USDT (BEP20) от {username}\nОплачено мест: {paid_seats}/{MAX_SEATS}",
reply_markup=builder.as_markup()
)
await message.answer("✅ Скриншот получен и отправлен на проверку Администратору. Пожалуйста, подождите.")
await state.clear()
except Exception as e:
logging.error(f"Failed to send BEP20 screenshot to admin: {e}")
await message.answer("Произошла ошибка. Пожалуйста, обратитесь напрямую к @elcrypto777.")
# --- PromptPay / QR slip processing ---
@router.message(PaymentStates.waiting_for_slip, F.photo)
async def process_slip(message: Message, state: FSMContext):
await message.answer("⏳ ИИ анализирует чек...")
photo = message.photo[-1] # Get highest resolution
file_info = await message.bot.get_file(photo.file_id)
downloaded_file = await message.bot.download_file(file_info.file_path)
file_bytes = downloaded_file.read()
is_valid = await analyze_slip(file_bytes)
username = f"@{message.from_user.username}" if message.from_user.username else f"ID: {message.from_user.id}"
admin_text = f"⚠️ Новая оплата PromptPay от {username}.\n"
if is_valid:
admin_text += "✅ Vision AI подтвердил 1,000 THB."
else:
admin_text += "❓ Vision AI НЕ подтвердил платёж или не смог прочитать."
paid_seats = await get_paid_seats()
builder = InlineKeyboardBuilder()
builder.button(text="✅ Подтвердить", callback_data=f"approve_{message.from_user.id}")
builder.button(text="❌ Отклонить", callback_data=f"reject_{message.from_user.id}")
builder.adjust(2)
try:
await message.bot.send_photo(
ADMIN_ID,
photo=photo.file_id,
caption=f"{admin_text}\nОплачено мест: {paid_seats}/{MAX_SEATS}",
reply_markup=builder.as_markup()
)
await message.answer("✅ Чек получен и отправлен на проверку Администратору. Пожалуйста, подождите.")
await state.clear()
except Exception as e:
logging.error(f"Failed to send slip to admin: {e}")
await message.answer("Произошла ошибка при отправке администратору. Пожалуйста, обратитесь к @elcrypto777.")
@router.callback_query(F.data.startswith("approve_"))
async def admin_approve_payment(callback: CallbackQuery):
if callback.from_user.id != ADMIN_ID:
await callback.answer("У вас нет прав.", show_alert=True)
return
target_id = int(callback.data.split("_")[1])
paid_seats = await get_paid_seats()
if paid_seats >= MAX_SEATS:
await callback.message.edit_caption(caption="🔴 Отклонено: Мест больше нет.")
await callback.answer("Все места проданы!")
return
await confirm_payment(target_id)
try:
await callback.bot.send_message(target_id, f"✅ Оплата успешно подтверждена!\n\nПрисоединяйтесь к VIP-группе:\n{VIP_CHAT_LINK}")
# Update the admin message
new_paid = await get_paid_seats()
old_caption = callback.message.caption or callback.message.text or ""
await callback.message.edit_caption(caption=f"✅ Подтверждено. Оплачено мест: {new_paid}/{MAX_SEATS}")
except Exception as e:
logging.error(f"Error notifying user {target_id}: {e}")
await callback.answer("Пользователь подтвержден, но не удалось отправить ему сообщение.")
@router.callback_query(F.data.startswith("reject_"))
async def admin_reject_payment(callback: CallbackQuery):
if callback.from_user.id != ADMIN_ID:
await callback.answer("У вас нет прав.", show_alert=True)
return
target_id = int(callback.data.split("_")[1])
try:
await callback.bot.send_message(target_id, "❌ Ваша оплата была отклонена. Пожалуйста, проверьте данные или свяжитесь с @elcrypto777.")
await callback.message.edit_caption(caption="❌ Оплата отклонена.")
except Exception as e:
logging.error(f"Error notifying user {target_id}: {e}")
await callback.answer("Не удалось отправить сообщение пользователю.")
# === REFACTORING FUNNEL ===
@router.callback_query(F.data == "refactoring")
async def start_refactoring(callback: CallbackQuery, state: FSMContext):
await state.set_state(RefactoringStates.waiting_for_url)
msg = (
"🌐 **Хотите, чтобы Евгений переделал ваш сайт в прямом эфире?**\n\n"
"На мастерклассе он лично возьмёт сайт одного из участников и проведёт полный рефакторинг с помощью 4 ИИ-ролей.\n\n"
"📎 **Отправьте ссылку на ваш текущий сайт:**\n\n"
"(Если у вас его ещё нет — нажмите кнопку ниже)"
)
await callback.message.answer(msg, reply_markup=get_no_website_kb(), parse_mode="Markdown")
await callback.answer()
@router.callback_query(F.data == "no_website")
async def skip_website(callback: CallbackQuery, state: FSMContext):
await mark_user_applied(callback.from_user.id)
await state.clear()
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer("Понял! Заявка зафиксирована ✔️\nНичего страшного, соберёте сайт прямо на мастерклассе.")
await show_payment_methods(callback.message, callback.from_user.id)
await callback.answer()
@router.message(RefactoringStates.waiting_for_url, F.text)
async def refactoring_got_url(message: Message, state: FSMContext):
url = message.text.strip()
await state.update_data(site_url=url)
await state.set_state(RefactoringStates.waiting_for_pain)
await message.answer(
"👍 Отлично! Теперь расскажите в 2-3 предложениях:\n\n"
"• Чем вы занимаетесь?\n"
"• Что не так с текущим сайтом?\n"
"• Какой результат хотите получить?\n\n"
"Чем подробнее — тем выше шанс, что Евгений выберет именно ваш сайт! 🎯"
)
@router.message(RefactoringStates.waiting_for_pain, F.text)
async def refactoring_got_pain(message: Message, state: FSMContext):
data = await state.get_data()
site_url = data.get("site_url", "не указан")
pain = message.text.strip()
await state.clear()
username = f"@{message.from_user.username}" if message.from_user.username else f"ID: {message.from_user.id}"
name = message.from_user.first_name or username
# Forward lead to admin
admin_msg = (
f"🔥 ГОРЯЧИЙ ЛИД — заявка на рефакторинг!\n\n"
f"👤 {name} ({username})\n"
f"🌐 Сайт: {site_url}\n"
f"💬 Боль: {pain}\n\n"
f"Статус оплаты: проверь в базе"
)
try:
await message.bot.send_message(ADMIN_ID, admin_msg)
except Exception as e:
logging.error(f"Failed to send refactoring lead to admin: {e}")
await mark_user_applied(message.from_user.id)
# Strong CTA to the user
await message.answer(
f"🎯 **Заявка принята!**\n\n"
f"Евгений лично увидит ваш сайт и вашу задачу. "
f"Чтобы гарантировать участие и шанс на живой рефакторинг — переходите к оплате.\n\n",
parse_mode="Markdown"
)
await show_payment_methods(message, message.from_user.id)
async def send_ai_reply(target, text: str):
# Ensure links have https:// if AI missed it
text = re.sub(r'(?<!https://)\b(psyrelation\.ru|taplink\.cc/[a-zA-Z0-9_-]+|fastpump\.fun|trade\.fastpump\.fun|demo\.fincombat\.xyz)\b', r'https://\1', text)
# Try sending as HTML (with fallback to plain text if AI messed up tags)
try:
if isinstance(target, Message):
await target.answer(text, reply_markup=get_main_kb(), parse_mode="HTML")
else:
await target.message.answer(text, reply_markup=get_main_kb(), parse_mode="HTML")
except Exception as e:
logging.error(f"HTML parsing failed for AI response: {e}. Fallback to plain text.")
# Strip simple HTML tags for plain text fallback
plain_text = text.replace("<b>", "").replace("</b>", "").replace("<i>", "").replace("</i>", "")
if isinstance(target, Message):
await target.answer(plain_text, reply_markup=get_main_kb())
else:
await target.message.answer(plain_text, reply_markup=get_main_kb())
# === QUICK Q&A BUTTONS ===
@router.callback_query(F.data == "q_what")
async def quick_what(callback: CallbackQuery):
await callback.answer()
await log_message(callback.from_user.id, "user", "[КНОПКА] Что это за мероприятие?")
async with typing_loop(callback.message.bot, callback.message.chat.id):
response = await generate_response("Расскажи подробнее, что это за мероприятие?")
await log_message(callback.from_user.id, "assistant", response)
await send_ai_reply(callback, response)
@router.callback_query(F.data == "q_projects")
async def quick_projects(callback: CallbackQuery):
await callback.answer()
await log_message(callback.from_user.id, "user", "[КНОПКА] Покажите реальные проекты")
async with typing_loop(callback.message.bot, callback.message.chat.id):
response = await generate_response("Покажи реальные проекты и кейсы Евгения.")
await log_message(callback.from_user.id, "assistant", response)
await send_ai_reply(callback, response)
# === AI CHAT (catch-all) ===
@router.message(F.text)
async def handle_ai_chat(message: Message, state: FSMContext):
await add_user(tg_id=message.from_user.id, username=message.from_user.username)
await log_message(message.from_user.id, "user", message.text)
async with typing_loop(message.bot, message.chat.id):
response_text = await generate_response(message.text)
await log_message(message.from_user.id, "assistant", response_text)
await send_ai_reply(message, response_text)