mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(channels): three-step onboarding wizard with live credential verify
This commit is contained in:
parent
2332792e8b
commit
a11f0586ba
@ -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<VerificationResult> preflight(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestBody PreflightRequest body) {
|
||||
long ws = workspaceId != null ? workspaceId : 1L;
|
||||
Map<String, Object> 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<String, Object> 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)) {
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package vip.mate.channel.verifier;
|
||||
|
||||
/**
|
||||
* Pre-flight credential verifier for a single channel type.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
@ -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<ChannelVerifier> verifiers;
|
||||
private Map<String, ChannelVerifier> byType;
|
||||
|
||||
@PostConstruct
|
||||
void index() {
|
||||
Map<String, ChannelVerifier> 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<ChannelVerifier> find(String channelType) {
|
||||
return Optional.ofNullable(byType.get(channelType));
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<String, Object> 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<String, Object> map, String key) {
|
||||
Object v = map.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
@ -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 <token>}. 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<String> 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<String, Object> 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<String, Object> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<String, Object> 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<String, Object> map, String key) {
|
||||
Object v = map.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<String, Object> map, String key) {
|
||||
Object v = map.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<String, Object> 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<String, Object> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> config,
|
||||
Long workspaceId
|
||||
) {
|
||||
}
|
||||
@ -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:
|
||||
* <ul>
|
||||
* <li>{@code headline} — one-line status shown in the verify card</li>
|
||||
* <li>{@code identity} — account display fields piped into Step 3 ("Connected as ...")</li>
|
||||
* <li>{@code invalidField} — when failed, the form key Step 1 should highlight on "Fix it"</li>
|
||||
* <li>{@code hint} — actionable next step, surfaced under the failure headline</li>
|
||||
* </ul>
|
||||
* 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<String, Object> identity,
|
||||
String invalidField,
|
||||
String hint
|
||||
) {
|
||||
|
||||
public static VerificationResult ok(long durationMs, String headline, Map<String, Object> 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<String, Object> identity,
|
||||
String invalidField, String hint) {
|
||||
Map<String, Object> id = identity != null ? identity : new LinkedHashMap<>();
|
||||
return new VerificationResult(false, false, durationMs, headline, id, invalidField, hint);
|
||||
}
|
||||
}
|
||||
@ -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}.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> ackFuture = new CompletableFuture<>();
|
||||
private final AtomicReference<Boolean> 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<String, Object> frame = objectMapper.readValue(json, Map.class);
|
||||
Map<String, Object> headers = (Map<String, Object>) 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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
* <p>
|
||||
* 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<String> 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<String> 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<String, Object> identityFor(String botToken, String baseUrl) {
|
||||
Map<String, Object> 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<String, Object> map, String key) {
|
||||
Object v = map.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
@ -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) =>
|
||||
|
||||
795
mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue
Normal file
795
mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue
Normal file
@ -0,0 +1,795 @@
|
||||
<template>
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="close">
|
||||
<div class="wizard">
|
||||
<!-- Header -->
|
||||
<div class="wizard-header">
|
||||
<div class="wizard-title-row">
|
||||
<div class="wizard-icon-wrap">
|
||||
<img class="wizard-icon-img" :src="iconPath" :alt="channelType" />
|
||||
</div>
|
||||
<div class="wizard-title-text">
|
||||
<h2 class="wizard-title">{{ serviceName }}</h2>
|
||||
<p class="wizard-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button class="wizard-close" @click="close" :title="t('common.cancel')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stepper -->
|
||||
<div class="stepper">
|
||||
<div v-for="(label, i) in stepLabels" :key="i" class="step" :class="stepClass(i)">
|
||||
<div class="step-circle">
|
||||
<svg v-if="i < currentStep" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<span v-else>{{ i + 1 }}</span>
|
||||
</div>
|
||||
<span class="step-label">{{ label }}</span>
|
||||
<div v-if="i < stepLabels.length - 1" class="step-connector" :class="{ done: i < currentStep }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body: switch on currentStep -->
|
||||
<div class="wizard-body">
|
||||
<!-- ============ Step 1 · Configure ============ -->
|
||||
<div v-if="currentStep === 0" class="step-pane">
|
||||
<!-- Optional: name (only when not auto-derived) -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('channels.fields.name') }} <span class="required">*</span></label>
|
||||
<input v-model="form.name" class="form-input" :placeholder="t('channels.placeholders.name')" />
|
||||
</div>
|
||||
|
||||
<!-- OAuth-style: scan card replaces the credential paste form.
|
||||
Two flavors:
|
||||
1. Popup-SDK (wecom): button triggers an external window, we
|
||||
only show the button + loading state.
|
||||
2. QR-display (weixin/dingtalk/feishu): button fetches a QR
|
||||
image from our backend, then we render it inline plus
|
||||
a live polling-status line.
|
||||
In both flavors, the credential callback auto-advances to
|
||||
Step 2 — no extra confirmation click. -->
|
||||
<div v-if="isOAuthStyle" class="oauth-card">
|
||||
<p class="oauth-headline">{{ oauthHeadline }}</p>
|
||||
<p class="oauth-hint">{{ oauthHint }}</p>
|
||||
<button
|
||||
v-if="!oauthQrImg"
|
||||
type="button"
|
||||
class="oauth-btn"
|
||||
:disabled="oauthLoading"
|
||||
@click="onOAuthStart"
|
||||
>
|
||||
<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>
|
||||
{{ oauthLoading ? t('common.loading') : oauthButtonLabel }}
|
||||
</button>
|
||||
<!-- QR placeholder while loading -->
|
||||
<div v-if="oauthLoading && !oauthQrImg && needsQrDisplay" class="oauth-qr-loading">
|
||||
<div class="spinner" />
|
||||
<p class="oauth-qr-status">{{ t('channels.dingtalkRegister.qrcodeLoading') }}…</p>
|
||||
</div>
|
||||
<!-- QR image + scan status -->
|
||||
<div v-if="oauthQrImg" class="oauth-qr">
|
||||
<img :src="oauthQrImg" alt="QR Code" class="oauth-qr-img" />
|
||||
<p class="oauth-qr-status" :class="oauthStatus">{{ oauthStatusText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How to get credentials (collapsed by default) -->
|
||||
<details v-if="!isOAuthStyle && webhookGuide" class="how-to">
|
||||
<summary class="how-to-summary">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
{{ t('channels.wizard.howToGet') }}
|
||||
</summary>
|
||||
<ol class="how-to-steps">
|
||||
<li v-for="(step, i) in webhookGuide.steps" :key="i" v-html="step"></li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<!-- Required credential fields (hidden for OAuth-style; the scan
|
||||
above fills them under the hood). -->
|
||||
<div v-if="!isOAuthStyle && requiredFields.length > 0" class="form-grid">
|
||||
<div v-for="field in requiredFields" :key="field.key" class="form-group full-width">
|
||||
<label class="form-label">
|
||||
{{ field.label }} <span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<div v-if="field.sensitive || field.type === 'password'" class="password-wrap">
|
||||
<input
|
||||
v-model="channelConfig[field.key]"
|
||||
:type="visibleFields[field.key] ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
:class="{ 'field-error': invalidField === field.key }"
|
||||
:placeholder="field.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="button" class="eye-btn" @click="visibleFields[field.key] = !visibleFields[field.key]">
|
||||
<svg v-if="visibleFields[field.key]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="channelConfig[field.key]"
|
||||
:type="field.type === 'number' ? 'number' : 'text'"
|
||||
class="form-input"
|
||||
:class="{ 'field-error': invalidField === field.key }"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
<span v-if="field.tooltip" class="form-hint">{{ field.tooltip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty-config channel types (web, webchat, webhook) -->
|
||||
<div v-else-if="!isOAuthStyle" class="empty-config">
|
||||
<p class="empty-text">{{ emptyConfigText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Show advanced (collapses optional fields) — hidden for OAuth-style
|
||||
channels until the user reaches Step 3, where they can re-edit
|
||||
via the legacy modal if needed. -->
|
||||
<button v-if="!isOAuthStyle && optionalFields.length > 0" class="advanced-toggle" @click="showAdvanced = !showAdvanced">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
:style="{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
{{ showAdvanced ? t('channels.wizard.back') : `${t('channels.advanced')} (${optionalFields.length})` }}
|
||||
</button>
|
||||
|
||||
<div v-if="!isOAuthStyle && showAdvanced" class="advanced-body">
|
||||
<div v-for="field in optionalFields" :key="field.key" class="form-group full-width">
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.tooltip" class="tooltip-icon" :title="field.tooltip">?</span>
|
||||
</label>
|
||||
<select v-if="field.type === 'select'" v-model="channelConfig[field.key]" class="form-input">
|
||||
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<div v-else-if="field.type === 'switch'" class="switch-wrap">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="channelConfig[field.key]" />
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
<span class="switch-label">{{ channelConfig[field.key] ? t('common.on') : t('common.off') }}</span>
|
||||
</div>
|
||||
<input v-else v-model="channelConfig[field.key]"
|
||||
:type="field.type === 'number' ? 'number' : 'text'"
|
||||
class="form-input" :placeholder="field.placeholder" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Step 2 · Verify ============ -->
|
||||
<div v-if="currentStep === 1" class="step-pane verify-pane">
|
||||
<div v-if="verifying" class="verify-card pending">
|
||||
<div class="spinner" />
|
||||
<p class="verify-headline">{{ t('channels.wizard.verifying', { service: serviceName }) }}</p>
|
||||
</div>
|
||||
<div v-else-if="verifyResult?.skipped" class="verify-card skipped">
|
||||
<div class="verify-icon">⏭</div>
|
||||
<p class="verify-headline">{{ t('channels.wizard.verifySkipped') }}</p>
|
||||
<p class="verify-detail">{{ t('channels.wizard.verifySkippedDetail') }}</p>
|
||||
</div>
|
||||
<div v-else-if="verifyResult?.ok" class="verify-card success">
|
||||
<div class="verify-icon">✓</div>
|
||||
<p class="verify-headline">{{ verifyResult.headline }}</p>
|
||||
<p v-if="verifyResult.durationMs" class="verify-detail">
|
||||
{{ t('channels.wizard.durationMs', { n: verifyResult.durationMs }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="verifyResult" class="verify-card failed">
|
||||
<div class="verify-icon">✗</div>
|
||||
<p class="verify-headline">{{ verifyResult.headline }}</p>
|
||||
<p v-if="verifyResult.hint" class="verify-detail">{{ verifyResult.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Step 3 · Ready ============ -->
|
||||
<div v-if="currentStep === 2" class="step-pane ready-pane">
|
||||
<div class="ready-hero">
|
||||
<div class="ready-check">🎉</div>
|
||||
<h3 class="ready-title">{{ readyHeadline }}</h3>
|
||||
<p class="ready-subtitle">{{ t('channels.wizard.readySubtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity card (account/team/etc. from VerificationResult) -->
|
||||
<div v-if="hasIdentity" class="identity-card">
|
||||
<div v-for="(value, key) in identityDisplay" :key="key" class="identity-row">
|
||||
<span class="identity-key">{{ formatIdentityKey(String(key)) }}</span>
|
||||
<span class="identity-value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bind agent -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('channels.wizard.bindAgentLabel') }}</label>
|
||||
<select v-model="form.agentId" class="form-input">
|
||||
<option :value="null">{{ t('channels.placeholders.selectAgent') }}</option>
|
||||
<option v-for="a in agents" :key="a.id" :value="a.id">
|
||||
{{ a.icon || '🤖' }} {{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="ready-hint">{{ t('channels.wizard.sendTestHint', { service: serviceName }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="wizard-footer">
|
||||
<button v-if="currentStep > 0" class="btn-secondary" @click="goBack">
|
||||
{{ t('channels.wizard.back') }}
|
||||
</button>
|
||||
<div class="footer-spacer" />
|
||||
<button
|
||||
v-if="currentStep === 0 && !isOAuthStyle"
|
||||
class="btn-primary"
|
||||
:disabled="!canSubmitConfig"
|
||||
@click="onSaveAndTest"
|
||||
>
|
||||
{{ hasVerifier ? t('channels.wizard.saveAndTest') : t('channels.wizard.continue') }}
|
||||
</button>
|
||||
<template v-else-if="currentStep === 1">
|
||||
<button v-if="verifyResult && !verifyResult.ok && !verifyResult.skipped" class="btn-primary" @click="onFixIt">
|
||||
{{ t('channels.wizard.fixIt') }}
|
||||
</button>
|
||||
<button v-else-if="verifyResult" class="btn-primary" @click="goNext">
|
||||
{{ t('channels.wizard.continue') }}
|
||||
</button>
|
||||
</template>
|
||||
<button v-else-if="currentStep === 2" class="btn-primary" @click="onDone" :disabled="saving">
|
||||
{{ saving ? t('common.loading') : t('channels.wizard.done') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CHANNEL_FIELD_DEFS } from '@/types'
|
||||
import type { Agent, Channel, ChannelFieldDef } from '@/types'
|
||||
import { channelApi } from '@/api'
|
||||
import {
|
||||
buildConfigJson,
|
||||
defaultAccessControl,
|
||||
defaultRenderConfig,
|
||||
} from '@/utils/channelConfigJson'
|
||||
import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth'
|
||||
import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll'
|
||||
import { useDingTalkAppRegister } from '@/composables/channels/useDingTalkAppRegister'
|
||||
import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
channelType: string
|
||||
agents: Agent[]
|
||||
defaultName?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { defaultName: '' })
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Channel was created successfully — parent should refresh the list. */
|
||||
created: [channel: Channel]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Channel types whose verifiers exist on the backend right now. The list
|
||||
// matches RFC-084 §4.3 — extending it requires no UI change, just register
|
||||
// a new ChannelVerifier bean.
|
||||
const VERIFIABLE_TYPES = new Set(['telegram', 'discord', 'slack', 'wecom', 'weixin', 'dingtalk', 'feishu'])
|
||||
|
||||
// Channel types whose Step 1 is an OAuth/QR scan instead of a credential
|
||||
// paste form. Once the scan callback delivers credentials, Step 1 hands
|
||||
// off to Step 2 automatically — there is no "Save & Test" button to press.
|
||||
const OAUTH_STYLE_TYPES = new Set(['wecom', 'weixin', 'dingtalk', 'feishu'])
|
||||
|
||||
// QR-display flavor (button → fetch QR → render image inline → poll). WeCom
|
||||
// uses an external SDK popup window instead, so it is excluded.
|
||||
const QR_DISPLAY_TYPES = new Set(['weixin', 'dingtalk', 'feishu'])
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const currentStep = ref(0)
|
||||
const stepLabels = computed(() => [
|
||||
t('channels.wizard.step1'),
|
||||
t('channels.wizard.step2'),
|
||||
t('channels.wizard.step3'),
|
||||
])
|
||||
|
||||
const form = ref<Partial<Channel>>({
|
||||
name: props.defaultName || translateServiceName(props.channelType),
|
||||
channelType: props.channelType,
|
||||
description: '',
|
||||
agentId: null as any,
|
||||
enabled: true,
|
||||
})
|
||||
const channelConfig = ref<Record<string, any>>({})
|
||||
const visibleFields = ref<Record<string, boolean>>({})
|
||||
const showAdvanced = ref(false)
|
||||
|
||||
const verifying = ref(false)
|
||||
const verifyResult = ref<VerifyResult | null>(null)
|
||||
const invalidField = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
interface VerifyResult {
|
||||
ok: boolean
|
||||
skipped: boolean
|
||||
durationMs: number
|
||||
headline: string
|
||||
identity: Record<string, any>
|
||||
invalidField?: string | null
|
||||
hint?: string | null
|
||||
}
|
||||
|
||||
// ==================== Derived ====================
|
||||
|
||||
const channelType = computed(() => props.channelType)
|
||||
const iconPath = computed(() => `/icons/channels/${channelType.value}.svg`)
|
||||
const serviceName = computed(() => translateServiceName(channelType.value))
|
||||
const subtitle = computed(() =>
|
||||
t('channels.wizard.configureSubtitle', { service: serviceName.value })
|
||||
)
|
||||
|
||||
const allFields = computed<ChannelFieldDef[]>(() => CHANNEL_FIELD_DEFS[channelType.value] || [])
|
||||
const requiredFields = computed(() => allFields.value.filter((f) => f.required))
|
||||
const optionalFields = computed(() => allFields.value.filter((f) => !f.required))
|
||||
|
||||
const hasVerifier = computed(() => VERIFIABLE_TYPES.has(channelType.value))
|
||||
const isOAuthStyle = computed(() => OAUTH_STYLE_TYPES.has(channelType.value))
|
||||
|
||||
// ==================== OAuth-style Step 1 ====================
|
||||
//
|
||||
// For channels like WeCom, Step 1 is a single "Scan to Connect" button.
|
||||
// The scan composable populates channelConfig under the hood and we
|
||||
// auto-advance to Step 2, which calls preflight to actually verify the
|
||||
// credentials work end-to-end.
|
||||
|
||||
// All four OAuth-style composables get instantiated up-front so reactive
|
||||
// state is available regardless of channel type. Each one's onConfirmed
|
||||
// fills the appropriate channelConfig fields then triggers onSaveAndTest()
|
||||
// — the wizard auto-advances to Step 2 the moment credentials arrive,
|
||||
// which is the magic that makes "scan once and watch the verify" work.
|
||||
|
||||
const wecomAuth = useWecomBotAuth((bot) => {
|
||||
channelConfig.value.bot_id = bot.botid
|
||||
channelConfig.value.secret = bot.secret
|
||||
void onSaveAndTest()
|
||||
})
|
||||
|
||||
const weixinAuth = useWeixinQrcodePoll(({ botToken, baseUrl }) => {
|
||||
channelConfig.value.bot_token = botToken
|
||||
if (baseUrl) channelConfig.value.base_url = baseUrl
|
||||
void onSaveAndTest()
|
||||
})
|
||||
|
||||
const dingtalkAuth = useDingTalkAppRegister(({ clientId, clientSecret }) => {
|
||||
channelConfig.value.client_id = clientId
|
||||
channelConfig.value.client_secret = clientSecret
|
||||
void onSaveAndTest()
|
||||
})
|
||||
|
||||
const feishuAuth = useFeishuAppRegister(({ appId, appSecret }) => {
|
||||
channelConfig.value.app_id = appId
|
||||
channelConfig.value.app_secret = appSecret
|
||||
void onSaveAndTest()
|
||||
})
|
||||
|
||||
const needsQrDisplay = computed(() => QR_DISPLAY_TYPES.has(channelType.value))
|
||||
|
||||
const oauthLoading = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'wecom': return wecomAuth.loading.value
|
||||
case 'weixin': return weixinAuth.loading.value
|
||||
case 'dingtalk': return dingtalkAuth.loading.value
|
||||
case 'feishu': return feishuAuth.loading.value
|
||||
default: return false
|
||||
}
|
||||
})
|
||||
|
||||
const oauthQrImg = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'weixin': return weixinAuth.qrcodeImg.value
|
||||
case 'dingtalk': return dingtalkAuth.qrcodeUrl.value
|
||||
case 'feishu': return feishuAuth.qrcodeUrl.value
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const oauthStatus = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'weixin': return weixinAuth.pollStatus.value
|
||||
case 'dingtalk': return dingtalkAuth.status.value
|
||||
case 'feishu': return feishuAuth.status.value
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const oauthStatusText = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'weixin': {
|
||||
switch (weixinAuth.pollStatus.value) {
|
||||
case 'scanned': return t('channels.weixin.scanned')
|
||||
case 'confirmed': return t('channels.weixin.loginSuccess')
|
||||
case 'expired': return t('channels.weixin.qrcodeExpired')
|
||||
default: return t('channels.weixin.scanHint')
|
||||
}
|
||||
}
|
||||
case 'dingtalk': {
|
||||
switch (dingtalkAuth.status.value) {
|
||||
case 'confirmed': return t('channels.dingtalkRegister.confirmed')
|
||||
case 'expired': return t('channels.dingtalkRegister.expired')
|
||||
case 'denied': return t('channels.dingtalkRegister.denied')
|
||||
default: return t('channels.dingtalkRegister.scanHint')
|
||||
}
|
||||
}
|
||||
case 'feishu': {
|
||||
switch (feishuAuth.status.value) {
|
||||
case 'confirmed': return t('channels.feishuRegister.confirmed')
|
||||
case 'expired': return t('channels.feishuRegister.expired')
|
||||
case 'denied': return t('channels.feishuRegister.denied')
|
||||
case 'error': return t('channels.feishuRegister.error')
|
||||
default: return t('channels.feishuRegister.scanHint')
|
||||
}
|
||||
}
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const oauthHeadline = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'wecom': return t('channels.wecom.authHint')
|
||||
case 'weixin': return t('channels.weixin.authHint')
|
||||
case 'dingtalk': return t('channels.dingtalkRegister.hint')
|
||||
case 'feishu': return t('channels.feishuRegister.hint')
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const oauthHint = computed(() => {
|
||||
if (!isOAuthStyle.value) return ''
|
||||
return t('channels.wizard.oauthScanHint', { service: serviceName.value })
|
||||
})
|
||||
|
||||
const oauthButtonLabel = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'wecom': return t('channels.wecom.authButton')
|
||||
case 'weixin': return t('channels.weixin.qrcodeButton')
|
||||
case 'dingtalk': return t('channels.dingtalkRegister.button')
|
||||
case 'feishu': return t('channels.feishuRegister.button')
|
||||
default: return t('channels.wizard.saveAndTest')
|
||||
}
|
||||
})
|
||||
|
||||
function onOAuthStart() {
|
||||
switch (channelType.value) {
|
||||
case 'wecom': wecomAuth.start(); break
|
||||
case 'weixin': weixinAuth.start(); break
|
||||
case 'dingtalk': dingtalkAuth.start(); break
|
||||
case 'feishu': feishuAuth.start(getDomainOrDefault()); break
|
||||
}
|
||||
}
|
||||
|
||||
function getDomainOrDefault(): string {
|
||||
// useFeishuAppRegister.start(domain) takes the region selector. We default
|
||||
// to 'feishu' (China) since that's what the field def defaults to; users
|
||||
// who need Lark international can still pick it post-Step-3 in advanced
|
||||
// edit. Adding a region toggle to Step 1 would defeat the "one button"
|
||||
// simplicity that justifies this redesign.
|
||||
return (channelConfig.value.domain as string) || 'feishu'
|
||||
}
|
||||
|
||||
const emptyConfigText = computed(() => {
|
||||
switch (channelType.value) {
|
||||
case 'web': return t('channels.webHint')
|
||||
case 'webchat': return t('channels.webchatHint')
|
||||
case 'webhook': return t('channels.webhookHint')
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const canSubmitConfig = computed(() => {
|
||||
if (!form.value.name) return false
|
||||
return requiredFields.value.every((f) => {
|
||||
const v = channelConfig.value[f.key]
|
||||
return v !== undefined && v !== null && String(v).length > 0
|
||||
})
|
||||
})
|
||||
|
||||
const webhookGuide = computed(() => {
|
||||
const guides: Record<string, string[]> = {
|
||||
telegram: [
|
||||
t('channels.guide.telegram.step1'),
|
||||
t('channels.guide.telegram.step2'),
|
||||
t('channels.guide.telegram.step3'),
|
||||
t('channels.guide.telegram.step4'),
|
||||
],
|
||||
discord: [
|
||||
t('channels.guide.discord.step1'),
|
||||
t('channels.guide.discord.step2'),
|
||||
t('channels.guide.discord.step3'),
|
||||
t('channels.guide.discord.step4'),
|
||||
t('channels.guide.discord.step5'),
|
||||
],
|
||||
qq: [
|
||||
t('channels.guide.qq.step1'),
|
||||
t('channels.guide.qq.step2'),
|
||||
t('channels.guide.qq.step3'),
|
||||
t('channels.guide.qq.step4'),
|
||||
],
|
||||
}
|
||||
const steps = guides[channelType.value]
|
||||
return steps ? { steps } : null
|
||||
})
|
||||
|
||||
const hasIdentity = computed(() => verifyResult.value && Object.keys(verifyResult.value.identity || {}).length > 0)
|
||||
const identityDisplay = computed(() => verifyResult.value?.identity || {})
|
||||
const readyHeadline = computed(() => {
|
||||
const id = verifyResult.value?.identity || {}
|
||||
if (id.accountName) return `${t('channels.wizard.readyHeadline')} — ${id.accountName}`
|
||||
return t('channels.wizard.readyHeadline')
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(() => {
|
||||
// Seed default values for optional fields with defaultValue, so the
|
||||
// submitted configJson reflects the same shape that the old modal builds.
|
||||
for (const f of allFields.value) {
|
||||
if (f.defaultValue !== undefined && channelConfig.value[f.key] === undefined) {
|
||||
channelConfig.value[f.key] = f.defaultValue
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Step navigation ====================
|
||||
|
||||
function stepClass(i: number) {
|
||||
if (i < currentStep.value) return 'done'
|
||||
if (i === currentStep.value) return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (currentStep.value > 0) currentStep.value -= 1
|
||||
}
|
||||
function goNext() {
|
||||
if (currentStep.value < 2) currentStep.value += 1
|
||||
}
|
||||
|
||||
async function onSaveAndTest() {
|
||||
invalidField.value = null
|
||||
if (!hasVerifier.value) {
|
||||
// No verifier — skip Step 2 with a synthetic skipped result, jump to Ready.
|
||||
verifyResult.value = {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
durationMs: 0,
|
||||
headline: t('channels.wizard.verifySkipped'),
|
||||
identity: {},
|
||||
}
|
||||
currentStep.value = 2
|
||||
return
|
||||
}
|
||||
currentStep.value = 1
|
||||
verifying.value = true
|
||||
verifyResult.value = null
|
||||
try {
|
||||
const configJson = buildConfigJson({
|
||||
channelType: channelType.value,
|
||||
channelConfig: channelConfig.value,
|
||||
accessControl: defaultAccessControl(),
|
||||
renderConfig: defaultRenderConfig(),
|
||||
})
|
||||
const res: any = await channelApi.preflight(channelType.value, configJson)
|
||||
verifyResult.value = res.data as VerifyResult
|
||||
if (!verifyResult.value.ok && !verifyResult.value.skipped) {
|
||||
invalidField.value = verifyResult.value.invalidField || null
|
||||
}
|
||||
} catch (e: any) {
|
||||
verifyResult.value = {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
durationMs: 0,
|
||||
headline: t('channels.wizard.verifyFailed'),
|
||||
identity: {},
|
||||
hint: e?.message || '',
|
||||
}
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFixIt() {
|
||||
// Return to Step 1 with the bad field highlighted.
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
async function onDone() {
|
||||
saving.value = true
|
||||
try {
|
||||
const configJson = buildConfigJson({
|
||||
channelType: channelType.value,
|
||||
channelConfig: channelConfig.value,
|
||||
accessControl: defaultAccessControl(),
|
||||
renderConfig: defaultRenderConfig(),
|
||||
})
|
||||
const payload: Partial<Channel> = { ...form.value, configJson, enabled: true }
|
||||
const res: any = await channelApi.create(payload)
|
||||
ElMessage.success(t('channels.messages.saveSuccess'))
|
||||
emit('created', res.data as Channel)
|
||||
close()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('channels.wizard.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
function translateServiceName(type: string): string {
|
||||
const k = `channels.types.${type}`
|
||||
const translated = t(k)
|
||||
return translated === k ? type : translated
|
||||
}
|
||||
|
||||
function formatIdentityKey(key: string): string {
|
||||
// Turn camelCase keys into "Camel Case" for display.
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
.wizard { background: var(--mc-bg-elevated); border-radius: 18px; width: 100%; max-width: 640px; max-height: 92vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.18); overflow: hidden; }
|
||||
|
||||
/* ===== Header ===== */
|
||||
.wizard-header { padding: 22px 26px 18px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.wizard-title-row { display: flex; align-items: center; gap: 14px; }
|
||||
.wizard-icon-wrap { width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: var(--mc-bg-sunken); flex-shrink: 0; overflow: hidden; }
|
||||
.wizard-icon-img { width: 38px; height: 38px; object-fit: cover; }
|
||||
.wizard-title-text { flex: 1; min-width: 0; }
|
||||
.wizard-title { font-size: 19px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
|
||||
.wizard-subtitle { font-size: 13px; color: var(--mc-text-secondary); margin: 2px 0 0; }
|
||||
.wizard-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 8px; flex-shrink: 0; }
|
||||
.wizard-close:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
|
||||
|
||||
/* ===== Stepper ===== */
|
||||
.stepper { display: flex; align-items: center; gap: 0; margin-top: 18px; padding: 0 6px; }
|
||||
.step { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
||||
.step-circle { width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; transition: all 0.18s; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border: 1.5px solid var(--mc-border); }
|
||||
.step.active .step-circle { background: var(--mc-primary); color: #fff; border-color: var(--mc-primary); }
|
||||
.step.done .step-circle { background: var(--mc-primary); color: #fff; border-color: var(--mc-primary); }
|
||||
.step-label { font-size: 13px; color: var(--mc-text-tertiary); font-weight: 500; }
|
||||
.step.active .step-label { color: var(--mc-primary); font-weight: 700; }
|
||||
.step.done .step-label { color: var(--mc-text-primary); }
|
||||
.step-connector { flex: 1; min-width: 36px; height: 1.5px; background: var(--mc-border); margin: 0 12px; align-self: center; transition: background 0.18s; }
|
||||
.step-connector.done { background: var(--mc-primary); }
|
||||
|
||||
/* ===== Body ===== */
|
||||
.wizard-body { flex: 1; overflow-y: auto; padding: 22px 26px; }
|
||||
.step-pane { display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* ===== Forms ===== */
|
||||
.form-grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
.form-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); display: flex; align-items: center; gap: 5px; }
|
||||
.required { color: var(--mc-danger, #ef4444); }
|
||||
.form-input { padding: 10px 12px; border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-elevated); width: 100%; box-sizing: border-box; transition: border-color 0.15s; }
|
||||
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 3px rgba(217,119,87,0.12); }
|
||||
.form-input.field-error { border-color: var(--mc-danger, #ef4444); box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12); }
|
||||
.form-hint { font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
|
||||
.password-wrap { position: relative; display: flex; align-items: center; }
|
||||
.password-wrap .form-input { padding-right: 36px; }
|
||||
.eye-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; }
|
||||
.eye-btn:hover { color: var(--mc-text-primary); }
|
||||
|
||||
.tooltip-icon { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 50%; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); font-size: 10px; font-weight: 700; cursor: help; }
|
||||
|
||||
/* ===== OAuth-style scan card (WeCom etc.) ===== */
|
||||
.oauth-card { background: linear-gradient(135deg, rgba(7, 193, 96, 0.06), rgba(7, 193, 96, 0.02)); border: 1px solid rgba(7, 193, 96, 0.18); border-radius: 12px; padding: 18px 18px 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.oauth-headline { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); margin: 0; line-height: 1.5; }
|
||||
.oauth-hint { font-size: 12px; color: var(--mc-text-secondary); margin: 0; line-height: 1.6; }
|
||||
.oauth-btn { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 6px; padding: 11px 18px; background: #07C160; color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.18s; }
|
||||
.oauth-btn:hover:not(:disabled) { background: #06AD56; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(7,193,96,0.3); }
|
||||
.oauth-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* QR display (weixin/dingtalk/feishu) */
|
||||
.oauth-qr { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 8px; padding: 14px; background: #fff; border-radius: 10px; border: 1px solid var(--mc-border); }
|
||||
.oauth-qr-img { width: 200px; height: 200px; border-radius: 4px; }
|
||||
.oauth-qr-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 220px; padding: 14px; background: #fff; border-radius: 10px; border: 1px dashed var(--mc-border); margin-top: 8px; }
|
||||
.oauth-qr-status { font-size: 13px; color: var(--mc-text-secondary); margin: 0; text-align: center; }
|
||||
.oauth-qr-status.scanned { color: #E6A23C; }
|
||||
.oauth-qr-status.confirmed { color: #10b981; font-weight: 600; }
|
||||
.oauth-qr-status.expired, .oauth-qr-status.denied, .oauth-qr-status.error { color: #f56c6c; }
|
||||
|
||||
/* ===== How-to-get details ===== */
|
||||
.how-to { background: var(--mc-bg-sunken); border-radius: 10px; padding: 10px 14px; }
|
||||
.how-to-summary { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); cursor: pointer; display: flex; align-items: center; gap: 6px; list-style: none; user-select: none; }
|
||||
.how-to-summary::-webkit-details-marker { display: none; }
|
||||
.how-to-summary svg { transition: transform 0.18s; }
|
||||
.how-to[open] .how-to-summary svg { transform: rotate(90deg); }
|
||||
.how-to-steps { margin: 10px 0 0; padding-left: 22px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.7; }
|
||||
.how-to-steps :deep(a) { color: var(--mc-primary); text-decoration: none; }
|
||||
.how-to-steps :deep(a:hover) { text-decoration: underline; }
|
||||
.how-to-steps :deep(code) { font-size: 12px; background: var(--mc-bg-elevated); padding: 1px 5px; border-radius: 3px; }
|
||||
.how-to-steps :deep(b) { color: var(--mc-text-primary); }
|
||||
|
||||
/* ===== Advanced ===== */
|
||||
.advanced-toggle { display: flex; align-items: center; gap: 6px; background: none; border: none; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); padding: 4px 0; align-self: flex-start; }
|
||||
.advanced-toggle:hover { color: var(--mc-text-primary); }
|
||||
.advanced-body { display: flex; flex-direction: column; gap: 14px; padding: 4px 0 0; }
|
||||
.switch-wrap { display: flex; align-items: center; gap: 8px; height: 36px; }
|
||||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch-slider { position: absolute; cursor: pointer; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||
.switch-slider::before { content: ""; position: absolute; height: 14px; width: 14px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s; }
|
||||
.switch input:checked + .switch-slider { background: var(--mc-primary); }
|
||||
.switch input:checked + .switch-slider::before { transform: translateX(16px); }
|
||||
.switch-label { font-size: 13px; color: var(--mc-text-secondary); }
|
||||
|
||||
.empty-config { padding: 24px 16px; text-align: center; background: var(--mc-bg-sunken); border-radius: 10px; }
|
||||
.empty-text { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
|
||||
|
||||
/* ===== Verify pane ===== */
|
||||
.verify-pane { min-height: 220px; align-items: center; justify-content: center; }
|
||||
.verify-card { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 36px 20px; border-radius: 14px; text-align: center; width: 100%; max-width: 420px; }
|
||||
.verify-card.pending { background: var(--mc-bg-sunken); }
|
||||
.verify-card.success { background: rgba(16, 185, 129, 0.06); border: 1px solid rgba(16, 185, 129, 0.2); }
|
||||
.verify-card.failed { background: rgba(239, 68, 68, 0.06); border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.verify-card.skipped { background: var(--mc-bg-sunken); }
|
||||
.verify-icon { font-size: 36px; line-height: 1; }
|
||||
.verify-card.success .verify-icon { color: #10b981; }
|
||||
.verify-card.failed .verify-icon { color: #ef4444; }
|
||||
.verify-headline { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
|
||||
.verify-detail { font-size: 13px; color: var(--mc-text-secondary); margin: 0; line-height: 1.6; }
|
||||
.spinner { width: 36px; height: 36px; border: 3px solid var(--mc-border); border-top-color: var(--mc-primary); border-radius: 50%; animation: wizard-spin 0.8s linear infinite; }
|
||||
@keyframes wizard-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ===== Ready pane ===== */
|
||||
.ready-pane { gap: 20px; }
|
||||
.ready-hero { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 16px 0 8px; }
|
||||
.ready-check { font-size: 44px; line-height: 1; }
|
||||
.ready-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 10px 0 4px; }
|
||||
.ready-subtitle { font-size: 13px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.identity-card { background: var(--mc-bg-sunken); border-radius: 10px; padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.identity-row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
|
||||
.identity-key { color: var(--mc-text-tertiary); font-weight: 500; }
|
||||
.identity-value { color: var(--mc-text-primary); font-weight: 600; word-break: break-all; text-align: right; }
|
||||
.ready-hint { font-size: 12px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.5; text-align: center; }
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.wizard-footer { display: flex; align-items: center; gap: 10px; padding: 16px 26px; border-top: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); }
|
||||
.footer-spacer { flex: 1; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 22px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); transition: all 0.15s; }
|
||||
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--mc-shadow-medium); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; box-shadow: none; }
|
||||
.btn-secondary { padding: 10px 18px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
</style>
|
||||
69
mateclaw-ui/src/components/channels/ChannelTypePicker.vue
Normal file
69
mateclaw-ui/src/components/channels/ChannelTypePicker.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="close">
|
||||
<div class="picker">
|
||||
<div class="picker-header">
|
||||
<h2 class="picker-title">{{ t('channels.newChannel') }}</h2>
|
||||
<button class="picker-close" @click="close" :title="t('common.cancel')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="picker-grid">
|
||||
<button
|
||||
v-for="type in types"
|
||||
:key="type"
|
||||
class="picker-card"
|
||||
@click="pick(type)"
|
||||
>
|
||||
<img :src="`/icons/channels/${type}.svg`" :alt="type" class="picker-icon" />
|
||||
<span class="picker-name">{{ t(`channels.types.${type}`) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** User picked a channel type — parent decides which UI to open. */
|
||||
pick: [channelType: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Order matches the dropdown in ChannelEditModal so users see the same
|
||||
// ordering. Web/WebChat/Webhook last because they're "platform" rather
|
||||
// than "messaging service".
|
||||
const types = [
|
||||
'telegram', 'discord', 'slack',
|
||||
'dingtalk', 'feishu', 'wecom', 'weixin', 'qq',
|
||||
'web', 'webchat', 'webhook',
|
||||
]
|
||||
|
||||
function pick(type: string) {
|
||||
emit('pick', type)
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
.picker { background: var(--mc-bg-elevated); border-radius: 18px; width: 100%; max-width: 520px; max-height: 88vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.18); overflow: hidden; }
|
||||
.picker-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.picker-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
|
||||
.picker-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 8px; }
|
||||
.picker-close:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
|
||||
.picker-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 20px 24px; overflow-y: auto; }
|
||||
.picker-card { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px 8px; background: var(--mc-bg-sunken); border: 1.5px solid transparent; border-radius: 12px; cursor: pointer; transition: all 0.15s; font-family: inherit; }
|
||||
.picker-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg, rgba(217,119,87,0.06)); transform: translateY(-1px); }
|
||||
.picker-icon { width: 36px; height: 36px; border-radius: 8px; }
|
||||
.picker-name { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); text-align: center; }
|
||||
</style>
|
||||
@ -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',
|
||||
|
||||
@ -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: '技能管理',
|
||||
|
||||
@ -85,7 +85,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Edit modal: async-loaded the first time it's opened, so the route
|
||||
chunk doesn't carry the modal's ~30KB form/auth UI on initial load. -->
|
||||
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. -->
|
||||
<ChannelEditModal
|
||||
v-if="showModal"
|
||||
v-model="showModal"
|
||||
@ -96,6 +100,22 @@
|
||||
@save="handleSave"
|
||||
@add-new-weixin="handleAddNewWeixin"
|
||||
/>
|
||||
|
||||
<!-- RFC-084 onboarding wizard: paste-token channel types use the new
|
||||
3-step Configure → Verify → Ready flow. Type picker decides which
|
||||
is shown when the user starts a new channel. -->
|
||||
<ChannelTypePicker
|
||||
v-if="showTypePicker"
|
||||
v-model="showTypePicker"
|
||||
@pick="onTypePicked"
|
||||
/>
|
||||
<ChannelOnboardingWizard
|
||||
v-if="showWizard && wizardType"
|
||||
v-model="showWizard"
|
||||
:channel-type="wizardType"
|
||||
:agents="agents"
|
||||
@created="onWizardCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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<Channel | null>(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<string>('')
|
||||
|
||||
// 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<Record<string | number, {
|
||||
connectionState: string
|
||||
lastError: string | null
|
||||
@ -280,9 +321,28 @@ function getConnectionTooltip(channel: Channel): string {
|
||||
// ==================== Modal control ====================
|
||||
|
||||
function openCreateModal() {
|
||||
// RFC-084: New channel always starts with the type picker so the wizard
|
||||
// can specialize Step 1 to the picked service. Editing an existing
|
||||
// channel still goes straight to the legacy modal via openEditModal.
|
||||
editingChannel.value = null
|
||||
modalDefaults.value = {}
|
||||
showModal.value = true
|
||||
showTypePicker.value = true
|
||||
}
|
||||
|
||||
function onTypePicked(type: string) {
|
||||
if (WIZARD_TYPES.has(type)) {
|
||||
wizardType.value = type
|
||||
showWizard.value = true
|
||||
} else {
|
||||
// Fall back to legacy modal for OAuth/QR types until they're migrated.
|
||||
modalDefaults.value = { type }
|
||||
showModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onWizardCreated(_channel: Channel) {
|
||||
await loadChannels()
|
||||
await loadStatus()
|
||||
}
|
||||
|
||||
function openEditModal(channel: Channel) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user