diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java index 3869c7e2..8653ac68 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java @@ -1,17 +1,24 @@ package vip.mate.channel.controller; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import vip.mate.channel.ChannelManager; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.service.ChannelService; +import vip.mate.channel.verifier.ChannelVerifierRegistry; +import vip.mate.channel.verifier.VerificationRequest; +import vip.mate.channel.verifier.VerificationResult; import vip.mate.audit.service.AuditEventService; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -23,6 +30,7 @@ import java.util.Map; * * @author MateClaw Team */ +@Slf4j @Tag(name = "渠道管理") @RestController @RequestMapping("/api/v1/channels") @@ -32,6 +40,8 @@ public class ChannelController { private final ChannelService channelService; private final ChannelManager channelManager; private final AuditEventService auditEventService; + private final ChannelVerifierRegistry verifierRegistry; + private final ObjectMapper objectMapper; @RequireWorkspaceRole("viewer") @Operation(summary = "获取渠道列表") @@ -179,6 +189,34 @@ public class ChannelController { .toList()); } + @RequireWorkspaceRole("admin") + @Operation(summary = "Pre-flight: validate draft channel config without persisting") + @PostMapping("/preflight") + public R preflight( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestBody PreflightRequest body) { + long ws = workspaceId != null ? workspaceId : 1L; + Map config = parseConfigJson(body.configJson()); + return verifierRegistry.find(body.channelType()) + .map(v -> R.ok(v.verify(new VerificationRequest(body.channelType(), config, ws)))) + .orElseGet(() -> R.ok(VerificationResult.skipped( + "No verifier registered for channel type '" + body.channelType() + + "' — skipping live check."))); + } + + private Map parseConfigJson(String json) { + if (json == null || json.isBlank()) return Collections.emptyMap(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.debug("preflight: invalid configJson, treating as empty: {}", e.getMessage()); + return Collections.emptyMap(); + } + } + + /** Wizard Step 2 payload — channel type + draft configJson, no entity yet. */ + public record PreflightRequest(String channelType, String configJson) {} + private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) { long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/ChannelVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/ChannelVerifier.java new file mode 100644 index 00000000..e8188ac6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/ChannelVerifier.java @@ -0,0 +1,37 @@ +package vip.mate.channel.verifier; + +/** + * Pre-flight credential verifier for a single channel type. + *

+ * Implementations validate a draft channel configuration by performing the + * cheapest possible auth probe against the upstream service (e.g. Telegram + * {@code getMe}, Slack {@code auth.test}, Discord {@code users/@me}). They + * MUST be side-effect free: no persistence, no shared connection state, no + * impact on running adapters. Network errors are reported via + * {@link VerificationResult#failed} rather than thrown. + *

+ * Verifiers are auto-discovered as Spring beans and indexed by + * {@link #getChannelType()} in {@link ChannelVerifierRegistry}. A channel + * type without a verifier degrades to a "skipped" verify step in the + * onboarding wizard — the user can still save and start the channel, but + * loses the live connection check. + * + * @author MateClaw Team + * @see ChannelVerifierRegistry + * @see VerificationResult + */ +public interface ChannelVerifier { + + /** + * Channel type discriminator, e.g. {@code "telegram"}, {@code "slack"}. + * Must match the {@code channelType} column in {@code mate_channel}. + */ + String getChannelType(); + + /** + * Validate the draft config. Implementations MUST bound every network + * call by a 5-second timeout and never throw — wrap upstream errors in + * {@link VerificationResult#failed}. + */ + VerificationResult verify(VerificationRequest request); +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/ChannelVerifierRegistry.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/ChannelVerifierRegistry.java new file mode 100644 index 00000000..cf40c8b3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/ChannelVerifierRegistry.java @@ -0,0 +1,46 @@ +package vip.mate.channel.verifier; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Indexes all {@link ChannelVerifier} beans by channel type. Spring injects + * the full list at construction time; one verifier per channel type is the + * contract — duplicates log a warning and the last one wins (keeps test + * doubles overridable without crashing the context). + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChannelVerifierRegistry { + + private final List verifiers; + private Map byType; + + @PostConstruct + void index() { + Map map = new HashMap<>(); + for (ChannelVerifier v : verifiers) { + ChannelVerifier prev = map.put(v.getChannelType(), v); + if (prev != null) { + log.warn("Duplicate ChannelVerifier for type '{}' — {} replaces {}", + v.getChannelType(), v.getClass().getSimpleName(), prev.getClass().getSimpleName()); + } + } + this.byType = Map.copyOf(map); + log.info("ChannelVerifierRegistry indexed {} verifier(s): {}", byType.size(), byType.keySet()); + } + + public Optional find(String channelType) { + return Optional.ofNullable(byType.get(channelType)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/DingTalkVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/DingTalkVerifier.java new file mode 100644 index 00000000..8c52cf7f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/DingTalkVerifier.java @@ -0,0 +1,131 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +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.LinkedHashMap; +import java.util.Map; + +/** + * Validates DingTalk app credentials via + * {@code POST /v1.0/oauth2/accessToken} (the same handshake the production + * adapter does to call Robot APIs). Mirrors {@code DingTalkChannelAdapter + * .getDingTalkAccessToken} so a green Step 2 maps to a green channel + * post-save. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DingTalkVerifier implements ChannelVerifier { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final String API_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"; + + private final ObjectMapper objectMapper; + + @Override + public String getChannelType() { + return "dingtalk"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String clientId = string(request.config(), "client_id"); + String clientSecret = string(request.config(), "client_secret"); + + if (clientId == null || clientId.isBlank()) { + return VerificationResult.failed(0, "Client ID (AppKey) is required", + "client_id", "Scan the DingTalk QR (one-click bot creation) — Client ID is filled automatically."); + } + if (clientSecret == null || clientSecret.isBlank()) { + return VerificationResult.failed(0, "Client Secret is required", + "client_secret", "Scan the DingTalk QR — Client Secret is filled automatically."); + } + + try { + String body = objectMapper.writeValueAsString(Map.of( + "appKey", clientId, + "appSecret", clientSecret)); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .timeout(TIMEOUT) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + long ms = System.currentTimeMillis() - t0; + + JsonNode root = objectMapper.readTree(resp.body()); + + // DingTalk returns {accessToken, expireIn} on success, or + // {code: "InvalidAuthentication"/..., message: "...", requestid: "..."} on failure. + String accessToken = root.path("accessToken").asText(""); + if (resp.statusCode() == 200 && !accessToken.isBlank()) { + int expire = root.path("expireIn").asInt(7200); + Map identity = new LinkedHashMap<>(); + identity.put("accountId", clientId); + identity.put("transport", "DingTalk OAuth2 v1.0"); + identity.put("tokenTtl", expire + "s"); + return VerificationResult.ok(ms, + "Connected — DingTalk issued an access token", + identity); + } + + String code = root.path("code").asText(""); + String msg = root.path("message").asText("auth failed"); + return VerificationResult.failed(ms, + "DingTalk rejected the credentials (" + code + "): " + msg, + invalidFieldFor(code), + hintFor(code, msg)); + } catch (java.net.http.HttpTimeoutException e) { + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Timed out talking to api.dingtalk.com", null, + "Network couldn't reach DingTalk in 5s. Check egress to api.dingtalk.com (port 443)."); + } catch (Exception e) { + log.debug("[dingtalk-verify] error: {}", e.getMessage()); + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Could not reach DingTalk: " + e.getClass().getSimpleName(), null, e.getMessage()); + } + } + + private static String invalidFieldFor(String code) { + if (code == null) return null; + return switch (code) { + case "InvalidAuthentication", "AccessKeyError" -> "client_secret"; + case "InvalidParameter.AppKey", "AppNotExist" -> "client_id"; + default -> null; + }; + } + + private static String hintFor(String code, String msg) { + if (code == null || code.isBlank()) { + return msg != null && !msg.isBlank() ? msg : "DingTalk auth failed."; + } + return switch (code) { + case "InvalidAuthentication" -> + "Client Secret rejected. Re-scan the QR — DingTalk rotates secrets on app re-publish."; + case "AppNotExist" -> + "App not found in your tenant. Verify the QR was for the right corporation."; + case "AccessKeyError" -> + "Authentication signature mismatch. Re-scan to refresh the credential pair."; + default -> "DingTalk error " + code + " — " + msg; + }; + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/DiscordVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/DiscordVerifier.java new file mode 100644 index 00000000..e3d95cba --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/DiscordVerifier.java @@ -0,0 +1,110 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.InetSocketAddress; +import java.net.ProxySelector; +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.LinkedHashMap; +import java.util.Map; + +/** + * Validates a Discord bot token via {@code GET /api/v10/users/@me} with + * {@code Authorization: Bot }. Same proxy semantics as + * {@link TelegramVerifier}. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DiscordVerifier implements ChannelVerifier { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final String API_URL = "https://discord.com/api/v10/users/@me"; + + private final ObjectMapper objectMapper; + + @Override + public String getChannelType() { + return "discord"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String botToken = string(request.config(), "bot_token"); + if (botToken == null || botToken.isBlank()) { + return VerificationResult.failed(0, "Bot Token is required", + "bot_token", "Get one from the Discord Developer Portal under Bot → Token."); + } + + HttpClient.Builder cb = HttpClient.newBuilder().connectTimeout(TIMEOUT); + applyProxy(cb, string(request.config(), "http_proxy")); + HttpClient client = cb.build(); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .timeout(TIMEOUT) + .header("Authorization", "Bot " + botToken) + .header("User-Agent", "MateClaw-Verifier/1.0 (+https://claw.mate.vip)") + .GET() + .build(); + + try { + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + long ms = System.currentTimeMillis() - t0; + if (resp.statusCode() == 200) { + JsonNode body = objectMapper.readTree(resp.body()); + String username = body.path("username").asText(""); + String discriminator = body.path("discriminator").asText("0"); + String displayName = "0".equals(discriminator) ? username : username + "#" + discriminator; + Map identity = new LinkedHashMap<>(); + identity.put("accountId", body.path("id").asText()); + identity.put("accountName", displayName); + identity.put("isBot", body.path("bot").asBoolean(true)); + identity.put("verified", body.path("verified").asBoolean(false)); + return VerificationResult.ok(ms, "Connected as " + displayName, identity); + } + if (resp.statusCode() == 401) { + return VerificationResult.failed(ms, "Discord rejected the bot token (401 Unauthorized)", + "bot_token", "Bot Token is invalid. Regenerate it in the Discord Developer Portal."); + } + return VerificationResult.failed(ms, "Discord returned HTTP " + resp.statusCode(), + null, "Unexpected response from Discord. Check the bot exists and the token has not been revoked."); + } catch (java.net.http.HttpTimeoutException e) { + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Timed out talking to discord.com", null, + "Network couldn't reach Discord in 5s. Set HTTP Proxy in advanced settings if needed."); + } catch (Exception e) { + log.debug("[discord-verify] error: {}", e.getMessage()); + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Could not reach Discord: " + e.getClass().getSimpleName(), null, e.getMessage()); + } + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } + + private static void applyProxy(HttpClient.Builder cb, String httpProxy) { + if (httpProxy == null || httpProxy.isBlank()) return; + try { + URI uri = URI.create(httpProxy); + if (uri.getHost() != null && uri.getPort() > 0) { + cb.proxy(ProxySelector.of(new InetSocketAddress(uri.getHost(), uri.getPort()))); + } + } catch (Exception ignored) { + // best effort — invalid proxy falls back to direct + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/FeishuVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/FeishuVerifier.java new file mode 100644 index 00000000..dbae04ed --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/FeishuVerifier.java @@ -0,0 +1,128 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +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.LinkedHashMap; +import java.util.Map; + +/** + * Validates Feishu / Lark app credentials via the + * {@code /open-apis/auth/v3/tenant_access_token/internal} endpoint. Mirrors + * the same exchange that {@code FeishuChannelAdapter.refreshTenantAccessToken} + * does on real startup, so a green Step 2 is a strong predictor of a green + * channel post-save. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FeishuVerifier implements ChannelVerifier { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final ObjectMapper objectMapper; + + @Override + public String getChannelType() { + return "feishu"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String appId = string(request.config(), "app_id"); + String appSecret = string(request.config(), "app_secret"); + String domain = string(request.config(), "domain"); + if (domain == null || domain.isBlank()) domain = "feishu"; + + if (appId == null || appId.isBlank()) { + return VerificationResult.failed(0, "App ID is required", + "app_id", "Scan the QR (one-click app creation) — App ID is filled automatically."); + } + if (appSecret == null || appSecret.isBlank()) { + return VerificationResult.failed(0, "App Secret is required", + "app_secret", "Scan the QR (one-click app creation) — App Secret is filled automatically."); + } + + String apiBase = "lark".equalsIgnoreCase(domain) + ? "https://open.larksuite.com" + : "https://open.feishu.cn"; + + try { + String body = objectMapper.writeValueAsString(Map.of( + "app_id", appId, + "app_secret", appSecret)); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(apiBase + "/open-apis/auth/v3/tenant_access_token/internal")) + .timeout(TIMEOUT) + .header("Content-Type", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + long ms = System.currentTimeMillis() - t0; + + JsonNode root = objectMapper.readTree(resp.body()); + int code = root.path("code").asInt(-1); + if (code == 0) { + int expire = root.path("expire").asInt(7200); + Map identity = new LinkedHashMap<>(); + identity.put("accountId", appId); + identity.put("region", "lark".equalsIgnoreCase(domain) ? "Lark (international)" : "Feishu (China)"); + identity.put("tokenTtl", expire + "s"); + String regionLabel = "lark".equalsIgnoreCase(domain) ? "Lark" : "Feishu"; + return VerificationResult.ok(ms, + "Connected to " + regionLabel + " — tenant_access_token issued", + identity); + } + String msg = root.path("msg").asText("auth failed"); + return VerificationResult.failed(ms, + "Feishu rejected the credentials (code " + code + "): " + msg, + invalidFieldFor(code), + hintFor(code, msg)); + } catch (java.net.http.HttpTimeoutException e) { + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Timed out talking to " + apiBase, null, + "Network couldn't reach Feishu in 5s. If you're on a corporate network, check egress to *.feishu.cn / *.larksuite.com."); + } catch (Exception e) { + log.debug("[feishu-verify] error: {}", e.getMessage()); + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Could not reach Feishu: " + e.getClass().getSimpleName(), null, e.getMessage()); + } + } + + private static String invalidFieldFor(int code) { + // 10003 / 99991663 / 99991664 family: app credential / signature errors + return switch (code) { + case 10003 -> "app_secret"; + case 10012 -> "app_id"; + default -> code >= 10000 && code < 20000 ? "app_secret" : null; + }; + } + + private static String hintFor(int code, String msg) { + return switch (code) { + case 10003 -> "App Secret rejected. Re-scan the QR — Feishu may have rotated the secret on app re-publish."; + case 10012 -> "App ID not recognized. Verify you scanned the QR for the right tenant."; + case 99991663 -> "Token cache stale. Re-scan to force a fresh credential pair."; + default -> msg != null && !msg.isBlank() + ? "Feishu code " + code + " — " + msg + : "Feishu code " + code + ". Re-scanning the QR usually resolves credential drift."; + }; + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/SlackVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/SlackVerifier.java new file mode 100644 index 00000000..b415a867 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/SlackVerifier.java @@ -0,0 +1,91 @@ +package vip.mate.channel.verifier; + +import com.slack.api.Slack; +import com.slack.api.methods.response.auth.AuthTestResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Validates a Slack bot token via {@code auth.test}. The {@code app_token} + * (used for Socket Mode) is intentionally not probed here because Slack + * does not expose a no-side-effect endpoint for it — {@code apps.connections.open} + * actually opens a WSS, which we do not want during a wizard step. We + * surface a hint when {@code app_token} is missing so the user knows Socket + * Mode won't work yet. + * + * @author MateClaw Team + */ +@Slf4j +@Component +public class SlackVerifier implements ChannelVerifier { + + @Override + public String getChannelType() { + return "slack"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String botToken = string(request.config(), "bot_token"); + String appToken = string(request.config(), "app_token"); + + if (botToken == null || botToken.isBlank()) { + return VerificationResult.failed(0, "Bot Token is required", + "bot_token", "Get one from your Slack App → OAuth & Permissions → Bot User OAuth Token (xoxb-…)."); + } + + try { + AuthTestResponse resp = Slack.getInstance().methods(botToken).authTest(r -> r); + long ms = System.currentTimeMillis() - t0; + if (resp.isOk()) { + Map identity = new LinkedHashMap<>(); + identity.put("accountId", resp.getUserId()); + identity.put("accountName", resp.getUser()); + identity.put("team", resp.getTeam()); + identity.put("teamId", resp.getTeamId()); + identity.put("botId", resp.getBotId()); + String headline = "Connected to " + resp.getTeam() + " as " + resp.getUser(); + if (appToken == null || appToken.isBlank()) { + return VerificationResult.failedWithIdentity(ms, + headline + " — but App Token missing for Socket Mode", + identity, "app_token", + "Bot Token is valid. Add an App-Level Token (xapp-…) with connections:write to enable Socket Mode."); + } + if (!appToken.startsWith("xapp-")) { + return VerificationResult.failedWithIdentity(ms, + headline + " — but App Token has wrong prefix", + identity, "app_token", + "App Token must start with xapp- (App-Level Token), not xoxb- (Bot Token)."); + } + return VerificationResult.ok(ms, headline, identity); + } + String error = resp.getError() != null ? resp.getError() : "auth_failed"; + return VerificationResult.failed(ms, "Slack says: " + error, + "bot_token", hintForSlackError(error)); + } catch (Exception e) { + log.debug("[slack-verify] error: {}", e.getMessage()); + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Could not reach Slack: " + e.getClass().getSimpleName(), null, e.getMessage()); + } + } + + private static String hintForSlackError(String code) { + return switch (code) { + case "invalid_auth", "not_authed" -> + "Bot Token is invalid. Verify you copied the full xoxb- string from OAuth & Permissions."; + case "account_inactive" -> "The Slack workspace or user is deactivated."; + case "token_revoked" -> "Bot Token has been revoked. Reinstall the app to get a fresh token."; + case "token_expired" -> "Bot Token has expired. Generate a new one in Slack App settings."; + default -> "Slack rejected the request: " + code; + }; + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/TelegramVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/TelegramVerifier.java new file mode 100644 index 00000000..481f1a5c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/TelegramVerifier.java @@ -0,0 +1,106 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.InetSocketAddress; +import java.net.ProxySelector; +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.LinkedHashMap; +import java.util.Map; + +/** + * Validates a Telegram bot token via {@code GET /bot{token}/getMe}. Honors + * the {@code http_proxy} field so Chinese users — who almost always need a + * proxy to reach api.telegram.org — get a representative result instead of + * a misleading timeout. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TelegramVerifier implements ChannelVerifier { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final ObjectMapper objectMapper; + + @Override + public String getChannelType() { + return "telegram"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String botToken = string(request.config(), "bot_token"); + if (botToken == null || botToken.isBlank()) { + return VerificationResult.failed(0, "Bot Token is required", + "bot_token", "Get one from @BotFather and paste it here."); + } + + HttpClient.Builder cb = HttpClient.newBuilder().connectTimeout(TIMEOUT); + applyProxy(cb, string(request.config(), "http_proxy")); + HttpClient client = cb.build(); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("https://api.telegram.org/bot" + botToken + "/getMe")) + .timeout(TIMEOUT) + .GET() + .build(); + + try { + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + long ms = System.currentTimeMillis() - t0; + JsonNode body = objectMapper.readTree(resp.body()); + if (resp.statusCode() == 200 && body.path("ok").asBoolean(false)) { + JsonNode r = body.path("result"); + String username = r.path("username").asText(""); + Map identity = new LinkedHashMap<>(); + identity.put("accountId", r.path("id").asLong()); + identity.put("accountName", "@" + username); + identity.put("firstName", r.path("first_name").asText("")); + identity.put("canJoinGroups", r.path("can_join_groups").asBoolean()); + identity.put("canReadAllGroupMessages", r.path("can_read_all_group_messages").asBoolean()); + return VerificationResult.ok(ms, "Connected as @" + username, identity); + } + // Telegram returns 401/404 for bad tokens with a JSON body containing "description" + String description = body.path("description").asText("Telegram rejected the token"); + return VerificationResult.failed(ms, "Telegram says: " + description, + "bot_token", "Bot Token rejected. Check the full string from @BotFather, including the colon."); + } catch (java.net.http.HttpTimeoutException e) { + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Timed out talking to api.telegram.org", null, + "Network couldn't reach Telegram in 5s. If you're in mainland China, set HTTP Proxy in advanced settings."); + } catch (Exception e) { + log.debug("[telegram-verify] error: {}", e.getMessage()); + return VerificationResult.failed(System.currentTimeMillis() - t0, + "Could not reach Telegram: " + e.getClass().getSimpleName(), null, e.getMessage()); + } + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } + + private static void applyProxy(HttpClient.Builder cb, String httpProxy) { + if (httpProxy == null || httpProxy.isBlank()) return; + try { + URI uri = URI.create(httpProxy); + if (uri.getHost() != null && uri.getPort() > 0) { + cb.proxy(ProxySelector.of(new InetSocketAddress(uri.getHost(), uri.getPort()))); + } + } catch (Exception ignored) { + // best effort — invalid proxy falls back to direct + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/VerificationRequest.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/VerificationRequest.java new file mode 100644 index 00000000..c77c2f99 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/VerificationRequest.java @@ -0,0 +1,17 @@ +package vip.mate.channel.verifier; + +import java.util.Map; + +/** + * Draft channel config submitted to the wizard's Verify step. Carries only + * what a verifier needs — no entity ID, no audit context — because preflight + * runs before the row exists in {@code mate_channel}. + * + * @author MateClaw Team + */ +public record VerificationRequest( + String channelType, + Map config, + Long workspaceId +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/VerificationResult.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/VerificationResult.java new file mode 100644 index 00000000..84bb2c0e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/VerificationResult.java @@ -0,0 +1,54 @@ +package vip.mate.channel.verifier; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Outcome of a {@link ChannelVerifier#verify} probe. Designed so the + * onboarding wizard's Step 2 can render success and failure with no extra + * round-trips: + *

    + *
  • {@code headline} — one-line status shown in the verify card
  • + *
  • {@code identity} — account display fields piped into Step 3 ("Connected as ...")
  • + *
  • {@code invalidField} — when failed, the form key Step 1 should highlight on "Fix it"
  • + *
  • {@code hint} — actionable next step, surfaced under the failure headline
  • + *
+ * The {@code skipped} variant lets channel types with no verifier (web, + * webchat, webhook) fast-forward through Step 2 without showing an error. + * + * @author MateClaw Team + */ +public record VerificationResult( + boolean ok, + boolean skipped, + long durationMs, + String headline, + Map identity, + String invalidField, + String hint +) { + + public static VerificationResult ok(long durationMs, String headline, Map identity) { + return new VerificationResult(true, false, durationMs, headline, + identity != null ? identity : Collections.emptyMap(), null, null); + } + + public static VerificationResult failed(long durationMs, String headline, String invalidField, String hint) { + return new VerificationResult(false, false, durationMs, headline, + Collections.emptyMap(), invalidField, hint); + } + + public static VerificationResult skipped(String headline) { + return new VerificationResult(true, true, 0L, headline, + Collections.emptyMap(), null, null); + } + + /** Convenience: rich failure with structured identity (for partial-success scenarios). */ + public static VerificationResult failedWithIdentity(long durationMs, String headline, + Map identity, + String invalidField, String hint) { + Map id = identity != null ? identity : new LinkedHashMap<>(); + return new VerificationResult(false, false, durationMs, headline, id, invalidField, hint); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/WeComVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/WeComVerifier.java new file mode 100644 index 00000000..7dff027c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/WeComVerifier.java @@ -0,0 +1,252 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Validates WeCom (企业微信) smart-bot credentials by performing a real + * {@code aibot_subscribe} handshake against {@code wss://openws.work.weixin.qq.com}. + *

+ * Why a full WS handshake instead of a cheaper REST probe: WeCom's smart-bot + * API has no REST equivalent — the long-connection subscribe is the only way + * to find out whether a {@code (bot_id, secret)} pair will actually connect. + * Skimping here would let bad credentials through Step 2 and surface as a red + * dot in production, which is exactly the failure mode the wizard exists to + * eliminate. + *

+ * The probe is short-lived: connect → send subscribe → wait ≤5s for ack → + * close. No heartbeat, no message handling, no retained state. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WeComVerifier implements ChannelVerifier { + + private static final String WS_URL = "wss://openws.work.weixin.qq.com"; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final long ACK_TIMEOUT_MS = 5_000L; + + private final ObjectMapper objectMapper; + + @Override + public String getChannelType() { + return "wecom"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String botId = string(request.config(), "bot_id"); + String secret = string(request.config(), "secret"); + + if (botId == null || botId.isBlank()) { + return VerificationResult.failed(0, "Bot ID is required", + "bot_id", "Scan the QR in WeCom to fetch the Bot ID and Secret automatically."); + } + if (secret == null || secret.isBlank()) { + return VerificationResult.failed(0, "Secret is required", + "secret", "Scan the QR in WeCom to fetch the Bot ID and Secret automatically."); + } + + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + + AckWaiter waiter = new AckWaiter(); + WebSocket ws = null; + try { + ws = httpClient.newWebSocketBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .buildAsync(URI.create(WS_URL), waiter) + .get(7, TimeUnit.SECONDS); + + String reqId = "aibot_subscribe-" + UUID.randomUUID(); + Map frame = Map.of( + "cmd", "aibot_subscribe", + "headers", Map.of("req_id", reqId), + "body", Map.of("bot_id", botId, "secret", secret) + ); + ws.sendText(objectMapper.writeValueAsString(frame), true) + .orTimeout(2, TimeUnit.SECONDS) + .join(); + + Map ack = waiter.awaitAck(ACK_TIMEOUT_MS); + long ms = System.currentTimeMillis() - t0; + + Object errcodeObj = ack.get("errcode"); + int errcode = errcodeObj instanceof Number n ? n.intValue() : 0; + if (errcode == 0) { + Map identity = new LinkedHashMap<>(); + identity.put("accountId", maskBotId(botId)); + identity.put("transport", "WebSocket (openws.work.weixin.qq.com)"); + return VerificationResult.ok(ms, "Connected — WeCom accepted the bot credentials", identity); + } + + String errmsg = String.valueOf(ack.getOrDefault("errmsg", "unknown error")); + return VerificationResult.failed(ms, + "WeCom rejected the credentials: " + errmsg, + invalidFieldFor(errcode), + hintFor(errcode, errmsg)); + } catch (TimeoutException e) { + long ms = System.currentTimeMillis() - t0; + // Timed out either on connect (handled by buildAsync) or on the ack wait. + String headline = waiter.opened() + ? "WeCom did not respond to the subscribe frame in 5s" + : "Could not reach openws.work.weixin.qq.com in 5s"; + return VerificationResult.failed(ms, headline, null, + "Check network egress to *.work.weixin.qq.com (port 443). If you are behind a corporate proxy, the WebSocket upgrade may be blocked."); + } catch (Exception e) { + log.debug("[wecom-verify] error: {}", e.getMessage()); + long ms = System.currentTimeMillis() - t0; + Throwable cause = e.getCause() != null ? e.getCause() : e; + return VerificationResult.failed(ms, + "Could not reach WeCom: " + cause.getClass().getSimpleName(), + null, cause.getMessage()); + } finally { + if (ws != null) { + try { + ws.sendClose(WebSocket.NORMAL_CLOSURE, "verify done") + .orTimeout(2, TimeUnit.SECONDS) + .exceptionally(ex -> null) + .join(); + } catch (Exception ignored) { + // best-effort close + } + } + } + } + + /** Map common WeCom errcodes to the form field most likely to fix them. */ + private static String invalidFieldFor(int errcode) { + // 40001..40015 family is "invalid credentials / signature" — the + // smart-bot API does not document a stable taxonomy, so we apply a + // conservative bucket: everything not network-class points at secret, + // because bot_id format errors are rare (the QR flow guarantees it). + return errcode >= 40000 && errcode < 50000 ? "secret" : null; + } + + private static String hintFor(int errcode, String errmsg) { + return switch (errcode) { + case 40001, 40014 -> "Secret rejected. Re-scan the QR in WeCom — your Secret may have been rotated."; + case 40013 -> "Bot ID is malformed. Re-scan the QR to refresh it."; + case 41001 -> "Authorization expired. Re-scan to obtain a fresh token."; + default -> errmsg != null && !errmsg.isBlank() + ? "WeCom errcode " + errcode + " — " + errmsg + : "WeCom errcode " + errcode + ". Re-scanning the QR usually resolves transient signature issues."; + }; + } + + private static String maskBotId(String botId) { + if (botId.length() <= 8) return botId; + return botId.substring(0, 6) + "…" + botId.substring(botId.length() - 4); + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } + + /** + * Minimal WebSocket listener that buffers text frames and resolves the + * first JSON frame whose {@code req_id} starts with {@code aibot_subscribe}. + * Other frames are ignored — the verifier never enters the message loop. + */ + private final class AckWaiter implements WebSocket.Listener { + + private final StringBuilder buf = new StringBuilder(); + private final CompletableFuture> ackFuture = new CompletableFuture<>(); + private final AtomicReference openedFlag = new AtomicReference<>(false); + + @Override + public void onOpen(WebSocket webSocket) { + openedFlag.set(true); + webSocket.request(1); + } + + @Override + public java.util.concurrent.CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + buf.append(data); + if (last) { + String full = buf.toString(); + buf.setLength(0); + tryResolve(full); + } + webSocket.request(1); + return null; + } + + @Override + public java.util.concurrent.CompletionStage onBinary(WebSocket webSocket, java.nio.ByteBuffer data, boolean last) { + byte[] bytes = new byte[data.remaining()]; + data.get(bytes); + buf.append(new String(bytes)); + if (last) { + String full = buf.toString(); + buf.setLength(0); + tryResolve(full); + } + webSocket.request(1); + return null; + } + + @Override + public java.util.concurrent.CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + if (!ackFuture.isDone()) { + ackFuture.completeExceptionally(new RuntimeException( + "WebSocket closed before ack: code=" + statusCode + ", reason=" + reason)); + } + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + if (!ackFuture.isDone()) ackFuture.completeExceptionally(error); + } + + @SuppressWarnings("unchecked") + private void tryResolve(String json) { + try { + Map frame = objectMapper.readValue(json, Map.class); + Map headers = (Map) frame.getOrDefault("headers", Map.of()); + String reqId = String.valueOf(headers.getOrDefault("req_id", "")); + if (reqId.startsWith("aibot_subscribe")) { + ackFuture.complete(frame); + } + // Other frames (heartbeat ack, server-pushed events) — ignored. + } catch (Exception e) { + // Malformed frame is not fatal — keep waiting until the timeout fires. + log.debug("[wecom-verify] non-JSON or unparseable frame ignored: {}", e.getMessage()); + } + } + + Map awaitAck(long timeoutMs) throws Exception { + try { + return ackFuture.get(timeoutMs, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof Exception ex) throw ex; + throw new RuntimeException(cause); + } + } + + boolean opened() { + return Boolean.TRUE.equals(openedFlag.get()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/verifier/WeixinVerifier.java b/mateclaw-server/src/main/java/vip/mate/channel/verifier/WeixinVerifier.java new file mode 100644 index 00000000..79a894b9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/verifier/WeixinVerifier.java @@ -0,0 +1,154 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +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.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Random; + +/** + * Validates a WeChat iLink Bot token by hitting + * {@code GET /ilink/bot/getupdates} on the configured base URL. + *

+ * iLink's getupdates is a long-poll (server holds the connection up to ~35s + * waiting for a message). For the verify probe we set a short HTTP request + * timeout — if the server reaches the long-poll wait, that already proves + * the bearer token was accepted, so a clean {@link java.net.http.HttpTimeoutException} + * is treated as success. 401/403 means the token is dead. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WeixinVerifier implements ChannelVerifier { + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3); + private static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"; + + private final ObjectMapper objectMapper; + + @Override + public String getChannelType() { + return "weixin"; + } + + @Override + public VerificationResult verify(VerificationRequest request) { + long t0 = System.currentTimeMillis(); + String botToken = string(request.config(), "bot_token"); + String baseUrl = string(request.config(), "base_url"); + if (baseUrl == null || baseUrl.isBlank()) baseUrl = DEFAULT_BASE_URL; + baseUrl = baseUrl.replaceAll("/+$", ""); + + if (botToken == null || botToken.isBlank()) { + return VerificationResult.failed(0, "Bot Token is required", + "bot_token", "Scan the WeChat QR — Bot Token is filled automatically once you confirm in WeChat."); + } + + try { + // X-WECHAT-UIN: base64(str(random_uint32)) — iLink's anti-replay header. + // Mirrors what ILinkClient.makeHeaders does on every real request. + long uinVal = new Random().nextLong(0, 0xFFFFFFFFL + 1); + String uin = Base64.getEncoder().encodeToString( + String.valueOf(uinVal).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ilink/bot/getupdates")) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/json") + .header("AuthorizationType", "ilink_bot_token") + .header("Authorization", "Bearer " + botToken) + .header("X-WECHAT-UIN", uin) + .POST(HttpRequest.BodyPublishers.ofString("{\"cursor\":\"\"}")) + .build(); + + HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + long ms = System.currentTimeMillis() - t0; + return interpretStatus(resp, ms, botToken, baseUrl); + } catch (java.net.http.HttpTimeoutException e) { + // Long-poll held the connection — implies the bearer token was + // accepted (the server only enters the poll loop after auth). + long ms = System.currentTimeMillis() - t0; + return VerificationResult.ok(ms, + "Connected — WeChat iLink accepted the bot token", + identityFor(botToken, baseUrl)); + } catch (Exception e) { + log.debug("[weixin-verify] error: {}", e.getMessage()); + long ms = System.currentTimeMillis() - t0; + return VerificationResult.failed(ms, + "Could not reach iLink: " + e.getClass().getSimpleName(), + null, e.getMessage()); + } + } + + private VerificationResult interpretStatus(HttpResponse resp, long ms, + String botToken, String baseUrl) { + int status = resp.statusCode(); + if (status == 200) { + // Server returned an immediate update before our timeout — token is fine. + return VerificationResult.ok(ms, + "Connected — WeChat iLink accepted the bot token", + identityFor(botToken, baseUrl)); + } + if (status == 401 || status == 403) { + return VerificationResult.failed(ms, + "iLink rejected the Bot Token (HTTP " + status + ")", + "bot_token", + "The token has been revoked or expired. Re-scan the QR to obtain a fresh one."); + } + // Other 4xx/5xx — surface the body snippet for debugging without exposing the token. + String snippet = resp.body() != null && !resp.body().isBlank() + ? truncate(resp.body(), 160) : "(empty response body)"; + // Try to parse iLink JSON error envelope. + try { + JsonNode root = objectMapper.readTree(resp.body()); + int errcode = root.path("errcode").asInt(0); + String errmsg = root.path("errmsg").asText(""); + if (errcode != 0 && !errmsg.isBlank()) { + return VerificationResult.failed(ms, + "iLink errcode " + errcode + ": " + errmsg, + errcode == 401 || errcode == 403 ? "bot_token" : null, + "If this persists, re-scan the QR to refresh credentials."); + } + } catch (Exception ignored) { + // Non-JSON body — fall through to generic message. + } + return VerificationResult.failed(ms, + "iLink returned HTTP " + status, null, snippet); + } + + private static Map identityFor(String botToken, String baseUrl) { + Map identity = new LinkedHashMap<>(); + identity.put("accountId", maskToken(botToken)); + identity.put("baseUrl", baseUrl); + identity.put("transport", "iLink long-polling"); + return identity; + } + + private static String maskToken(String token) { + if (token.length() <= 8) return token; + return token.substring(0, 4) + "…" + token.substring(token.length() - 4); + } + + private static String truncate(String s, int max) { + return s.length() <= max ? s : s.substring(0, max) + "…"; + } + + private static String string(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index bb8e6a9d..27a02992 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -232,6 +232,13 @@ export const channelApi = { health: (id: string | number) => http.get(`/channels/${id}/health`), /** Batch health for all channels in current workspace. */ healthAll: () => http.get('/channels/health'), + /** + * Wizard Step 2 — validate a draft config without persisting. + * Returns a VerificationResult: { ok, skipped, durationMs, headline, + * identity, invalidField, hint }. + */ + preflight: (channelType: string, configJson: string) => + http.post('/channels/preflight', { channelType, configJson }), // 微信 iLink Bot QR 码登录 weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'), weixinQrcodeStatus: (qrcode: string) => diff --git a/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue b/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue new file mode 100644 index 00000000..534925a3 --- /dev/null +++ b/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue @@ -0,0 +1,795 @@ + + + + + diff --git a/mateclaw-ui/src/components/channels/ChannelTypePicker.vue b/mateclaw-ui/src/components/channels/ChannelTypePicker.vue new file mode 100644 index 00000000..4c75227b --- /dev/null +++ b/mateclaw-ui/src/components/channels/ChannelTypePicker.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 52ba6e8f..146e65d4 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1747,6 +1747,33 @@ export default { mediaReason: 'Media download: download images and files', }, }, + wizard: { + step1: 'Configure', + step2: 'Verify', + step3: 'Ready', + configureSubtitle: 'Enter your credentials to connect {service}', + howToGet: 'How to get credentials', + saveAndTest: 'Save & Test', + back: 'Back', + continue: 'Continue', + done: 'Done', + verifying: 'Verifying with {service}…', + verifySuccess: 'Connected', + verifyFailed: 'Verification failed', + verifySkipped: 'No live check needed', + verifySkippedDetail: "This channel type doesn't have credentials to verify. You can save and start it directly.", + durationMs: '({n} ms)', + fixIt: 'Fix it', + retry: 'Retry', + readyHeadline: 'Ready to go', + readySubtitle: 'Bind an agent and send a test message to confirm.', + bindAgentLabel: 'Bind to agent', + sendTest: 'Send a test message', + sendTestHint: 'Open {service} and message the bot — replies should arrive in seconds.', + saveFailed: 'Could not save the channel', + noVerifierHint: 'No live check is available for {type}. Saving will start the channel; check the connection dot afterwards.', + oauthScanHint: 'After scanning, the credentials will be filled and verified automatically — you do not need to copy anything.', + }, }, skills: { title: 'Skills', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 563f572d..180a15df 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1757,6 +1757,33 @@ export default { mediaReason: '媒体下载:下载图片和文件', }, }, + wizard: { + step1: '配置', + step2: '验证', + step3: '就绪', + configureSubtitle: '填入凭证以连接 {service}', + howToGet: '如何获取凭证', + saveAndTest: '保存并测试', + back: '返回', + continue: '继续', + done: '完成', + verifying: '正在与 {service} 通信…', + verifySuccess: '连接成功', + verifyFailed: '验证失败', + verifySkipped: '无需在线验证', + verifySkippedDetail: '该渠道类型没有需要验证的凭证,可以直接保存并启用。', + durationMs: '({n} ms)', + fixIt: '修复', + retry: '重试', + readyHeadline: '一切就绪', + readySubtitle: '绑定一个 Agent,发条测试消息确认。', + bindAgentLabel: '绑定 Agent', + sendTest: '发送测试消息', + sendTestHint: '打开 {service} 给机器人发条消息——回复应该几秒内到达。', + saveFailed: '渠道保存失败', + noVerifierHint: '{type} 暂未支持在线验证。保存会直接启动渠道,请关注卡片上的连接状态指示。', + oauthScanHint: '扫码完成后会自动填充并验证凭证——你不需要手动复制任何东西。', + }, }, skills: { title: '技能管理', diff --git a/mateclaw-ui/src/views/Channels.vue b/mateclaw-ui/src/views/Channels.vue index e6628d16..f684d90f 100644 --- a/mateclaw-ui/src/views/Channels.vue +++ b/mateclaw-ui/src/views/Channels.vue @@ -85,7 +85,11 @@ + chunk doesn't carry the modal's ~30KB form/auth UI on initial load. + Used for editing existing channels and for OAuth-style channel types + (weixin/wecom/dingtalk/feishu) whose existing scan flows are the + verification — those continue to live in the legacy modal until + migrated per RFC-084. --> + + + + @@ -109,6 +129,8 @@ import type { Channel, Agent } from '@/types' // Async-loaded modal: separate chunk, only fetched when the user first clicks // "create" or "edit". The /channels initial load is a list page only. const ChannelEditModal = defineAsyncComponent(() => import('@/components/channels/ChannelEditModal.vue')) +const ChannelTypePicker = defineAsyncComponent(() => import('@/components/channels/ChannelTypePicker.vue')) +const ChannelOnboardingWizard = defineAsyncComponent(() => import('@/components/channels/ChannelOnboardingWizard.vue')) const { t } = useI18n() @@ -118,6 +140,25 @@ const showModal = ref(false) const editingChannel = ref(null) const modalDefaults = ref<{ type?: string; name?: string }>({}) +// RFC-084 onboarding wizard state. The picker is the entry for "+ New +// Channel"; based on the picked type, we either open the new wizard +// (paste-token types) or fall back to the legacy modal (OAuth/QR types +// whose existing flows already cover Configure + Verify in one step). +const showTypePicker = ref(false) +const showWizard = ref(false) +const wizardType = ref('') + +// Channel types that the new 3-step wizard handles end-to-end. All four +// OAuth-style flows (wecom popup-SDK, weixin/dingtalk/feishu QR scans) now +// run inside the wizard — see OAUTH_STYLE_TYPES in +// ChannelOnboardingWizard.vue. The legacy modal stays only for editing +// existing channels (where 3-step would feel like ceremony). +const WIZARD_TYPES = new Set([ + 'telegram', 'discord', 'slack', 'qq', + 'web', 'webchat', 'webhook', + 'wecom', 'weixin', 'dingtalk', 'feishu', +]) + const channelStatusMap = ref