feat(channel/qq): add scan-to-bind onboarding via QQ Open Platform Lite portal

This commit is contained in:
matevip 2026-05-20 21:47:49 +08:00
parent b26bca1584
commit 51e6542a5a
9 changed files with 633 additions and 0 deletions

View File

@ -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.
* <p>
* Drives the QQ Open Platform Lite bind portal:
* <pre>
* 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?}
* </pre>
* <p>
* 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.
* <p>
* 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<String, RegistrationSession> 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<String, ?> 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<String> 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<Map.Entry<String, RegistrationSession>> 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;
}
}
}

View File

@ -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.
*
* <p>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:
*
* <pre>base64( IV(12 bytes) ciphertext(N bytes) AuthTag(16 bytes) )</pre>
*/
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);
}
}

View File

@ -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.
*
* <p>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<String, Object> begin(Map<String, String> params) throws Exception {
QQAppRegistrationService.RegistrationSession session = service.begin();
return Map.of("session_id", session.sessionId);
}
@Override
public Map<String, Object> pollStatus(String sessionId) {
QQAppRegistrationService.RegistrationSession session = service.getSession(sessionId);
if (session == null) {
return 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);
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;
}
}

View File

@ -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 ====================

View File

@ -194,6 +194,43 @@
</div>
</div>
<!-- QQ 扫码绑定QQ 开放平台 Lite portal -->
<div v-if="form.channelType === 'qq'" class="qq-register-card">
<div class="qq-register-header">
<strong>{{ t('channels.qqRegister.title') }}</strong>
</div>
<p class="qq-register-hint">{{ t('channels.qqRegister.hint') }}</p>
<button
type="button"
class="qq-register-btn"
@click="qqRegister.start()"
:disabled="qqRegister.loading.value || qqRegister.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>
{{ qqRegister.loading.value
? t('channels.qqRegister.buttonLoading')
: t('channels.qqRegister.button') }}
</button>
<div v-if="qqRegister.loading.value && !qqRegister.qrcodeUrl.value" class="qq-register-qrcode qq-register-qrcode--loading">
<div class="qq-register-qrcode-spinner"></div>
<p class="qq-register-status">{{ t('channels.qqRegister.qrcodeLoading') }}</p>
</div>
<div v-else-if="qqRegister.qrcodeUrl.value" class="qq-register-qrcode">
<img :src="qqRegister.qrcodeUrl.value" :alt="t('channels.qqRegister.button')" class="qq-register-qrcode-img" />
<p class="qq-register-status" :class="qqRegister.status.value">
<template v-if="qqRegister.status.value === 'confirmed'">{{ t('channels.qqRegister.confirmed') }}</template>
<template v-else-if="qqRegister.status.value === 'expired'">{{ t('channels.qqRegister.expired') }}</template>
<template v-else-if="qqRegister.status.value === 'denied'">{{ t('channels.qqRegister.denied') }}</template>
<template v-else>{{ t('channels.qqRegister.scanHint') }}</template>
</p>
</div>
</div>
<!-- 企业微信扫码授权 -->
<div v-if="form.channelType === 'wecom'" class="wecom-auth-card">
<p class="wecom-auth-hint">{{ t('channels.wecom.authHint') }}</p>
@ -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<ChannelFieldDef[]>(() => {
@ -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; }

View File

@ -81,6 +81,40 @@
</div>
</div>
<!-- QQ scan-to-bind (hybrid: shown alongside the manual fields so
users can fall back to copy-paste if the portal blocks the
vendor tag or they prefer the developer console). -->
<div v-if="channelType === 'qq'" class="oauth-card oauth-card--hybrid">
<p class="oauth-headline">{{ t('channels.qqRegister.hint') }}</p>
<button
v-if="!qqAuth.qrcodeUrl.value"
type="button"
class="oauth-btn"
:disabled="qqAuth.loading.value"
@click="qqAuth.start()"
>
<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"/>
</svg>
{{ qqAuth.loading.value ? t('channels.qqRegister.buttonLoading') : t('channels.qqRegister.button') }}
</button>
<div v-if="qqAuth.loading.value && !qqAuth.qrcodeUrl.value" class="oauth-qr-loading">
<div class="spinner" />
<p class="oauth-qr-status">{{ t('channels.qqRegister.qrcodeLoading') }}</p>
</div>
<div v-if="qqAuth.qrcodeUrl.value" class="oauth-qr">
<img :src="qqAuth.qrcodeUrl.value" alt="QR Code" class="oauth-qr-img" />
<p class="oauth-qr-status" :class="qqAuth.status.value">
<template v-if="qqAuth.status.value === 'confirmed'">{{ t('channels.qqRegister.confirmed') }}</template>
<template v-else-if="qqAuth.status.value === 'expired'">{{ t('channels.qqRegister.expired') }}</template>
<template v-else-if="qqAuth.status.value === 'denied'">{{ t('channels.qqRegister.denied') }}</template>
<template v-else>{{ t('channels.qqRegister.scanHint') }}</template>
</p>
</div>
</div>
<!-- How to get credentials (collapsed by default) -->
<details v-if="!isOAuthStyle && webhookGuide" class="how-to">
<summary class="how-to-summary">
@ -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(() => {

View File

@ -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<QqRegisterStatus>('')
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.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 }
}

View File

@ -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.',

View File

@ -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 自动填入下方。',