From c88025c2a373b7a6788c472d4fb9f05876ac397c Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Fri, 15 May 2026 18:43:21 +0800 Subject: [PATCH] feat(dingtalk): implement one-click QR registration and polling mechanism (#8198) --- .../sources/dingtalk/app_registration.py | 146 ++++++++++++++++++ astrbot/dashboard/routes/platform.py | 37 +++++ .../components/platform/AddNewPlatform.vue | 65 +++++++- .../platform/PlatformRegistrationAction.vue | 13 ++ .../i18n/locales/en-US/features/platform.json | 8 +- .../i18n/locales/ru-RU/features/platform.json | 8 +- .../i18n/locales/zh-CN/features/platform.json | 8 +- docs/en/platform/dingtalk.md | 16 +- docs/zh/platform/dingtalk.md | 16 +- tests/test_dingtalk_app_registration.py | 47 ++++++ 10 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 astrbot/core/platform/sources/dingtalk/app_registration.py create mode 100644 tests/test_dingtalk_app_registration.py diff --git a/astrbot/core/platform/sources/dingtalk/app_registration.py b/astrbot/core/platform/sources/dingtalk/app_registration.py new file mode 100644 index 000000000..c177d30ae --- /dev/null +++ b/astrbot/core/platform/sources/dingtalk/app_registration.py @@ -0,0 +1,146 @@ +import os +from dataclasses import dataclass +from typing import Any + +import aiohttp + +DEFAULT_DINGTALK_REGISTRATION_BASE_URL = "https://oapi.dingtalk.com" +DEFAULT_DINGTALK_REGISTRATION_SOURCE = "DING_DWS_CLAW" + + +@dataclass +class DingtalkAppRegistration: + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str + expires_in: int + interval: int + + +def dingtalk_registration_base_url() -> str: + return ( + os.getenv("DINGTALK_REGISTRATION_BASE_URL", "").strip() + or DEFAULT_DINGTALK_REGISTRATION_BASE_URL + ).rstrip("/") + + +def dingtalk_registration_source() -> str: + return ( + os.getenv("DINGTALK_REGISTRATION_SOURCE", "").strip() + or DEFAULT_DINGTALK_REGISTRATION_SOURCE + ) + + +def _string_field(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if isinstance(value, str): + return value.strip() + return "" + + +def _int_field(data: dict[str, Any], key: str, default: int) -> int: + value = data.get(key) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + return default + + +async def _post_registration( + path: str, + payload: dict[str, str], +) -> tuple[int, dict[str, Any]]: + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.post( + f"{dingtalk_registration_base_url()}{path}", + json=payload, + ) as response: + status = response.status + data = await response.json(content_type=None) + if not isinstance(data, dict): + raise RuntimeError("DingTalk registration response format is invalid") + return status, data + + +def _raise_dingtalk_registration_error( + status: int, + raw: dict[str, Any], + action: str, +) -> None: + errcode = raw.get("errcode", 0) + if status < 400 and int(errcode or 0) == 0: + return + errmsg = _string_field(raw, "errmsg") or "unknown error" + raise RuntimeError(f"[{action}] {errmsg} (errcode={errcode})") + + +async def request_dingtalk_app_registration() -> DingtalkAppRegistration: + status, init_raw = await _post_registration( + "/app/registration/init", + {"source": dingtalk_registration_source()}, + ) + _raise_dingtalk_registration_error(status, init_raw, "init") + nonce = _string_field(init_raw, "nonce") + if not nonce: + raise RuntimeError("[init] missing nonce") + + status, begin_raw = await _post_registration( + "/app/registration/begin", + {"nonce": nonce}, + ) + _raise_dingtalk_registration_error(status, begin_raw, "begin") + + device_code = _string_field(begin_raw, "device_code") + verification_uri_complete = _string_field(begin_raw, "verification_uri_complete") + if not device_code: + raise RuntimeError("[begin] missing device_code") + if not verification_uri_complete: + raise RuntimeError("[begin] missing verification_uri_complete") + + return DingtalkAppRegistration( + device_code=device_code, + user_code=_string_field(begin_raw, "user_code"), + verification_uri=_string_field(begin_raw, "verification_uri"), + verification_uri_complete=verification_uri_complete, + expires_in=max(_int_field(begin_raw, "expires_in", 7200), 60), + interval=max(_int_field(begin_raw, "interval", 3), 1), + ) + + +async def poll_dingtalk_app_registration_once(device_code: str) -> dict[str, Any]: + status, raw = await _post_registration( + "/app/registration/poll", + {"device_code": device_code}, + ) + _raise_dingtalk_registration_error(status, raw, "poll") + return dingtalk_registration_poll_result(raw) + + +def dingtalk_registration_poll_result(raw: dict[str, Any]) -> dict[str, Any]: + status_raw = _string_field(raw, "status").upper() + if status_raw == "WAITING": + return {"status": "pending"} + if status_raw == "SUCCESS": + client_id = _string_field(raw, "client_id") + client_secret = _string_field(raw, "client_secret") + if not client_id or not client_secret: + return {"status": "error", "message": "扫码成功但未获取到钉钉应用凭证"} + return { + "status": "created", + "client_id": client_id, + "client_secret": client_secret, + } + if status_raw == "FAIL": + return { + "status": "error", + "message": _string_field(raw, "fail_reason") or "钉钉扫码创建失败", + } + if status_raw == "EXPIRED": + return {"status": "expired", "message": "钉钉扫码已过期,请重新创建"} + return { + "status": "error", + "message": f"钉钉扫码创建返回未知状态: {status_raw or 'UNKNOWN'}", + } diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py index e48bb9c2a..2b3a9c7f8 100644 --- a/astrbot/dashboard/routes/platform.py +++ b/astrbot/dashboard/routes/platform.py @@ -8,6 +8,10 @@ from quart import request from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.platform import Platform +from astrbot.core.platform.sources.dingtalk.app_registration import ( + poll_dingtalk_app_registration_once, + request_dingtalk_app_registration, +) from astrbot.core.platform.sources.lark.app_registration import ( poll_app_registration_once, request_app_registration, @@ -138,6 +142,8 @@ class PlatformRoute(Route): payload, platform_config, ) + if platform_type == "dingtalk": + return await self._handle_dingtalk_registration(action, payload) return Response().error( f"Unsupported platform registration: {platform_type}" @@ -200,6 +206,37 @@ class PlatformRoute(Route): return Response().error(f"Unsupported action: {action}").__dict__, 400 + async def _handle_dingtalk_registration(self, action: str, payload: dict): + if action == "start": + registration = await request_dingtalk_app_registration() + 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_dingtalk_app_registration_once(device_code) + return Response().ok(result).__dict__ + + return Response().error(f"Unsupported action: {action}").__dict__, 400 + async def _handle_weixin_oc_registration( self, action: str, diff --git a/dashboard/src/components/platform/AddNewPlatform.vue b/dashboard/src/components/platform/AddNewPlatform.vue index 43096bea7..f079b1a68 100644 --- a/dashboard/src/components/platform/AddNewPlatform.vue +++ b/dashboard/src/components/platform/AddNewPlatform.vue @@ -31,19 +31,19 @@
-
+
{{ tm('registrationAction.mode.title') }}
- + -
+
+
+ + mdi-book-open-variant + {{ tm('dialog.viewTutorial') }} + +
+ +
+
+ +
+
+ {{ tm('registrationAction.mode.title') }} +
+ + + + + +
+ +
+ +
mdi-book-open-variant @@ -390,6 +424,7 @@ export default { selectedPlatformType: null, selectedPlatformConfig: null, larkCreationMode: '', + dingtalkCreationMode: '', aBConfigRadioVal: '0', selectedAbConfId: 'default', @@ -469,6 +504,16 @@ export default { } } + if (this.isDingtalkPlatform && !this.dingtalkCreationMode) { + return false; + } + + if (this.isDingtalkPlatform && this.dingtalkCreationMode === 'scan') { + if (!this.selectedPlatformConfig?.client_id || !this.selectedPlatformConfig?.client_secret) { + return false; + } + } + if (this.isWeixinOcPlatform && !this.selectedPlatformConfig?.weixin_oc_token) { return false; } @@ -511,6 +556,9 @@ export default { }, isWeixinOcPlatform() { return this.selectedPlatformConfig?.type === 'weixin_oc'; + }, + isDingtalkPlatform() { + return this.selectedPlatformConfig?.type === 'dingtalk'; } }, watch: { @@ -518,9 +566,11 @@ export default { if (newType && this.platformTemplates[newType]) { this.selectedPlatformConfig = JSON.parse(JSON.stringify(this.platformTemplates[newType])); this.larkCreationMode = ''; + this.dingtalkCreationMode = ''; } else { this.selectedPlatformConfig = null; this.larkCreationMode = ''; + this.dingtalkCreationMode = ''; } }, selectedAbConfId(newConfigId) { @@ -606,6 +656,7 @@ export default { this.selectedPlatformType = null; this.selectedPlatformConfig = null; this.larkCreationMode = ''; + this.dingtalkCreationMode = ''; this.aBConfigRadioVal = '0'; this.selectedAbConfId = 'default'; @@ -1181,17 +1232,17 @@ export default { gap: 8px; } -.lark-creation-mode .v-label { +.creation-mode-group .v-label { opacity: 0.9; } -.lark-creation-title { +.creation-mode-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.78); } -.lark-registration-inline { +.registration-inline { display: flex; align-items: flex-start; justify-content: flex-start; diff --git a/dashboard/src/components/platform/PlatformRegistrationAction.vue b/dashboard/src/components/platform/PlatformRegistrationAction.vue index c0df0f74e..ba40ac60c 100644 --- a/dashboard/src/components/platform/PlatformRegistrationAction.vue +++ b/dashboard/src/components/platform/PlatformRegistrationAction.vue @@ -66,6 +66,13 @@ const REGISTRATION_ACTIONS = { successKey: 'registrationAction.weixinOc.created', statusKeyPrefix: 'registrationAction.weixinOc.status', }, + dingtalk: { + endpoint: '/api/platform/registration/dingtalk', + icon: 'mdi-qrcode', + titleKey: 'registrationAction.dingtalk.title', + scanTitleKey: 'registrationAction.dingtalk.scanTitle', + successKey: 'registrationAction.dingtalk.created', + }, }; export default { @@ -256,6 +263,12 @@ export default { if (data.weixin_oc_base_url) { this.platformConfig.weixin_oc_base_url = data.weixin_oc_base_url; } + if (data.client_id) { + this.platformConfig.client_id = data.client_id; + } + if (data.client_secret) { + this.platformConfig.client_secret = data.client_secret; + } }, getStatusText(status) { const normalizedStatus = status || 'idle'; diff --git a/dashboard/src/i18n/locales/en-US/features/platform.json b/dashboard/src/i18n/locales/en-US/features/platform.json index d2c9650fb..7282d312d 100644 --- a/dashboard/src/i18n/locales/en-US/features/platform.json +++ b/dashboard/src/i18n/locales/en-US/features/platform.json @@ -152,6 +152,11 @@ "error": "Login Failed" } }, + "dingtalk": { + "title": "DingTalk QR Setup", + "scanTitle": "Scan with DingTalk on your phone", + "created": "Setup Complete" + }, "start": "Start Setup", "created": "Setup Complete", "startFailed": "Failed to start QR setup", @@ -159,7 +164,8 @@ "mode": { "title": "Choose setup method", "scan": "One-click QR setup", - "manual": "Manual setup (supports self-hosted Feishu)" + "manual": "Manual setup", + "larkManual": "Manual setup (supports self-hosted Feishu)" }, "scanTitle": "Scan with Feishu on your phone", "status": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/platform.json b/dashboard/src/i18n/locales/ru-RU/features/platform.json index b1a6db673..c3609d79b 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/platform.json +++ b/dashboard/src/i18n/locales/ru-RU/features/platform.json @@ -152,6 +152,11 @@ "error": "Ошибка входа" } }, + "dingtalk": { + "title": "Создание DingTalk по QR", + "scanTitle": "Отсканируйте в мобильном DingTalk", + "created": "Настройка завершена" + }, "start": "Начать настройку", "created": "Настройка завершена", "startFailed": "Не удалось начать QR настройку", @@ -159,7 +164,8 @@ "mode": { "title": "Выберите способ создания", "scan": "Создать через QR", - "manual": "Создать вручную (поддерживает self-hosted Feishu)" + "manual": "Создать вручную", + "larkManual": "Создать вручную (поддерживает self-hosted Feishu)" }, "scanTitle": "Отсканируйте в мобильном Feishu", "status": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/platform.json b/dashboard/src/i18n/locales/zh-CN/features/platform.json index e2bb348de..8faaf9a9f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/platform.json +++ b/dashboard/src/i18n/locales/zh-CN/features/platform.json @@ -152,6 +152,11 @@ "error": "登录失败" } }, + "dingtalk": { + "title": "钉钉扫码创建", + "scanTitle": "请使用手机钉钉扫码", + "created": "创建成功" + }, "start": "开始创建", "created": "创建成功", "startFailed": "发起扫码创建失败", @@ -159,7 +164,8 @@ "mode": { "title": "选择创建方式", "scan": "扫码一键创建", - "manual": "手动创建(支持企业自部署飞书平台)" + "manual": "手动创建", + "larkManual": "手动创建(支持企业自部署飞书平台)" }, "scanTitle": "请使用手机飞书扫码", "status": { diff --git a/docs/en/platform/dingtalk.md b/docs/en/platform/dingtalk.md index 9b21f7cdf..a4bb53fa2 100644 --- a/docs/en/platform/dingtalk.md +++ b/docs/en/platform/dingtalk.md @@ -16,6 +16,20 @@ Proactive message push: Supported. ## Create and Configure the App +DingTalk supports two setup methods: one-click QR creation in AstrBot, or manually creating an app in DingTalk Open Platform. + +### Option 1: One-click QR Creation + +AstrBot version requirement: >= v4.25.0. + +Open AstrBot Dashboard -> `Bots` -> `+ Create Bot`, then select `DingTalk`. + +Under `Creation Method`, select `One-click QR setup`, scan the QR code with the DingTalk mobile app, then create or bind a bot on the DingTalk authorization page. After creation succeeds, AstrBot automatically fills in `ClientID` and `ClientSecret`. Click `Save` to finish. + +After QR creation succeeds, continue checking the event subscription, version release, and group installation steps below. + +### Option 2: Manual Creation + Go to the [DingTalk Open Platform](https://open-dev.dingtalk.com/fe/app), then create an app: ![image](https://files.astrbot.app/docs/source/images/dingtalk/image-4.png) @@ -36,7 +50,7 @@ Go to Credentials & Basic Information, then copy `ClientID` and `ClientSecret`. Open AstrBot Dashboard -> `Bots` -> `+ Create Bot`, then create a DingTalk adapter. -Fill in `ClientID` and `ClientSecret`, then click Save. AstrBot will request authorization from DingTalk Open Platform automatically. +If you want AstrBot to create the app for you, select `One-click QR setup` and complete the scan. If you already created the app yourself, select `Manual setup`, fill in `ClientID` and `ClientSecret`, then click Save. AstrBot will request authorization from DingTalk Open Platform automatically. Back in DingTalk Open Platform, open Event Subscriptions, select `Stream mode push`, and click Save. If successful, you will see a connected status. diff --git a/docs/zh/platform/dingtalk.md b/docs/zh/platform/dingtalk.md index dcc141065..9139abd79 100644 --- a/docs/zh/platform/dingtalk.md +++ b/docs/zh/platform/dingtalk.md @@ -16,6 +16,20 @@ ## 创建和配置应用 +钉钉支持两种创建方式:在 AstrBot 中扫码一键创建,或在钉钉开放平台手动创建应用。 + +### 方式一:扫码一键创建 + +需要 AstrBot 版本 >= v4.25.0。 + +打开 AstrBot 管理面板 -> `机器人` -> `+ 创建机器人`,选择 `钉钉(DingTalk)`。 + +在 `选择创建方式` 中选择 `扫码一键创建`,使用手机钉钉扫描页面中的二维码,并在钉钉页面中创建或绑定机器人。创建成功后,AstrBot 会自动写入 `ClientID` 和 `ClientSecret`,此时点击 `保存` 即可。 + +扫码创建完成后,仍建议检查后文的事件订阅、版本发布和拉入群组步骤。 + +### 方式二:手动创建 + 前往 [钉钉开放平台](https://open-dev.dingtalk.com/fe/app),点击创建应用: ![image](https://files.astrbot.app/docs/source/images/dingtalk/image-4.png) @@ -36,7 +50,7 @@ 打开 AstrBot 管理面板 -> `机器人` -> `+ 创建机器人`,创建一个钉钉适配器。 -将刚刚复制的 `ClientID` 和 `ClientSecret` 填入,点击保存,AstrBot 将会自动向钉钉开放平台请求。 +如果使用扫码一键创建,选择 `扫码一键创建` 并完成扫码;如果使用自己创建的钉钉应用,选择 `手动创建`,将刚刚复制的 `ClientID` 和 `ClientSecret` 填入。点击保存后,AstrBot 将会自动向钉钉开放平台请求。 回到钉钉开放平台,点击事件订阅,选择 `Stream 模式推送`,点击保存,如果没有意外情况,将会看到 连接接入成功 字样。 diff --git a/tests/test_dingtalk_app_registration.py b/tests/test_dingtalk_app_registration.py new file mode 100644 index 000000000..63ea95905 --- /dev/null +++ b/tests/test_dingtalk_app_registration.py @@ -0,0 +1,47 @@ +from astrbot.core.platform.sources.dingtalk.app_registration import ( + DEFAULT_DINGTALK_REGISTRATION_BASE_URL, + DEFAULT_DINGTALK_REGISTRATION_SOURCE, + dingtalk_registration_base_url, + dingtalk_registration_poll_result, + dingtalk_registration_source, +) + + +def test_dingtalk_registration_defaults(monkeypatch): + monkeypatch.delenv("DINGTALK_REGISTRATION_BASE_URL", raising=False) + monkeypatch.delenv("DINGTALK_REGISTRATION_SOURCE", raising=False) + + assert dingtalk_registration_base_url() == DEFAULT_DINGTALK_REGISTRATION_BASE_URL + assert dingtalk_registration_source() == DEFAULT_DINGTALK_REGISTRATION_SOURCE + + +def test_dingtalk_registration_poll_result_maps_waiting_and_success(): + assert dingtalk_registration_poll_result({"status": "WAITING"}) == { + "status": "pending" + } + + assert dingtalk_registration_poll_result( + { + "status": "SUCCESS", + "client_id": "client-id", + "client_secret": "client-secret", + } + ) == { + "status": "created", + "client_id": "client-id", + "client_secret": "client-secret", + } + + +def test_dingtalk_registration_poll_result_maps_fail_and_expired(): + assert dingtalk_registration_poll_result( + {"status": "FAIL", "fail_reason": "denied"} + ) == { + "status": "error", + "message": "denied", + } + + assert dingtalk_registration_poll_result({"status": "EXPIRED"}) == { + "status": "expired", + "message": "钉钉扫码已过期,请重新创建", + }