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