- platform/interface.py: PlatformClient Protocol + Pydantic models (User, MessageResponse, UserSettings) — no explicit session management, Master handles container lifecycle - platform/mock.py: MockPlatformClient with simulated latency, [MOCK] responses, is_new correctly True only on first creation - core/protocol.py: unified dataclasses for all events and responses (IncomingMessage/Command/Callback, OutgoingMessage/UI/Notification, AuthFlow, ChatContext, SettingsAction, etc.) - core/store.py: StateStore Protocol + InMemoryStore (tests) + SQLiteStore (prod) with JSON serialization - core/chat.py: ChatManager — chat metadata (C1/C2/C3), not container lifecycle (that's the platform's job) - core/auth.py: AuthManager — start_flow / confirm / is_authenticated - core/settings.py: SettingsManager — get/apply with store cache - core/handler.py: EventDispatcher — registry-based routing with keys (command name, action name, attachment type, "*" catch-all) - core/handlers/: register_all() + start/new/message/callback/settings handlers; voice slot falls back to stub text until voice_handler added - conftest.py: sys.path fix so local platform/ shadows stdlib platform - docs/api-contract.md: rewritten for Lambda Lab 3.0 container model 46 tests passing, 0 warnings.
111 lines
3.7 KiB
Python
111 lines
3.7 KiB
Python
# core/chat.py
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
import structlog
|
||
|
||
from core.protocol import ChatContext
|
||
from core.store import StateStore
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
def _to_dict(ctx: ChatContext) -> dict:
|
||
return {
|
||
"chat_id": ctx.chat_id,
|
||
"display_name": ctx.display_name,
|
||
"platform": ctx.platform,
|
||
"surface_ref": ctx.surface_ref,
|
||
"created_at": ctx.created_at.isoformat(),
|
||
"is_archived": ctx.is_archived,
|
||
}
|
||
|
||
|
||
def _from_dict(d: dict) -> ChatContext:
|
||
return ChatContext(
|
||
chat_id=d["chat_id"],
|
||
display_name=d["display_name"],
|
||
platform=d["platform"],
|
||
surface_ref=d["surface_ref"],
|
||
created_at=datetime.fromisoformat(d["created_at"]),
|
||
is_archived=d.get("is_archived", False),
|
||
)
|
||
|
||
|
||
class ChatManager:
|
||
"""
|
||
Управляет метаданными чатов (C1/C2/C3 в workspace пользователя).
|
||
НЕ управляет lifecycle контейнера — это дело Master'а на стороне платформы.
|
||
"""
|
||
|
||
def __init__(self, platform: object, store: StateStore) -> None:
|
||
self._store = store
|
||
|
||
def _key(self, user_id: str, chat_id: str) -> str:
|
||
return f"chat:{user_id}:{chat_id}"
|
||
|
||
async def get_or_create(
|
||
self,
|
||
user_id: str,
|
||
chat_id: str,
|
||
platform: str,
|
||
surface_ref: str,
|
||
name: str | None = None,
|
||
) -> ChatContext:
|
||
key = self._key(user_id, chat_id)
|
||
stored = await self._store.get(key)
|
||
if stored:
|
||
return _from_dict(stored)
|
||
|
||
ctx = ChatContext(
|
||
chat_id=chat_id,
|
||
display_name=name or f"Чат {chat_id}",
|
||
platform=platform,
|
||
surface_ref=surface_ref,
|
||
created_at=datetime.now(UTC),
|
||
)
|
||
await self._store.set(key, _to_dict(ctx))
|
||
logger.info("Chat created", chat_id=chat_id, user_id=user_id)
|
||
return ctx
|
||
|
||
async def get(self, chat_id: str, user_id: str | None = None) -> ChatContext | None:
|
||
if user_id:
|
||
stored = await self._store.get(self._key(user_id, chat_id))
|
||
return _from_dict(stored) if stored else None
|
||
# Scan by chat_id suffix when user_id unknown (slower)
|
||
for key in await self._store.keys("chat:"):
|
||
if key.endswith(f":{chat_id}"):
|
||
stored = await self._store.get(key)
|
||
if stored:
|
||
return _from_dict(stored)
|
||
return None
|
||
|
||
async def rename(self, chat_id: str, name: str, user_id: str | None = None) -> ChatContext:
|
||
ctx = await self.get(chat_id, user_id)
|
||
if not ctx:
|
||
raise ValueError(f"Chat {chat_id} not found")
|
||
ctx.display_name = name
|
||
for key in await self._store.keys("chat:"):
|
||
if key.endswith(f":{chat_id}"):
|
||
await self._store.set(key, _to_dict(ctx))
|
||
break
|
||
return ctx
|
||
|
||
async def archive(self, chat_id: str, user_id: str | None = None) -> None:
|
||
ctx = await self.get(chat_id, user_id)
|
||
if not ctx:
|
||
raise ValueError(f"Chat {chat_id} not found")
|
||
ctx.is_archived = True
|
||
for key in await self._store.keys("chat:"):
|
||
if key.endswith(f":{chat_id}"):
|
||
await self._store.set(key, _to_dict(ctx))
|
||
break
|
||
|
||
async def list_active(self, user_id: str) -> list[ChatContext]:
|
||
chats = []
|
||
for key in await self._store.keys(f"chat:{user_id}:"):
|
||
stored = await self._store.get(key)
|
||
if stored and not stored.get("is_archived"):
|
||
chats.append(_from_dict(stored))
|
||
return chats
|