feat(dingtalk): one-click bot creation via OAuth device flow

Mirrors the feishu one-click flow: scan a QR with the DingTalk app,
approve, and the bot's client_id / client_secret get auto-filled instead
of forcing the user through the open-dev console. Saves about seven
manual steps per channel setup.

Backend
- Bump dingtalk-stream from 1.3.5 to 1.3.12. Diff against the classes we
  depend on (OpenDingTalkStreamClient, ChatbotMessage, MessageContent,
  GenericEventListener) is empty — pure point-release bumps, no API churn.
- New DingTalkAppRegistrationService: synchronously runs init + begin
  against /app/registration/{init,begin} on oapi.dingtalk.com to obtain
  the device_code and verification URL, then spawns a daemon worker that
  polls /app/registration/poll every 5s until SUCCESS / FAIL / EXPIRED is
  returned. Sessions evict after 7 minutes, worker has a 6-minute hard
  runtime cap, transient HTTP errors do not terminate the loop. Same
  shape as the feishu service, but written from scratch because the
  dingtalk-stream SDK doesn't wrap this OAuth device flow.
- Two new endpoints under /api/v1/channels/webhook:
  POST /dingtalk/register/begin returns session_id;
  GET  /dingtalk/register/status returns status + qrcode_img (data URI
  PNG, ZXing-encoded from the verification URL, matching the feishu and
  weixin flows). Status surface: waiting / confirmed / expired / denied.

Frontend
- channelApi.dingtalkRegisterBegin / dingtalkRegisterStatus.
- New useDingTalkAppRegister composable, structurally identical to
  useFeishuAppRegister minus the domain argument. Stops polling on
  terminal status, fires onConfirmed with {clientId, clientSecret}.
