feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons

This commit is contained in:
hh
2026-08-28 17:57:09 +02:00
parent 5fc58e3bc7
commit 5177392f46
15 changed files with 2244 additions and 4 deletions
+124 -2
View File
@@ -112,6 +112,11 @@ class ConversationTexts:
merge_prompt: str = _DEFAULT_MERGE_PROMPT
interrupted: str = "прервано"
answered: str = "Пользователь ответил: {answer}"
unanswered: str = (
"Пользователь не ответил за {minutes} мин. Вопрос ему показан текстом; "
"заверши тёрн сейчас, ответ придёт следующим сообщением."
)
seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None
@@ -130,6 +135,14 @@ class _Runner:
turn_id: str | None = None
@dataclass(frozen=True, slots=True)
class _Question:
conversation_id: str
turn_id: str | None
questions: list[dict[str, Any]]
answer: asyncio.Future[str]
class Conversations:
def __init__(
self,
@@ -145,6 +158,7 @@ class Conversations:
normal_window: float = 3600.0,
idle_days: Sequence[int] = (2,),
idle_interval: float = 3600.0,
question_timeout: float = 600.0,
) -> None:
self._db = db
self._agents = agents
@@ -157,6 +171,8 @@ class Conversations:
self._normal_window = normal_window
self._idle_days = tuple(sorted(idle_days))
self._idle_interval = idle_interval
self._question_timeout = question_timeout
self._questions: dict[str, _Question] = {}
self._queue = InjectQueue(db)
self._runners: dict[int, _Runner] = {}
self._tasks: set[asyncio.Task[None]] = set()
@@ -278,6 +294,17 @@ class Conversations:
if other is not row and other.visible:
other.visible = False
session.add(other)
same_window = await session.exec(
select(ConversationBinding).where(
ConversationBinding.frontend == frontend,
ConversationBinding.external_id == external_id,
ConversationBinding.conversation_id != conv.id,
col(ConversationBinding.visible).is_(True),
)
)
for other in same_window.all():
other.visible = False
session.add(other)
if row is None:
row = ConversationBinding(
conversation_id=cast("int", conv.id),
@@ -436,7 +463,15 @@ class Conversations:
title: str | None = None,
window: int | None = None,
origin: str = "api",
binding: tuple[str, str] | None = None,
) -> Conversation:
"""Create a conversation and queue its seed turn (§8.2).
``binding`` = ``(frontend, external_id)`` puts it into a window that
already exists (a topic the user created) instead of asking the
home frontend to ``materialize`` one. ``text`` rides with the seed
as the first thing the user said, whatever the seed mode.
"""
if seed not in SEEDS:
msg = f"unknown seed {seed!r}"
raise ValueError(msg)
@@ -465,7 +500,10 @@ class Conversations:
origin=origin,
session_id=session_id,
)
await self.materialize(conv)
if binding is not None:
await self.bind(conv, frontend=binding[0], external_id=binding[1])
else:
await self.materialize(conv)
prompt = await self._seed_text(
SeedContext(
kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title
@@ -619,6 +657,86 @@ class Conversations:
)
return row
# ---- §3.7 questions ------------------------------------------------
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
"""``AskUserQuestion`` reached ``can_use_tool``: show it, wait for the answer.
Returns the answer text, or ``None`` when nobody answered within
``question_timeout`` - the caller then tells the model to finish the
turn, the frontend has already rendered the question as text.
"""
conv = await self.get(key)
if conv is None:
return None
runner = self._runners.get(cast("int", conv.id))
question_id = f"q_{uuid.uuid4().hex[:10]}"
pending = _Question(
conversation_id=key,
turn_id=runner.turn_id if runner is not None else None,
questions=list(payload.get("questions") or []),
answer=asyncio.get_running_loop().create_future(),
)
self._questions[question_id] = pending
await self._set_pending_question(conv, value=True)
self._bus.publish(
"question",
conversation_id=key,
turn_id=pending.turn_id,
question_id=question_id,
questions=pending.questions,
timeout=self._question_timeout,
)
try:
async with asyncio.timeout(self._question_timeout):
answer = await pending.answer
except TimeoutError:
self._bus.publish(
"question.timeout",
conversation_id=key,
turn_id=pending.turn_id,
question_id=question_id,
)
return None
finally:
self._questions.pop(question_id, None)
await self._set_pending_question(conv, value=False)
self._bus.publish(
"question.answered",
conversation_id=key,
turn_id=pending.turn_id,
question_id=question_id,
answer=answer,
)
return answer
def answer(self, question_id: str, answer: str) -> bool:
pending = self._questions.get(question_id)
if pending is None or pending.answer.done():
return False
pending.answer.set_result(answer)
return True
def pending_question(self, key: str) -> tuple[str, list[dict[str, Any]]] | None:
for question_id, pending in self._questions.items():
if pending.conversation_id == key and not pending.answer.done():
return question_id, pending.questions
return None
def answer_text(self, answer: str | None) -> str:
if answer is None:
return self._texts.unanswered.format(
minutes=round(self._question_timeout / 60)
)
return self._texts.answered.format(answer=answer)
async def _set_pending_question(self, conv: Conversation, *, value: bool) -> None:
async def apply(row: Conversation) -> None:
row.pending_question = value
with contextlib.suppress(LookupError):
await self._update(conv, apply)
async def schedules(self, conv: Conversation | None = None) -> list[Schedule]:
stmt = select(Schedule).order_by(col(Schedule.execute_at))
if conv is not None:
@@ -757,6 +875,7 @@ class Conversations:
async def clear(row: Conversation) -> None:
row.running_turn = None
row.pending_question = False
await self._update(conv, clear)
note = f"тёрн {turn_id} оборван рестартом gateway"
@@ -851,7 +970,8 @@ class Conversations:
body = f"История родителя скопирована ({scope}); продолжай в ней."
elif ctx.seed == "morning":
body = "Хендаут не приехал."
return f"{head}\n\n{body}" if body else head
parts = [head, body, ctx.text if ctx.seed != "brief" else None]
return "\n\n".join(p for p in parts if p)
def _runner(self, row_id: int) -> _Runner:
runner = self._runners.get(row_id)
@@ -933,8 +1053,10 @@ class Conversations:
conversation_id=conv.external_id,
turn_id=turn_id,
item=head.id,
item_origin=head.origin,
source="queue",
prompt=prompt,
user_text=head.text,
text=text,
)