ForcePilot/backend/package/yuxi/channels/adapters/line/doctor.py
Kris a4ec94ef9d feat(line): 实现完整的 LINE 聊天适配器功能
新增 LINE 官方账号对接的全套功能,包括:
1. 基础的 Bot 探测、会话解析、消息格式化能力
2. 富媒体消息模板、快速回复、卡片指令支持
3. Webhook 签名验证、重放防护、多账户路由管理
4. 消息发送、回复、分块传输、用户绑定管理
5. 交互式配置向导与诊断工具
2026-05-12 00:45:33 +08:00

331 lines
12 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from yuxi.channels.adapters.line.probe import probe_line_bot
class LINEDoctor:
def __init__(self, adapter=None):
self._adapter = adapter
async def run_diagnostics(self) -> dict:
findings: list[dict] = []
health = "healthy"
token, secret = await self._resolve_credentials()
if not token:
findings.append(
{
"severity": "error",
"check": "channel_access_token",
"message": "Channel Access Token not configured",
}
)
health = "unhealthy"
return {"status": health, "findings": findings}
token_check = await self._check_token(token)
findings.append(token_check)
if token_check["severity"] == "error":
health = "unhealthy"
if not secret:
findings.append(
{
"severity": "warning",
"check": "channel_secret",
"message": "Channel Secret not configured — webhook signature verification will fail",
}
)
if health != "unhealthy":
health = "degraded"
webhook_check = await self._check_webhook_config(token)
findings.append(webhook_check)
if webhook_check["severity"] == "error":
health = "unhealthy"
bot_profile_check = await self._check_bot_profile(token)
findings.append(bot_profile_check)
quota_check = await self._check_message_quota(token)
findings.append(quota_check)
delivery_check = await self._check_delivery_status(token)
findings.append(delivery_check)
config_check = await self._check_config_completeness()
findings.append(config_check)
return {"status": health, "findings": findings}
async def _resolve_credentials(self) -> tuple[str | None, str | None]:
if self._adapter:
try:
token, secret = await self._adapter._resolve_token_and_secret()
return token, secret
except Exception:
pass
return None, None
async def _check_token(self, token: str) -> dict:
result = await probe_line_bot(token)
if result.get("status") == "ok":
return {
"severity": "info",
"check": "token_validity",
"message": f"Token valid — Bot: {result.get('display_name', '')}",
"details": result,
}
return {
"severity": "error",
"check": "token_validity",
"message": f"Token invalid: {result.get('message', 'Unknown error')}",
}
async def _check_webhook_config(self, token: str) -> dict:
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://api.line.me/v2/bot/channel/webhook/endpoint",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 200:
data = resp.json()
endpoint = data.get("endpoint", "")
active = data.get("active", False)
use_status = data.get("use", True)
sub_issues = []
if not endpoint:
sub_issues.append("No webhook endpoint URL configured")
if not active:
sub_issues.append("Webhook use is not enabled in LINE console")
if not use_status:
sub_issues.append("Webhook is disabled")
severity = "info"
if not endpoint or not use_status:
severity = "error"
elif not active:
severity = "warning"
return {
"severity": severity,
"check": "webhook_endpoint",
"message": f"Webhook: {endpoint or 'not set'} (active={active}, use={use_status})",
"details": data,
"issues": sub_issues if sub_issues else None,
}
if resp.status_code == 401:
return {
"severity": "error",
"check": "webhook_endpoint",
"message": "Could not verify webhook endpoint — token invalid",
}
except Exception as e:
return {
"severity": "warning",
"check": "webhook_endpoint",
"message": f"Could not verify webhook endpoint: {e}",
}
return {
"severity": "warning",
"check": "webhook_endpoint",
"message": "Could not verify webhook endpoint configuration",
}
async def _check_bot_profile(self, token: str) -> dict:
result = await probe_line_bot(token)
if result.get("status") == "ok":
missing = []
if not result.get("display_name"):
missing.append("display_name")
if not result.get("user_id"):
missing.append("user_id")
if not result.get("picture_url"):
missing.append("picture_url")
if missing:
return {
"severity": "warning",
"check": "bot_profile",
"message": f"Bot profile incomplete — missing: {', '.join(missing)}",
}
return {
"severity": "info",
"check": "bot_profile",
"message": f"Bot profile complete — {result.get('display_name', '')}",
}
return {
"severity": "warning",
"check": "bot_profile",
"message": "Could not retrieve bot profile",
}
async def _check_message_quota(self, token: str) -> dict:
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
headers = {"Authorization": f"Bearer {token}"}
quota_resp = await client.get(
"https://api.line.me/v2/bot/message/quota",
headers=headers,
)
consumption_resp = await client.get(
"https://api.line.me/v2/bot/message/quota/consumption",
headers=headers,
)
quota_data = {}
consumption_data = {}
if quota_resp.status_code == 200:
quota_data = quota_resp.json()
if consumption_resp.status_code == 200:
consumption_data = consumption_resp.json()
quota_type = quota_data.get("type", "unknown")
quota_value = quota_data.get("value")
total_usage = consumption_data.get("totalUsage", 0)
detail_parts = []
if quota_value is not None:
detail_parts.append(f"quota={quota_value}")
detail_parts.append(f"type={quota_type}")
severity = "info"
warnings = []
if total_usage > 0 and quota_value and quota_value > 0:
usage_pct = total_usage / quota_value * 100
detail_parts.append(f"usage={total_usage}/{quota_value} ({usage_pct:.1f}%)")
if usage_pct > 90:
severity = "error"
warnings.append(f"Message quota usage at {usage_pct:.1f}% — critical!")
elif usage_pct > 70:
severity = "warning"
warnings.append(f"Message quota usage at {usage_pct:.1f}%")
elif total_usage > 0:
detail_parts.append(f"usage={total_usage}")
return {
"severity": severity,
"check": "message_quota",
"message": f"Message quota: {', '.join(detail_parts)}",
"details": {"quota": quota_data, "consumption": consumption_data},
"issues": warnings if warnings else None,
}
except Exception as e:
return {
"severity": "warning",
"check": "message_quota",
"message": f"Could not check message quota: {e}",
}
async def _check_delivery_status(self, token: str) -> dict:
try:
today = datetime.now(UTC).strftime("%Y%m%d")
import httpx
async with httpx.AsyncClient(timeout=10) as client:
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get(
"https://api.line.me/v2/bot/insight/message/delivery",
headers=headers,
params={"date": today},
)
if resp.status_code == 200:
data = resp.json()
broadcast = data.get("broadcast", 0)
targeting = data.get("targeting", 0)
auto_response = data.get("autoResponse", 0)
total = broadcast + targeting + auto_response
detail = f"broadcast={broadcast}, targeting={targeting}, auto={auto_response}"
if total > 0:
detail += f", total={total}"
return {
"severity": "info",
"check": "delivery_status",
"message": f"Today's delivery: {detail}",
"details": data,
}
if resp.status_code == 429:
return {
"severity": "warning",
"check": "delivery_status",
"message": "Rate limited when checking delivery status",
}
except Exception as e:
return {
"severity": "warning",
"check": "delivery_status",
"message": f"Could not check delivery status: {e}",
}
return {
"severity": "info",
"check": "delivery_status",
"message": "Delivery stats not available yet today",
}
async def _check_config_completeness(self) -> dict:
if not self._adapter:
return {
"severity": "info",
"check": "config_completeness",
"message": "No adapter available for config check",
}
config = self._adapter.config
issues = []
accounts = config.get("accounts", {})
if not accounts:
issues.append("No LINE accounts configured")
dm_policy = config.get("dm_policy", "open")
if dm_policy == "allowlist":
allow_from = config.get("allow_from", [])
if not allow_from:
issues.append("DM policy is 'allowlist' but no users in allow_from")
elif dm_policy == "pairing":
if self._adapter:
pending = len(self._adapter._dm_pending_pairing)
if pending > 0:
issues.append(f"{pending} users awaiting pairing approval")
group_policy = config.get("group_policy", "open")
if group_policy == "allowlist":
groups = config.get("groups", [])
enabled_groups = [g for g in groups if g.get("enabled", True)]
if not enabled_groups:
issues.append("Group policy is 'allowlist' but no groups are enabled")
if not issues:
return {
"severity": "info",
"check": "config_completeness",
"message": (
f"Config complete — dm_policy={dm_policy}, group_policy={group_policy}, accounts={len(accounts)}"
),
}
return {
"severity": "warning",
"check": "config_completeness",
"message": f"Config issues found: {'; '.join(issues)}",
"issues": issues,
}
async def run_line_diagnostics(adapter=None) -> dict:
doctor = LINEDoctor(adapter)
return await doctor.run_diagnostics()