ForcePilot/backend/package/yuxi/external_systems/integrations/slack/operations.py

405 lines
15 KiB
Python
Raw Normal View History

"""Slack discover / preview_tools / create_tools handler 实现。
handler 签名遵循 ``OperationHandler`` 协议
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``
handler 内部职责
1. ``system_config`` 提取 ``base_url`` / ``auth_config`` / ``_resolved_token``
2. 构建 ``auth_headers``token use_cases 层注入到 ``system_config["_resolved_token"]``
3. 调用 Slack Web API 聚合元数据conversations.list / users.list / team.info
4. 调用 ``SlackConfigGenerator`` 生成工具配置草稿
429 退避分层见设计文档 §6.2
- discover/preview/create 阶段本模块 ``_request_with_retry`` 实现解析 Retry-After
- 运行时工具执行通过工具 ``retry_policy.retry_status_codes: [429, 502, 503, 504]`` 配置
Slack 错误响应特殊性见设计文档 §8.1
- HTTP 状态码成功与失败均可能返回 ``200 OK``
- 响应体必含 ``ok`` 布尔字段失败时 ``ok=false`` 且包含 ``error`` 字符串字段
- ``_request_with_retry`` HTTP 2xx 时额外检查 ``ok`` 字段``ok=false`` 通过
``map_slack_error`` 转换为 ``ExternalSystemError`` 子类
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any
from yuxi.external_systems.exceptions import (
AuthError,
ExecutionError,
RateLimitExceededError,
)
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
from yuxi.external_systems.integrations.slack.constants import (
_RETRY_BASE_DELAY,
_RETRY_MAX_ATTEMPTS,
_SLACK_HEADERS,
BASE_URL,
CONVERSATION_TYPES,
PAGE_SIZE,
)
from yuxi.external_systems.integrations.slack.error_extractor import (
map_slack_error,
)
from yuxi.external_systems.integrations.slack.generators import (
SlackConfigGenerator,
)
if TYPE_CHECKING:
pass
# ── system_config 解析辅助 ────────────────────────────────────────────────
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
"""从 system_config 提取 token构建 Slack 请求头。
system_config token use_cases 层通过 ``token_manager.get_token`` 预处理后
注入到 ``system_config["_resolved_token"]["access_token"]``handler 不自行换取
tokentoken 获取是 use_cases 层的职责
"""
token_info = system_config.get("_resolved_token")
if not isinstance(token_info, dict) or not token_info.get("access_token"):
raise AuthError("slack: system_config 缺少 _resolved_tokenuse_cases 层未注入 token")
access_token = token_info["access_token"]
headers = dict(_SLACK_HEADERS)
headers["Authorization"] = f"Bearer {access_token}"
return headers
def _resolve_base_url(system_config: dict[str, Any]) -> str:
"""从 system_config 提取 base_url缺省返回 ``https://slack.com/api``。"""
connection_config = system_config.get("connection_config") or {}
base_url = connection_config.get("base_url") or BASE_URL
return str(base_url).rstrip("/")
def _resolve_selected_tools(system_config: dict[str, Any]) -> list[str] | None:
"""从 system_config 提取用户选择的工具 slug 列表。"""
discovery_options = system_config.get("discovery_options") or {}
selected = discovery_options.get("selected_tools")
return list(selected) if selected else None
# ── 429 退避 HTTP 请求 ────────────────────────────────────────────────────
async def _request_with_retry(
base_url: str,
method: str,
headers: dict[str, str],
*,
method_verb: str = "GET",
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""带 429 退避的 HTTP 请求discover/preview/create 阶段使用)。
解析 ``Retry-After`` 头部秒数按指数退避重试最多 ``_RETRY_MAX_ATTEMPTS``
Slack 错误响应特殊性HTTP 2xx ``ok=false`` 也视为错误通过
``map_slack_error`` 转换为 ``ExternalSystemError`` 子类抛出
网络层异常超时 / 连接错误转换为 ``ExecutionError``
Args:
base_url: Slack Web API 基础 URL ``https://slack.com/api``
method: Web API 方法名 ``conversations.list``
headers: 请求头 Authorization
method_verb: HTTP 方法``GET`` / ``POST``
params: 查询参数GET
json_body: JSON bodyPOST 写操作
Returns:
解析后的 JSON 响应 dict``ok=true`` 的成功响应
"""
import httpx
url = f"{base_url}/{method}"
last_error: Exception | None = None
for attempt in range(_RETRY_MAX_ATTEMPTS):
try:
async with httpx.AsyncClient() as client:
if method_verb == "GET":
resp = await client.get(url, headers=headers, params=params)
else:
# POST 写操作使用 JSON body必须设置 Content-Type: application/json
post_headers = dict(headers)
post_headers["Content-Type"] = "application/json"
resp = await client.request(method_verb, url, headers=post_headers, json=json_body)
# 429 限流:优先使用 Retry-After 头退避
if resp.status_code == 429:
if attempt == _RETRY_MAX_ATTEMPTS - 1:
retry_after_header = resp.headers.get("Retry-After", "60")
try:
retry_after = int(retry_after_header)
except ValueError:
retry_after = 60
raise RateLimitExceededError(
f"slack: 限流,已重试 {_RETRY_MAX_ATTEMPTS} 次仍失败: {url}",
retry_after=retry_after,
limit_type="qps",
)
retry_after_header = resp.headers.get("Retry-After")
if retry_after_header:
try:
delay = float(retry_after_header)
except ValueError:
delay = _RETRY_BASE_DELAY * (2**attempt)
else:
delay = _RETRY_BASE_DELAY * (2**attempt)
await asyncio.sleep(delay)
continue
# 非 2xx解析错误 body 并映射为 ExternalSystemError 子类
if resp.status_code >= 400:
try:
error_body = resp.json()
except Exception:
error_body = {"error": f"http_{resp.status_code}"}
raise map_slack_error(resp.status_code, error_body)
# 2xx解析 JSON 响应
try:
body = resp.json()
except Exception as exc:
raise ExecutionError(f"slack: 响应 JSON 解析失败: {url}: {exc}") from exc
# Slack 特殊性HTTP 200 也可能 ok=false
if isinstance(body, dict) and body.get("ok") is False:
raise map_slack_error(resp.status_code, body)
return body
except httpx.HTTPError as exc:
last_error = exc
if attempt == _RETRY_MAX_ATTEMPTS - 1:
break
await asyncio.sleep(_RETRY_BASE_DELAY * (2**attempt))
continue
raise ExecutionError(f"slack: 请求 {url} 失败,已重试 {_RETRY_MAX_ATTEMPTS} 次: {last_error}")
# ── cursor 分页自动迭代 ──────────────────────────────────────────────────
async def _paginate(
base_url: str,
method: str,
headers: dict[str, str],
params: dict[str, Any],
) -> list[dict[str, Any]]:
"""cursor 分页自动迭代聚合所有页结果GET 请求)。
Slack 列表接口``conversations.list`` / ``users.list`` /
``conversations.history`` / ``conversations.replies``均使用 cursor 分页
响应中的 ``response_metadata.next_cursor`` 为空字符串时表示结束
Args:
method: Web API 方法名 ``conversations.list``
params: 初始查询参数不含 ``cursor``
Returns:
聚合后的资源列表``channels`` / ``members`` / ``messages``
"""
aggregated: list[dict[str, Any]] = []
cursor: str | None = None
while True:
query = {**params}
if cursor:
query["cursor"] = cursor
resp = await _request_with_retry(
base_url,
method,
headers,
method_verb="GET",
params=query,
)
# 响应体中资源列表的 key 因方法而异
for key in ("channels", "members", "messages"):
items = resp.get(key)
if isinstance(items, list):
aggregated.extend(items)
break
cursor = (resp.get("response_metadata") or {}).get("next_cursor", "")
if not cursor:
break
return aggregated
# ── 三个 OperationHandler 实现 ────────────────────────────────────────────
async def discover_slack(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""discover handler发现 Slack 工作区可用资源(频道 + 用户 + 工作区信息)。
通过 ``conversations.list`` + ``users.list`` + ``team.info`` 三个接口聚合返回
工作区元数据供前端展示可用频道与用户
Returns:
资源列表每项形如
- ``{"type": "channel", "id": "C1234567890", "name": "general", "is_private": false}``
- ``{"type": "user", "id": "U1234567890", "name": "alice", "email": "..."}``
- ``{"type": "team", "id": "T1234567890", "name": "Acme", "domain": "acme"}``
"""
base_url = _resolve_base_url(system_config)
headers = _build_auth_headers(system_config)
channels = await _paginate(
base_url,
"conversations.list",
headers,
params={"types": CONVERSATION_TYPES, "limit": PAGE_SIZE},
)
users = await _paginate(
base_url,
"users.list",
headers,
params={"limit": PAGE_SIZE},
)
team_resp = await _request_with_retry(
base_url,
"team.info",
headers,
method_verb="GET",
)
team = team_resp.get("team") or {}
result: list[dict[str, Any]] = [
*[
{
"type": "channel",
"id": c.get("id", ""),
"name": c.get("name", ""),
"is_private": c.get("is_private", False),
}
for c in channels
if isinstance(c, dict)
],
*[
{
"type": "user",
"id": u.get("id", ""),
"name": u.get("name", ""),
"email": (u.get("profile") or {}).get("email") if isinstance(u.get("profile"), dict) else None,
}
for u in users
if isinstance(u, dict) and not u.get("deleted")
],
]
if team:
result.append(
{
"type": "team",
"id": team.get("id", ""),
"name": team.get("name", ""),
"domain": team.get("domain"),
}
)
return result
async def preview_slack_tools(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""preview_tools handler预览将生成的工具列表不持久化
Returns:
工具预览列表每项为 ``ExternalToolCreateInput`` 兼容的 dict
"""
payload = _build_generator_payload(system_config)
generator = SlackConfigGenerator()
return await generator.generate(payload)
async def create_slack_tools(
source_type: str,
system_config: dict[str, Any],
) -> GeneratedToolsDraft:
"""create_tools handler生成工具草稿不直接持久化
Returns:
``GeneratedToolsDraft`` use_cases 层统一持久化
"""
payload = _build_generator_payload(system_config)
generator = SlackConfigGenerator()
tool_configs = await generator.generate(payload)
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
# ── 生成器 payload 构造 ───────────────────────────────────────────────────
def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
"""从 system_config 构造生成器 payload。
流程
1. 解析 base_url / auth_type / auth_config
2. 解析用户选择的工具 slug 列表可选
3. 汇总为生成器 payload
"""
base_url = _resolve_base_url(system_config)
auth_config = system_config.get("auth_config") or {}
auth_type = system_config.get("auth_type", "slack_bot_token")
selected_tools = _resolve_selected_tools(system_config)
payload: dict[str, Any] = {
"base_url": base_url,
"auth_type": auth_type,
"auth_config": auth_config,
}
if selected_tools is not None:
payload["selected_tools"] = selected_tools
return payload
# ── 健康检查 ─────────────────────────────────────────────────────────────
async def health_check_slack(system_config: dict[str, Any]) -> dict[str, Any]:
"""Slack 健康检查:调用 ``POST /api/auth.test`` 验证 Token 有效性。
use_cases 层直接调用不注册到 ``IntegrationOperationRegistry``
Returns::
{
"status": "healthy" | "unhealthy",
"team_id": "T1234567890",
"user_id": "U1234567890",
"bot_id": "B1234567890",
"duration_ms": 123,
}
"""
start = time.monotonic()
try:
base_url = _resolve_base_url(system_config)
headers = _build_auth_headers(system_config)
resp = await _request_with_retry(
base_url,
"auth.test",
headers,
method_verb="POST",
)
return {
"status": "healthy",
"team_id": resp.get("team_id"),
"user_id": resp.get("user_id"),
"bot_id": resp.get("bot_id"),
"duration_ms": int((time.monotonic() - start) * 1000),
}
except Exception as exc:
return {
"status": "unhealthy",
"detail": str(exc),
"duration_ms": int((time.monotonic() - start) * 1000),
}