Compare commits

...

1 Commits

Author SHA1 Message Date
Soulter
993d0424d5 feat(dingtalk): implement one-click QR registration and polling mechanism 2026-05-15 17:04:23 +08:00
10 changed files with 352 additions and 12 deletions

View File

@@ -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'}",
}

View File

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

View File

@@ -31,19 +31,19 @@
</v-select>
<div class="mt-3" v-if="selectedPlatformConfig">
<div v-if="isLarkPlatform">
<div class="lark-creation-title mt-4 mb-1">
<div class="creation-mode-title mt-4 mb-1">
{{ tm('registrationAction.mode.title') }}
</div>
<v-radio-group
v-model="larkCreationMode"
class="lark-creation-mode"
class="creation-mode-group"
hide-details
>
<v-radio value="scan" :label="tm('registrationAction.mode.scan')"></v-radio>
<v-radio value="manual" :label="tm('registrationAction.mode.manual')"></v-radio>
<v-radio value="manual" :label="tm('registrationAction.mode.larkManual')"></v-radio>
</v-radio-group>
<div v-if="larkCreationMode === 'scan'" class="lark-registration-inline mt-3">
<div v-if="larkCreationMode === 'scan'" class="registration-inline mt-3">
<PlatformRegistrationAction
:platform-config="selectedPlatformConfig"
:active="larkCreationMode === 'scan'"
@@ -54,6 +54,40 @@
</div>
<div v-else-if="larkCreationMode === 'manual'" class="mt-2">
<div class="platform-action-row">
<v-btn color="info" variant="tonal" @click="openTutorial" class="mt-2">
<v-icon start>mdi-book-open-variant</v-icon>
{{ tm('dialog.viewTutorial') }}
</v-btn>
</div>
<AstrBotConfig :iterable="selectedPlatformConfig" :metadata="metadata['platform_group']?.metadata"
metadataKey="platform" />
</div>
</div>
<div v-else-if="isDingtalkPlatform">
<div class="creation-mode-title mt-4 mb-1">
{{ tm('registrationAction.mode.title') }}
</div>
<v-radio-group
v-model="dingtalkCreationMode"
class="creation-mode-group"
hide-details
>
<v-radio value="scan" :label="tm('registrationAction.mode.scan')"></v-radio>
<v-radio value="manual" :label="tm('registrationAction.mode.manual')"></v-radio>
</v-radio-group>
<div v-if="dingtalkCreationMode === 'scan'" class="registration-inline mt-3">
<PlatformRegistrationAction
:platform-config="selectedPlatformConfig"
:active="dingtalkCreationMode === 'scan'"
@success="showSuccess"
@error="showError"
/>
</div>
<div v-else-if="dingtalkCreationMode === 'manual'" class="mt-2">
<div class="platform-action-row">
<v-btn color="info" variant="tonal" @click="openTutorial" class="mt-2">
<v-icon start>mdi-book-open-variant</v-icon>
@@ -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;

View File

@@ -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';

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -152,6 +152,11 @@
"error": "登录失败"
}
},
"dingtalk": {
"title": "钉钉扫码创建",
"scanTitle": "请使用手机钉钉扫码",
"created": "创建成功"
},
"start": "开始创建",
"created": "创建成功",
"startFailed": "发起扫码创建失败",
@@ -159,7 +164,8 @@
"mode": {
"title": "选择创建方式",
"scan": "扫码一键创建",
"manual": "手动创建(支持企业自部署飞书平台)"
"manual": "手动创建",
"larkManual": "手动创建(支持企业自部署飞书平台)"
},
"scanTitle": "请使用手机飞书扫码",
"status": {

View File

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

View File

@@ -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 模式推送`,点击保存,如果没有意外情况,将会看到 连接接入成功 字样。

View File

@@ -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": "钉钉扫码已过期,请重新创建",
}