This commit is contained in:
dv 2026-03-26 00:48:04 +03:00
parent d3dc448552
commit 5986f420b1
10 changed files with 196 additions and 44 deletions

View file

@ -3,7 +3,12 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from admin.auth import require_admin from admin.auth import require_admin
from admin.templates_env import templates 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 from database.queries.organizations import get_all_organizations
router = APIRouter(prefix="/bot-users", dependencies=[Depends(require_admin)]) 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(...)): async def unblock_user(user_id: int, org_id: int = Form(...)):
await unblock_bot_user(user_id=user_id, org_id=org_id) await unblock_bot_user(user_id=user_id, org_id=org_id)
return RedirectResponse("/admin/bot-users", status_code=303) 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)

View file

@ -27,7 +27,7 @@
<thead> <thead>
<tr> <tr>
<th>#</th><th>MAX ID</th><th>Имя</th><th>Телефон</th> <th>#</th><th>MAX ID</th><th>Имя</th><th>Телефон</th>
<th>Организация</th><th>Статус</th><th>Дата</th><th>Разблокировать</th> <th>Организация</th><th>Статус</th><th>Дата</th><th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -48,21 +48,43 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ u.created_at[:16] }}</td> <td>{{ u.created_at[:16] }}</td>
<td> <td style="white-space:nowrap">
{% if u.is_blocked or not u.is_authorized %} <div style="display:flex;flex-direction:column;gap:4px">
<form action="/admin/bot-users/{{ u.id }}/unblock" method="post"
style="display:flex;gap:6px;align-items:center"> {# Разблокировать — только для заблокированных (admin задаёт org вручную) #}
<select name="org_id" required style="width:auto"> {% if u.is_blocked %}
<option value="">орг</option> <form action="/admin/bot-users/{{ u.id }}/unblock" method="post"
{% for org in orgs %} style="display:flex;gap:4px;align-items:center">
<option value="{{ org.id }}" {% if u.org_id == org.id %}selected{% endif %}>{{ org.name }}</option> <select name="org_id" required style="width:auto">
{% endfor %} <option value="">орг</option>
</select> {% for org in orgs %}
<button class="btn btn-primary" type="submit">Разблокировать</button> <option value="{{ org.id }}" {% if u.org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit" title="Разблокировать и авторизовать">Разблокировать</button>
</form>
{% endif %}
{# Сбросить авторизацию — для авторизованных и заблокированных (повторная auth через бот) #}
{% if u.is_authorized or u.is_blocked %}
<form action="/admin/bot-users/{{ u.id }}/reset" method="post">
<button class="btn btn-secondary" type="submit"
onclick="return confirm('Сбросить авторизацию? Пользователь должен будет заново поделиться контактом.')"
title="Сбросить — пользователь заново пройдёт авторизацию через бот">
Сбросить auth
</button>
</form>
{% endif %}
{# Удалить полностью #}
<form action="/admin/bot-users/{{ u.id }}/delete" method="post">
<button class="btn" style="background:#dc3545;color:#fff" type="submit"
onclick="return confirm('Удалить пользователя полностью? Это действие необратимо.')">
Удалить
</button>
</form> </form>
{% else %}
</div>
{% endif %}
</td> </td>
</tr> </tr>
{% else %} {% else %}

View file

@ -21,7 +21,10 @@ from maxapi.types.updates.message_callback import MessageCallback
from maxapi.types.updates.message_created import MessageCreated from maxapi.types.updates.message_created import MessageCreated
from bot.keyboards import done_ticket_kb, main_menu_kb from bot.keyboards import done_ticket_kb, main_menu_kb
from bot.logging_setup import get_logger
from bot.states import TicketStates from bot.states import TicketStates
logger = get_logger("bot.handlers.create_ticket")
from database.queries.ticket_attachments import add_attachment from database.queries.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket from database.queries.tickets import create_ticket
import external.client as ext import external.client as ext
@ -56,10 +59,11 @@ def _extract_attachments(event: MessageCreated) -> list[dict]:
@router.message_callback(F.callback.payload == "create_ticket") @router.message_callback(F.callback.payload == "create_ticket")
async def cb_create_ticket( async def cb_create_ticket(
event: MessageCallback, context: BaseContext event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
await context.set_state(TicketStates.waiting_description) await context.set_state(TicketStates.waiting_description)
await context.set_data({"pending_attachments": []}) await context.set_data({"pending_attachments": []})
logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"])
await event.answer(notification="") await event.answer(notification="")
chat_id = event.message.recipient.chat_id if event.message else None 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"], created_by=bot_user["id"],
description=description, 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", []): for att in data.get("pending_attachments", []):
await add_attachment( await add_attachment(
@ -174,7 +183,8 @@ async def cb_done_ticket(
) )
# Fire-and-forget: отправка во внешнюю систему # Fire-and-forget: отправка во внешнюю систему
loop = asyncio.get_event_loop() async def _sync_external():
asyncio.create_task( loop = asyncio.get_event_loop()
loop.run_in_executor(None, ext.sync_ticket, ticket_id) await loop.run_in_executor(None, ext.sync_ticket, ticket_id)
)
asyncio.create_task(_sync_external())

View file

@ -17,7 +17,10 @@ from maxapi.context import BaseContext
from maxapi.filters.command import Command from maxapi.filters.command import Command
from bot.keyboards import contact_request_kb, main_menu_kb from bot.keyboards import contact_request_kb, main_menu_kb
from bot.logging_setup import get_logger
from bot.states import AuthStates from bot.states import AuthStates
logger = get_logger("bot.handlers.start")
from database.queries.bot_users import ( from database.queries.bot_users import (
authorize_bot_user, authorize_bot_user,
block_bot_user, block_bot_user,
@ -49,6 +52,7 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
) )
if bot_user["is_blocked"]: if bot_user["is_blocked"]:
logger.info("🚫 bot_started blocked | user_id=%s", user.user_id)
await event.bot.send_message( await event.bot.send_message(
chat_id=event.chat_id, chat_id=event.chat_id,
text=( text=(
@ -59,6 +63,7 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
return return
if bot_user["is_authorized"]: if bot_user["is_authorized"]:
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
await context.set_state(None) await context.set_state(None)
await event.bot.send_message( await event.bot.send_message(
chat_id=event.chat_id, chat_id=event.chat_id,
@ -68,6 +73,7 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
return return
# Новый пользователь — запрашиваем контакт # Новый пользователь — запрашиваем контакт
logger.info("👋 bot_started new user | user_id=%s", user.user_id)
await context.set_state(AuthStates.waiting_contact) await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message( await event.bot.send_message(
chat_id=event.chat_id, chat_id=event.chat_id,
@ -174,6 +180,7 @@ async def on_contact_shared(
# Ищем в белом списке # Ищем в белом списке
org_user = await find_by_phone(phone) org_user = await find_by_phone(phone)
if org_user is None: 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 block_bot_user(max_user_id=user_id, phone=phone)
await context.set_state(None) await context.set_state(None)
await event.bot.send_message( await event.bot.send_message(
@ -186,6 +193,7 @@ async def on_contact_shared(
return return
# Авторизуем # Авторизуем
logger.info("✅ authorized | user_id=%s phone=%s org_id=%s", user_id, phone, org_user["org_id"])
await authorize_bot_user( await authorize_bot_user(
max_user_id=user_id, max_user_id=user_id,
phone=phone, phone=phone,

27
bot/logging_setup.py Normal file
View file

@ -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)

View file

@ -3,76 +3,117 @@ from typing import Any
from maxapi.filters.middleware import BaseMiddleware from maxapi.filters.middleware import BaseMiddleware
from maxapi.types.updates.bot_started import BotStarted 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 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.bot_users import get_bot_user_by_max_id
from database.queries.message_log import log_message 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): class AuthMiddleware(BaseMiddleware):
"""
Проверяет авторизацию пользователя перед передачей события в обработчик.
Пропускает без проверки:
- BotStarted (первый запуск)
- Сообщение с командой /start
Для остальных событий:
- Блокированные или незарегистрированные игнорируем
- Авторизованные добавляем bot_user в data и логируем сообщение
"""
async def __call__( async def __call__(
self, self,
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]], handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
event: Any, event: Any,
data: dict[str, Any], data: dict[str, Any],
) -> Any: ) -> Any:
# Пропускаем bot_started — он обрабатывается без авторизации update_type = getattr(event, "update_type", type(event).__name__)
# BotStarted — всегда пропускаем
if isinstance(event, 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) return await handler(event, data)
# Пропускаем команду /start # /start — пропускаем без авторизации
if isinstance(event, MessageCreated): if isinstance(event, MessageCreated):
body = event.message.body body = event.message.body
text = (body.text or "").strip() if body else "" text = (body.text or "").strip() if body else ""
if text.lower().startswith("/start"): if text.lower().startswith("/start"):
ids = event.get_ids()
logger.info("▶ /start | user_id=%s", ids[1])
return await handler(event, data) return await handler(event, data)
# Получаем user_id из события # Получаем user_id
ids = event.get_ids() ids = event.get_ids()
user_id = ids[1] if ids else None user_id = ids[1] if ids else None
chat_id = ids[0] if ids else None
if user_id is None: if user_id is None:
return return
bot_user = await get_bot_user_by_max_id(user_id) bot_user = await get_bot_user_by_max_id(user_id)
if bot_user is None: if bot_user is None:
# Незнакомый пользователь — игнорируем logger.debug("⚠ unknown user_id=%s | %s — ignored", user_id, update_type)
return return
if bot_user["is_blocked"]: if bot_user["is_blocked"]:
logger.info("🚫 blocked %s | %s — ignored", _user_label(bot_user, user_id), update_type)
return return
if not bot_user["is_authorized"]: 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 return
data["bot_user"] = bot_user data["bot_user"] = bot_user
# Логируем входящее сообщение # Логируем входящие сообщения
if isinstance(event, MessageCreated): if isinstance(event, MessageCreated):
chat_id, _ = event.get_ids()
body = event.message.body body = event.message.body
text_preview = ""
atts = [] atts = []
if body and body.attachments: if body:
for a in body.attachments: text_preview = (body.text or "")[:80]
atts.append({"type": str(getattr(a, "type", ""))}) 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( await log_message(
direction="in", direction="in",
max_user_id=user_id, max_user_id=user_id,
max_chat_id=chat_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, text=body.text if body else None,
attachments=atts or 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) return await handler(event, data)

Binary file not shown.

Binary file not shown.

View file

@ -71,6 +71,28 @@ async def block_bot_user(max_user_id: int, phone: str | None = None) -> None:
await db.commit() 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: async def unblock_bot_user(user_id: int, org_id: int) -> None:
db = get_db() db = get_db()
await db.execute( await db.execute(

View file

@ -1,6 +1,9 @@
import asyncio import asyncio
import uvicorn import uvicorn
from bot.logging_setup import setup_logging
setup_logging() # до всех остальных импортов
from database.connection import init_db from database.connection import init_db
from bot.setup import create_bot_and_dispatcher from bot.setup import create_bot_and_dispatcher
from admin.app import create_admin_app from admin.app import create_admin_app