From acf6eccb3abbc97b320758a04a7a8d64b9aeb9f4 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 11:12:22 +0800 Subject: [PATCH] feat(dingtalk): one-click bot creation via OAuth device flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mateclaw-server/pom.xml | 2 +- .../controller/ChannelWebhookController.java | 53 +++++ .../DingTalkAppRegistrationService.java | 225 ++++++++++++++++++ mateclaw-ui/src/api/index.ts | 5 + .../components/channels/ChannelEditModal.vue | 56 +++++ .../channels/useDingTalkAppRegister.ts | 109 +++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 11 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 11 + 8 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkAppRegistrationService.java create mode 100644 mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 3bc9241d..a7bddb0c 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -195,7 +195,7 @@ com.dingtalk.open dingtalk-stream - 1.3.5 + 1.3.12 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java index 07bebb0c..a606ae85 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java @@ -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> 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> 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 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> feishuWebhook(@RequestBody Map payload) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkAppRegistrationService.java b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkAppRegistrationService.java new file mode 100644 index 00000000..13e22dfc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkAppRegistrationService.java @@ -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) + *

+ * 钉钉 SDK 没把 Device Flow 端点包装进 dingtalk-stream(那个 SDK 只管 WebSocket 长连接)。 + * 三个 HTTP 端点是钉钉官方的 OAuth 2.0 Device Flow 标准协议: + *

+ *   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?}
+ * 
+ *

+ * 跟 {@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 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 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 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> 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; + } + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 0603f01c..2800bce0 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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 ==================== diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index 474dfce7..3a3f1959 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -116,6 +116,39 @@ + +

+
+ {{ t('channels.dingtalkRegister.title') }} +
+

{{ t('channels.dingtalkRegister.hint') }}

+ +
+ +

+ + + + +

+
+
+
@@ -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(() => { @@ -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; } diff --git a/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts b/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts new file mode 100644 index 00000000..d5be922b --- /dev/null +++ b/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts @@ -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('') + + let pollTimer: ReturnType | 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 } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 478676dc..c67aae6b 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1643,6 +1643,17 @@ export default { step4: 'After starting the channel, auto-receives messages via WebSocket long connection, no public IP or callback URL required', }, }, + 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.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 4aed029c..76181c23 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1653,6 +1653,17 @@ export default { step4: '启动渠道后通过 WebSocket 长连接自动接收消息,无需公网 IP 和回调 URL', }, }, + 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 自动填入下方。',