fix(telegram,conversations): attachments by day with sweep in both modes, merge renames the topic, gone topics unbind, failures visible
This commit is contained in:
@@ -19,8 +19,10 @@ import logging
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
@@ -71,14 +73,21 @@ _HELP = "General - мастер, любой другой топик - ветка
|
|||||||
class Attachments:
|
class Attachments:
|
||||||
"""Where files from Telegram go.
|
"""Where files from Telegram go.
|
||||||
|
|
||||||
``ephemeral`` - under the gateway's data dir, swept after ``keep_days``;
|
Files land in ``<root>/YYYY-MM-DD/<unixts>-<name>`` (the day in ``tz``)
|
||||||
``vault`` - into a directory the agent owns. Pulling what is worth
|
and whatever is older than ``keep_days`` is swept; ``None`` never
|
||||||
keeping into the vault is the agent's job either way.
|
sweeps. ``ephemeral`` - under the gateway's data dir; ``vault`` -
|
||||||
|
``dir`` is an inbox inside the agent's zone: the agent moves keepers
|
||||||
|
next to the note, the rest is swept after ``keep_days``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mode: Literal["ephemeral", "vault"] = "ephemeral"
|
mode: Literal["ephemeral", "vault"] = "ephemeral"
|
||||||
dir: Path | None = None
|
dir: Path | None = None
|
||||||
keep_days: int = 7
|
keep_days: int | None = 7
|
||||||
|
tz: str = "UTC"
|
||||||
|
|
||||||
|
def day(self, now: float | None = None) -> str:
|
||||||
|
stamp = datetime.fromtimestamp(now or time.time(), tz=ZoneInfo(self.tz))
|
||||||
|
return stamp.date().isoformat()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def root(self) -> Path:
|
def root(self) -> Path:
|
||||||
@@ -174,7 +183,6 @@ class TelegramFrontend(Frontend):
|
|||||||
self.chat_id,
|
self.chat_id,
|
||||||
getattr(me, "has_topics_enabled", None),
|
getattr(me, "has_topics_enabled", None),
|
||||||
)
|
)
|
||||||
self._sweep_attachments()
|
|
||||||
await self.bot.set_my_commands(
|
await self.bot.set_my_commands(
|
||||||
[BotCommand(command=c, description=d) for c, d in _COMMAND_HELP]
|
[BotCommand(command=c, description=d) for c, d in _COMMAND_HELP]
|
||||||
)
|
)
|
||||||
@@ -183,6 +191,7 @@ class TelegramFrontend(Frontend):
|
|||||||
tg.create_task(self.inbox.run())
|
tg.create_task(self.inbox.run())
|
||||||
tg.create_task(self.outbox.run())
|
tg.create_task(self.outbox.run())
|
||||||
tg.create_task(self._events())
|
tg.create_task(self._events())
|
||||||
|
tg.create_task(self._sweep_loop())
|
||||||
finally:
|
finally:
|
||||||
for draft in list(self._drafts.values()):
|
for draft in list(self._drafts.values()):
|
||||||
await draft.stop()
|
await draft.stop()
|
||||||
@@ -373,7 +382,11 @@ class TelegramFrontend(Frontend):
|
|||||||
return
|
return
|
||||||
if message.chat.id != self.chat_id:
|
if message.chat.id != self.chat_id:
|
||||||
return
|
return
|
||||||
in_topic = message.is_topic_message or message.forum_topic_created is not None
|
in_topic = (
|
||||||
|
message.is_topic_message
|
||||||
|
or message.forum_topic_created is not None
|
||||||
|
or message.forum_topic_edited is not None
|
||||||
|
)
|
||||||
thread_id = message.message_thread_id if in_topic else None
|
thread_id = message.message_thread_id if in_topic else None
|
||||||
is_master = (
|
is_master = (
|
||||||
thread_id is None or self._ext(thread_id) == await self._master_ext()
|
thread_id is None or self._ext(thread_id) == await self._master_ext()
|
||||||
@@ -382,10 +395,12 @@ class TelegramFrontend(Frontend):
|
|||||||
return
|
return
|
||||||
if message.forum_topic_edited is not None and thread_id is not None:
|
if message.forum_topic_edited is not None and thread_id is not None:
|
||||||
if message.forum_topic_edited.name:
|
if message.forum_topic_edited.name:
|
||||||
self._topic_names[thread_id] = message.forum_topic_edited.name
|
await self._on_topic_renamed(
|
||||||
|
thread_id, message.forum_topic_edited.name, is_master=is_master
|
||||||
|
)
|
||||||
return
|
return
|
||||||
text = (message.text or message.caption or "").strip()
|
text = (message.text or message.caption or "").strip()
|
||||||
attachment = await self._save_attachment(message, thread_id)
|
attachment = await self._save_attachment(message)
|
||||||
if attachment:
|
if attachment:
|
||||||
text = f"{text}\n\n{attachment}".strip()
|
text = f"{text}\n\n{attachment}".strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -414,6 +429,14 @@ class TelegramFrontend(Frontend):
|
|||||||
if conv.running_turn or conv.pending_question:
|
if conv.running_turn or conv.pending_question:
|
||||||
await self._react(message, item.id)
|
await self._react(message, item.id)
|
||||||
|
|
||||||
|
async def _on_topic_renamed(
|
||||||
|
self, thread_id: int, name: str, *, is_master: bool
|
||||||
|
) -> None:
|
||||||
|
self._topic_names[thread_id] = name
|
||||||
|
conv = None if is_master else await self._live(thread_id)
|
||||||
|
if conv is not None:
|
||||||
|
await self.conversations.set_title(conv, name)
|
||||||
|
|
||||||
async def _live(self, thread_id: int) -> Conversation | None:
|
async def _live(self, thread_id: int) -> Conversation | None:
|
||||||
conv = await self.conversations.find_bound(
|
conv = await self.conversations.find_bound(
|
||||||
frontend=FRONTEND, external_id=self._ext(thread_id)
|
frontend=FRONTEND, external_id=self._ext(thread_id)
|
||||||
@@ -446,13 +469,15 @@ class TelegramFrontend(Frontend):
|
|||||||
with contextlib.suppress(TelegramAPIError):
|
with contextlib.suppress(TelegramAPIError):
|
||||||
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
|
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
|
||||||
|
|
||||||
async def _save_attachment(
|
async def _save_attachment(self, message: Message) -> str | None:
|
||||||
self, message: Message, thread_id: int | None
|
|
||||||
) -> str | None:
|
|
||||||
file_id: str | None = None
|
file_id: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
kind = ""
|
kind = ""
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
|
if message.sticker:
|
||||||
|
return "[вложение: стикер - не поддерживается]"
|
||||||
|
if message.animation:
|
||||||
|
return "[вложение: анимация - не поддерживается]"
|
||||||
if message.photo:
|
if message.photo:
|
||||||
photo = message.photo[-1]
|
photo = message.photo[-1]
|
||||||
file_id, kind, size = photo.file_id, "фото", photo.file_size
|
file_id, kind, size = photo.file_id, "фото", photo.file_size
|
||||||
@@ -496,12 +521,13 @@ class TelegramFrontend(Frontend):
|
|||||||
f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - "
|
f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - "
|
||||||
"больше 20 МБ, Telegram не отдаёт ботам]"
|
"больше 20 МБ, Telegram не отдаёт ботам]"
|
||||||
)
|
)
|
||||||
folder = self.attachments.root / (self._ext(thread_id).replace("/", "_"))
|
now = time.time()
|
||||||
|
folder = self.attachments.root / self.attachments.day(now)
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
self.attachments.root.chmod(0o755)
|
self.attachments.root.chmod(0o755)
|
||||||
folder.chmod(0o755)
|
folder.chmod(0o755)
|
||||||
path = folder / f"{int(time.time())}-{Path(name).name}"
|
path = folder / f"{int(now)}-{Path(name).name}"
|
||||||
try:
|
try:
|
||||||
await self.bot.download(file_id, destination=path)
|
await self.bot.download(file_id, destination=path)
|
||||||
path.chmod(0o644)
|
path.chmod(0o644)
|
||||||
@@ -509,19 +535,33 @@ class TelegramFrontend(Frontend):
|
|||||||
_log.warning("attachment download failed: %s", exc)
|
_log.warning("attachment download failed: %s", exc)
|
||||||
return f"[вложение: {kind} {name} - не скачалось: {exc}]"
|
return f"[вложение: {kind} {name} - не скачалось: {exc}]"
|
||||||
shown = f", {size // 1024} КБ" if size else ""
|
shown = f", {size // 1024} КБ" if size else ""
|
||||||
return f"[вложение: {kind} {path}{shown}]"
|
keep = self.attachments.keep_days
|
||||||
|
if keep is None:
|
||||||
|
return f"[вложение: {kind} {path}{shown}]"
|
||||||
|
return (
|
||||||
|
f"[вложение: {kind} {path}{shown}; хранится {keep} дн., "
|
||||||
|
"перенеси в vault, если нужно]"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _sweep_loop(self, interval: float = 6 * 3600) -> None:
|
||||||
|
while True:
|
||||||
|
self._sweep_attachments()
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
def _sweep_attachments(self) -> None:
|
def _sweep_attachments(self) -> None:
|
||||||
if self.attachments.mode != "ephemeral":
|
keep = self.attachments.keep_days
|
||||||
return
|
|
||||||
root = self.attachments.root
|
root = self.attachments.root
|
||||||
if not root.exists():
|
if keep is None or not root.exists():
|
||||||
return
|
return
|
||||||
cutoff = time.time() - self.attachments.keep_days * 86400
|
cutoff = time.time() - keep * 86400
|
||||||
for path in root.rglob("*"):
|
for path in root.rglob("*"):
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
if path.is_file() and path.stat().st_mtime < cutoff:
|
if path.is_file() and path.stat().st_mtime < cutoff:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
for folder in root.iterdir():
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
if folder.is_dir() and not any(folder.iterdir()):
|
||||||
|
folder.rmdir()
|
||||||
|
|
||||||
# ---- commands --------------------------------------------------------
|
# ---- commands --------------------------------------------------------
|
||||||
|
|
||||||
@@ -634,7 +674,10 @@ class TelegramFrontend(Frontend):
|
|||||||
if kind == "conversation.bound":
|
if kind == "conversation.bound":
|
||||||
self._targets.pop(str(event.get("conversation_id")), None)
|
self._targets.pop(str(event.get("conversation_id")), None)
|
||||||
return
|
return
|
||||||
if kind in ("delivery.sent", "delivery.failed", "conversation.created"):
|
if kind == "delivery.failed":
|
||||||
|
await self._on_delivery_failed(event)
|
||||||
|
return
|
||||||
|
if kind in ("delivery.sent", "conversation.created"):
|
||||||
return
|
return
|
||||||
key = event.get("conversation_id")
|
key = event.get("conversation_id")
|
||||||
if not isinstance(key, str):
|
if not isinstance(key, str):
|
||||||
@@ -661,10 +704,13 @@ class TelegramFrontend(Frontend):
|
|||||||
draft = self._drafts.get(key)
|
draft = self._drafts.get(key)
|
||||||
if draft is not None:
|
if draft is not None:
|
||||||
await draft.stop()
|
await draft.stop()
|
||||||
if event.get("stop") == "error" and event.get("origin") == "user":
|
if event.get("stop") == "error":
|
||||||
|
origin = event.get("origin")
|
||||||
await self._deliver(
|
await self._deliver(
|
||||||
conv,
|
conv,
|
||||||
"⚠️ тёрн упал, смотри логи gateway",
|
"⚠️ тёрн упал, смотри логи gateway"
|
||||||
|
if origin == "user"
|
||||||
|
else f"⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway",
|
||||||
turn_id=event.get("turn_id"),
|
turn_id=event.get("turn_id"),
|
||||||
key=f"{event.get('turn_id')}:error",
|
key=f"{event.get('turn_id')}:error",
|
||||||
)
|
)
|
||||||
@@ -687,6 +733,38 @@ class TelegramFrontend(Frontend):
|
|||||||
case "conversation.merged":
|
case "conversation.merged":
|
||||||
await self._deliver(conv, "✅ слито в мастер", key=f"{key}:merged")
|
await self._deliver(conv, "✅ слито в мастер", key=f"{key}:merged")
|
||||||
|
|
||||||
|
async def _on_delivery_failed(self, event: Event) -> None:
|
||||||
|
row = event.get("conversation_row")
|
||||||
|
_log.error(
|
||||||
|
"delivery to %s/%s (conversation row %s) failed: %s",
|
||||||
|
event.get("chat_id"),
|
||||||
|
event.get("thread_id"),
|
||||||
|
row,
|
||||||
|
event.get("error"),
|
||||||
|
)
|
||||||
|
if not event.get("gone") or not isinstance(row, int):
|
||||||
|
return
|
||||||
|
conv = await self.conversations.get_row(row)
|
||||||
|
if conv is None:
|
||||||
|
return
|
||||||
|
bound = next(
|
||||||
|
(
|
||||||
|
b
|
||||||
|
for b in await self.conversations.bindings(conv)
|
||||||
|
if b.frontend == FRONTEND and b.visible
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if bound is None:
|
||||||
|
return
|
||||||
|
_log.warning(
|
||||||
|
"window %s of %s is gone; unbinding", bound.external_id, conv.external_id
|
||||||
|
)
|
||||||
|
await self.conversations.bind(
|
||||||
|
conv, frontend=FRONTEND, external_id=bound.external_id, visible=False
|
||||||
|
)
|
||||||
|
self._targets.pop(conv.external_id, None)
|
||||||
|
|
||||||
async def _on_reply(self, conv: Conversation, event: Event) -> None:
|
async def _on_reply(self, conv: Conversation, event: Event) -> None:
|
||||||
turn_id = str(event.get("turn_id") or "")
|
turn_id = str(event.get("turn_id") or "")
|
||||||
origin = str(event.get("item_origin") or "")
|
origin = str(event.get("item_origin") or "")
|
||||||
@@ -789,6 +867,7 @@ class TelegramFrontend(Frontend):
|
|||||||
ask = self._asks.get(question_id)
|
ask = self._asks.get(question_id)
|
||||||
if ask is None or not qi_raw.isdigit():
|
if ask is None or not qi_raw.isdigit():
|
||||||
await self._callback_reply(query, "вопрос уже закрыт")
|
await self._callback_reply(query, "вопрос уже закрыт")
|
||||||
|
await self._strip_keyboard(query)
|
||||||
return
|
return
|
||||||
qi = int(qi_raw)
|
qi = int(qi_raw)
|
||||||
question = ask.questions[qi]
|
question = ask.questions[qi]
|
||||||
@@ -831,6 +910,17 @@ class TelegramFrontend(Frontend):
|
|||||||
if not self.conversations.answer(question_id, answer):
|
if not self.conversations.answer(question_id, answer):
|
||||||
await self._edit_asks(ask, "⌛ время вышло - ответь текстом")
|
await self._edit_asks(ask, "⌛ время вышло - ответь текстом")
|
||||||
|
|
||||||
|
async def _strip_keyboard(self, query: CallbackQuery) -> None:
|
||||||
|
message = query.message if isinstance(query.message, Message) else None
|
||||||
|
if message is None:
|
||||||
|
return
|
||||||
|
with contextlib.suppress(TelegramAPIError):
|
||||||
|
await self.bot.edit_message_reply_markup(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
reply_markup=None,
|
||||||
|
)
|
||||||
|
|
||||||
async def _callback_reply(self, query: CallbackQuery, text: str | None) -> None:
|
async def _callback_reply(self, query: CallbackQuery, text: str | None) -> None:
|
||||||
with contextlib.suppress(TelegramAPIError):
|
with contextlib.suppress(TelegramAPIError):
|
||||||
await self.bot.answer_callback_query(query.id, text=text)
|
await self.bot.answer_callback_query(query.id, text=text)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class Outbox:
|
|||||||
if "parse" in text and not row.plain:
|
if "parse" in text and not row.plain:
|
||||||
await self._retry(row, str(exc), delay=0.0, plain=True)
|
await self._retry(row, str(exc), delay=0.0, plain=True)
|
||||||
elif any(marker in text for marker in _GONE):
|
elif any(marker in text for marker in _GONE):
|
||||||
await self._fail(row, str(exc))
|
await self._fail(row, str(exc), gone=True)
|
||||||
else:
|
else:
|
||||||
await self._fail(row, str(exc))
|
await self._fail(row, str(exc))
|
||||||
except (TelegramNotFound, TelegramForbiddenError) as exc:
|
except (TelegramNotFound, TelegramForbiddenError) as exc:
|
||||||
@@ -180,7 +180,7 @@ class Outbox:
|
|||||||
if delay < 5.0:
|
if delay < 5.0:
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
|
||||||
async def _fail(self, row: Delivery, error: str) -> None:
|
async def _fail(self, row: Delivery, error: str, *, gone: bool = False) -> None:
|
||||||
_log.error(
|
_log.error(
|
||||||
"delivery #%s to %s/%s given up: %s",
|
"delivery #%s to %s/%s given up: %s",
|
||||||
row.id,
|
row.id,
|
||||||
@@ -196,6 +196,7 @@ class Outbox:
|
|||||||
chat_id=row.chat_id,
|
chat_id=row.chat_id,
|
||||||
thread_id=row.thread_id,
|
thread_id=row.thread_id,
|
||||||
error=error,
|
error=error,
|
||||||
|
gone=gone,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _mark(
|
async def _mark(
|
||||||
|
|||||||
+308
-5
@@ -13,8 +13,14 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|||||||
from beaver_gateway.core.registry import McpRegistry
|
from beaver_gateway.core.registry import McpRegistry
|
||||||
from beaver_gateway.core.transcript import build_entries
|
from beaver_gateway.core.transcript import build_entries
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
from beaver_gateway.frontends.telegram import TelegramFrontend
|
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
||||||
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
|
from beaver_gateway.frontends.telegram.render import (
|
||||||
|
LIMIT,
|
||||||
|
chunks,
|
||||||
|
status_label,
|
||||||
|
to_html,
|
||||||
|
to_html_tail,
|
||||||
|
)
|
||||||
from beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate
|
from beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from test_conversations import ScriptedClient, StubFrontend, World
|
from test_conversations import ScriptedClient, StubFrontend, World
|
||||||
@@ -33,6 +39,7 @@ class FakeBot:
|
|||||||
self.reactions: list[tuple[int, list[Any]]] = []
|
self.reactions: list[tuple[int, list[Any]]] = []
|
||||||
self.fail_sends = 0
|
self.fail_sends = 0
|
||||||
self.reject_html = False
|
self.reject_html = False
|
||||||
|
self.gone_threads: set[int] = set()
|
||||||
self.order: list[tuple[str, int | None]] = []
|
self.order: list[tuple[str, int | None]] = []
|
||||||
self._message_id = 100
|
self._message_id = 100
|
||||||
self.session = SimpleNamespace(close=self._close)
|
self.session = SimpleNamespace(close=self._close)
|
||||||
@@ -64,6 +71,11 @@ class FakeBot:
|
|||||||
method=SendMessage(chat_id=0, text="x"),
|
method=SendMessage(chat_id=0, text="x"),
|
||||||
message="Bad Request: can't parse entities",
|
message="Bad Request: can't parse entities",
|
||||||
)
|
)
|
||||||
|
if message_thread_id in self.gone_threads:
|
||||||
|
raise TelegramBadRequest(
|
||||||
|
method=SendMessage(chat_id=0, text="x"),
|
||||||
|
message="Bad Request: message thread not found",
|
||||||
|
)
|
||||||
self._message_id += 1
|
self._message_id += 1
|
||||||
self.order.append(("message", message_thread_id))
|
self.order.append(("message", message_thread_id))
|
||||||
self.sent.append(
|
self.sent.append(
|
||||||
@@ -86,7 +98,8 @@ class FakeBot:
|
|||||||
|
|
||||||
async def create_forum_topic(self, chat_id, name, **_: Any) -> Any:
|
async def create_forum_topic(self, chat_id, name, **_: Any) -> Any:
|
||||||
self.topics.append(name)
|
self.topics.append(name)
|
||||||
return SimpleNamespace(message_thread_id=900 + len(self.topics), name=name)
|
created = sum(1 for t in self.topics if not t.startswith("edit:"))
|
||||||
|
return SimpleNamespace(message_thread_id=900 + created, name=name)
|
||||||
|
|
||||||
async def edit_forum_topic(
|
async def edit_forum_topic(
|
||||||
self, chat_id, message_thread_id, name=None, **_: Any
|
self, chat_id, message_thread_id, name=None, **_: Any
|
||||||
@@ -148,6 +161,33 @@ class FakeBot:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def topic_edited(self, name: str, thread: int) -> None:
|
||||||
|
self.push(
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"message_id": 10 + len(self.updates),
|
||||||
|
"date": 1700000000,
|
||||||
|
"chat": {"id": USER, "type": "private"},
|
||||||
|
"from": {"id": USER, "is_bot": False, "first_name": "h"},
|
||||||
|
"message_thread_id": thread,
|
||||||
|
"is_topic_message": True,
|
||||||
|
"forum_topic_edited": {"name": name},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def media(self, body: dict[str, Any], *, thread: int | None = None) -> None:
|
||||||
|
message: dict[str, Any] = {
|
||||||
|
"message_id": 10 + len(self.updates),
|
||||||
|
"date": 1700000000,
|
||||||
|
"chat": {"id": USER, "type": "private"},
|
||||||
|
"from": {"id": USER, "is_bot": False, "first_name": "h"},
|
||||||
|
**body,
|
||||||
|
}
|
||||||
|
if thread is not None:
|
||||||
|
message |= {"message_thread_id": thread, "is_topic_message": True}
|
||||||
|
self.push({"message": message})
|
||||||
|
|
||||||
def callback(self, data: str, message_id: int) -> None:
|
def callback(self, data: str, message_id: int) -> None:
|
||||||
self.push(
|
self.push(
|
||||||
{
|
{
|
||||||
@@ -455,9 +495,10 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None:
|
|||||||
frontend="telegram", external_id=f"{USER}/11"
|
frontend="telegram", external_id=f"{USER}/11"
|
||||||
)
|
)
|
||||||
assert branch.status == "merged"
|
assert branch.status == "merged"
|
||||||
|
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"]
|
||||||
stack.bot.message("/new отчёт")
|
stack.bot.message("/new отчёт")
|
||||||
await stack.until(lambda: stack.sent_with("в новом топике"), what="new topic")
|
await stack.until(lambda: stack.sent_with("в новом топике"), what="new topic")
|
||||||
assert stack.bot.topics == ["🦫 General", "отчёт"]
|
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"]
|
||||||
child = await stack.world.conversations.find_bound(
|
child = await stack.world.conversations.find_bound(
|
||||||
frontend="telegram", external_id=f"{USER}/902"
|
frontend="telegram", external_id=f"{USER}/902"
|
||||||
)
|
)
|
||||||
@@ -583,18 +624,112 @@ def test_render_helpers() -> None:
|
|||||||
== "<b>жирно</b> и <code>code</code> <b>"
|
== "<b>жирно</b> и <code>code</code> <b>"
|
||||||
)
|
)
|
||||||
assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт"
|
assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт"
|
||||||
assert to_html("```py\nx = 1\n```") == "<pre>x = 1</pre>"
|
assert to_html("```\nx < 1 & y\n```") == "<pre>x < 1 & y</pre>"
|
||||||
|
assert (
|
||||||
|
to_html("```py\nunterminated\n")
|
||||||
|
== '<pre><code class="language-py">unterminated</code></pre>'
|
||||||
|
)
|
||||||
assert (
|
assert (
|
||||||
to_html("[док](https://a.b/c?x=1&y=2)")
|
to_html("[док](https://a.b/c?x=1&y=2)")
|
||||||
== '<a href="https://a.b/c?x=1&y=2">док</a>'
|
== '<a href="https://a.b/c?x=1&y=2">док</a>'
|
||||||
)
|
)
|
||||||
|
assert (
|
||||||
|
to_html("[tg](tg://user?id=1) https://x.y/a_b_c")
|
||||||
|
== '<a href="tg://user?id=1">tg</a> https://x.y/a_b_c'
|
||||||
|
)
|
||||||
|
assert to_html(" ") == (
|
||||||
|
'<a href="https://x.y/i.png">alt</a> <a href="https://x.y/j.png">https://x.y/j.png</a>'
|
||||||
|
)
|
||||||
|
assert to_html("~~нет~~ _к_ *к* __ж__") == "<s>нет</s> <i>к</i> <i>к</i> <b>ж</b>"
|
||||||
|
assert to_html("---\n***\n___") == "———\n———\n———"
|
||||||
|
assert to_html("`**raw** [[x]]`") == "<code>**raw** [[x]]</code>"
|
||||||
parts = chunks("абв\n\n" + "г" * 30 + "\n\n" + "д" * 30, limit=40)
|
parts = chunks("абв\n\n" + "г" * 30 + "\n\n" + "д" * 30, limit=40)
|
||||||
assert parts == ["абв\n\n" + "г" * 30, "д" * 30]
|
assert parts == ["абв\n\n" + "г" * 30, "д" * 30]
|
||||||
|
assert chunks(" \n ") == []
|
||||||
assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
|
assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
|
||||||
assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
|
assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
|
||||||
assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la"
|
assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_wikilinks() -> None:
|
||||||
|
assert to_html("[[Note]]") == "<u>Note</u>"
|
||||||
|
assert to_html("[[Note|alias]]") == "<u>alias</u>"
|
||||||
|
assert to_html("[[Note#heading]]") == "<u>Note › heading</u>"
|
||||||
|
assert to_html("[[A & B|**x**]]") == "<u>**x**</u>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_blocks() -> None:
|
||||||
|
assert (
|
||||||
|
to_html("> цитата **жирно**\n> вторая\n\nтекст")
|
||||||
|
== "<blockquote>цитата <b>жирно</b>\nвторая</blockquote>\n\nтекст"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
to_html("1. один\n2) два\n - [ ] нет\n- [x] да")
|
||||||
|
== "1. один\n2. два\n ☐ нет\n☑ да"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
to_html("| a | bb |\n|---|:--:|\n| ccc | d |\n| e |")
|
||||||
|
== "<pre>a | bb\nccc | d\ne</pre>"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
to_html("```python\nprint(1)\n```")
|
||||||
|
== '<pre><code class="language-python">print(1)</code></pre>'
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
to_html("||секрет|| и `||нет||`")
|
||||||
|
== "<tg-spoiler>секрет</tg-spoiler> и <code>||нет||</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunks_split_fence_into_valid_fences() -> None:
|
||||||
|
body = "\n".join(f"x = {i} & {i}" for i in range(400))
|
||||||
|
text = f"до\n```python\n{body}\n```\nпосле"
|
||||||
|
parts = chunks(text, limit=1500)
|
||||||
|
assert len(parts) > 2
|
||||||
|
assert parts[0] == "до"
|
||||||
|
assert all(p.startswith("```python\n") and p.endswith("```") for p in parts[1:-1])
|
||||||
|
assert parts[-1].endswith("```\nпосле")
|
||||||
|
fences = [p.removesuffix("\nпосле") for p in parts if p.startswith("```")]
|
||||||
|
inner = [p.removeprefix("```python\n").removesuffix("\n```") for p in fences]
|
||||||
|
assert "\n".join(inner) == body
|
||||||
|
for p in parts:
|
||||||
|
rendered = to_html(p)
|
||||||
|
assert 0 < len(rendered) <= 1500
|
||||||
|
for p in fences:
|
||||||
|
assert to_html(p).startswith('<pre><code class="language-python">')
|
||||||
|
assert to_html(p).endswith("</code></pre>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunks_split_blockquote_and_stay_under_limit() -> None:
|
||||||
|
text = "\n".join(f"> строка {i} **ж** <" for i in range(300))
|
||||||
|
parts = chunks(text, limit=1000)
|
||||||
|
assert len(parts) > 1
|
||||||
|
for p in parts:
|
||||||
|
rendered = to_html(p)
|
||||||
|
assert len(rendered) <= 1000
|
||||||
|
assert rendered.startswith("<blockquote>") and rendered.endswith(
|
||||||
|
"</blockquote>"
|
||||||
|
)
|
||||||
|
assert "".join(parts).count("строка") == 300
|
||||||
|
long_line = "слово " * 2000
|
||||||
|
parts = chunks(long_line, limit=LIMIT)
|
||||||
|
assert all(0 < len(to_html(p)) <= LIMIT for p in parts)
|
||||||
|
assert " ".join(parts).split() == long_line.split()
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_html_tail_inside_fence() -> None:
|
||||||
|
full = (
|
||||||
|
"intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter"
|
||||||
|
)
|
||||||
|
rendered = to_html_tail(full, 100)
|
||||||
|
assert rendered.startswith('<pre><code class="language-py">code ')
|
||||||
|
assert rendered.endswith("</code></pre>\nafter")
|
||||||
|
assert "```" not in rendered
|
||||||
|
assert to_html_tail("a\n> b\n> c", 3) == "<blockquote>c</blockquote>"
|
||||||
|
assert to_html_tail("**x** y", 100) == "<b>x</b> y"
|
||||||
|
assert to_html_tail("x" * 50, 10) == "x" * 10
|
||||||
|
|
||||||
|
|
||||||
async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) -> None:
|
async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) -> None:
|
||||||
stack.bot.push(
|
stack.bot.push(
|
||||||
{
|
{
|
||||||
@@ -614,3 +749,171 @@ async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack)
|
|||||||
preview = rows[0].payload["message"]["link_preview_options"]
|
preview = rows[0].payload["message"]["link_preview_options"]
|
||||||
assert preview["url"] == "https://x.y"
|
assert preview["url"] == "https://x.y"
|
||||||
assert preview.get("is_disabled") is None
|
assert preview.get("is_disabled") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _photo(caption: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"caption": caption,
|
||||||
|
"photo": [
|
||||||
|
{
|
||||||
|
"file_id": "f1",
|
||||||
|
"file_unique_id": "u1",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"file_size": 2048,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attachments_land_in_day_folder_with_keep_note(stack: Stack) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-att-"))
|
||||||
|
stack.tg.attachments = Attachments(dir=root, keep_days=3, tz="Asia/Bangkok")
|
||||||
|
stack.bot.media(_photo("смотри"))
|
||||||
|
await stack.until(lambda: stack.sent_with("смотри"), what="reply")
|
||||||
|
prompt = next(
|
||||||
|
p for c in ScriptedClient.instances for p in c.prompts if "смотри" in p
|
||||||
|
)
|
||||||
|
(day,) = list(root.iterdir())
|
||||||
|
assert day.name == stack.tg.attachments.day()
|
||||||
|
(saved,) = list(day.iterdir())
|
||||||
|
assert saved.read_bytes() == b"data" and saved.name.endswith("-u1.jpg")
|
||||||
|
assert prompt.endswith(
|
||||||
|
f"смотри\n\n[вложение: фото {saved}, 2 КБ; хранится 3 дн., "
|
||||||
|
"перенеси в vault, если нужно]"
|
||||||
|
)
|
||||||
|
stack.tg.attachments = Attachments(dir=root, keep_days=None)
|
||||||
|
stack.bot.media(_photo("ещё"))
|
||||||
|
await stack.until(lambda: stack.sent_with("ещё"), what="second reply")
|
||||||
|
prompt = next(p for c in ScriptedClient.instances for p in c.prompts if "ещё" in p)
|
||||||
|
assert prompt.endswith(" КБ]") and "хранится" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sticker_alone_becomes_an_unsupported_note(stack: Stack) -> None:
|
||||||
|
stack.bot.media(
|
||||||
|
{
|
||||||
|
"sticker": {
|
||||||
|
"file_id": "s1",
|
||||||
|
"file_unique_id": "su1",
|
||||||
|
"type": "regular",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"is_animated": False,
|
||||||
|
"is_video": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await stack.until(lambda: stack.sent_with("стикер"), what="reply")
|
||||||
|
prompt = next(
|
||||||
|
p for c in ScriptedClient.instances for p in c.prompts if "стикер" in p
|
||||||
|
)
|
||||||
|
assert prompt.endswith("[вложение: стикер - не поддерживается]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_drops_old_files_and_empty_day_folders() -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-sweep-"))
|
||||||
|
old = root / "2020-01-01" / "1-old.jpg"
|
||||||
|
old.parent.mkdir()
|
||||||
|
old.write_bytes(b"x")
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.utime(old, (1_577_836_800, 1_577_836_800))
|
||||||
|
fresh = root / "2099-01-01" / "2-new.jpg"
|
||||||
|
fresh.parent.mkdir()
|
||||||
|
fresh.write_bytes(b"y")
|
||||||
|
tg = TelegramFrontend(
|
||||||
|
token="t", user_id=USER, attachments=Attachments(dir=root, keep_days=None)
|
||||||
|
)
|
||||||
|
tg._sweep_attachments() # noqa: SLF001
|
||||||
|
assert old.exists()
|
||||||
|
tg.attachments = Attachments(dir=root, keep_days=7)
|
||||||
|
tg._sweep_attachments() # noqa: SLF001
|
||||||
|
assert not old.parent.exists() and fresh.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_topic_rename_updates_the_title(stack: Stack) -> None:
|
||||||
|
stack.bot.topic_created("план", 7)
|
||||||
|
stack.bot.message("hello", thread=7)
|
||||||
|
await stack.until(lambda: stack.sent_with("hello"), what="reply")
|
||||||
|
stack.bot.topic_edited("новое имя", 7)
|
||||||
|
|
||||||
|
async def titled() -> Any:
|
||||||
|
conv = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=f"{USER}/7"
|
||||||
|
)
|
||||||
|
return conv if conv is not None and conv.title == "новое имя" else None
|
||||||
|
|
||||||
|
deadline = asyncio.get_running_loop().time() + 5
|
||||||
|
branch = None
|
||||||
|
while branch is None and asyncio.get_running_loop().time() < deadline:
|
||||||
|
branch = await titled()
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
assert branch is not None
|
||||||
|
stack.tg._topic_names.clear() # noqa: SLF001
|
||||||
|
assert await stack.tg.mark_topic(branch)
|
||||||
|
assert stack.bot.topics[-1] == "edit:7:✅ новое имя"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_callback_strips_the_keyboard(stack: Stack) -> None:
|
||||||
|
stack.bot.callback("q:nope:0:1", 555)
|
||||||
|
await stack.until(
|
||||||
|
lambda: {"message_id": 555, "markup": None} in stack.bot.edits,
|
||||||
|
what="keyboard removed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failed_background_turn_is_reported_once(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
event = {
|
||||||
|
"type": "turn.end",
|
||||||
|
"conversation_id": master.external_id,
|
||||||
|
"turn_id": "t-err",
|
||||||
|
"origin": "крон",
|
||||||
|
"stop": "error",
|
||||||
|
}
|
||||||
|
await stack.tg._on_event(event) # noqa: SLF001
|
||||||
|
await stack.tg._on_event(event) # noqa: SLF001
|
||||||
|
await stack.until(
|
||||||
|
lambda: stack.sent_with("фоновый тёрн (крон) упал"), what="notice"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
assert len([m for m in stack.bot.sent if "упал" in m["text"]]) == 1
|
||||||
|
await stack.tg._on_event({**event, "turn_id": "t-user", "origin": "user"}) # noqa: SLF001
|
||||||
|
notice = await stack.until(lambda: stack.sent_with("⚠️ тёрн упал"), what="user")
|
||||||
|
assert "фоновый" not in notice["text"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gone_topic_unbinds_and_the_next_message_starts_afresh(
|
||||||
|
stack: Stack,
|
||||||
|
) -> None:
|
||||||
|
stack.bot.message("hello", thread=7)
|
||||||
|
await stack.until(lambda: stack.sent_with("hello"), what="reply")
|
||||||
|
branch = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=f"{USER}/7"
|
||||||
|
)
|
||||||
|
stack.bot.gone_threads.add(7)
|
||||||
|
await stack.world.conversations.say(branch, "psst")
|
||||||
|
|
||||||
|
async def unbound() -> bool:
|
||||||
|
rows = await stack.world.conversations.bindings(branch)
|
||||||
|
return all(not b.visible for b in rows)
|
||||||
|
|
||||||
|
deadline = asyncio.get_running_loop().time() + 5
|
||||||
|
while not await unbound() and asyncio.get_running_loop().time() < deadline:
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
assert await unbound()
|
||||||
|
assert branch.external_id not in stack.tg._targets # noqa: SLF001
|
||||||
|
async with stack.world.db.session() as session:
|
||||||
|
rows = list((await session.exec(select(Delivery))).all())
|
||||||
|
assert [r.status for r in rows if r.text == "psst"] == ["failed"]
|
||||||
|
stack.bot.gone_threads.clear()
|
||||||
|
stack.bot.message("again", thread=7)
|
||||||
|
await stack.until(lambda: stack.sent_with("again"), what="fresh reply")
|
||||||
|
fresh = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=f"{USER}/7"
|
||||||
|
)
|
||||||
|
assert fresh is not None and fresh.id != branch.id and fresh.status == "open"
|
||||||
|
|||||||
Reference in New Issue
Block a user