- ChannelEditModal: dingtalk-register-card rendered when channelType is
  dingtalk, scoped DingTalk blue (#1f79ff) to differentiate from feishu's
  indigo. onConfirmed writes channelConfig.client_id / client_secret so
  the existing form fields update reactively.
- i18n: channels.dingtalkRegister.* keys for title / hint / button states
  / scan / confirmed / expired / denied / startFailed.
This commit is contained in:
matevip 2026-04-28 11:12:22 +08:00
parent a27898507c
commit acf6eccb3a
8 changed files with 471 additions and 1 deletions

View File

@ -195,7 +195,7 @@
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.5</version>
<version>1.3.12</version>
</dependency>
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== -->

View File

@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import vip.mate.channel.ChannelAdapter;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.dingtalk.DingTalkAppRegistrationService;
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
import vip.mate.channel.discord.DiscordChannelAdapter;
import vip.mate.channel.feishu.FeishuAppRegistrationService;
@ -49,6 +50,7 @@ public class ChannelWebhookController {
private final ChannelManager channelManager;
private final FeishuAppRegistrationService feishuAppRegistrationService;
private final DingTalkAppRegistrationService dingTalkAppRegistrationService;
@Operation(summary = "钉钉消息回调")
@PostMapping("/dingtalk")
@ -63,6 +65,57 @@ public class ChannelWebhookController {
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
}
// ==================== 钉钉一键应用注册OAuth Device Flow ====================
@Operation(summary = "启动钉钉扫码注册应用流程")
@PostMapping("/dingtalk/register/begin")
public ResponseEntity<Map<String, Object>> dingtalkRegisterBegin() {
try {
DingTalkAppRegistrationService.RegistrationSession session = dingTalkAppRegistrationService.begin();
return ResponseEntity.ok(Map.of("session_id", session.sessionId));
} catch (Exception e) {
log.error("[dingtalk-register] begin failed: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to start registration: " + e.getMessage()));
}
}
@Operation(summary = "查询钉钉扫码注册状态")
@GetMapping("/dingtalk/register/status")
public ResponseEntity<Map<String, Object>> dingtalkRegisterStatus(@RequestParam("session") String sessionId) {
DingTalkAppRegistrationService.RegistrationSession session = dingTalkAppRegistrationService.getSession(sessionId);
if (session == null) {
return ResponseEntity.ok(Map.of("status", "expired", "error", "session not found or expired"));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", session.status.name().toLowerCase());
if (session.qrcodeUrl != null) {
body.put("qrcode_url", session.qrcodeUrl);
// Same as the feishu register flow: SDK gives us a verification URL string,
// browsers can't render that as an image, so encode into a PNG data URI here
// and cache it on the session so ZXing only runs once per registration attempt.
if (session.qrcodeImgDataUri == null) {
try {
String base64 = generateQrCodeBase64(session.qrcodeUrl);
session.qrcodeImgDataUri = "data:image/png;base64," + base64;
} catch (Exception e) {
log.warn("[dingtalk-register] QR encode failed: {}", e.getMessage());
}
}
if (session.qrcodeImgDataUri != null) {
body.put("qrcode_img", session.qrcodeImgDataUri);
}
}
if (session.status == DingTalkAppRegistrationService.Status.CONFIRMED) {
body.put("client_id", session.clientId);
body.put("client_secret", session.clientSecret);
}
if (session.errorMessage != null) {
body.put("error", session.errorMessage);
}
return ResponseEntity.ok(body);
}
@Operation(summary = "飞书消息回调")
@PostMapping("/feishu")
public ResponseEntity<Map<String, Object>> feishuWebhook(@RequestBody Map<String, Object> payload) {

View File

@ -0,0 +1,225 @@
package vip.mate.channel.dingtalk;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 钉钉"一键应用注册"服务OAuth Device Authorization Grant
* <p>
* 钉钉 SDK 没把 Device Flow 端点包装进 dingtalk-stream那个 SDK 只管 WebSocket 长连接
* 三个 HTTP 端点是钉钉官方的 OAuth 2.0 Device Flow 标准协议
* <pre>
* POST /app/registration/init body {source} {nonce}5 分钟 TTL
* POST /app/registration/begin body {nonce} {device_code, verification_uri_complete}
* POST /app/registration/poll body {device_code} {status: WAITING/SUCCESS/FAIL/EXPIRED, client_id?, client_secret?}
* </pre>
* <p>
* {@code FeishuAppRegistrationService} 同构但飞书走 SDK 阻塞调用 + 回调钉钉这边纯 HTTP
* 我们自己起 worker 线程做轮询 5 秒一次直到终态
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DingTalkAppRegistrationService {
private static final String API_BASE = "https://oapi.dingtalk.com";
private static final String SOURCE = "MATECLAW";
private static final long POLL_INTERVAL_MS = 5000L;
private static final long POLL_REQUEST_TIMEOUT_MS = 10_000L;
private static final long INIT_REQUEST_TIMEOUT_MS = 15_000L;
/** Session lifetime upper bound: device_code expires in ~5 min, give 7 min buffer for late polls. */
private static final long SESSION_TTL_MS = 7 * 60_000L;
/** Max wall-clock for the polling worker — kills runaway sessions even if DingTalk never returns terminal state. */
private static final long WORKER_MAX_RUNTIME_MS = 6 * 60_000L;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
/** session_id → registration session */
private final ConcurrentHashMap<String, RegistrationSession> sessions = new ConcurrentHashMap<>();
/**
* Kick off a registration: do init + begin synchronously to get the QR URL, then spawn a worker
* that polls /poll until terminal. Returns immediately with the session id.
*/
public RegistrationSession begin() throws Exception {
evictExpiredSessions();
// Step 1: init (synchronous, surfaces errors to caller before any session is recorded)
Map<?, ?> init = postJson("/app/registration/init", Map.of("source", SOURCE), INIT_REQUEST_TIMEOUT_MS);
Integer initCode = init.get("errcode") instanceof Number n ? n.intValue() : null;
if (initCode != null && initCode != 0) {
throw new IllegalStateException("DingTalk init failed: errcode=" + initCode + ", errmsg=" + init.get("errmsg"));
}
String nonce = (String) init.get("nonce");
if (nonce == null || nonce.isBlank()) {
throw new IllegalStateException("DingTalk init returned empty nonce");
}
// Step 2: begin (exchanges nonce for device_code + QR URL)
Map<?, ?> begin = postJson("/app/registration/begin", Map.of("nonce", nonce), INIT_REQUEST_TIMEOUT_MS);
Integer beginCode = begin.get("errcode") instanceof Number n ? n.intValue() : null;
if (beginCode != null && beginCode != 0) {
throw new IllegalStateException("DingTalk begin failed: errcode=" + beginCode + ", errmsg=" + begin.get("errmsg"));
}
String deviceCode = (String) begin.get("device_code");
String verificationUri = (String) begin.get("verification_uri_complete");
if (deviceCode == null || deviceCode.isBlank() || verificationUri == null || verificationUri.isBlank()) {
throw new IllegalStateException("DingTalk begin returned empty device_code or URI");
}
// Build session with QR URL ready, then spawn worker
String sessionId = UUID.randomUUID().toString();
RegistrationSession session = new RegistrationSession(sessionId);
session.qrcodeUrl = verificationUri;
session.status = Status.WAITING;
sessions.put(sessionId, session);
Thread worker = new Thread(() -> pollUntilTerminal(session, deviceCode),
"dingtalk-register-" + sessionId.substring(0, 8));
worker.setDaemon(true);
worker.start();
log.info("[dingtalk-register] session {} started (device_code suffix=...{})",
sessionId, deviceCode.length() > 6 ? deviceCode.substring(deviceCode.length() - 6) : deviceCode);
return session;
}
public RegistrationSession getSession(String sessionId) {
evictExpiredSessions();
return sessions.get(sessionId);
}
/**
* Polling worker. Waits {@link #POLL_INTERVAL_MS} between polls until DingTalk returns a terminal
* status, hard wall-clock cap of {@link #WORKER_MAX_RUNTIME_MS} so a server-side hang doesn't
* leak threads.
*/
private void pollUntilTerminal(RegistrationSession session, String deviceCode) {
long startMs = System.currentTimeMillis();
while (true) {
if (System.currentTimeMillis() - startMs > WORKER_MAX_RUNTIME_MS) {
session.status = Status.EXPIRED;
session.errorMessage = "polling worker timed out";
session.lastUpdateMs = System.currentTimeMillis();
log.warn("[dingtalk-register] session {} timed out after {} ms",
session.sessionId, WORKER_MAX_RUNTIME_MS);
return;
}
try {
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
try {
Map<?, ?> poll = postJson("/app/registration/poll", Map.of("device_code", deviceCode), POLL_REQUEST_TIMEOUT_MS);
String status = (String) poll.get("status");
if (status == null) status = "WAITING";
session.lastUpdateMs = System.currentTimeMillis();
switch (status) {
case "SUCCESS" -> {
session.clientId = (String) poll.get("client_id");
session.clientSecret = (String) poll.get("client_secret");
session.status = Status.CONFIRMED;
log.info("[dingtalk-register] session {} confirmed, clientId={}",
session.sessionId, session.clientId);
return;
}
case "FAIL" -> {
Object failReason = poll.get("fail_reason");
session.errorMessage = failReason != null ? failReason.toString() : "unknown";
session.status = Status.DENIED;
log.info("[dingtalk-register] session {} denied: {}",
session.sessionId, session.errorMessage);
return;
}
case "EXPIRED" -> {
session.status = Status.EXPIRED;
log.info("[dingtalk-register] session {} expired", session.sessionId);
return;
}
case "WAITING" -> {
// keep polling
}
default -> {
log.debug("[dingtalk-register] session {} unknown status: {}", session.sessionId, status);
}
}
} catch (Exception e) {
// Transient errors don't fail the session keep polling, the device_code is still valid
log.debug("[dingtalk-register] poll attempt failed (will retry): {}", e.getMessage());
}
}
}
private Map<?, ?> postJson(String path, Map<String, ?> body, long timeoutMs) throws Exception {
String json = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_BASE + path))
.header("Content-Type", "application/json; charset=utf-8")
.timeout(Duration.ofMillis(timeoutMs))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("DingTalk " + path + " HTTP " + response.statusCode() + ": " + response.body());
}
return objectMapper.readValue(response.body(), Map.class);
}
private void evictExpiredSessions() {
long cutoff = System.currentTimeMillis() - SESSION_TTL_MS;
Iterator<Map.Entry<String, RegistrationSession>> it = sessions.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().createdAtMs < cutoff) it.remove();
}
}
public enum Status {
/** init/begin done, polling for user scan + confirmation */
WAITING,
/** User confirmed; clientId/clientSecret returned */
CONFIRMED,
/** device_code expired before user finished */
EXPIRED,
/** User explicitly denied or DingTalk returned FAIL */
DENIED
}
public static class RegistrationSession {
public final String sessionId;
final long createdAtMs = System.currentTimeMillis();
public volatile Status status = Status.WAITING;
public volatile String qrcodeUrl;
/** Cached "data:image/png;base64,..." rendered from qrcodeUrl by the controller. */
public volatile String qrcodeImgDataUri;
public volatile String clientId;
public volatile String clientSecret;
public volatile String errorMessage;
public volatile long lastUpdateMs = System.currentTimeMillis();
RegistrationSession(String sessionId) {
this.sessionId = sessionId;
}
}
}

