refactor(wechat): 整理代码风格与导入顺序,新增多项微信适配功能
本次提交包含多项优化与新增功能: 1. 清理多个文件中多余的空行与导入顺序 2. 修复voice.py中的多行字符串格式化问题 3. 新增微信公众号被动回复构建函数与配置项 4. 新增企业微信markdown消息发送支持 5. 新增消息去重TTL与最大条目配置 6. 新增markdown文本截断工具函数 7. 新增微信授权与OAuth相关工具方法 8. 重构消息去重逻辑,使用DedupPolicy替代本地字典实现 9. 新增子账号多租户支持功能 10. 新增消息动作处理适配器,支持send/reply等操作 11. 修复token持久化逻辑,新增状态存储支持
This commit is contained in:
parent
80e3f66974
commit
a1d9ba9683
@ -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)
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROMPT_PREFIXES = {
|
||||
"wecom": "你是一个企业微信 AI 助手,通过企业微信与用户沟通。请保持专业、简洁,使用中文回复。",
|
||||
"mp": "你是一个微信公众号 AI 助手,通过公众号消息与用户沟通。请保持友好、专业,使用中文回复。",
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 - 仅使用客服消息(当前行为)"
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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 续期间隔",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
WECHAT_DIRECTORY_CONTRACT: dict[str, Any] = {
|
||||
"channel_id": "wechat",
|
||||
"listing_methods": {
|
||||
|
||||
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
WECOM_ERROR_CODES: dict[int, str] = {
|
||||
-1: "系统繁忙,请稍后重试",
|
||||
0: "请求成功",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -112,4 +112,4 @@ class WeChatMonitoringAdapter:
|
||||
return issues
|
||||
|
||||
def reset_metrics(self) -> None:
|
||||
self._metrics.clear()
|
||||
self._metrics.clear()
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
|
||||
|
||||
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"<![CDATA[{text.replace(']]>', ']]]]><![CDATA[>')}]]>"
|
||||
|
||||
|
||||
def build_text_passive_reply(
|
||||
to_user: str,
|
||||
from_user: str,
|
||||
content: str,
|
||||
) -> str:
|
||||
timestamp = int(time.time())
|
||||
return (
|
||||
f'{XML_HEADER}\n'
|
||||
f'<xml>\n'
|
||||
f' <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n'
|
||||
f' <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n'
|
||||
f' <CreateTime>{timestamp}</CreateTime>\n'
|
||||
f' <MsgType>{_escape_cdata("text")}</MsgType>\n'
|
||||
f' <Content>{_escape_cdata(content[:2048])}</Content>\n'
|
||||
f'</xml>'
|
||||
)
|
||||
|
||||
|
||||
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'<xml>\n'
|
||||
f' <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n'
|
||||
f' <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n'
|
||||
f' <CreateTime>{timestamp}</CreateTime>\n'
|
||||
f' <MsgType>{_escape_cdata("image")}</MsgType>\n'
|
||||
f' <Image><MediaId>{_escape_cdata(media_id)}</MediaId></Image>\n'
|
||||
f'</xml>'
|
||||
)
|
||||
|
||||
|
||||
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'<xml>\n'
|
||||
f' <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n'
|
||||
f' <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n'
|
||||
f' <CreateTime>{timestamp}</CreateTime>\n'
|
||||
f' <MsgType>{_escape_cdata("voice")}</MsgType>\n'
|
||||
f' <Voice><MediaId>{_escape_cdata(media_id)}</MediaId></Voice>\n'
|
||||
f'</xml>'
|
||||
)
|
||||
|
||||
|
||||
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'<xml>\n'
|
||||
f' <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n'
|
||||
f' <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n'
|
||||
f' <CreateTime>{timestamp}</CreateTime>\n'
|
||||
f' <MsgType>{_escape_cdata("video")}</MsgType>\n'
|
||||
f' <Video>\n'
|
||||
f' <MediaId>{_escape_cdata(media_id)}</MediaId>\n'
|
||||
f' <Title>{_escape_cdata(title)}</Title>\n'
|
||||
f' <Description>{_escape_cdata(description)}</Description>\n'
|
||||
f' </Video>\n'
|
||||
f'</xml>'
|
||||
)
|
||||
|
||||
|
||||
def build_transfer_customer_service_reply(
|
||||
to_user: str,
|
||||
from_user: str,
|
||||
) -> str:
|
||||
timestamp = int(time.time())
|
||||
return (
|
||||
f'{XML_HEADER}\n'
|
||||
f'<xml>\n'
|
||||
f' <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n'
|
||||
f' <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n'
|
||||
f' <CreateTime>{timestamp}</CreateTime>\n'
|
||||
f' <MsgType>{_escape_cdata("transfer_customer_service")}</MsgType>\n'
|
||||
f'</xml>'
|
||||
)
|
||||
|
||||
|
||||
def build_empty_passive_reply() -> str:
|
||||
return ""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
WECHAT_SETUP_CONTRACT: dict[str, Any] = {
|
||||
"channel_id": "wechat",
|
||||
"setup_steps": [
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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. "
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user