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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue