feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons
This commit is contained in:
@@ -0,0 +1,845 @@
|
||||
"""``TelegramFrontend`` - the private chat with the bot as the window (§3.8).
|
||||
|
||||
General is the master, a topic is a branch. The user makes a topic and the
|
||||
first message in it spawns the branch (``seed=morning``); a message into a
|
||||
topic whose branch is merged or closed spawns a new branch on the same
|
||||
topic. Replies stream as drafts and land through the outbox; turns that
|
||||
came from other windows are mirrored with a marker; ``origin=system`` is
|
||||
never shown. ``AskUserQuestion`` becomes inline buttons (§3.7).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import html
|
||||
import logging
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
ReactionTypeEmoji,
|
||||
Update,
|
||||
)
|
||||
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||
from beaver_gateway.frontends.telegram.inbox import Inbox
|
||||
from beaver_gateway.frontends.telegram.outbox import Outbox
|
||||
from beaver_gateway.frontends.telegram.render import status_label
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beaver_gateway.core.bus import Event, EventBus
|
||||
from beaver_gateway.core.conversations import Conversations
|
||||
from beaver_gateway.core.kinds import Kind
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||
|
||||
__all__ = ["FRONTEND", "Attachments", "TelegramFrontend"]
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.telegram")
|
||||
|
||||
FRONTEND = "telegram"
|
||||
_DONE = "done"
|
||||
_COMMANDS = ("merge", "new", "chat", "status", "start", "help")
|
||||
_HELP = (
|
||||
"General - мастер, топик - ветка. Создай топик и пиши в него.\n"
|
||||
"/merge - слить ветку в мастер\n"
|
||||
"/new [название] - новая ветка (в General - новый топик)\n"
|
||||
"/chat <тема> - открыть глубокий чат в vault\n"
|
||||
"/status - что с этим разговором" # noqa: RUF001
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Attachments:
|
||||
"""Where files from Telegram go.
|
||||
|
||||
``ephemeral`` - under the gateway's data dir, swept after ``keep_days``;
|
||||
``vault`` - into a directory the agent owns. Pulling what is worth
|
||||
keeping into the vault is the agent's job either way.
|
||||
"""
|
||||
|
||||
mode: Literal["ephemeral", "vault"] = "ephemeral"
|
||||
dir: Path | None = None
|
||||
keep_days: int = 7
|
||||
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
if self.dir is not None:
|
||||
return self.dir
|
||||
if self.mode == "vault":
|
||||
msg = "Attachments(mode='vault') needs `dir`"
|
||||
raise ValueError(msg)
|
||||
return Path(tempfile.gettempdir()) / "beaver-attachments"
|
||||
|
||||
|
||||
EPHEMERAL = Attachments()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ask:
|
||||
conversation_id: str
|
||||
chat_id: int
|
||||
thread_id: int | None
|
||||
questions: list[dict[str, Any]]
|
||||
messages: list[int] = field(default_factory=list)
|
||||
picked: dict[int, list[str]] = field(default_factory=dict)
|
||||
done: set[int] = field(default_factory=set)
|
||||
|
||||
|
||||
class TelegramFrontend(Frontend):
|
||||
name = FRONTEND
|
||||
kinds = ("master", "branch")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
user_id: int,
|
||||
master_agent: str | None = None,
|
||||
branch_agent: str | None = None,
|
||||
chat_id: int | None = None,
|
||||
attachments: Attachments = EPHEMERAL,
|
||||
draft_interval: float = 0.7,
|
||||
queued_reaction: str = "👀",
|
||||
poll_timeout: int = 30,
|
||||
outbox_backoff: float = 2.0,
|
||||
) -> None:
|
||||
self._token = token
|
||||
self.user_id = user_id
|
||||
self.chat_id = chat_id if chat_id is not None else user_id
|
||||
self.master_agent = master_agent
|
||||
self.branch_agent = branch_agent
|
||||
self.attachments = attachments
|
||||
self.draft_interval = draft_interval
|
||||
self.queued_reaction = queued_reaction
|
||||
self.poll_timeout = poll_timeout
|
||||
self.outbox_backoff = outbox_backoff
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._bot: Bot | None = None
|
||||
self._inbox: Inbox | None = None
|
||||
self._outbox: Outbox | None = None
|
||||
self._targets: dict[str, tuple[int, int | None] | None] = {}
|
||||
self._topic_names: dict[int, str] = {}
|
||||
self._drafts: dict[str, Draft] = {}
|
||||
self._asks: dict[str, _Ask] = {}
|
||||
self._reactions: dict[int, tuple[int, int]] = {}
|
||||
self._tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
# ---- Frontend --------------------------------------------------------
|
||||
|
||||
def agent_for(self, kind: Kind) -> str | None:
|
||||
return {"master": self.master_agent, "branch": self.branch_agent}.get(kind)
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
if runtime.conversations is None or runtime.bus is None:
|
||||
msg = "TelegramFrontend needs runtime.conversations and runtime.bus"
|
||||
raise RuntimeError(msg)
|
||||
self._runtime = runtime
|
||||
if self._bot is None:
|
||||
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None))
|
||||
self._inbox = Inbox(
|
||||
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
|
||||
)
|
||||
self._outbox = Outbox(
|
||||
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
|
||||
)
|
||||
|
||||
async def serve(self) -> None:
|
||||
me = await self.bot.get_me()
|
||||
_log.info(
|
||||
"telegram: @%s, user %s, chat %s, topics=%s",
|
||||
me.username,
|
||||
self.user_id,
|
||||
self.chat_id,
|
||||
getattr(me, "has_topics_enabled", None),
|
||||
)
|
||||
self._sweep_attachments()
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(self.inbox.run())
|
||||
tg.create_task(self.outbox.run())
|
||||
tg.create_task(self._events())
|
||||
finally:
|
||||
for draft in list(self._drafts.values()):
|
||||
await draft.stop()
|
||||
await self.bot.session.close()
|
||||
|
||||
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||
if conv.kind == "master":
|
||||
return await self.conversations.bind(
|
||||
conv, frontend=FRONTEND, external_id=self._ext(None)
|
||||
)
|
||||
if conv.kind != "branch":
|
||||
return None
|
||||
topic = await self.bot.create_forum_topic(
|
||||
self.chat_id, name=(conv.title or "ветка")[:128]
|
||||
)
|
||||
self._topic_names[topic.message_thread_id] = topic.name
|
||||
return await self.conversations.bind(
|
||||
conv, frontend=FRONTEND, external_id=self._ext(topic.message_thread_id)
|
||||
)
|
||||
|
||||
async def mark_topic(self, conv: Conversation, prefix: str = "✅ ") -> bool:
|
||||
"""Rotation hook for M3.
|
||||
|
||||
``closeForumTopic`` does not exist in private chats; the state of a
|
||||
merged or closed branch lives in its name.
|
||||
"""
|
||||
target = await self._target_of(conv)
|
||||
if target is None or target[1] is None:
|
||||
return False
|
||||
name = self._topic_names.get(target[1]) or conv.title or "ветка"
|
||||
if name.startswith(prefix):
|
||||
return True
|
||||
await self.bot.edit_forum_topic(
|
||||
target[0], target[1], name=f"{prefix}{name}"[:128]
|
||||
)
|
||||
self._topic_names[target[1]] = f"{prefix}{name}"
|
||||
return True
|
||||
|
||||
# ---- plumbing --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def bot(self) -> Bot:
|
||||
if self._bot is None:
|
||||
msg = "configure() must be called before use"
|
||||
raise RuntimeError(msg)
|
||||
return self._bot
|
||||
|
||||
@property
|
||||
def inbox(self) -> Inbox:
|
||||
return cast("Inbox", self._inbox)
|
||||
|
||||
@property
|
||||
def outbox(self) -> Outbox:
|
||||
return cast("Outbox", self._outbox)
|
||||
|
||||
@property
|
||||
def conversations(self) -> Conversations:
|
||||
return cast(
|
||||
"Conversations", cast("GatewayRuntime", self._runtime).conversations
|
||||
)
|
||||
|
||||
@property
|
||||
def bus(self) -> EventBus:
|
||||
return cast("EventBus", cast("GatewayRuntime", self._runtime).bus)
|
||||
|
||||
def _ext(self, thread_id: int | None) -> str:
|
||||
return f"{self.chat_id}/{thread_id}" if thread_id else str(self.chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _parse_ext(ext: str) -> tuple[int, int | None]:
|
||||
chat, _, thread = ext.partition("/")
|
||||
return int(chat), int(thread) if thread else None
|
||||
|
||||
async def _target_of(self, conv: Conversation) -> tuple[int, int | None] | None:
|
||||
key = conv.external_id
|
||||
if key not in self._targets:
|
||||
bound = next(
|
||||
(
|
||||
b
|
||||
for b in await self.conversations.bindings(conv)
|
||||
if b.frontend == FRONTEND and b.visible
|
||||
),
|
||||
None,
|
||||
)
|
||||
self._targets[key] = self._parse_ext(bound.external_id) if bound else None
|
||||
return self._targets[key]
|
||||
|
||||
async def _deliver(
|
||||
self,
|
||||
conv: Conversation,
|
||||
text: str,
|
||||
*,
|
||||
turn_id: str | None = None,
|
||||
key: str | None = None,
|
||||
) -> None:
|
||||
target = await self._target_of(conv)
|
||||
if target is None or not text.strip():
|
||||
return
|
||||
await self.outbox.enqueue(
|
||||
chat_id=target[0],
|
||||
thread_id=target[1],
|
||||
text=text,
|
||||
conversation_id=conv.id,
|
||||
turn_id=turn_id,
|
||||
dedupe_key=key,
|
||||
)
|
||||
|
||||
async def _master(self) -> Conversation:
|
||||
ext = self._ext(None)
|
||||
conv = await self.conversations.find_bound(frontend=FRONTEND, external_id=ext)
|
||||
if conv is not None and conv.status == "open":
|
||||
return conv
|
||||
masters = await self.conversations.find(kind="master", status="open", limit=1)
|
||||
if masters:
|
||||
await self.conversations.bind(
|
||||
masters[0], frontend=FRONTEND, external_id=ext
|
||||
)
|
||||
return masters[0]
|
||||
return await self.conversations.spawn(
|
||||
kind="master", seed="clean", origin=FRONTEND, binding=(FRONTEND, ext)
|
||||
)
|
||||
|
||||
async def _branch(
|
||||
self, thread_id: int, *, title: str | None, text: str | None
|
||||
) -> Conversation:
|
||||
master = await self._master()
|
||||
return await self.conversations.spawn(
|
||||
kind="branch",
|
||||
seed="morning",
|
||||
parent=master,
|
||||
title=(title or self._topic_names.get(thread_id) or "ветка")[:128],
|
||||
text=text,
|
||||
origin=FRONTEND,
|
||||
binding=(FRONTEND, self._ext(thread_id)),
|
||||
)
|
||||
|
||||
# ---- inbox -----------------------------------------------------------
|
||||
|
||||
async def _handle(self, update: Update) -> None:
|
||||
if update.message is not None:
|
||||
await self._on_message(update.message)
|
||||
elif update.callback_query is not None:
|
||||
await self._on_callback(update.callback_query)
|
||||
|
||||
async def _on_message(self, message: Message) -> None:
|
||||
if message.forum_topic_created is not None and message.message_thread_id:
|
||||
self._topic_names[message.message_thread_id] = (
|
||||
message.forum_topic_created.name
|
||||
)
|
||||
if message.from_user is None or message.from_user.id != self.user_id:
|
||||
_log.warning(
|
||||
"ignoring message from %s", getattr(message.from_user, "id", None)
|
||||
)
|
||||
return
|
||||
if message.chat.id != self.chat_id:
|
||||
return
|
||||
in_topic = message.is_topic_message or message.forum_topic_created is not None
|
||||
thread_id = message.message_thread_id if in_topic else None
|
||||
if message.forum_topic_created is not None and thread_id is not None:
|
||||
if await self._live(thread_id) is None:
|
||||
await self._branch(
|
||||
thread_id, title=message.forum_topic_created.name, text=None
|
||||
)
|
||||
return
|
||||
if message.forum_topic_edited is not None and thread_id is not None:
|
||||
if message.forum_topic_edited.name:
|
||||
self._topic_names[thread_id] = message.forum_topic_edited.name
|
||||
return
|
||||
text = (message.text or message.caption or "").strip()
|
||||
attachment = await self._save_attachment(message, thread_id)
|
||||
if attachment:
|
||||
text = f"{text}\n\n{attachment}".strip()
|
||||
if not text:
|
||||
return
|
||||
if message.text and message.text.startswith("/"):
|
||||
command, _, args = message.text[1:].partition(" ")
|
||||
command = command.partition("@")[0].lower()
|
||||
if command in _COMMANDS:
|
||||
await self._command(command, args.strip(), message, thread_id)
|
||||
return
|
||||
if thread_id is None:
|
||||
conv = await self._master()
|
||||
else:
|
||||
conv = await self._live(thread_id)
|
||||
if conv is None:
|
||||
await self._branch(thread_id, title=self._title_from(text), text=text)
|
||||
return
|
||||
pending = self.conversations.pending_question(conv.external_id)
|
||||
if pending is not None and self.conversations.answer(pending[0], text):
|
||||
await self._close_ask(pending[0], f"✍️ {text}")
|
||||
return
|
||||
item = await self.conversations.post(conv, text, origin=FRONTEND)
|
||||
if conv.running_turn or conv.pending_question:
|
||||
await self._react(message, item.id)
|
||||
|
||||
async def _live(self, thread_id: int) -> Conversation | None:
|
||||
conv = await self.conversations.find_bound(
|
||||
frontend=FRONTEND, external_id=self._ext(thread_id)
|
||||
)
|
||||
return conv if conv is not None and conv.status == "open" else None
|
||||
|
||||
@staticmethod
|
||||
def _title_from(text: str) -> str:
|
||||
line = text.strip().splitlines()[0]
|
||||
return line if len(line) <= 60 else line[:57] + "…"
|
||||
|
||||
async def _react(self, message: Message, item_id: int | None) -> None:
|
||||
if not self.queued_reaction or item_id is None:
|
||||
return
|
||||
try:
|
||||
await self.bot.set_message_reaction(
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
reaction=[ReactionTypeEmoji(emoji=self.queued_reaction)],
|
||||
)
|
||||
except TelegramAPIError as exc:
|
||||
_log.debug("reaction failed: %s", exc)
|
||||
return
|
||||
self._reactions[item_id] = (message.chat.id, message.message_id)
|
||||
|
||||
async def _unreact(self, item_id: int | None) -> None:
|
||||
target = self._reactions.pop(cast("int", item_id), None) if item_id else None
|
||||
if target is None:
|
||||
return
|
||||
with contextlib.suppress(TelegramAPIError):
|
||||
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
|
||||
|
||||
async def _save_attachment(
|
||||
self, message: Message, thread_id: int | None
|
||||
) -> str | None:
|
||||
file_id: str | None = None
|
||||
name: str | None = None
|
||||
kind = ""
|
||||
size: int | None = None
|
||||
if message.photo:
|
||||
photo = message.photo[-1]
|
||||
file_id, kind, size = photo.file_id, "фото", photo.file_size
|
||||
name = f"{photo.file_unique_id}.jpg"
|
||||
elif message.document:
|
||||
doc = message.document
|
||||
file_id, kind, size = doc.file_id, "файл", doc.file_size
|
||||
name = doc.file_name or f"{doc.file_unique_id}.bin"
|
||||
elif message.voice:
|
||||
file_id, kind, size = (
|
||||
message.voice.file_id,
|
||||
"голосовое",
|
||||
message.voice.file_size,
|
||||
)
|
||||
name = f"{message.voice.file_unique_id}.ogg"
|
||||
elif message.audio:
|
||||
file_id, kind, size = (
|
||||
message.audio.file_id,
|
||||
"аудио",
|
||||
message.audio.file_size,
|
||||
)
|
||||
name = message.audio.file_name or f"{message.audio.file_unique_id}.mp3"
|
||||
elif message.video:
|
||||
file_id, kind, size = (
|
||||
message.video.file_id,
|
||||
"видео",
|
||||
message.video.file_size,
|
||||
)
|
||||
name = message.video.file_name or f"{message.video.file_unique_id}.mp4"
|
||||
elif message.video_note:
|
||||
file_id, kind, size = (
|
||||
message.video_note.file_id,
|
||||
"кружок",
|
||||
message.video_note.file_size,
|
||||
)
|
||||
name = f"{message.video_note.file_unique_id}.mp4"
|
||||
if file_id is None or name is None:
|
||||
return None
|
||||
if size and size > 20 * 1024 * 1024:
|
||||
return (
|
||||
f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - "
|
||||
"больше 20 МБ, Telegram не отдаёт ботам]"
|
||||
)
|
||||
folder = self.attachments.root / (self._ext(thread_id).replace("/", "_"))
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
self.attachments.root.chmod(0o755)
|
||||
folder.chmod(0o755)
|
||||
path = folder / f"{int(time.time())}-{Path(name).name}"
|
||||
try:
|
||||
await self.bot.download(file_id, destination=path)
|
||||
path.chmod(0o644)
|
||||
except (TelegramAPIError, OSError) as exc:
|
||||
_log.warning("attachment download failed: %s", exc)
|
||||
return f"[вложение: {kind} {name} - не скачалось: {exc}]"
|
||||
shown = f", {size // 1024} КБ" if size else ""
|
||||
return f"[вложение: {kind} {path}{shown}]"
|
||||
|
||||
def _sweep_attachments(self) -> None:
|
||||
if self.attachments.mode != "ephemeral":
|
||||
return
|
||||
root = self.attachments.root
|
||||
if not root.exists():
|
||||
return
|
||||
cutoff = time.time() - self.attachments.keep_days * 86400
|
||||
for path in root.rglob("*"):
|
||||
with contextlib.suppress(OSError):
|
||||
if path.is_file() and path.stat().st_mtime < cutoff:
|
||||
path.unlink()
|
||||
|
||||
# ---- commands --------------------------------------------------------
|
||||
|
||||
async def _command(
|
||||
self, command: str, args: str, message: Message, thread_id: int | None
|
||||
) -> None:
|
||||
reply = await self._run_command(command, args, thread_id)
|
||||
if reply:
|
||||
await self.outbox.enqueue(
|
||||
chat_id=message.chat.id, thread_id=thread_id, text=reply
|
||||
)
|
||||
|
||||
async def _run_command(self, command: str, args: str, thread_id: int | None) -> str:
|
||||
if command in ("start", "help"):
|
||||
return _HELP
|
||||
conv = (
|
||||
await self._master() if thread_id is None else await self._live(thread_id)
|
||||
)
|
||||
if command == "status":
|
||||
return await self._status(conv)
|
||||
if command == "merge":
|
||||
if conv is None or conv.kind != "branch":
|
||||
return "сливать нечего: это не открытая ветка"
|
||||
self._spawn_task(self._merge(conv))
|
||||
return "🔀 сливаю в мастер…"
|
||||
if command == "new":
|
||||
if thread_id is None:
|
||||
child = await self.conversations.spawn(
|
||||
kind="branch",
|
||||
seed="morning",
|
||||
parent=conv,
|
||||
title=args or None,
|
||||
origin=FRONTEND,
|
||||
)
|
||||
return f"🌿 ветка «{child.title or child.external_id}» - в новом топике"
|
||||
if conv is not None:
|
||||
await self.conversations.set_status(conv, "closed")
|
||||
await self._branch(thread_id, title=args or None, text=None)
|
||||
return "🌿 новая ветка на этом топике"
|
||||
if command == "chat":
|
||||
if not args:
|
||||
return "/chat <тема>"
|
||||
try:
|
||||
deep = await self.conversations.spawn(
|
||||
kind="deep", seed="clean", title=args, origin=FRONTEND
|
||||
)
|
||||
except (ValueError, LookupError) as exc:
|
||||
return f"не вышло: {exc}"
|
||||
where = next(
|
||||
(
|
||||
b.external_id
|
||||
for b in await self.conversations.bindings(deep)
|
||||
if b.visible
|
||||
),
|
||||
deep.external_id,
|
||||
)
|
||||
return f"💬 глубокий чат: {where}"
|
||||
return _HELP
|
||||
|
||||
async def _status(self, conv: Conversation | None) -> str:
|
||||
if conv is None:
|
||||
return "этот топик ни к чему не привязан - напиши, и откроется ветка"
|
||||
info = await self.conversations.describe(conv)
|
||||
queued = sum(
|
||||
1
|
||||
for i in await self.conversations.queue.recent(
|
||||
cast("int", conv.id), limit=20
|
||||
)
|
||||
if i.status == "queued"
|
||||
)
|
||||
lines = [
|
||||
f"{conv.kind} · {conv.status} · {conv.agent_name}",
|
||||
f"сессия: {'живая' if info['live'] else 'нет'}"
|
||||
f" · тёрн: {'идёт' if conv.running_turn else 'нет'}"
|
||||
f" · в очереди: {queued}",
|
||||
]
|
||||
if conv.pending_question:
|
||||
lines.append("❓ ждёт ответа на вопрос")
|
||||
if conv.kind == "master":
|
||||
pool = self.conversations.pool
|
||||
lines.append(f"пул: {len(pool)} сессий, rss {pool.rss() // (1 << 20)} МБ")
|
||||
lines.append(f"outbox: {await self.outbox.pending()} в очереди")
|
||||
lines.append(f"id: {conv.external_id}")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _merge(self, conv: Conversation) -> None:
|
||||
try:
|
||||
await self.conversations.merge(conv)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.exception("merge of %s failed", conv.external_id)
|
||||
await self._deliver(conv, f"⚠️ слив не удался: {exc}")
|
||||
|
||||
def _spawn_task(self, coro: Any) -> None:
|
||||
task = asyncio.create_task(coro)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
# ---- bus -------------------------------------------------------------
|
||||
|
||||
async def _events(self) -> None:
|
||||
async for event in self.bus.stream():
|
||||
try:
|
||||
await self._on_event(event)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("event %s failed", event.get("type"))
|
||||
|
||||
async def _on_event(self, event: Event) -> None:
|
||||
kind = event["type"]
|
||||
if kind == "conversation.bound":
|
||||
self._targets.pop(str(event.get("conversation_id")), None)
|
||||
return
|
||||
if kind in ("delivery.sent", "delivery.failed", "conversation.created"):
|
||||
return
|
||||
key = event.get("conversation_id")
|
||||
if not isinstance(key, str):
|
||||
return
|
||||
conv = await self.conversations.get(key)
|
||||
if conv is None:
|
||||
return
|
||||
target = await self._target_of(conv)
|
||||
if target is None:
|
||||
return
|
||||
match kind:
|
||||
case "turn.start":
|
||||
if event.get("origin") == "user":
|
||||
await self._open_draft(key, event, target)
|
||||
case "stream":
|
||||
self._feed_draft(key, event)
|
||||
case "tool":
|
||||
draft = self._drafts.get(key)
|
||||
if draft is not None and event.get("parent_tool_use_id") is None:
|
||||
draft.set_status(
|
||||
f"⏳ {status_label(str(event['name']), event.get('input'))}"
|
||||
)
|
||||
case "turn.end":
|
||||
await self._close_draft(key)
|
||||
if event.get("stop") == "error" and event.get("origin") == "user":
|
||||
await self._deliver(
|
||||
conv,
|
||||
"⚠️ тёрн упал, смотри логи gateway",
|
||||
turn_id=event.get("turn_id"),
|
||||
key=f"{event.get('turn_id')}:error",
|
||||
)
|
||||
case "reply":
|
||||
await self._on_reply(conv, event)
|
||||
case "say":
|
||||
await self._deliver(
|
||||
conv, str(event.get("text") or ""), turn_id=event.get("turn_id")
|
||||
)
|
||||
case "question":
|
||||
await self._ask(conv, event, target)
|
||||
case "question.answered":
|
||||
await self._close_ask(
|
||||
str(event["question_id"]), f"✅ {event.get('answer') or ''}"
|
||||
)
|
||||
case "question.timeout":
|
||||
await self._close_ask(
|
||||
str(event["question_id"]), "⌛ время вышло - ответь текстом"
|
||||
)
|
||||
case "conversation.merged":
|
||||
await self._deliver(conv, "✅ слито в мастер", key=f"{key}:merged")
|
||||
|
||||
async def _on_reply(self, conv: Conversation, event: Event) -> None:
|
||||
turn_id = str(event.get("turn_id") or "")
|
||||
origin = str(event.get("item_origin") or "")
|
||||
await self._unreact(event.get("item"))
|
||||
if origin != FRONTEND and not origin.startswith("сид"):
|
||||
user_text = str(event.get("user_text") or "")
|
||||
if user_text:
|
||||
await self._deliver(
|
||||
conv,
|
||||
f"📝 из панели:\n{user_text}",
|
||||
turn_id=turn_id,
|
||||
key=f"{turn_id}:mirror",
|
||||
)
|
||||
await self._deliver(
|
||||
conv, str(event.get("text") or ""), turn_id=turn_id, key=f"{turn_id}:reply"
|
||||
)
|
||||
|
||||
# ---- drafts ------------------------------------------------------------
|
||||
|
||||
async def _open_draft(
|
||||
self, key: str, event: Event, target: tuple[int, int | None]
|
||||
) -> None:
|
||||
if target[0] < 0:
|
||||
return
|
||||
await self._close_draft(key)
|
||||
draft = Draft(
|
||||
self.bot,
|
||||
chat_id=target[0],
|
||||
thread_id=target[1],
|
||||
turn_id=str(event.get("turn_id") or key),
|
||||
interval=self.draft_interval,
|
||||
)
|
||||
self._drafts[key] = draft
|
||||
draft.start()
|
||||
|
||||
def _feed_draft(self, key: str, event: Event) -> None:
|
||||
draft = self._drafts.get(key)
|
||||
if draft is None or event.get("parent_tool_use_id") is not None:
|
||||
return
|
||||
raw = event.get("event") or {}
|
||||
kind = raw.get("type")
|
||||
if kind == "content_block_delta":
|
||||
delta = raw.get("delta") or {}
|
||||
if delta.get("type") == "text_delta":
|
||||
draft.set_status("✍️ пишу…")
|
||||
draft.append(str(delta.get("text") or ""))
|
||||
elif delta.get("type") == "thinking_delta":
|
||||
draft.set_status("🤔 думаю…")
|
||||
elif kind == "content_block_start":
|
||||
block = raw.get("content_block") or {}
|
||||
if block.get("type") == "tool_use":
|
||||
draft.set_status(
|
||||
f"⏳ {status_label(str(block.get('name') or ''), None)}"
|
||||
)
|
||||
|
||||
async def _close_draft(self, key: str) -> None:
|
||||
draft = self._drafts.pop(key, None)
|
||||
if draft is not None:
|
||||
await draft.stop()
|
||||
|
||||
# ---- questions (§3.7) ---------------------------------------------------
|
||||
|
||||
async def _ask(
|
||||
self, conv: Conversation, event: Event, target: tuple[int, int | None]
|
||||
) -> None:
|
||||
question_id = str(event["question_id"])
|
||||
questions = [q for q in event.get("questions") or [] if isinstance(q, dict)]
|
||||
if not questions:
|
||||
return
|
||||
ask = _Ask(
|
||||
conversation_id=conv.external_id,
|
||||
chat_id=target[0],
|
||||
thread_id=target[1],
|
||||
questions=questions,
|
||||
)
|
||||
self._asks[question_id] = ask
|
||||
for qi, question in enumerate(questions):
|
||||
try:
|
||||
sent = await self.bot.send_message(
|
||||
target[0],
|
||||
_question_html(question),
|
||||
message_thread_id=target[1],
|
||||
parse_mode="HTML",
|
||||
reply_markup=_keyboard(question_id, qi, question, []),
|
||||
)
|
||||
except TelegramAPIError:
|
||||
_log.exception("question %s could not be sent", question_id)
|
||||
continue
|
||||
ask.messages.append(sent.message_id)
|
||||
|
||||
async def _on_callback(self, query: CallbackQuery) -> None:
|
||||
if query.from_user.id != self.user_id or not query.data:
|
||||
return
|
||||
parts = query.data.split(":")
|
||||
if len(parts) != 4 or parts[0] != "q":
|
||||
return
|
||||
_, question_id, qi_raw, choice = parts
|
||||
ask = self._asks.get(question_id)
|
||||
if ask is None or not qi_raw.isdigit():
|
||||
await self._callback_reply(query, "вопрос уже закрыт")
|
||||
return
|
||||
qi = int(qi_raw)
|
||||
question = ask.questions[qi]
|
||||
options = [str(o.get("label", "")) for o in question.get("options") or []]
|
||||
picked = ask.picked.setdefault(qi, [])
|
||||
multi = bool(question.get("multiSelect"))
|
||||
if choice == _DONE:
|
||||
ask.done.add(qi)
|
||||
elif choice.isdigit() and int(choice) < len(options):
|
||||
label = options[int(choice)]
|
||||
if multi:
|
||||
if label in picked:
|
||||
picked.remove(label)
|
||||
else:
|
||||
picked.append(label)
|
||||
else:
|
||||
picked[:] = [label]
|
||||
ask.done.add(qi)
|
||||
message = query.message if isinstance(query.message, Message) else None
|
||||
if message is not None:
|
||||
with contextlib.suppress(TelegramAPIError):
|
||||
if qi in ask.done:
|
||||
await self.bot.edit_message_text(
|
||||
f"{_question_html(question)}\n\n"
|
||||
f"✅ {html.escape(', '.join(picked) or '-')}",
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await self.bot.edit_message_reply_markup(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
reply_markup=_keyboard(question_id, qi, question, picked),
|
||||
)
|
||||
await self._callback_reply(query, None)
|
||||
if len(ask.done) == len(ask.questions):
|
||||
answer = _answer_text(ask)
|
||||
self._asks.pop(question_id, None)
|
||||
if not self.conversations.answer(question_id, answer):
|
||||
await self._edit_asks(ask, "⌛ время вышло - ответь текстом")
|
||||
|
||||
async def _callback_reply(self, query: CallbackQuery, text: str | None) -> None:
|
||||
with contextlib.suppress(TelegramAPIError):
|
||||
await self.bot.answer_callback_query(query.id, text=text)
|
||||
|
||||
async def _close_ask(self, question_id: str, note: str) -> None:
|
||||
ask = self._asks.pop(question_id, None)
|
||||
if ask is not None:
|
||||
await self._edit_asks(ask, note)
|
||||
|
||||
async def _edit_asks(self, ask: _Ask, note: str) -> None:
|
||||
for qi, message_id in enumerate(ask.messages):
|
||||
if qi in ask.done and not note.startswith("⌛"):
|
||||
continue
|
||||
with contextlib.suppress(TelegramAPIError):
|
||||
await self.bot.edit_message_text(
|
||||
f"{_question_html(ask.questions[qi])}\n\n{html.escape(note)}",
|
||||
chat_id=ask.chat_id,
|
||||
message_id=message_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
def _question_html(question: dict[str, Any]) -> str:
|
||||
header = html.escape(str(question.get("header") or "").strip())
|
||||
body = html.escape(str(question.get("question") or "").strip())
|
||||
lines = [f"❓ <b>{header}</b>" if header else "❓", body]
|
||||
for option in question.get("options") or []:
|
||||
label = html.escape(str(option.get("label", "")))
|
||||
description = html.escape(str(option.get("description") or "").strip())
|
||||
lines.append(f"• <b>{label}</b>" + (f" - {description}" if description else ""))
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
def _keyboard(
|
||||
question_id: str, qi: int, question: dict[str, Any], picked: list[str]
|
||||
) -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=("☑ " if str(o.get("label", "")) in picked else "")
|
||||
+ str(o.get("label", ""))[:60],
|
||||
callback_data=f"q:{question_id}:{qi}:{oi}",
|
||||
)
|
||||
]
|
||||
for oi, o in enumerate(question.get("options") or [])
|
||||
]
|
||||
if question.get("multiSelect"):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ готово", callback_data=f"q:{question_id}:{qi}:{_DONE}"
|
||||
)
|
||||
]
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _answer_text(ask: _Ask) -> str:
|
||||
if len(ask.questions) == 1:
|
||||
return ", ".join(ask.picked.get(0, [])) or "-"
|
||||
return "; ".join(
|
||||
f"{q.get('header') or q.get('question')}: "
|
||||
f"{', '.join(ask.picked.get(i, [])) or '-'}"
|
||||
for i, q in enumerate(ask.questions)
|
||||
)
|
||||
Reference in New Issue
Block a user