from __future__ import annotations import asyncio import time from typing import Any from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient from yuxi.channels.adapters.bluebubbles.send import send_typing_indicator from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger class StreamingTypingSync: TYPING_REFRESH_INTERVAL = 8.0 def __init__(self, client: BlueBubblesClient | None = None): self._client = client self._active_chats: set[str] = set() self._last_typing: dict[str, float] = {} self._keepalive_task: asyncio.Task | None = None self._running = False def set_client(self, client: BlueBubblesClient) -> None: self._client = client async def start_typing(self, chat_id: str) -> None: if not self._client: return self._active_chats.add(chat_id) try: await send_typing_indicator(self._client, chat_id, display=True) self._last_typing[chat_id] = time.monotonic() except Exception: logger.debug(f"[BlueBubbles] Failed to start typing indicator for {chat_id}") if not self._running: self._running = True self._keepalive_task = asyncio.create_task(self._typing_keepalive()) async def stop_typing(self, chat_id: str) -> None: self._active_chats.discard(chat_id) if not self._client: return try: await send_typing_indicator(self._client, chat_id, display=False) self._last_typing.pop(chat_id, None) except Exception: logger.debug(f"[BlueBubbles] Failed to stop typing indicator for {chat_id}") if not self._active_chats: self._running = False if self._keepalive_task and not self._keepalive_task.done(): self._keepalive_task.cancel() self._keepalive_task = None async def stop_all(self) -> None: chats = list(self._active_chats) for chat_id in chats: await self.stop_typing(chat_id) async def _typing_keepalive(self) -> None: while self._running and self._active_chats: await asyncio.sleep(self.TYPING_REFRESH_INTERVAL) if not self._client: continue now = time.monotonic() for chat_id in list(self._active_chats): last = self._last_typing.get(chat_id, 0) if now - last >= self.TYPING_REFRESH_INTERVAL: try: await send_typing_indicator(self._client, chat_id, display=True) self._last_typing[chat_id] = now except Exception: logger.debug(f"[BlueBubbles] Typing keepalive failed for {chat_id}") async def send_stream_with_typing( adapter: Any, chat_id: str, msg_id: str, chunk: str, finished: bool, typing_sync: StreamingTypingSync | None = None, ) -> DeliveryResult: if typing_sync and not finished: await typing_sync.start_typing(chat_id) result = await adapter.send_stream_chunk(chat_id, msg_id, chunk, finished) if finished and typing_sync: await typing_sync.stop_typing(chat_id) return result class StreamBuffer: def __init__(self, max_buffer_size: int = 65536): self._max_buffer_size = max_buffer_size self._buffers: dict[str, str] = {} def append(self, stream_id: str, text: str) -> str: current = self._buffers.get(stream_id, "") new_content = current + text if len(new_content) > self._max_buffer_size: new_content = new_content[-self._max_buffer_size :] self._buffers[stream_id] = new_content return new_content def get(self, stream_id: str) -> str: return self._buffers.get(stream_id, "") def clear(self, stream_id: str) -> None: self._buffers.pop(stream_id, None)