mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
667fc85d54
add new field type to Channel struct config.channels refactor to channel_list update config version to 3 update the docs
433 lines
11 KiB
Go
433 lines
11 KiB
Go
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)
|
|
}
|
|
|
|
bc := cfg.Channels.Get(config.ChannelWeCom)
|
|
if bc == nil {
|
|
bc = &config.Channel{Type: config.ChannelWeCom}
|
|
cfg.Channels["wecom"] = bc
|
|
}
|
|
bc.Enabled = true
|
|
|
|
var wecomCfg config.WeComSettings
|
|
bc.Decode(&wecomCfg)
|
|
wecomCfg.BotID = botID
|
|
wecomCfg.Secret = *config.NewSecureString(secret)
|
|
if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
|
|
wecomCfg.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)
|
|
}
|
|
}
|
|
}
|