feat(tg): forum-first adapter complete — handlers, bot.py, 46 tests pass
This commit is contained in:
parent
82dc840544
commit
24c61468d7
9 changed files with 675 additions and 0 deletions
76
adapter/telegram/bot.py
Normal file
76
adapter/telegram/bot.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.types import BotCommand
|
||||||
|
|
||||||
|
from adapter.telegram import db
|
||||||
|
from adapter.telegram.handlers import commands, message, settings, start, topic_events
|
||||||
|
from core.auth import AuthManager
|
||||||
|
from core.chat import ChatManager
|
||||||
|
from core.handler import EventDispatcher
|
||||||
|
from core.settings import SettingsManager
|
||||||
|
from core.store import InMemoryStore
|
||||||
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformMiddleware:
|
||||||
|
def __init__(self, dispatcher: EventDispatcher) -> None:
|
||||||
|
self._dispatcher = dispatcher
|
||||||
|
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
data["dispatcher"] = self._dispatcher
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_event_dispatcher() -> EventDispatcher:
|
||||||
|
platform = MockPlatformClient()
|
||||||
|
store = InMemoryStore()
|
||||||
|
return EventDispatcher(
|
||||||
|
platform=platform,
|
||||||
|
chat_mgr=ChatManager(platform, store),
|
||||||
|
auth_mgr=AuthManager(platform, store),
|
||||||
|
settings_mgr=SettingsManager(platform, store),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
token = os.environ.get("BOT_TOKEN")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("BOT_TOKEN env variable is not set")
|
||||||
|
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
bot = Bot(token=token)
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
event_dispatcher = build_event_dispatcher()
|
||||||
|
|
||||||
|
dp.message.middleware(PlatformMiddleware(event_dispatcher))
|
||||||
|
dp.callback_query.middleware(PlatformMiddleware(event_dispatcher))
|
||||||
|
|
||||||
|
dp.include_router(topic_events.router)
|
||||||
|
dp.include_router(start.router)
|
||||||
|
dp.include_router(commands.router)
|
||||||
|
dp.include_router(settings.router)
|
||||||
|
dp.include_router(message.router)
|
||||||
|
|
||||||
|
await bot.set_my_commands([
|
||||||
|
BotCommand(command="start", description="Начать / восстановить сессию"),
|
||||||
|
BotCommand(command="new", description="Создать новый чат"),
|
||||||
|
BotCommand(command="archive", description="Архивировать текущий чат"),
|
||||||
|
BotCommand(command="rename", description="Переименовать текущий чат"),
|
||||||
|
BotCommand(command="settings", description="Настройки"),
|
||||||
|
])
|
||||||
|
|
||||||
|
logger.info("bot_starting")
|
||||||
|
await dp.start_polling(bot, allowed_updates=["message", "callback_query"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
74
adapter/telegram/handlers/commands.py
Normal file
74
adapter/telegram/handlers/commands.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from adapter.telegram import db
|
||||||
|
from adapter.telegram.keyboards.settings import settings_main_keyboard
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="commands")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("new"))
|
||||||
|
async def cmd_new(message: Message) -> None:
|
||||||
|
"""Create a new topic and register it as a new chat."""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
chat_id = message.chat.id
|
||||||
|
n = db.count_active_chats(user_id) + 1
|
||||||
|
new_name = f"Чат #{n}"
|
||||||
|
topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name)
|
||||||
|
thread_id = topic.message_thread_id
|
||||||
|
db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name)
|
||||||
|
await message.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_thread_id=thread_id,
|
||||||
|
text=f"Создан {new_name}. Напиши что-нибудь.",
|
||||||
|
)
|
||||||
|
logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("archive"))
|
||||||
|
async def cmd_archive(message: Message) -> None:
|
||||||
|
"""Archive the current topic."""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
thread_id = message.message_thread_id
|
||||||
|
chat = db.get_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
if chat is None or chat["archived_at"] is not None:
|
||||||
|
await message.answer("Этот чат не найден или уже архивирован.")
|
||||||
|
return
|
||||||
|
await message.bot.close_forum_topic(chat_id=message.chat.id, message_thread_id=thread_id)
|
||||||
|
db.archive_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
logger.info("cmd_archive", user_id=user_id, thread_id=thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("rename"))
|
||||||
|
async def cmd_rename(message: Message) -> None:
|
||||||
|
"""Rename the current topic. Usage: /rename New Name"""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
thread_id = message.message_thread_id
|
||||||
|
parts = (message.text or "").split(maxsplit=1)
|
||||||
|
new_name = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
if not new_name:
|
||||||
|
await message.answer("Использование: /rename Новое название")
|
||||||
|
return
|
||||||
|
chat = db.get_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
if chat is None:
|
||||||
|
await message.answer("Этот чат не найден.")
|
||||||
|
return
|
||||||
|
await message.bot.edit_forum_topic(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
message_thread_id=thread_id,
|
||||||
|
name=new_name[:128],
|
||||||
|
)
|
||||||
|
db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128])
|
||||||
|
logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("settings"))
|
||||||
|
async def cmd_settings(message: Message) -> None:
|
||||||
|
"""Open settings menu."""
|
||||||
|
await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
|
||||||
81
adapter/telegram/handlers/message.py
Normal file
81
adapter/telegram/handlers/message.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from adapter.telegram import converter, db
|
||||||
|
from core.handler import EventDispatcher
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="message")
|
||||||
|
|
||||||
|
STREAM_EDIT_INTERVAL = 1.5
|
||||||
|
STREAM_MIN_DELTA = 100
|
||||||
|
TELEGRAM_MAX_LEN = 4096
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text & F.message_thread_id)
|
||||||
|
async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None:
|
||||||
|
"""Route a text message in a topic to the platform and stream the response."""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
thread_id = message.message_thread_id
|
||||||
|
|
||||||
|
chat = db.get_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
if chat is None or chat["archived_at"] is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
incoming = converter.from_message(message)
|
||||||
|
if incoming is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
platform_user = await dispatcher._platform.get_or_create_user(
|
||||||
|
external_id=str(user_id),
|
||||||
|
platform="telegram",
|
||||||
|
display_name=message.from_user.full_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
placeholder = await message.reply("...")
|
||||||
|
|
||||||
|
accumulated = ""
|
||||||
|
last_edit_time = 0.0
|
||||||
|
last_edit_len = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for chunk in dispatcher._platform.stream_message(
|
||||||
|
user_id=platform_user.user_id,
|
||||||
|
chat_id=str(thread_id),
|
||||||
|
text=incoming.text,
|
||||||
|
attachments=None,
|
||||||
|
):
|
||||||
|
accumulated += chunk.delta
|
||||||
|
now = time.monotonic()
|
||||||
|
delta = len(accumulated) - last_edit_len
|
||||||
|
if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL:
|
||||||
|
await _safe_edit(placeholder, accumulated)
|
||||||
|
last_edit_time = now
|
||||||
|
last_edit_len = len(accumulated)
|
||||||
|
|
||||||
|
await _safe_edit(placeholder, accumulated or "...")
|
||||||
|
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "thread not found" in str(e).lower():
|
||||||
|
db.archive_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
logger.warning("topic_deleted_during_message", thread_id=thread_id)
|
||||||
|
else:
|
||||||
|
logger.error("telegram_error", error=str(e))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("platform_error", user_id=user_id, thread_id=thread_id)
|
||||||
|
await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже")
|
||||||
|
|
||||||
|
|
||||||
|
async def _safe_edit(message: Message, text: str) -> None:
|
||||||
|
try:
|
||||||
|
await message.edit_text(text[:TELEGRAM_MAX_LEN])
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "not modified" not in str(e).lower():
|
||||||
|
raise
|
||||||
171
adapter/telegram/handlers/settings.py
Normal file
171
adapter/telegram/handlers/settings.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
# adapter/telegram/handlers/settings.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from adapter.telegram.keyboards.settings import (
|
||||||
|
back_keyboard,
|
||||||
|
safety_keyboard,
|
||||||
|
settings_main_keyboard,
|
||||||
|
skills_keyboard,
|
||||||
|
)
|
||||||
|
from adapter.telegram.states import SettingsState
|
||||||
|
from core.handler import EventDispatcher
|
||||||
|
from core.protocol import SettingsAction
|
||||||
|
|
||||||
|
router = Router(name="settings")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("settings"))
|
||||||
|
async def cmd_settings(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.set_state(SettingsState.menu)
|
||||||
|
await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "settings:back")
|
||||||
|
async def cb_settings_back(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
|
await state.set_state(SettingsState.menu)
|
||||||
|
await callback.message.edit_text("⚙️ Настройки", reply_markup=settings_main_keyboard())
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "settings:skills")
|
||||||
|
async def cb_skills(callback: CallbackQuery, state: FSMContext, dispatcher: EventDispatcher) -> None:
|
||||||
|
data = await state.get_data()
|
||||||
|
active_chat_id = data.get("active_chat_id", "")
|
||||||
|
# Get platform user id
|
||||||
|
platform_user_id = str(callback.from_user.id)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🧩 Скиллы\nНажмите для переключения:",
|
||||||
|
reply_markup=skills_keyboard(settings.skills),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle_skill:"))
|
||||||
|
async def cb_toggle_skill(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
skill = callback.data.split(":", 1)[1]
|
||||||
|
platform_user_id = str(callback.from_user.id)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
current = settings.skills.get(skill, False)
|
||||||
|
action = SettingsAction(
|
||||||
|
action="toggle_skill",
|
||||||
|
payload={"skill": skill, "enabled": not current},
|
||||||
|
)
|
||||||
|
await dispatcher._platform.update_settings(platform_user_id, action)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=skills_keyboard(settings.skills))
|
||||||
|
await callback.answer(f"{'Включён' if not current else 'Выключен'}: {skill}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "settings:safety")
|
||||||
|
async def cb_safety(callback: CallbackQuery, state: FSMContext, dispatcher: EventDispatcher) -> None:
|
||||||
|
platform_user_id = str(callback.from_user.id)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🔒 Безопасность\nПодтверждение перед выполнением:",
|
||||||
|
reply_markup=safety_keyboard(settings.safety),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle_safety:"))
|
||||||
|
async def cb_toggle_safety(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
trigger = callback.data.split(":", 1)[1]
|
||||||
|
platform_user_id = str(callback.from_user.id)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
current = settings.safety.get(trigger, False)
|
||||||
|
action = SettingsAction(
|
||||||
|
action="set_safety",
|
||||||
|
payload={"trigger": trigger, "enabled": not current},
|
||||||
|
)
|
||||||
|
await dispatcher._platform.update_settings(platform_user_id, action)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=safety_keyboard(settings.safety))
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "settings:soul")
|
||||||
|
async def cb_soul_menu(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
|
await state.set_state(SettingsState.soul_editing)
|
||||||
|
await state.update_data(soul_field=None)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🧠 Личность агента\n\nЧто хотите изменить?\n\n"
|
||||||
|
"Отправьте: name: <имя агента>\n"
|
||||||
|
"Или: instructions: <инструкции>\n\n"
|
||||||
|
"Или нажмите Назад.",
|
||||||
|
reply_markup=back_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(SettingsState.soul_editing)
|
||||||
|
async def handle_soul_input(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
text = message.text or ""
|
||||||
|
platform_user_id = str(message.from_user.id)
|
||||||
|
|
||||||
|
if ":" in text:
|
||||||
|
field, _, value = text.partition(":")
|
||||||
|
field = field.strip().lower()
|
||||||
|
value = value.strip()
|
||||||
|
if field in ("name", "instructions"):
|
||||||
|
action = SettingsAction(
|
||||||
|
action="set_soul",
|
||||||
|
payload={"field": field, "value": value},
|
||||||
|
)
|
||||||
|
await dispatcher._platform.update_settings(platform_user_id, action)
|
||||||
|
await message.answer(f"✅ {field} обновлено.")
|
||||||
|
await state.set_state(SettingsState.menu)
|
||||||
|
await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"Формат: name: <имя> или instructions: <инструкции>\n"
|
||||||
|
"Пример: name: Алекс"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "settings:connectors")
|
||||||
|
async def cb_connectors(callback: CallbackQuery) -> None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🔗 Коннекторы\n\nОAuth-интеграции — скоро.",
|
||||||
|
reply_markup=back_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "settings:plan")
|
||||||
|
async def cb_plan(callback: CallbackQuery, dispatcher: EventDispatcher) -> None:
|
||||||
|
platform_user_id = str(callback.from_user.id)
|
||||||
|
|
||||||
|
settings = await dispatcher._platform.get_settings(platform_user_id)
|
||||||
|
plan = settings.plan
|
||||||
|
text = (
|
||||||
|
f"💳 Подписка\n\n"
|
||||||
|
f"Тариф: {plan.get('name', '?')}\n"
|
||||||
|
f"Токены: {plan.get('tokens_used', 0)} / {plan.get('tokens_limit', 0)}"
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(text, reply_markup=back_keyboard())
|
||||||
|
await callback.answer()
|
||||||
75
adapter/telegram/handlers/start.py
Normal file
75
adapter/telegram/handlers/start.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.filters import CommandStart
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from adapter.telegram import db
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="start")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def cmd_start(message: Message) -> None:
|
||||||
|
"""
|
||||||
|
Bootstrap the user's forum.
|
||||||
|
|
||||||
|
First visit: create Чат #1, hide General topic.
|
||||||
|
Returning visit: health-check all active topics, archive stale ones.
|
||||||
|
"""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
|
await _check_and_prune_stale_topics(message, user_id, chat_id)
|
||||||
|
|
||||||
|
active = db.get_active_chats(user_id)
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
try:
|
||||||
|
topic = await message.bot.create_forum_topic(chat_id=chat_id, name="Чат #1")
|
||||||
|
thread_id = topic.message_thread_id
|
||||||
|
db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1")
|
||||||
|
logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id)
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
logger.warning("start_create_topic_failed", error=str(e))
|
||||||
|
await message.answer(
|
||||||
|
"Не удалось создать топик. Убедись, что в @BotFather включён "
|
||||||
|
"Threaded Mode для этого бота."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.bot.hide_general_forum_topic(chat_id=chat_id)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
pass # Not critical
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"Привет! Это твоё личное пространство с AI-агентом Lambda. "
|
||||||
|
"Каждый топик — отдельный контекст. Напиши что-нибудь."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
f"Снова привет! У тебя {len(active)} активных чатов. "
|
||||||
|
"Напиши /new чтобы создать новый."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_and_prune_stale_topics(
|
||||||
|
message: Message, user_id: int, chat_id: int
|
||||||
|
) -> None:
|
||||||
|
"""Send typing action to each active topic; archive any that no longer exist."""
|
||||||
|
for chat in db.get_active_chats(user_id):
|
||||||
|
thread_id = chat["thread_id"]
|
||||||
|
try:
|
||||||
|
await message.bot.send_chat_action(
|
||||||
|
chat_id=chat_id,
|
||||||
|
action="typing",
|
||||||
|
message_thread_id=thread_id,
|
||||||
|
)
|
||||||
|
except TelegramBadRequest:
|
||||||
|
db.archive_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id)
|
||||||
44
adapter/telegram/handlers/topic_events.py
Normal file
44
adapter/telegram/handlers/topic_events.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from adapter.telegram import db
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="topic_events")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.forum_topic_created)
|
||||||
|
async def on_topic_created(message: Message) -> None:
|
||||||
|
"""User created a topic via Telegram UI — register it as a new chat."""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
thread_id = message.message_thread_id
|
||||||
|
name = message.forum_topic_created.name
|
||||||
|
db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name)
|
||||||
|
logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.forum_topic_edited)
|
||||||
|
async def on_topic_edited(message: Message) -> None:
|
||||||
|
"""User renamed a topic via Telegram UI — sync chat_name in DB."""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
thread_id = message.message_thread_id
|
||||||
|
new_name = message.forum_topic_edited.name
|
||||||
|
if db.get_chat(user_id=user_id, thread_id=thread_id) is None:
|
||||||
|
return
|
||||||
|
db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name)
|
||||||
|
logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.forum_topic_closed)
|
||||||
|
async def on_topic_closed(message: Message) -> None:
|
||||||
|
"""User closed a topic via Telegram UI — auto-archive the chat."""
|
||||||
|
user_id = message.from_user.id
|
||||||
|
thread_id = message.message_thread_id
|
||||||
|
if db.get_chat(user_id=user_id, thread_id=thread_id) is None:
|
||||||
|
return
|
||||||
|
db.archive_chat(user_id=user_id, thread_id=thread_id)
|
||||||
|
logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id)
|
||||||
8
adapter/telegram/states.py
Normal file
8
adapter/telegram/states.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsState(StatesGroup):
|
||||||
|
menu = State()
|
||||||
|
soul_editing = State()
|
||||||
76
tests/adapter/telegram/test_commands.py
Normal file
76
tests/adapter/telegram/test_commands.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
import adapter.telegram.db as db_mod
|
||||||
|
importlib.reload(db_mod)
|
||||||
|
db_mod.init_db()
|
||||||
|
return db_mod
|
||||||
|
|
||||||
|
|
||||||
|
def make_message(*, user_id=1, thread_id=42, chat_id=100, text="/new"):
|
||||||
|
m = SimpleNamespace()
|
||||||
|
m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
|
||||||
|
m.message_thread_id = thread_id
|
||||||
|
m.chat = SimpleNamespace(id=chat_id)
|
||||||
|
m.text = text
|
||||||
|
m.answer = AsyncMock()
|
||||||
|
m.reply = AsyncMock()
|
||||||
|
m.bot = MagicMock()
|
||||||
|
m.bot.create_forum_topic = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(message_thread_id=200)
|
||||||
|
)
|
||||||
|
m.bot.close_forum_topic = AsyncMock()
|
||||||
|
m.bot.edit_forum_topic = AsyncMock()
|
||||||
|
m.bot.send_message = AsyncMock()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cmd_new_creates_topic(fresh_db, monkeypatch):
|
||||||
|
import adapter.telegram.handlers.commands as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
fresh_db.create_chat(1, 42, "Чат #1")
|
||||||
|
msg = make_message(user_id=1, thread_id=42, chat_id=100)
|
||||||
|
await mod.cmd_new(msg)
|
||||||
|
msg.bot.create_forum_topic.assert_called_once()
|
||||||
|
call_kwargs = str(msg.bot.create_forum_topic.call_args)
|
||||||
|
assert "Чат #2" in call_kwargs
|
||||||
|
assert fresh_db.get_chat(1, 200) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cmd_archive_closes_and_archives(fresh_db, monkeypatch):
|
||||||
|
import adapter.telegram.handlers.commands as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
fresh_db.create_chat(1, 42, "Чат #1")
|
||||||
|
msg = make_message(user_id=1, thread_id=42, chat_id=100)
|
||||||
|
await mod.cmd_archive(msg)
|
||||||
|
msg.bot.close_forum_topic.assert_called_once_with(chat_id=100, message_thread_id=42)
|
||||||
|
assert fresh_db.get_chat(1, 42)["archived_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cmd_archive_unknown_topic_replies_error(fresh_db, monkeypatch):
|
||||||
|
import adapter.telegram.handlers.commands as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
msg = make_message(user_id=1, thread_id=999, chat_id=100)
|
||||||
|
await mod.cmd_archive(msg)
|
||||||
|
msg.answer.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cmd_rename_updates_db_and_topic(fresh_db, monkeypatch):
|
||||||
|
import adapter.telegram.handlers.commands as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
fresh_db.create_chat(1, 42, "Чат #1")
|
||||||
|
msg = make_message(user_id=1, thread_id=42, chat_id=100, text="/rename Работа")
|
||||||
|
await mod.cmd_rename(msg)
|
||||||
|
msg.bot.edit_forum_topic.assert_called_once_with(
|
||||||
|
chat_id=100, message_thread_id=42, name="Работа"
|
||||||
|
)
|
||||||
|
assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
|
||||||
70
tests/adapter/telegram/test_topic_events.py
Normal file
70
tests/adapter/telegram/test_topic_events.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
import adapter.telegram.db as db_mod
|
||||||
|
importlib.reload(db_mod)
|
||||||
|
db_mod.init_db()
|
||||||
|
return db_mod
|
||||||
|
|
||||||
|
|
||||||
|
def make_service_message(*, user_id=1, thread_id=42, topic_name="Мой чат"):
|
||||||
|
m = SimpleNamespace()
|
||||||
|
m.message_thread_id = thread_id
|
||||||
|
m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
|
||||||
|
m.chat = SimpleNamespace(id=user_id)
|
||||||
|
m.forum_topic_created = SimpleNamespace(name=topic_name)
|
||||||
|
m.forum_topic_edited = SimpleNamespace(name="Новое имя")
|
||||||
|
m.forum_topic_closed = SimpleNamespace()
|
||||||
|
m.answer = AsyncMock()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
|
||||||
|
import adapter.telegram.handlers.topic_events as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
msg = make_service_message(user_id=5, thread_id=99, topic_name="Мой чат")
|
||||||
|
await mod.on_topic_created(msg)
|
||||||
|
chat = fresh_db.get_chat(5, 99)
|
||||||
|
assert chat is not None
|
||||||
|
assert chat["chat_name"] == "Мой чат"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
|
||||||
|
import adapter.telegram.handlers.topic_events as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
fresh_db.create_chat(5, 99, "Старое имя")
|
||||||
|
msg = make_service_message(user_id=5, thread_id=99)
|
||||||
|
await mod.on_topic_edited(msg)
|
||||||
|
assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
|
||||||
|
import adapter.telegram.handlers.topic_events as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
msg = make_service_message(user_id=5, thread_id=999)
|
||||||
|
await mod.on_topic_edited(msg) # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_topic_closed_archives_chat(fresh_db):
|
||||||
|
import adapter.telegram.handlers.topic_events as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
fresh_db.create_chat(5, 99, "Чат #1")
|
||||||
|
msg = make_service_message(user_id=5, thread_id=99)
|
||||||
|
await mod.on_topic_closed(msg)
|
||||||
|
assert fresh_db.get_chat(5, 99)["archived_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
|
||||||
|
import adapter.telegram.handlers.topic_events as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
msg = make_service_message(user_id=5, thread_id=999)
|
||||||
|
await mod.on_topic_closed(msg) # should not raise
|
||||||
Loading…
Add table
Add a link
Reference in a new issue