Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
bbd39e3efe Merge remote-tracking branch 'origin/master' into soulter/feat/wecom-kf 2026-04-27 15:32:00 +08:00
Soulter
a12a9b40d7 feat: add new wecom kefu adapter 2026-04-25 16:31:47 +08:00
12 changed files with 1073 additions and 6 deletions

View File

@@ -44,6 +44,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
"qq_official_webhook",
"weixin_official_account",
"wecom",
"wecom_kf",
"wecom_ai_bot",
"slack",
"lark",
@@ -381,6 +382,20 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6195,
},
"微信客服": {
"id": "wecom_kf",
"type": "wecom_kf",
"enable": False,
"corpid": "",
"secret": "",
"token": "",
"encoding_aes_key": "",
"api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/",
"unified_webhook_mode": True,
"webhook_uuid": "",
"callback_server_host": "0.0.0.0",
"port": 6195,
},
"企业微信智能机器人": {
"id": "wecom_ai_bot",
"type": "wecom_ai_bot",

View File

@@ -154,6 +154,10 @@ class PlatformManager:
from .sources.wecom.wecom_adapter import (
WecomPlatformAdapter, # noqa: F401
)
case "wecom_kf":
from .sources.wecom.wecom_kf_adapter import (
WecomKFPlatformAdapter, # noqa: F401
)
case "wecom_ai_bot":
from .sources.wecom_ai_bot.wecomai_adapter import (
WecomAIBotAdapter, # noqa: F401

View File

@@ -160,6 +160,42 @@ class WeChatKF(BaseWeChatAPI):
"""
return self._get("kf/account/list")
def add_account(self, name, media_id=""):
"""添加客服帐号
:param name: 客服帐号名称
:param media_id: 客服头像临时素材 media_id
:return: 接口调用结果
"""
data = {"name": name}
if media_id:
data["media_id"] = media_id
return self._post("kf/account/add", data=data)
def update_account(self, open_kfid, name="", media_id=""):
"""修改客服帐号
:param open_kfid: 客服帐号ID
:param name: 客服帐号名称
:param media_id: 客服头像临时素材 media_id
:return: 接口调用结果
"""
data = {"open_kfid": open_kfid}
if name:
data["name"] = name
if media_id:
data["media_id"] = media_id
return self._post("kf/account/update", data=data)
def del_account(self, open_kfid):
"""删除客服帐号
:param open_kfid: 客服帐号ID
:return: 接口调用结果
"""
data = {"open_kfid": open_kfid}
return self._post("kf/account/del", data=data)
def add_contact_way(self, open_kfid, scene):
"""获取客服帐号链接

View File

@@ -0,0 +1,468 @@
import asyncio
import sys
import time
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, cast
import quart
from requests import Response
from wechatpy.enterprise import WeChatClient, parse_message
from wechatpy.enterprise.crypto import WeChatCrypto
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.messages import BaseMessage
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Image, Plain, Record
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
register_platform_adapter,
)
from astrbot.core import logger
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.media_utils import convert_audio_to_wav
from astrbot.core.utils.webhook_utils import log_webhook_info
from .wecom_event import WecomPlatformEvent
from .wecom_kf import WeChatKF
from .wecom_kf_message import WeChatKFMessage
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class WecomKFServer:
def __init__(self, config: dict) -> None:
self.server = quart.Quart(__name__)
self.port = int(cast(str, config.get("port")))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
self.server.add_url_rule(
"/callback/command",
view_func=self.verify,
methods=["GET"],
)
self.server.add_url_rule(
"/callback/command",
view_func=self.callback_command,
methods=["POST"],
)
self.crypto = WeChatCrypto(
config["token"].strip(),
config["encoding_aes_key"].strip(),
config["corpid"].strip(),
)
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.shutdown_event = asyncio.Event()
async def verify(self):
return await self.handle_verify(quart.request)
async def handle_verify(self, request) -> str:
logger.info(f"验证微信客服请求有效性: {request.args}")
args = request.args
try:
echo_str = self.crypto.check_signature(
args.get("msg_signature"),
args.get("timestamp"),
args.get("nonce"),
args.get("echostr"),
)
logger.info("验证微信客服请求有效性成功。")
return echo_str
except InvalidSignatureException:
logger.error("验证微信客服请求有效性失败,签名异常,请检查配置。")
raise
async def callback_command(self):
return await self.handle_callback(quart.request)
async def handle_callback(self, request) -> str:
data = await request.get_data()
msg_signature = request.args.get("msg_signature")
timestamp = request.args.get("timestamp")
nonce = request.args.get("nonce")
try:
xml = self.crypto.decrypt_message(data, msg_signature, timestamp, nonce)
except InvalidSignatureException:
logger.error("解密微信客服回调失败,签名异常,请检查配置。")
raise
msg = cast(BaseMessage, parse_message(xml))
logger.info(f"解析微信客服回调成功: {msg}")
if self.callback:
await self.callback(msg)
return "success"
async def start_polling(self) -> None:
logger.info(
f"将在 {self.callback_server_host}:{self.port} 端口启动 微信客服 适配器。",
)
await self.server.run_task(
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger(self) -> None:
await self.shutdown_event.wait()
@register_platform_adapter(
"wecom_kf", "微信客服适配器", support_streaming_message=False
)
class WecomKFPlatformAdapter(Platform):
MSGID_DEDUP_TTL_SECONDS = 300
TEXT_CONTENT_DEDUP_TTL_SECONDS = 15
def __init__(
self,
platform_config: dict,
platform_settings: dict,
event_queue: asyncio.Queue,
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.api_base_url = platform_config.get(
"api_base_url",
"https://qyapi.weixin.qq.com/cgi-bin/",
)
self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False)
if not self.api_base_url:
self.api_base_url = "https://qyapi.weixin.qq.com/cgi-bin/"
self.api_base_url = self.api_base_url.removesuffix("/")
if not self.api_base_url.endswith("/cgi-bin"):
self.api_base_url += "/cgi-bin"
if not self.api_base_url.endswith("/"):
self.api_base_url += "/"
self.server = WecomKFServer(self.config)
self.client = WeChatClient(
self.config["corpid"].strip(),
self.config["secret"].strip(),
)
self.wechat_kf_api = WeChatKF(client=self.client)
self.wechat_kf_message_api = WeChatKFMessage(self.client)
self.client.__setattr__("kf", self.wechat_kf_api)
self.client.__setattr__("kf_message", self.wechat_kf_message_api)
self.client.__setattr__("API_BASE_URL", self.api_base_url)
self._seen_msgids: dict[str, float] = {}
self._seen_text_messages: dict[str, float] = {}
self._kf_accounts: list[dict[str, Any]] = []
self._kf_contact_links: list[dict[str, str]] = []
self._kf_link_error = ""
async def callback(msg: BaseMessage) -> None:
if msg.type == "unknown" and msg._data.get("Event") == "kf_msg_or_event":
await self._handle_kf_msg_or_event(msg)
return
logger.debug("微信客服适配器忽略非客服回调: %s", msg)
self.server.callback = callback
@override
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"wecom_kf",
"微信客服适配器",
id=self.config.get("id", "wecom_kf"),
support_streaming_message=False,
support_proactive_message=False,
)
@override
async def send_by_session(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
logger.warning("微信客服适配器不支持 send_by_session 主动发送。")
await super().send_by_session(session, message_chain)
async def run(self) -> None:
await self._refresh_kf_contact_links()
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(微信客服)", webhook_uuid)
await self.server.shutdown_event.wait()
else:
await self.server.start_polling()
async def webhook_callback(self, request: Any) -> Any:
if request.method == "GET":
return await self.server.handle_verify(request)
return await self.server.handle_callback(request)
async def _refresh_kf_contact_links(self) -> None:
loop = asyncio.get_running_loop()
try:
account_payload = await loop.run_in_executor(
None,
self.wechat_kf_api.get_account_list,
)
accounts = account_payload.get("account_list", [])
self._kf_accounts = accounts if isinstance(accounts, list) else []
contact_links: list[dict[str, str]] = []
for index, account in enumerate(self._kf_accounts):
if not isinstance(account, dict):
continue
open_kfid = str(account.get("open_kfid", "")).strip()
if not open_kfid:
continue
scene = f"astrbot_{index}"
contact_payload = await loop.run_in_executor(
None,
self.wechat_kf_api.add_contact_way,
open_kfid,
scene,
)
url = str(contact_payload.get("url", "")).strip()
if not url:
continue
contact_links.append(
{
"open_kfid": open_kfid,
"name": str(account.get("name", "")).strip() or open_kfid,
"qrcode": url,
"qrcode_img_content": url,
}
)
self._kf_contact_links = contact_links
self._kf_link_error = ""
logger.info(
"微信客服适配器获取到 %s 个客服账号,生成 %s 个客服链接。",
len(self._kf_accounts),
len(self._kf_contact_links),
)
except Exception as e:
self._kf_link_error = str(e)
logger.error("获取微信客服列表或客服链接失败: %s", e, exc_info=True)
async def get_kf_accounts_payload(self) -> dict[str, Any]:
await self._refresh_kf_contact_links()
return {
"accounts": self._kf_accounts,
"contact_links": self._kf_contact_links,
"link_error": self._kf_link_error,
}
async def add_kf_account(self, name: str, media_id: str = "") -> dict[str, Any]:
payload = await asyncio.get_running_loop().run_in_executor(
None,
self.wechat_kf_api.add_account,
name,
media_id,
)
await self._refresh_kf_contact_links()
return cast(dict[str, Any], payload)
async def update_kf_account(
self, open_kfid: str, name: str = "", media_id: str = ""
) -> dict[str, Any]:
payload = await asyncio.get_running_loop().run_in_executor(
None,
self.wechat_kf_api.update_account,
open_kfid,
name,
media_id,
)
await self._refresh_kf_contact_links()
return cast(dict[str, Any], payload)
async def upload_kf_avatar(self, file_path: Path) -> dict[str, Any]:
def upload() -> dict[str, Any]:
with file_path.open("rb") as f:
return cast(dict[str, Any], self.client.media.upload("image", f))
return await asyncio.get_running_loop().run_in_executor(None, upload)
async def delete_kf_account(self, open_kfid: str) -> dict[str, Any]:
payload = await asyncio.get_running_loop().run_in_executor(
None,
self.wechat_kf_api.del_account,
open_kfid,
)
await self._refresh_kf_contact_links()
return cast(dict[str, Any], payload)
async def _handle_kf_msg_or_event(self, msg: BaseMessage) -> None:
token = msg._data.get("Token", "")
open_kfid = msg._data.get("OpenKfId", "")
messages = await asyncio.get_running_loop().run_in_executor(
None,
self._sync_all_messages,
token,
open_kfid,
)
for item in messages:
await self.convert_wechat_kf_message(item)
def _sync_all_messages(self, token: str, open_kfid: str) -> list[dict[str, Any]]:
cursor = ""
has_more = 1
messages: list[dict[str, Any]] = []
while has_more:
payload = self.wechat_kf_api.sync_msg(token, open_kfid, cursor=cursor)
msg_list = payload.get("msg_list", [])
if isinstance(msg_list, list):
messages.extend(item for item in msg_list if isinstance(item, dict))
has_more = int(payload.get("has_more", 0) or 0)
next_cursor = str(payload.get("next_cursor", "")).strip()
if not has_more or not next_cursor or next_cursor == cursor:
break
cursor = next_cursor
return messages
def _is_duplicate_msgid(self, msgid: str) -> bool:
now = time.monotonic()
expired_msgids = [
cached_msgid
for cached_msgid, expires_at in self._seen_msgids.items()
if expires_at <= now
]
for cached_msgid in expired_msgids:
self._seen_msgids.pop(cached_msgid, None)
if msgid in self._seen_msgids:
return True
self._seen_msgids[msgid] = now + self.MSGID_DEDUP_TTL_SECONDS
return False
def _is_duplicate_text_message(self, session_id: str, text: str) -> bool:
normalized_text = text.strip()
if not normalized_text:
return False
now = time.monotonic()
expired_keys = [
key
for key, expires_at in self._seen_text_messages.items()
if expires_at <= now
]
for key in expired_keys:
self._seen_text_messages.pop(key, None)
dedup_key = f"{session_id}:{normalized_text}"
if dedup_key in self._seen_text_messages:
return True
self._seen_text_messages[dedup_key] = now + self.TEXT_CONTENT_DEDUP_TTL_SECONDS
return False
async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None:
logger.info(f"收到微信客服消息: {msg}")
msgid = str(msg.get("msgid", "")).strip()
if msgid and self._is_duplicate_msgid(msgid):
logger.debug("忽略重复微信客服消息 msgid=%s", msgid)
return None
msgtype = msg.get("msgtype")
open_kfid = str(msg.get("open_kfid", "")).strip()
external_userid = str(msg.get("external_userid", "")).strip()
if not open_kfid or not external_userid:
logger.debug(
"忽略缺少 open_kfid 或 external_userid 的微信客服消息: %s", msg
)
return None
abm = AstrBotMessage()
abm.raw_message = dict(msg)
abm.raw_message["_wechat_kf_flag"] = None
abm.self_id = open_kfid
abm.sender = MessageMember(external_userid, external_userid)
abm.session_id = f"{open_kfid}_{external_userid}"
abm.type = MessageType.FRIEND_MESSAGE
abm.message_id = msgid or uuid.uuid4().hex[:8]
abm.timestamp = int(msg.get("send_time", time.time()))
abm.message_str = ""
if msgtype == "text":
text = msg.get("text", {}).get("content", "").strip()
if self._is_duplicate_text_message(abm.session_id, text):
logger.debug(
"忽略 15 秒内重复微信客服文本消息 session_id=%s text=%s",
abm.session_id,
text,
)
return None
abm.message = [Plain(text=text)]
abm.message_str = text
elif msgtype == "image":
media_id = msg.get("image", {}).get("media_id", "")
path = await self._download_media(media_id, "wecom_kf_img", ".jpg")
abm.message = [Image(file=str(path), url=str(path))]
abm.message_str = "[图片]"
elif msgtype == "voice":
media_id = msg.get("voice", {}).get("media_id", "")
path = await self._download_media(media_id, "wecom_kf_voice", ".amr")
try:
wav_path = path.with_suffix(".wav")
converted_path = await convert_audio_to_wav(str(path), str(wav_path))
except Exception as e:
logger.error(
f"转换微信客服音频失败: {e}。如果没有安装 ffmpeg 请先安装。"
)
return None
abm.message = [Record(file=converted_path, url=converted_path)]
else:
logger.warning(f"未实现的微信客服消息事件: {msg}")
return None
await self.handle_msg(abm)
return abm
async def _download_media(self, media_id: str, prefix: str, suffix: str) -> Path:
resp: Response = await asyncio.get_running_loop().run_in_executor(
None,
self.client.media.download,
media_id,
)
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
path = temp_dir / f"{prefix}_{media_id or uuid.uuid4().hex}{suffix}"
path.write_bytes(resp.content)
return path
async def handle_msg(self, message: AstrBotMessage) -> None:
message_event = WecomPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
)
self.commit_event(message_event)
def get_client(self) -> WeChatClient:
return self.client
def get_stats(self) -> dict:
stat = super().get_stats()
stat["wecom_kf"] = {
"accounts": self._kf_accounts,
"contact_links": self._kf_contact_links,
"link_error": self._kf_link_error,
}
return stat
async def terminate(self) -> None:
self.server.shutdown_event.set()
try:
await self.server.server.shutdown()
except Exception:
pass
logger.info("微信客服适配器已被关闭")

View File

@@ -12,6 +12,7 @@ class PlatformAdapterType(enum.Flag):
QQOFFICIAL_WEBHOOK = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
WECOM_KF = enum.auto()
WECOM_AI_BOT = enum.auto()
LARK = enum.auto()
DINGTALK = enum.auto()
@@ -36,6 +37,7 @@ ADAPTER_NAME_2_TYPE = {
"qq_official_webhook": PlatformAdapterType.QQOFFICIAL_WEBHOOK,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"wecom_kf": PlatformAdapterType.WECOM_KF,
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,

View File

@@ -140,7 +140,7 @@ def _evaluate_send_message_tool(config: dict[str, Any]) -> list[dict[str, Any]]:
if not platform_type:
continue
if platform_type in {"wecom", "weixin_official_account"}:
if platform_type in {"wecom", "wecom_kf", "weixin_official_account"}:
continue
if platform_type == "wecom_ai_bot":

View File

@@ -8,6 +8,7 @@ from quart import request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform import Platform
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .route import Response, Route, RouteContext
@@ -41,6 +42,31 @@ class PlatformRoute(Route):
view_func=self.get_platform_stats,
methods=["GET"],
)
self.app.add_url_rule(
"/api/platform/wecom-kf/<platform_id>/accounts",
view_func=self.get_wecom_kf_accounts,
methods=["GET"],
)
self.app.add_url_rule(
"/api/platform/wecom-kf/<platform_id>/accounts",
view_func=self.add_wecom_kf_account,
methods=["POST"],
)
self.app.add_url_rule(
"/api/platform/wecom-kf/<platform_id>/accounts/<open_kfid>",
view_func=self.update_wecom_kf_account,
methods=["PUT"],
)
self.app.add_url_rule(
"/api/platform/wecom-kf/<platform_id>/accounts/<open_kfid>",
view_func=self.delete_wecom_kf_account,
methods=["DELETE"],
)
self.app.add_url_rule(
"/api/platform/wecom-kf/<platform_id>/avatar",
view_func=self.upload_wecom_kf_avatar,
methods=["POST"],
)
async def unified_webhook_callback(self, webhook_uuid: str):
"""统一 webhook 回调入口
@@ -98,3 +124,105 @@ class PlatformRoute(Route):
except Exception as e:
logger.error(f"获取平台统计信息失败: {e}", exc_info=True)
return Response().error(f"获取统计信息失败: {e}").__dict__, 500
def _find_wecom_kf_platform(self, platform_id: str) -> Platform | None:
for platform in self.platform_manager.platform_insts:
if platform.meta().id == platform_id and platform.meta().name == "wecom_kf":
return platform
return None
async def get_wecom_kf_accounts(self, platform_id: str):
platform = self._find_wecom_kf_platform(platform_id)
if not platform or not hasattr(platform, "get_kf_accounts_payload"):
return Response().error("未找到微信客服平台实例").__dict__, 404
try:
payload = await platform.get_kf_accounts_payload()
return Response().ok(payload).__dict__
except Exception as e:
logger.error(f"获取微信客服账号列表失败: {e}", exc_info=True)
return Response().error(f"获取微信客服账号列表失败: {e}").__dict__, 500
async def add_wecom_kf_account(self, platform_id: str):
platform = self._find_wecom_kf_platform(platform_id)
if not platform or not hasattr(platform, "add_kf_account"):
return Response().error("未找到微信客服平台实例").__dict__, 404
body = await request.get_json(silent=True) or {}
name = str(body.get("name", "")).strip()
media_id = str(body.get("media_id", "")).strip()
if not name:
return Response().error("客服名称不能为空").__dict__, 400
try:
payload = await platform.add_kf_account(name, media_id)
return Response().ok(payload, "微信客服账号已创建").__dict__
except Exception as e:
logger.error(f"创建微信客服账号失败: {e}", exc_info=True)
return Response().error(f"创建微信客服账号失败: {e}").__dict__, 500
async def update_wecom_kf_account(self, platform_id: str, open_kfid: str):
platform = self._find_wecom_kf_platform(platform_id)
if not platform or not hasattr(platform, "update_kf_account"):
return Response().error("未找到微信客服平台实例").__dict__, 404
body = await request.get_json(silent=True) or {}
name = str(body.get("name", "")).strip()
media_id = str(body.get("media_id", "")).strip()
if not name and not media_id:
return Response().error(
"客服名称和头像 media_id 不能同时为空"
).__dict__, 400
try:
payload = await platform.update_kf_account(open_kfid, name, media_id)
return Response().ok(payload, "微信客服账号已更新").__dict__
except Exception as e:
logger.error(f"更新微信客服账号失败: {e}", exc_info=True)
return Response().error(f"更新微信客服账号失败: {e}").__dict__, 500
async def delete_wecom_kf_account(self, platform_id: str, open_kfid: str):
platform = self._find_wecom_kf_platform(platform_id)
if not platform or not hasattr(platform, "delete_kf_account"):
return Response().error("未找到微信客服平台实例").__dict__, 404
try:
payload = await platform.delete_kf_account(open_kfid)
return Response().ok(payload, "微信客服账号已删除").__dict__
except Exception as e:
logger.error(f"删除微信客服账号失败: {e}", exc_info=True)
return Response().error(f"删除微信客服账号失败: {e}").__dict__, 500
async def upload_wecom_kf_avatar(self, platform_id: str):
platform = self._find_wecom_kf_platform(platform_id)
if not platform or not hasattr(platform, "upload_kf_avatar"):
return Response().error("未找到微信客服平台实例").__dict__, 404
files = await request.files
file = files.get("file")
if not file:
return Response().error("未上传头像文件").__dict__, 400
if file.mimetype and not file.mimetype.startswith("image/"):
return Response().error("头像文件必须是图片").__dict__, 400
from pathlib import Path
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
suffix = Path(file.filename or "").suffix or ".jpg"
target = temp_dir / f"wecom_kf_avatar_{platform_id}{suffix}"
try:
await file.save(target)
payload = await platform.upload_kf_avatar(target)
media_id = str(payload.get("media_id", "")).strip()
if not media_id:
return Response().error("微信客服头像上传未返回 media_id").__dict__, 500
return Response().ok({"media_id": media_id, "raw": payload}).__dict__
except Exception as e:
logger.error(f"上传微信客服头像失败: {e}", exc_info=True)
return Response().error(f"上传微信客服头像失败: {e}").__dict__, 500
finally:
try:
target.unlink(missing_ok=True)
except OSError:
pass

View File

@@ -130,6 +130,31 @@
"waiting": "Waiting for QR",
"close": "Close"
},
"wecomKf": {
"title": "WeCom Customer Service",
"manage": "Manage CS",
"name": "Account name",
"mediaId": "Avatar media_id (optional)",
"avatarFile": "Upload avatar",
"actions": "Actions",
"create": "Create",
"update": "Save",
"cancelEdit": "Cancel",
"close": "Close",
"loading": "Loading accounts",
"empty": "No customer service accounts",
"loadFailed": "Failed to load customer service accounts",
"saveFailed": "Failed to save customer service account",
"deleteFailed": "Failed to delete customer service account",
"nameRequired": "Account name is required",
"updateRequired": "Account name and avatar media_id cannot both be empty",
"createSuccess": "Customer service account created",
"updateSuccess": "Customer service account updated",
"deleteSuccess": "Customer service account deleted",
"avatarUploadSuccess": "Avatar uploaded and media_id filled",
"avatarUploadFailed": "Failed to upload avatar",
"deleteConfirm": "Delete customer service account {name}?"
},
"errorDialog": {
"title": "Error Details",
"platformId": "Platform ID",

View File

@@ -130,6 +130,31 @@
"waiting": "Ожидание QR",
"close": "Закрыть"
},
"wecomKf": {
"title": "WeCom Customer Service",
"manage": "Управлять",
"name": "Имя аккаунта",
"mediaId": "Avatar media_id (optional)",
"avatarFile": "Загрузить аватар",
"actions": "Действия",
"create": "Создать",
"update": "Сохранить",
"cancelEdit": "Отмена",
"close": "Закрыть",
"loading": "Загрузка аккаунтов",
"empty": "Нет аккаунтов",
"loadFailed": "Не удалось загрузить аккаунты",
"saveFailed": "Не удалось сохранить аккаунт",
"deleteFailed": "Не удалось удалить аккаунт",
"nameRequired": "Имя аккаунта обязательно",
"updateRequired": "Имя аккаунта и avatar media_id не могут быть пустыми одновременно",
"createSuccess": "Аккаунт создан",
"updateSuccess": "Аккаунт обновлен",
"deleteSuccess": "Аккаунт удален",
"avatarUploadSuccess": "Аватар загружен, media_id заполнен",
"avatarUploadFailed": "Не удалось загрузить аватар",
"deleteConfirm": "Удалить аккаунт {name}?"
},
"errorDialog": {
"title": "Детали ошибки",
"platformId": "ID платформы",

View File

@@ -130,6 +130,31 @@
"waiting": "等待二维码",
"close": "关闭"
},
"wecomKf": {
"title": "微信客服管理",
"manage": "管理客服",
"name": "客服名称",
"mediaId": "头像 media_id可选",
"avatarFile": "上传头像",
"actions": "操作",
"create": "新建",
"update": "保存修改",
"cancelEdit": "取消编辑",
"close": "关闭",
"loading": "正在加载客服列表",
"empty": "暂无客服账号",
"loadFailed": "加载微信客服账号失败",
"saveFailed": "保存微信客服账号失败",
"deleteFailed": "删除微信客服账号失败",
"nameRequired": "客服名称不能为空",
"updateRequired": "客服名称和头像 media_id 不能同时为空",
"createSuccess": "微信客服账号已创建",
"updateSuccess": "微信客服账号已更新",
"deleteSuccess": "微信客服账号已删除",
"avatarUploadSuccess": "头像已上传media_id 已填入",
"avatarUploadFailed": "上传头像失败",
"deleteConfirm": "确定要删除微信客服账号 {name} 吗?"
},
"errorDialog": {
"title": "错误详情",
"platformId": "平台 ID",

View File

@@ -14,7 +14,7 @@ export function getPlatformIcon(name) {
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
} else if (name === 'weixin_oc' || name === 'weixin_oc') {
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
} else if (name === 'wecom' || name === 'wecom_ai_bot') {
} else if (name === 'wecom' || name === 'wecom_kf' || name === 'wecom_ai_bot') {
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
} else if (name === 'weixin_official_account') {
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
@@ -56,6 +56,7 @@ export function getTutorialLink(platformType) {
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp.html",
"wecom": "https://docs.astrbot.app/platform/wecom.html",
"wecom_kf": "https://docs.astrbot.app/platform/wecom.html",
"weixin_oc": "https://docs.astrbot.app/platform/weixin_oc.html",
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",
"lark": "https://docs.astrbot.app/platform/lark.html",
@@ -101,6 +102,7 @@ export function getPlatformDisplayName(platformId) {
qq_official: 'qq_official (QQ 官方机器人平台)',
weixin_official_account: 'weixin_official_account (微信公众号)',
wecom: 'wecom (企业微信应用)',
wecom_kf: 'wecom_kf (微信客服)',
wecom_ai_bot: 'wecom_ai_bot (企业微信智能机器人)',
lark: 'lark (飞书)',
dingtalk: 'dingtalk (钉钉)',

View File

@@ -72,6 +72,21 @@
{{ tm('platformQr.show') }}
</v-chip>
</div>
<div
class="platform-qr-chip"
v-if="item.type === 'wecom_kf' && getPlatformStat(item.id)"
>
<v-chip
size="small"
color="primary"
variant="tonal"
class="platform-qr-chip-item"
@click.stop="openWecomKfDialog(item.id)"
>
<v-icon size="small" start>mdi-robot</v-icon>
{{ tm('wecomKf.manage') }}
</v-chip>
</div>
<div v-if="getPlatformStat(item.id)?.unified_webhook && item.webhook_uuid" class="webhook-info">
<v-chip
size="small"
@@ -160,11 +175,26 @@
{{ tm('platformQr.title') }}
</v-card-title>
<v-card-text class="px-4 pb-4">
<div class="platform-qr-status">
<div v-if="getPlatformQrLoginStat(currentQrPlatformId)?.qr_status" class="platform-qr-status">
{{ tm('platformQr.status') }}: {{ getPlatformQrLoginStat(currentQrPlatformId)?.qr_status || tm('platformQr.waiting') }}
</div>
<div v-if="getPlatformQrLoginStat(currentQrPlatformId)?.contact_links" class="platform-qr-list">
<div
v-for="item in getPlatformQrItems(currentQrPlatformId)"
:key="item.open_kfid || item.qrcode_img_content || item.qrcode"
class="platform-qr-list-item"
>
<div class="platform-qr-name">{{ item.name || item.open_kfid || tm('platformQr.title') }}</div>
<QrCodeViewer
:value="(item.qrcode_img_content || item.qrcode || '')"
:alt="item.name || tm('platformQr.title')"
:size="180"
/>
</div>
</div>
<QrCodeViewer
:value="(getPlatformQrLoginStat(currentQrPlatformId)?.qrcode_img_content || getPlatformQrLoginStat(currentQrPlatformId)?.qrcode || '')"
v-else
:value="(getPlatformQrItems(currentQrPlatformId)[0]?.qrcode_img_content || getPlatformQrItems(currentQrPlatformId)[0]?.qrcode || '')"
:alt="tm('platformQr.title')"
/>
</v-card-text>
@@ -177,6 +207,95 @@
</v-card>
</v-dialog>
<v-dialog v-model="showWecomKfDialog" max-width="760">
<v-card>
<v-card-title class="d-flex align-center pa-4">
<v-icon class="me-2">mdi-robot</v-icon>
{{ tm('wecomKf.title') }}
<v-spacer></v-spacer>
<v-btn icon variant="text" :loading="wecomKfLoading" @click="loadWecomKfAccounts">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="px-4 pb-4">
<div class="wecom-kf-form">
<v-text-field
v-model="wecomKfForm.name"
:label="tm('wecomKf.name')"
variant="outlined"
density="compact"
hide-details
/>
<v-file-input
v-model="wecomKfAvatarFile"
:label="tm('wecomKf.avatarFile')"
accept="image/*"
variant="outlined"
density="compact"
hide-details
prepend-icon=""
prepend-inner-icon="mdi-robot"
:loading="wecomKfAvatarUploading"
@update:model-value="uploadWecomKfAvatar"
/>
<div class="wecom-kf-actions">
<v-btn variant="tonal" @click="resetWecomKfForm" :disabled="wecomKfSaving">
{{ tm('wecomKf.cancelEdit') }}
</v-btn>
<v-btn color="primary" @click="saveWecomKfAccount" :loading="wecomKfSaving">
{{ wecomKfForm.open_kfid ? tm('wecomKf.update') : tm('wecomKf.create') }}
</v-btn>
</div>
</div>
<v-alert
v-if="wecomKfError"
type="error"
variant="tonal"
density="compact"
class="mt-4"
>
{{ wecomKfError }}
</v-alert>
<v-table class="mt-4" density="comfortable">
<thead>
<tr>
<th>{{ tm('wecomKf.name') }}</th>
<th>open_kfid</th>
<th class="text-right">{{ tm('wecomKf.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="wecomKfAccounts.length === 0">
<td colspan="3" class="text-center text-medium-emphasis py-6">
{{ wecomKfLoading ? tm('wecomKf.loading') : tm('wecomKf.empty') }}
</td>
</tr>
<tr v-for="account in wecomKfAccounts" :key="account.open_kfid">
<td>{{ account.name || '-' }}</td>
<td class="wecom-kf-openid">{{ account.open_kfid }}</td>
<td class="text-right">
<v-btn icon variant="text" size="small" @click="editWecomKfAccount(account)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon variant="text" size="small" color="error" @click="deleteWecomKfAccount(account)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer></v-spacer>
<v-btn variant="tonal" color="primary" @click="showWecomKfDialog = false">
{{ tm('wecomKf.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 错误详情对话框 -->
<v-dialog v-model="showErrorDialog" max-width="700">
<v-card>
@@ -292,6 +411,19 @@ export default {
currentErrorPlatform: null,
showQrDialog: false,
currentQrPlatformId: "",
showWecomKfDialog: false,
currentWecomKfPlatformId: "",
wecomKfAccounts: [],
wecomKfLoading: false,
wecomKfSaving: false,
wecomKfAvatarUploading: false,
wecomKfAvatarFile: null,
wecomKfError: "",
wecomKfForm: {
open_kfid: "",
name: "",
media_id: "",
},
store: useCommonStore()
}
@@ -390,8 +522,21 @@ export default {
},
hasQrPayload(platformId) {
return this.getPlatformQrItems(platformId).length > 0;
},
getPlatformQrItems(platformId) {
const stat = this.getPlatformQrLoginStat(platformId);
return Boolean(stat?.qrcode_img_content || stat?.qrcode);
if (!stat) {
return [];
}
if (Array.isArray(stat.contact_links)) {
return stat.contact_links.filter((item) => item?.qrcode_img_content || item?.qrcode);
}
if (stat.qrcode_img_content || stat.qrcode) {
return [stat];
}
return [];
},
getPlatformQrLoginStat(platformId) {
@@ -399,9 +544,12 @@ export default {
if (stat?.weixin_oc) {
return stat.weixin_oc;
}
if (stat?.wecom_kf) {
return stat.wecom_kf;
}
if (stat && typeof stat === "object") {
for (const value of Object.values(stat)) {
if (value && typeof value === "object" && ("qrcode_img_content" in value || "qrcode" in value)) {
if (value && typeof value === "object" && ("qrcode_img_content" in value || "qrcode" in value || "contact_links" in value)) {
return value;
}
}
@@ -414,6 +562,144 @@ export default {
this.showQrDialog = true;
},
openWecomKfDialog(platformId) {
this.currentWecomKfPlatformId = platformId;
this.showWecomKfDialog = true;
this.resetWecomKfForm();
this.loadWecomKfAccounts();
},
resetWecomKfForm() {
this.wecomKfForm = {
open_kfid: "",
name: "",
media_id: "",
};
this.wecomKfAvatarFile = null;
this.wecomKfError = "";
},
async loadWecomKfAccounts() {
if (!this.currentWecomKfPlatformId) {
return;
}
this.wecomKfLoading = true;
this.wecomKfError = "";
try {
const platformId = encodeURIComponent(this.currentWecomKfPlatformId);
const res = await axios.get(`/api/platform/wecom-kf/${platformId}/accounts`);
if (res.data.status === 'ok') {
this.wecomKfAccounts = res.data.data.accounts || [];
await this.getPlatformStats();
} else {
this.wecomKfError = res.data.message || this.tm('wecomKf.loadFailed');
}
} catch (err) {
this.wecomKfError = err.response?.data?.message || err.message || this.tm('wecomKf.loadFailed');
} finally {
this.wecomKfLoading = false;
}
},
editWecomKfAccount(account) {
this.wecomKfForm = {
open_kfid: account.open_kfid || "",
name: account.name || "",
media_id: "",
};
this.wecomKfError = "";
},
async uploadWecomKfAvatar(fileValue) {
const file = Array.isArray(fileValue) ? fileValue[0] : fileValue;
if (!file) {
return;
}
this.wecomKfAvatarUploading = true;
this.wecomKfError = "";
try {
const platformId = encodeURIComponent(this.currentWecomKfPlatformId);
const formData = new FormData();
formData.append('file', file);
const res = await axios.post(`/api/platform/wecom-kf/${platformId}/avatar`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
const mediaId = res.data?.data?.media_id;
if (mediaId) {
this.wecomKfForm.media_id = mediaId;
this.showSuccess(this.tm('wecomKf.avatarUploadSuccess'));
} else {
this.wecomKfError = this.tm('wecomKf.avatarUploadFailed');
}
} catch (err) {
this.wecomKfError = err.response?.data?.message || err.message || this.tm('wecomKf.avatarUploadFailed');
} finally {
this.wecomKfAvatarUploading = false;
}
},
async saveWecomKfAccount() {
const name = String(this.wecomKfForm.name || "").trim();
const mediaId = String(this.wecomKfForm.media_id || "").trim();
if (!this.wecomKfForm.open_kfid && !name) {
this.wecomKfError = this.tm('wecomKf.nameRequired');
return;
}
if (this.wecomKfForm.open_kfid && !name && !mediaId) {
this.wecomKfError = this.tm('wecomKf.updateRequired');
return;
}
this.wecomKfSaving = true;
this.wecomKfError = "";
try {
const platformId = encodeURIComponent(this.currentWecomKfPlatformId);
const payload = { name, media_id: mediaId };
if (this.wecomKfForm.open_kfid) {
const openKfid = encodeURIComponent(this.wecomKfForm.open_kfid);
await axios.put(
`/api/platform/wecom-kf/${platformId}/accounts/${openKfid}`,
payload
);
this.showSuccess(this.tm('wecomKf.updateSuccess'));
} else {
await axios.post(`/api/platform/wecom-kf/${platformId}/accounts`, payload);
this.showSuccess(this.tm('wecomKf.createSuccess'));
}
this.resetWecomKfForm();
await this.loadWecomKfAccounts();
} catch (err) {
this.wecomKfError = err.response?.data?.message || err.message || this.tm('wecomKf.saveFailed');
} finally {
this.wecomKfSaving = false;
}
},
async deleteWecomKfAccount(account) {
const name = account.name || account.open_kfid;
const message = this.tm('wecomKf.deleteConfirm', { name });
if (!(await askForConfirmationDialog(message, this.confirmDialog))) {
return;
}
this.wecomKfSaving = true;
this.wecomKfError = "";
try {
const platformId = encodeURIComponent(this.currentWecomKfPlatformId);
const openKfid = encodeURIComponent(account.open_kfid);
await axios.delete(`/api/platform/wecom-kf/${platformId}/accounts/${openKfid}`);
this.showSuccess(this.tm('wecomKf.deleteSuccess'));
if (this.wecomKfForm.open_kfid === account.open_kfid) {
this.resetWecomKfForm();
}
await this.loadWecomKfAccounts();
} catch (err) {
this.wecomKfError = err.response?.data?.message || err.message || this.tm('wecomKf.deleteFailed');
} finally {
this.wecomKfSaving = false;
}
},
getStatusColor(status) {
switch (status) {
case 'running': return 'success';
@@ -697,4 +983,55 @@ export default {
margin-bottom: 10px;
color: rgba(0, 0, 0, 0.7);
}
.platform-qr-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.platform-qr-list-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.platform-qr-name {
max-width: 100%;
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
text-align: center;
}
.wecom-kf-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(180px, 1fr) auto;
gap: 12px;
align-items: start;
}
.wecom-kf-actions {
display: flex;
gap: 8px;
align-items: center;
}
.wecom-kf-openid {
max-width: 260px;
overflow-wrap: anywhere;
font-family: monospace;
font-size: 12px;
}
@media (max-width: 700px) {
.wecom-kf-form {
grid-template-columns: 1fr;
}
.wecom-kf-actions {
justify-content: flex-end;
}
}
</style>