"""Generic fixed-schema text webhook channel adapter.""" from __future__ import annotations import asyncio from typing import Any from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage from beaver.interfaces.channels.base import ChannelInboundSink class GenericWebhookAdapter: def __init__( self, *, channel_id: str, kind: str, mode: str, account_id: str, display_name: str = "", inbound_sink: ChannelInboundSink, response_timeout_seconds: float = 1800, ) -> None: self.channel_id = channel_id self.kind = kind self.mode = mode self.account_id = account_id self.display_name = display_name or channel_id self.inbound_sink = inbound_sink self.response_timeout_seconds = max(1.0, float(response_timeout_seconds)) self.started = False self._pending: dict[str, asyncio.Future[OutboundMessage]] = {} async def start(self) -> None: self.started = True async def stop(self) -> None: self.started = False for future in list(self._pending.values()): if not future.done(): future.cancel() self._pending.clear() async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]: text = str(payload.get("text") or "").strip() peer_id = str(payload.get("peer_id") or "").strip() message_id = str(payload.get("message_id") or "").strip() thread_id = str(payload.get("thread_id") or "").strip() or None peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown" user_id = str(payload.get("user_id") or "").strip() or None if not text: return {"ok": False, "error": "text is required"} if not peer_id: return {"ok": False, "error": "peer_id is required"} if not message_id: return {"ok": False, "error": "message_id is required"} identity = ChannelIdentity( channel_id=self.channel_id, kind=self.kind, account_id=self.account_id, peer_id=peer_id, thread_id=thread_id, peer_type=peer_type, user_id=user_id, message_id=message_id, ) inbound = InboundMessage( channel=self.channel_id, content=text, user_id=user_id, channel_identity=identity, metadata={"webhook": {"peer_type": peer_type}}, ) future = asyncio.get_running_loop().create_future() self._pending[inbound.message_id] = future accept = await self.inbound_sink.accept_inbound(inbound) if not accept.accepted: self._pending.pop(inbound.message_id, None) record = accept.record or {} return { "ok": accept.error is None, "duplicate": accept.duplicate, "pending": accept.pending, "session_id": accept.session_id, "status": record.get("status"), "run_id": record.get("run_id"), "reply": record.get("reply"), "error": accept.error or record.get("error"), } try: outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds) except asyncio.TimeoutError: self._pending.pop(inbound.message_id, None) return { "ok": True, "duplicate": False, "pending": True, "session_id": accept.session_id, } return { "ok": outbound.finish_reason != "error", "duplicate": False, "pending": False, "session_id": outbound.session_id, "run_id": outbound.run_id, "reply": outbound.content, "error": outbound.metadata.get("error"), } async def send(self, message: OutboundMessage) -> None: future = self._pending.pop(message.message_id, None) if future is None or future.done(): message.metadata["delivery_status"] = "unclaimed" return future.set_result(message)