ForcePilot/backend/package/yuxi/channel/extensions/slack/streaming.py
Kris bfc7755137 feat(channel): 添加 Slack 渠道扩展
新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- monitor: 渠道状态监控
- status: 会话状态管理
- actions: 交互动作处理
- interactive: 交互式消息
- commands: 斜杠指令
- threading: 线程管理
- mentions: @提及
- constants: 常量定义
- types: 类型定义
2026-05-21 11:43:23 +08:00

212 lines
6.3 KiB
Python

import logging
import time
from dataclasses import dataclass, field
from typing import Any
from yuxi.channel.extensions.slack.constants import (
SLACK_STREAM_BUFFER_CHARS,
SLACK_BENIGN_FINALIZE_CODES,
SLACK_PREVIEW_THROTTLE_MS,
SLACK_PREVIEW_MAX_CHARS,
)
from yuxi.channel.extensions.slack.errors import SlackStreamNotDeliveredError
logger = logging.getLogger(__name__)
@dataclass
class SlackStreamSession:
client: Any = None
channel: str = ""
thread_ts: str = ""
team_id: str | None = None
user_id: str | None = None
delivered: bool = False
pending_text: str = ""
stopped: bool = False
_streamer: Any = field(default=None, repr=False)
async def start(self, initial_text: str = "") -> None:
kwargs: dict = {"channel": self.channel}
if self.thread_ts:
kwargs["thread_ts"] = self.thread_ts
if self.team_id:
kwargs["recipient_team_id"] = self.team_id
if self.user_id:
kwargs["recipient_user_id"] = self.user_id
self._streamer = self.client.chat_stream(**kwargs)
if initial_text:
result = await self._streamer.append(markdown_text=initial_text)
if result is not None:
self.delivered = True
async def append(self, text: str) -> None:
if self.stopped or not text:
return
self.pending_text = text
result = await self._streamer.append(markdown_text=text)
if result is not None:
self.delivered = True
self.pending_text = ""
async def stop(self, final_text: str = "") -> None:
if self.stopped:
return
self.stopped = True
try:
from slack_sdk.errors import SlackApiError
except ImportError:
SlackApiError = None
try:
if final_text:
await self._streamer.stop(markdown_text=final_text)
else:
await self._streamer.stop()
self.delivered = True
self.pending_text = ""
except SlackApiError as e:
error_code = e.response.get("error", "unknown")
if error_code in SLACK_BENIGN_FINALIZE_CODES:
if self.pending_text and not self.delivered:
raise SlackStreamNotDeliveredError(self.pending_text, error_code) from e
return
raise
@dataclass
class SlackDraftStreamSession:
client: Any = None
channel: str = ""
thread_ts: str = ""
target_id: str = ""
message_ts: str = ""
content: str = ""
throttle_ms: int = SLACK_PREVIEW_THROTTLE_MS
max_chars: int = SLACK_PREVIEW_MAX_CHARS
delivered: bool = False
stopped: bool = False
_last_flush: float = 0.0
async def start(self, initial_text: str = "") -> None:
kwargs: dict = {
"channel": self.target_id or self.channel,
"text": initial_text or "...",
"mrkdwn": True,
}
if self.thread_ts:
kwargs["thread_ts"] = self.thread_ts
result = await self.client.chat_postMessage(**kwargs)
self.message_ts = result.get("ts", "")
self.content = initial_text or ""
self._last_flush = time.monotonic()
async def append(self, text: str) -> None:
if self.stopped or not text:
return
self.content += text
now = time.monotonic()
if now - self._last_flush < self.throttle_ms / 1000:
return
await self._flush()
async def stop(self, final_text: str = "") -> None:
if self.stopped:
return
self.stopped = True
if final_text:
self.content += final_text
await self._flush()
async def _flush(self) -> None:
if not self.message_ts or not self.content:
return
try:
truncated = self.content[: self.max_chars]
await self.client.chat_update(
channel=self.target_id or self.channel,
ts=self.message_ts,
text=truncated,
mrkdwn=True,
)
self.delivered = True
self._last_flush = time.monotonic()
except Exception as e:
logger.warning("Draft stream update failed: %s", e)
class SlackStreaming:
streaming_mode = "native"
preview_stream_throttle_ms = SLACK_PREVIEW_THROTTLE_MS
preview_min_initial_chars = 18
block_streaming_enabled = False
block_streaming_break = "text_end"
block_streaming_chunk_min_chars = 800
block_streaming_chunk_max_chars = 1200
block_streaming_chunk_break_preference = "paragraph"
block_streaming_coalesce_defaults = None
def create_draft_stream_session(self, target_id: str) -> SlackDraftStreamSession:
return SlackDraftStreamSession(target_id=target_id)
def create_block_chunker(self) -> object:
return {"mode": "length"}
async def start_stream(
self,
client,
channel: str,
thread_ts: str = "",
team_id: str | None = None,
user_id: str | None = None,
) -> SlackStreamSession:
session = SlackStreamSession(
client=client,
channel=channel,
thread_ts=thread_ts,
team_id=team_id,
user_id=user_id,
)
await session.start()
return session
async def append_stream(self, session: SlackStreamSession, text: str) -> None:
await session.append(text)
async def stop_stream(self, session: SlackStreamSession, final_text: str = "") -> None:
await session.stop(final_text)
def should_use_streaming(self, text_length: int) -> bool:
return text_length > SLACK_STREAM_BUFFER_CHARS
async def start_draft_stream(
self,
client,
channel: str,
thread_ts: str = "",
initial_text: str = "",
) -> SlackDraftStreamSession:
session = SlackDraftStreamSession(
client=client,
channel=channel,
target_id=channel,
thread_ts=thread_ts,
)
await session.start(initial_text)
return session
async def append_draft_stream(self, session: SlackDraftStreamSession, text: str) -> None:
await session.append(text)
async def stop_draft_stream(self, session: SlackDraftStreamSession, final_text: str = "") -> None:
await session.stop(final_text)