import asyncio import time import aiohttp from asgiref.sync import sync_to_async from django.conf import settings from core.clients import ClientBase, transport from core.messaging import history from core.modules.mixed_protocol import normalize_gateway_event from core.models import PersonIdentifier class GatewayClient(ClientBase): """ Generic gateway-backed client for mixed protocol services. Expected gateway contract: - GET /v1/events/next -> JSON event (or 204 for no events) - POST /v1/send - POST /v1/typing/start - POST /v1/typing/stop """ poll_interval_seconds = 1 def __init__(self, ur, loop, service): super().__init__(ur, loop, service) self._task = None self._stopping = False self._not_found_count = 0 self.base_url = str( getattr( settings, f"{service.upper()}_HTTP_URL", f"http://{service}:8080", ) ).rstrip("/") self.enabled = bool( str( getattr(settings, f"{service.upper()}_ENABLED", "true"), ).lower() in {"1", "true", "yes", "on"} ) def start(self): if not self.enabled: self.log.info("%s gateway disabled by settings", self.service) return if self._task is None: self.log.info("%s gateway client starting (%s)", self.service, self.base_url) self._task = self.loop.create_task(self._poll_loop()) async def start_typing(self, identifier): return await transport.start_typing(self.service, identifier) async def stop_typing(self, identifier): return await transport.stop_typing(self.service, identifier) async def _gateway_next_event(self): url = f"{self.base_url}/v1/events/next" timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(url) as response: if response.status == 204: return "empty", None if response.status == 404: return "not_found", None if response.status != 200: return "error", None try: return "ok", await response.json() except Exception: return "error", None async def _poll_loop(self): while not self._stopping: try: status, event = await self._gateway_next_event() if status == "ok" and event: self._not_found_count = 0 await self._handle_event(event) elif status == "not_found": self._not_found_count += 1 if self._not_found_count >= 3: self.log.warning( "%s gateway endpoint /v1/events/next returned 404 repeatedly; stopping client. " "Set %s_ENABLED=false or configure %s_HTTP_URL.", self.service, self.service.upper(), self.service.upper(), ) self._stopping = True break await asyncio.sleep(self.poll_interval_seconds) elif status in {"empty", "error"}: await asyncio.sleep(self.poll_interval_seconds) except asyncio.CancelledError: raise except Exception as exc: self.log.warning("%s gateway poll error: %s", self.service, exc) await asyncio.sleep(max(2, self.poll_interval_seconds)) async def _handle_event(self, event): normalized = normalize_gateway_event(self.service, event) event_type = normalized.event_type if event_type == "message": await self._handle_message(normalized) return if event_type == "read": await self.ur.message_read( self.service, identifier=normalized.identifier, message_timestamps=normalized.message_timestamps, read_ts=normalized.payload.get("read_ts"), read_by=normalized.payload.get("read_by") or normalized.payload.get("reader") or normalized.identifier, payload=normalized.payload, ) return if event_type in {"typing_start", "typing_started"}: await self.ur.started_typing( self.service, identifier=normalized.identifier, payload=normalized.payload, ) return if event_type in {"typing_stop", "typing_stopped"}: await self.ur.stopped_typing( self.service, identifier=normalized.identifier, payload=normalized.payload, ) return async def _handle_message(self, event): identifier_value = event.identifier if not identifier_value: return text = event.text ts = int(event.ts or int(time.time() * 1000)) attachments = event.attachments identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter( identifier=identifier_value, service=self.service, ) ) if not identifiers: return xmpp_attachments = [] if attachments: fetched = await asyncio.gather( *[transport.fetch_attachment(self.service, att) for att in attachments] ) for row in fetched: if row: xmpp_attachments.append(row) for identifier in identifiers: session = await history.get_chat_session(identifier.user, identifier) await history.store_message( session=session, sender=identifier_value, text=text, ts=ts, outgoing=False, ) await self.ur.xmpp.client.send_from_external( identifier.user, identifier, text, is_outgoing_message=False, attachments=xmpp_attachments, ) await self.ur.message_received( self.service, identifier=identifier, text=text, ts=ts, payload=event.payload, )