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; } } }