init3
This commit is contained in:
parent
d3dc448552
commit
5986f420b1
10 changed files with 196 additions and 44 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -48,21 +48,43 @@
|
|||
{% endif %}
|
||||
</td>
|
||||
<td>{{ u.created_at[:16] }}</td>
|
||||
<td>
|
||||
{% if u.is_blocked or not u.is_authorized %}
|
||||
<form action="/admin/bot-users/{{ u.id }}/unblock" method="post"
|
||||
style="display:flex;gap:6px;align-items:center">
|
||||
<select name="org_id" required style="width:auto">
|
||||
<option value="">— орг —</option>
|
||||
{% for org in orgs %}
|
||||
<option value="{{ org.id }}" {% if u.org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Разблокировать</button>
|
||||
<td style="white-space:nowrap">
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
|
||||
{# Разблокировать — только для заблокированных (admin задаёт org вручную) #}
|
||||
{% if u.is_blocked %}
|
||||
<form action="/admin/bot-users/{{ u.id }}/unblock" method="post"
|
||||
style="display:flex;gap:4px;align-items:center">
|
||||
<select name="org_id" required style="width:auto">
|
||||
<option value="">— орг —</option>
|
||||
{% for org in orgs %}
|
||||
<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>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
27
bot/logging_setup.py
Normal file
27
bot/logging_setup.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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(
|
||||
|
|
|
|||
3
main.py
3
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue