From 9c555261b3ae19d3ca012c929e013cc639ff6ecc Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Tue, 31 Mar 2026 21:35:16 +0300 Subject: [PATCH] feat: implement adapter/telegram/ with aiogram 3.x Virtual DM chats, FSM (idle/waiting_response/settings states), SQLite local DB for tg_users+chats, converter, keyboards, and handlers for /start, /new, /chats, /settings, confirm callbacks. --- adapter/__init__.py | 0 adapter/telegram/__init__.py | 0 adapter/telegram/bot.py | 101 +++++++++++++ adapter/telegram/converter.py | 50 +++++++ adapter/telegram/db.py | 121 ++++++++++++++++ adapter/telegram/handlers/__init__.py | 0 adapter/telegram/handlers/auth.py | 67 +++++++++ adapter/telegram/handlers/chat.py | 122 ++++++++++++++++ adapter/telegram/handlers/confirm.py | 49 +++++++ adapter/telegram/handlers/settings.py | 189 +++++++++++++++++++++++++ adapter/telegram/keyboards/__init__.py | 0 adapter/telegram/keyboards/chat.py | 16 +++ adapter/telegram/keyboards/confirm.py | 11 ++ adapter/telegram/keyboards/settings.py | 52 +++++++ adapter/telegram/states.py | 13 ++ 15 files changed, 791 insertions(+) create mode 100644 adapter/__init__.py create mode 100644 adapter/telegram/__init__.py create mode 100644 adapter/telegram/bot.py create mode 100644 adapter/telegram/converter.py create mode 100644 adapter/telegram/db.py create mode 100644 adapter/telegram/handlers/__init__.py create mode 100644 adapter/telegram/handlers/auth.py create mode 100644 adapter/telegram/handlers/chat.py create mode 100644 adapter/telegram/handlers/confirm.py create mode 100644 adapter/telegram/handlers/settings.py create mode 100644 adapter/telegram/keyboards/__init__.py create mode 100644 adapter/telegram/keyboards/chat.py create mode 100644 adapter/telegram/keyboards/confirm.py create mode 100644 adapter/telegram/keyboards/settings.py create mode 100644 adapter/telegram/states.py diff --git a/adapter/__init__.py b/adapter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/telegram/__init__.py b/adapter/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/telegram/bot.py b/adapter/telegram/bot.py new file mode 100644 index 0000000..c372f7b --- /dev/null +++ b/adapter/telegram/bot.py @@ -0,0 +1,101 @@ +# adapter/telegram/bot.py +from __future__ import annotations + +import asyncio +import os + +import structlog +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage + +from adapter.telegram import db +from adapter.telegram.handlers import auth, chat, confirm, settings +from core.auth import AuthManager +from core.chat import ChatManager +from core.handler import EventDispatcher +from core.handlers.callback import handle_confirm as core_handle_confirm +from core.handlers.chat import handle_archive, handle_list_chats, handle_new_chat, handle_rename +from core.handlers.message import handle_message +from core.handlers.settings import ( + handle_settings, + handle_settings_skills, +) +from core.handlers.start import handle_start +from core.settings import SettingsManager +from core.store import InMemoryStore +from platform.mock import MockPlatformClient + +logger = structlog.get_logger(__name__) + + +class DispatcherMiddleware: + """Injects EventDispatcher into every handler via data dict.""" + + 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(platform: MockPlatformClient) -> EventDispatcher: + store = InMemoryStore() + chat_mgr = ChatManager(platform, store) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + + ed = EventDispatcher( + platform=platform, + chat_mgr=chat_mgr, + auth_mgr=auth_mgr, + settings_mgr=settings_mgr, + ) + + # Register core handlers + ed.register(type(None).__mro__[0], "start", handle_start) # placeholder + from core.protocol import IncomingCommand, IncomingMessage, IncomingCallback + ed.register(IncomingCommand, "start", handle_start) + ed.register(IncomingCommand, "settings", handle_settings) + ed.register(IncomingCommand, "settings_skills", handle_settings_skills) + ed.register(IncomingCommand, "new", handle_new_chat) + ed.register(IncomingCommand, "chats", handle_list_chats) + ed.register(IncomingCommand, "rename", handle_rename) + ed.register(IncomingCommand, "archive", handle_archive) + ed.register(IncomingMessage, "*", handle_message) + ed.register(IncomingCallback, "confirm", core_handle_confirm) + ed.register(IncomingCallback, "cancel", core_handle_confirm) + + return ed + + +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) + storage = MemoryStorage() + dp = Dispatcher(storage=storage) + + platform = MockPlatformClient() + event_dispatcher = build_event_dispatcher(platform) + + # Register middleware on all update types + dp.message.middleware(DispatcherMiddleware(event_dispatcher)) + dp.callback_query.middleware(DispatcherMiddleware(event_dispatcher)) + + # Include routers + dp.include_router(auth.router) + dp.include_router(chat.router) + dp.include_router(settings.router) + dp.include_router(confirm.router) + + logger.info("Bot starting") + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/adapter/telegram/converter.py b/adapter/telegram/converter.py new file mode 100644 index 0000000..976d63d --- /dev/null +++ b/adapter/telegram/converter.py @@ -0,0 +1,50 @@ +# adapter/telegram/converter.py +from __future__ import annotations + +from aiogram.types import Message + +from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI + + +def from_message(message: Message, chat_id: str) -> IncomingMessage: + return IncomingMessage( + user_id=str(message.from_user.id), + chat_id=chat_id, + text=message.text or message.caption or "", + attachments=_extract_attachments(message), + platform="telegram", + ) + + +def _extract_attachments(message: Message) -> list[Attachment]: + attachments: list[Attachment] = [] + if message.photo: + file = message.photo[-1] + attachments.append(Attachment( + type="image", + url=f"tg://file/{file.file_id}", + mime_type="image/jpeg", + )) + if message.document: + attachments.append(Attachment( + type="document", + url=f"tg://file/{message.document.file_id}", + mime_type=message.document.mime_type or "application/octet-stream", + filename=message.document.file_name, + )) + if message.voice: + attachments.append(Attachment( + type="audio", + url=f"tg://file/{message.voice.file_id}", + mime_type="audio/ogg", + )) + return attachments + + +def format_outgoing(chat_name: str, event: OutgoingEvent) -> str: + prefix = f"[{chat_name}] " + if isinstance(event, OutgoingMessage): + return prefix + event.text + if isinstance(event, OutgoingUI): + return prefix + event.text + return prefix + str(event) diff --git a/adapter/telegram/db.py b/adapter/telegram/db.py new file mode 100644 index 0000000..2d39729 --- /dev/null +++ b/adapter/telegram/db.py @@ -0,0 +1,121 @@ +# adapter/telegram/db.py +from __future__ import annotations + +import os +import sqlite3 +import uuid +from contextlib import contextmanager + +DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db") + + +@contextmanager +def _conn(): + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + try: + yield con + con.commit() + finally: + con.close() + + +def init_db() -> None: + with _conn() as con: + con.executescript(""" + CREATE TABLE IF NOT EXISTS tg_users ( + tg_user_id INTEGER PRIMARY KEY, + platform_user_id TEXT NOT NULL, + display_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + 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, + FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) + ); + """) + + +def get_or_create_tg_user( + tg_user_id: int, + platform_user_id: str, + display_name: str | None, +) -> dict: + with _conn() as con: + row = con.execute( + "SELECT * FROM tg_users WHERE tg_user_id = ?", (tg_user_id,) + ).fetchone() + if row: + return dict(row) + con.execute( + "INSERT INTO tg_users (tg_user_id, platform_user_id, display_name) VALUES (?, ?, ?)", + (tg_user_id, platform_user_id, display_name), + ) + return { + "tg_user_id": tg_user_id, + "platform_user_id": platform_user_id, + "display_name": display_name, + } + + +def create_chat(tg_user_id: int, name: str) -> str: + chat_id = str(uuid.uuid4()) + with _conn() as con: + con.execute( + "INSERT INTO chats (chat_id, tg_user_id, name) VALUES (?, ?, ?)", + (chat_id, tg_user_id, name), + ) + return chat_id + + +def get_last_chat(tg_user_id: int) -> dict | None: + with _conn() as con: + row = con.execute( + "SELECT * FROM chats WHERE tg_user_id = ? AND archived_at IS NULL " + "ORDER BY created_at DESC LIMIT 1", + (tg_user_id,), + ).fetchone() + return dict(row) if row else None + + +def get_user_chats(tg_user_id: int) -> list[dict]: + with _conn() as con: + rows = con.execute( + "SELECT * FROM chats WHERE tg_user_id = ? AND archived_at IS NULL " + "ORDER BY created_at ASC", + (tg_user_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def count_chats(tg_user_id: int) -> int: + with _conn() as con: + row = con.execute( + "SELECT COUNT(*) FROM chats WHERE tg_user_id = ? AND archived_at IS NULL", + (tg_user_id,), + ).fetchone() + return row[0] + + +def get_chat_by_id(chat_id: str) -> dict | None: + with _conn() as con: + row = con.execute("SELECT * FROM chats WHERE chat_id = ?", (chat_id,)).fetchone() + return dict(row) if row else None + + +def rename_chat(chat_id: str, new_name: str) -> None: + with _conn() as con: + con.execute("UPDATE chats SET name = ? WHERE chat_id = ?", (new_name, chat_id)) + + +def archive_chat(chat_id: str) -> None: + with _conn() as con: + con.execute( + "UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE chat_id = ?", + (chat_id,), + ) diff --git a/adapter/telegram/handlers/__init__.py b/adapter/telegram/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/telegram/handlers/auth.py b/adapter/telegram/handlers/auth.py new file mode 100644 index 0000000..9f8329e --- /dev/null +++ b/adapter/telegram/handlers/auth.py @@ -0,0 +1,67 @@ +# adapter/telegram/handlers/auth.py +from __future__ import annotations + +from aiogram import Router +from aiogram.filters import CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from adapter.telegram import db +from adapter.telegram.states import ChatState +from core.handler import EventDispatcher +from core.protocol import IncomingCommand + +router = Router(name="auth") + + +@router.message(CommandStart()) +async def cmd_start( + message: Message, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + tg_id = message.from_user.id + display_name = message.from_user.full_name + + # Ensure user exists in platform (mock) + from platform.mock import MockPlatformClient + # platform is available via dispatcher._platform + platform = dispatcher._platform + user = await platform.get_or_create_user( + external_id=str(tg_id), + platform="telegram", + display_name=display_name, + ) + platform_user_id = user.user_id + + # Upsert in local DB + db.get_or_create_tg_user(tg_id, platform_user_id, display_name) + + last_chat = db.get_last_chat(tg_id) + + if last_chat is None: + # New user — create first chat + chat_name = "Чат #1" + chat_id = db.create_chat(tg_id, chat_name) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await message.answer( + f"Привет, {message.from_user.first_name}! 👋\n" + f"Я создал тебе первый чат. Просто пиши.\n\n" + f"Команды: /new — новый чат, /chats — список чатов" + ) + else: + chat_id = last_chat["chat_id"] + chat_name = last_chat["name"] + 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}]") + + # Register auth in core + event = IncomingCommand( + user_id=platform_user_id, + platform="telegram", + chat_id=chat_id, + command="start", + ) + await dispatcher.dispatch(event) diff --git a/adapter/telegram/handlers/chat.py b/adapter/telegram/handlers/chat.py new file mode 100644 index 0000000..e88484e --- /dev/null +++ b/adapter/telegram/handlers/chat.py @@ -0,0 +1,122 @@ +# adapter/telegram/handlers/chat.py +from __future__ import annotations + +import asyncio + +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 import db +from adapter.telegram.converter import format_outgoing, from_message +from adapter.telegram.keyboards.chat import chats_list_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 + +router = Router(name="chat") + + +async def _send_outgoing(message: Message, chat_name: str, events: list) -> 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" + kb = confirm_keyboard(action_id) + await message.answer(format_outgoing(chat_name, event), reply_markup=kb) + elif isinstance(event, OutgoingMessage): + await message.answer(format_outgoing(chat_name, event)) + + +@router.message(ChatState.idle, F.text | F.photo | F.document | F.voice) +async def handle_message( + message: Message, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + data = await state.get_data() + chat_id = data.get("active_chat_id") + chat_name = data.get("active_chat_name", "Чат") + + if not chat_id: + await message.answer("Нет активного чата. Введите /start") + return + + await state.set_state(ChatState.waiting_response) + + # Typing indicator loop + async def _typing_loop(): + while True: + await message.bot.send_chat_action(message.chat.id, "typing") + await asyncio.sleep(4) + + task = asyncio.create_task(_typing_loop()) + 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) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + await state.set_state(ChatState.idle) + await _send_outgoing(message, chat_name, events) + + +@router.message(Command("new")) +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 + + count = db.count_chats(tg_id) + chat_name = name or f"Чат #{count + 1}" + + chat_id = db.create_chat(tg_id, chat_name) + 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}] создан. Пиши!") + + +@router.message(Command("chats")) +async def cmd_list_chats(message: Message, state: FSMContext) -> None: + tg_id = message.from_user.id + chats = db.get_user_chats(tg_id) + if not chats: + await message.answer("Нет активных чатов. Введи /new чтобы создать.") + return + + data = await state.get_data() + active_id = data.get("active_chat_id") + kb = chats_list_keyboard(chats, active_id) + await message.answer("Твои чаты:", reply_markup=kb) + + +@router.callback_query(F.data.startswith("switch:")) +async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None: + _, 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) + await callback.message.edit_text(f"✅ Переключился на [{chat_name}]") + await callback.answer() + + +@router.callback_query(F.data == "new_chat") +async def cb_new_chat(callback: CallbackQuery, state: FSMContext) -> None: + tg_id = callback.from_user.id + count = db.count_chats(tg_id) + chat_name = f"Чат #{count + 1}" + chat_id = db.create_chat(tg_id, chat_name) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await callback.message.edit_text(f"✅ [{chat_name}] создан. Пиши!") + await callback.answer() diff --git a/adapter/telegram/handlers/confirm.py b/adapter/telegram/handlers/confirm.py new file mode 100644 index 0000000..d8a1839 --- /dev/null +++ b/adapter/telegram/handlers/confirm.py @@ -0,0 +1,49 @@ +# adapter/telegram/handlers/confirm.py +from __future__ import annotations + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from core.handler import EventDispatcher +from core.protocol import IncomingCallback + +router = Router(name="confirm") + + +@router.callback_query(F.data.startswith("confirm:")) +async def handle_confirm( + callback: CallbackQuery, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + parts = callback.data.split(":", 2) + _, decision, action_id = parts # "yes" or "no" + + data = await state.get_data() + chat_id = data.get("active_chat_id", "") + chat_name = data.get("active_chat_name", "Чат") + + 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) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) + + incoming = IncomingCallback( + user_id=platform_user_id, + platform="telegram", + chat_id=chat_id, + action="confirm" if decision == "yes" else "cancel", + payload={"action_id": action_id}, + ) + events = await dispatcher.dispatch(incoming) + + 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)) + + await callback.answer() diff --git a/adapter/telegram/handlers/settings.py b/adapter/telegram/handlers/settings.py new file mode 100644 index 0000000..546b0af --- /dev/null +++ b/adapter/telegram/handlers/settings.py @@ -0,0 +1,189 @@ +# 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 ChatState, 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", "") + tg_id = callback.from_user.id + # Get platform user id + from adapter.telegram import db as tgdb + tg_user = tgdb.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)) + + 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] + 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) + platform_user_id = tg_user.get("platform_user_id", str(tg_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: + 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) + platform_user_id = tg_user.get("platform_user_id", str(tg_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] + 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) + platform_user_id = tg_user.get("platform_user_id", str(tg_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 "" + from adapter.telegram import db as tgdb + tg_id = message.from_user.id + tg_user = tgdb.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)) + + 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: + 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) + platform_user_id = tg_user.get("platform_user_id", str(tg_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() diff --git a/adapter/telegram/keyboards/__init__.py b/adapter/telegram/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/telegram/keyboards/chat.py b/adapter/telegram/keyboards/chat.py new file mode 100644 index 0000000..b580993 --- /dev/null +++ b/adapter/telegram/keyboards/chat.py @@ -0,0 +1,16 @@ +# adapter/telegram/keyboards/chat.py +from __future__ import annotations + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + +def chats_list_keyboard(chats: list[dict], active_chat_id: str | None) -> InlineKeyboardMarkup: + buttons = [] + for chat in chats: + mark = "● " if chat["chat_id"] == active_chat_id else "" + buttons.append([InlineKeyboardButton( + text=f"{mark}{chat['name']}", + callback_data=f"switch:{chat['chat_id']}:{chat['name']}", + )]) + buttons.append([InlineKeyboardButton(text="➕ Новый чат", callback_data="new_chat")]) + return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/adapter/telegram/keyboards/confirm.py b/adapter/telegram/keyboards/confirm.py new file mode 100644 index 0000000..348898c --- /dev/null +++ b/adapter/telegram/keyboards/confirm.py @@ -0,0 +1,11 @@ +# adapter/telegram/keyboards/confirm.py +from __future__ import annotations + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + +def confirm_keyboard(action_id: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="✅ Да", callback_data=f"confirm:yes:{action_id}"), + InlineKeyboardButton(text="❌ Нет", callback_data=f"confirm:no:{action_id}"), + ]]) diff --git a/adapter/telegram/keyboards/settings.py b/adapter/telegram/keyboards/settings.py new file mode 100644 index 0000000..1d0c2f6 --- /dev/null +++ b/adapter/telegram/keyboards/settings.py @@ -0,0 +1,52 @@ +# adapter/telegram/keyboards/settings.py +from __future__ import annotations + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + +from platform.interface import UserSettings + + +def settings_main_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🧩 Скиллы", callback_data="settings:skills"), + InlineKeyboardButton(text="🔗 Коннекторы", callback_data="settings:connectors"), + ], + [ + InlineKeyboardButton(text="🧠 Личность", callback_data="settings:soul"), + InlineKeyboardButton(text="🔒 Безопасность", callback_data="settings:safety"), + ], + [ + InlineKeyboardButton(text="💳 Подписка", callback_data="settings:plan"), + ], + ]) + + +def skills_keyboard(skills: dict[str, bool]) -> InlineKeyboardMarkup: + buttons = [] + for skill, enabled in skills.items(): + icon = "✅" if enabled else "❌" + buttons.append([InlineKeyboardButton( + text=f"{icon} {skill}", + callback_data=f"toggle_skill:{skill}", + )]) + buttons.append([InlineKeyboardButton(text="← Назад", callback_data="settings:back")]) + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def safety_keyboard(safety: dict[str, bool]) -> InlineKeyboardMarkup: + buttons = [] + for trigger, enabled in safety.items(): + icon = "✅" if enabled else "❌" + buttons.append([InlineKeyboardButton( + text=f"{icon} {trigger}", + callback_data=f"toggle_safety:{trigger}", + )]) + buttons.append([InlineKeyboardButton(text="← Назад", callback_data="settings:back")]) + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def back_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="← Назад", callback_data="settings:back")], + ]) diff --git a/adapter/telegram/states.py b/adapter/telegram/states.py new file mode 100644 index 0000000..251c6ef --- /dev/null +++ b/adapter/telegram/states.py @@ -0,0 +1,13 @@ +# adapter/telegram/states.py +from aiogram.fsm.state import State, StatesGroup + + +class ChatState(StatesGroup): + idle = State() # В активном чате, ждём сообщения + waiting_response = State() # Запрос ушёл на платформу, ждём ответа + + +class SettingsState(StatesGroup): + menu = State() # Главное меню настроек + soul_editing = State() # Редактирует имя/инструкции агента + confirm_action = State() # Подтверждение деструктивного действия