diff --git a/astrbot/core/platform/sources/weixin_oc/login_registration.py b/astrbot/core/platform/sources/weixin_oc/login_registration.py new file mode 100644 index 000000000..9fb5243c7 --- /dev/null +++ b/astrbot/core/platform/sources/weixin_oc/login_registration.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass +from typing import Any + +from .weixin_oc_client import WeixinOCClient + +DEFAULT_WEIXIN_OC_BASE_URL = "https://ilinkai.weixin.qq.com" +DEFAULT_WEIXIN_OC_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c" +DEFAULT_WEIXIN_OC_BOT_TYPE = "3" +DEFAULT_WEIXIN_OC_QR_POLL_INTERVAL = 1 +DEFAULT_WEIXIN_OC_LONG_POLL_TIMEOUT_MS = 35_000 +DEFAULT_WEIXIN_OC_API_TIMEOUT_MS = 15_000 + + +@dataclass +class WeixinOCLoginRegistration: + qrcode: str + qrcode_img_content: str + interval: int + + +def normalize_weixin_oc_base_url(base_url: str | None) -> str: + return (base_url or DEFAULT_WEIXIN_OC_BASE_URL).strip().rstrip("/") + + +def _string_field(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if isinstance(value, str): + return value.strip() + return "" + + +def _int_config(value: Any, default: int, minimum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(parsed, minimum) + + +def weixin_oc_login_result( + data: dict[str, Any], + *, + default_base_url: str, +) -> dict[str, Any]: + raw_status = _string_field(data, "status") or "wait" + if raw_status == "confirmed": + bot_token = _string_field(data, "bot_token") + if not bot_token: + return {"status": "error", "message": "登录成功但未返回 token"} + base_url = _string_field(data, "baseurl") or default_base_url + return { + "status": "created", + "qr_status": raw_status, + "weixin_oc_token": bot_token, + "weixin_oc_account_id": _string_field(data, "ilink_bot_id"), + "weixin_oc_base_url": normalize_weixin_oc_base_url(base_url), + "weixin_oc_user_id": _string_field(data, "ilink_user_id"), + } + if raw_status == "expired": + return {"status": "expired", "qr_status": raw_status, "message": "二维码已过期"} + if raw_status in {"cancel", "canceled", "denied"}: + return {"status": "denied", "qr_status": raw_status, "message": "用户取消登录"} + return {"status": "pending", "qr_status": raw_status} + + +def _client( + *, + adapter_id: str, + base_url: str, + api_timeout_ms: int, +) -> WeixinOCClient: + return WeixinOCClient( + adapter_id=adapter_id, + base_url=base_url, + cdn_base_url=DEFAULT_WEIXIN_OC_CDN_BASE_URL, + api_timeout_ms=api_timeout_ms, + ) + + +async def request_weixin_oc_login_qr( + platform_config: dict[str, Any], +) -> WeixinOCLoginRegistration: + base_url = normalize_weixin_oc_base_url( + _string_field(platform_config, "weixin_oc_base_url") + ) + bot_type = _string_field(platform_config, "weixin_oc_bot_type") + if not bot_type: + bot_type = DEFAULT_WEIXIN_OC_BOT_TYPE + api_timeout_ms = _int_config( + platform_config.get("weixin_oc_api_timeout_ms"), + DEFAULT_WEIXIN_OC_API_TIMEOUT_MS, + 1_000, + ) + interval = _int_config( + platform_config.get("weixin_oc_qr_poll_interval"), + DEFAULT_WEIXIN_OC_QR_POLL_INTERVAL, + 1, + ) + + client = _client( + adapter_id=str(platform_config.get("id") or "weixin_oc"), + base_url=base_url, + api_timeout_ms=api_timeout_ms, + ) + try: + data = await client.request_json( + "GET", + "ilink/bot/get_bot_qrcode", + params={"bot_type": bot_type}, + token_required=False, + timeout_ms=15_000, + ) + finally: + await client.close() + + qrcode = _string_field(data, "qrcode") + qrcode_img_content = _string_field(data, "qrcode_img_content") + if not qrcode or not qrcode_img_content: + raise RuntimeError("个人微信二维码响应格式异常") + + return WeixinOCLoginRegistration( + qrcode=qrcode, + qrcode_img_content=qrcode_img_content, + interval=interval, + ) + + +async def poll_weixin_oc_login_once( + *, + platform_config: dict[str, Any], + qrcode: str, +) -> dict[str, Any]: + if not qrcode: + raise ValueError("Missing qrcode") + + base_url = normalize_weixin_oc_base_url( + _string_field(platform_config, "weixin_oc_base_url") + ) + api_timeout_ms = _int_config( + platform_config.get("weixin_oc_api_timeout_ms"), + DEFAULT_WEIXIN_OC_API_TIMEOUT_MS, + 1_000, + ) + long_poll_timeout_ms = _int_config( + platform_config.get("weixin_oc_long_poll_timeout_ms"), + DEFAULT_WEIXIN_OC_LONG_POLL_TIMEOUT_MS, + 1_000, + ) + + client = _client( + adapter_id=str(platform_config.get("id") or "weixin_oc"), + base_url=base_url, + api_timeout_ms=api_timeout_ms, + ) + try: + data = await client.request_json( + "GET", + "ilink/bot/get_qrcode_status", + params={"qrcode": qrcode}, + token_required=False, + timeout_ms=long_poll_timeout_ms, + headers={"iLink-App-ClientVersion": "1"}, + ) + finally: + await client.close() + + return weixin_oc_login_result(data, default_base_url=base_url) diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py index 01923b6f2..e48bb9c2a 100644 --- a/astrbot/dashboard/routes/platform.py +++ b/astrbot/dashboard/routes/platform.py @@ -13,6 +13,10 @@ from astrbot.core.platform.sources.lark.app_registration import ( request_app_registration, ) from astrbot.core.platform.sources.lark.bot_info import request_lark_bot_info +from astrbot.core.platform.sources.weixin_oc.login_registration import ( + poll_weixin_oc_login_once, + request_weixin_oc_login_qr, +) from .route import Response, Route, RouteContext @@ -117,61 +121,117 @@ class PlatformRoute(Route): action = str(payload.get("action", "")).strip().lower() if not action: return Response().error("Missing action").__dict__, 400 - if platform_type != "lark": - return Response().error( - f"Unsupported platform registration: {platform_type}" - ).__dict__, 404 platform_config = payload.get("platform_config") if not isinstance(platform_config, dict): platform_config = {} - domain = str(platform_config.get("domain") or "").strip() - if action == "start": - registration = await request_app_registration(domain) - return ( - Response() - .ok( - { - "status": "pending", - "device_code": registration.device_code, - "registration_code": registration.device_code, - "user_code": registration.user_code, - "verification_uri": registration.verification_uri, - "verification_uri_complete": registration.verification_uri_complete, - "expires_in": registration.expires_in, - "interval": registration.interval, - } - ) - .__dict__ + if platform_type == "lark": + return await self._handle_lark_registration( + action, + payload, + platform_config, + ) + if platform_type == "weixin_oc": + return await self._handle_weixin_oc_registration( + action, + payload, + platform_config, ) - if action == "poll": - device_code = str( - payload.get("device_code") or payload.get("registration_code") or "" - ).strip() - if not device_code: - return Response().error("Missing device_code").__dict__, 400 - result = await poll_app_registration_once( - domain=domain, - device_code=device_code, - ) - if result.get("status") == "created": - try: - bot_info = await request_lark_bot_info( - domain=str(result.get("domain") or domain), - app_id=str(result.get("app_id") or ""), - app_secret=str(result.get("app_secret") or ""), - ) - if bot_info.app_name: - result["bot_name"] = bot_info.app_name - if bot_info.open_id: - result["bot_open_id"] = bot_info.open_id - except Exception as e: - logger.error(f"获取飞书机器人信息失败: {e}", exc_info=True) - return Response().ok(result).__dict__ - - return Response().error(f"Unsupported action: {action}").__dict__, 400 + return Response().error( + f"Unsupported platform registration: {platform_type}" + ).__dict__, 404 except Exception as e: logger.error(f"处理平台一键创建请求失败: {e}", exc_info=True) return Response().error(str(e)).__dict__, 500 + + async def _handle_lark_registration( + self, + action: str, + payload: dict, + platform_config: dict, + ): + domain = str(platform_config.get("domain") or "").strip() + + if action == "start": + registration = await request_app_registration(domain) + return ( + Response() + .ok( + { + "status": "pending", + "device_code": registration.device_code, + "registration_code": registration.device_code, + "user_code": registration.user_code, + "verification_uri": registration.verification_uri, + "verification_uri_complete": registration.verification_uri_complete, + "expires_in": registration.expires_in, + "interval": registration.interval, + } + ) + .__dict__ + ) + + if action == "poll": + device_code = str( + payload.get("device_code") or payload.get("registration_code") or "" + ).strip() + if not device_code: + return Response().error("Missing device_code").__dict__, 400 + result = await poll_app_registration_once( + domain=domain, + device_code=device_code, + ) + if result.get("status") == "created": + try: + bot_info = await request_lark_bot_info( + domain=str(result.get("domain") or domain), + app_id=str(result.get("app_id") or ""), + app_secret=str(result.get("app_secret") or ""), + ) + if bot_info.app_name: + result["bot_name"] = bot_info.app_name + if bot_info.open_id: + result["bot_open_id"] = bot_info.open_id + except Exception as e: + logger.error(f"获取飞书机器人信息失败: {e}", exc_info=True) + return Response().ok(result).__dict__ + + return Response().error(f"Unsupported action: {action}").__dict__, 400 + + async def _handle_weixin_oc_registration( + self, + action: str, + payload: dict, + platform_config: dict, + ): + if action == "start": + registration = await request_weixin_oc_login_qr(platform_config) + return ( + Response() + .ok( + { + "status": "pending", + "registration_code": registration.qrcode, + "qrcode": registration.qrcode, + "qrcode_img_content": registration.qrcode_img_content, + "interval": registration.interval, + } + ) + .__dict__ + ) + + if action == "poll": + qrcode = str( + payload.get("qrcode") or payload.get("registration_code") or "" + ).strip() + if not qrcode: + return Response().error("Missing qrcode").__dict__, 400 + result = await poll_weixin_oc_login_once( + platform_config=platform_config, + qrcode=qrcode, + ) + return Response().ok(result).__dict__ + + return Response().error(f"Unsupported action: {action}").__dict__, 400 diff --git a/dashboard/src/components/platform/AddNewPlatform.vue b/dashboard/src/components/platform/AddNewPlatform.vue index d969f14fd..43096bea7 100644 --- a/dashboard/src/components/platform/AddNewPlatform.vue +++ b/dashboard/src/components/platform/AddNewPlatform.vue @@ -30,30 +30,52 @@