from __future__ import annotations import logging import time from yuxi.channel.extensions.zulip.client import ZulipAsyncClient from yuxi.channel.extensions.zulip.outbound import parse_target_id logger = logging.getLogger(__name__) class ZulipDraftStream: def __init__( self, client: ZulipAsyncClient, channel_target: str, content_limit: int = 10000, edit_interval_ms: int = 350, thread_id: str | None = None, ): self._client = client self._channel_target = channel_target self._content_limit = content_limit self._edit_interval_ms = edit_interval_ms self._thread_id = thread_id self._message_id: str | None = None self._last_edit_at: float = 0.0 self._buffer = "" self._placeholder_sent = False async def _send_placeholder(self) -> None: chat_type, destination = parse_target_id(self._channel_target) payload: dict = {"type": chat_type, "to": destination, "content": "▌"} if chat_type == "stream" and self._thread_id: payload["topic"] = self._thread_id result = await self._client.send_message(payload) self._message_id = str(result.get("id", "")) self._placeholder_sent = True async def append(self, chunk: str) -> None: if not self._placeholder_sent: raise RuntimeError("placeholder must be sent before appending") self._buffer += chunk now = time.monotonic() elapsed_ms = (now - self._last_edit_at) * 1000 if self._last_edit_at > 0 and elapsed_ms < self._edit_interval_ms: return await self._flush() async def _flush(self) -> None: if not self._message_id: return content = self._buffer[: self._content_limit] try: await self._client.update_message( message_id=int(self._message_id), content=content, ) self._last_edit_at = time.monotonic() except Exception as exc: logger.warning("Zulip block stream update failed: %s", exc) async def finalize(self) -> str | None: if self._message_id: content = self._buffer[: self._content_limit] try: await self._client.update_message( message_id=int(self._message_id), content=content, ) except Exception: pass return self._message_id async def discard(self) -> None: if self._message_id: try: await self._client.delete_message(message_id=int(self._message_id)) except Exception: pass self._message_id = None async def init(self) -> None: if not self._placeholder_sent: await self._send_placeholder() class ZulipStreaming: streaming_mode = "block" block_streaming_enabled = True async def create_block_chunker( self, client: ZulipAsyncClient, target_id: str, thread_id: str | None = None, ) -> ZulipDraftStream: stream = ZulipDraftStream(client, target_id, thread_id=thread_id) await stream.init() return stream