mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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>
|
<dependency>
|
||||||
<groupId>com.dingtalk.open</groupId>
|
<groupId>com.dingtalk.open</groupId>
|
||||||
<artifactId>dingtalk-stream</artifactId>
|
<artifactId>dingtalk-stream</artifactId>
|
||||||
<version>1.3.5</version>
|
<version>1.3.12</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.channel.ChannelAdapter;
|
import vip.mate.channel.ChannelAdapter;
|
||||||
import vip.mate.channel.ChannelManager;
|
import vip.mate.channel.ChannelManager;
|
||||||
|
import vip.mate.channel.dingtalk.DingTalkAppRegistrationService;
|
||||||
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
||||||
import vip.mate.channel.discord.DiscordChannelAdapter;
|
import vip.mate.channel.discord.DiscordChannelAdapter;
|
||||||
import vip.mate.channel.feishu.FeishuAppRegistrationService;
|
import vip.mate.channel.feishu.FeishuAppRegistrationService;
|
||||||
@ -49,6 +50,7 @@ public class ChannelWebhookController {
|
|||||||
|
|
||||||
private final ChannelManager channelManager;
|
private final ChannelManager channelManager;
|
||||||
private final FeishuAppRegistrationService feishuAppRegistrationService;
|
private final FeishuAppRegistrationService feishuAppRegistrationService;
|
||||||
|
private final DingTalkAppRegistrationService dingTalkAppRegistrationService;
|
||||||
|
|
||||||
@Operation(summary = "钉钉消息回调")
|
@Operation(summary = "钉钉消息回调")
|
||||||
@PostMapping("/dingtalk")
|
@PostMapping("/dingtalk")
|
||||||
@ -63,6 +65,57 @@ public class ChannelWebhookController {
|
|||||||
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
|
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 = "飞书消息回调")
|
@Operation(summary = "飞书消息回调")
|
||||||
@PostMapping("/feishu")
|
@PostMapping("/feishu")
|
||||||
public ResponseEntity<Map<String, Object>> feishuWebhook(@RequestBody Map<String, Object> payload) {
|
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)}`),
|
http.post(`/channels/webhook/feishu/register/begin?domain=${encodeURIComponent(domain)}`),
|
||||||
feishuRegisterStatus: (sessionId: string) =>
|
feishuRegisterStatus: (sessionId: string) =>
|
||||||
http.get(`/channels/webhook/feishu/register/status?session=${encodeURIComponent(sessionId)}`),
|
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 ====================
|
// ==================== MCP Server ====================
|
||||||
|
|||||||
@ -116,6 +116,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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+) -->
|
<!-- 飞书一键应用注册(oapi-sdk 2.6+) -->
|
||||||
<div v-if="form.channelType === 'feishu'" class="feishu-register-card">
|
<div v-if="form.channelType === 'feishu'" class="feishu-register-card">
|
||||||
<div class="feishu-register-header">
|
<div class="feishu-register-header">
|
||||||
@ -396,6 +429,7 @@ import {
|
|||||||
import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll'
|
import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll'
|
||||||
import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth'
|
import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth'
|
||||||
import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister'
|
import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister'
|
||||||
|
import { useDingTalkAppRegister } from '@/composables/channels/useDingTalkAppRegister'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@ -473,6 +507,13 @@ const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => {
|
|||||||
channelConfig.value.app_secret = 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) ==========
|
// ========== Field defs (derived) ==========
|
||||||
|
|
||||||
const currentFieldDefs = computed<ChannelFieldDef[]>(() => {
|
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(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; }
|
.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 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-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; }
|
.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>',
|
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: {
|
feishuRegister: {
|
||||||
title: 'One-click Feishu App Creation',
|
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.',
|
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>',
|
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: {
|
feishuRegister: {
|
||||||
title: '一键创建飞书应用',
|
title: '一键创建飞书应用',
|
||||||
hint: '点击按钮获取二维码,使用飞书扫一扫并确认授权后,会自动在你的企业里创建自建应用,App ID / App Secret 自动填入下方。',
|
hint: '点击按钮获取二维码,使用飞书扫一扫并确认授权后,会自动在你的企业里创建自建应用,App ID / App Secret 自动填入下方。',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user