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