mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(channel/qq): add scan-to-bind onboarding via QQ Open Platform Lite portal
This commit is contained in:
parent
b26bca1584
commit
51e6542a5a
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
116
mateclaw-ui/src/composables/channels/useQqAppRegister.ts
Normal file
116
mateclaw-ui/src/composables/channels/useQqAppRegister.ts
Normal 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 }
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
@ -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 自动填入下方。',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user