ForcePilot/backend/package/yuxi/channel/extensions/dingtalk/doctor.py
Kris 472afc4d1e feat(dingtalk): 新增钉钉渠道插件完整实现
该提交实现了完整的钉钉聊天渠道插件,包含:
1. 基础配置、账号管理与凭证校验
2. WebSocket长连接网关与消息去重
3. 消息接收/解析/分发与安全校验
4. 媒体文件上传下载与缓存
5. 互动卡片流式更新与回调处理
6. 群管理、命令支持与诊断工具
7. 完整的插件元数据与依赖声明
2026-05-21 10:44:03 +08:00

216 lines
7.9 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.

from __future__ import annotations
import logging
import time
import httpx
from yuxi.channel.doctor.models import (
CheckStatus,
ConnectivityCheckResult,
CredentialCheckResult,
DiagnosisResult,
DiagnosisWarning,
LegacyConfigRule,
PermissionCheckResult,
RepairResult,
RepairStep,
Severity,
)
logger = logging.getLogger(__name__)
DINGTALK_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
DINGTALK_CONNECTIVITY_URL = "https://api.dingtalk.com"
def _extract_credential(config: dict) -> tuple[str, str]:
channels = config.get("channels", {})
dingtalk = channels.get("dingtalk", {})
app_key = (
dingtalk.get("appKey")
or dingtalk.get("app_key")
or ""
)
app_secret = (
dingtalk.get("appSecret")
or dingtalk.get("app_secret")
or ""
)
return app_key, app_secret
class DingTalkDoctor:
dm_allow_from_mode = "topOnly"
group_model = "route"
group_allow_from_fallback_to_allow_from = False
warn_on_empty_group_sender_allowlist = True
async def check_credentials(self, config: dict) -> CredentialCheckResult:
app_key, app_secret = _extract_credential(config)
if not app_key or not app_secret:
return CredentialCheckResult(
status=CheckStatus.FAIL,
token_obtained=False,
error="AppKey 或 AppSecret 未配置",
)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
DINGTALK_TOKEN_URL,
json={"appKey": app_key, "appSecret": app_secret},
)
data = resp.json()
if "accessToken" in data:
expires_in = data.get("expireIn", 7200)
return CredentialCheckResult(
status=CheckStatus.PASS,
token_obtained=True,
expires_in=expires_in,
)
error_msg = data.get("message", str(data))
return CredentialCheckResult(
status=CheckStatus.FAIL,
token_obtained=False,
error=error_msg,
)
except httpx.HTTPStatusError as e:
return CredentialCheckResult(
status=CheckStatus.FAIL,
token_obtained=False,
error=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
)
except Exception as e:
logger.warning("DingTalk credential check error: %s", e)
return CredentialCheckResult(
status=CheckStatus.FAIL,
token_obtained=False,
error=str(e),
)
async def check_permissions(self, config: dict) -> PermissionCheckResult:
app_key, app_secret = _extract_credential(config)
if not app_key or not app_secret:
return PermissionCheckResult(
status=CheckStatus.FAIL,
error="AppKey 或 AppSecret 未配置,无法检查权限",
)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
token_resp = await client.post(
DINGTALK_TOKEN_URL,
json={"appKey": app_key, "appSecret": app_secret},
)
token_data = token_resp.json()
access_token = token_data.get("accessToken")
if not access_token:
return PermissionCheckResult(
status=CheckStatus.FAIL,
error="无法获取 access_token权限检查中止",
)
resp = await client.get(
"https://api.dingtalk.com/v1.0/robot/organizations/briefManage",
headers={"x-acs-dingtalk-access-token": access_token},
)
if resp.status_code == 200:
return PermissionCheckResult(
status=CheckStatus.PASS,
can_send_message=True,
can_read_message=True,
)
return PermissionCheckResult(
status=CheckStatus.WARN,
can_send_message=True,
can_read_message=True,
error=f"权限查询异常: HTTP {resp.status_code}",
)
except Exception as e:
logger.warning("DingTalk permission check error: %s", e)
return PermissionCheckResult(
status=CheckStatus.FAIL,
error=str(e),
)
async def check_connectivity(self, config: dict) -> ConnectivityCheckResult:
try:
start = time.monotonic()
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(DINGTALK_CONNECTIVITY_URL)
latency_ms = (time.monotonic() - start) * 1000
if resp.status_code < 500:
return ConnectivityCheckResult(
status=CheckStatus.PASS,
latency_ms=round(latency_ms, 2),
endpoint=DINGTALK_CONNECTIVITY_URL,
)
return ConnectivityCheckResult(
status=CheckStatus.FAIL,
latency_ms=round(latency_ms, 2),
endpoint=DINGTALK_CONNECTIVITY_URL,
error=f"HTTP {resp.status_code}",
)
except Exception as e:
logger.warning("DingTalk connectivity check error: %s", e)
return ConnectivityCheckResult(
status=CheckStatus.FAIL,
endpoint=DINGTALK_CONNECTIVITY_URL,
error=str(e),
)
def normalize_compatibility_config(self, config: dict) -> dict:
return config
def collect_allowlist_warnings(self, allowlist_config: dict) -> list[DiagnosisWarning]:
warnings: list[DiagnosisWarning] = []
dm_allowlist = allowlist_config.get("dm_allowlist", [])
if not dm_allowlist:
warnings.append(
DiagnosisWarning(
severity=Severity.WARNING,
code="dm_allowlist_empty",
message="DM 白名单为空,任何用户都可以直接向机器人发消息",
suggestion="建议配置 dm_allowlist 限制可访问用户",
)
)
return warnings
def generate_repair_plan(self, diagnosis: DiagnosisResult) -> list[RepairStep]:
plan: list[RepairStep] = []
if diagnosis.credential and diagnosis.credential.status == CheckStatus.FAIL:
plan.append(
RepairStep(
id="fix_dingtalk_credential",
description="检查钉钉 AppKey/AppSecret 配置",
action="update_config",
params={"field": "appKey/appSecret"},
)
)
if diagnosis.connectivity and diagnosis.connectivity.status == CheckStatus.FAIL:
plan.append(
RepairStep(
id="fix_dingtalk_connectivity",
description="检查网络连通性和钉钉 API 可达性",
action="check_network",
)
)
return plan
async def execute_repair(self, step: RepairStep, config: dict) -> RepairResult:
if step.id == "fix_dingtalk_credential":
return RepairResult(
step_id=step.id,
success=False,
message="凭证修复需要手动更新 AppKey/AppSecret 配置",
)
return RepairResult(
step_id=step.id,
success=False,
message=f"未知修复步骤: {step.id}",
)
def legacy_config_rules(self) -> list[LegacyConfigRule]:
return []