diff --git a/backend/package/yuxi/channels/adapters/wechat/adapter.py b/backend/package/yuxi/channels/adapters/wechat/adapter.py index abc8281d..5ee4f0c9 100644 --- a/backend/package/yuxi/channels/adapters/wechat/adapter.py +++ b/backend/package/yuxi/channels/adapters/wechat/adapter.py @@ -26,6 +26,7 @@ from yuxi.channels.models import ( EventType, HealthStatus, ) +from yuxi.channels.policy.dedup_policy import DedupPolicy from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger @@ -103,11 +104,13 @@ from .selector import WeChatSelectorAdapter from .send_cache import WeChatSendCache from .sent_message_store import SentMessageStore from .template_adapter import WeChatTemplateAdapter +from .template_card import TemplateCard from .threading_adapter import WeChatThreadingAdapter from .voice import send_voice_bridge, send_voice_mp, send_voice_wecom from .wecom import ( WeComClient, WeComMonitor, + build_wecom_markdown_payload, build_wecom_text_payload, send_wecom_message, ) @@ -126,9 +129,10 @@ class WeChatAdapter(BaseChannelAdapter): meta = WECHAT_META text_chunk_limit = 2048 - supports_markdown = False + supports_markdown = True supports_streaming = False max_media_size_mb = 20 + webhook_path = "wechat" def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) @@ -141,14 +145,23 @@ class WeChatAdapter(BaseChannelAdapter): self._polling_task: asyncio.Task | None = None self._wecom_client: WeComClient | None = None + self._wecom_client_lock = asyncio.Lock() self._mp_client: MPClient | None = None self._bridge_client: BridgeClient | None = None self._qr_login_mgr: QRLoginManager | None = None self._monitor: WeComMonitor | None = None + self._sub_monitors: dict[str, WeComMonitor] = {} + self._sub_clients: dict[str, WeComClient | MPClient | BridgeClient] = {} + self._sub_polling_tasks: dict[str, asyncio.Task] = {} self._rate_limiter = TokenBucketRateLimiter(rate=20, per=60.0) - self._dedup: dict[tuple, float] = {} - self._dedup_ttl = 1.0 + self._dedup_ttl = self.config.get("dedup_ttl_seconds", 1.0) + self._max_dedup_entries = self.config.get("max_dedup_entries", 10000) + self._send_dedup = DedupPolicy( + ttl=int(self._dedup_ttl), + maxsize=self._max_dedup_entries, + redis_url=os.environ.get("REDIS_URL"), + ) self._banned = False self._banned_reason: str | None = None @@ -175,7 +188,7 @@ class WeChatAdapter(BaseChannelAdapter): self._mention = WeChatMentionAdapter() self._router = WeChatMessagingRouter() self._heartbeat = WeChatHeartbeatAdapter(self.config) - self._message_actions = WeChatMessageActionAdapter() + self._message_actions = WeChatMessageActionAdapter(self) self._group_admin = WeChatGroupAdmin() self._attachment = WeChatAttachmentAdapter() @@ -249,6 +262,8 @@ class WeChatAdapter(BaseChannelAdapter): self._status = ChannelStatus.CONNECTED logger.info(f"[WeChat] Channel started ({self._mode} mode)") + await self._connect_sub_accounts() + except ChannelAuthenticationError: self._status = ChannelStatus.ERROR raise @@ -274,6 +289,22 @@ class WeChatAdapter(BaseChannelAdapter): await self._monitor.stop() self._monitor = None + for account_id, monitor in list(self._sub_monitors.items()): + try: + await monitor.stop() + except Exception as e: + logger.warning(f"[WeChat] Error stopping sub-account monitor {account_id}: {e}") + self._sub_monitors.clear() + + for account_id, task in list(self._sub_polling_tasks.items()): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + self._sub_polling_tasks.clear() + if self._polling_task and not self._polling_task.done(): self._polling_task.cancel() try: @@ -288,6 +319,8 @@ class WeChatAdapter(BaseChannelAdapter): self._qr_login_mgr = None self._qr_url = None + await self._send_dedup.close() + if self._http_client: await self._http_client.aclose() self._http_client = None @@ -340,8 +373,14 @@ class WeChatAdapter(BaseChannelAdapter): error=f"Channel temporarily banned: {self._banned_reason or 'API unauthorized (48001)'}{remaining}", ) - if self._mode == "wecom" and self._is_wecom_duplicate(response): - return DeliveryResult(success=False, error="Duplicate message within dedup window") + if self._mode == "wecom": + import hashlib + + user_id = response.identity.channel_user_id + dedup_hash = hashlib.sha256(f"{user_id}:{response.content}".encode()).hexdigest() + dedup_key = f"send:{self.channel_id}:{dedup_hash}" + if await self._send_dedup.check_and_mark(dedup_key, ttl=int(self._dedup_ttl)): + return DeliveryResult(success=False, error="Duplicate message within dedup window") if not await self._rate_limiter.acquire(): return DeliveryResult(success=False, error="Rate limit exceeded, try again later") @@ -354,26 +393,79 @@ class WeChatAdapter(BaseChannelAdapter): except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker is open, WeChat channel unavailable") - def _is_wecom_duplicate(self, response: ChannelResponse) -> bool: - import time + async def send_stream_chunk( + self, + chat_id: str, + msg_id: str, + chunk: str, + finished: bool = False, + ) -> DeliveryResult: + is_wecom = self._mode == "wecom" - user_id = response.identity.channel_user_id - key = (user_id, response.content) - now = time.monotonic() + if is_wecom and self._heartbeat.typing_enabled and not finished: + try: + await self._heartbeat.send_typing_via_response(self.send, chat_id, active=True) + except Exception: + pass - if key in self._dedup: - if now - self._dedup[key] < self._dedup_ttl: - return True - self._dedup[key] = now + from yuxi.channels.models import ChannelIdentity, ChannelResponse - stale_keys = [k for k, ts in self._dedup.items() if now - ts > self._dedup_ttl] - for k in stale_keys: - del self._dedup[k] - return False + identity = ChannelIdentity( + channel_id=self.channel_id, + channel_type=self.channel_type, + channel_chat_id=chat_id, + channel_user_id="", + ) + + if finished: + response = ChannelResponse(identity=identity, content=chunk) + return await self.send(response) + + stream_cfg = self.config.get("streaming", {}) if isinstance(self.config.get("streaming"), dict) else {} + fallback_cfg = stream_cfg.get("fallback", {}) if isinstance(stream_cfg, dict) else {} + chunk_size = fallback_cfg.get("chunk_size", self.text_chunk_limit) + + if len(chunk) <= chunk_size: + response = ChannelResponse(identity=identity, content=chunk) + return await self.send(response) + + from .streaming_fallback import ParagraphChunker, ProgressPrefixFormatter + + chunker = ParagraphChunker(chunk_size=chunk_size) + chunker.feed(chunk) + paragraphs = chunker.flush() + if not paragraphs: + paragraphs = [chunk] + + progress_enabled = stream_cfg.get("progress_indicator", True) + formatter = ProgressPrefixFormatter(enabled=progress_enabled) + total = len(paragraphs) + + for i, para in enumerate(paragraphs, 1): + prefix = formatter.format(i, total) + content = f"{prefix} {para}" if prefix else para + response = ChannelResponse(identity=identity, content=content) + await self.send(response) + + return DeliveryResult(success=True) + + async def send_typing_indicator(self, chat_id: str, active: bool) -> DeliveryResult: + is_wecom = self._mode == "wecom" + if not active or not is_wecom or not self._heartbeat.typing_enabled: + return DeliveryResult(success=True) + + try: + result = await self._heartbeat.send_typing_via_response(self.send, chat_id, active=True) + return result or DeliveryResult(success=True) + except Exception as e: + return DeliveryResult(success=False, error=str(e)) async def _do_send(self, response: ChannelResponse, disable_notification: bool = False) -> DeliveryResult: if self._mode == "wecom": - result = await self._send_wecom(response, disable_notification=disable_notification) + if response.metadata.get("template_card") is not None: + result = await self._send_wecom_template_card(response) + else: + result = await self._send_wecom(response, disable_notification=disable_notification) self._check_banned_response(result) return result elif self._mode == "mp": @@ -612,19 +704,151 @@ class WeChatAdapter(BaseChannelAdapter): "[mp] (app_id + app_secret), or [personal] (bridge_url)." ) + async def _connect_sub_accounts(self) -> None: + account_ids = self._account_mgr.list_account_ids(self.config) + if len(account_ids) <= 1: + return + + sub_limit = self.config.get("max_sub_accounts", 5) + sub_count = 0 + + for account_id in account_ids: + if account_id == "default": + continue + + if sub_count >= sub_limit: + logger.warning(f"[WeChat] Sub-account limit ({sub_limit}) reached, skipping remaining sub-accounts") + break + + account = self._account_mgr.resolve_account(self.config, account_id) + if not account.enabled: + continue + + sub_count += 1 + logger.info( + f"[WeChat] Connecting sub-account '{account_id}' (mode={account.mode}, {sub_count}/{sub_limit})" + ) + + if account.mode == "wecom" and self._http_client: + await self._connect_sub_wecom(account_id, account) + elif account.mode == "mp": + logger.info(f"[WeChat] Sub-account '{account_id}': MP mode uses webhook, no monitor needed") + elif account.mode == "personal" and self._http_client: + await self._connect_sub_personal(account_id, account) + + async def _connect_sub_wecom(self, account_id: str, account: Any) -> None: + sub_config = { + "corp_id": account.corp_id, + "corp_secret": account.corp_secret, + "agent_id": account.agent_id, + "proxy": account.proxy or self.config.get("proxy"), + "webhook_url": account.bridge_url or "", + } + sub_client = WeComClient(self._http_client, sub_config) + + token_data = await self.state_get(f"{account_id}_access_token", namespace="auth") + if token_data and isinstance(token_data, dict): + import time as _time + + token_str = token_data.get("access_token") + expires_at = token_data.get("expires_at", 0) + if token_str and expires_at > _time.time() + 300: + sub_client.set_token(token_str, expires_at) + logger.info(f"[WeChat] Sub-account '{account_id}' token restored from state_store") + + try: + await sub_client.get_access_token() + self._sub_clients[account_id] = sub_client + + import time as _time + + await self.state_set( + f"{account_id}_access_token", + { + "access_token": sub_client._access_token, + "expires_at": sub_client._token_expires_at, + }, + namespace="auth", + ) + + sub_monitor = WeComMonitor(self._http_client, sub_config, self._handle_message) + await sub_monitor.start(lambda: sub_client.get_access_token()) + self._sub_monitors[account_id] = sub_monitor + + logger.info(f"[WeChat] Sub-account '{account_id}' WeCom monitor started") + except Exception as e: + logger.error(f"[WeChat] Failed to connect sub-account '{account_id}': {e}") + + async def _connect_sub_personal(self, account_id: str, account: Any) -> None: + bridge_url = (account.bridge_url or "").rstrip("/") + if not bridge_url: + logger.warning(f"[WeChat] Sub-account '{account_id}' has no bridge_url") + return + + sub_bridge = BridgeClient(self._http_client, bridge_url) + healthy = await sub_bridge.health_check() + if not healthy: + logger.warning(f"[WeChat] Sub-account '{account_id}' bridge health check failed") + return + + self._sub_clients[account_id] = sub_bridge + + poll_interval = self.config.get("poll_interval", 1.0) + task = asyncio.create_task(self._sub_bridge_event_loop(account_id, sub_bridge, poll_interval)) + self._sub_polling_tasks[account_id] = task + logger.info(f"[WeChat] Sub-account '{account_id}' bridge polling started") + + async def _sub_bridge_event_loop(self, account_id: str, bridge_client: BridgeClient, poll_interval: float) -> None: + iteration = 0 + while True: + try: + messages = await bridge_client.fetch_events() + for raw_msg in messages or []: + channel_msg = self._normalize_bridge_message(raw_msg) + await self._handle_message(channel_msg) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[WeChat] Sub-account '{account_id}' bridge event loop error: {e}") + await asyncio.sleep(poll_interval) + iteration += 1 + # ---- WeCom Mode ---- async def _start_wecom(self) -> None: - corp_id = self.config.get("corp_id") - corp_secret = self.config.get("corp_secret") - agent_id = self.config.get("agent_id") + async with self._wecom_client_lock: + corp_id = self.config.get("corp_id") + corp_secret = self.config.get("corp_secret") + agent_id = self.config.get("agent_id") - if not all([corp_id, corp_secret, agent_id]): - raise ChannelException("WeCom mode requires corp_id, corp_secret, and agent_id") + if not all([corp_id, corp_secret, agent_id]): + raise ChannelException("WeCom mode requires corp_id, corp_secret, and agent_id") - self._wecom_client = WeComClient(self._http_client, self.config) - await self._wecom_client.get_access_token() - logger.info("[WeChat/WeCom] Access token obtained") + self._wecom_client = WeComClient(self._http_client, self.config) + + token_data = await self.state_get("access_token", namespace="auth") + if token_data and isinstance(token_data, dict): + import time as _time + + token_str = token_data.get("access_token") + expires_at = token_data.get("expires_at", 0) + if token_str and expires_at > _time.time() + 300: + self._wecom_client.set_token(token_str, expires_at) + logger.info("[WeChat/WeCom] Token restored from state_store") + + await self._wecom_client.get_access_token() + logger.info("[WeChat/WeCom] Access token obtained") + + import time as _time + + await self.state_set( + "access_token", + { + "access_token": self._wecom_client._access_token, + "expires_at": self._wecom_client._token_expires_at, + }, + namespace="auth", + ) self._monitor = WeComMonitor(self._http_client, self.config, self._handle_message) await self._monitor.start(lambda: self._wecom_client.get_access_token()) @@ -638,20 +862,54 @@ class WeChatAdapter(BaseChannelAdapter): await self._auto_join_groups() async def _send_wecom(self, response: ChannelResponse, disable_notification: bool = False) -> DeliveryResult: + if self._wecom_client is None: + return DeliveryResult(success=False, error="WeCom client not initialized") reply_to_user = None if response.reply_to_message_id: reply_to_user = response.metadata.get( "reply_to_channel_user_id", response.metadata.get("sender_wxid"), ) - payload = build_wecom_text_payload( + + use_markdown = ( + self.config.get("enable_markdown", True) + and response.metadata.get("markdown", False) + ) + + if use_markdown: + payload = build_wecom_markdown_payload( + agent_id=self.config["agent_id"], + to_user=response.identity.channel_user_id, + content=response.content, + chat_type=response.metadata.get("chat_type", "direct"), + reply_to_msg_id=response.reply_to_message_id, + reply_to_user=reply_to_user, + safe=1 if disable_notification else 0, + ) + else: + payload = build_wecom_text_payload( + agent_id=self.config["agent_id"], + to_user=response.identity.channel_user_id, + content=response.content, + chat_type=response.metadata.get("chat_type", "direct"), + reply_to_msg_id=response.reply_to_message_id, + reply_to_user=reply_to_user, + safe=1 if disable_notification else 0, + ) + return await send_wecom_message(self._wecom_client, self._http_client, payload) + + async def _send_wecom_template_card(self, response: ChannelResponse) -> DeliveryResult: + if self._wecom_client is None: + return DeliveryResult(success=False, error="WeCom client not initialized") + + card = response.metadata.get("template_card") + if not isinstance(card, TemplateCard): + return DeliveryResult(success=False, error="Invalid template_card in response metadata") + + payload = build_wecom_template_card_payload( agent_id=self.config["agent_id"], to_user=response.identity.channel_user_id, - content=response.content, - chat_type=response.metadata.get("chat_type", "direct"), - reply_to_msg_id=response.reply_to_message_id, - reply_to_user=reply_to_user, - safe=1 if disable_notification else 0, + card=card, ) return await send_wecom_message(self._wecom_client, self._http_client, payload) @@ -709,9 +967,31 @@ class WeChatAdapter(BaseChannelAdapter): raise ChannelException("MP mode requires app_id and app_secret") self._mp_client = MPClient(self._http_client, self.config) + + token_data = await self.state_get("access_token", namespace="auth") + if token_data and isinstance(token_data, dict): + import time as _time + + token_str = token_data.get("access_token") + expires_at = token_data.get("expires_at", 0) + if token_str and expires_at > _time.time() + 300: + self._mp_client.set_token(token_str, expires_at) + logger.info("[WeChat/MP] Token restored from state_store") + await self._mp_client.get_access_token() logger.info("[WeChat/MP] Access token obtained") + import time as _time + + await self.state_set( + "access_token", + { + "access_token": self._mp_client._access_token, + "expires_at": self._mp_client._token_expires_at, + }, + namespace="auth", + ) + webhook_url = self.config.get("webhook_url") if webhook_url: logger.info(f"[WeChat/MP] Webhook mode ready: {webhook_url}") @@ -727,6 +1007,13 @@ class WeChatAdapter(BaseChannelAdapter): "reply_to_channel_user_id", response.metadata.get("sender_wxid"), ) + + if self.config.get("passive_reply_mode", "auto") != "disabled": + await self._set_passive_cache( + response.identity.channel_user_id, + response.content, + ) + return await send_mp_custom_message( self._mp_client, self._http_client, @@ -736,6 +1023,48 @@ class WeChatAdapter(BaseChannelAdapter): reply_to_user=reply_to_user, ) + async def _set_passive_cache(self, openid: str, content: str, ttl: int = 30) -> None: + key = f"passive_reply:{openid}" + value = {"content": content, "expires_at": utc_now_naive().timestamp() + ttl} + await self.state_set(key, value, namespace="passive_reply") + + async def _pop_passive_cache(self, openid: str) -> dict | None: + key = f"passive_reply:{openid}" + data = await self.state_get(key, namespace="passive_reply") + if data: + await self.state_delete(key, namespace="passive_reply") + return data + + async def try_passive_reply(self, webhook_body: dict) -> str | None: + if self._mode != "mp": + return None + + passive_mode = self.config.get("passive_reply_mode", "auto") + + from_user = webhook_body.get("FromUserName", "") + to_user = webhook_body.get("ToUserName", "") + + if passive_mode == "disabled": + return None + + if passive_mode == "transfer_cs": + from .mp import build_transfer_customer_service_reply + + return build_transfer_customer_service_reply(from_user, to_user) + + if passive_mode == "auto": + cached = await self._pop_passive_cache(from_user) + if cached: + from .mp import build_text_passive_reply + + return build_text_passive_reply(from_user, to_user, cached["content"]) + + from .mp import build_empty_passive_reply + + return build_empty_passive_reply() + + return None + def _normalize_mp_message(self, payload: dict) -> ChannelMessage: from_user = payload.get("FromUserName", "") msg_id = str(payload.get("MsgId", "")) @@ -990,15 +1319,22 @@ class WeChatAdapter(BaseChannelAdapter): return await send_wecom_message(self._wecom_client, self._http_client, payload) async def _send_mp_media(self, media_type: str, data: Any, chat_id: str) -> DeliveryResult: - from .mp.send import send_mp_image + from .mp.send import send_mp_image, send_mp_video, send_mp_voice - if media_type != "image": - return DeliveryResult(success=False, error=f"MP only supports image media, got: {media_type}") + if media_type not in ("image", "voice", "video"): + return DeliveryResult(success=False, error=f"MP only supports image/voice/video media, got: {media_type}") if not isinstance(data, bytes): return DeliveryResult(success=False, error="send_media expects raw bytes for MP mode") - return await send_mp_image(self._mp_client, self._http_client, chat_id, data) + if media_type == "image": + return await send_mp_image(self._mp_client, self._http_client, chat_id, data) + elif media_type == "voice": + return await send_mp_voice(self._mp_client, self._http_client, chat_id, data) + elif media_type == "video": + return await send_mp_video(self._mp_client, self._http_client, chat_id, data) + + return DeliveryResult(success=False, error=f"Unsupported MP media type: {media_type}") async def _send_bridge_media( self, media_type: str, data: Any, chat_id: str, disable_notification: bool = False @@ -1036,6 +1372,30 @@ class WeChatAdapter(BaseChannelAdapter): async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: return DeliveryResult(success=False, error="WeChat does not support reactions") + def supports_action(self, action: str) -> bool: + return self._message_actions.is_supported(action) + + def resolve_execution_mode(self, action: str) -> str: + return "direct" + + async def handle_action(self, ctx) -> DeliveryResult: + handler = self._message_actions.get_handler(ctx.action) + if handler is None: + return DeliveryResult(success=False, error=f"Action '{ctx.action}' not supported by WeChat") + try: + result = await handler(**ctx.args, chat_id=ctx.chat_id, msg_id=ctx.msg_id) + if isinstance(result, DeliveryResult): + return result + if isinstance(result, dict): + return DeliveryResult( + success=result.get("success", False), + message_id=result.get("message_id"), + error=result.get("error"), + ) + return DeliveryResult(success=True) + except Exception as e: + return DeliveryResult(success=False, error=str(e)) + async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: self._check_ban_cooldown() if self._banned: @@ -1159,7 +1519,7 @@ class WeChatAdapter(BaseChannelAdapter): @property def is_banned(self) -> bool: - return self._banned + return self._banned or self._ban_permanent @property def banned_reason(self) -> str | None: @@ -1391,9 +1751,20 @@ class WeChatAdapter(BaseChannelAdapter): "banned": self._banned, "ban_permanent": self._ban_permanent, "ban_attempts": self._ban_attempts, + "dedup_ttl": self._dedup_ttl, }, ) + def export_dedup_state(self) -> dict[str, Any]: + return { + "dedup_ttl": self._dedup_ttl, + "max_entries": self._max_dedup_entries, + "backend": "DedupPolicy (TTLCache + optional Redis)", + } + + def import_dedup_state(self, state: dict[str, Any]) -> None: + logger.debug("[WeChat] Dedup state import skipped: DedupPolicy handles persistence internally") + @property def selector(self) -> WeChatSelectorAdapter: return self._selector @@ -1477,28 +1848,32 @@ class WeChatAdapter(BaseChannelAdapter): if self._mode == "wecom": token = self.config.get("token", "") if not token: - return True + logger.warning("[WeChat] Webhook signature verification skipped: token not configured for wecom mode") + return False signature = headers.get("x-wx-signature", headers.get("signature", "")) timestamp = headers.get("x-wx-timestamp", headers.get("timestamp", "")) nonce = headers.get("x-wx-nonce", headers.get("nonce", "")) if not all([signature, timestamp, nonce]): - return True + logger.warning("[WeChat] Webhook signature verification failed: missing signature/timestamp/nonce") + return False return wecom_verify_sig(token, timestamp, nonce, signature) if self._mode == "mp": token = self.config.get("token", "") if not token: - return True + logger.warning("[WeChat] Webhook signature verification skipped: token not configured for mp mode") + return False signature = headers.get("x-wx-signature", headers.get("signature", "")) timestamp = headers.get("x-wx-timestamp", headers.get("timestamp", "")) nonce = headers.get("x-wx-nonce", headers.get("nonce", "")) if not all([signature, timestamp, nonce]): - return True + logger.warning("[WeChat] Webhook signature verification failed: missing signature/timestamp/nonce") + return False return mp_verify_sig(token, timestamp, nonce, signature) diff --git a/backend/package/yuxi/channels/adapters/wechat/agent_prompt.py b/backend/package/yuxi/channels/adapters/wechat/agent_prompt.py index 405266fd..886d5a30 100644 --- a/backend/package/yuxi/channels/adapters/wechat/agent_prompt.py +++ b/backend/package/yuxi/channels/adapters/wechat/agent_prompt.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - PROMPT_PREFIXES = { "wecom": "你是一个企业微信 AI 助手,通过企业微信与用户沟通。请保持专业、简洁,使用中文回复。", "mp": "你是一个微信公众号 AI 助手,通过公众号消息与用户沟通。请保持友好、专业,使用中文回复。", diff --git a/backend/package/yuxi/channels/adapters/wechat/auth_adapter.py b/backend/package/yuxi/channels/adapters/wechat/auth_adapter.py index 273863a3..c3946eeb 100644 --- a/backend/package/yuxi/channels/adapters/wechat/auth_adapter.py +++ b/backend/package/yuxi/channels/adapters/wechat/auth_adapter.py @@ -4,12 +4,115 @@ from typing import Any class WeChatAuthAdapter: - @staticmethod - def get_dm_exposure(config: dict[str, Any]) -> str: + def get_dm_exposure(self, config: dict[str, Any]) -> str: return config.get("dm_policy", "pairing") - @staticmethod - def get_authorization_url(config: dict[str, Any]) -> str | None: + def get_authorization_url(self, config: dict[str, Any]) -> str | None: if config.get("wecom_auth_url"): return config["wecom_auth_url"] return None + + def build_wecom_oauth_url( + self, + corp_id: str, + redirect_uri: str, + state: str = "", + scope: str = "snsapi_base", + ) -> str: + params = { + "appid": corp_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scope, + } + query = "&".join(f"{k}={v}" for k, v in params.items()) + url = f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}" + if state: + url += f"&state={state}" + url += "#wechat_redirect" + return url + + def build_mp_oauth_url( + self, + app_id: str, + redirect_uri: str, + state: str = "", + scope: str = "snsapi_userinfo", + ) -> str: + params = { + "appid": app_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scope, + } + query = "&".join(f"{k}={v}" for k, v in params.items()) + url = f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}" + if state: + url += f"&state={state}" + url += "#wechat_redirect" + return url + + async def exchange_wecom_code(self, code: str, corp_id: str, corp_secret: str) -> dict[str, Any]: + import httpx + + access_token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + async with httpx.AsyncClient() as client: + token_resp = await client.get( + access_token_url, + params={"corpid": corp_id, "corpsecret": corp_secret}, + ) + token_data = token_resp.json() + access_token = token_data.get("access_token", "") + + if not access_token: + return {"success": False, "error": f"Failed to get access token: {token_data}"} + + userinfo_url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo" + user_resp = await client.get( + userinfo_url, + params={"access_token": access_token, "code": code}, + ) + user_data = user_resp.json() + + if user_data.get("errcode") == 0: + return {"success": True, "user_info": user_data, "access_token": access_token} + return {"success": False, "error": str(user_data)} + + async def exchange_mp_code(self, code: str, app_id: str, app_secret: str) -> dict[str, Any]: + import httpx + + url = "https://api.weixin.qq.com/sns/oauth2/access_token" + async with httpx.AsyncClient() as client: + resp = await client.get( + url, + params={ + "appid": app_id, + "secret": app_secret, + "code": code, + "grant_type": "authorization_code", + }, + ) + data = resp.json() + + if "access_token" in data and "openid" in data: + userinfo_url = "https://api.weixin.qq.com/sns/userinfo" + user_resp = await client.get( + userinfo_url, + params={ + "access_token": data["access_token"], + "openid": data["openid"], + "lang": "zh_CN", + }, + ) + user_data = user_resp.json() + return { + "success": True, + "user_info": user_data, + "access_token": data["access_token"], + "refresh_token": data.get("refresh_token"), + "openid": data["openid"], + } + return {"success": False, "error": str(data)} + + def get_oauth_enabled(self, config: dict[str, Any]) -> bool: + return bool(config.get("wecom_oauth_enabled", False) or config.get("mp_oauth_enabled", False)) diff --git a/backend/package/yuxi/channels/adapters/wechat/bridge.py b/backend/package/yuxi/channels/adapters/wechat/bridge.py index 0b951b7c..3edfa4a8 100644 --- a/backend/package/yuxi/channels/adapters/wechat/bridge.py +++ b/backend/package/yuxi/channels/adapters/wechat/bridge.py @@ -5,8 +5,8 @@ from typing import Any import httpx -from yuxi.channels.models import DeliveryResult from yuxi.channels.adapters.wechat.format import truncate_text +from yuxi.channels.models import DeliveryResult class BridgeClient: diff --git a/backend/package/yuxi/channels/adapters/wechat/capabilities.py b/backend/package/yuxi/channels/adapters/wechat/capabilities.py index ecb86dd3..47b3b559 100644 --- a/backend/package/yuxi/channels/adapters/wechat/capabilities.py +++ b/backend/package/yuxi/channels/adapters/wechat/capabilities.py @@ -15,7 +15,7 @@ WECHAT_META = ChannelMeta( aliases=["weixin", "WeChat"], detail_label="WeChat 微信", system_image="wechat", - markdown_capable=False, + markdown_capable=True, exposure="configured", show_configured=True, show_in_setup=True, diff --git a/backend/package/yuxi/channels/adapters/wechat/config_schema.py b/backend/package/yuxi/channels/adapters/wechat/config_schema.py index 4c757463..6c431c88 100644 --- a/backend/package/yuxi/channels/adapters/wechat/config_schema.py +++ b/backend/package/yuxi/channels/adapters/wechat/config_schema.py @@ -51,6 +51,17 @@ WECHAT_CONFIG_SCHEMA: dict[str, Any] = { "minItems": 1, "maxItems": 5, }, + "passive_reply_mode": { + "type": "string", + "enum": ["auto", "transfer_cs", "disabled"], + "default": "auto", + "description": ( + "MP 模式被动回复策略:" + "auto - 自动尝试被动回复(优先缓存 → 空回复 + 客服消息兜底);" + "transfer_cs - 始终转发到客服;" + "disabled - 仅使用客服消息(当前行为)" + ), + }, }, } diff --git a/backend/package/yuxi/channels/adapters/wechat/config_ui_hints.py b/backend/package/yuxi/channels/adapters/wechat/config_ui_hints.py index 520cfb27..88826208 100644 --- a/backend/package/yuxi/channels/adapters/wechat/config_ui_hints.py +++ b/backend/package/yuxi/channels/adapters/wechat/config_ui_hints.py @@ -133,6 +133,22 @@ def get_config_ui_hints() -> dict[str, Any]: "min": 0.5, "max": 10.0, }, + "dedup_ttl_seconds": { + "label": "去重 TTL (秒)", + "description": "消息去重的有效时间窗口,相同 user+content 在此时间内的重复消息将被过滤", + "type": "number", + "default": 1.0, + "min": 0.1, + "max": 60.0, + }, + "max_dedup_entries": { + "label": "最大去重条目", + "description": "去重缓存的条目上限,超出后自动淘汰旧条目", + "type": "number", + "default": 10000, + "min": 100, + "max": 1000000, + }, "heartbeat_interval": { "label": "心跳间隔 (秒)", "description": "企业微信模式下的 token 续期间隔", diff --git a/backend/package/yuxi/channels/adapters/wechat/debounce.py b/backend/package/yuxi/channels/adapters/wechat/debounce.py index 32a93269..26822ff2 100644 --- a/backend/package/yuxi/channels/adapters/wechat/debounce.py +++ b/backend/package/yuxi/channels/adapters/wechat/debounce.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable from typing import Any from yuxi.channels.models import ChannelMessage diff --git a/backend/package/yuxi/channels/adapters/wechat/diagnose.py b/backend/package/yuxi/channels/adapters/wechat/diagnose.py index 9ccec697..ccd062b9 100644 --- a/backend/package/yuxi/channels/adapters/wechat/diagnose.py +++ b/backend/package/yuxi/channels/adapters/wechat/diagnose.py @@ -1,10 +1,9 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any import httpx - if TYPE_CHECKING: from .adapter import WeChatAdapter diff --git a/backend/package/yuxi/channels/adapters/wechat/directory_contract.py b/backend/package/yuxi/channels/adapters/wechat/directory_contract.py index 6d7a3987..5799a528 100644 --- a/backend/package/yuxi/channels/adapters/wechat/directory_contract.py +++ b/backend/package/yuxi/channels/adapters/wechat/directory_contract.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - WECHAT_DIRECTORY_CONTRACT: dict[str, Any] = { "channel_id": "wechat", "listing_methods": { diff --git a/backend/package/yuxi/channels/adapters/wechat/errors.py b/backend/package/yuxi/channels/adapters/wechat/errors.py index ff734e3f..ce4a7e56 100644 --- a/backend/package/yuxi/channels/adapters/wechat/errors.py +++ b/backend/package/yuxi/channels/adapters/wechat/errors.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - WECOM_ERROR_CODES: dict[int, str] = { -1: "系统繁忙,请稍后重试", 0: "请求成功", diff --git a/backend/package/yuxi/channels/adapters/wechat/event_subscribe_adapter.py b/backend/package/yuxi/channels/adapters/wechat/event_subscribe_adapter.py index 810eef6c..a3aa5c61 100644 --- a/backend/package/yuxi/channels/adapters/wechat/event_subscribe_adapter.py +++ b/backend/package/yuxi/channels/adapters/wechat/event_subscribe_adapter.py @@ -1,7 +1,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from typing import Any -from collections.abc import Callable, Awaitable from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/wechat/format.py b/backend/package/yuxi/channels/adapters/wechat/format.py index 598586bc..76883c94 100644 --- a/backend/package/yuxi/channels/adapters/wechat/format.py +++ b/backend/package/yuxi/channels/adapters/wechat/format.py @@ -1,14 +1,33 @@ from __future__ import annotations +import re + from yuxi.channels.models import MessageType TRUNCATION_MARKER = "\n...(内容过长已截断)" +_MARKDOWN_BOUNDARY_PATTERN = re.compile( + r'(\n\n|\n(?=#{1,6}\s)|\n(?=>\s)|\n(?=-\s)|\n(?=\d+\.\s))' +) + def truncate_text(text: str, limit: int) -> str: if len(text) <= limit: return text - return text[: limit - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER + cut = max(0, limit - len(TRUNCATION_MARKER)) + return text[:cut] + TRUNCATION_MARKER + + +def truncate_markdown(text: str, limit: int) -> str: + if len(text) <= limit: + return text + cut = max(0, limit - len(TRUNCATION_MARKER)) + candidates = list(_MARKDOWN_BOUNDARY_PATTERN.finditer(text[: cut + 1])) + if candidates: + boundary = candidates[-1].start() + if boundary > cut * 0.7: + return text[:boundary] + TRUNCATION_MARKER + return text[:cut] + TRUNCATION_MARKER def map_wecom_msg_type(msg_type: str) -> MessageType: diff --git a/backend/package/yuxi/channels/adapters/wechat/heartbeat_adapter.py b/backend/package/yuxi/channels/adapters/wechat/heartbeat_adapter.py index 21cd31ae..39c07ef2 100644 --- a/backend/package/yuxi/channels/adapters/wechat/heartbeat_adapter.py +++ b/backend/package/yuxi/channels/adapters/wechat/heartbeat_adapter.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from yuxi.channels.models import DeliveryResult + from .adapter import WeChatAdapter @@ -11,14 +13,44 @@ class WeChatHeartbeatAdapter: self._typing_enabled = config.get("typing_indicator", False) self._heartbeat_interval = config.get("heartbeat_interval", 300.0) - async def send_typing_indicator(self, send_fn, chat_id: str) -> None: - if not self._typing_enabled: + @property + def typing_enabled(self) -> bool: + return self._typing_enabled + + async def send_typing_indicator(self, send_fn, chat_id: str, active: bool = True) -> None: + if not self._typing_enabled or not active: return try: await send_fn(chat_id, "...") except Exception: pass + async def send_typing_via_response( + self, + send_fn, + chat_id: str, + active: bool = True, + ) -> DeliveryResult | None: + if not self._typing_enabled or not active: + return None + try: + from yuxi.channels.models import ( + ChannelIdentity, + ChannelResponse, + ChannelType, + ) + + identity = ChannelIdentity( + channel_id="wechat", + channel_type=ChannelType.WECHAT, + channel_user_id="", + channel_chat_id=chat_id, + ) + response = ChannelResponse(identity=identity, content="...") + return await send_fn(response) + except Exception: + return None + async def heartbeat_check(self, adapter: WeChatAdapter) -> dict[str, Any]: import time diff --git a/backend/package/yuxi/channels/adapters/wechat/message_actions.py b/backend/package/yuxi/channels/adapters/wechat/message_actions.py index 63a4f32b..0e5af976 100644 --- a/backend/package/yuxi/channels/adapters/wechat/message_actions.py +++ b/backend/package/yuxi/channels/adapters/wechat/message_actions.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from yuxi.channels.message_actions import ActionStatus, MessageAction @@ -63,18 +63,152 @@ WECHAT_MESSAGE_ACTIONS: dict[str, dict[str, Any]] = { "search": {"status": "unsupported", "reason": "WeChat doesn't support message search API"}, "set-profile": {"status": "unsupported", "reason": "WeChat doesn't support profile API"}, "set-presence": {"status": "unsupported", "reason": "WeChat doesn't support presence API"}, + "template_card": {"status": "implemented", "impl": "template_card.py"}, } class WeChatMessageActionAdapter: + def __init__(self, adapter: Any = None): + self._adapter = adapter + @staticmethod def is_supported(action_name: str) -> bool: info = WECHAT_MESSAGE_ACTIONS.get(action_name, {}) return info.get("status") == "implemented" - @staticmethod - def get_handler(action_name: str) -> Callable[..., Awaitable[Any]] | None: - return None + def get_handler(self, action_name: str) -> Callable[..., Awaitable[Any]] | None: + if self._adapter is None: + return None + return self._build_handler(action_name) + + def _build_handler(self, action_name: str) -> Callable[..., Awaitable[Any]] | None: + adapter = self._adapter + + async def _send(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + content = kwargs.get("content", "") + if not chat_id or not content: + return {"success": False, "error": "missing chat_id or content"} + from yuxi.channels.models import ChannelIdentity, ChannelResponse + + identity = ChannelIdentity( + channel_id=adapter.channel_id, + channel_type=adapter.channel_type, + channel_chat_id=chat_id, + channel_user_id=kwargs.get("channel_user_id", ""), + ) + response = ChannelResponse(identity=identity, content=content) + result = await adapter.send(response) + return {"success": result.success, "error": result.error} + + async def _reply(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + content = kwargs.get("content", "") + reply_to_msg_id = kwargs.get("reply_to_msg_id", "") + if not chat_id or not content: + return {"success": False, "error": "missing chat_id or content"} + from yuxi.channels.models import ChannelIdentity, ChannelResponse + + identity = ChannelIdentity( + channel_id=adapter.channel_id, + channel_type=adapter.channel_type, + channel_chat_id=chat_id, + channel_user_id=kwargs.get("channel_user_id", ""), + ) + response = ChannelResponse( + identity=identity, + content=content, + reply_to_message_id=reply_to_msg_id, + metadata={ + "reply_to_channel_user_id": kwargs.get("reply_to_channel_user_id", ""), + "sender_wxid": kwargs.get("sender_wxid"), + "chat_type": kwargs.get("chat_type", "direct"), + }, + ) + result = await adapter.send(response) + return {"success": result.success, "error": result.error} + + async def _send_attachment(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + media_type = kwargs.get("media_type", "file") + data = kwargs.get("data") + if not chat_id or data is None: + return {"success": False, "error": "missing chat_id or data"} + result = await adapter.send_media(chat_id, media_type, data) + return {"success": result.success, "error": result.error} + + async def _download_file(**kwargs: Any) -> Any: + file_id = kwargs.get("file_id", "") + if not file_id: + return {"success": False, "error": "missing file_id"} + try: + data = await adapter.download_media(file_id) + return {"success": True, "data": data} + except Exception as e: + return {"success": False, "error": str(e)} + + async def _upload_file(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + data = kwargs.get("data") + media_type = kwargs.get("media_type", "file") + if not chat_id or data is None: + return {"success": False, "error": "missing chat_id or data"} + result = await adapter.send_media(chat_id, media_type, data) + return {"success": result.success, "error": result.error} + + async def _read(**kwargs: Any) -> Any: + msg_id = kwargs.get("msg_id", "") + if not msg_id: + return {"success": False, "error": "missing msg_id"} + result = await adapter.read_message(msg_id) + return result + + async def _rename_group(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + name = kwargs.get("name", "") + if not chat_id or not name: + return {"success": False, "error": "missing chat_id or name"} + if adapter._mode != "wecom" or not adapter._wecom_client or not adapter._http_client: + return {"success": False, "error": "rename group only supported in WeCom mode"} + result = await adapter._group_admin.rename_group(adapter._wecom_client, adapter._http_client, chat_id, name) + return {"success": result.success, "error": result.error} + + async def _add_participant(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + user_id = kwargs.get("user_id", "") + if not chat_id or not user_id: + return {"success": False, "error": "missing chat_id or user_id"} + if adapter._mode != "wecom" or not adapter._wecom_client or not adapter._http_client: + return {"success": False, "error": "add participant only supported in WeCom mode"} + result = await adapter._group_admin.add_participant( + adapter._wecom_client, adapter._http_client, chat_id, user_id + ) + return {"success": result.success, "error": result.error} + + async def _remove_participant(**kwargs: Any) -> Any: + chat_id = kwargs.get("chat_id", "") + user_id = kwargs.get("user_id", "") + if not chat_id or not user_id: + return {"success": False, "error": "missing chat_id or user_id"} + if adapter._mode != "wecom" or not adapter._wecom_client or not adapter._http_client: + return {"success": False, "error": "remove participant only supported in WeCom mode"} + result = await adapter._group_admin.remove_participant( + adapter._wecom_client, adapter._http_client, chat_id, user_id + ) + return {"success": result.success, "error": result.error} + + handler_map: dict[str, Callable[..., Awaitable[Any]]] = { + "send": _send, + "reply": _reply, + "sendAttachment": _send_attachment, + "download-file": _download_file, + "upload-file": _upload_file, + "read": _read, + "renameGroup": _rename_group, + "addParticipant": _add_participant, + "removeParticipant": _remove_participant, + } + return handler_map.get(action_name) @staticmethod def list_supported_actions() -> list[str]: diff --git a/backend/package/yuxi/channels/adapters/wechat/monitoring_adapter.py b/backend/package/yuxi/channels/adapters/wechat/monitoring_adapter.py index cff142bd..ac6fcf5f 100644 --- a/backend/package/yuxi/channels/adapters/wechat/monitoring_adapter.py +++ b/backend/package/yuxi/channels/adapters/wechat/monitoring_adapter.py @@ -112,4 +112,4 @@ class WeChatMonitoringAdapter: return issues def reset_metrics(self) -> None: - self._metrics.clear() \ No newline at end of file + self._metrics.clear() diff --git a/backend/package/yuxi/channels/adapters/wechat/mp/__init__.py b/backend/package/yuxi/channels/adapters/wechat/mp/__init__.py index 952bbc81..a71322e1 100644 --- a/backend/package/yuxi/channels/adapters/wechat/mp/__init__.py +++ b/backend/package/yuxi/channels/adapters/wechat/mp/__init__.py @@ -7,6 +7,12 @@ from yuxi.channels.adapters.wechat.mp.crypto import ( verify_url_echostr, ) from yuxi.channels.adapters.wechat.mp.send import ( + build_empty_passive_reply, + build_image_passive_reply, + build_text_passive_reply, + build_transfer_customer_service_reply, + build_video_passive_reply, + build_voice_passive_reply, send_mp_custom_message, send_mp_template_message, ) @@ -18,4 +24,10 @@ __all__ = [ "verify_url_echostr", "send_mp_custom_message", "send_mp_template_message", + "build_text_passive_reply", + "build_image_passive_reply", + "build_voice_passive_reply", + "build_video_passive_reply", + "build_transfer_customer_service_reply", + "build_empty_passive_reply", ] diff --git a/backend/package/yuxi/channels/adapters/wechat/mp/client.py b/backend/package/yuxi/channels/adapters/wechat/mp/client.py index 600be06d..599df39f 100644 --- a/backend/package/yuxi/channels/adapters/wechat/mp/client.py +++ b/backend/package/yuxi/channels/adapters/wechat/mp/client.py @@ -58,6 +58,10 @@ class MPClient: self._access_token = None self._token_expires_at = 0.0 + def set_token(self, access_token: str, expires_at: float) -> None: + self._access_token = access_token + self._token_expires_at = expires_at + async def upload_temp_media(self, file_data: bytes, filename: str, media_type: str) -> str: token = await self.get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/media/upload?access_token={token}&type={media_type}" diff --git a/backend/package/yuxi/channels/adapters/wechat/mp/send.py b/backend/package/yuxi/channels/adapters/wechat/mp/send.py index 51096c43..06989af2 100644 --- a/backend/package/yuxi/channels/adapters/wechat/mp/send.py +++ b/backend/package/yuxi/channels/adapters/wechat/mp/send.py @@ -1,16 +1,19 @@ from __future__ import annotations +import time from typing import Any import httpx -from yuxi.channels.models import DeliveryResult -from yuxi.channels.adapters.wechat.format import truncate_text from yuxi.channels.adapters.wechat.errors import is_token_expired, parse_mp_error +from yuxi.channels.adapters.wechat.format import truncate_text from yuxi.channels.adapters.wechat.retry import retry_with_backoff +from yuxi.channels.models import DeliveryResult from .client import MPClient +XML_HEADER = '' + async def send_mp_custom_message( client: MPClient, @@ -203,3 +206,105 @@ async def send_mp_news( return await _post_with_token_retry(client, http_client, token, payload) except httpx.HTTPError as e: return DeliveryResult(success=False, error=str(e)) + + +def _escape_cdata(text: str) -> str: + return f"', ']]]]>')}]]>" + + +def build_text_passive_reply( + to_user: str, + from_user: str, + content: str, +) -> str: + timestamp = int(time.time()) + return ( + f'{XML_HEADER}\n' + f'\n' + f' {_escape_cdata(to_user)}\n' + f' {_escape_cdata(from_user)}\n' + f' {timestamp}\n' + f' {_escape_cdata("text")}\n' + f' {_escape_cdata(content[:2048])}\n' + f'' + ) + + +def build_image_passive_reply( + to_user: str, + from_user: str, + media_id: str, +) -> str: + timestamp = int(time.time()) + return ( + f'{XML_HEADER}\n' + f'\n' + f' {_escape_cdata(to_user)}\n' + f' {_escape_cdata(from_user)}\n' + f' {timestamp}\n' + f' {_escape_cdata("image")}\n' + f' {_escape_cdata(media_id)}\n' + f'' + ) + + +def build_voice_passive_reply( + to_user: str, + from_user: str, + media_id: str, +) -> str: + timestamp = int(time.time()) + return ( + f'{XML_HEADER}\n' + f'\n' + f' {_escape_cdata(to_user)}\n' + f' {_escape_cdata(from_user)}\n' + f' {timestamp}\n' + f' {_escape_cdata("voice")}\n' + f' {_escape_cdata(media_id)}\n' + f'' + ) + + +def build_video_passive_reply( + to_user: str, + from_user: str, + media_id: str, + title: str = "", + description: str = "", +) -> str: + timestamp = int(time.time()) + return ( + f'{XML_HEADER}\n' + f'\n' + f' {_escape_cdata(to_user)}\n' + f' {_escape_cdata(from_user)}\n' + f' {timestamp}\n' + f' {_escape_cdata("video")}\n' + f' \n' + f'' + ) + + +def build_transfer_customer_service_reply( + to_user: str, + from_user: str, +) -> str: + timestamp = int(time.time()) + return ( + f'{XML_HEADER}\n' + f'\n' + f' {_escape_cdata(to_user)}\n' + f' {_escape_cdata(from_user)}\n' + f' {timestamp}\n' + f' {_escape_cdata("transfer_customer_service")}\n' + f'' + ) + + +def build_empty_passive_reply() -> str: + return "" diff --git a/backend/package/yuxi/channels/adapters/wechat/pairing.py b/backend/package/yuxi/channels/adapters/wechat/pairing.py index 055b46f5..7322f805 100644 --- a/backend/package/yuxi/channels/adapters/wechat/pairing.py +++ b/backend/package/yuxi/channels/adapters/wechat/pairing.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import secrets import time -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path from typing import Any diff --git a/backend/package/yuxi/channels/adapters/wechat/polling_lease.py b/backend/package/yuxi/channels/adapters/wechat/polling_lease.py index a8fcd379..d33ae9c2 100644 --- a/backend/package/yuxi/channels/adapters/wechat/polling_lease.py +++ b/backend/package/yuxi/channels/adapters/wechat/polling_lease.py @@ -64,15 +64,10 @@ class PollingLease: px=int(self._lease_ttl * 1000), ) if not acquired: - logger.warning( - f"[PollingLease/{self._channel_id}] Lease held by another instance" - ) + logger.warning(f"[PollingLease/{self._channel_id}] Lease held by another instance") return False except Exception as e: - logger.warning( - f"[PollingLease/{self._channel_id}] Redis unavailable, " - f"falling back to local mode: {e}" - ) + logger.warning(f"[PollingLease/{self._channel_id}] Redis unavailable, falling back to local mode: {e}") self._acquired_at = time.monotonic() self._last_renew = self._acquired_at @@ -108,9 +103,7 @@ class PollingLease: int(self._lease_ttl * 1000), ) except Exception as e: - logger.error( - f"[PollingLease/{self._channel_id}] Renew failed: {e}" - ) + logger.error(f"[PollingLease/{self._channel_id}] Renew failed: {e}") self._last_renew = time.monotonic() except asyncio.CancelledError: break @@ -144,4 +137,4 @@ class PollingLease: "acquired_at": self._acquired_at, "last_renew": self._last_renew, "lease_ttl": self._lease_ttl, - } \ No newline at end of file + } diff --git a/backend/package/yuxi/channels/adapters/wechat/setup_contract.py b/backend/package/yuxi/channels/adapters/wechat/setup_contract.py index 99a0306e..b3f00314 100644 --- a/backend/package/yuxi/channels/adapters/wechat/setup_contract.py +++ b/backend/package/yuxi/channels/adapters/wechat/setup_contract.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - WECHAT_SETUP_CONTRACT: dict[str, Any] = { "channel_id": "wechat", "setup_steps": [ diff --git a/backend/package/yuxi/channels/adapters/wechat/setup_wizard.py b/backend/package/yuxi/channels/adapters/wechat/setup_wizard.py index 1e53d72b..4eeaaee0 100644 --- a/backend/package/yuxi/channels/adapters/wechat/setup_wizard.py +++ b/backend/package/yuxi/channels/adapters/wechat/setup_wizard.py @@ -3,6 +3,9 @@ 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 @@ -86,13 +89,69 @@ class WeChatSetupWizard: else: valid = False - self._current_step = WizardStep.WEBHOOK if valid else WizardStep.CREDENTIALS - return { - "success": valid, - "mode": self._selected_mode, - "message": "Credentials validated" if valid else "Invalid credentials", - "next_step": "webhook" if valid else "credentials", - } + 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: diff --git a/backend/package/yuxi/channels/adapters/wechat/voice.py b/backend/package/yuxi/channels/adapters/wechat/voice.py index 8b6b02e6..0dc5e38c 100644 --- a/backend/package/yuxi/channels/adapters/wechat/voice.py +++ b/backend/package/yuxi/channels/adapters/wechat/voice.py @@ -74,9 +74,7 @@ def ensure_format( return voice_data if target_format not in VOICE_FORMATS: - raise ValueError( - f"Unsupported target format: {target_format}, must be one of {VOICE_FORMATS}" - ) + raise ValueError(f"Unsupported target format: {target_format}, must be one of {VOICE_FORMATS}") raise ValueError( f"Voice format conversion from {source_format} to {target_format} is not supported. " diff --git a/backend/package/yuxi/channels/adapters/wechat/wecom/__init__.py b/backend/package/yuxi/channels/adapters/wechat/wecom/__init__.py index 7d7316e2..b30ff16b 100644 --- a/backend/package/yuxi/channels/adapters/wechat/wecom/__init__.py +++ b/backend/package/yuxi/channels/adapters/wechat/wecom/__init__.py @@ -9,6 +9,7 @@ from yuxi.channels.adapters.wechat.wecom.crypto import ( ) from yuxi.channels.adapters.wechat.wecom.monitor import WeComMonitor from yuxi.channels.adapters.wechat.wecom.send import ( + build_wecom_markdown_payload, build_wecom_text_payload, send_wecom_message, ) @@ -20,6 +21,7 @@ __all__ = [ "verify_signature", "verify_url_signature", "WeComMonitor", + "build_wecom_markdown_payload", "build_wecom_text_payload", "send_wecom_message", ] diff --git a/backend/package/yuxi/channels/adapters/wechat/wecom/client.py b/backend/package/yuxi/channels/adapters/wechat/wecom/client.py index e21f288d..6a9ae5b3 100644 --- a/backend/package/yuxi/channels/adapters/wechat/wecom/client.py +++ b/backend/package/yuxi/channels/adapters/wechat/wecom/client.py @@ -58,6 +58,10 @@ class WeComClient: self._access_token = None self._token_expires_at = 0.0 + def set_token(self, access_token: str, expires_at: float) -> None: + self._access_token = access_token + self._token_expires_at = expires_at + async def upload_media(self, file_data: bytes, filename: str, media_type: str) -> str: token = await self.get_access_token() url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={token}&type={media_type}" diff --git a/backend/package/yuxi/channels/adapters/wechat/wecom/crypto.py b/backend/package/yuxi/channels/adapters/wechat/wecom/crypto.py index fe749477..4a6f5d5d 100644 --- a/backend/package/yuxi/channels/adapters/wechat/wecom/crypto.py +++ b/backend/package/yuxi/channels/adapters/wechat/wecom/crypto.py @@ -2,12 +2,12 @@ from __future__ import annotations import base64 import hashlib -import struct +import os import socket -import time +import struct -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding as sym_padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes def verify_signature(token: str, timestamp: str, nonce: str, signature: str) -> bool: @@ -44,7 +44,7 @@ def encrypt_message( ) -> str: key = base64.b64decode(encoding_aes_key + "=") - random_bytes = struct.pack("I", int(time.time())) + random_bytes = os.urandom(16) content_bytes = content.encode("utf-8") app_id_bytes = app_id.encode("utf-8") diff --git a/backend/package/yuxi/channels/adapters/wechat/wecom/send.py b/backend/package/yuxi/channels/adapters/wechat/wecom/send.py index f1c5e8b1..eb125bbc 100644 --- a/backend/package/yuxi/channels/adapters/wechat/wecom/send.py +++ b/backend/package/yuxi/channels/adapters/wechat/wecom/send.py @@ -4,11 +4,11 @@ from typing import Any import httpx +from yuxi.channels.adapters.wechat.errors import is_token_expired, parse_wecom_error +from yuxi.channels.adapters.wechat.format import truncate_markdown, truncate_text +from yuxi.channels.adapters.wechat.retry import retry_with_backoff from yuxi.channels.exceptions import ChannelRateLimitError from yuxi.channels.models import DeliveryResult -from yuxi.channels.adapters.wechat.format import truncate_text -from yuxi.channels.adapters.wechat.errors import is_token_expired, parse_wecom_error -from yuxi.channels.adapters.wechat.retry import retry_with_backoff from .client import WeComClient @@ -79,6 +79,31 @@ def build_wecom_text_payload( return payload +def build_wecom_markdown_payload( + agent_id: str, + to_user: str, + content: str, + chat_type: str = "direct", + reply_to_msg_id: str | None = None, + reply_to_user: str | None = None, + safe: int = 0, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "msgtype": "markdown", + "agentid": agent_id, + "markdown": {"content": truncate_markdown(content, 2048)}, + "safe": safe, + } + payload["touser"] = to_user + if reply_to_msg_id and reply_to_user: + payload["_reply_to_msg_id"] = reply_to_msg_id + payload["_reply_to_user"] = reply_to_user + quoted_prefix = f"> 回复 @{reply_to_user}\n\n" + if len(quoted_prefix + content) <= 2048: + payload["markdown"]["content"] = quoted_prefix + content + return payload + + def build_wecom_image_payload(agent_id: str, to_user: str, media_id: str) -> dict[str, Any]: return { "touser": to_user, @@ -180,3 +205,21 @@ async def send_wecom_video( payload = build_wecom_video_payload(agent_id, to_user, media_id, title, description) return await send_wecom_message(client, http_client, payload) + + +def build_wecom_template_card_payload( + agent_id: str, + to_user: str, + card: "TemplateCard", + enable_id_trans: bool = False, + enable_duplicate_check: bool = False, +) -> dict[str, Any]: + from yuxi.channels.adapters.wechat.template_card_render import render_template_card_message + + return render_template_card_message( + to_user=to_user, + agent_id=agent_id, + card=card, + enable_id_trans=enable_id_trans, + enable_duplicate_check=enable_duplicate_check, + )