mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
a27898507c
commit
acf6eccb3a
@ -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 SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
@ -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; }
|
||||
|
||||
109
mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts
Normal file
109
mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts
Normal 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 }
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
@ -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 自动填入下方。',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user