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 []