package vip.mate.skill.installer; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.skill.installer.model.HubSkillInfo; import vip.mate.skill.installer.model.SkillBundle; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; /** * ClawHub 市场 API 客户端 *

* 提供 skill 搜索和 bundle 获取能力。 * * @author MateClaw Team */ @Slf4j @Service public class SkillHubClient { private final SkillHubProperties properties; private final ObjectMapper objectMapper; private final HttpClient httpClient; public SkillHubClient(SkillHubProperties properties, ObjectMapper objectMapper) { this.properties = properties; this.objectMapper = objectMapper; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(properties.getHttpTimeout())) .followRedirects(HttpClient.Redirect.NORMAL) .build(); } /** * 搜索 ClawHub 市场 */ public List search(String query, int limit) { String url = properties.getBaseUrl() + properties.getSearchPath() + "?q=" + encodeParam(query) + "&limit=" + limit; for (int attempt = 0; attempt <= properties.getHttpRetries(); attempt++) { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(properties.getHttpTimeout())) .GET() .header("Accept", "application/json") .header("User-Agent", "MateClaw/1.0") .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { return parseSearchResponse(response.body()); } if (isRetryable(response.statusCode()) && attempt < properties.getHttpRetries()) { log.warn("Hub search attempt {} failed with status {}, retrying...", attempt + 1, response.statusCode()); Thread.sleep(backoffMs(attempt)); continue; } log.warn("Hub search failed with status {}: {}", response.statusCode(), response.body()); return Collections.emptyList(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Collections.emptyList(); } catch (Exception e) { if (attempt < properties.getHttpRetries()) { log.warn("Hub search attempt {} error: {}, retrying...", attempt + 1, e.getMessage()); try { Thread.sleep(backoffMs(attempt)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return Collections.emptyList(); } } else { log.error("Hub search failed after {} attempts: {}", properties.getHttpRetries() + 1, e.getMessage()); } } } return Collections.emptyList(); } /** * 获取 skill bundle 详情 */ public SkillBundle fetchBundle(String slug, String version) { String path = version != null && !version.isBlank() ? "/api/v1/skills/" + slug + "/versions/" + encodeParam(version) : "/api/v1/skills/" + slug; String url = properties.getBaseUrl() + path; try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(properties.getHttpTimeout())) .GET() .header("Accept", "application/json") .header("User-Agent", "MateClaw/1.0") .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { return parseBundleResponse(response.body(), slug); } log.warn("Hub fetchBundle failed for '{}': status {}", slug, response.statusCode()); return null; } catch (Exception e) { log.error("Hub fetchBundle error for '{}': {}", slug, e.getMessage()); return null; } } // ==================== 内部方法 ==================== @SuppressWarnings("unchecked") private List parseSearchResponse(String body) { try { Map json = objectMapper.readValue(body, new TypeReference<>() {}); Object data = json.get("data"); if (data == null) { data = json.get("results"); } if (data == null) { data = json.get("skills"); } if (data instanceof List list) { String jsonStr = objectMapper.writeValueAsString(list); return objectMapper.readValue(jsonStr, new TypeReference<>() {}); } return Collections.emptyList(); } catch (Exception e) { log.warn("Failed to parse hub search response: {}", e.getMessage()); return Collections.emptyList(); } } @SuppressWarnings("unchecked") private SkillBundle parseBundleResponse(String body, String slug) { try { Map json = objectMapper.readValue(body, new TypeReference<>() {}); String name = getStr(json, "name", slug); String content = getStr(json, "content", ""); String description = getStr(json, "description", ""); String author = getStr(json, "author", ""); String version = getStr(json, "version", "1.0.0"); String icon = getStr(json, "icon", ""); Map references = json.containsKey("references") ? objectMapper.convertValue(json.get("references"), new TypeReference<>() {}) : Map.of(); Map scripts = json.containsKey("scripts") ? objectMapper.convertValue(json.get("scripts"), new TypeReference<>() {}) : Map.of(); return new SkillBundle(name, content, references, scripts, "clawhub", properties.getBaseUrl() + "/skills/" + slug, version, description, author, icon); } catch (Exception e) { log.warn("Failed to parse hub bundle response: {}", e.getMessage()); return null; } } private String getStr(Map map, String key, String defaultVal) { Object v = map.get(key); return v != null ? v.toString() : defaultVal; } private boolean isRetryable(int statusCode) { return statusCode == 408 || statusCode == 429 || statusCode >= 500; } private long backoffMs(int attempt) { return (long) (800 * Math.pow(2, attempt)); } private String encodeParam(String value) { return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); } }