View File

@ -237,6 +237,11 @@ export const channelApi = {
http.post(`/channels/webhook/feishu/register/begin?domain=${encodeURIComponent(domain)}`),
feishuRegisterStatus: (sessionId: string) =>
http.get(`/channels/webhook/feishu/register/status?session=${encodeURIComponent(sessionId)}`),
// DingTalk one-click app registration (OAuth Device Flow)
dingtalkRegisterBegin: () =>
http.post('/channels/webhook/dingtalk/register/begin'),
dingtalkRegisterStatus: (sessionId: string) =>
http.get(`/channels/webhook/dingtalk/register/status?session=${encodeURIComponent(sessionId)}`),
}
// ==================== MCP Server ====================

View File

@ -116,6 +116,39 @@
</div>
</div>
<!-- 钉钉一键机器人注册OAuth Device Flow -->
<div v-if="form.channelType === 'dingtalk'" class="dingtalk-register-card">
<div class="dingtalk-register-header">
<strong>{{ t('channels.dingtalkRegister.title') }}</strong>
</div>
<p class="dingtalk-register-hint">{{ t('channels.dingtalkRegister.hint') }}</p>
<button
type="button"
class="dingtalk-register-btn"
@click="dingtalkRegister.start()"
:disabled="dingtalkRegister.loading.value || dingtalkRegister.status.value === 'waiting'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/>
<line x1="21" y1="14" x2="21" y2="17"/><line x1="14" y1="21" x2="17" y2="21"/>
<line x1="21" y1="21" x2="21" y2="21"/>
</svg>
{{ dingtalkRegister.loading.value
? t('channels.dingtalkRegister.buttonLoading')
: t('channels.dingtalkRegister.button') }}
</button>
<div v-if="dingtalkRegister.qrcodeUrl.value" class="dingtalk-register-qrcode">
<img :src="dingtalkRegister.qrcodeUrl.value" :alt="t('channels.dingtalkRegister.button')" class="dingtalk-register-qrcode-img" />
<p class="dingtalk-register-status" :class="dingtalkRegister.status.value">
<template v-if="dingtalkRegister.status.value === 'confirmed'">{{ t('channels.dingtalkRegister.confirmed') }}</template>
<template v-else-if="dingtalkRegister.status.value === 'expired'">{{ t('channels.dingtalkRegister.expired') }}</template>
<template v-else-if="dingtalkRegister.status.value === 'denied'">{{ t('channels.dingtalkRegister.denied') }}</template>
<template v-else>{{ t('channels.dingtalkRegister.scanHint') }}</template>
</p>
</div>
</div>
<!-- 飞书一键应用注册oapi-sdk 2.6+ -->
<div v-if="form.channelType === 'feishu'" class="feishu-register-card">
<div class="feishu-register-header">
@ -396,6 +429,7 @@ import {
import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll'
import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth'
import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister'
import { useDingTalkAppRegister } from '@/composables/channels/useDingTalkAppRegister'
interface Props {
modelValue: boolean
@ -473,6 +507,13 @@ const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => {
channelConfig.value.app_secret = appSecret
})
// DingTalk one-click app registration via Device Flow same UX shape as
// feishu's flow, returns client_id/client_secret instead.
const dingtalkRegister = useDingTalkAppRegister(({ clientId, clientSecret }) => {
channelConfig.value.client_id = clientId
channelConfig.value.client_secret = clientSecret
})
// ========== Field defs (derived) ==========
const currentFieldDefs = computed<ChannelFieldDef[]>(() => {
@ -735,6 +776,21 @@ function save() {
.guide-steps :deep(code) { font-size: 12px; background: var(--mc-bg-sunken); padding: 1px 5px; border-radius: 3px; }
.guide-steps :deep(b) { color: var(--mc-text-primary); font-weight: 600; }
/* DingTalk one-click register */
.dingtalk-register-card { background: linear-gradient(135deg, rgba(31,121,255,0.05), rgba(0,144,255,0.05)); border: 1px solid rgba(31,121,255,0.2); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.dingtalk-register-header { font-size: 13px; color: var(--mc-text-primary); margin-bottom: 6px; }
.dingtalk-register-hint { font-size: 12px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; }
.dingtalk-register-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 10px 16px; background: #1f79ff; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.dingtalk-register-btn:hover:not(:disabled) { background: #1668e3; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(31,121,255,0.3); }
.dingtalk-register-btn:active:not(:disabled) { transform: translateY(0); }
.dingtalk-register-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.dingtalk-register-qrcode { display: flex; flex-direction: column; align-items: center; margin-top: 16px; padding: 16px; background: #fff; border-radius: 8px; border: 1px solid var(--mc-border); }
.dingtalk-register-qrcode-img { width: 200px; height: 200px; border-radius: 4px; }
.dingtalk-register-status { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; text-align: center; }
.dingtalk-register-status.confirmed { color: #10b981; font-weight: 500; }
.dingtalk-register-status.expired { color: #f56c6c; }
.dingtalk-register-status.denied { color: #f56c6c; }
/* Feishu one-click register */
.feishu-register-card { background: linear-gradient(135deg, rgba(0,128,255,0.05), rgba(99,102,241,0.05)); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.feishu-register-header { font-size: 13px; color: var(--mc-text-primary); margin-bottom: 6px; }

View File

@ -0,0 +1,109 @@
import { ref, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { channelApi } from '@/api'
export type DingTalkRegisterStatus = '' | 'waiting' | 'confirmed' | 'expired' | 'denied'
export interface DingTalkRegisterResult {
clientId: string
clientSecret: string
}
/**
* "一键应用注册"
* 1. POST /dingtalk/register/begin sessionId worker 5s /poll
* 2. 2s /dingtalk/register/status qrcode_img / confirmed
* 3. confirmed / expired / denied
*
* useFeishuAppRegister begin domain
*/
export function useDingTalkAppRegister(onConfirmed: (r: DingTalkRegisterResult) => void) {
const { t } = useI18n()
const qrcodeUrl = ref('')
const loading = ref(false)
const status = ref<DingTalkRegisterStatus>('')
let pollTimer: ReturnType<typeof setInterval> | null = null
let confirmedFired = false
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function reset() {
stopPolling()
qrcodeUrl.value = ''
status.value = ''
confirmedFired = false
}
async function start() {
reset()
loading.value = true
let sessionId = ''
try {
const res: any = await channelApi.dingtalkRegisterBegin()
sessionId = res?.data?.session_id || res?.session_id || ''
if (!sessionId) {
ElMessage.error(t('channels.dingtalkRegister.startFailed'))
return
}
status.value = 'waiting'
} catch {
ElMessage.error(t('channels.dingtalkRegister.startFailed'))
return
} finally {
loading.value = false
}
pollTimer = setInterval(async () => {
try {
const res: any = await channelApi.dingtalkRegisterStatus(sessionId)
const data = res?.data || res || {}
const s = (data.status as DingTalkRegisterStatus) || 'waiting'
// Prefer the backend-rendered base64 PNG. The raw qrcode_url is the
// verification URL that needs encoding into a QR image; browsers can't
// render plain text as an image. Fall back to URL only as a defensive
// last resort.
const img = data.qrcode_img || data.qrcode_url
if (img && qrcodeUrl.value !== img) {
qrcodeUrl.value = img
}
status.value = s
if (s === 'confirmed') {
if (confirmedFired) return
confirmedFired = true
stopPolling()
const clientId = data.client_id || ''
const clientSecret = data.client_secret || ''
if (clientId && clientSecret) {
onConfirmed({ clientId, clientSecret })
ElMessage.success(t('channels.dingtalkRegister.confirmed'))
}
return
}
if (s === 'expired') {
stopPolling()
ElMessage.warning(t('channels.dingtalkRegister.expired'))
} else if (s === 'denied') {
stopPolling()
ElMessage.warning(t('channels.dingtalkRegister.denied'))
}
} catch {
// Silent — transient network errors should not abort the loop.
}
}, 2000)
}
onBeforeUnmount(stopPolling)
return { qrcodeUrl, loading, status, start, reset, stopPolling }
}

View File

@ -1643,6 +1643,17 @@ export default {
step4: 'After starting the channel, auto-receives messages via <b>WebSocket long connection</b>, <b>no public IP or callback URL required</b>',
},
},
dingtalkRegister: {
title: 'One-click DingTalk Bot Creation',
hint: 'Click the button to get a QR code. Scan it with DingTalk and approve authorization — a bot app will be created in your tenant and the Client ID / Client Secret will be auto-filled below.',
button: 'Scan to Create Bot',
buttonLoading: 'Preparing QR code…',
scanHint: 'Scan the QR code above with the DingTalk app and confirm authorization',
confirmed: 'Bot created. Client ID and Secret have been auto-filled.',
expired: 'QR code expired, please try again',
denied: 'Authorization was denied',
startFailed: 'Failed to fetch QR code, please check your network',
},
feishuRegister: {
title: 'One-click Feishu App Creation',
hint: 'Click the button to get a QR code. Scan it with Feishu and confirm authorization — an in-house app will be created in your tenant and the App ID / App Secret will be auto-filled below.',

View File

@ -1653,6 +1653,17 @@ export default {
step4: '启动渠道后通过 <b>WebSocket 长连接</b>自动接收消息,<b>无需公网 IP 和回调 URL</b>',
},
},
dingtalkRegister: {
title: '一键创建钉钉机器人',
hint: '点击按钮获取二维码使用钉钉扫一扫并授权后会自动在你的企业里创建机器人应用Client ID / Client Secret 自动填入下方。',
button: '扫码自动创建机器人',
buttonLoading: '正在准备二维码…',
scanHint: '请使用钉钉 App 扫描上方二维码并确认授权',
confirmed: '机器人创建成功Client ID 和 Secret 已自动填入',
expired: '二维码已过期,请重新生成',
denied: '授权被拒绝',
startFailed: '获取二维码失败,请检查网络',
},
feishuRegister: {
title: '一键创建飞书应用',
hint: '点击按钮获取二维码使用飞书扫一扫并确认授权后会自动在你的企业里创建自建应用App ID / App Secret 自动填入下方。',