Improve Telegram forum onboarding and topic safety
This commit is contained in:
parent
2b56b98697
commit
a1b7a14138
13 changed files with 1101 additions and 376 deletions
|
|
@ -10,7 +10,7 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
|||
from aiogram.types import BotCommand
|
||||
|
||||
from adapter.telegram import db
|
||||
from adapter.telegram.handlers import auth, chat, confirm, settings
|
||||
from adapter.telegram.handlers import auth, chat, confirm, forum, settings
|
||||
from core.auth import AuthManager
|
||||
from core.chat import ChatManager
|
||||
from core.handler import EventDispatcher
|
||||
|
|
@ -54,7 +54,7 @@ def build_event_dispatcher(platform: MockPlatformClient) -> EventDispatcher:
|
|||
)
|
||||
|
||||
# Register core handlers
|
||||
from core.protocol import IncomingCommand, IncomingMessage, IncomingCallback
|
||||
from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
|
||||
ed.register(IncomingCommand, "start", handle_start)
|
||||
ed.register(IncomingCommand, "settings", handle_settings)
|
||||
ed.register(IncomingCommand, "settings_skills", handle_settings_skills)
|
||||
|
|
@ -89,6 +89,7 @@ async def main() -> None:
|
|||
|
||||
# Include routers
|
||||
dp.include_router(auth.router)
|
||||
dp.include_router(forum.router)
|
||||
dp.include_router(chat.router)
|
||||
dp.include_router(settings.router)
|
||||
dp.include_router(confirm.router)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
from aiogram.types import Message
|
||||
|
||||
from adapter.telegram import db
|
||||
from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI
|
||||
|
||||
|
||||
|
|
@ -16,6 +17,21 @@ def from_message(message: Message, chat_id: str) -> IncomingMessage:
|
|||
)
|
||||
|
||||
|
||||
def is_forum_message(message: Message) -> bool:
|
||||
return getattr(message, "message_thread_id", None) is not None
|
||||
|
||||
|
||||
def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None:
|
||||
thread_id = getattr(message, "message_thread_id", None)
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
chat = db.get_chat_by_thread(tg_user_id, thread_id)
|
||||
if not chat:
|
||||
return None
|
||||
return chat["chat_id"]
|
||||
|
||||
|
||||
def _extract_attachments(message: Message) -> list[Attachment]:
|
||||
attachments: list[Attachment] = []
|
||||
if message.photo:
|
||||
|
|
@ -41,10 +57,10 @@ def _extract_attachments(message: Message) -> list[Attachment]:
|
|||
return attachments
|
||||
|
||||
|
||||
def format_outgoing(chat_name: str, event: OutgoingEvent) -> str:
|
||||
prefix = f"[{chat_name}] "
|
||||
def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str:
|
||||
rendered_prefix = f"[{chat_name}] " if prefix else ""
|
||||
if isinstance(event, OutgoingMessage):
|
||||
return prefix + event.text
|
||||
return rendered_prefix + event.text
|
||||
if isinstance(event, OutgoingUI):
|
||||
return prefix + event.text
|
||||
return prefix + str(event)
|
||||
return rendered_prefix + event.text
|
||||
return rendered_prefix + str(event)
|
||||
|
|
|
|||
|
|
@ -27,18 +27,29 @@ def init_db() -> None:
|
|||
tg_user_id INTEGER PRIMARY KEY,
|
||||
platform_user_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
forum_group_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
tg_user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at TIMESTAMP,
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
tg_user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at TIMESTAMP,
|
||||
forum_thread_id INTEGER,
|
||||
FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
|
||||
);
|
||||
""")
|
||||
# Миграция для существующих БД
|
||||
try:
|
||||
con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_or_create_tg_user(
|
||||
|
|
@ -119,3 +130,38 @@ def archive_chat(chat_id: str) -> None:
|
|||
"UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE chat_id = ?",
|
||||
(chat_id,),
|
||||
)
|
||||
|
||||
|
||||
def set_forum_group(tg_user_id: int, group_id: int) -> None:
|
||||
with _conn() as con:
|
||||
con.execute(
|
||||
"UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?",
|
||||
(group_id, tg_user_id),
|
||||
)
|
||||
|
||||
|
||||
def get_forum_group(tg_user_id: int) -> int | None:
|
||||
with _conn() as con:
|
||||
row = con.execute(
|
||||
"SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?",
|
||||
(tg_user_id,),
|
||||
).fetchone()
|
||||
return row["forum_group_id"] if row else None
|
||||
|
||||
|
||||
def set_forum_thread(chat_id: str, thread_id: int) -> None:
|
||||
with _conn() as con:
|
||||
con.execute(
|
||||
"UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?",
|
||||
(thread_id, chat_id),
|
||||
)
|
||||
|
||||
|
||||
def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None:
|
||||
with _conn() as con:
|
||||
row = con.execute(
|
||||
"SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? "
|
||||
"AND archived_at IS NULL",
|
||||
(tg_user_id, thread_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
|
|
|||
|
|
@ -3,31 +3,87 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command, CommandObject
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from adapter.telegram import db
|
||||
from adapter.telegram.converter import format_outgoing, from_message
|
||||
from adapter.telegram.converter import (
|
||||
format_outgoing,
|
||||
from_message,
|
||||
is_forum_message,
|
||||
resolve_forum_chat_id,
|
||||
)
|
||||
from adapter.telegram.keyboards.chat import chats_list_keyboard
|
||||
from adapter.telegram.keyboards.confirm import confirm_keyboard
|
||||
from adapter.telegram.states import ChatState
|
||||
from core.handler import EventDispatcher
|
||||
from core.protocol import OutgoingMessage, OutgoingUI
|
||||
from adapter.telegram.keyboards.confirm import confirm_keyboard
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = Router(name="chat")
|
||||
|
||||
|
||||
async def _send_outgoing(message: Message, chat_name: str, events: list) -> None:
|
||||
def _thread_id(message: Message) -> int | None:
|
||||
return getattr(message, "message_thread_id", None)
|
||||
|
||||
|
||||
def _callback_thread_id(callback: CallbackQuery) -> int | None:
|
||||
if callback.message is None:
|
||||
return None
|
||||
return getattr(callback.message, "message_thread_id", None)
|
||||
|
||||
|
||||
async def _send_reply(
|
||||
message: Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup=None,
|
||||
thread_id: int | None = None,
|
||||
) -> None:
|
||||
if thread_id is None:
|
||||
await message.answer(text, reply_markup=reply_markup)
|
||||
return
|
||||
|
||||
await message.bot.send_message(
|
||||
message.chat.id,
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
message_thread_id=thread_id,
|
||||
)
|
||||
|
||||
|
||||
async def _send_outgoing(
|
||||
message: Message,
|
||||
chat_name: str,
|
||||
events: list,
|
||||
*,
|
||||
forum_mode: bool,
|
||||
thread_id: int | None = None,
|
||||
) -> None:
|
||||
for event in events:
|
||||
if isinstance(event, OutgoingUI):
|
||||
from adapter.telegram.keyboards.confirm import confirm_keyboard
|
||||
action_id = event.buttons[0].payload.get("action_id", "unknown") if event.buttons else "unknown"
|
||||
action_id = (
|
||||
event.buttons[0].payload.get("action_id", "unknown")
|
||||
if event.buttons
|
||||
else "unknown"
|
||||
)
|
||||
kb = confirm_keyboard(action_id)
|
||||
await message.answer(format_outgoing(chat_name, event), reply_markup=kb)
|
||||
await _send_reply(
|
||||
message,
|
||||
format_outgoing(chat_name, event, prefix=not forum_mode),
|
||||
reply_markup=kb,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
elif isinstance(event, OutgoingMessage):
|
||||
await message.answer(format_outgoing(chat_name, event))
|
||||
await _send_reply(
|
||||
message,
|
||||
format_outgoing(chat_name, event, prefix=not forum_mode),
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
|
||||
@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/"))
|
||||
|
|
@ -37,8 +93,28 @@ async def handle_message(
|
|||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
data = await state.get_data()
|
||||
chat_id = data.get("active_chat_id")
|
||||
chat_name = data.get("active_chat_name", "Чат")
|
||||
tg_id = message.from_user.id
|
||||
tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name)
|
||||
platform_user_id = tg_user.get("platform_user_id", str(tg_id))
|
||||
forum_mode = is_forum_message(message)
|
||||
thread_id = _thread_id(message) if forum_mode else None
|
||||
|
||||
if forum_mode:
|
||||
chat_id = resolve_forum_chat_id(message, tg_id)
|
||||
if not chat_id:
|
||||
await _send_reply(
|
||||
message,
|
||||
"Эта форум-тема ещё не зарегистрирована. Выполните /new в этой теме.",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return
|
||||
|
||||
chat = db.get_chat_by_id(chat_id)
|
||||
chat_name = chat["name"] if chat else data.get("active_chat_name", "Чат")
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
else:
|
||||
chat_id = data.get("active_chat_id")
|
||||
chat_name = data.get("active_chat_name", "Чат")
|
||||
|
||||
if not chat_id:
|
||||
await message.answer("Нет активного чата. Введите /start")
|
||||
|
|
@ -46,18 +122,21 @@ async def handle_message(
|
|||
|
||||
await state.set_state(ChatState.waiting_response)
|
||||
|
||||
# Typing indicator loop
|
||||
async def _typing_loop():
|
||||
async def _typing_loop() -> None:
|
||||
while True:
|
||||
await message.bot.send_chat_action(message.chat.id, "typing")
|
||||
if thread_id is None:
|
||||
await message.bot.send_chat_action(message.chat.id, "typing")
|
||||
else:
|
||||
await message.bot.send_chat_action(
|
||||
message.chat.id,
|
||||
"typing",
|
||||
message_thread_id=thread_id,
|
||||
)
|
||||
await asyncio.sleep(4)
|
||||
|
||||
task = asyncio.create_task(_typing_loop())
|
||||
events: list = []
|
||||
try:
|
||||
tg_id = message.from_user.id
|
||||
tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name)
|
||||
platform_user_id = tg_user.get("platform_user_id", str(tg_id))
|
||||
|
||||
incoming = from_message(message, chat_id)
|
||||
incoming.user_id = platform_user_id
|
||||
events = await dispatcher.dispatch(incoming)
|
||||
|
|
@ -69,7 +148,13 @@ async def handle_message(
|
|||
pass
|
||||
|
||||
await state.set_state(ChatState.idle)
|
||||
await _send_outgoing(message, chat_name, events)
|
||||
await _send_outgoing(
|
||||
message,
|
||||
chat_name,
|
||||
events,
|
||||
forum_mode=forum_mode,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("new"))
|
||||
|
|
@ -77,18 +162,79 @@ async def cmd_new_chat(message: Message, state: FSMContext) -> None:
|
|||
tg_id = message.from_user.id
|
||||
args = message.text.split(maxsplit=1)
|
||||
name = args[1].strip() if len(args) > 1 else None
|
||||
thread_id = _thread_id(message)
|
||||
|
||||
if thread_id is not None:
|
||||
chat = db.get_chat_by_thread(tg_id, thread_id)
|
||||
if chat:
|
||||
chat_id = chat["chat_id"]
|
||||
chat_name = name or chat["name"]
|
||||
if name and name != chat["name"]:
|
||||
db.rename_chat(chat_id, name)
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
await state.set_state(ChatState.idle)
|
||||
await _send_reply(
|
||||
message,
|
||||
f"✅ [{chat_name}] уже связан с этой темой.",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return
|
||||
|
||||
count = db.count_chats(tg_id)
|
||||
chat_name = name or f"Чат #{count + 1}"
|
||||
chat_id = db.create_chat(tg_id, chat_name)
|
||||
db.set_forum_thread(chat_id, thread_id)
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
await state.set_state(ChatState.idle)
|
||||
await _send_reply(
|
||||
message,
|
||||
f"✅ [{chat_name}] зарегистрирован в этой теме. Пиши!",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return
|
||||
|
||||
count = db.count_chats(tg_id)
|
||||
chat_name = name or f"Чат #{count + 1}"
|
||||
|
||||
chat_id = db.create_chat(tg_id, chat_name)
|
||||
created_thread_id = None
|
||||
forum_group_id = db.get_forum_group(tg_id)
|
||||
|
||||
if forum_group_id is not None:
|
||||
try:
|
||||
topic = await message.bot.create_forum_topic(chat_id=forum_group_id, name=chat_name)
|
||||
created_thread_id = (
|
||||
getattr(topic, "message_thread_id", None)
|
||||
or getattr(topic, "thread_id", None)
|
||||
)
|
||||
if created_thread_id is not None:
|
||||
db.set_forum_thread(chat_id, created_thread_id)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback for Telegram API
|
||||
logger.warning(
|
||||
"Failed to create forum topic for new chat",
|
||||
tg_user_id=tg_id,
|
||||
chat_name=chat_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
await state.set_state(ChatState.idle)
|
||||
await message.answer(f"✅ [{chat_name}] создан. Пиши!")
|
||||
if created_thread_id is not None:
|
||||
await message.answer(f"✅ [{chat_name}] создан. Форум-тема тоже создана.")
|
||||
else:
|
||||
await message.answer(f"✅ [{chat_name}] создан. Пиши!")
|
||||
|
||||
|
||||
@router.message(Command("chats"))
|
||||
async def cmd_list_chats(message: Message, state: FSMContext) -> None:
|
||||
if is_forum_message(message):
|
||||
await _send_reply(
|
||||
message,
|
||||
"В forum-теме переключение между чатами отключено. "
|
||||
"Эта тема всегда привязана к одному чату. Используй /chats в личке с ботом.",
|
||||
thread_id=_thread_id(message),
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = message.from_user.id
|
||||
chats = db.get_user_chats(tg_id)
|
||||
if not chats:
|
||||
|
|
@ -103,6 +249,13 @@ async def cmd_list_chats(message: Message, state: FSMContext) -> None:
|
|||
|
||||
@router.callback_query(F.data.startswith("switch:"))
|
||||
async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if _callback_thread_id(callback) is not None:
|
||||
await callback.answer(
|
||||
"Переключение чатов доступно только в личке с ботом.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
_, chat_id, chat_name = callback.data.split(":", 2)
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
await state.set_state(ChatState.idle)
|
||||
|
|
@ -112,6 +265,13 @@ async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None:
|
|||
|
||||
@router.callback_query(F.data == "new_chat")
|
||||
async def cb_new_chat(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if _callback_thread_id(callback) is not None:
|
||||
await callback.answer(
|
||||
"Создание нового чата из списка доступно только в личке с ботом.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
count = db.count_chats(tg_id)
|
||||
chat_name = f"Чат #{count + 1}"
|
||||
|
|
|
|||
|
|
@ -5,12 +5,35 @@ from aiogram import F, Router
|
|||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from adapter.telegram import db
|
||||
from adapter.telegram.converter import format_outgoing, is_forum_message, resolve_forum_chat_id
|
||||
from core.handler import EventDispatcher
|
||||
from core.protocol import IncomingCallback
|
||||
from core.protocol import IncomingCallback, OutgoingMessage, OutgoingUI
|
||||
|
||||
router = Router(name="confirm")
|
||||
|
||||
|
||||
async def _send_reply(
|
||||
callback: CallbackQuery,
|
||||
text: str,
|
||||
*,
|
||||
thread_id: int | None = None,
|
||||
) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if thread_id is None:
|
||||
await callback.message.answer(text)
|
||||
return
|
||||
|
||||
await callback.message.bot.send_message(
|
||||
callback.message.chat.id,
|
||||
text,
|
||||
message_thread_id=thread_id,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm:"))
|
||||
async def handle_confirm(
|
||||
callback: CallbackQuery,
|
||||
|
|
@ -23,12 +46,21 @@ async def handle_confirm(
|
|||
data = await state.get_data()
|
||||
chat_id = data.get("active_chat_id", "")
|
||||
chat_name = data.get("active_chat_name", "Чат")
|
||||
thread_id = getattr(callback.message, "message_thread_id", None)
|
||||
forum_mode = callback.message is not None and is_forum_message(callback.message)
|
||||
|
||||
from adapter.telegram import db as tgdb
|
||||
tg_id = callback.from_user.id
|
||||
tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
|
||||
tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
|
||||
platform_user_id = tg_user.get("platform_user_id", str(tg_id))
|
||||
|
||||
if forum_mode and callback.message is not None:
|
||||
resolved_chat_id = resolve_forum_chat_id(callback.message, tg_id)
|
||||
if resolved_chat_id:
|
||||
chat_id = resolved_chat_id
|
||||
chat = db.get_chat_by_id(chat_id)
|
||||
if chat:
|
||||
chat_name = chat["name"]
|
||||
|
||||
incoming = IncomingCallback(
|
||||
user_id=platform_user_id,
|
||||
platform="telegram",
|
||||
|
|
@ -38,12 +70,12 @@ async def handle_confirm(
|
|||
)
|
||||
events = await dispatcher.dispatch(incoming)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
if callback.message is not None:
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
|
||||
for event in events:
|
||||
from core.protocol import OutgoingMessage, OutgoingUI
|
||||
from adapter.telegram.converter import format_outgoing
|
||||
if isinstance(event, (OutgoingMessage, OutgoingUI)):
|
||||
await callback.message.answer(format_outgoing(chat_name, event))
|
||||
rendered = format_outgoing(chat_name, event, prefix=not forum_mode)
|
||||
await _send_reply(callback, rendered, thread_id=thread_id if forum_mode else None)
|
||||
|
||||
await callback.answer()
|
||||
|
|
|
|||
212
adapter/telegram/handlers/forum.py
Normal file
212
adapter/telegram/handlers/forum.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Chat, Message, ReplyKeyboardRemove
|
||||
|
||||
from adapter.telegram import db
|
||||
from adapter.telegram.keyboards.forum import forum_group_request_keyboard
|
||||
from adapter.telegram.states import ChatState, ForumSetupState
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = Router(name="forum")
|
||||
|
||||
|
||||
def _thread_id_from_topic(topic: object) -> int | None:
|
||||
thread_id = getattr(topic, "message_thread_id", None)
|
||||
if thread_id is not None:
|
||||
return thread_id
|
||||
return getattr(topic, "thread_id", None)
|
||||
|
||||
|
||||
def _resolve_forwarded_chat(message: Message) -> Chat | None:
|
||||
forwarded_chat = getattr(message, "forward_from_chat", None)
|
||||
if forwarded_chat is not None:
|
||||
return forwarded_chat
|
||||
|
||||
forward_origin = getattr(message, "forward_origin", None)
|
||||
if forward_origin is None:
|
||||
return None
|
||||
|
||||
sender_chat = getattr(forward_origin, "sender_chat", None)
|
||||
if sender_chat is not None:
|
||||
return sender_chat
|
||||
|
||||
return getattr(forward_origin, "chat", None)
|
||||
|
||||
|
||||
def _forward_debug_payload(message: Message) -> dict[str, object]:
|
||||
forward_origin = getattr(message, "forward_origin", None)
|
||||
forwarded_chat = _resolve_forwarded_chat(message)
|
||||
return {
|
||||
"has_forward_from_chat": getattr(message, "forward_from_chat", None) is not None,
|
||||
"has_forward_origin": forward_origin is not None,
|
||||
"forward_origin_type": getattr(forward_origin, "type", None),
|
||||
"forwarded_chat_id": getattr(forwarded_chat, "id", None),
|
||||
"forwarded_chat_type": getattr(forwarded_chat, "type", None),
|
||||
"forwarded_chat_is_forum": getattr(forwarded_chat, "is_forum", None),
|
||||
}
|
||||
|
||||
|
||||
async def _send_message(
|
||||
message: Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup=None,
|
||||
thread_id: int | None = None,
|
||||
) -> None:
|
||||
if thread_id is None:
|
||||
await message.answer(text, reply_markup=reply_markup)
|
||||
return
|
||||
|
||||
await message.bot.send_message(
|
||||
message.chat.id,
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
message_thread_id=thread_id,
|
||||
)
|
||||
|
||||
|
||||
async def _complete_group_link(message: Message, state: FSMContext, forwarded_chat: Chat) -> None:
|
||||
bot_user = await message.bot.get_me()
|
||||
member = await message.bot.get_chat_member(forwarded_chat.id, bot_user.id)
|
||||
can_manage_topics = getattr(member, "can_manage_topics", False)
|
||||
is_admin = member.status in ("administrator", "creator")
|
||||
if not is_admin or (member.status == "administrator" and not can_manage_topics):
|
||||
logger.warning(
|
||||
"Forum onboarding failed: bot lacks forum admin rights",
|
||||
tg_user_id=message.from_user.id,
|
||||
forum_group_id=forwarded_chat.id,
|
||||
member_status=member.status,
|
||||
can_manage_topics=can_manage_topics,
|
||||
)
|
||||
await message.answer(
|
||||
"Я не вижу прав на управление темами. "
|
||||
"Добавь меня администратором с правом `can_manage_topics` и попробуй снова.",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
return
|
||||
|
||||
tg_user_id = message.from_user.id
|
||||
db.set_forum_group(tg_user_id, forwarded_chat.id)
|
||||
logger.info(
|
||||
"Forum group linked",
|
||||
tg_user_id=tg_user_id,
|
||||
forum_group_id=forwarded_chat.id,
|
||||
forum_group_title=getattr(forwarded_chat, "title", None),
|
||||
)
|
||||
|
||||
created_topics = 0
|
||||
for chat in db.get_user_chats(tg_user_id):
|
||||
if chat.get("forum_thread_id") is not None:
|
||||
continue
|
||||
|
||||
topic = await message.bot.create_forum_topic(
|
||||
chat_id=forwarded_chat.id,
|
||||
name=chat["name"],
|
||||
)
|
||||
thread_id = _thread_id_from_topic(topic)
|
||||
if thread_id is None:
|
||||
logger.warning("Forum topic created without thread id", chat_id=chat["chat_id"])
|
||||
continue
|
||||
|
||||
db.set_forum_thread(chat["chat_id"], thread_id)
|
||||
created_topics += 1
|
||||
logger.info(
|
||||
"Forum topic linked to chat",
|
||||
tg_user_id=tg_user_id,
|
||||
chat_id=chat["chat_id"],
|
||||
forum_group_id=forwarded_chat.id,
|
||||
forum_thread_id=thread_id,
|
||||
)
|
||||
|
||||
await state.set_state(ChatState.idle)
|
||||
logger.info(
|
||||
"Forum onboarding completed",
|
||||
tg_user_id=tg_user_id,
|
||||
forum_group_id=forwarded_chat.id,
|
||||
created_topics=created_topics,
|
||||
)
|
||||
await message.answer(
|
||||
f"✅ Группа подключена. Создал {created_topics} тем(ы) для существующих чатов.",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("forum"))
|
||||
async def cmd_forum(message: Message, state: FSMContext) -> None:
|
||||
await state.set_state(ForumSetupState.waiting_for_group)
|
||||
logger.info("Forum onboarding started", tg_user_id=message.from_user.id)
|
||||
await message.answer(
|
||||
"Выбери forum-группу кнопкой ниже. Бот должен уже быть добавлен туда "
|
||||
"администратором с правом управления темами.\n\n"
|
||||
"Если кнопка не сработает, можно переслать сообщение из группы как fallback.",
|
||||
reply_markup=forum_group_request_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(ForumSetupState.waiting_for_group)
|
||||
async def handle_group_forward(message: Message, state: FSMContext) -> None:
|
||||
chat_shared = getattr(message, "chat_shared", None)
|
||||
if chat_shared is not None:
|
||||
logger.info(
|
||||
"Forum onboarding chat selected via request_chat",
|
||||
tg_user_id=message.from_user.id,
|
||||
forum_group_id=chat_shared.chat_id,
|
||||
forum_group_title=getattr(chat_shared, "title", None),
|
||||
request_id=getattr(chat_shared, "request_id", None),
|
||||
)
|
||||
forwarded_chat = Chat(
|
||||
id=chat_shared.chat_id,
|
||||
type="supergroup",
|
||||
title=getattr(chat_shared, "title", None),
|
||||
is_forum=True,
|
||||
)
|
||||
await _complete_group_link(message, state, forwarded_chat)
|
||||
return
|
||||
|
||||
debug_payload = _forward_debug_payload(message)
|
||||
logger.info(
|
||||
"Forum onboarding message received",
|
||||
tg_user_id=message.from_user.id,
|
||||
**debug_payload,
|
||||
)
|
||||
|
||||
forwarded_chat = _resolve_forwarded_chat(message)
|
||||
if forwarded_chat is None:
|
||||
logger.warning(
|
||||
"Forum onboarding failed: missing forwarded chat metadata",
|
||||
tg_user_id=message.from_user.id,
|
||||
**debug_payload,
|
||||
)
|
||||
await message.answer(
|
||||
"Не вижу в сообщении данных о группе. "
|
||||
"Нажми кнопку `Выбрать forum-группу` или перешли сообщение именно из нужной супергруппы, не копируй текст вручную."
|
||||
)
|
||||
return
|
||||
|
||||
if forwarded_chat.type != "supergroup":
|
||||
logger.warning(
|
||||
"Forum onboarding failed: forwarded chat is not supergroup",
|
||||
tg_user_id=message.from_user.id,
|
||||
**debug_payload,
|
||||
)
|
||||
await message.answer(
|
||||
"Пересылка пришла не из супергруппы. Нужна именно supergroup с включёнными Topics."
|
||||
)
|
||||
return
|
||||
|
||||
if getattr(forwarded_chat, "is_forum", None) is False:
|
||||
logger.warning(
|
||||
"Forum onboarding failed: supergroup is not forum-enabled",
|
||||
tg_user_id=message.from_user.id,
|
||||
**debug_payload,
|
||||
)
|
||||
await message.answer(
|
||||
"Это супергруппа, но в ней выключены Topics. Включи Topics и попробуй снова."
|
||||
)
|
||||
return
|
||||
await _complete_group_link(message, state, forwarded_chat)
|
||||
22
adapter/telegram/keyboards/forum.py
Normal file
22
adapter/telegram/keyboards/forum.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from aiogram.types import KeyboardButton, KeyboardButtonRequestChat, ReplyKeyboardMarkup
|
||||
|
||||
|
||||
def forum_group_request_keyboard() -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[[
|
||||
KeyboardButton(
|
||||
text="Выбрать forum-группу",
|
||||
request_chat=KeyboardButtonRequestChat(
|
||||
request_id=1,
|
||||
chat_is_channel=False,
|
||||
chat_is_forum=True,
|
||||
bot_is_member=True,
|
||||
request_title=True,
|
||||
),
|
||||
)
|
||||
]],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True,
|
||||
)
|
||||
|
|
@ -11,3 +11,7 @@ class SettingsState(StatesGroup):
|
|||
menu = State() # Главное меню настроек
|
||||
soul_editing = State() # Редактирует имя/инструкции агента
|
||||
confirm_action = State() # Подтверждение деструктивного действия
|
||||
|
||||
|
||||
class ForumSetupState(StatesGroup):
|
||||
waiting_for_group = State() # Ждём пересылку сообщения из супергруппы
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue