ForcePilot/backend/package/yuxi/channels/adapters/slack/draft_stream.py
Kris a2aa782b86 feat(slack adapter): 实现完整的Slack频道适配器基础功能
新增Slack适配器全套核心模块,包括消息处理流水线、会话管理、配置适配、权限控制等完整功能:
1. 新增语音、视觉相关的TTS和图像分析导出接口
2. 实现消息预处理、路由、线程上下文处理的完整流水线
3. 新增账号管理、缓存机制、房间上下文提取功能
4. 支持Webhook和Socket Mode两种事件接收方式
5. 实现权限白名单、审批配对、自动状态管理功能
6. 新增配置迁移、作用域校验、重连策略等辅助模块
2026-05-12 00:48:57 +08:00

141 lines
4.1 KiB
Python

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