ForcePilot/backend/package/yuxi/channel/extensions/feishu/config.py
Kris 5e91bb9985 feat(feishu): 新增飞书渠道完整插件实现
新增飞书渠道插件,包含基础配置、事件处理、消息收发、工具调用、权限管理等完整功能模块,支持WebSocket长连接、卡片消息、流式响应、群管理、审批通知等能力
2026-05-21 10:46:42 +08:00

246 lines
14 KiB
Python

from __future__ import annotations
import logging
import os
from pathlib import Path
from yuxi.channel.extensions.feishu.types import FeishuAccount
logger = logging.getLogger(__name__)
class FeishuConfigAdapter:
def list_account_ids(self, config: dict) -> list[str]:
feishu_cfg = _get_feishu_config(config)
accounts = feishu_cfg.get("accounts", {})
if accounts:
return list(accounts.keys())
if feishu_cfg.get("appId") or feishu_cfg.get("app_id"):
return ["default"]
if os.environ.get("FEISHU_APP_ID"):
return ["default"]
return []
def default_account_id(self, config: dict) -> str:
feishu_cfg = _get_feishu_config(config)
return feishu_cfg.get("defaultAccount") or feishu_cfg.get("default_account", "default")
async def resolve_account(self, account_id: str, config: dict | None = None) -> FeishuAccount:
feishu_cfg = _get_feishu_config(config) if config else {}
accounts = feishu_cfg.get("accounts", {})
account = accounts.get(account_id, {}) if accounts else {}
app_id = account.get("appId") or account.get("app_id") or feishu_cfg.get("appId") or feishu_cfg.get("app_id") or os.environ.get("FEISHU_APP_ID", "")
app_secret = account.get("appSecret") or account.get("app_secret")
if not app_secret:
secret_file = account.get("appSecretFile") or account.get("app_secret_file") or feishu_cfg.get("appSecretFile")
if secret_file:
try:
app_secret = Path(secret_file).read_text(encoding="utf-8").strip()
except OSError:
logger.warning("Failed to read app secret file: %s", secret_file)
if not app_secret:
app_secret = feishu_cfg.get("appSecret") or feishu_cfg.get("app_secret", "")
if not app_secret:
app_secret = os.environ.get("FEISHU_APP_SECRET", "")
encrypt_key = account.get("encryptKey") or account.get("encrypt_key") or feishu_cfg.get("encryptKey") or feishu_cfg.get("encrypt_key") or os.environ.get("FEISHU_ENCRYPT_KEY")
verification_token = account.get("verificationToken") or account.get("verification_token") or feishu_cfg.get("verificationToken") or feishu_cfg.get("verification_token") or os.environ.get("FEISHU_VERIFICATION_TOKEN")
domain = account.get("domain") or feishu_cfg.get("domain", "feishu")
connection_mode = account.get("connectionMode") or account.get("connection_mode") or feishu_cfg.get("connectionMode") or feishu_cfg.get("connection_mode", "websocket")
dm_policy = account.get("dmPolicy") or account.get("dm_policy") or feishu_cfg.get("dmPolicy") or feishu_cfg.get("dm_policy", "pairing")
group_policy = account.get("groupPolicy") or account.get("group_policy") or feishu_cfg.get("groupPolicy") or feishu_cfg.get("group_policy", "allowlist")
if group_policy == "allowall":
group_policy = "open"
allow_from = account.get("allowFrom") or account.get("allow_from") or feishu_cfg.get("allowFrom") or feishu_cfg.get("allow_from", [])
group_allow_from = account.get("groupAllowFrom") or account.get("group_allow_from") or feishu_cfg.get("groupAllowFrom") or feishu_cfg.get("group_allow_from", [])
return FeishuAccount(
account_id=account_id,
app_id=app_id,
app_secret=app_secret,
name=account.get("name", account_id),
domain=domain,
connection_mode=connection_mode,
encrypt_key=encrypt_key,
verification_token=verification_token,
enabled=account.get("enabled", True),
dm_policy=dm_policy,
group_policy=group_policy,
allow_from=list(allow_from) if allow_from else [],
group_allow_from=list(group_allow_from) if group_allow_from else [],
require_mention=account.get("requireMention") if "requireMention" in account else feishu_cfg.get("requireMention", True),
group_session_scope=account.get("groupSessionScope") or feishu_cfg.get("groupSessionScope") or feishu_cfg.get("group_session_scope", "group"),
reply_in_thread=account.get("replyInThread") or feishu_cfg.get("replyInThread") or feishu_cfg.get("reply_in_thread", "disabled"),
render_mode=account.get("renderMode") or feishu_cfg.get("renderMode") or feishu_cfg.get("render_mode", "auto"),
streaming=account.get("streaming") if "streaming" in account else feishu_cfg.get("streaming", True),
typing_indicator=account.get("typingIndicator") if "typingIndicator" in account else feishu_cfg.get("typingIndicator", True),
reaction_notifications=account.get("reactionNotifications") or feishu_cfg.get("reactionNotifications") or feishu_cfg.get("reaction_notifications", "own"),
resolve_sender_names=account.get("resolveSenderNames") if "resolveSenderNames" in account else feishu_cfg.get("resolveSenderNames", True),
text_chunk_limit=account.get("textChunkLimit") or feishu_cfg.get("textChunkLimit") or feishu_cfg.get("text_chunk_limit", 4000),
media_max_mb=account.get("mediaMaxMb") or feishu_cfg.get("mediaMaxMb") or feishu_cfg.get("media_max_mb", 30),
http_timeout_ms=account.get("httpTimeoutMs") or feishu_cfg.get("httpTimeoutMs") or feishu_cfg.get("http_timeout_ms", 30000),
webhook_path=account.get("webhookPath") or feishu_cfg.get("webhookPath") or feishu_cfg.get("webhook_path", "/feishu/events"),
default_account=feishu_cfg.get("defaultAccount") or feishu_cfg.get("default_account", ""),
tools=account.get("tools") or feishu_cfg.get("tools", {}),
groups=account.get("groups") or feishu_cfg.get("groups", {}),
dms=account.get("dms") or feishu_cfg.get("dms", {}),
)
def is_configured(self, account: FeishuAccount | dict) -> bool:
if isinstance(account, FeishuAccount):
return account.is_configured()
return bool(account.get("app_id") or account.get("appId")) and bool(account.get("app_secret") or account.get("appSecret"))
def is_enabled(self, account: FeishuAccount | dict, config: dict | None = None) -> bool:
if isinstance(account, FeishuAccount):
return account.enabled
return account.get("enabled", True)
def disabled_reason(self, account: FeishuAccount | dict, config: dict | None = None) -> str:
if not self.is_configured(account):
return "App ID or App Secret not configured"
return ""
def describe_account(self, account: FeishuAccount | dict, config: dict | None = None) -> dict:
if isinstance(account, FeishuAccount):
return {
"account_id": account.account_id,
"name": account.name,
"configured": account.is_configured(),
"domain": account.domain,
"connection_mode": account.connection_mode,
}
return {
"account_id": account.get("account_id", ""),
"name": account.get("name", ""),
"configured": self.is_configured(account),
"domain": account.get("domain", "feishu"),
"connection_mode": account.get("connection_mode", "websocket"),
}
def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None:
import asyncio
async def _resolve():
account = await self.resolve_account(account_id or "default", config)
return account.allow_from
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(_resolve())
loop.close()
return result
return None
def inspect_account(self, config: dict, account_id: str | None = None) -> dict:
feishu_cfg = _get_feishu_config(config)
accounts = feishu_cfg.get("accounts", {})
account = accounts.get(account_id or "default", {}) if accounts else {}
return {
"account_id": account_id or "default",
"configured": bool(account.get("appId") or account.get("app_id") or feishu_cfg.get("appId")),
"app_id": account.get("appId") or account.get("app_id") or feishu_cfg.get("appId", ""),
"dm_policy": account.get("dmPolicy") or account.get("dm_policy", "pairing"),
}
def set_account_enabled(self, config: dict, account_id: str, enabled: bool) -> dict:
config.setdefault("channels", {}).setdefault("feishu", {}).setdefault("accounts", {}).setdefault(account_id, {})
config["channels"]["feishu"]["accounts"][account_id]["enabled"] = enabled
return config
def delete_account(self, config: dict, account_id: str) -> dict:
accounts = config.get("channels", {}).get("feishu", {}).get("accounts", {})
if account_id in accounts:
del accounts[account_id]
return config
def has_configured_state(self, config: dict) -> bool:
feishu_cfg = _get_feishu_config(config)
accounts = feishu_cfg.get("accounts", {})
if accounts:
return any(bool(a.get("appId") or a.get("app_id")) for a in accounts.values())
return bool(feishu_cfg.get("appId") or feishu_cfg.get("app_id") or os.environ.get("FEISHU_APP_ID"))
def has_persisted_auth_state(self, config: dict) -> bool:
return self.has_configured_state(config)
def config_schema(self) -> dict:
return {
"type": "object",
"title": "飞书渠道配置",
"properties": {
"enabled": {"type": "boolean", "default": True},
"domain": {"type": "string", "enum": ["feishu", "lark"], "default": "feishu"},
"connectionMode": {"type": "string", "enum": ["websocket", "webhook"], "default": "websocket"},
"dmPolicy": {"type": "string", "enum": ["pairing", "allowlist", "open", "disabled"], "default": "pairing"},
"groupPolicy": {"type": "string", "enum": ["open", "allowlist", "disabled"], "default": "allowlist"},
"allowFrom": {"type": "array", "items": {"type": "string"}},
"groupAllowFrom": {"type": "array", "items": {"type": "string"}},
"requireMention": {"type": "boolean", "default": True},
"groupSessionScope": {"type": "string", "enum": ["group", "group_sender", "group_topic", "group_topic_sender"], "default": "group"},
"replyInThread": {"type": "string", "enum": ["disabled", "enabled"], "default": "disabled"},
"renderMode": {"type": "string", "enum": ["auto", "raw", "card"], "default": "auto"},
"streaming": {"type": "boolean", "default": True},
"typingIndicator": {"type": "boolean", "default": True},
"reactionNotifications": {"type": "string", "enum": ["off", "own", "all"], "default": "own"},
"resolveSenderNames": {"type": "boolean", "default": True},
"textChunkLimit": {"type": "integer", "default": 4000},
"mediaMaxMb": {"type": "integer", "default": 30},
"httpTimeoutMs": {"type": "integer", "default": 30000},
"streamingThrottleMs": {"type": "integer", "default": 160, "description": "流式卡片更新节流间隔(毫秒)"},
"streamingSignificantDeltaChars": {"type": "integer", "default": 18, "description": "触发更新的最小新增字符数"},
"streamingPrintFrequencyMs": {"type": "integer", "default": 50, "description": "打字机打印频率(毫秒)"},
"accounts": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"appId": {"type": "string"},
"appSecret": {"type": "string"},
"name": {"type": "string"},
"domain": {"type": "string", "enum": ["feishu", "lark"]},
"enabled": {"type": "boolean"},
},
},
},
"tools": {
"type": "object",
"properties": {
"doc": {"type": "boolean", "default": True},
"chat": {"type": "boolean", "default": True},
"wiki": {"type": "boolean", "default": True},
"drive": {"type": "boolean", "default": True},
"perm": {"type": "boolean", "default": False},
"scopes": {"type": "boolean", "default": True},
"bitable": {"type": "boolean", "default": True},
},
},
},
}
def get_channel_config(self, config: dict) -> dict:
return _get_feishu_config(config)
def get_group_config(self, config: dict, chat_id: str) -> dict | None:
groups = _get_feishu_config(config).get("groups", {})
return groups.get(chat_id)
def collect_warnings(self, config: dict, account_id: str | None = None, account: FeishuAccount | None = None) -> list[str]:
warnings = []
group_policy = account.group_policy if account else _get_feishu_config(config).get("groupPolicy", "allowlist")
if group_policy == "open" and not _get_feishu_config(config).get("groups"):
warnings.append("groupPolicy is 'open' without groups allowlist")
return warnings
def _get_feishu_config(config: dict | None) -> dict:
if not config:
return {}
return config.get("channels", {}).get("feishu", {})