Files
patrickandClaude Sonnet 5.5 cdcf7f673a
test / pytest (3.11) (push) Canceled after 0s
test / pytest (3.13) (push) Canceled after 0s
fix: Härtung nach Referenz-Review, Phase-6d-Fallbacks vermerkt
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>
2026-11-06 11:58:54 +01:00

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]