新增了Zalo用户频道的完整适配器实现,包括: - 基础的适配器初始化与导出结构 - 群组同步与成员获取功能 - 请求限流与退避重试机制 - 健康检查与状态探针 - 消息反应/表情处理工具 - 贴纸缓存与消息去重功能 - 消息ID格式化与追踪 - TTS语音合成支持 - 消息发送权限校验 - 长文本分块发送 - 操作审批流程 - 常量配置与国际化支持 - 图像视觉分析功能 - 贴纸消息处理 - 登录与配置向导 - 群组上下文缓存 - 网关连接管理 - 配置Schema校验 - 状态问题与安全审计 - 内联按钮与交互组件 - 交互式回调分发 - 联系人与群组目录管理 - 富媒体卡片消息支持
214 lines
7.8 KiB
Python
214 lines
7.8 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Annotated, Any
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from yuxi.utils.logging_config import logger
|
||
|
||
ZALOUSER_TOOL_DESCRIPTION = """
|
||
在 Zalo 个人账户上执行操作。支持 7 种动作(action):
|
||
|
||
- send: 发送 Markdown 文本消息到指定会话
|
||
- image: 发送图片到指定会话
|
||
- link: 发送链接到指定会话
|
||
- friends: 列出好友列表
|
||
- groups: 列出群组列表
|
||
- me: 获取当前登录账户信息
|
||
- status: 检查 Zalo 账户认证状态
|
||
|
||
使用场景:
|
||
1. 向 Zalo 联系人发送消息通知
|
||
2. 查看好友或群组列表
|
||
3. 检查 Zalo 账户状态
|
||
|
||
上下文自动推导:
|
||
如果未显式提供 chat_id,工具会自动从 deliveryContext 推导 threadId 和 isGroup。
|
||
"""
|
||
|
||
|
||
class ZaloUserToolInput(BaseModel):
|
||
action: str = Field(
|
||
description="操作类型: send, image, link, friends, groups, me, status",
|
||
)
|
||
chat_id: str = Field(
|
||
default="",
|
||
description="目标会话 ID。对于 send/image/link 必须指定;留空时自动从上下文推导",
|
||
)
|
||
content: str = Field(
|
||
default="",
|
||
description="对于 send: Markdown 文本内容;对于 image: 图片 URL;对于 link: 链接 URL",
|
||
)
|
||
caption: str = Field(
|
||
default="",
|
||
description="对于 link: 链接的标题文本;对于 image: 图片描述",
|
||
)
|
||
query: str = Field(
|
||
default="",
|
||
description="对于 friends/groups: 搜索过滤关键词",
|
||
)
|
||
|
||
|
||
class ZaloUserToolAdapter:
|
||
"""Zalo User 工具适配器:将 7 种动作桥接到 Adapter 能力"""
|
||
|
||
def __init__(self, adapter: Any):
|
||
self._adapter = adapter
|
||
|
||
async def execute(
|
||
self,
|
||
action: str,
|
||
chat_id: str = "",
|
||
content: str = "",
|
||
caption: str = "",
|
||
query: str = "",
|
||
delivery_context: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
resolved_chat_id = chat_id or self._resolve_ambient_target(delivery_context)
|
||
|
||
if action == "send":
|
||
return await self._do_send(resolved_chat_id, content)
|
||
elif action == "image":
|
||
return await self._do_image(resolved_chat_id, content, caption)
|
||
elif action == "link":
|
||
return await self._do_link(resolved_chat_id, content, caption)
|
||
elif action == "friends":
|
||
return await self._do_friends(query)
|
||
elif action == "groups":
|
||
return await self._do_groups(query)
|
||
elif action == "me":
|
||
return await self._do_me()
|
||
elif action == "status":
|
||
return await self._do_status()
|
||
else:
|
||
return {
|
||
"error": f"Unknown action: {action}",
|
||
"valid_actions": ["send", "image", "link", "friends", "groups", "me", "status"],
|
||
}
|
||
|
||
def _resolve_ambient_target(self, delivery_context: dict[str, Any] | None) -> str:
|
||
if not delivery_context:
|
||
return ""
|
||
thread_id = delivery_context.get("threadId") or delivery_context.get("thread_id") or ""
|
||
if thread_id:
|
||
return str(thread_id)
|
||
return ""
|
||
|
||
async def _do_send(self, chat_id: str, content: str) -> dict[str, Any]:
|
||
if not chat_id or not content:
|
||
return {"error": "chat_id and content are required for send action"}
|
||
try:
|
||
identity = self._adapter._build_stream_identity(chat_id, "")
|
||
from yuxi.channels.models import ChannelResponse, MessageType
|
||
|
||
response = ChannelResponse(
|
||
identity=identity,
|
||
message_type=MessageType.TEXT,
|
||
content=content,
|
||
)
|
||
result = await self._adapter.send(response)
|
||
if result.success:
|
||
return {"status": "sent", "message_id": result.message_id}
|
||
return {"status": "failed", "error": result.error}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] send failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
async def _do_image(self, chat_id: str, image_url: str, caption: str) -> dict[str, Any]:
|
||
if not chat_id or not image_url:
|
||
return {"error": "chat_id and content (image URL) are required for image action"}
|
||
try:
|
||
result = await self._adapter.send_media(chat_id, "image", image_url)
|
||
if result.success:
|
||
return {"status": "sent", "message_id": result.message_id}
|
||
return {"status": "failed", "error": result.error}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] image failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
async def _do_link(self, chat_id: str, url: str, caption: str) -> dict[str, Any]:
|
||
if not chat_id or not url:
|
||
return {"error": "chat_id and content (link URL) are required for link action"}
|
||
try:
|
||
from .send import send_link
|
||
|
||
result = await send_link(self._adapter._bridge, chat_id, url, caption)
|
||
if result.success:
|
||
return {"status": "sent", "message_id": result.message_id}
|
||
return {"status": "failed", "error": result.error}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] link failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
async def _do_friends(self, query: str) -> dict[str, Any]:
|
||
try:
|
||
friends = await self._adapter.list_friends(query)
|
||
return {"friends": friends, "count": len(friends)}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] friends failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
async def _do_groups(self, query: str) -> dict[str, Any]:
|
||
try:
|
||
groups = await self._adapter.list_groups(query)
|
||
return {"groups": groups, "count": len(groups)}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] groups failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
async def _do_me(self) -> dict[str, Any]:
|
||
try:
|
||
info = await self._adapter.get_account_info()
|
||
return {"account": info}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] me failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
async def _do_status(self) -> dict[str, Any]:
|
||
try:
|
||
health = await self._adapter.health_check()
|
||
return {
|
||
"status": health.status,
|
||
"credential_stage": health.metadata.get("credential_stage", "unknown"),
|
||
"adapter_status": health.metadata.get("adapter_status", "unknown"),
|
||
"account": health.metadata.get("account", "unknown"),
|
||
}
|
||
except Exception as e:
|
||
logger.exception(f"[ZaloUserTool] status failed: {e}")
|
||
return {"error": str(e)}
|
||
|
||
|
||
def create_zalouser_tool(adapter: Any):
|
||
"""创建注册到 LangChain 的 zalouser 工具
|
||
|
||
使用方式:
|
||
tool = create_zalouser_tool(adapter_instance)
|
||
"""
|
||
tool_adapter = ZaloUserToolAdapter(adapter)
|
||
|
||
from yuxi.agents.toolkits.registry import tool as registry_tool
|
||
|
||
@registry_tool(
|
||
category="channel",
|
||
tags=["zalo", "消息"],
|
||
display_name="Zalo User",
|
||
description=ZALOUSER_TOOL_DESCRIPTION,
|
||
args_schema=ZaloUserToolInput,
|
||
)
|
||
async def zalouser_tool(
|
||
action: Annotated[str, "操作类型: send, image, link, friends, groups, me, status"],
|
||
chat_id: Annotated[str, "目标会话 ID(可选,自动从上下文推导)"] = "",
|
||
content: Annotated[str, "消息内容/图片URL/链接URL"] = "",
|
||
caption: Annotated[str, "标题/描述"] = "",
|
||
query: Annotated[str, "搜索关键词"] = "",
|
||
) -> dict:
|
||
return await tool_adapter.execute(
|
||
action=action,
|
||
chat_id=chat_id,
|
||
content=content,
|
||
caption=caption,
|
||
query=query,
|
||
)
|
||
|
||
return zalouser_tool
|