From 51e6542a5a69b64090c7552dcf920662a519cced Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 21:47:49 +0800 Subject: [PATCH] feat(channel/qq): add scan-to-bind onboarding via QQ Open Platform Lite portal --- .../channel/qq/QQAppRegistrationService.java | 249 ++++++++++++++++++ .../vip/mate/channel/qq/QQBindCrypto.java | 59 +++++ .../channel/qrcode/QQQRCodeAuthProvider.java | 73 +++++ mateclaw-ui/src/api/index.ts | 5 + .../components/channels/ChannelEditModal.vue | 65 +++++ .../channels/ChannelOnboardingWizard.vue | 42 +++ .../composables/channels/useQqAppRegister.ts | 116 ++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 12 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 12 + 9 files changed, 633 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/qq/QQAppRegistrationService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/qq/QQBindCrypto.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/qrcode/QQQRCodeAuthProvider.java create mode 100644 mateclaw-ui/src/composables/channels/useQqAppRegister.ts diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qq/QQAppRegistrationService.java b/mateclaw-server/src/main/java/vip/mate/channel/qq/QQAppRegistrationService.java new file mode 100644 index 00000000..a1b43e47 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qq/QQAppRegistrationService.java @@ -0,0 +1,249 @@ +package vip.mate.channel.qq; + +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.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * QQ Bot "scan-to-bind" registration service. + *

+ * Drives the QQ Open Platform Lite bind portal: + *

+ *   POST {portal}/lite/create_bind_task   body {key}        → {task_id}
+ *   POST {portal}/lite/poll_bind_result   body {task_id}    → {status, bot_appid?, bot_encrypt_secret?, user_openid?}
+ * 
+ *

+ * The {@code key} is a base64-encoded 256-bit random AES key generated locally + * — the portal uses it to AES-256-GCM-encrypt {@code client_secret} so the + * plaintext never travels in the clear. Decryption happens here, after which + * the session exposes {@code clientId} / {@code clientSecret} to the SPI + * provider. + *

