feat(channel/doctor): 新增渠道诊断修复工具模块
新增了完整的渠道诊断修复工具,包含数据模型定义与核心执行逻辑: 1. 定义了检查状态、严重性、修复模式等枚举类型 2. 实现了诊断选项、各类检查结果、修复步骤与结果等数据类 3. 提供了ChannelDoctorRunner核心类,支持单渠道/多渠道诊断、单步骤/全量修复 4. 实现了配置规范化、过期配置清理、权限/凭证/连通性检查等完整流程 5. 支持诊断结果序列化为字典格式,以及上下文管理器式的运行器管理
This commit is contained in:
parent
0dcc370819
commit
058513022a
535
backend/package/yuxi/channel/doctor/__init__.py
Normal file
535
backend/package/yuxi/channel/doctor/__init__.py
Normal file
@ -0,0 +1,535 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channel.doctor.models import (
|
||||
CheckStatus,
|
||||
ConfigMutation,
|
||||
ConnectivityCheckResult,
|
||||
CredentialCheckResult,
|
||||
DiagnosisResult,
|
||||
DiagnosisWarning,
|
||||
DoctorOptions,
|
||||
PermissionCheckResult,
|
||||
RepairResult,
|
||||
Severity,
|
||||
)
|
||||
from yuxi.channel.config.defaults import TIMEOUT
|
||||
from yuxi.channel.protocols import DoctorConfigMutation, DoctorProtocol
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
|
||||
from yuxi.channel.security.log_sanitizer import sanitize_config_object
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_TIMEOUT = TIMEOUT.doctor.check
|
||||
|
||||
_NOT_FOUND = object()
|
||||
|
||||
|
||||
def _safe_serialize(val: Any) -> str:
|
||||
if val is None or val is _NOT_FOUND:
|
||||
return ""
|
||||
if isinstance(val, (dict, list, tuple)):
|
||||
return json.dumps(val, ensure_ascii=False, default=str)
|
||||
if isinstance(val, bool):
|
||||
return "true" if val else "false"
|
||||
return str(val)
|
||||
|
||||
|
||||
def _values_differ(old: Any, new: Any) -> bool:
|
||||
if old is _NOT_FOUND and new is _NOT_FOUND:
|
||||
return False
|
||||
if old is _NOT_FOUND or new is _NOT_FOUND:
|
||||
return True
|
||||
|
||||
old_normalized = "" if old is None else old
|
||||
new_normalized = "" if new is None else new
|
||||
|
||||
if type(old_normalized) is not type(new_normalized):
|
||||
return True
|
||||
|
||||
if isinstance(old_normalized, (dict, list)):
|
||||
return json.dumps(old_normalized, sort_keys=True, ensure_ascii=False, default=str) != json.dumps(
|
||||
new_normalized, sort_keys=True, ensure_ascii=False, default=str
|
||||
)
|
||||
|
||||
return old_normalized != new_normalized
|
||||
|
||||
|
||||
class ChannelDoctorRunner:
|
||||
async def diagnose(
|
||||
self,
|
||||
channel_type: str,
|
||||
account_id: str,
|
||||
config: dict,
|
||||
options: DoctorOptions | None = None,
|
||||
) -> DiagnosisResult:
|
||||
plugin = self._get_doctor_plugin(channel_type)
|
||||
opts = options or DoctorOptions()
|
||||
|
||||
config_before = copy.deepcopy(config)
|
||||
config = copy.deepcopy(config)
|
||||
|
||||
result = DiagnosisResult(
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
overall_status=CheckStatus.PASS,
|
||||
timestamp=time.time(),
|
||||
config_before=sanitize_config_object(config_before),
|
||||
config_after=sanitize_config_object(config),
|
||||
)
|
||||
|
||||
self._run_config_normalization(result, plugin, config)
|
||||
self._run_stale_config_cleanup(result, plugin, config, opts)
|
||||
|
||||
await self._run_checks(result, plugin, config, opts)
|
||||
|
||||
result.config_after = sanitize_config_object(config)
|
||||
|
||||
self._aggregate_and_plan(result, plugin, opts)
|
||||
|
||||
return result
|
||||
|
||||
async def repair(
|
||||
self,
|
||||
channel_type: str,
|
||||
step_id: str,
|
||||
config: dict,
|
||||
options: DoctorOptions | None = None,
|
||||
diagnosis: DiagnosisResult | None = None,
|
||||
) -> RepairResult:
|
||||
plugin = self._get_doctor_plugin(channel_type)
|
||||
opts = options or DoctorOptions()
|
||||
|
||||
if diagnosis is None:
|
||||
diagnosis = await self.diagnose(channel_type, "default", config, opts)
|
||||
|
||||
for step in diagnosis.repair_plan:
|
||||
if step.id == step_id:
|
||||
return await plugin.execute_repair(step, config)
|
||||
|
||||
raise ValueError(f"Repair step {step_id} not found in diagnosis plan")
|
||||
|
||||
async def repair_all(
|
||||
self,
|
||||
channel_type: str,
|
||||
config: dict,
|
||||
options: DoctorOptions | None = None,
|
||||
diagnosis: DiagnosisResult | None = None,
|
||||
) -> DiagnosisResult:
|
||||
plugin = self._get_doctor_plugin(channel_type)
|
||||
opts = options or DoctorOptions()
|
||||
|
||||
if not opts.should_repair:
|
||||
raise ValueError(
|
||||
f"repair_all requires repair_mode in (REPAIR, FORCE, YES), "
|
||||
f"got {opts.repair_mode.value}"
|
||||
)
|
||||
|
||||
if diagnosis is None:
|
||||
diagnosis = await self.diagnose(channel_type, "default", config, opts)
|
||||
|
||||
diagnosis = await self._execute_repairs(diagnosis, plugin, config, opts)
|
||||
return diagnosis
|
||||
|
||||
DEFAULT_DIAGNOSE_ALL_MAX_CONCURRENCY = 10
|
||||
|
||||
async def diagnose_all_channels(
|
||||
self,
|
||||
configs: dict[str, dict],
|
||||
options: DoctorOptions | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
) -> list[DiagnosisResult]:
|
||||
opts = options or DoctorOptions()
|
||||
limit = max_concurrency if max_concurrency is not None else self.DEFAULT_DIAGNOSE_ALL_MAX_CONCURRENCY
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def _diagnose_one(channel_type: str, cfg: dict) -> DiagnosisResult | None:
|
||||
async with semaphore:
|
||||
try:
|
||||
return await self.diagnose(channel_type, "default", cfg, opts)
|
||||
except NotImplementedError:
|
||||
logger.warning(
|
||||
"Channel %s skipped: DoctorProtocol not implemented", channel_type
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception("Diagnosis failed for channel %s", channel_type)
|
||||
return DiagnosisResult(
|
||||
channel_type=channel_type,
|
||||
account_id="default",
|
||||
overall_status=CheckStatus.FAIL,
|
||||
timestamp=time.time(),
|
||||
warnings=[
|
||||
DiagnosisWarning(
|
||||
severity=Severity.ERROR,
|
||||
code="diagnose_error",
|
||||
message=f"Diagnosis failed: {e}",
|
||||
suggestion="Check plugin implementation and config",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
tasks = [_diagnose_one(ct, cfg) for ct, cfg in configs.items()]
|
||||
gathered = await asyncio.gather(*tasks)
|
||||
return [r for r in gathered if r is not None]
|
||||
|
||||
async def write_config(
|
||||
self,
|
||||
config_path: str | Path,
|
||||
config: dict,
|
||||
backup: bool = True,
|
||||
) -> None:
|
||||
config_path = Path(config_path)
|
||||
|
||||
if backup and config_path.exists():
|
||||
backup_path = config_path.with_suffix(config_path.suffix + ".bak")
|
||||
config_path.rename(backup_path)
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
# ── internal helpers ──────────────────────────────────
|
||||
|
||||
def _get_doctor_plugin(self, channel_type: str) -> DoctorProtocol:
|
||||
plugin = ChannelPluginRegistry.get(channel_type)
|
||||
if plugin is None:
|
||||
raise NotImplementedError(f"Channel {channel_type} is not registered")
|
||||
if not isinstance(plugin, DoctorProtocol):
|
||||
raise NotImplementedError(
|
||||
f"Channel {channel_type} does not implement DoctorProtocol"
|
||||
)
|
||||
return plugin
|
||||
|
||||
def _run_config_normalization(
|
||||
self,
|
||||
result: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
config: dict,
|
||||
) -> None:
|
||||
try:
|
||||
normalized = plugin.normalize_compatibility_config(config)
|
||||
if normalized != config:
|
||||
for rule in plugin.legacy_config_rules():
|
||||
old_val = self._resolve_nested(config, rule.old_key)
|
||||
new_val = self._resolve_nested(normalized, rule.new_key)
|
||||
if _values_differ(old_val, new_val):
|
||||
result.config_changes.append(
|
||||
ConfigMutation(
|
||||
key=rule.new_key,
|
||||
description=f"Migrated from {rule.old_key} -> {rule.new_key}",
|
||||
old_value=_safe_serialize(old_val),
|
||||
new_value=_safe_serialize(new_val),
|
||||
version=len(result.config_changes) + 1,
|
||||
reason="config_normalization",
|
||||
)
|
||||
)
|
||||
config.clear()
|
||||
config.update(normalized)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Config normalization failed for %s", result.channel_type
|
||||
)
|
||||
result.warnings.append(
|
||||
DiagnosisWarning(
|
||||
severity=Severity.WARNING,
|
||||
code="normalization_failed",
|
||||
message=f"配置规范化失败: {e}",
|
||||
suggestion="检查插件 normalize_compatibility_config 实现",
|
||||
)
|
||||
)
|
||||
|
||||
if hasattr(plugin, "run_config_sequence"):
|
||||
try:
|
||||
repaired = plugin.run_config_sequence(config, False)
|
||||
if repaired != config:
|
||||
config.clear()
|
||||
config.update(repaired)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Config sequence failed for %s", result.channel_type
|
||||
)
|
||||
|
||||
def _run_stale_config_cleanup(
|
||||
self,
|
||||
result: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
config: dict,
|
||||
options: DoctorOptions,
|
||||
) -> None:
|
||||
if not options.should_repair:
|
||||
return
|
||||
|
||||
if hasattr(plugin, "clean_stale_config"):
|
||||
try:
|
||||
mutation: DoctorConfigMutation = plugin.clean_stale_config(config)
|
||||
if mutation.config != config:
|
||||
config.clear()
|
||||
config.update(mutation.config)
|
||||
for change in mutation.changes:
|
||||
result.config_changes.append(
|
||||
ConfigMutation(
|
||||
key="",
|
||||
description=change,
|
||||
version=len(result.config_changes) + 1,
|
||||
reason="stale_config_cleanup",
|
||||
)
|
||||
)
|
||||
if mutation.warnings:
|
||||
result.security_warnings.extend(mutation.warnings)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Stale config cleanup failed for %s", result.channel_type
|
||||
)
|
||||
|
||||
async def _run_checks(
|
||||
self,
|
||||
result: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
config: dict,
|
||||
options: DoctorOptions,
|
||||
) -> None:
|
||||
async def _check_credential() -> CredentialCheckResult:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
plugin.check_credentials(config), timeout=CHECK_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Credential check timed out for %s", result.channel_type)
|
||||
return CredentialCheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
token_obtained=False,
|
||||
error=f"凭证检查超时 ({CHECK_TIMEOUT}s)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Credential check failed for %s", result.channel_type)
|
||||
return CredentialCheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
token_obtained=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def _check_permission() -> PermissionCheckResult:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
plugin.check_permissions(config), timeout=CHECK_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Permission check timed out for %s", result.channel_type)
|
||||
return PermissionCheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
error=f"权限检查超时 ({CHECK_TIMEOUT}s)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Permission check failed for %s", result.channel_type)
|
||||
return PermissionCheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def _check_connectivity() -> ConnectivityCheckResult:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
plugin.check_connectivity(config), timeout=CHECK_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Connectivity check timed out for %s", result.channel_type)
|
||||
return ConnectivityCheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
error=f"连通性检查超时 ({CHECK_TIMEOUT}s)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Connectivity check failed for %s", result.channel_type
|
||||
)
|
||||
return ConnectivityCheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
result.credential, result.permission, result.connectivity = await asyncio.gather(
|
||||
_check_credential(),
|
||||
_check_permission(),
|
||||
_check_connectivity(),
|
||||
)
|
||||
|
||||
self._collect_allowlist_warnings(result, plugin, config)
|
||||
self._collect_security_warnings(result, plugin, config)
|
||||
|
||||
def _collect_allowlist_warnings(
|
||||
self,
|
||||
result: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
config: dict,
|
||||
) -> None:
|
||||
try:
|
||||
allowlist_cfg = config.get("allowlist", {})
|
||||
result.warnings.extend(plugin.collect_allowlist_warnings(allowlist_cfg))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Allowlist warning collection failed for %s",
|
||||
result.channel_type,
|
||||
)
|
||||
|
||||
def _collect_security_warnings(
|
||||
self,
|
||||
result: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
config: dict,
|
||||
) -> None:
|
||||
if hasattr(plugin, "collect_preview_warnings"):
|
||||
try:
|
||||
warnings = plugin.collect_preview_warnings(config, "doctor --fix")
|
||||
result.security_warnings.extend(warnings)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Preview warning collection failed for %s",
|
||||
result.channel_type,
|
||||
)
|
||||
|
||||
if hasattr(plugin, "collect_mutable_allowlist_warnings"):
|
||||
try:
|
||||
warnings = plugin.collect_mutable_allowlist_warnings(config)
|
||||
result.security_warnings.extend(warnings)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Mutable allowlist warning collection failed for %s",
|
||||
result.channel_type,
|
||||
)
|
||||
|
||||
if hasattr(plugin, "collect_empty_allowlist_extra_warnings"):
|
||||
try:
|
||||
ctx = {"config": config, "plugin": plugin}
|
||||
if not plugin.should_skip_default_empty_group_allowlist_warning(ctx):
|
||||
warnings = plugin.collect_empty_allowlist_extra_warnings(ctx)
|
||||
result.security_warnings.extend(warnings)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Empty allowlist extra warning collection failed for %s",
|
||||
result.channel_type,
|
||||
)
|
||||
|
||||
def _aggregate_and_plan(
|
||||
self,
|
||||
result: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
options: DoctorOptions,
|
||||
) -> None:
|
||||
if self._has_failures(result):
|
||||
result.repair_plan = plugin.generate_repair_plan(result)
|
||||
|
||||
result.overall_status = self._aggregate_status(result)
|
||||
|
||||
def _has_failures(self, result: DiagnosisResult) -> bool:
|
||||
checks = [result.credential, result.permission, result.connectivity]
|
||||
return any(c and c.status != CheckStatus.PASS for c in checks)
|
||||
|
||||
def _aggregate_status(self, result: DiagnosisResult) -> CheckStatus:
|
||||
checks = [result.credential, result.permission, result.connectivity]
|
||||
statuses = [c.status for c in checks if c]
|
||||
if any(s == CheckStatus.FAIL for s in statuses):
|
||||
return CheckStatus.FAIL
|
||||
if any(s == CheckStatus.WARN for s in statuses):
|
||||
return CheckStatus.WARN
|
||||
if result.security_warnings:
|
||||
return CheckStatus.WARN
|
||||
return CheckStatus.PASS
|
||||
|
||||
async def _execute_repairs(
|
||||
self,
|
||||
diagnosis: DiagnosisResult,
|
||||
plugin: DoctorProtocol,
|
||||
config: dict,
|
||||
options: DoctorOptions,
|
||||
) -> DiagnosisResult:
|
||||
for step in diagnosis.repair_plan:
|
||||
if step.requires_interactive and options.is_non_interactive:
|
||||
diagnosis.repair_results.append(
|
||||
RepairResult(
|
||||
step_id=step.id,
|
||||
success=False,
|
||||
message="Skipped: requires interactive confirmation",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Repair step %s skipped: requires interactive confirmation",
|
||||
step.id,
|
||||
)
|
||||
continue
|
||||
|
||||
if step.is_aggressive and not options.should_force:
|
||||
diagnosis.repair_results.append(
|
||||
RepairResult(
|
||||
step_id=step.id,
|
||||
success=False,
|
||||
message="Skipped: requires --force for aggressive repair",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Repair step %s skipped: requires --force", step.id
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await plugin.execute_repair(step, config)
|
||||
diagnosis.repair_results.append(result)
|
||||
logger.info("Repair step %s: success=%s", step.id, result.success)
|
||||
except Exception as e:
|
||||
logger.exception("Repair step %s failed", step.id)
|
||||
diagnosis.repair_results.append(
|
||||
RepairResult(
|
||||
step_id=step.id,
|
||||
success=False,
|
||||
message=str(e),
|
||||
)
|
||||
)
|
||||
|
||||
return diagnosis
|
||||
|
||||
@staticmethod
|
||||
def _resolve_nested(d: dict, dotted_key: str) -> object:
|
||||
keys = dotted_key.split(".")
|
||||
current: object = d
|
||||
for k in keys:
|
||||
if isinstance(current, dict):
|
||||
current = current.get(k, _NOT_FOUND)
|
||||
else:
|
||||
return _NOT_FOUND
|
||||
return current
|
||||
|
||||
|
||||
_doctor_runner_ctx: contextvars.ContextVar[ChannelDoctorRunner | None] = contextvars.ContextVar(
|
||||
"doctor_runner", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_doctor_runner() -> ChannelDoctorRunner:
|
||||
runner = _doctor_runner_ctx.get()
|
||||
if runner is None:
|
||||
runner = ChannelDoctorRunner()
|
||||
_doctor_runner_ctx.set(runner)
|
||||
return runner
|
||||
|
||||
|
||||
def set_doctor_runner(runner: ChannelDoctorRunner) -> None:
|
||||
_doctor_runner_ctx.set(runner)
|
||||
|
||||
|
||||
def reset_doctor_runner() -> None:
|
||||
_doctor_runner_ctx.set(None)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "doctor_runner":
|
||||
return get_doctor_runner()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
264
backend/package/yuxi/channel/doctor/models.py
Normal file
264
backend/package/yuxi/channel/doctor/models.py
Normal file
@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CheckStatus(StrEnum):
|
||||
PASS = "pass"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
SKIP = "skip"
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class DoctorRepairMode(StrEnum):
|
||||
NONE = "none"
|
||||
YES = "yes"
|
||||
REPAIR = "repair"
|
||||
FORCE = "force"
|
||||
NON_INTERACTIVE = "non_interactive"
|
||||
DEEP = "deep"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoctorOptions:
|
||||
repair_mode: DoctorRepairMode = DoctorRepairMode.NONE
|
||||
deep: bool = False
|
||||
generate_gateway_token: bool = False
|
||||
workspace_suggestions: bool = True
|
||||
|
||||
@property
|
||||
def should_repair(self) -> bool:
|
||||
return self.repair_mode in (DoctorRepairMode.REPAIR, DoctorRepairMode.FORCE, DoctorRepairMode.YES)
|
||||
|
||||
@property
|
||||
def should_force(self) -> bool:
|
||||
return self.repair_mode == DoctorRepairMode.FORCE
|
||||
|
||||
@property
|
||||
def is_non_interactive(self) -> bool:
|
||||
return self.repair_mode in (
|
||||
DoctorRepairMode.NON_INTERACTIVE,
|
||||
DoctorRepairMode.REPAIR,
|
||||
DoctorRepairMode.YES,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialCheckResult:
|
||||
status: CheckStatus
|
||||
token_obtained: bool
|
||||
expires_in: int | None = None
|
||||
error: str | None = None
|
||||
detail: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionCheckResult:
|
||||
status: CheckStatus
|
||||
can_send_message: bool = False
|
||||
can_read_message: bool = False
|
||||
can_manage_group: bool = False
|
||||
missing_permissions: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectivityCheckResult:
|
||||
status: CheckStatus
|
||||
latency_ms: float | None = None
|
||||
endpoint: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagnosisWarning:
|
||||
severity: Severity
|
||||
code: str
|
||||
message: str
|
||||
suggestion: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyConfigRule:
|
||||
old_key: str
|
||||
new_key: str
|
||||
transform: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigMutation:
|
||||
description: str = ""
|
||||
old_value: str = ""
|
||||
new_value: str = ""
|
||||
key: str = ""
|
||||
version: int = 0
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
operator: str = "system"
|
||||
reason: str = ""
|
||||
checksum: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.checksum:
|
||||
self.checksum = self._compute_checksum()
|
||||
|
||||
def _compute_checksum(self) -> str:
|
||||
payload = f"{self.key}|{self.description}|{self.old_value}|{self.new_value}"
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelStatusWarning:
|
||||
channel_type: str
|
||||
account_id: str
|
||||
status: CheckStatus
|
||||
message: str
|
||||
detail: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairStep:
|
||||
id: str
|
||||
description: str
|
||||
action: str
|
||||
reversible: bool = True
|
||||
params: dict = field(default_factory=dict)
|
||||
requires_interactive: bool = False
|
||||
is_aggressive: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairResult:
|
||||
step_id: str
|
||||
success: bool
|
||||
message: str
|
||||
config_before: dict | None = None
|
||||
config_after: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagnosisResult:
|
||||
channel_type: str
|
||||
account_id: str
|
||||
overall_status: CheckStatus
|
||||
timestamp: float
|
||||
|
||||
credential: CredentialCheckResult | None = None
|
||||
permission: PermissionCheckResult | None = None
|
||||
connectivity: ConnectivityCheckResult | None = None
|
||||
|
||||
config_before: dict | None = None
|
||||
config_after: dict | None = None
|
||||
config_changes: list[ConfigMutation] = field(default_factory=list)
|
||||
|
||||
warnings: list[DiagnosisWarning] = field(default_factory=list)
|
||||
security_warnings: list[str] = field(default_factory=list)
|
||||
channel_status_warnings: list[ChannelStatusWarning] = field(default_factory=list)
|
||||
|
||||
repair_plan: list[RepairStep] = field(default_factory=list)
|
||||
repair_results: list[RepairResult] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
result: dict = {
|
||||
"channel_type": self.channel_type,
|
||||
"account_id": self.account_id,
|
||||
"overall_status": self.overall_status.value,
|
||||
"timestamp": self.timestamp,
|
||||
"credential": self._credential_dict(),
|
||||
"permission": self._permission_dict(),
|
||||
"connectivity": self._connectivity_dict(),
|
||||
"config_before": self.config_before,
|
||||
"config_after": self.config_after,
|
||||
"config_changes": [
|
||||
{
|
||||
"key": c.key,
|
||||
"description": c.description,
|
||||
"old_value": c.old_value,
|
||||
"new_value": c.new_value,
|
||||
"version": c.version,
|
||||
"timestamp": c.timestamp,
|
||||
"operator": c.operator,
|
||||
"reason": c.reason,
|
||||
"checksum": c.checksum,
|
||||
}
|
||||
for c in self.config_changes
|
||||
],
|
||||
"warnings": [
|
||||
{"severity": w.severity.value, "code": w.code, "message": w.message, "suggestion": w.suggestion}
|
||||
for w in self.warnings
|
||||
],
|
||||
"security_warnings": self.security_warnings,
|
||||
"channel_status_warnings": [
|
||||
{
|
||||
"channel_type": c.channel_type,
|
||||
"account_id": c.account_id,
|
||||
"status": c.status.value,
|
||||
"message": c.message,
|
||||
"detail": c.detail,
|
||||
}
|
||||
for c in self.channel_status_warnings
|
||||
],
|
||||
"repair_plan": [
|
||||
{
|
||||
"id": s.id,
|
||||
"description": s.description,
|
||||
"action": s.action,
|
||||
"reversible": s.reversible,
|
||||
"params": s.params,
|
||||
"requires_interactive": s.requires_interactive,
|
||||
"is_aggressive": s.is_aggressive,
|
||||
}
|
||||
for s in self.repair_plan
|
||||
],
|
||||
"repair_results": [
|
||||
{
|
||||
"step_id": r.step_id,
|
||||
"success": r.success,
|
||||
"message": r.message,
|
||||
"config_before": r.config_before,
|
||||
"config_after": r.config_after,
|
||||
}
|
||||
for r in self.repair_results
|
||||
],
|
||||
}
|
||||
return result
|
||||
|
||||
def _credential_dict(self) -> dict | None:
|
||||
if not self.credential:
|
||||
return None
|
||||
return {
|
||||
"status": self.credential.status.value,
|
||||
"token_obtained": self.credential.token_obtained,
|
||||
"expires_in": self.credential.expires_in,
|
||||
"error": self.credential.error,
|
||||
}
|
||||
|
||||
def _permission_dict(self) -> dict | None:
|
||||
if not self.permission:
|
||||
return None
|
||||
return {
|
||||
"status": self.permission.status.value,
|
||||
"can_send_message": self.permission.can_send_message,
|
||||
"can_read_message": self.permission.can_read_message,
|
||||
"can_manage_group": self.permission.can_manage_group,
|
||||
"missing_permissions": self.permission.missing_permissions,
|
||||
"error": self.permission.error,
|
||||
}
|
||||
|
||||
def _connectivity_dict(self) -> dict | None:
|
||||
if not self.connectivity:
|
||||
return None
|
||||
return {
|
||||
"status": self.connectivity.status.value,
|
||||
"latency_ms": self.connectivity.latency_ms,
|
||||
"endpoint": self.connectivity.endpoint,
|
||||
"error": self.connectivity.error,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user