900 lines
35 KiB
Python
900 lines
35 KiB
Python
"""``TelegramFrontend`` - the private chat with the bot as the window (§3.8).
|
|
|
|
A private chat with topics has no General: the gateway makes one topic for
|
|
the master (``master_topic``) and rebinds it to every new master; any other
|
|
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 (
|
|
BotCommand,
|
|
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"
|
|
_COMMAND_HELP = (
|
|
("merge", "слить ветку в мастер"),
|
|
("new", "новая ветка: в General - новый топик, в топике - заново на нём"),
|
|
("chat", "открыть глубокий чат в vault: /chat тема"),
|
|
("status", "что с этим разговором"), # noqa: RUF001
|
|
("help", "команды"),
|
|
)
|
|
_COMMANDS = (*(c for c, _ in _COMMAND_HELP), "start")
|
|
_HELP = "General - мастер, любой другой топик - ветка.\n" + "\n".join(
|
|
f"/{c} - {d}" for c, d in _COMMAND_HELP
|
|
)
|
|
|
|
|
|
@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,
|
|
master_topic: str = "🦫 General",
|
|
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.master_topic = master_topic
|
|
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._master_window: str | 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()
|
|
await self.bot.set_my_commands(
|
|
[BotCommand(command=c, description=d) for c, d in _COMMAND_HELP]
|
|
)
|
|
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=await self._master_ext()
|
|
)
|
|
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_ext(self) -> str:
|
|
"""The master's window.
|
|
|
|
General in a forum group, our own topic in a private chat (Telegram
|
|
has no General there). Created once, then found through whatever
|
|
master used it last.
|
|
"""
|
|
if self._master_window is not None:
|
|
return self._master_window
|
|
last = await self.conversations.last_binding(frontend=FRONTEND, kind="master")
|
|
if last is not None:
|
|
self._master_window = last.external_id
|
|
elif self.chat_id < 0:
|
|
self._master_window = self._ext(None)
|
|
else:
|
|
topic = await self.bot.create_forum_topic(
|
|
self.chat_id, name=self.master_topic[:128]
|
|
)
|
|
self._topic_names[topic.message_thread_id] = topic.name
|
|
self._master_window = self._ext(topic.message_thread_id)
|
|
return self._master_window
|
|
|
|
async def _master(self, text: str | None = None) -> tuple[Conversation, bool]:
|
|
"""The open master behind its window, spawning one when there is none.
|
|
|
|
``text`` rides with the seed of a fresh master; the second value says
|
|
whether it was consumed that way.
|
|
"""
|
|
ext = await self._master_ext()
|
|
conv = await self.conversations.find_bound(frontend=FRONTEND, external_id=ext)
|
|
if conv is not None and conv.status == "open":
|
|
return conv, False
|
|
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], False
|
|
conv = await self.conversations.spawn(
|
|
kind="master",
|
|
seed="clean",
|
|
text=text,
|
|
origin=FRONTEND,
|
|
binding=(FRONTEND, ext),
|
|
)
|
|
return conv, text is not None
|
|
|
|
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:
|
|
created = message.forum_topic_created
|
|
if (
|
|
created is not None
|
|
and message.message_thread_id
|
|
and not created.is_name_implicit
|
|
):
|
|
self._topic_names[message.message_thread_id] = 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
|
|
is_master = (
|
|
thread_id is None or self._ext(thread_id) == await self._master_ext()
|
|
)
|
|
if message.forum_topic_created is not 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 is_master:
|
|
conv, consumed = await self._master(text)
|
|
if consumed:
|
|
return
|
|
else:
|
|
conv = await self._live(thread_id)
|
|
if conv is None:
|
|
title = self._topic_names.get(thread_id) or self._title_from(text)
|
|
await self._branch(thread_id, title=title, 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
|
|
is_master = (
|
|
thread_id is None or self._ext(thread_id) == await self._master_ext()
|
|
)
|
|
conv = (await self._master())[0] if is_master 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 is_master or 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":
|
|
if conv.kind == "master" and str(
|
|
event.get("item_origin") or ""
|
|
).startswith("сид"):
|
|
return
|
|
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)
|
|
)
|