+ * Sessions live in memory (ConcurrentHashMap) with a 12-minute TTL — the + * QR code itself expires after ~5 min on the portal side, the extra buffer + * is for late polls. A background worker polls the portal every 2s until + * a terminal state, capped at 6 min wall-clock to avoid thread leaks. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class QQAppRegistrationService { + + /** QQ Open Platform portal host (overridable for proxies / test envs). */ + private static final String PORTAL_HOST = + System.getenv().getOrDefault("QQ_BIND_PORTAL_HOST", "q.qq.com"); + /** Vendor source tag forwarded to the portal in the QR URL. */ + private static final String PORTAL_SOURCE = "mateclaw"; + /** Portal path that hosts the user-facing scan landing page. */ + private static final String PORTAL_CONNECT_PATH = "/qqbot/openclaw/connect.html"; + + private static final long POLL_INTERVAL_MS = 2_000L; + private static final long POLL_REQUEST_TIMEOUT_MS = 10_000L; + private static final long INIT_REQUEST_TIMEOUT_MS = 15_000L; + private static final long SESSION_TTL_MS = 12 * 60_000L; + private static final long WORKER_MAX_RUNTIME_MS = 6 * 60_000L; + + /** Portal status codes (bind portal returns numeric codes, not strings). */ + private static final int PORTAL_STATUS_PENDING = 1; + private static final int PORTAL_STATUS_COMPLETED = 2; + private static final int PORTAL_STATUS_EXPIRED = 3; + + private final ObjectMapper objectMapper; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + /** + * Kick off a new bind session. Returns immediately with the QR URL set; + * polling for completion happens in a background worker. + */ + public RegistrationSession begin() throws Exception { + evictExpiredSessions(); + + String aesKey = QQBindCrypto.generateKey(); + Map response = postJson("/lite/create_bind_task", Map.of("key", aesKey), INIT_REQUEST_TIMEOUT_MS); + Integer retcode = response.get("retcode") instanceof Number n ? n.intValue() : null; + if (retcode == null || retcode != 0) { + throw new IllegalStateException( + "create_bind_task failed: retcode=" + retcode + ", msg=" + response.get("msg")); + } + Object dataObj = response.get("data"); + if (!(dataObj instanceof Map data)) { + throw new IllegalStateException("create_bind_task returned no data"); + } + String taskId = data.get("task_id") instanceof String s ? s : null; + if (taskId == null || taskId.isBlank()) { + throw new IllegalStateException("create_bind_task returned empty task_id"); + } + + String sessionId = UUID.randomUUID().toString(); + RegistrationSession session = new RegistrationSession(sessionId); + session.qrcodeUrl = buildConnectUrl(taskId); + session.status = Status.WAITING; + sessions.put(sessionId, session); + + Thread worker = new Thread(() -> pollUntilTerminal(session, taskId, aesKey), + "qq-register-" + sessionId.substring(0, 8)); + worker.setDaemon(true); + worker.start(); + + log.info("[qq-register] session {} started (task_id suffix=...{})", + sessionId, taskId.length() > 6 ? taskId.substring(taskId.length() - 6) : taskId); + return session; + } + + public RegistrationSession getSession(String sessionId) { + evictExpiredSessions(); + return sessions.get(sessionId); + } + + private void pollUntilTerminal(RegistrationSession session, String taskId, String aesKey) { + 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("[qq-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 response = postJson("/lite/poll_bind_result", + Map.of("task_id", taskId), POLL_REQUEST_TIMEOUT_MS); + Integer retcode = response.get("retcode") instanceof Number n ? n.intValue() : null; + if (retcode == null || retcode != 0) { + log.debug("[qq-register] poll non-zero retcode={}, msg={} (will retry)", + retcode, response.get("msg")); + continue; + } + Object dataObj = response.get("data"); + if (!(dataObj instanceof Map data)) { + continue; + } + int portalStatus = data.get("status") instanceof Number n ? n.intValue() : 0; + session.lastUpdateMs = System.currentTimeMillis(); + + switch (portalStatus) { + case PORTAL_STATUS_COMPLETED -> { + String appId = data.get("bot_appid") instanceof String s ? s + : (data.get("bot_appid") != null ? data.get("bot_appid").toString() : null); + String encryptedSecret = data.get("bot_encrypt_secret") instanceof String s ? s : null; + String userOpenid = data.get("user_openid") instanceof String s ? s : null; + if (appId == null || encryptedSecret == null) { + session.status = Status.DENIED; + session.errorMessage = "portal returned completed without credentials"; + log.warn("[qq-register] session {} completed but missing credentials", session.sessionId); + return; + } + try { + session.clientSecret = QQBindCrypto.decryptSecret(encryptedSecret, aesKey); + } catch (Exception e) { + session.status = Status.DENIED; + session.errorMessage = "failed to decrypt client_secret: " + e.getMessage(); + log.error("[qq-register] session {} decrypt failed: {}", + session.sessionId, e.getMessage()); + return; + } + session.clientId = appId; + session.userOpenid = userOpenid; + session.status = Status.CONFIRMED; + log.info("[qq-register] session {} confirmed, appId={}", session.sessionId, appId); + return; + } + case PORTAL_STATUS_EXPIRED -> { + session.status = Status.EXPIRED; + log.info("[qq-register] session {} expired", session.sessionId); + return; + } + case PORTAL_STATUS_PENDING -> { + // keep polling + } + default -> log.debug("[qq-register] session {} unknown portal status: {}", + session.sessionId, portalStatus); + } + } catch (Exception e) { + log.debug("[qq-register] poll attempt failed (will retry): {}", e.getMessage()); + } + } + } + + private String buildConnectUrl(String taskId) { + String encoded = URLEncoder.encode(taskId, StandardCharsets.UTF_8); + return "https://" + PORTAL_HOST + PORTAL_CONNECT_PATH + + "?task_id=" + encoded + "&_wv=2&source=" + PORTAL_SOURCE; + } + + private Map postJson(String path, Map body, long timeoutMs) throws Exception { + String json = objectMapper.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://" + PORTAL_HOST + path)) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .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("portal " + 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 { + WAITING, CONFIRMED, EXPIRED, DENIED + } + + public static class RegistrationSession { + public final String sessionId; + final long createdAtMs = System.currentTimeMillis(); + + public volatile Status status = Status.WAITING; + public volatile String qrcodeUrl; + public volatile String qrcodeImgDataUri; + /** Decrypted bot app_id (filled on confirmed). */ + public volatile String clientId; + /** Decrypted bot client_secret (filled on confirmed). */ + public volatile String clientSecret; + /** OpenID of the user who scanned (filled on confirmed). */ + public volatile String userOpenid; + public volatile String errorMessage; + public volatile long lastUpdateMs = System.currentTimeMillis(); + + RegistrationSession(String sessionId) { + this.sessionId = sessionId; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qq/QQBindCrypto.java b/mateclaw-server/src/main/java/vip/mate/channel/qq/QQBindCrypto.java new file mode 100644 index 00000000..e9ada0ee --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qq/QQBindCrypto.java @@ -0,0 +1,59 @@ +package vip.mate.channel.qq; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * AES-256-GCM helpers for the QQ scan-to-bind onboarding flow. + * + *

The bind portal encrypts the bot {@code client_secret} with a key + * supplied by this server, so the plaintext secret never travels in the + * clear. Ciphertext layout returned by the portal is: + * + *

base64( IV(12 bytes) ‖ ciphertext(N bytes) ‖ AuthTag(16 bytes) )
+ */ +final class QQBindCrypto { + + private static final int KEY_BYTES = 32; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + private static final SecureRandom RANDOM = new SecureRandom(); + + private QQBindCrypto() {} + + /** Generate a fresh 256-bit AES key, base64-encoded. */ + static String generateKey() { + byte[] key = new byte[KEY_BYTES]; + RANDOM.nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + + /** + * Decrypt an AES-256-GCM ciphertext produced by the bind portal. + * + * @param encryptedBase64 base64-encoded {@code IV ‖ ciphertext ‖ tag} + * @param keyBase64 base64 AES key (same one passed to create_bind_task) + * @return decrypted UTF-8 plaintext + */ + static String decryptSecret(String encryptedBase64, String keyBase64) throws Exception { + byte[] key = Base64.getDecoder().decode(keyBase64); + byte[] raw = Base64.getDecoder().decode(encryptedBase64); + if (raw.length < IV_BYTES + (TAG_BITS / 8)) { + throw new IllegalArgumentException("ciphertext too short"); + } + byte[] iv = new byte[IV_BYTES]; + System.arraycopy(raw, 0, iv, 0, IV_BYTES); + byte[] ciphertextWithTag = new byte[raw.length - IV_BYTES]; + System.arraycopy(raw, IV_BYTES, ciphertextWithTag, 0, ciphertextWithTag.length); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec keySpec = new SecretKeySpec(key, "AES"); + GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_BITS, iv); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); + byte[] plaintext = cipher.doFinal(ciphertextWithTag); + return new String(plaintext, java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/QQQRCodeAuthProvider.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/QQQRCodeAuthProvider.java new file mode 100644 index 00000000..7c21a9e2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/QQQRCodeAuthProvider.java @@ -0,0 +1,73 @@ +package vip.mate.channel.qrcode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.channel.qq.QQAppRegistrationService; +import vip.mate.channel.qrcode.util.QrCodeImageEncoder; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * QR-code auth provider for the QQ Bot scan-to-bind flow. + * + *

Wraps {@link QQAppRegistrationService} to expose its session model + * through the unified {@link ChannelQRCodeAuthProvider} contract. On + * {@code status=confirmed}, the credentials surface as {@code app_id} and + * {@code client_secret} — matching the keys the QQ channel adapter reads + * from {@code configJson}. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class QQQRCodeAuthProvider implements ChannelQRCodeAuthProvider { + + private final QQAppRegistrationService service; + + @Override + public String channelType() { + return "qq"; + } + + @Override + public Map begin(Map params) throws Exception { + QQAppRegistrationService.RegistrationSession session = service.begin(); + return Map.of("session_id", session.sessionId); + } + + @Override + public Map pollStatus(String sessionId) { + QQAppRegistrationService.RegistrationSession session = service.getSession(sessionId); + if (session == null) { + return 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); + if (session.qrcodeImgDataUri == null) { + try { + session.qrcodeImgDataUri = QrCodeImageEncoder.toDataUri(session.qrcodeUrl); + } catch (Exception e) { + log.warn("[qq-register] QR encode failed: {}", e.getMessage()); + } + } + if (session.qrcodeImgDataUri != null) { + body.put("qrcode_img", session.qrcodeImgDataUri); + } + } + if (session.status == QQAppRegistrationService.Status.CONFIRMED) { + // Key names mirror configJson fields that QQChannelAdapter reads. + body.put("app_id", session.clientId); + body.put("client_secret", session.clientSecret); + if (session.userOpenid != null) { + body.put("user_openid", session.userOpenid); + } + } + if (session.errorMessage != null) { + body.put("error", session.errorMessage); + } + return body; + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index def69533..93cf2af9 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -463,6 +463,11 @@ export const channelApi = { http.post('/channels/webhook/dingtalk/register/begin'), dingtalkRegisterStatus: (sessionId: string) => http.get(`/channels/webhook/dingtalk/register/status?session=${encodeURIComponent(sessionId)}`), + // QQ Bot scan-to-bind (Lite portal). Uses the unified channel QR auth endpoint. + qqRegisterBegin: () => + http.post('/channels/qrcode/qq/begin'), + qqRegisterStatus: (sessionId: string) => + http.get(`/channels/qrcode/qq/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 affb044c..c1b9dc56 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -194,6 +194,43 @@ + +

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

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

+ +
+
+

{{ t('channels.qqRegister.qrcodeLoading') }}…

+
+
+ +

+ + + + +

+
+
+

{{ t('channels.wecom.authHint') }}

@@ -442,6 +479,7 @@ import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll' import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth' import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister' import { useDingTalkAppRegister } from '@/composables/channels/useDingTalkAppRegister' +import { useQqAppRegister } from '@/composables/channels/useQqAppRegister' import AgentPickerDialog from '@/components/common/AgentPickerDialog.vue' interface Props { @@ -534,6 +572,15 @@ const dingtalkRegister = useDingTalkAppRegister(({ clientId, clientSecret }) => form.value.enabled = true }) +// QQ Bot scan-to-bind via the Lite portal — fills app_id/client_secret. +// The user must have created the bot on q.qq.com beforehand; this flow only +// skips the manual copy-paste of credentials. +const qqRegister = useQqAppRegister(({ appId, clientSecret }) => { + channelConfig.value.app_id = appId + channelConfig.value.client_secret = clientSecret + form.value.enabled = true +}) + // ========== Field defs (derived) ========== const currentFieldDefs = computed(() => { @@ -869,6 +916,24 @@ function save() { .dingtalk-register-status.expired { color: #f56c6c; } .dingtalk-register-status.denied { color: #f56c6c; } +/* QQ scan-to-bind (QQ Open Platform Lite portal) */ +.qq-register-card { background: linear-gradient(135deg, rgba(20,134,255,0.05), rgba(96,165,250,0.05)); border: 1px solid rgba(20,134,255,0.2); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; } +.qq-register-header { font-size: 13px; color: var(--mc-text-primary); margin-bottom: 6px; } +.qq-register-hint { font-size: 12px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; } +.qq-register-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 10px 16px; background: #1486ff; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; } +.qq-register-btn:hover:not(:disabled) { background: #0d6fd9; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(20,134,255,0.3); } +.qq-register-btn:active:not(:disabled) { transform: translateY(0); } +.qq-register-btn:disabled { opacity: 0.6; cursor: not-allowed; } +.qq-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); } +.qq-register-qrcode-img { width: 200px; height: 200px; border-radius: 4px; } +.qq-register-qrcode--loading { min-height: 240px; justify-content: center; } +.qq-register-qrcode-spinner { width: 40px; height: 40px; border: 3px solid rgba(20,134,255,0.2); border-top-color: #1486ff; border-radius: 50%; animation: qq-register-spin 0.8s linear infinite; } +@keyframes qq-register-spin { to { transform: rotate(360deg); } } +.qq-register-status { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; text-align: center; } +.qq-register-status.confirmed { color: #10b981; font-weight: 500; } +.qq-register-status.expired { color: #f56c6c; } +.qq-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/components/channels/ChannelOnboardingWizard.vue b/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue index cce38d09..18f67255 100644 --- a/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue +++ b/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue @@ -81,6 +81,40 @@
+ +
+

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

+ +
+
+

{{ t('channels.qqRegister.qrcodeLoading') }}…

+
+
+ QR Code +

+ + + + +

+
+
+
@@ -280,6 +314,7 @@ import { import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth' import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll' import { useDingTalkAppRegister } from '@/composables/channels/useDingTalkAppRegister' +import { useQqAppRegister } from '@/composables/channels/useQqAppRegister' import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister' import AgentPickerDialog from '@/components/common/AgentPickerDialog.vue' @@ -401,6 +436,13 @@ const feishuAuth = useFeishuAppRegister(({ appId, appSecret }) => { void onSaveAndTest() }) +// QQ scan-to-bind is hybrid (kept alongside manual fields), so we don't +// auto-advance — let the user see fields populated and click Next when ready. +const qqAuth = useQqAppRegister(({ appId, clientSecret }) => { + channelConfig.value.app_id = appId + channelConfig.value.client_secret = clientSecret +}) + const needsQrDisplay = computed(() => QR_DISPLAY_TYPES.has(channelType.value)) const oauthLoading = computed(() => { diff --git a/mateclaw-ui/src/composables/channels/useQqAppRegister.ts b/mateclaw-ui/src/composables/channels/useQqAppRegister.ts new file mode 100644 index 00000000..bf261835 --- /dev/null +++ b/mateclaw-ui/src/composables/channels/useQqAppRegister.ts @@ -0,0 +1,116 @@ +import { ref, onBeforeUnmount } from 'vue' +import { useI18n } from 'vue-i18n' +import { mcToast } from '@/composables/useMcToast' +import { channelApi } from '@/api' + +export type QqRegisterStatus = '' | 'waiting' | 'confirmed' | 'expired' | 'denied' + +export interface QqRegisterResult { + appId: string + clientSecret: string + userOpenid?: string +} + +/** + * QQ Bot "scan-to-bind" frontend state machine: + * 1. POST /channels/qrcode/qq/begin → sessionId (backend creates the bind task) + * 2. Immediate first status poll, then every 2s + * 3. Render qrcode_img once it arrives; auto-fill app_id + client_secret on confirmed + * 4. Terminal states (confirmed / expired / denied) stop polling + * + * `loading` stays true from click until the QR image is visible, so the UI can show + * a spinner placeholder during the brief window between begin() and the first poll + * that returns an image. + */ +export function useQqAppRegister(onConfirmed: (r: QqRegisterResult) => 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.qqRegisterBegin() + sessionId = res?.data?.session_id || res?.session_id || '' + if (!sessionId) { + loading.value = false + mcToast.error(t('channels.qqRegister.startFailed')) + return + } + status.value = 'waiting' + } catch { + loading.value = false + mcToast.error(t('channels.qqRegister.startFailed')) + return + } + + const pollOnce = async () => { + try { + const res: any = await channelApi.qqRegisterStatus(sessionId) + const data = res?.data || res || {} + const s = (data.status as QqRegisterStatus) || 'waiting' + + const img = data.qrcode_img || data.qrcode_url + if (img && qrcodeUrl.value !== img) { + qrcodeUrl.value = img + loading.value = false + } + status.value = s + + if (s === 'confirmed') { + if (confirmedFired) return + confirmedFired = true + stopPolling() + loading.value = false + const appId = data.app_id || '' + const clientSecret = data.client_secret || '' + if (appId && clientSecret) { + onConfirmed({ appId, clientSecret, userOpenid: data.user_openid }) + mcToast.success(t('channels.qqRegister.confirmed')) + } + return + } + + if (s === 'expired') { + stopPolling() + loading.value = false + mcToast.warning(t('channels.qqRegister.expired')) + } else if (s === 'denied') { + stopPolling() + loading.value = false + mcToast.warning(t('channels.qqRegister.denied')) + } + } catch { + // Silent — transient network errors should not abort the loop. + } + } + + await pollOnce() + pollTimer = setInterval(pollOnce, 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 08cbca47..5c42e46d 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2980,6 +2980,18 @@ export default { denied: 'Authorization was denied', startFailed: 'Failed to fetch QR code, please check your network', }, + qqRegister: { + title: 'Scan to Bind QQ Bot', + hint: 'You must have already created a bot on q.qq.com. Click the button, scan with mobile QQ, and pick the bot to bind — its AppID / AppSecret will be auto-filled below without manual copy-paste.', + button: 'Scan to Bind Existing Bot', + buttonLoading: 'Preparing QR code…', + qrcodeLoading: 'Generating QR code', + scanHint: 'Scan the QR code above with mobile QQ and pick a bot on the page', + confirmed: 'Bound. AppID and AppSecret have been auto-filled.', + expired: 'QR code expired, please try again', + denied: 'Binding failed or was cancelled', + 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 5a1f4319..320b54cc 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3080,6 +3080,18 @@ export default { denied: '授权被拒绝', startFailed: '获取二维码失败,请检查网络', }, + qqRegister: { + title: '扫码绑定 QQ 机器人', + hint: '需要先在 QQ 开放平台 (q.qq.com) 创建好机器人。点击按钮后用手机 QQ 扫码,在弹出页面选择要绑定的机器人,AppID / AppSecret 会自动填入下方,无需手动复制。', + button: '扫码绑定已有机器人', + buttonLoading: '正在准备二维码…', + qrcodeLoading: '正在生成二维码', + scanHint: '请使用手机 QQ 扫描上方二维码并在页面中选择机器人', + confirmed: '绑定成功,AppID 和 AppSecret 已自动填入', + expired: '二维码已过期,请重新生成', + denied: '绑定失败或被取消', + startFailed: '获取二维码失败,请检查网络', + }, feishuRegister: { title: '一键创建飞书应用', hint: '点击按钮获取二维码,使用飞书扫一扫并确认授权后,会自动在你的企业里创建自建应用,App ID / App Secret 自动填入下方。',