该提交实现了完整的钉钉聊天渠道插件,包含: 1. 基础配置、账号管理与凭证校验 2. WebSocket长连接网关与消息去重 3. 消息接收/解析/分发与安全校验 4. 媒体文件上传下载与缓存 5. 互动卡片流式更新与回调处理 6. 群管理、命令支持与诊断工具 7. 完整的插件元数据与依赖声明
216 lines
7.9 KiB
Python
216 lines
7.9 KiB
Python
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 []
|