新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - monitor: 渠道状态监控 - status: 会话状态管理 - actions: 交互动作处理 - interactive: 交互式消息 - commands: 斜杠指令 - threading: 线程管理 - mentions: @提及 - constants: 常量定义 - types: 类型定义
389 lines
18 KiB
Python
389 lines
18 KiB
Python
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 Secret(HTTP 模式用于验证请求签名)",
|
||
"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 {}
|