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 @@
+
+
+