diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b0d4e3c351..feb5c0c769 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -7,6 +7,38 @@ VERSION = "4.21.0" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") +PERSONAL_WECHAT_CONFIG_METADATA = { + "weixin_oc_base_url": { + "description": "Base URL", + "type": "string", + "hint": "默认值: https://ilinkai.weixin.qq.com", + }, + "weixin_oc_bot_type": { + "description": "扫码参数 bot_type", + "type": "string", + "hint": "默认值: 3", + }, + "weixin_oc_qr_poll_interval": { + "description": "二维码状态轮询间隔(秒)", + "type": "int", + "hint": "每隔多少秒轮询一次二维码状态。", + }, + "weixin_oc_long_poll_timeout_ms": { + "description": "getUpdates 长轮询超时时间(毫秒)", + "type": "int", + "hint": "会话消息拉取接口超时参数。", + }, + "weixin_oc_api_timeout_ms": { + "description": "HTTP 请求超时(毫秒)", + "type": "int", + "hint": "通用 API 请求超时参数。", + }, + "weixin_oc_token": { + "description": "登录后 token(可留空)", + "type": "string", + "hint": "扫码登录成功后会自动写入;高级场景可手动填写。", + }, +} WEBHOOK_SUPPORTED_PLATFORMS = [ "qq_official_webhook", @@ -364,6 +396,16 @@ class ChatProviderTemplate(TypedDict): "callback_server_host": "0.0.0.0", "port": 6198, }, + "个人微信": { + "id": "weixin_personal", + "type": "weixin_oc", + "enable": False, + "weixin_oc_base_url": "https://ilinkai.weixin.qq.com", + "weixin_oc_bot_type": "3", + "weixin_oc_qr_poll_interval": 1, + "weixin_oc_long_poll_timeout_ms": 35_000, + "weixin_oc_api_timeout_ms": 15_000, + }, "飞书(Lark)": { "id": "lark", "type": "lark", @@ -869,6 +911,7 @@ class ChatProviderTemplate(TypedDict): "type": "bool", "hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。", }, + **PERSONAL_WECHAT_CONFIG_METADATA, "webhook_uuid": { "invisible": True, "description": "Webhook UUID", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 68737b2bcf..15c04166dc 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -170,6 +170,10 @@ async def load_platform(self, platform_config: dict) -> None: from .sources.misskey.misskey_adapter import ( MisskeyPlatformAdapter, # noqa: F401 ) + case "weixin_oc": + from .sources.weixin_oc.weixin_oc_adapter import ( + WeixinOCAdapter, # noqa: F401 + ) case "slack": from .sources.slack.slack_adapter import SlackAdapter # noqa: F401 case "satori": diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py new file mode 100644 index 0000000000..b9caa0b093 --- /dev/null +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -0,0 +1,926 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import io +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import quote + +import qrcode as qrcode_lib + +from astrbot import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import File, Image, Plain, Record, Video +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + Platform, + PlatformMetadata, + register_platform_adapter, +) +from astrbot.core import astrbot_config +from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + +from .weixin_oc_client import WeixinOCClient +from .weixin_oc_event import WeixinOCMessageEvent + +if TYPE_CHECKING: # pragma: no cover - typing-only helper + pass + + +@dataclass +class OpenClawLoginSession: + session_key: str + qrcode: str + qrcode_img_content: str + started_at: float + status: str = "wait" + bot_token: str | None = None + account_id: str | None = None + base_url: str | None = None + user_id: str | None = None + error: str | None = None + + +@register_platform_adapter( + "weixin_oc", + "个人微信", + support_streaming_message=False, +) +class WeixinOCAdapter(Platform): + IMAGE_ITEM_TYPE = 2 + VOICE_ITEM_TYPE = 3 + FILE_ITEM_TYPE = 4 + VIDEO_ITEM_TYPE = 5 + IMAGE_UPLOAD_TYPE = 1 + VIDEO_UPLOAD_TYPE = 2 + FILE_UPLOAD_TYPE = 3 + + 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.base_url = str( + platform_config.get("weixin_oc_base_url", "https://ilinkai.weixin.qq.com") + ).rstrip("/") + self.bot_type = str(platform_config.get("weixin_oc_bot_type", "3")) + self.qr_poll_interval = max( + 1, + int(platform_config.get("weixin_oc_qr_poll_interval", 1)), + ) + self.long_poll_timeout_ms = int( + platform_config.get("weixin_oc_long_poll_timeout_ms", 35_000), + ) + self.api_timeout_ms = int( + platform_config.get("weixin_oc_api_timeout_ms", 15_000), + ) + self.cdn_base_url = str( + platform_config.get( + "weixin_oc_cdn_base_url", + "https://novac2c.cdn.weixin.qq.com/c2c", + ) + ).rstrip("/") + + self.metadata = PlatformMetadata( + name="weixin_oc", + description="个人微信", + id=cast(str, self.config.get("id", "weixin_oc")), + support_streaming_message=False, + ) + + self._shutdown_event = asyncio.Event() + self._login_session: OpenClawLoginSession | None = None + self._sync_buf = "" + self._qr_expired_count = 0 + self._context_tokens: dict[str, str] = {} + self._last_inbound_error = "" + + self.token = str(platform_config.get("weixin_oc_token", "")).strip() or None + self.account_id = ( + str(platform_config.get("weixin_oc_account_id", "")).strip() or None + ) + self._load_account_state() + self.client = WeixinOCClient( + adapter_id=self.meta().id, + base_url=self.base_url, + cdn_base_url=self.cdn_base_url, + api_timeout_ms=self.api_timeout_ms, + token=self.token, + ) + + if self.token: + logger.info( + "weixin_oc adapter %s loaded with token from config.", + self.meta().id, + ) + + def _sync_client_state(self) -> None: + self.client.base_url = self.base_url + self.client.cdn_base_url = self.cdn_base_url + self.client.api_timeout_ms = self.api_timeout_ms + self.client.token = self.token + + def _load_account_state(self) -> None: + if not self.token: + token = str(self.config.get("weixin_oc_token", "")).strip() + if token: + self.token = token + if not self.account_id: + account_id = str(self.config.get("weixin_oc_account_id", "")).strip() + if account_id: + self.account_id = account_id + sync_buf = str(self.config.get("weixin_oc_sync_buf", "")).strip() + if sync_buf: + self._sync_buf = sync_buf + saved_base = str(self.config.get("weixin_oc_base_url", "")).strip() + if saved_base: + self.base_url = saved_base.rstrip("/") + + async def _save_account_state(self) -> None: + self.config["weixin_oc_token"] = self.token or "" + self.config["weixin_oc_account_id"] = self.account_id or "" + self.config["weixin_oc_sync_buf"] = self._sync_buf + self.config["weixin_oc_base_url"] = self.base_url + + for platform in astrbot_config.get("platform", []): + if not isinstance(platform, dict): + continue + if platform.get("id") != self.config.get("id"): + continue + if platform.get("type") != self.config.get("type"): + continue + platform["weixin_oc_token"] = self.token or "" + platform["weixin_oc_account_id"] = self.account_id or "" + platform["weixin_oc_sync_buf"] = self._sync_buf + platform["weixin_oc_base_url"] = self.base_url + break + + self._sync_client_state() + astrbot_config.save_config() + + def _is_login_session_valid( + self, login_session: OpenClawLoginSession | None + ) -> bool: + if not login_session: + return False + return (time.time() - login_session.started_at) * 1000 < 5 * 60_000 + + def _resolve_inbound_media_dir(self) -> Path: + media_dir = Path(get_astrbot_temp_path()) + media_dir.mkdir(parents=True, exist_ok=True) + return media_dir + + @staticmethod + def _normalize_inbound_filename(file_name: str, fallback_name: str) -> str: + normalized = Path(file_name or "").name.strip() + return normalized or fallback_name + + def _save_inbound_media( + self, + content: bytes, + *, + prefix: str, + file_name: str, + fallback_suffix: str, + ) -> Path: + normalized_name = self._normalize_inbound_filename( + file_name, + f"{prefix}{fallback_suffix}", + ) + stem = Path(normalized_name).stem or prefix + suffix = Path(normalized_name).suffix or fallback_suffix + target = ( + self._resolve_inbound_media_dir() + / f"{prefix}_{uuid.uuid4().hex}_{stem}{suffix}" + ) + target.write_bytes(content) + return target + + @staticmethod + def _build_plain_text_item(text: str) -> dict[str, Any]: + return { + "type": 1, + "text_item": { + "text": text, + }, + } + + async def _prepare_media_item( + self, + user_id: str, + media_path: Path, + upload_media_type: int, + item_type: int, + file_name: str, + ) -> dict[str, Any]: + raw_bytes = media_path.read_bytes() + raw_size = len(raw_bytes) + raw_md5 = hashlib.md5(raw_bytes).hexdigest() + file_key = uuid.uuid4().hex + aes_key_hex = uuid.uuid4().bytes.hex() + ciphertext_size = self.client.aes_padded_size(raw_size) + + payload = await self.client.request_json( + "POST", + "ilink/bot/getuploadurl", + payload={ + "filekey": file_key, + "media_type": upload_media_type, + "to_user_id": user_id, + "rawsize": raw_size, + "rawfilemd5": raw_md5, + "filesize": ciphertext_size, + "no_need_thumb": True, + "aeskey": aes_key_hex, + "base_info": { + "channel_version": "astrbot", + }, + }, + token_required=True, + timeout_ms=self.api_timeout_ms, + ) + logger.debug( + "weixin_oc(%s): getuploadurl response user=%s media_type=%s raw_size=%s raw_md5=%s filekey=%s file=%s upload_param_len=%s", + self.meta().id, + user_id, + upload_media_type, + raw_size, + raw_md5, + file_key, + media_path.name, + len(str(payload.get("upload_param", ""))), + ) + upload_param = str(payload.get("upload_param", "")).strip() + if not upload_param: + raise RuntimeError("getuploadurl returned empty upload_param") + + encrypted_query_param = await self.client.upload_to_cdn( + upload_param, + file_key, + aes_key_hex, + media_path, + ) + logger.debug( + "weixin_oc(%s): prepared media item type=%s file=%s user=%s mid_size=%s upload_param_len=%s query_len=%s", + self.meta().id, + item_type, + media_path.name, + user_id, + ciphertext_size, + len(upload_param), + len(encrypted_query_param), + ) + + aes_key_b64 = base64.b64encode(aes_key_hex.encode("utf-8")).decode("utf-8") + media_payload = { + "encrypt_query_param": encrypted_query_param, + "aes_key": aes_key_b64, + "encrypt_type": 1, + } + + if item_type == self.IMAGE_ITEM_TYPE: + return { + "type": self.IMAGE_ITEM_TYPE, + "image_item": { + "media": media_payload, + "mid_size": ciphertext_size, + }, + } + if item_type == self.VIDEO_ITEM_TYPE: + return { + "type": self.VIDEO_ITEM_TYPE, + "video_item": { + "media": media_payload, + "video_size": ciphertext_size, + }, + } + + file_len = str(raw_size) + return { + "type": self.FILE_ITEM_TYPE, + "file_item": { + "media": media_payload, + "file_name": file_name, + "len": file_len, + }, + } + + async def _resolve_inbound_media_component( + self, + item: dict[str, Any], + ) -> Image | Video | File | Record | None: + item_type = int(item.get("type") or 0) + + if item_type == self.IMAGE_ITEM_TYPE: + image_item = cast(dict[str, Any], item.get("image_item", {}) or {}) + media = cast(dict[str, Any], image_item.get("media", {}) or {}) + encrypted_query_param = str(media.get("encrypt_query_param", "")).strip() + if not encrypted_query_param: + return None + image_aes_key = str(image_item.get("aeskey", "")).strip() + if image_aes_key: + aes_key_value = base64.b64encode(bytes.fromhex(image_aes_key)).decode( + "utf-8" + ) + else: + aes_key_value = str(media.get("aes_key", "")).strip() + if aes_key_value: + content = await self.client.download_and_decrypt_media( + encrypted_query_param, + aes_key_value, + ) + else: + content = await self.client.download_cdn_bytes(encrypted_query_param) + image_path = self._save_inbound_media( + content, + prefix="weixin_oc_img", + file_name="image.jpg", + fallback_suffix=".jpg", + ) + return Image.fromFileSystem(str(image_path)) + + if item_type == self.VIDEO_ITEM_TYPE: + video_item = cast(dict[str, Any], item.get("video_item", {}) or {}) + media = cast(dict[str, Any], video_item.get("media", {}) or {}) + encrypted_query_param = str(media.get("encrypt_query_param", "")).strip() + aes_key_value = str(media.get("aes_key", "")).strip() + if not encrypted_query_param or not aes_key_value: + return None + content = await self.client.download_and_decrypt_media( + encrypted_query_param, + aes_key_value, + ) + video_path = self._save_inbound_media( + content, + prefix="weixin_oc_video", + file_name="video.mp4", + fallback_suffix=".mp4", + ) + return Video.fromFileSystem(str(video_path)) + + if item_type == self.FILE_ITEM_TYPE: + file_item = cast(dict[str, Any], item.get("file_item", {}) or {}) + media = cast(dict[str, Any], file_item.get("media", {}) or {}) + encrypted_query_param = str(media.get("encrypt_query_param", "")).strip() + aes_key_value = str(media.get("aes_key", "")).strip() + if not encrypted_query_param or not aes_key_value: + return None + file_name = self._normalize_inbound_filename( + str(file_item.get("file_name", "")).strip(), + "file.bin", + ) + content = await self.client.download_and_decrypt_media( + encrypted_query_param, + aes_key_value, + ) + file_path = self._save_inbound_media( + content, + prefix="weixin_oc_file", + file_name=file_name, + fallback_suffix=".bin", + ) + return File(name=file_name, file=str(file_path)) + + if item_type == self.VOICE_ITEM_TYPE: + voice_item = cast(dict[str, Any], item.get("voice_item", {}) or {}) + media = cast(dict[str, Any], voice_item.get("media", {}) or {}) + encrypted_query_param = str(media.get("encrypt_query_param", "")).strip() + aes_key_value = str(media.get("aes_key", "")).strip() + if not encrypted_query_param or not aes_key_value: + return None + content = await self.client.download_and_decrypt_media( + encrypted_query_param, + aes_key_value, + ) + voice_path = self._save_inbound_media( + content, + prefix="weixin_oc_voice", + file_name="voice.silk", + fallback_suffix=".silk", + ) + return Record.fromFileSystem(str(voice_path)) + + return None + + async def _resolve_media_file_path( + self, segment: Image | Video | File + ) -> Path | None: + try: + if isinstance(segment, File): + path = await segment.get_file() + elif isinstance(segment, (Image, Video)): + path = await segment.convert_to_file_path() + else: + path = "" + except Exception as e: + logger.warning("weixin_oc(%s): media resolve failed: %s", self.meta().id, e) + return None + + if not path: + return None + media_path = Path(path) + if not media_path.exists() or not media_path.is_file(): + return None + return media_path + + async def _send_items_to_session( + self, + user_id: str, + item_list: list[dict[str, Any]], + ) -> bool: + if not self.token: + logger.warning("weixin_oc(%s): missing token, skip send", self.meta().id) + return False + if not item_list: + logger.warning( + "weixin_oc(%s): empty message payload is ignored", + self.meta().id, + ) + return False + context_token = self._context_tokens.get(user_id) + if not context_token: + logger.warning( + "weixin_oc(%s): context token missing for %s, skip send", + self.meta().id, + user_id, + ) + return False + await self.client.request_json( + "POST", + "ilink/bot/sendmessage", + payload={ + "base_info": { + "channel_version": "astrbot", + }, + "msg": { + "from_user_id": "", + "to_user_id": user_id, + "client_id": uuid.uuid4().hex, + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": item_list, + }, + }, + token_required=True, + headers={}, + ) + return True + + async def _send_media_segment( + self, + user_id: str, + segment: Image | Video | File, + text: str | None = None, + ) -> bool: + if not self.token: + logger.warning( + "weixin_oc(%s): missing token, skip media send", self.meta().id + ) + return False + media_path = await self._resolve_media_file_path(segment) + if media_path is None: + logger.warning( + "weixin_oc(%s): skip media segment, media file not resolvable", + self.meta().id, + ) + return False + + item_type = self.IMAGE_ITEM_TYPE + upload_media_type = self.IMAGE_UPLOAD_TYPE + if isinstance(segment, Video): + item_type = self.VIDEO_ITEM_TYPE + upload_media_type = self.VIDEO_UPLOAD_TYPE + elif isinstance(segment, File): + item_type = self.FILE_ITEM_TYPE + upload_media_type = self.FILE_UPLOAD_TYPE + + file_name = ( + segment.name + if isinstance(segment, File) and segment.name + else media_path.name + ) + try: + media_item = await self._prepare_media_item( + user_id, + media_path, + upload_media_type, + item_type, + file_name, + ) + except Exception as e: + logger.error("weixin_oc(%s): prepare media failed: %s", self.meta().id, e) + return False + + if text: + await self._send_items_to_session( + user_id, + [self._build_plain_text_item(text)], + ) + return await self._send_items_to_session(user_id, [media_item]) + + async def _start_login_session(self) -> OpenClawLoginSession: + endpoint = "ilink/bot/get_bot_qrcode" + params = {"bot_type": self.bot_type} + logger.info("weixin_oc(%s): request QR code from %s", self.meta().id, endpoint) + data = await self.client.request_json( + "GET", + endpoint, + params=params, + token_required=False, + timeout_ms=15_000, + ) + qrcode = str(data.get("qrcode", "")).strip() + qrcode_url = str(data.get("qrcode_img_content", "")).strip() + if not qrcode or not qrcode_url: + raise RuntimeError("qrcode response missing qrcode or qrcode_img_content") + qr_console_url = ( + f"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=" + f"{quote(qrcode_url)}" + ) + logger.info( + "weixin_oc(%s): QR session started, qr_link=%s 请使用手机微信扫码登录,二维码有效期 5 分钟,过期后会自动刷新。", + self.meta().id, + qr_console_url, + ) + try: + qr = qrcode_lib.QRCode(border=1) + qr.add_data(qrcode_url) + qr.make(fit=True) + qr_buffer = io.StringIO() + qr.print_ascii(out=qr_buffer, tty=False) + logger.info( + "weixin_oc(%s): terminal QR code:\n%s", + self.meta().id, + qr_buffer.getvalue(), + ) + except Exception as e: + logger.warning( + "weixin_oc(%s): failed to render terminal QR code: %s", + self.meta().id, + e, + ) + login_session = OpenClawLoginSession( + session_key=str(uuid.uuid4()), + qrcode=qrcode, + qrcode_img_content=qrcode_url, + started_at=time.time(), + ) + self._login_session = login_session + self._qr_expired_count = 0 + self._last_inbound_error = "" + return login_session + + async def _poll_qr_status(self, login_session: OpenClawLoginSession) -> None: + endpoint = "ilink/bot/get_qrcode_status" + logger.debug("weixin_oc(%s): poll qrcode status", self.meta().id) + data = await self.client.request_json( + "GET", + endpoint, + params={"qrcode": login_session.qrcode}, + token_required=False, + timeout_ms=self.long_poll_timeout_ms, + headers={"iLink-App-ClientVersion": "1"}, + ) + status = str(data.get("status", "wait")).strip() + login_session.status = status + if status == "expired": + self._qr_expired_count += 1 + if self._qr_expired_count > 3: + login_session.error = "二维码已过期,超过重试次数,等待下次重试" + self._login_session = None + return + logger.warning( + "weixin_oc(%s): qr expired, refreshing (%s/%s)", + self.meta().id, + self._qr_expired_count, + 3, + ) + new_session = await self._start_login_session() + self._login_session = new_session + return + + if status == "confirmed": + bot_token = data.get("bot_token") + account_id = data.get("ilink_bot_id") + base_url = data.get("baseurl") + user_id = data.get("ilink_user_id") + if not bot_token: + login_session.error = "登录返回成功但未返回 bot_token" + return + login_session.bot_token = str(bot_token) + login_session.account_id = str(account_id) if account_id else None + login_session.base_url = str(base_url) if base_url else self.base_url + login_session.user_id = str(user_id) if user_id else None + self.token = login_session.bot_token + self.account_id = login_session.account_id + if login_session.base_url: + self.base_url = login_session.base_url.rstrip("/") + await self._save_account_state() + + def _message_text_from_item_list( + self, item_list: list[dict[str, Any]] | None + ) -> str: + if not item_list: + return "" + text_parts: list[str] = [] + for item in item_list: + item_type = int(item.get("type") or 0) + if item_type == 1: + text = str(item.get("text_item", {}).get("text", "")).strip() + if text: + text_parts.append(text) + elif item_type == 2: + text_parts.append("[图片]") + elif item_type == 3: + voice_text = str(item.get("voice_item", {}).get("text", "")).strip() + if voice_text: + text_parts.append(voice_text) + else: + text_parts.append("[语音]") + elif item_type == 4: + text_parts.append("[文件]") + elif item_type == 5: + text_parts.append("[视频]") + else: + ref = item.get("ref_msg") + if isinstance(ref, dict): + ref_item = ref.get("message_item") + if isinstance(ref_item, dict): + ref_text = str(self._message_text_from_item_list([ref_item])) + if ref_text: + text_parts.append(f"[引用:{ref_text}]") + return "\n".join(text_parts).strip() + + async def _item_list_to_components( + self, item_list: list[dict[str, Any]] | None + ) -> list[Any]: + if not item_list: + return [] + parts: list[Any] = [] + for item in item_list: + item_type = int(item.get("type") or 0) + if item_type == 1: + text = str(item.get("text_item", {}).get("text", "")).strip() + if text: + parts.append(Plain(text)) + continue + try: + media_component = await self._resolve_inbound_media_component(item) + except Exception as e: + logger.warning( + "weixin_oc(%s): resolve inbound media failed: %s", + self.meta().id, + e, + ) + media_component = None + if media_component is not None: + parts.append(media_component) + return parts + + async def _handle_inbound_message(self, msg: dict[str, Any]) -> None: + from_user_id = str(msg.get("from_user_id", "")).strip() + if not from_user_id: + logger.debug("weixin_oc: skip message with empty from_user_id.") + return + + context_token = str(msg.get("context_token", "")).strip() + if context_token: + self._context_tokens[from_user_id] = context_token + + item_list = cast(list[dict[str, Any]], msg.get("item_list", [])) + components = await self._item_list_to_components(item_list) + text = self._message_text_from_item_list(item_list) + message_id = str(msg.get("message_id") or msg.get("msg_id") or uuid.uuid4().hex) + create_time = msg.get("create_time_ms") or msg.get("create_time") + if isinstance(create_time, (int, float)) and create_time > 1_000_000_000_000: + ts = int(float(create_time) / 1000) + elif isinstance(create_time, (int, float)): + ts = int(create_time) + else: + ts = int(time.time()) + + abm = AstrBotMessage() + abm.self_id = self.meta().id + abm.sender = MessageMember(user_id=from_user_id, nickname=from_user_id) + abm.type = MessageType.FRIEND_MESSAGE + abm.session_id = from_user_id + abm.message_id = message_id + abm.message = components + abm.message_str = text + abm.timestamp = ts + abm.raw_message = msg + + self.commit_event( + WeixinOCMessageEvent( + message_str=text, + message_obj=abm, + platform_meta=self.meta(), + session_id=abm.session_id, + platform=self, + ) + ) + + async def _poll_inbound_updates(self) -> None: + data = await self.client.request_json( + "POST", + "ilink/bot/getupdates", + payload={ + "base_info": { + "channel_version": "astrbot", + }, + "get_updates_buf": self._sync_buf, + }, + token_required=True, + timeout_ms=self.long_poll_timeout_ms, + ) + ret = int(data.get("ret") or 0) + errcode = data.get("errcode", 0) + if ret != 0 and ret is not None: + errmsg = str(data.get("errmsg", "")) + self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}" + logger.warning( + "weixin_oc(%s): getupdates error: %s", + self.meta().id, + self._last_inbound_error, + ) + return + if errcode and int(errcode) != 0: + errmsg = str(data.get("errmsg", "")) + self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}" + logger.warning( + "weixin_oc(%s): getupdates error: %s", + self.meta().id, + self._last_inbound_error, + ) + return + + if data.get("get_updates_buf"): + self._sync_buf = str(data.get("get_updates_buf")) + await self._save_account_state() + + for msg in data.get("msgs", []) if isinstance(data.get("msgs"), list) else []: + if self._shutdown_event.is_set(): + return + if not isinstance(msg, dict): + continue + await self._handle_inbound_message(msg) + + def _message_chain_to_text(self, message_chain: MessageChain) -> str: + text = "" + for segment in message_chain.chain: + if isinstance(segment, Plain): + text += segment.text + return text.strip() + + async def _send_to_session( + self, user_id: str, text: str, _components: list[Any] | None = None + ) -> bool: + if not text: + text = self._message_chain_to_text(MessageChain(_components or [])) + if not text: + logger.warning( + "weixin_oc(%s): message without plain text is ignored", + self.meta().id, + ) + return False + return await self._send_items_to_session( + user_id, + [self._build_plain_text_item(text)], + ) + + async def send_by_session( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + target_user = session.session_id + pending_text = "" + has_supported_segment = False + for segment in message_chain.chain: + if isinstance(segment, Plain): + pending_text += segment.text + continue + + if isinstance(segment, (Image, Video, File)): + has_supported_segment = True + await self._send_media_segment( + target_user, + segment, + text=pending_text.strip() or None, + ) + pending_text = "" + continue + + logger.debug( + "weixin_oc(%s): unsupported outbound segment type %s", + self.meta().id, + type(segment).__name__, + ) + + if pending_text: + has_supported_segment = True + await self._send_to_session(target_user, pending_text.strip()) + + if not has_supported_segment: + logger.warning( + "weixin_oc(%s): outbound message ignored, no supported segments", + self.meta().id, + ) + await super().send_by_session(session, message_chain) + + def meta(self) -> PlatformMetadata: + return self.metadata + + async def run(self) -> None: + try: + while not self._shutdown_event.is_set(): + if not self.token: + if not self._is_login_session_valid(self._login_session): + try: + self._login_session = await self._start_login_session() + self._qr_expired_count = 0 + except Exception as e: + logger.error( + "weixin_oc(%s): start login failed: %s", + self.meta().id, + e, + ) + await asyncio.sleep(5) + continue + + current_login = self._login_session + if current_login is None: + continue + + try: + await self._poll_qr_status(current_login) + except asyncio.TimeoutError: + logger.debug( + "weixin_oc(%s): qr status long-poll timeout", + self.meta().id, + ) + except Exception as e: + logger.error( + "weixin_oc(%s): poll qr status failed: %s", + self.meta().id, + e, + ) + current_login.error = str(e) + await asyncio.sleep(2) + + if self.token: + logger.info( + "weixin_oc(%s): login confirmed, account=%s", + self.meta().id, + self.account_id or "", + ) + continue + + if current_login.error: + await asyncio.sleep(2) + else: + await asyncio.sleep(self.qr_poll_interval) + continue + + await self._poll_inbound_updates() + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("weixin_oc(%s): run failed: %s", self.meta().id, e) + finally: + await self.client.close() + + async def terminate(self) -> None: + self._shutdown_event.set() + + def get_stats(self) -> dict: + stat = super().get_stats() + login_session = self._login_session + stat["weixin_oc"] = { + "configured": bool(self.token), + "account_id": self.account_id, + "base_url": self.base_url, + "qr_session_key": login_session.session_key if login_session else None, + "qr_status": login_session.status if login_session else None, + "qrcode": login_session.qrcode if login_session else None, + "qrcode_img_content": login_session.qrcode_img_content + if login_session + else None, + "qr_error": login_session.error if login_session else None, + "sync_buf_len": len(self._sync_buf), + "last_error": self._last_inbound_error, + } + return stat diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py new file mode 100644 index 0000000000..5ea30d911c --- /dev/null +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import random +from pathlib import Path +from typing import Any, cast +from urllib.parse import quote + +import aiohttp +from Crypto.Cipher import AES + +from astrbot import logger + + +class WeixinOCClient: + def __init__( + self, + *, + adapter_id: str, + base_url: str, + cdn_base_url: str, + api_timeout_ms: int, + token: str | None = None, + ) -> None: + self.adapter_id = adapter_id + self.base_url = base_url + self.cdn_base_url = cdn_base_url + self.api_timeout_ms = api_timeout_ms + self.token = token + self._http_session: aiohttp.ClientSession | None = None + + async def ensure_http_session(self) -> None: + if self._http_session is None or self._http_session.closed: + timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000) + self._http_session = aiohttp.ClientSession(timeout=timeout) + + async def close(self) -> None: + if self._http_session is not None and not self._http_session.closed: + await self._http_session.close() + self._http_session = None + + def _build_base_headers(self, token_required: bool = False) -> dict[str, str]: + headers = { + "Content-Type": "application/json", + "AuthorizationType": "ilink_bot_token", + "X-WECHAT-UIN": base64.b64encode( + str(random.getrandbits(32)).encode("utf-8") + ).decode("utf-8"), + } + if token_required and self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def _resolve_url(self, endpoint: str) -> str: + return f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + + def _build_cdn_upload_url(self, upload_param: str, file_key: str) -> str: + return ( + f"{self.cdn_base_url}/upload?" + f"encrypted_query_param={quote(upload_param)}&filekey={quote(file_key)}" + ) + + def _build_cdn_download_url(self, encrypted_query_param: str) -> str: + return ( + f"{self.cdn_base_url}/download?" + f"encrypted_query_param={quote(encrypted_query_param)}" + ) + + @staticmethod + def aes_padded_size(size: int) -> int: + return size + (16 - (size % 16) or 16) + + @staticmethod + def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes: + pad_len = block_size - (len(data) % block_size) + if pad_len == 0: + pad_len = block_size + return data + bytes([pad_len]) * pad_len + + @staticmethod + def pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes: + if not data: + return data + pad_len = data[-1] + if pad_len <= 0 or pad_len > block_size: + return data + if data[-pad_len:] != bytes([pad_len]) * pad_len: + return data + return data[:-pad_len] + + @staticmethod + def parse_media_aes_key(aes_key_value: str) -> bytes: + normalized = aes_key_value.strip() + if not normalized: + raise ValueError("empty media aes key") + padded = normalized + "=" * (-len(normalized) % 4) + decoded = base64.b64decode(padded) + if len(decoded) == 16: + return decoded + decoded_text = decoded.decode("ascii", errors="ignore") + if len(decoded) == 32 and all( + c in "0123456789abcdefABCDEF" for c in decoded_text + ): + return bytes.fromhex(decoded_text) + raise ValueError("unsupported media aes key format") + + async def upload_to_cdn( + self, + upload_param: str, + file_key: str, + aes_key_hex: str, + media_path: Path, + ) -> str: + raw_data = media_path.read_bytes() + logger.debug( + "weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s", + self.adapter_id, + media_path.name, + len(raw_data), + hashlib.md5(raw_data).hexdigest(), + file_key, + ) + cipher = AES.new(bytes.fromhex(aes_key_hex), AES.MODE_ECB) + encrypted = cipher.encrypt(self.pkcs7_pad(raw_data)) + logger.debug( + "weixin_oc(%s): encrypt done aes_key_len=%s plain_size=%s cipher_size=%s", + self.adapter_id, + len(bytes.fromhex(aes_key_hex)), + len(raw_data), + len(encrypted), + ) + + await self.ensure_http_session() + assert self._http_session is not None + timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000) + cdn_url = self._build_cdn_upload_url(upload_param, file_key) + + async with self._http_session.post( + cdn_url, + data=encrypted, + headers={"Content-Type": "application/octet-stream"}, + timeout=timeout, + ) as resp: + detail = await resp.text() + logger.debug( + "weixin_oc(%s): CDN upload response status=%s url=%s x-error-message=%s x-encrypted-param=%s body=%s", + self.adapter_id, + resp.status, + cdn_url, + resp.headers.get("x-error-message"), + resp.headers.get("x-encrypted-param"), + detail[:512], + ) + if resp.status >= 400 and resp.status < 500: + raise RuntimeError( + f"upload media to cdn failed: {resp.status} {detail}" + ) + if resp.status != 200: + raise RuntimeError( + f"upload media to cdn failed: {resp.status} {detail}" + ) + download_param = resp.headers.get("x-encrypted-param") + if not download_param: + raise RuntimeError( + "upload media to cdn failed: missing x-encrypted-param" + ) + return download_param + + async def download_cdn_bytes(self, encrypted_query_param: str) -> bytes: + await self.ensure_http_session() + assert self._http_session is not None + timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000) + async with self._http_session.get( + self._build_cdn_download_url(encrypted_query_param), + timeout=timeout, + ) as resp: + if resp.status >= 400: + detail = await resp.text() + raise RuntimeError( + f"download media from cdn failed: {resp.status} {detail}" + ) + return await resp.read() + + async def download_and_decrypt_media( + self, + encrypted_query_param: str, + aes_key_value: str, + ) -> bytes: + encrypted = await self.download_cdn_bytes(encrypted_query_param) + key = self.parse_media_aes_key(aes_key_value) + cipher = AES.new(key, AES.MODE_ECB) + return self.pkcs7_unpad(cipher.decrypt(encrypted)) + + async def request_json( + self, + method: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + payload: dict[str, Any] | None = None, + token_required: bool = False, + timeout_ms: int | None = None, + headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + await self.ensure_http_session() + assert self._http_session is not None + req_timeout = timeout_ms if timeout_ms is not None else self.api_timeout_ms + timeout = aiohttp.ClientTimeout(total=req_timeout / 1000) + merged_headers = self._build_base_headers(token_required=token_required) + if headers: + merged_headers.update(headers) + + async with self._http_session.request( + method, + self._resolve_url(endpoint), + params=params, + json=payload, + headers=merged_headers, + timeout=timeout, + ) as resp: + text = await resp.text() + if resp.status >= 400: + raise RuntimeError(f"{method} {endpoint} failed: {resp.status} {text}") + if not text: + return {} + return cast(dict[str, Any], json.loads(text)) diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_event.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_event.py new file mode 100644 index 0000000000..abe3b5a066 --- /dev/null +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_event.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import ( + At, + BaseMessageComponent, + File, + Image, + Plain, + Record, + Video, +) + +if TYPE_CHECKING: # pragma: no cover - typing helper + from .weixin_oc_adapter import WeixinOCAdapter + + +class WeixinOCMessageEvent(AstrMessageEvent): + def __init__( + self, + message_str, + message_obj, + platform_meta, + session_id, + platform: WeixinOCAdapter, + ) -> None: + super().__init__(message_str, message_obj, platform_meta, session_id) + self.platform = platform + + @staticmethod + def _segment_to_text(segment: BaseMessageComponent) -> str: + if isinstance(segment, Plain): + return segment.text + if isinstance(segment, Image): + return "[图片]" + if isinstance(segment, File): + return f"[文件:{segment.name}]" + if isinstance(segment, Video): + return "[视频]" + if isinstance(segment, Record): + return "[音频]" + if isinstance(segment, At): + return f"@{segment.name or segment.qq}" + return "[消息]" + + @staticmethod + def _build_plain_text(message: MessageChain) -> str: + return "".join( + WeixinOCMessageEvent._segment_to_text(seg) for seg in message.chain + ) + + async def send(self, message: MessageChain) -> None: + if not message.chain: + return + await self.platform.send_by_session(self.session, message) + await super().send(message) + + async def send_streaming(self, generator, use_fallback: bool = False): + if not use_fallback: + buffer = None + async for chain in generator: + if not buffer: + buffer = chain + else: + buffer.chain.extend(chain.chain) + if not buffer: + return None + await self.send(buffer) + return await super().send_streaming(generator, use_fallback) + + buffer = "" + async for chain in generator: + if not isinstance(chain, MessageChain): + continue + for component in chain.chain: + if not isinstance(component, Plain): + await self.send(MessageChain(chain=[component])) + await asyncio.sleep(1.2) + continue + buffer += component.text + if buffer.strip(): + await self.send(MessageChain([Plain(buffer)])) + return await super().send_streaming(generator, use_fallback) diff --git a/dashboard/package.json b/dashboard/package.json index d8ce1c7af9..00224a0f58 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,6 +14,7 @@ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { + "qrcode": "^1.5.4", "@guolao/vue-monaco-editor": "^1.5.4", "@tiptap/starter-kit": "2.1.7", "@tiptap/vue-3": "2.1.7", @@ -76,4 +77,4 @@ "lodash-es": "4.17.23" } } -} \ No newline at end of file +} diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index a3926a9534..775b52a2fa 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: pinyin-pro: specifier: ^3.26.0 version: 3.28.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 shiki: specifier: ^3.20.0 version: 3.22.0 @@ -537,66 +540,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1277,6 +1293,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001778: resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} @@ -1316,6 +1336,9 @@ packages: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1549,6 +1572,10 @@ packages: decache@4.6.2: resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1566,6 +1593,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1585,6 +1615,9 @@ packages: electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emojis-list@3.0.0: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} @@ -1805,6 +1838,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1945,6 +1982,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2317,6 +2358,10 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -2423,6 +2468,11 @@ packages: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} engines: {node: '>=20'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + querystring@0.2.1: resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} engines: {node: '>=0.4.x'} @@ -2448,10 +2498,17 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2534,6 +2591,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2585,6 +2645,10 @@ packages: peerDependencies: monaco-editor: ^0.52.2 + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2975,6 +3039,9 @@ packages: webpack-cli: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2987,6 +3054,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2994,6 +3065,17 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4154,6 +4236,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001778: {} ccount@2.0.1: {} @@ -4201,6 +4285,12 @@ snapshots: dependencies: source-map: 0.6.1 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4445,6 +4535,8 @@ snapshots: dependencies: callsite: 1.0.0 + decamelize@1.2.0: {} + deep-is@0.1.4: {} delaunator@5.0.1: @@ -4459,6 +4551,8 @@ snapshots: dependencies: dequal: 2.0.3 + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -4479,6 +4573,8 @@ snapshots: electron-to-chromium@1.5.307: {} + emoji-regex@8.0.0: {} + emojis-list@3.0.0: {} enhanced-resolve@5.20.0: @@ -4739,6 +4835,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4881,6 +4979,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5231,6 +5331,8 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -5374,6 +5476,12 @@ snapshots: dependencies: hookified: 1.15.1 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + querystring@0.2.1: {} queue-microtask@1.2.3: {} @@ -5396,8 +5504,12 @@ snapshots: dependencies: regex-utilities: 2.3.0 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve@1.22.11: @@ -5492,6 +5604,8 @@ snapshots: semver@7.7.4: {} + set-blocking@2.0.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5553,6 +5667,12 @@ snapshots: shiki: 3.22.0 optional: true + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5929,6 +6049,8 @@ snapshots: - esbuild - uglify-js + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5939,10 +6061,37 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} xml-name-validator@4.0.0: {} + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} yup@1.2.0: diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 7f734b0498..6f6f319576 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 229 icons */ +/* Auto-generated MDI subset – 230 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -720,6 +720,10 @@ content: "\F0A66"; } +.mdi-qrcode::before { + content: "\F0432"; +} + .mdi-refresh::before { content: "\F0450"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 20cd8f5c89..47daa82b65 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 5ddf299e2e..cbfdec3ced 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/components/shared/QrCodeViewer.vue b/dashboard/src/components/shared/QrCodeViewer.vue new file mode 100644 index 0000000000..89c9a879ac --- /dev/null +++ b/dashboard/src/components/shared/QrCodeViewer.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index acbf70c8c0..3b9ff3df8a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -618,6 +618,30 @@ "description": "Send Replies via Webhook Only", "hint": "When enabled, all WeCom AI Bot replies are sent through msg_push_webhook_url. The message push webhook supports more message types (such as images, files, etc.). If you do not need the typing effect, it is strongly recommended to use this option. " }, + "weixin_oc_base_url": { + "description": "API Base URL", + "hint": "Default: https://ilinkai.weixin.qq.com" + }, + "weixin_oc_bot_type": { + "description": "bot_type (QR login parameter)", + "hint": "Default: 3" + }, + "weixin_oc_qr_poll_interval": { + "description": "QR status poll interval (seconds)", + "hint": "Polling interval in seconds for QR code status." + }, + "weixin_oc_long_poll_timeout_ms": { + "description": "getUpdates long-poll timeout (ms)", + "hint": "Timeout parameter for polling messages." + }, + "weixin_oc_api_timeout_ms": { + "description": "HTTP timeout (ms)", + "hint": "Generic API request timeout." + }, + "weixin_oc_token": { + "description": "Token after login (optional)", + "hint": "Automatically written after QR login; can be filled manually for advanced scenarios." + }, "kook_bot_token": { "description": "Bot Token", "type": "string", diff --git a/dashboard/src/i18n/locales/en-US/features/platform.json b/dashboard/src/i18n/locales/en-US/features/platform.json index 64448ca1ea..a62b2f6806 100644 --- a/dashboard/src/i18n/locales/en-US/features/platform.json +++ b/dashboard/src/i18n/locales/en-US/features/platform.json @@ -123,6 +123,13 @@ "unknown": "Unknown", "errors": "error(s)" }, + "platformQr": { + "title": "QR Login", + "show": "Show QR", + "status": "QR Status", + "waiting": "Waiting for QR", + "close": "Close" + }, "errorDialog": { "title": "Error Details", "platformId": "Platform ID", diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 6424a3f705..4cde9b932a 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -614,6 +614,30 @@ "description": "Отправлять ответы только через Webhook", "hint": "Все ответы WeCom AI Bot будут идти через вебхук пуш-сообщений. Поддерживает больше типов контента." }, + "weixin_oc_base_url": { + "description": "URL API iLink", + "hint": "Значение по умолчанию: https://ilinkai.weixin.qq.com" + }, + "weixin_oc_bot_type": { + "description": "bot_type (параметр входа по QR)", + "hint": "Значение по умолчанию: 3" + }, + "weixin_oc_qr_poll_interval": { + "description": "Интервал опроса статуса QR (сек)", + "hint": "Пауза между запросами статуса QR-кода." + }, + "weixin_oc_long_poll_timeout_ms": { + "description": "Таймаут long-poll getUpdates (мс)", + "hint": "Параметр таймаута запроса получения сообщений." + }, + "weixin_oc_api_timeout_ms": { + "description": "Таймаут HTTP запроса (мс)", + "hint": "Общий таймаут для HTTP запросов." + }, + "weixin_oc_token": { + "description": "Token после входа (опционально)", + "hint": "Автоматически сохраняется после QR логина; для сложных сценариев можно ввести вручную." + }, "kook_bot_token": { "description": "Токен бота", "type": "string", diff --git a/dashboard/src/i18n/locales/ru-RU/features/platform.json b/dashboard/src/i18n/locales/ru-RU/features/platform.json index 04c7613a93..42bcae42e6 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/platform.json +++ b/dashboard/src/i18n/locales/ru-RU/features/platform.json @@ -116,14 +116,21 @@ "error": "Ошибка" }, "runtimeStatus": { - "running": "Работает", - "error": "Ошибка", - "pending": "Ожидание", - "stopped": "Остановлен", - "unknown": "Неизвестно", - "errors": "ошибок" - }, - "errorDialog": { + "running": "Работает", + "error": "Ошибка", + "pending": "Ожидание", + "stopped": "Остановлен", + "unknown": "Неизвестно", + "errors": "ошибок" + }, + "platformQr": { + "title": "QR вход", + "show": "Показать QR", + "status": "Статус QR", + "waiting": "Ожидание QR", + "close": "Закрыть" + }, + "errorDialog": { "title": "Детали ошибки", "platformId": "ID платформы", "errorCount": "Кол-во ошибок", @@ -132,4 +139,4 @@ "traceback": "Стек вызовов", "close": "Закрыть" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index afca8e7fcb..2c3cbfd16b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -620,6 +620,30 @@ "description": "仅使用 Webhook 发送消息", "hint": "可选。启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。" }, + "weixin_oc_base_url": { + "description": "Base URL 地址", + "hint": "默认值: https://ilinkai.weixin.qq.com" + }, + "weixin_oc_bot_type": { + "description": "扫码参数 bot_type", + "hint": "默认值: 3" + }, + "weixin_oc_qr_poll_interval": { + "description": "二维码状态轮询间隔(秒)", + "hint": "每隔多少秒轮询一次二维码状态。" + }, + "weixin_oc_long_poll_timeout_ms": { + "description": "getUpdates 长轮询超时时间(毫秒)", + "hint": "会话消息拉取接口超时参数。" + }, + "weixin_oc_api_timeout_ms": { + "description": "HTTP 请求超时(毫秒)", + "hint": "通用 API 请求超时参数。" + }, + "weixin_oc_token": { + "description": "登录后 token(可留空)", + "hint": "扫码登录成功后会自动写入;高级场景可手动填写。" + }, "kook_bot_token": { "description": "机器人 Token", "type": "string", diff --git a/dashboard/src/i18n/locales/zh-CN/features/platform.json b/dashboard/src/i18n/locales/zh-CN/features/platform.json index f26d9507dd..75dc5a2a6f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/platform.json +++ b/dashboard/src/i18n/locales/zh-CN/features/platform.json @@ -123,6 +123,13 @@ "unknown": "未知", "errors": "个错误" }, + "platformQr": { + "title": "二维码登录", + "show": "查看二维码", + "status": "二维码状态", + "waiting": "等待二维码", + "close": "关闭" + }, "errorDialog": { "title": "错误详情", "platformId": "平台 ID", diff --git a/dashboard/src/utils/platformUtils.js b/dashboard/src/utils/platformUtils.js index 87523f3ad1..04cb226faa 100644 --- a/dashboard/src/utils/platformUtils.js +++ b/dashboard/src/utils/platformUtils.js @@ -12,6 +12,8 @@ export function getPlatformIcon(name) { return new URL('@/assets/images/platform_logos/onebot.png', import.meta.url).href } else if (name === 'qq_official' || name === 'qq_official_webhook') { 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') { return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href } else if (name === 'weixin_official_account') { @@ -50,6 +52,7 @@ export function getTutorialLink(platformType) { "qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html", "aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html", "wecom": "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", "telegram": "https://docs.astrbot.app/platform/telegram.html", diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue index f50df95546..a6c6898a63 100644 --- a/dashboard/src/views/PlatformPage.vue +++ b/dashboard/src/views/PlatformPage.vue @@ -57,6 +57,21 @@ {{ getPlatformStat(item.id)?.error_count }} {{ tm('runtimeStatus.errors') }} +
+ + mdi-qrcode + {{ tm('platformQr.show') }} + +
+ + + + mdi-qrcode + {{ tm('platformQr.title') }} + + +
+ {{ tm('platformQr.status') }}: {{ getPlatformQrLoginStat(currentQrPlatformId)?.qr_status || tm('platformQr.waiting') }} +
+ +
+ + + + {{ tm('platformQr.close') }} + + +
+
+ @@ -194,9 +233,10 @@ import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'; import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue'; import ItemCard from '@/components/shared/ItemCard.vue'; import AddNewPlatform from '@/components/platform/AddNewPlatform.vue'; +import QrCodeViewer from '@/components/shared/QrCodeViewer.vue'; import { useCommonStore } from '@/stores/common'; import { useI18n, useModuleI18n, mergeDynamicTranslations } from '@/i18n/composables'; -import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils'; +import { getPlatformIcon } from '@/utils/platformUtils'; import { askForConfirmation as askForConfirmationDialog, useConfirmDialog @@ -209,7 +249,8 @@ export default { WaitingForRestart, ConsoleDisplayer, ItemCard, - AddNewPlatform + AddNewPlatform, + QrCodeViewer, }, setup() { const { t } = useI18n(); @@ -248,6 +289,8 @@ export default { // 错误详情对话框 showErrorDialog: false, currentErrorPlatform: null, + showQrDialog: false, + currentQrPlatformId: "", store: useCommonStore() } @@ -276,10 +319,10 @@ export default { mounted() { this.getConfig(); this.getPlatformStats(); - // 每 10 秒刷新一次平台状态 + // 每 5 秒刷新一次平台状态 this.statsRefreshInterval = setInterval(() => { this.getPlatformStats(); - }, 10000); + }, 5000); // 监听语言切换事件,重新加载配置以获取插件的 i18n 数据 window.addEventListener('astrbot-locale-changed', this.handleLocaleChange); @@ -326,8 +369,8 @@ export default { }); }, - getPlatformStats() { - axios.get('/api/platform/stats').then((res) => { + async getPlatformStats() { + await axios.get('/api/platform/stats').then((res) => { if (res.data.status === 'ok') { // 将数组转换为以 id 为 key 的对象,方便查找 const stats = {}; @@ -345,6 +388,31 @@ export default { return this.platformStats[platformId] || null; }, + hasQrPayload(platformId) { + const stat = this.getPlatformQrLoginStat(platformId); + return Boolean(stat?.qrcode_img_content || stat?.qrcode); + }, + + getPlatformQrLoginStat(platformId) { + const stat = this.getPlatformStat(platformId); + if (stat?.weixin_oc) { + return stat.weixin_oc; + } + 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)) { + return value; + } + } + } + return null; + }, + + openPlatformQrDialog(platformId) { + this.currentQrPlatformId = platformId; + this.showQrDialog = true; + }, + getStatusColor(status) { switch (status) { case 'running': return 'success'; @@ -618,4 +686,14 @@ export default { max-height: 300px; overflow-y: auto; } + +.platform-qr-chip { + margin-top: 4px; +} + +.platform-qr-status { + font-size: 13px; + margin-bottom: 10px; + color: rgba(0, 0, 0, 0.7); +} diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 9932d7a700..5b92e97d2f 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -92,6 +92,7 @@ export default defineConfig({ { text: "企微应用", link: "/wecom" }, { text: "企微智能机器人", link: "/wecom_ai_bot" }, { text: "微信公众号", link: "/weixin-official-account" }, + { text: "个人微信", link: "/weixin_oc" }, { text: "飞书", link: "/lark" }, { text: "钉钉", link: "/dingtalk" }, { text: "Telegram", link: "/telegram" }, @@ -326,6 +327,7 @@ export default defineConfig({ { text: "WeCom Application", link: "/wecom" }, { text: "WeCom AI Bot", link: "/wecom_ai_bot" }, { text: "WeChat Official Account", link: "/weixin-official-account" }, + { text: "Personal WeChat", link: "/weixin_oc" }, { text: "Lark", link: "/lark" }, { text: "DingTalk", link: "/dingtalk" }, { text: "Telegram", link: "/telegram" }, diff --git a/docs/en/platform/weixin_oc.md b/docs/en/platform/weixin_oc.md new file mode 100644 index 0000000000..8234e615a6 --- /dev/null +++ b/docs/en/platform/weixin_oc.md @@ -0,0 +1,74 @@ +# Connect Personal WeChat + +> Introduced in v4.22.0. + +AstrBot supports connecting a personal WeChat account through the `Personal WeChat` adapter. This adapter is implemented on top of Tencent's official `openclaw-weixin` interface, uses QR-code login plus long polling, and does not require a Webhook callback URL. + +> [!NOTE] +> Please upgrade your mobile WeChat to a recent version. +> +> **iOS**: >= 4.0.70 + +## Supported Message Types + +| Message Type | Receive | Send | Notes | +| --- | --- | --- | --- | +| Text | Yes | Yes | | +| Image | Yes | Yes | Downloaded and decrypted into the local temp directory on receive | +| Voice | Yes | Yes* | *WeChat cloud-side transcription is used, so no local transcription is required | +| Video | Yes | Yes | Downloaded and decrypted into the local temp directory on receive | +| File | Yes | Yes | Downloaded and decrypted into the local temp directory on receive | + +## Create the Bot + +1. Open AstrBot WebUI. +2. Click `Bots` in the left sidebar. +3. Click `+ Create Bot` in the upper-right corner. +4. Select `Personal WeChat`. + +## Configuration Notes + +In most cases, you only need to pay attention to these fields: + +- `ID(id)`: Any value you like, used to distinguish different bot instances. +- `Enable(enable)`: Turn it on. + +Leave the remaining options at their default values unless you explicitly know you need to change them: + +- `QR Poll Interval (weixin_oc_qr_poll_interval)` +- `Long Poll Timeout (weixin_oc_long_poll_timeout_ms)` +- `API Timeout (weixin_oc_api_timeout_ms)` + +> [!TIP] +> `token` and `account_id` are saved automatically by AstrBot after QR login succeeds. You normally do not need to fill them manually. + +## QR Login + +1. Fill in the configuration and click `Save`. +2. Return to the bot list. AstrBot will automatically request a login QR code from WeChat. +3. On the bot card, click `View QR Code` to open the QR dialog. +4. Scan it with WeChat on your phone, then confirm the login inside WeChat. + +After login succeeds, AstrBot will automatically persist the login state. On later restarts, if the session is still valid, you usually do not need to scan again. + +> [!NOTE] +> If the QR code expires, AstrBot will automatically request a new one. Please scan the refreshed QR code instead of the old one. + +## Verification + +After login succeeds, send a message from WeChat. If AstrBot replies normally, the integration is working. + +You can also watch the `Console` page in WebUI to confirm that the adapter has completed login and started polling messages. + +## Media File Storage + +Received images, videos, files, and voice messages are downloaded and decrypted into AstrBot's local temporary directory: + +`data/temp` + +These files are temporary cached files and can be further used by plugins, agents, or the file service. + +## Notes + +- This adapter logs in by scanning a QR code with a personal WeChat account, so its setup flow is different from WeChat Official Account and WeCom. +- No public callback URL is required, and Unified Webhook Mode is not needed. diff --git a/docs/zh/platform/weixin_oc.md b/docs/zh/platform/weixin_oc.md new file mode 100644 index 0000000000..a2078e4e57 --- /dev/null +++ b/docs/zh/platform/weixin_oc.md @@ -0,0 +1,78 @@ +# 接入个人微信 + +> v4.22.0 引入。 + +AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。 + +> [!NOTE] +> 需要升级到最新的手机微信版本: +> +> **iOS**: >= 4.0.70 + +## 支持的消息类型 + +| 消息类型 | 是否支持接收 | 是否支持发送 | 备注 | +| --- | --- | --- | --- | +| 文本 | 是 | 是 | | +| 图片 | 是 | 是 | 接收时会下载并解密到本地临时目录 | +| 语音 | 是 | 是* | *微信云端会自动转录成文本,无需本地转录 | +| 视频 | 是 | 是 | 接收时会下载并解密到本地临时目录 | +| 文件 | 是 | 是 | 接收时会下载并解密到本地临时目录 | + +## 创建机器人 + +1. 进入 AstrBot WebUI。 +2. 点击左侧栏 `机器人`。 +3. 点击右上角 `+ 创建机器人`。 +4. 选择 `个人微信`。 + +## 配置项说明 + +通常只需要关注以下几个配置: + +- `ID(id)`:随意填写,用于区分不同的机器人实例。 +- `启用(enable)`:勾选。 + +其余配置**保持默认即可**,一般无需修改,除非您明确知道用途: + +- `二维码轮询间隔(weixin_oc_qr_poll_interval)` +- `长轮询超时(weixin_oc_long_poll_timeout_ms)` +- `API 超时(weixin_oc_api_timeout_ms)` + +> [!TIP] +> `token` 和 `account_id` 会在扫码登录成功后由 AstrBot 自动保存,通常不需要手动填写。 + +## 扫码登录 + +1. 填好配置后点击 `保存`。 +2. 返回机器人列表,AstrBot 会自动向微信接口申请登录二维码。 +3. 在**机器人卡片**中点击 “查看二维码” 按钮,会弹出二维码对话框。(点击保存之后可能需要等 5 到 10 秒左右才会出现这个按钮) +4. 使用手机微信扫码,并在微信内确认登录。 + +![微信二维码入口](weixin_qr_entry.png) + +登录成功后,AstrBot 会自动保存登录态。后续重启时,如果登录态仍有效,通常不需要再次扫码。 + +> [!NOTE] +> 1. 如果二维码过期,AstrBot 会自动重新申请新的二维码。刷新后请使用新的二维码重新扫码。 +> 2. 如果 WebUI 没看到 “查看二维码” 按钮,可以前往终端或者 WebUI 控制台,找到 `请使用手机微信扫码登录,二维码有效期 5 分钟,过期后会自动刷新。` 对应的日志,附近会显示二维码扫码链接和终端直接输出的二维码,直选择一种方式扫码即可。 + +## 验证 + +登录成功后,用微信发送一条消息。如果 AstrBot 能正常回复,说明接入成功。 + +也可以在 WebUI `控制台` 中观察日志,确认适配器已经完成登录并开始轮询消息。 + +## 多媒体文件保存位置 + +接收到的图片、视频、文件、语音会被 AstrBot 下载并解密后保存到本地临时目录: + +`data/temp` + +这些文件属于 AstrBot 的临时缓存文件,后续插件、Agent 或文件服务可以继续读取和处理。 + +## 已知说明 + +- 该适配器通过扫码登录个人微信,接入方式与微信公众号、企业微信不同。 +- 不需要配置公网回调地址,也不需要开启统一 Webhook 模式。 + diff --git a/docs/zh/platform/weixin_qr_entry.png b/docs/zh/platform/weixin_qr_entry.png new file mode 100644 index 0000000000..b376bce488 Binary files /dev/null and b/docs/zh/platform/weixin_qr_entry.png differ diff --git a/pyproject.toml b/pyproject.toml index c7de286020..6bf948cadf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "pydantic>=2.12.5", "pydub>=0.25.1", "pyjwt>=2.10.1", + "qrcode>=8.2", "python-telegram-bot>=22.6", "qq-botpy>=1.2.1", "quart>=0.20.0",