diff --git a/admin/routes/bot_users.py b/admin/routes/bot_users.py index 046d52b..3274ef5 100644 --- a/admin/routes/bot_users.py +++ b/admin/routes/bot_users.py @@ -3,7 +3,12 @@ from fastapi.responses import HTMLResponse, RedirectResponse from admin.auth import require_admin from admin.templates_env import templates -from database.queries.bot_users import get_all_bot_users, unblock_bot_user +from database.queries.bot_users import ( + delete_bot_user, + get_all_bot_users, + reset_bot_user, + unblock_bot_user, +) from database.queries.organizations import get_all_organizations router = APIRouter(prefix="/bot-users", dependencies=[Depends(require_admin)]) @@ -39,3 +44,17 @@ async def list_bot_users( async def unblock_user(user_id: int, org_id: int = Form(...)): await unblock_bot_user(user_id=user_id, org_id=org_id) return RedirectResponse("/admin/bot-users", status_code=303) + + +@router.post("/{user_id}/reset") +async def reset_user(user_id: int): + """Сбрасывает авторизацию — пользователь должен заново авторизоваться через бот.""" + await reset_bot_user(user_id=user_id) + return RedirectResponse("/admin/bot-users", status_code=303) + + +@router.post("/{user_id}/delete") +async def delete_user(user_id: int): + """Удаляет пользователя бота полностью.""" + await delete_bot_user(user_id=user_id) + return RedirectResponse("/admin/bot-users", status_code=303) diff --git a/admin/templates/bot_users/list.html b/admin/templates/bot_users/list.html index 67b2cb2..74189be 100644 --- a/admin/templates/bot_users/list.html +++ b/admin/templates/bot_users/list.html @@ -27,7 +27,7 @@ #MAX IDИмяТелефон - ОрганизацияСтатусДатаРазблокировать + ОрганизацияСтатусДатаДействия @@ -48,21 +48,43 @@ {% endif %} {{ u.created_at[:16] }} - - {% if u.is_blocked or not u.is_authorized %} -
- - + +
+ + {# Разблокировать — только для заблокированных (admin задаёт org вручную) #} + {% if u.is_blocked %} + + + + + {% endif %} + + {# Сбросить авторизацию — для авторизованных и заблокированных (повторная auth через бот) #} + {% if u.is_authorized or u.is_blocked %} +
+ +
+ {% endif %} + + {# Удалить полностью #} +
+
- {% else %} - — - {% endif %} + +
{% else %} diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index 813f545..f90cc5d 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -21,7 +21,10 @@ from maxapi.types.updates.message_callback import MessageCallback from maxapi.types.updates.message_created import MessageCreated from bot.keyboards import done_ticket_kb, main_menu_kb +from bot.logging_setup import get_logger from bot.states import TicketStates + +logger = get_logger("bot.handlers.create_ticket") from database.queries.ticket_attachments import add_attachment from database.queries.tickets import create_ticket import external.client as ext @@ -56,10 +59,11 @@ def _extract_attachments(event: MessageCreated) -> list[dict]: @router.message_callback(F.callback.payload == "create_ticket") async def cb_create_ticket( - event: MessageCallback, context: BaseContext + event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await context.set_state(TicketStates.waiting_description) await context.set_data({"pending_attachments": []}) + logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"]) await event.answer(notification="") chat_id = event.message.recipient.chat_id if event.message else None @@ -153,6 +157,11 @@ async def cb_done_ticket( created_by=bot_user["id"], description=description, ) + logger.info( + "✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s", + ticket_id, bot_user["max_user_id"], bot_user["org_id"], + len(data.get("pending_attachments", [])), + ) for att in data.get("pending_attachments", []): await add_attachment( @@ -174,7 +183,8 @@ async def cb_done_ticket( ) # Fire-and-forget: отправка во внешнюю систему - loop = asyncio.get_event_loop() - asyncio.create_task( - loop.run_in_executor(None, ext.sync_ticket, ticket_id) - ) + async def _sync_external(): + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, ext.sync_ticket, ticket_id) + + asyncio.create_task(_sync_external()) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 5db8282..45099cb 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -17,7 +17,10 @@ from maxapi.context import BaseContext from maxapi.filters.command import Command from bot.keyboards import contact_request_kb, main_menu_kb +from bot.logging_setup import get_logger from bot.states import AuthStates + +logger = get_logger("bot.handlers.start") from database.queries.bot_users import ( authorize_bot_user, block_bot_user, @@ -49,6 +52,7 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: ) if bot_user["is_blocked"]: + logger.info("🚫 bot_started blocked | user_id=%s", user.user_id) await event.bot.send_message( chat_id=event.chat_id, text=( @@ -59,6 +63,7 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: return if bot_user["is_authorized"]: + logger.info("✅ bot_started returning user | user_id=%s", user.user_id) await context.set_state(None) await event.bot.send_message( chat_id=event.chat_id, @@ -68,6 +73,7 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: return # Новый пользователь — запрашиваем контакт + logger.info("👋 bot_started new user | user_id=%s", user.user_id) await context.set_state(AuthStates.waiting_contact) await event.bot.send_message( chat_id=event.chat_id, @@ -174,6 +180,7 @@ async def on_contact_shared( # Ищем в белом списке org_user = await find_by_phone(phone) if org_user is None: + logger.warning("🚫 phone not found, blocking | user_id=%s phone=%s", user_id, phone) await block_bot_user(max_user_id=user_id, phone=phone) await context.set_state(None) await event.bot.send_message( @@ -186,6 +193,7 @@ async def on_contact_shared( return # Авторизуем + logger.info("✅ authorized | user_id=%s phone=%s org_id=%s", user_id, phone, org_user["org_id"]) await authorize_bot_user( max_user_id=user_id, phone=phone, diff --git a/bot/logging_setup.py b/bot/logging_setup.py new file mode 100644 index 0000000..edbb414 --- /dev/null +++ b/bot/logging_setup.py @@ -0,0 +1,27 @@ +""" +Настройка логирования для бота. + +Формат: [время] УРОВЕНЬ [модуль] сообщение +Логи пишутся в stdout (Docker подхватывает их через docker logs). +""" +import logging +import sys + + +def setup_logging(level: str = "INFO") -> None: + fmt = "%(asctime)s %(levelname)-8s [%(name)s] %(message)s" + datefmt = "%Y-%m-%d %H:%M:%S" + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format=fmt, + datefmt=datefmt, + stream=sys.stdout, + force=True, + ) + # Приглушаем шумные библиотеки + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("aiosqlite").setLevel(logging.WARNING) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/bot/middleware.py b/bot/middleware.py index d8cece6..bc1e318 100644 --- a/bot/middleware.py +++ b/bot/middleware.py @@ -3,76 +3,117 @@ from typing import Any from maxapi.filters.middleware import BaseMiddleware from maxapi.types.updates.bot_started import BotStarted +from maxapi.types.updates.message_callback import MessageCallback from maxapi.types.updates.message_created import MessageCreated +from bot.logging_setup import get_logger from database.queries.bot_users import get_bot_user_by_max_id from database.queries.message_log import log_message +logger = get_logger("bot.middleware") + + +def _user_label(bot_user: dict | None, user_id: int | None) -> str: + if bot_user: + name = bot_user.get("first_name") or bot_user.get("username") or "" + return f"user_id={bot_user['max_user_id']} ({name})" if name else f"user_id={bot_user['max_user_id']}" + return f"user_id={user_id}" + class AuthMiddleware(BaseMiddleware): - """ - Проверяет авторизацию пользователя перед передачей события в обработчик. - - Пропускает без проверки: - - BotStarted (первый запуск) - - Сообщение с командой /start - - Для остальных событий: - - Блокированные или незарегистрированные → игнорируем - - Авторизованные → добавляем bot_user в data и логируем сообщение - """ - async def __call__( self, handler: Callable[[Any, dict[str, Any]], Awaitable[Any]], event: Any, data: dict[str, Any], ) -> Any: - # Пропускаем bot_started — он обрабатывается без авторизации + update_type = getattr(event, "update_type", type(event).__name__) + + # BotStarted — всегда пропускаем if isinstance(event, BotStarted): + logger.info("▶ bot_started | user_id=%s chat_id=%s", event.user.user_id, event.chat_id) return await handler(event, data) - # Пропускаем команду /start + # /start — пропускаем без авторизации if isinstance(event, MessageCreated): body = event.message.body text = (body.text or "").strip() if body else "" if text.lower().startswith("/start"): + ids = event.get_ids() + logger.info("▶ /start | user_id=%s", ids[1]) return await handler(event, data) - # Получаем user_id из события + # Получаем user_id ids = event.get_ids() user_id = ids[1] if ids else None + chat_id = ids[0] if ids else None if user_id is None: return bot_user = await get_bot_user_by_max_id(user_id) if bot_user is None: - # Незнакомый пользователь — игнорируем + logger.debug("⚠ unknown user_id=%s | %s — ignored", user_id, update_type) return + if bot_user["is_blocked"]: + logger.info("🚫 blocked %s | %s — ignored", _user_label(bot_user, user_id), update_type) return + if not bot_user["is_authorized"]: - # Ещё не авторизовался — игнорируем (повторный промпт не нужен) + # Пропускаем шаринг контакта — это шаг авторизации + if isinstance(event, MessageCreated): + body = event.message.body + if body and body.attachments: + from maxapi.enums.attachment import AttachmentType + for att in body.attachments: + if getattr(att, "type", None) == AttachmentType.CONTACT: + logger.info( + "📱 contact shared | %s", + _user_label(bot_user, user_id), + ) + data["bot_user"] = bot_user + return await handler(event, data) + logger.debug("⚠ not authorized %s | %s — ignored", _user_label(bot_user, user_id), update_type) return data["bot_user"] = bot_user - # Логируем входящее сообщение + # Логируем входящие сообщения if isinstance(event, MessageCreated): - chat_id, _ = event.get_ids() body = event.message.body + text_preview = "" atts = [] - if body and body.attachments: - for a in body.attachments: - atts.append({"type": str(getattr(a, "type", ""))}) + if body: + text_preview = (body.text or "")[:80] + if body.attachments: + for a in body.attachments: + atts.append({"type": str(getattr(a, "type", ""))}) + logger.info( + "💬 message | %s | chat=%s | text=%r | attachments=%s", + _user_label(bot_user, user_id), chat_id, + text_preview, [a["type"] for a in atts] or None, + ) await log_message( direction="in", max_user_id=user_id, max_chat_id=chat_id, - message_id=event.message.body.mid if body else None, + message_id=body.mid if body else None, text=body.text if body else None, attachments=atts or None, ) + elif isinstance(event, MessageCallback): + payload = event.callback.payload or "" + logger.info( + "🔘 callback | %s | chat=%s | payload=%r", + _user_label(bot_user, user_id), chat_id, payload, + ) + + else: + logger.info( + "📨 %s | %s | chat=%s", + update_type, _user_label(bot_user, user_id), chat_id, + ) + return await handler(event, data) diff --git a/data/support.db-shm b/data/support.db-shm index 38e97fb..46ff81f 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index 976552e..c7fb78f 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/database/queries/bot_users.py b/database/queries/bot_users.py index 7311197..6902b69 100644 --- a/database/queries/bot_users.py +++ b/database/queries/bot_users.py @@ -71,6 +71,28 @@ async def block_bot_user(max_user_id: int, phone: str | None = None) -> None: await db.commit() +async def reset_bot_user(user_id: int) -> None: + """Сбрасывает авторизацию — пользователь должен заново пройти auth.""" + db = get_db() + await db.execute( + """ + UPDATE bot_users + SET is_authorized = 0, is_blocked = 0, org_id = NULL, phone = NULL, + updated_at = datetime('now') + WHERE id = ? + """, + (user_id,), + ) + await db.commit() + + +async def delete_bot_user(user_id: int) -> None: + """Полное удаление пользователя бота (каскад удаляет заявки).""" + db = get_db() + await db.execute("DELETE FROM bot_users WHERE id = ?", (user_id,)) + await db.commit() + + async def unblock_bot_user(user_id: int, org_id: int) -> None: db = get_db() await db.execute( diff --git a/main.py b/main.py index e4345ed..efb069b 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,9 @@ import asyncio import uvicorn +from bot.logging_setup import setup_logging +setup_logging() # до всех остальных импортов + from database.connection import init_db from bot.setup import create_bot_and_dispatcher from admin.app import create_admin_app