本次提交包含多项优化与新增功能: 1. 清理多个文件中多余的空行与导入顺序 2. 修复voice.py中的多行字符串格式化问题 3. 新增微信公众号被动回复构建函数与配置项 4. 新增企业微信markdown消息发送支持 5. 新增消息去重TTL与最大条目配置 6. 新增markdown文本截断工具函数 7. 新增微信授权与OAuth相关工具方法 8. 重构消息去重逻辑,使用DedupPolicy替代本地字典实现 9. 新增子账号多租户支持功能 10. 新增消息动作处理适配器,支持send/reply等操作 11. 修复token持久化逻辑,新增状态存储支持
222 lines
8.5 KiB
Python
222 lines
8.5 KiB
Python
from __future__ import annotations
|
||
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from yuxi.channels.adapters.wechat.probe import probe_bridge, probe_mp, probe_wecom
|
||
from yuxi.utils.logging_config import logger
|
||
|
||
|
||
class WizardStep(Enum):
|
||
MODE_SELECTION = "mode_selection"
|
||
CREDENTIALS = "credentials"
|
||
VALIDATION = "validation"
|
||
WEBHOOK = "webhook"
|
||
CONFIRMATION = "confirmation"
|
||
|
||
|
||
class WeChatSetupWizard:
|
||
def __init__(self):
|
||
self._current_step = WizardStep.MODE_SELECTION
|
||
self._selected_mode: str = ""
|
||
self._config_snapshot: dict[str, Any] = {}
|
||
|
||
def get_available_modes(self) -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"id": "wecom",
|
||
"label": "企业微信 (WeCom)",
|
||
"description": "使用企业微信应用消息 API,需要 corp_id、corp_secret、agent_id",
|
||
"required_fields": ["corp_id", "corp_secret", "agent_id"],
|
||
},
|
||
{
|
||
"id": "mp",
|
||
"label": "公众号 (MP)",
|
||
"description": "使用微信公众号客服消息 API,需要 app_id、app_secret",
|
||
"required_fields": ["app_id", "app_secret"],
|
||
},
|
||
{
|
||
"id": "personal",
|
||
"label": "个人微信 (Bridge)",
|
||
"description": "通过桥接服务连接个人微信,需要 bridge_url",
|
||
"required_fields": ["bridge_url"],
|
||
},
|
||
]
|
||
|
||
async def select_mode(self, mode: str) -> dict[str, Any]:
|
||
if mode not in ("wecom", "mp", "personal"):
|
||
return {
|
||
"success": False,
|
||
"error": f"Invalid mode: {mode}",
|
||
"available": [m["id"] for m in self.get_available_modes()],
|
||
}
|
||
self._selected_mode = mode
|
||
self._current_step = WizardStep.CREDENTIALS
|
||
return {"success": True, "mode": mode, "next_step": "credentials"}
|
||
|
||
async def set_credentials(self, credentials: dict[str, Any]) -> dict[str, Any]:
|
||
if not self._selected_mode:
|
||
return {"success": False, "error": "Please select a mode first"}
|
||
|
||
required = {
|
||
"wecom": ["corp_id", "corp_secret", "agent_id"],
|
||
"mp": ["app_id", "app_secret"],
|
||
"personal": ["bridge_url"],
|
||
}.get(self._selected_mode, [])
|
||
|
||
missing = [k for k in required if not credentials.get(k)]
|
||
if missing:
|
||
return {"success": False, "error": f"Missing required fields: {missing}"}
|
||
|
||
self._config_snapshot = {}
|
||
for k in required:
|
||
self._config_snapshot[k] = credentials[k]
|
||
self._current_step = WizardStep.VALIDATION
|
||
return {"success": True, "config": self._config_snapshot, "next_step": "validation"}
|
||
|
||
async def validate_connection(self, http_client_factory=None) -> dict[str, Any]:
|
||
if not self._config_snapshot:
|
||
return {"success": False, "error": "No credentials configured"}
|
||
|
||
if self._selected_mode == "wecom":
|
||
valid = all(self._config_snapshot.get(k) for k in ("corp_id", "corp_secret"))
|
||
elif self._selected_mode == "mp":
|
||
valid = all(self._config_snapshot.get(k) for k in ("app_id", "app_secret"))
|
||
elif self._selected_mode == "personal":
|
||
valid = bool(self._config_snapshot.get("bridge_url"))
|
||
else:
|
||
valid = False
|
||
|
||
if not valid:
|
||
self._current_step = WizardStep.CREDENTIALS
|
||
return {
|
||
"success": False,
|
||
"mode": self._selected_mode,
|
||
"error": "Invalid credentials",
|
||
"next_step": "credentials",
|
||
}
|
||
|
||
if http_client_factory is None:
|
||
proxy = self._config_snapshot.get("proxy")
|
||
timeout_s = self._config_snapshot.get("timeout_seconds", 15.0)
|
||
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(timeout_s)) as http_client:
|
||
return await self._probe_connection(http_client)
|
||
else:
|
||
http_client = http_client_factory()
|
||
return await self._probe_connection(http_client)
|
||
|
||
async def _probe_connection(self, http_client: httpx.AsyncClient) -> dict[str, Any]:
|
||
try:
|
||
status = None
|
||
if self._selected_mode == "wecom":
|
||
status = await probe_wecom(http_client, self._config_snapshot)
|
||
elif self._selected_mode == "mp":
|
||
status = await probe_mp(http_client, self._config_snapshot)
|
||
elif self._selected_mode == "personal":
|
||
bridge_url = self._config_snapshot.get("bridge_url", "")
|
||
status = await probe_bridge(http_client, bridge_url)
|
||
|
||
if status is None:
|
||
self._current_step = WizardStep.CREDENTIALS
|
||
return {
|
||
"success": False,
|
||
"error": "Unknown probe mode",
|
||
"next_step": "credentials",
|
||
}
|
||
|
||
if status.status == "healthy":
|
||
self._current_step = WizardStep.WEBHOOK
|
||
return {
|
||
"success": True,
|
||
"mode": self._selected_mode,
|
||
"message": f"{self._selected_mode} API 连接验证成功",
|
||
"metadata": status.metadata,
|
||
"next_step": "webhook",
|
||
}
|
||
|
||
self._current_step = WizardStep.CREDENTIALS
|
||
return {
|
||
"success": False,
|
||
"mode": self._selected_mode,
|
||
"error": status.last_error or f"{self._selected_mode} API 连接失败",
|
||
"next_step": "credentials",
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"[WeChat/SetupWizard] Probe failed for mode={self._selected_mode}: {e}")
|
||
self._current_step = WizardStep.CREDENTIALS
|
||
return {
|
||
"success": False,
|
||
"mode": self._selected_mode,
|
||
"error": str(e),
|
||
"next_step": "credentials",
|
||
}
|
||
|
||
async def configure_webhook(self, webhook_url: str) -> dict[str, Any]:
|
||
if not self._config_snapshot:
|
||
return {"success": False, "error": "No credentials configured"}
|
||
|
||
self._config_snapshot["webhook_url"] = webhook_url
|
||
self._config_snapshot["token"] = self._config_snapshot.get("token", "")
|
||
self._current_step = WizardStep.CONFIRMATION
|
||
return {
|
||
"success": True,
|
||
"webhook_url": webhook_url,
|
||
"next_step": "confirmation",
|
||
"note": "请在企业微信后台/公众号配置服务器地址为以上 webhook_url,并配置相同的 Token / EncodingAESKey"
|
||
if self._selected_mode == "wecom"
|
||
else "",
|
||
}
|
||
|
||
async def confirm_and_apply(self) -> dict[str, Any]:
|
||
if self._current_step != WizardStep.CONFIRMATION:
|
||
return {"success": False, "error": "Please complete all steps before confirming"}
|
||
|
||
config = {
|
||
"enabled": True,
|
||
"mode": self._selected_mode,
|
||
**self._config_snapshot,
|
||
}
|
||
|
||
logger.info(f"[WeChat/SetupWizard] Configuration confirmed for mode={self._selected_mode}")
|
||
return {
|
||
"success": True,
|
||
"mode": self._selected_mode,
|
||
"config": config,
|
||
"message": f"微信 {self._selected_mode} 模式配置完成,请启动适配器以验证连接",
|
||
}
|
||
|
||
def get_current_step_info(self) -> dict[str, Any]:
|
||
return {
|
||
"step": self._current_step.value,
|
||
"mode": self._selected_mode,
|
||
"config_snapshot": dict(self._config_snapshot),
|
||
}
|
||
|
||
def reset(self) -> None:
|
||
self._current_step = WizardStep.MODE_SELECTION
|
||
self._selected_mode = ""
|
||
self._config_snapshot = {}
|
||
|
||
|
||
class WeChatSetupAdapter:
|
||
ROBUST_READ_COMMANDS = [
|
||
"wechat config validate",
|
||
"wechat config show",
|
||
"wechat status",
|
||
]
|
||
|
||
@staticmethod
|
||
def get_wizard_summary(config: dict[str, Any]) -> dict[str, Any]:
|
||
has_corp = all(config.get(k) for k in ("corp_id", "corp_secret", "agent_id"))
|
||
has_mp = all(config.get(k) for k in ("app_id", "app_secret"))
|
||
has_bridge = bool(config.get("bridge_url"))
|
||
mode = "wecom" if has_corp else "mp" if has_mp else "personal" if has_bridge else "unconfigured"
|
||
return {
|
||
"mode": mode,
|
||
"configured": mode != "unconfigured",
|
||
"webhook_configured": bool(config.get("webhook_url")),
|
||
"enabled": config.get("enabled", False),
|
||
}
|