ForcePilot/backend/package/yuxi/channel/extensions/slack/config.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

389 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import hashlib
import logging
from yuxi.channel.extensions.slack.types import (
SlackDmPolicy,
SlackGroupPolicy,
SlackMode,
SlackStreamingMode,
SlackReactionLevel,
SlackReactionNotifications,
SlackTokenSource,
)
logger = logging.getLogger(__name__)
class SlackConfigAdapter:
def list_account_ids(self, config: dict) -> list[str]:
accounts = config.get("channels", {}).get("slack", {}).get("accounts", {})
if not accounts:
return ["default"] if self._env_bot_token() else []
return list(accounts.keys())
async def resolve_account(self, account_id: str, config: dict | None = None) -> dict:
account_config = self._get_account_config(config or {}, account_id)
bot_token, bot_token_source = self._resolve_token(account_config.get("botToken"), "SLACK_BOT_TOKEN")
app_token, app_token_source = self._resolve_token(account_config.get("appToken"), "SLACK_APP_TOKEN")
user_token, user_token_source = self._resolve_token(account_config.get("userToken"), "SLACK_USER_TOKEN")
signing_secret = account_config.get("signingSecret") or os.environ.get("SLACK_SIGNING_SECRET", "")
mode = SlackMode.SOCKET
if account_config.get("mode"):
mode = SlackMode(account_config["mode"])
elif not app_token:
mode = SlackMode.HTTP
dm_policy = SlackDmPolicy.PAIRING
if account_config.get("dmPolicy"):
dm_policy = SlackDmPolicy(account_config["dmPolicy"])
group_policy = SlackGroupPolicy.OPEN
if account_config.get("groupPolicy"):
group_policy = SlackGroupPolicy(account_config["groupPolicy"])
return {
"account_id": account_id,
"name": account_config.get("name", account_id),
"enabled": account_config.get("enabled", True),
"bot_token": bot_token,
"bot_token_source": bot_token_source,
"app_token": app_token,
"app_token_source": app_token_source,
"user_token": user_token,
"user_token_source": user_token_source,
"signing_secret": signing_secret,
"mode": mode,
"dm_policy": dm_policy,
"group_policy": group_policy,
"allow_from": account_config.get("allowFrom", []),
"group_allow_from": account_config.get("groupAllowFrom", []),
"channels": account_config.get("channels", {}),
"text_chunk_limit": account_config.get("textChunkLimit", 40000),
"media_max_mb": account_config.get("mediaMaxMb", 50),
"streaming_mode": SlackStreamingMode(account_config.get("streamingMode", "native")),
"streaming_min_chars": account_config.get("streamingMinChars", 256),
"reaction_level": SlackReactionLevel(account_config.get("reactionLevel", "minimal")),
"reaction_notifications": SlackReactionNotifications(account_config.get("reactionNotifications", "own")),
"send_read_receipts": account_config.get("sendReadReceipts", True),
}
@staticmethod
def _resolve_token(account_value: str | None, env_key: str) -> tuple[str, SlackTokenSource]:
if account_value is not None:
if account_value == "none":
return "", SlackTokenSource.NONE
if account_value.startswith("$"):
env_val = os.environ.get(account_value.strip("${}"), "")
return env_val, SlackTokenSource.CONFIG if env_val else SlackTokenSource.NONE
return account_value, SlackTokenSource.CONFIG
env_val = os.environ.get(env_key, "")
if env_val:
return env_val, SlackTokenSource.ENV
return "", SlackTokenSource.NONE
def is_configured(self, account: dict) -> bool:
bot_token = account.get("bot_token", "")
app_token = account.get("app_token", "")
signing_secret = account.get("signing_secret", "")
return bool(bot_token and (app_token or signing_secret))
def is_enabled(self, account: dict, config: dict) -> bool:
account_id = account.get("account_id", "")
account_config = self._get_account_config(config, account_id)
return account_config.get("enabled", account.get("enabled", True))
def disabled_reason(self, account: dict, config: dict) -> str:
if self.is_enabled(account, config):
return ""
account_id = account.get("account_id", "")
account_config = self._get_account_config(config, account_id)
return account_config.get("disabled_reason", "Account is disabled in config")
def unconfigured_reason(self, account: dict, config: dict) -> str:
if self.is_configured(account):
return ""
bot_token = account.get("bot_token", "")
app_token = account.get("app_token", "")
signing_secret = account.get("signing_secret", "")
mode = account.get("mode", SlackMode.SOCKET)
missing = []
if not bot_token:
missing.append("bot_token")
if mode == SlackMode.SOCKET and not app_token:
missing.append("app_token")
if mode == SlackMode.HTTP and not signing_secret:
missing.append("signing_secret")
if missing:
return f"Missing required credentials: {', '.join(missing)}"
return "Account is not fully configured"
def describe_account(self, account: dict, config: dict) -> dict:
bot_token = account.get("bot_token", "")
app_token = account.get("app_token", "")
return {
"account_id": account.get("account_id", ""),
"name": account.get("name", account.get("account_id", "")),
"mode": account.get("mode", SlackMode.SOCKET),
"configured": self.is_configured(account),
"enabled": self.is_enabled(account, config),
"bot_token_fingerprint": self._token_fingerprint(bot_token),
"app_token_fingerprint": self._token_fingerprint(app_token),
"dm_policy": account.get("dm_policy", SlackDmPolicy.PAIRING),
"group_policy": account.get("group_policy", SlackGroupPolicy.OPEN),
}
def default_account_id(self, config: dict) -> str:
return config.get("channels", {}).get("slack", {}).get("default_account", "default")
def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None:
account_config = self._get_account_config(config, account_id)
allow_from = account_config.get("allow_from")
if allow_from is None:
return None
return [str(entry) for entry in allow_from]
def format_allow_from(self, config: dict, account_id: str | None, allow_from: list[str | int]) -> list[str]:
return [str(entry) for entry in allow_from]
def has_configured_state(self, config: dict) -> bool:
accounts = config.get("channels", {}).get("slack", {}).get("accounts", {})
if not accounts:
return bool(self._env_bot_token())
for account_config in accounts.values():
if account_config.get("bot_token") or self._env_bot_token():
return True
return False
def has_persisted_auth_state(self, config: dict) -> bool:
accounts = config.get("channels", {}).get("slack", {}).get("accounts", {})
if not accounts:
return False
for account_config in accounts.values():
if account_config.get("bot_token"):
return True
return False
def inspect_account(self, config: dict, account_id: str | None = None) -> dict:
aid = account_id or "default"
account_config = self._get_account_config(config, aid)
return {
"account_id": aid,
"name": account_config.get("name", aid),
"enabled": account_config.get("enabled", True),
"mode": account_config.get("mode", "socket"),
"dm_policy": account_config.get("dm_policy", "pairing"),
"group_policy": account_config.get("group_policy", "open"),
"allow_from": account_config.get("allow_from", []),
"group_allow_from": account_config.get("group_allow_from", []),
"bot_token_configured": bool(account_config.get("bot_token") or self._env_bot_token()),
"app_token_configured": bool(account_config.get("app_token") or self._env_app_token()),
}
def set_account_enabled(self, config: dict, account_id: str, enabled: bool) -> dict:
config.setdefault("channels", {}).setdefault("slack", {}).setdefault("accounts", {})
config["channels"]["slack"]["accounts"].setdefault(account_id, {})
config["channels"]["slack"]["accounts"][account_id]["enabled"] = enabled
return config
def delete_account(self, config: dict, account_id: str) -> dict:
accounts = config.get("channels", {}).get("slack", {}).get("accounts", {})
if account_id in accounts:
del accounts[account_id]
return config
def resolve_default_to(self, config: dict, account_id: str | None = None) -> str | None:
accounts = config.get("channels", {}).get("slack", {}).get("accounts", {})
if not accounts:
return None
if account_id and account_id in accounts:
return account_id
return next(iter(accounts.keys()))
def config_schema(self) -> dict:
return {
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Slack 渠道配置",
"properties": {
"accounts": {
"type": "object",
"title": "账户列表",
"description": "Slack 账户配置key 为账户 ID",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "账户名称",
},
"enabled": {
"type": "boolean",
"title": "启用",
"default": True,
},
"bot_token": {
"type": "string",
"title": "Bot Token",
"description": "Slack Bot User OAuth Token (xoxb-),也可设为环境变量 SLACK_BOT_TOKEN",
"x-ui-password": True,
},
"app_token": {
"type": "string",
"title": "App-Level Token",
"description": "Slack App-Level Token (xapp-)Socket 模式必需,也可用环境变量",
"x-ui-password": True,
},
"user_token": {
"type": "string",
"title": "User Token",
"description": "Slack User OAuth Token (xoxp-),也可通过 SLACK_USER_TOKEN 环境变量注入",
"x-ui-password": True,
},
"signing_secret": {
"type": "string",
"title": "Signing Secret",
"description": "Slack Signing SecretHTTP 模式用于验证请求签名)",
"x-ui-password": True,
},
"mode": {
"type": "string",
"title": "连接模式",
"enum": ["socket", "http"],
"default": "socket",
},
"dm_policy": {
"type": "string",
"title": "DM 策略",
"enum": ["pairing", "allowlist", "open", "disabled"],
"default": "pairing",
},
"group_policy": {
"type": "string",
"title": "群组策略",
"enum": ["open", "allowlist", "disabled"],
"default": "open",
},
"allow_from": {
"type": "array",
"title": "DM 白名单",
"items": {"type": "string"},
},
"group_allow_from": {
"type": "array",
"title": "群组白名单",
"items": {"type": "string"},
},
"channels": {
"type": "object",
"title": "频道配置",
"additionalProperties": {
"type": "object",
"properties": {
"require_mention": {
"type": "boolean",
"title": "需要 @提及",
"default": True,
},
"enabled": {
"type": "boolean",
"title": "启用",
"default": True,
},
"users": {
"type": "array",
"title": "授权用户",
"items": {"type": "string"},
},
},
},
},
"text_chunk_limit": {
"type": "integer",
"title": "文本分块上限",
"default": 40000,
"minimum": 1000,
"maximum": 40000,
},
"media_max_mb": {
"type": "integer",
"title": "媒体大小上限 (MB)",
"default": 50,
"minimum": 1,
"maximum": 100,
},
"streaming_mode": {
"type": "string",
"title": "流式输出模式",
"enum": ["native", "draft", "off"],
"default": "native",
},
"streaming_min_chars": {
"type": "integer",
"title": "流式最小字符数",
"default": 256,
"minimum": 64,
"maximum": 40000,
},
"reaction_level": {
"type": "string",
"title": "反应级别",
"enum": ["off", "ack", "minimal"],
"default": "minimal",
},
"reaction_notifications": {
"type": "string",
"title": "反应通知",
"enum": ["off", "own", "all"],
"default": "own",
},
"send_read_receipts": {
"type": "boolean",
"title": "发送已读回执",
"default": True,
},
},
},
},
"default_account": {
"type": "string",
"title": "默认账户",
"description": "默认使用的 Slack 账户 ID",
"default": "default",
},
},
}
def _token_fingerprint(self, token: str) -> str:
if not token:
return ""
return hashlib.sha256(token.encode()).hexdigest()[:8]
@staticmethod
def _env_bot_token() -> str:
return os.environ.get("SLACK_BOT_TOKEN", "")
@staticmethod
def _env_app_token() -> str:
return os.environ.get("SLACK_APP_TOKEN", "")
@staticmethod
def _env_user_token() -> str:
return os.environ.get("SLACK_USER_TOKEN", "")
@staticmethod
def _get_account_config(config: dict, account_id: str | None = None) -> dict:
accounts = config.get("channels", {}).get("slack", {}).get("accounts", {})
if account_id and account_id in accounts:
return accounts[account_id]
if "default" in accounts:
return accounts["default"]
return {}