ForcePilot/backend/package/yuxi/channels/adapters/slack/streaming.py
Kris df1a7c7bca refactor(slack-adapter): 整理导入顺序并修复格式问题
本次提交包含多个Slack适配器相关的代码优化:
1. 统一多个文件中datetime和UTC的导入顺序
2. 调整collection.abc导入的参数顺序
3. 修复normalizer.py的文件末尾空行问题
4. 重新排序blocks.py中的函数导入
5. 调整directory_config.py中的函数顺序
6. 重构http_handler中的channel_manager调用方式
7. 新增Slack原生流探测逻辑和相关状态管理
8. 扩展消息动作分类和默认配置
9. 新增大量Slack消息块构建工具函数
10. 大幅重构__init__.py的导出内容,整理导入顺序
11. 为adapter新增熔断机制、缓存持久化和更多API方法
12. 新增多种系统事件处理逻辑
2026-05-13 16:14:38 +08:00

227 lines
7.1 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
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
class StreamState(StrEnum):
INIT = "init"
STREAMING = "streaming"
STOPPED = "stopped"
ERROR = "error"
_BENIGN_FINALIZE_ERRORS = frozenset(
{
"user_not_found",
"team_not_found",
"missing_recipient_user_id",
"not_in_channel",
"channel_not_found",
}
)
class SlackStreamNotDeliveredError(Exception):
pass
@dataclass
class NativeStreamSession:
channel: str
team_id: str = ""
user_id: str = ""
message_ts: str = ""
accumulated_text: str = ""
state: StreamState = StreamState.INIT
error: str = ""
_MISSING_OVERRIDE = object()
class NativeStreamManager:
def __init__(self, client: AsyncWebClient):
self._client = client
self._sessions: dict[str, NativeStreamSession] = {}
self._probed: bool = False
self._native_available: bool | None = None
async def probe_native_stream(self) -> bool:
if self._probed:
return self._native_available is True
self._probed = True
try:
result = await self._client.api_call(
api_method="chat.streamLookup",
http_verb="POST",
params={"limit": 1},
)
available = result.get("ok", False)
if not available:
err = result.get("error", "")
if err in ("method_not_supported", "invalid_auth"):
self._native_available = False
logger.info(f"Slack Native Stream API not available: {err}, falling back to edit-in-place")
else:
self._native_available = True
else:
self._native_available = True
except SlackApiError as e:
err = e.response.get("error", str(e))
logger.info(f"Slack Native Stream API probe failed: {err}, falling back to edit-in-place")
self._native_available = False
except Exception:
self._native_available = False
return self._native_available is True
@property
def native_available(self) -> bool | None:
return self._native_available
async def start_stream(
self,
channel: str,
*,
team_id: str = "",
user_id: str = "",
text: str = "",
) -> DeliveryResult:
session_key = f"{channel}:{team_id}:{user_id}"
existing = self._sessions.get(session_key)
if existing and existing.state == StreamState.STREAMING:
return await self.append_stream(channel, text, team_id=team_id, user_id=user_id)
try:
params: dict = {
"channel": channel,
"text": text or "...",
}
if user_id:
params["user_id"] = user_id
if team_id:
params["team_id"] = team_id
result = await self._client.api_call(
api_method="chat.startStream",
http_verb="POST",
params=params,
)
ok = result.get("ok", False)
ts = result.get("ts", "")
session = NativeStreamSession(
channel=channel,
team_id=team_id,
user_id=user_id,
message_ts=ts,
accumulated_text=text,
state=StreamState.STREAMING if ok else StreamState.ERROR,
error=result.get("error", ""),
)
self._sessions[session_key] = session
if ok:
logger.debug(f"Native stream started: channel={channel}, ts={ts}")
return DeliveryResult(success=True, message_id=ts)
logger.warning(f"Native stream start failed: {result.get('error')}")
return DeliveryResult(success=False, error=result.get("error", "Unknown error"))
except SlackApiError as e:
err = e.response.get("error", str(e))
logger.error(f"Native stream start error: {err}")
return DeliveryResult(success=False, error=err)
async def append_stream(
self,
channel: str,
text: str,
*,
team_id: str = "",
user_id: str = "",
ts: str = "",
) -> DeliveryResult:
session_key = f"{channel}:{team_id}:{user_id}"
session = self._sessions.get(session_key)
if not session or session.state != StreamState.STREAMING:
return await self.start_stream(channel, team_id=team_id, user_id=user_id, text=text)
try:
result = await self._client.api_call(
api_method="chat.appendStream",
http_verb="POST",
params={
"channel": channel,
"ts": ts or session.message_ts,
"text": text,
},
)
ok = result.get("ok", False)
if ok:
session.accumulated_text = text
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
err = e.response.get("error", str(e))
logger.error(f"Native stream append error: {err}")
return DeliveryResult(success=False, error=err)
async def stop_stream(
self,
channel: str,
*,
team_id: str = "",
user_id: str = "",
ts: str = "",
final_text: str = _MISSING_OVERRIDE,
) -> DeliveryResult:
session_key = f"{channel}:{team_id}:{user_id}"
session = self._sessions.pop(session_key, None)
try:
result = await self._client.api_call(
api_method="chat.stopStream",
http_verb="POST",
params={
"channel": channel,
"ts": ts or (session.message_ts if session else ""),
"text": final_text
if final_text is not _MISSING_OVERRIDE
else (session.accumulated_text if session else ""),
},
)
ok = result.get("ok", False)
err = result.get("error", "")
if not ok and not _is_benign_stop_error(err):
logger.error(f"Native stream stop error: {err}")
raise SlackStreamNotDeliveredError(f"Stream not delivered: {err}")
return DeliveryResult(
success=True,
error=err if err and err not in _BENIGN_FINALIZE_ERRORS else None,
)
except SlackApiError as e:
err = e.response.get("error", str(e))
if _is_benign_stop_error(err):
return DeliveryResult(success=True)
logger.error(f"Native stream stop API error: {err}")
raise SlackStreamNotDeliveredError(f"Stream not delivered: {err}") from e
def _is_benign_stop_error(error: str) -> bool:
return error in _BENIGN_FINALIZE_ERRORS