from __future__ import annotations import asyncio import time from dataclasses import dataclass from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger DRAFT_STREAM_MAX_CHARS = 8000 DRAFT_STREAM_THROTTLE_MS = 1000 DRAFT_STREAM_MIN_THROTTLE_MS = 250 class DraftStream: def __init__( self, client: AsyncWebClient, *, max_chars: int = DRAFT_STREAM_MAX_CHARS, throttle_ms: int = DRAFT_STREAM_THROTTLE_MS, ): self._client = client self._max_chars = max_chars self._throttle_ms = max(throttle_ms, DRAFT_STREAM_MIN_THROTTLE_MS) self._active: dict[str, _DraftState] = {} self._lock = asyncio.Lock() async def start( self, channel: str, text: str, *, thread_ts: str | None = None, ) -> DeliveryResult: try: params: dict = { "channel": channel, "text": text[: self._max_chars], "mrkdwn": True, } if thread_ts: params["thread_ts"] = thread_ts result = await self._client.chat_postMessage(**params) ts = result.get("ts", "") if not ts: return DeliveryResult(success=False, error="No message ts returned") async with self._lock: self._active[channel] = _DraftState( channel=channel, ts=ts, text=text, last_update=time.monotonic(), ) return DeliveryResult(success=True, message_id=ts) except SlackApiError as e: err = e.response.get("error", str(e)) logger.error(f"Draft stream start failed: {err}") return DeliveryResult(success=False, error=err) async def append( self, channel: str, text: str, *, ts: str = "", ) -> DeliveryResult: async with self._lock: state = self._active.get(channel) if not state: return DeliveryResult(success=False, error="No active draft stream for channel") now = time.monotonic() elapsed_ms = (now - state.last_update) * 1000 if elapsed_ms < self._throttle_ms: await asyncio.sleep((self._throttle_ms - elapsed_ms) / 1000) try: result = await self._client.chat_update( channel=channel, ts=ts or state.ts, text=text[: self._max_chars], mrkdwn=True, ) async with self._lock: state.text = text state.last_update = time.monotonic() return DeliveryResult( success=result.get("ok", False), message_id=result.get("ts"), error=result.get("error"), ) except SlackApiError as e: err = e.response.get("error", str(e)) if err == "message_not_found": return await self.start(channel, text) logger.error(f"Draft stream append failed: {err}") return DeliveryResult(success=False, error=err) async def seal(self, channel: str) -> DeliveryResult: state = self._active.pop(channel, None) if not state: return DeliveryResult(success=False, error="No active draft stream") return DeliveryResult(success=True, message_id=state.ts) async def clear(self, channel: str) -> None: self._active.pop(channel, None) async def stop(self, channel: str) -> None: self._active.pop(channel, None) async def force_new_message( self, channel: str, text: str, ) -> DeliveryResult: self._active.pop(channel, None) return await self.start(channel, text) @property def active_channels(self) -> set[str]: return set(self._active.keys()) @dataclass class _DraftState: channel: str ts: str text: str last_update: float