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.
This commit is contained in:
parent
a3449fc864
commit
9c555261b3
15 changed files with 791 additions and 0 deletions
0
adapter/__init__.py
Normal file
0
adapter/__init__.py
Normal file
0
adapter/telegram/__init__.py
Normal file
0
adapter/telegram/__init__.py
Normal file
101
adapter/telegram/bot.py
Normal file
101
adapter/telegram/bot.py
Normal file
|
|
@ -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())
|
||||
50
adapter/telegram/converter.py
Normal file
50
adapter/telegram/converter.py
Normal file
|
|
@ -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)
|
||||
121
adapter/telegram/db.py
Normal file
121
adapter/telegram/db.py
Normal file
|
|
@ -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,),
|
||||
)
|
||||
0
adapter/telegram/handlers/__init__.py
Normal file
0
adapter/telegram/handlers/__init__.py
Normal file
67
adapter/telegram/handlers/auth.py
Normal file
67
adapter/telegram/handlers/auth.py
Normal file
|
|
@ -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)
|
||||
122
adapter/telegram/handlers/chat.py
Normal file
122
adapter/telegram/handlers/chat.py
Normal file
|
|
@ -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()
|
||||
49
adapter/telegram/handlers/confirm.py
Normal file
49
adapter/telegram/handlers/confirm.py
Normal file
|
|
@ -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()
|
||||
189
adapter/telegram/handlers/settings.py
Normal file
189
adapter/telegram/handlers/settings.py
Normal file
|
|
@ -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()
|
||||
0
adapter/telegram/keyboards/__init__.py
Normal file
0
adapter/telegram/keyboards/__init__.py
Normal file
16
adapter/telegram/keyboards/chat.py
Normal file
16
adapter/telegram/keyboards/chat.py
Normal file
|
|
@ -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)
|
||||
11
adapter/telegram/keyboards/confirm.py
Normal file
11
adapter/telegram/keyboards/confirm.py
Normal file
|
|
@ -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}"),
|
||||
]])
|
||||
52
adapter/telegram/keyboards/settings.py
Normal file
52
adapter/telegram/keyboards/settings.py
Normal file
|
|
@ -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")],
|
||||
])
|
||||
13
adapter/telegram/states.py
Normal file
13
adapter/telegram/states.py
Normal file
|
|
@ -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() # Подтверждение деструктивного действия
|
||||
Loading…
Add table
Add a link
Reference in a new issue