mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): add WeCom QR binding flow to channel settings (#1994)
- add backend WeCom QR flow endpoints and in-memory flow state management - add frontend WeCom binding UI with QR polling and channel enable toggle - update channel config behavior and i18n strings for WeCom and WeChat - apply minor formatting cleanup in model-related components
This commit is contained in:
@@ -19,6 +19,8 @@ type Handler struct {
|
||||
oauthState map[string]string
|
||||
weixinMu sync.Mutex
|
||||
weixinFlows map[string]*weixinFlow
|
||||
wecomMu sync.Mutex
|
||||
wecomFlows map[string]*wecomFlow
|
||||
}
|
||||
|
||||
// NewHandler creates an instance of the API handler.
|
||||
@@ -29,6 +31,7 @@ func NewHandler(configPath string) *Handler {
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
oauthState: make(map[string]string),
|
||||
weixinFlows: make(map[string]*weixinFlow),
|
||||
wecomFlows: make(map[string]*wecomFlow),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +78,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
|
||||
// WeChat QR login flow
|
||||
h.registerWeixinRoutes(mux)
|
||||
|
||||
// WeCom QR login flow
|
||||
h.registerWecomRoutes(mux)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomFlowTTL = 5 * time.Minute
|
||||
wecomFlowGCAge = 30 * time.Minute
|
||||
wecomQRSourceID = "picoclaw"
|
||||
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
|
||||
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
|
||||
wecomQRHTTPTimeout = 15 * time.Second
|
||||
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
|
||||
wecomPollStartTimeout = 15 * time.Second
|
||||
wecomPollStatusTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
wecomStatusWait = "wait"
|
||||
wecomStatusScanned = "scaned"
|
||||
wecomStatusConfirmed = "confirmed"
|
||||
wecomStatusExpired = "expired"
|
||||
wecomStatusError = "error"
|
||||
)
|
||||
|
||||
type wecomFlow struct {
|
||||
ID string
|
||||
SCode string
|
||||
QRDataURI string
|
||||
BotID string
|
||||
Status string
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type wecomFlowResponse struct {
|
||||
FlowID string `json:"flow_id"`
|
||||
Status string `json:"status"`
|
||||
QRDataURI string `json:"qr_data_uri,omitempty"`
|
||||
BotID string `json:"bot_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type wecomQRGenerateResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
SCode string `json:"scode"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type wecomQRQueryResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
BotInfo struct {
|
||||
BotID string `json:"botid"`
|
||||
Secret string `json:"secret"`
|
||||
} `json:"bot_info"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// registerWecomRoutes binds WeCom QR login endpoints to the ServeMux.
|
||||
func (h *Handler) registerWecomRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/wecom/flows", h.handleStartWecomFlow)
|
||||
mux.HandleFunc("GET /api/wecom/flows/{id}", h.handlePollWecomFlow)
|
||||
}
|
||||
|
||||
// handleStartWecomFlow starts a new WeCom QR login flow.
|
||||
//
|
||||
// POST /api/wecom/flows
|
||||
func (h *Handler) handleStartWecomFlow(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), wecomPollStartTimeout)
|
||||
defer cancel()
|
||||
|
||||
session, err := fetchWecomQRCode(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
dataURI, err := generateQRDataURI(session.Data.AuthURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
flow := &wecomFlow{
|
||||
ID: newWecomFlowID(),
|
||||
SCode: session.Data.SCode,
|
||||
QRDataURI: dataURI,
|
||||
Status: wecomStatusWait,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ExpiresAt: now.Add(wecomFlowTTL),
|
||||
}
|
||||
h.storeWecomFlow(flow)
|
||||
|
||||
logger.InfoCF("wecom", "QR flow started", map[string]any{"flow_id": flow.ID})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(wecomFlowResponse{
|
||||
FlowID: flow.ID,
|
||||
Status: flow.Status,
|
||||
QRDataURI: flow.QRDataURI,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePollWecomFlow polls the WeCom API for QR code status and updates the flow.
|
||||
//
|
||||
// GET /api/wecom/flows/{id}
|
||||
func (h *Handler) handlePollWecomFlow(w http.ResponseWriter, r *http.Request) {
|
||||
flowID := strings.TrimSpace(r.PathValue("id"))
|
||||
if flowID == "" {
|
||||
http.Error(w, "missing flow id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
flow, ok := h.getWecomFlow(flowID)
|
||||
if !ok {
|
||||
http.Error(w, "flow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if flow.Status == wecomStatusConfirmed ||
|
||||
flow.Status == wecomStatusExpired ||
|
||||
flow.Status == wecomStatusError {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(wecomFlowResponse{
|
||||
FlowID: flow.ID,
|
||||
Status: flow.Status,
|
||||
BotID: flow.BotID,
|
||||
Error: flow.Error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), wecomPollStatusTimeout)
|
||||
defer cancel()
|
||||
|
||||
statusResp, err := queryWecomQRCodeStatus(ctx, flow.SCode)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(wecomFlowResponse{
|
||||
FlowID: flow.ID,
|
||||
Status: flow.Status,
|
||||
QRDataURI: flow.QRDataURI,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(statusResp.Data.Status) {
|
||||
case wecomStatusWait:
|
||||
// no-op
|
||||
case wecomStatusScanned, "scanned":
|
||||
h.updateWecomFlowStatus(flowID, wecomStatusScanned)
|
||||
case "success":
|
||||
if statusResp.Data.BotInfo.BotID == "" || statusResp.Data.BotInfo.Secret == "" {
|
||||
h.setWecomFlowError(flowID, "login confirmed but missing bot credentials")
|
||||
break
|
||||
}
|
||||
if saveErr := h.saveWecomBinding(
|
||||
statusResp.Data.BotInfo.BotID,
|
||||
statusResp.Data.BotInfo.Secret,
|
||||
); saveErr != nil {
|
||||
h.setWecomFlowError(flowID, fmt.Sprintf("failed to save credentials: %v", saveErr))
|
||||
logger.ErrorCF("wecom", "failed to save credentials", map[string]any{"error": saveErr.Error()})
|
||||
break
|
||||
}
|
||||
h.setWecomFlowConfirmed(flowID, statusResp.Data.BotInfo.BotID)
|
||||
logger.InfoCF("wecom", "QR login confirmed, credentials saved", map[string]any{
|
||||
"flow_id": flowID,
|
||||
"bot_id": statusResp.Data.BotInfo.BotID,
|
||||
})
|
||||
case wecomStatusExpired:
|
||||
h.updateWecomFlowStatus(flowID, wecomStatusExpired)
|
||||
}
|
||||
|
||||
flow, _ = h.getWecomFlow(flowID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := wecomFlowResponse{
|
||||
FlowID: flow.ID,
|
||||
Status: flow.Status,
|
||||
BotID: flow.BotID,
|
||||
Error: flow.Error,
|
||||
}
|
||||
if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned {
|
||||
resp.QRDataURI = flow.QRDataURI
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (h *Handler) saveWecomBinding(botID, secret string) error {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Channels.WeCom.Enabled = true
|
||||
cfg.Channels.WeCom.BotID = botID
|
||||
cfg.Channels.WeCom.SetSecret(secret)
|
||||
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
||||
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
||||
}
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status := h.gatewayStatusData()
|
||||
gatewayStatus, _ := status["gateway_status"].(string)
|
||||
if gatewayStatus != "running" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := h.RestartGateway(); err != nil {
|
||||
logger.ErrorCF("wecom", "failed to restart gateway after saving binding", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchWecomQRCode(ctx context.Context) (wecomQRGenerateResponse, error) {
|
||||
targetURL, err := buildWecomQRGenerateURL(wecomQRGenerateEndpoint, wecomQRSourceID, wecomPlatformCode())
|
||||
if err != nil {
|
||||
return wecomQRGenerateResponse{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRGenerateResponse
|
||||
if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil {
|
||||
return wecomQRGenerateResponse{}, err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRGenerateResponse{}, fmt.Errorf(
|
||||
"errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
|
||||
return wecomQRGenerateResponse{}, fmt.Errorf("response missing scode or auth_url")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func queryWecomQRCodeStatus(ctx context.Context, scode string) (wecomQRQueryResponse, error) {
|
||||
targetURL, err := buildWecomQRQueryURL(wecomQRQueryEndpoint, scode)
|
||||
if err != nil {
|
||||
return wecomQRQueryResponse{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRQueryResponse
|
||||
if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil {
|
||||
return wecomQRQueryResponse{}, err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf(
|
||||
"errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildWecomQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("plat", strconv.Itoa(platformCode))
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWecomQRQueryURL(baseURL, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func doWecomJSONGet(ctx context.Context, targetURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: wecomQRHTTPTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode JSON response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomPlatformCode() int {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return 1
|
||||
case "windows":
|
||||
return 2
|
||||
case "linux":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func newWecomFlowID() string {
|
||||
buf := make([]byte, 12)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("wc_%d", time.Now().UnixNano())
|
||||
}
|
||||
return "wc_" + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func (h *Handler) storeWecomFlow(flow *wecomFlow) {
|
||||
h.wecomMu.Lock()
|
||||
defer h.wecomMu.Unlock()
|
||||
h.gcWecomFlowsLocked(time.Now())
|
||||
h.wecomFlows[flow.ID] = flow
|
||||
}
|
||||
|
||||
func (h *Handler) getWecomFlow(flowID string) (*wecomFlow, bool) {
|
||||
h.wecomMu.Lock()
|
||||
defer h.wecomMu.Unlock()
|
||||
h.gcWecomFlowsLocked(time.Now())
|
||||
flow, ok := h.wecomFlows[flowID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cp := *flow
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
func (h *Handler) updateWecomFlowStatus(flowID, status string) {
|
||||
h.wecomMu.Lock()
|
||||
defer h.wecomMu.Unlock()
|
||||
if flow, ok := h.wecomFlows[flowID]; ok {
|
||||
flow.Status = status
|
||||
flow.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) setWecomFlowConfirmed(flowID, botID string) {
|
||||
h.wecomMu.Lock()
|
||||
defer h.wecomMu.Unlock()
|
||||
if flow, ok := h.wecomFlows[flowID]; ok {
|
||||
flow.Status = wecomStatusConfirmed
|
||||
flow.BotID = botID
|
||||
flow.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) setWecomFlowError(flowID, errMsg string) {
|
||||
h.wecomMu.Lock()
|
||||
defer h.wecomMu.Unlock()
|
||||
if flow, ok := h.wecomFlows[flowID]; ok {
|
||||
flow.Status = wecomStatusError
|
||||
flow.Error = errMsg
|
||||
flow.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) gcWecomFlowsLocked(now time.Time) {
|
||||
for id, flow := range h.wecomFlows {
|
||||
if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned {
|
||||
if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
|
||||
flow.Status = wecomStatusExpired
|
||||
flow.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
if flow.Status != wecomStatusWait &&
|
||||
flow.Status != wecomStatusScanned &&
|
||||
now.Sub(flow.UpdatedAt) > wecomFlowGCAge {
|
||||
delete(h.wecomFlows, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,14 @@ export interface WeixinFlowResponse {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WecomFlowResponse {
|
||||
flow_id: string
|
||||
status: "wait" | "scaned" | "confirmed" | "expired" | "error"
|
||||
qr_data_uri?: string
|
||||
bot_id?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function startWeixinFlow(): Promise<WeixinFlowResponse> {
|
||||
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
|
||||
}
|
||||
@@ -84,4 +92,16 @@ export async function pollWeixinFlow(
|
||||
)
|
||||
}
|
||||
|
||||
export async function startWecomFlow(): Promise<WecomFlowResponse> {
|
||||
return request<WecomFlowResponse>("/api/wecom/flows", { method: "POST" })
|
||||
}
|
||||
|
||||
export async function pollWecomFlow(
|
||||
flowID: string,
|
||||
): Promise<WecomFlowResponse> {
|
||||
return request<WecomFlowResponse>(
|
||||
`/api/wecom/flows/${encodeURIComponent(flowID)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
import { WecomForm } from "@/components/channels/channel-forms/wecom-form"
|
||||
import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -186,7 +187,7 @@ function getRequiredFieldKeys(channelName: string): string[] {
|
||||
case "onebot":
|
||||
return ["ws_url"]
|
||||
case "wecom":
|
||||
return ["bot_id", "secret"]
|
||||
return []
|
||||
case "whatsapp":
|
||||
return ["bridge_url"]
|
||||
case "pico":
|
||||
@@ -326,6 +327,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
return getChannelDisplayName(channel, t)
|
||||
}, [channel, channelName, t])
|
||||
|
||||
const hidesPageLevelEnableToggle = channel?.name === "wecom"
|
||||
|
||||
const hiddenKeys = useMemo(() => {
|
||||
if (!channel) return []
|
||||
if (channel.name === "whatsapp") {
|
||||
@@ -410,6 +413,36 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
}
|
||||
}, [loadData, t])
|
||||
|
||||
const handleWecomBindSuccess = useCallback(async () => {
|
||||
try {
|
||||
setEnabled(true)
|
||||
await Promise.all([loadData(true), refreshGatewayState({ force: true })])
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||
setServerError(message)
|
||||
await loadData(true)
|
||||
}
|
||||
}, [loadData, t])
|
||||
|
||||
const handleWecomEnabledChange = useCallback(
|
||||
async (nextEnabled: boolean) => {
|
||||
try {
|
||||
setEnabled(nextEnabled)
|
||||
await Promise.all([
|
||||
loadData(true),
|
||||
refreshGatewayState({ force: true }),
|
||||
])
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||
setServerError(message)
|
||||
await loadData(true)
|
||||
}
|
||||
},
|
||||
[loadData, t],
|
||||
)
|
||||
|
||||
const renderForm = () => {
|
||||
if (!channel) return null
|
||||
const isEdit = configured
|
||||
@@ -460,6 +493,27 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
onBindSuccess={() => void handleWeixinBindSuccess()}
|
||||
/>
|
||||
)
|
||||
case "wecom":
|
||||
return (
|
||||
<>
|
||||
<WecomForm
|
||||
config={editConfig}
|
||||
isEdit={isEdit}
|
||||
onBindSuccess={() => void handleWecomBindSuccess()}
|
||||
onEnabledChange={(nextEnabled) =>
|
||||
void handleWecomEnabledChange(nextEnabled)
|
||||
}
|
||||
/>
|
||||
<GenericForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericForm
|
||||
@@ -524,12 +578,33 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
{channel?.name === "weixin" && (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<IconAlertTriangle
|
||||
size={18}
|
||||
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{t("channels.weixin.warningTitle")}
|
||||
</p>
|
||||
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
|
||||
{t("channels.weixin.warningDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hidesPageLevelEnableToggle && (
|
||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderForm()}
|
||||
|
||||
|
||||
@@ -123,7 +123,9 @@ export function GenericForm({
|
||||
bot_id: t("channels.form.desc.appId"),
|
||||
websocket_url: t("channels.form.desc.wsUrl"),
|
||||
dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }),
|
||||
group_policy: t("channels.form.desc.genericField", { field: "group policy" }),
|
||||
group_policy: t("channels.form.desc.genericField", {
|
||||
field: "group policy",
|
||||
}),
|
||||
group_allow_from: t("channels.form.desc.allowFrom"),
|
||||
send_thinking_message: t("channels.form.desc.genericField", {
|
||||
field: "thinking message behavior",
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
IconQrcode,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
} from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
type BindingState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "waiting"
|
||||
| "scaned"
|
||||
| "confirmed"
|
||||
| "expired"
|
||||
| "error"
|
||||
|
||||
interface WecomFormProps {
|
||||
config: ChannelConfig
|
||||
isEdit: boolean
|
||||
onBindSuccess?: () => void
|
||||
onEnabledChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
export function WecomForm({
|
||||
config,
|
||||
isEdit,
|
||||
onBindSuccess,
|
||||
onEnabledChange,
|
||||
}: WecomFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [bindState, setBindState] = useState<BindingState>("idle")
|
||||
const [qrDataURI, setQrDataURI] = useState<string | null>(null)
|
||||
const [botID, setBotID] = useState<string | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState("")
|
||||
const [enabled, setEnabled] = useState(config.enabled === true)
|
||||
const [toggleSaving, setToggleSaving] = useState(false)
|
||||
const [toggleError, setToggleError] = useState("")
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollGenerationRef = useRef(0)
|
||||
const existingBotID = asString(config.bot_id)
|
||||
const isBound = isEdit && existingBotID !== ""
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollGenerationRef.current += 1
|
||||
if (pollTimerRef.current !== null) {
|
||||
clearInterval(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling])
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(config.enabled === true)
|
||||
}, [config.enabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!existingBotID) return
|
||||
stopPolling()
|
||||
setBotID(existingBotID)
|
||||
setBindState("confirmed")
|
||||
setErrorMsg("")
|
||||
}, [existingBotID, stopPolling])
|
||||
|
||||
const startPolling = useCallback(
|
||||
(id: string) => {
|
||||
stopPolling()
|
||||
const generation = pollGenerationRef.current
|
||||
let inFlight = false
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const resp = await pollWecomFlow(id)
|
||||
if (generation !== pollGenerationRef.current) {
|
||||
return
|
||||
}
|
||||
if (resp.status === "scaned") {
|
||||
setBindState("scaned")
|
||||
} else if (resp.status === "confirmed") {
|
||||
stopPolling()
|
||||
setBotID(resp.bot_id ?? existingBotID ?? null)
|
||||
setBindState("confirmed")
|
||||
onBindSuccess?.()
|
||||
} else if (resp.status === "expired") {
|
||||
stopPolling()
|
||||
setBindState("expired")
|
||||
} else if (resp.status === "error") {
|
||||
stopPolling()
|
||||
setBindState("error")
|
||||
setErrorMsg(resp.error ?? t("channels.wecom.errorGeneric"))
|
||||
}
|
||||
} catch {
|
||||
// transient network error — keep polling
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}, 2000)
|
||||
},
|
||||
[existingBotID, onBindSuccess, stopPolling, t],
|
||||
)
|
||||
|
||||
const handleEnabledChange = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!existingBotID || toggleSaving) {
|
||||
return
|
||||
}
|
||||
setToggleSaving(true)
|
||||
setToggleError("")
|
||||
try {
|
||||
await patchAppConfig({
|
||||
channels: {
|
||||
wecom: {
|
||||
enabled: checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
setEnabled(checked)
|
||||
onEnabledChange?.(checked)
|
||||
} catch (e) {
|
||||
setToggleError(
|
||||
e instanceof Error ? e.message : t("channels.wecom.errorGeneric"),
|
||||
)
|
||||
} finally {
|
||||
setToggleSaving(false)
|
||||
}
|
||||
},
|
||||
[existingBotID, onEnabledChange, t, toggleSaving],
|
||||
)
|
||||
|
||||
const handleBind = async () => {
|
||||
setBindState("loading")
|
||||
setErrorMsg("")
|
||||
setToggleError("")
|
||||
setQrDataURI(null)
|
||||
stopPolling()
|
||||
try {
|
||||
const resp = await startWecomFlow()
|
||||
setQrDataURI(resp.qr_data_uri ?? null)
|
||||
setBindState("waiting")
|
||||
startPolling(resp.flow_id)
|
||||
} catch (e) {
|
||||
setBindState("error")
|
||||
setErrorMsg(
|
||||
e instanceof Error ? e.message : t("channels.wecom.errorGeneric"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRebind = () => {
|
||||
stopPolling()
|
||||
setBindState("idle")
|
||||
setQrDataURI(null)
|
||||
setBotID(null)
|
||||
setErrorMsg("")
|
||||
void handleBind()
|
||||
}
|
||||
|
||||
const renderBindSection = () => {
|
||||
if (bindState === "idle") {
|
||||
if (isBound) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex items-center gap-2 rounded-full bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<IconCheck size={16} />
|
||||
{t("channels.wecom.bound")}
|
||||
</div>
|
||||
{existingBotID && (
|
||||
<p className="text-muted-foreground font-mono text-xs">
|
||||
{existingBotID}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRebind}
|
||||
className="mt-1 gap-2"
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
{t("channels.wecom.rebind")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("channels.wecom.notBound")}
|
||||
</p>
|
||||
<Button onClick={handleBind} className="gap-2">
|
||||
<IconQrcode size={16} />
|
||||
{t("channels.wecom.bind")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bindState === "loading") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<IconLoader2
|
||||
className="text-muted-foreground animate-spin"
|
||||
size={32}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("channels.wecom.generating")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bindState === "waiting" || bindState === "scaned") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
{qrDataURI ? (
|
||||
<img
|
||||
src={qrDataURI}
|
||||
alt="WeCom QR Code"
|
||||
className="border-border/60 h-48 w-48 rounded-xl border bg-white p-2 shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="border-border/60 bg-muted flex h-48 w-48 items-center justify-center rounded-xl border">
|
||||
<IconLoader2
|
||||
className="text-muted-foreground animate-spin"
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{bindState === "scaned" ? (
|
||||
<div className="flex items-center gap-2 rounded-full bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
<IconLoader2 size={14} className="animate-spin" />
|
||||
{t("channels.wecom.scanned")}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("channels.wecom.scanHint")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRebind}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<IconRefresh size={14} className="mr-1" />
|
||||
{t("channels.wecom.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bindState === "confirmed") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<IconCheck
|
||||
size={28}
|
||||
className="text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("channels.wecom.bound")}
|
||||
</p>
|
||||
{botID && (
|
||||
<p className="text-muted-foreground font-mono text-xs">{botID}</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRebind}
|
||||
className="mt-1 gap-2"
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
{t("channels.wecom.rebind")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bindState === "expired") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<IconX size={28} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{t("channels.wecom.expired")}
|
||||
</p>
|
||||
<Button onClick={handleRebind} className="gap-2">
|
||||
<IconRefresh size={14} />
|
||||
{t("channels.wecom.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bindState === "error") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="bg-destructive/10 flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<IconX size={28} className="text-destructive" />
|
||||
</div>
|
||||
<p className="text-destructive text-sm">
|
||||
{errorMsg || t("channels.wecom.errorGeneric")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={handleRebind} className="gap-2">
|
||||
<IconRefresh size={14} />
|
||||
{t("channels.wecom.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{isBound
|
||||
? t("channels.wecom.enableDesc")
|
||||
: t("channels.wecom.enableBindFirst")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!isBound || toggleSaving}
|
||||
onCheckedChange={(checked) => void handleEnabledChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
{toggleError && (
|
||||
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
||||
<div className="border-border/60 border-b px-4 py-3">
|
||||
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{t("channels.wecom.bindDesc")}
|
||||
</p>
|
||||
</div>
|
||||
{renderBindSection()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface AddForm {
|
||||
modelName: string
|
||||
@@ -103,7 +103,8 @@ export function AddModelSheet({
|
||||
}
|
||||
|
||||
const setField =
|
||||
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
(key: keyof AddForm) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
if (fieldErrors[key]) {
|
||||
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface EditForm {
|
||||
apiKey: string
|
||||
@@ -92,7 +92,8 @@ export function EditModelSheet({
|
||||
}, [model])
|
||||
|
||||
const setField =
|
||||
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(key: keyof EditForm) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
@@ -28,7 +28,8 @@ export function ModelCard({
|
||||
}: ModelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const isOAuth = model.auth_method === "oauth"
|
||||
const canSetDefault = model.configured && !model.is_default && !model.is_virtual
|
||||
const canSetDefault =
|
||||
model.configured && !model.is_default && !model.is_virtual
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -247,7 +247,6 @@
|
||||
"weixin": {
|
||||
"warningTitle": "Testing phase, use with caution",
|
||||
"warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
|
||||
"bindEnableSuccess": "WeChat connected and the channel has been enabled automatically.",
|
||||
"bindTitle": "WeChat Account Binding",
|
||||
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
||||
"bind": "Bind WeChat",
|
||||
@@ -262,6 +261,23 @@
|
||||
"refresh": "Refresh QR",
|
||||
"errorGeneric": "An error occurred. Please try again."
|
||||
},
|
||||
"wecom": {
|
||||
"bindTitle": "WeCom Binding",
|
||||
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
|
||||
"enableDesc": "Once bound, you can enable or disable the channel here.",
|
||||
"enableBindFirst": "Bind the bot first, then enable the channel.",
|
||||
"bind": "Bind WeCom",
|
||||
"rebind": "Re-bind",
|
||||
"bound": "WeCom Bound",
|
||||
"notBound": "WeCom AI Bot not bound yet.",
|
||||
"generating": "Generating QR code...",
|
||||
"scanHint": "Open WeCom and scan the QR code",
|
||||
"scanned": "Scanned, please confirm in WeCom",
|
||||
"expired": "QR code expired",
|
||||
"retry": "Try Again",
|
||||
"refresh": "Refresh QR",
|
||||
"errorGeneric": "An error occurred. Please try again."
|
||||
},
|
||||
"field": {
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "Enter bot token",
|
||||
|
||||
@@ -247,7 +247,6 @@
|
||||
"weixin": {
|
||||
"warningTitle": "测试阶段,请谨慎使用",
|
||||
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
||||
"bindEnableSuccess": "微信已连接,频道已自动启用。",
|
||||
"bindTitle": "微信账号绑定",
|
||||
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||
"bind": "绑定微信",
|
||||
@@ -262,6 +261,23 @@
|
||||
"refresh": "刷新二维码",
|
||||
"errorGeneric": "发生错误,请重试。"
|
||||
},
|
||||
"wecom": {
|
||||
"bindTitle": "企业微信绑定",
|
||||
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
|
||||
"enableDesc": "绑定后可在这里直接启用或停用频道。",
|
||||
"enableBindFirst": "请先完成绑定,然后再启用频道。",
|
||||
"bind": "绑定企业微信",
|
||||
"rebind": "重新绑定",
|
||||
"bound": "企业微信已绑定",
|
||||
"notBound": "尚未绑定企业微信 AI Bot。",
|
||||
"generating": "正在生成二维码...",
|
||||
"scanHint": "打开企业微信,扫描二维码",
|
||||
"scanned": "已扫码,请在企业微信中确认",
|
||||
"expired": "二维码已过期",
|
||||
"retry": "重试",
|
||||
"refresh": "刷新二维码",
|
||||
"errorGeneric": "发生错误,请重试。"
|
||||
},
|
||||
"field": {
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "输入 Bot Token",
|
||||
|
||||
Reference in New Issue
Block a user