Telegram: Plain-Text-Fallback bei Parse-Fehler, tag-sichere Kürzung auf 4096. Matrix: bytebewusste Kürzung. Downtime: lesender Abgleich nach dem POST, generische Fehlermeldung im Chat. Agent: Platzhalter für nicht ausgeführte Tool-Calls, Text im letzten Schritt erzwingen, Zeitreserve, Kompaktierung, Limit pro Antwort. CLAUDE.md: Fallbacks für Phase 6d. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
80 lines
3.5 KiB
Python
80 lines
3.5 KiB
Python
import asyncio
|
|
import logging
|
|
from typing import Awaitable, Callable, Sequence
|
|
|
|
from ..chat import BACKEND_TELEGRAM, ChatAdapter, ChatError, MessageRef, Target, TELEGRAM_MESSAGE_MAX, truncate_html
|
|
from .extras import Extras, ExtrasProvider
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
SEND_TIMEOUT_SECONDS = 45.0
|
|
ERROR_MAX = 200
|
|
|
|
AdapterProvider = Callable[[], Awaitable[list[ChatAdapter]]]
|
|
|
|
|
|
class Deliverer:
|
|
def __init__(self, adapters: AdapterProvider, timeout: float = SEND_TIMEOUT_SECONDS):
|
|
self._adapters = adapters
|
|
self._timeout = timeout
|
|
|
|
async def send(self, text: str, targets: Sequence[Target], extras: ExtrasProvider | None = None,
|
|
silent: bool = False) -> list[MessageRef]:
|
|
results = await asyncio.gather(*(self._send_one(target, text, extras, silent) for target in targets))
|
|
return [ref for ref in results if ref is not None]
|
|
|
|
async def edit(self, refs: Sequence[MessageRef], text: str, extras: ExtrasProvider | None = None,
|
|
silent: bool = False) -> None:
|
|
await asyncio.gather(*(self._edit_one(ref, text, extras, silent) for ref in refs))
|
|
|
|
async def _send_one(self, target: Target, text: str, extras: ExtrasProvider | None,
|
|
silent: bool) -> MessageRef | None:
|
|
full, options = _apply(target.backend, text, extras(target.backend) if extras else None, silent)
|
|
try:
|
|
return await self._call(target.backend, lambda adapter: adapter.send(target.target, full, **options))
|
|
except Exception as exc:
|
|
log.warning("Senden über %s fehlgeschlagen: %s", target.backend, _brief(exc))
|
|
return None
|
|
|
|
async def _edit_one(self, ref: MessageRef, text: str, extras: ExtrasProvider | None, silent: bool) -> None:
|
|
full, options = _apply(ref.backend, text, extras(ref.backend) if extras else None, silent)
|
|
try:
|
|
await self._call(ref.backend, lambda adapter: adapter.edit(ref, full, **options))
|
|
except Exception as exc:
|
|
log.warning("Editieren über %s fehlgeschlagen: %s", ref.backend, _brief(exc))
|
|
|
|
async def _adapter_for(self, backend: str) -> ChatAdapter | None:
|
|
return next((a for a in await self._adapters() if a.name == backend), None)
|
|
|
|
async def _call(self, backend: str, action: Callable[[ChatAdapter], Awaitable]):
|
|
adapter = await self._adapter_for(backend)
|
|
if adapter is None:
|
|
raise ChatError(f"Backend {backend} ist nicht aktiv")
|
|
try:
|
|
return await asyncio.wait_for(action(adapter), self._timeout)
|
|
except Exception:
|
|
fresh = await self._adapter_for(backend)
|
|
# Die Einstellungen können den Adapter zwischenzeitlich ersetzt und geschlossen haben.
|
|
if fresh is None or fresh is adapter:
|
|
raise
|
|
return await asyncio.wait_for(action(fresh), self._timeout)
|
|
|
|
|
|
def _apply(backend: str, text: str, extra: Extras | None, silent: bool = False) -> tuple[str, dict]:
|
|
options: dict = {"silent": True} if silent else {}
|
|
if extra is None:
|
|
return text, options
|
|
if extra.buttons:
|
|
options["buttons"] = extra.buttons
|
|
if not extra.hint:
|
|
return text, options
|
|
if backend == BACKEND_TELEGRAM:
|
|
# Reserviert Platz für den Hinweis, damit die anschließende Kürzung im Adapter ihn nicht abschneidet.
|
|
budget = max(TELEGRAM_MESSAGE_MAX - len(extra.hint) - 1, 0)
|
|
text = truncate_html(text, budget)
|
|
return f"{text}\n{extra.hint}", options
|
|
|
|
|
|
def _brief(exc: Exception) -> str:
|
|
return f"{type(exc).__name__}: {exc}"[:ERROR_MAX]
|