diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index 330fb65..479eb62 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -231,9 +231,11 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog. if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode weixin-ilink config: %w", err) } - if strings.TrimSpace(cfg.BotToken) == "" { - return nil, fmt.Errorf("%s: missing bot token", platform) - } + // A weixin-ilink account may carry an empty bot_token: it is a pending + // scan-login account the panel drives via the login-qr/login-status admin + // endpoints (design/08 §4). The bridge's configured() gate skips it until + // the scan confirms and the token is persisted, so an empty token here is + // legitimate — build the adapter (disconnected) rather than dropping it. w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink")) if err != nil { return nil, fmt.Errorf("build weixin-ilink: %w", err) diff --git a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx index edc99ff..9a4d5af 100644 --- a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx +++ b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx @@ -7,6 +7,7 @@ import { PLATFORM_LABEL } from "../channelFields"; import { ChannelLogList } from "./channelLogList"; import { ChannelIdentityTest } from "./channelIdentityTest"; import { KookPanel } from "./KookPanel"; +import { WeixinLogin } from "./WeixinLogin"; import { GenericAdapterPanel } from "./GenericAdapterPanel"; import { platformOf } from "./channelUtils"; import { useChannelActions } from "./useChannelActions"; @@ -36,7 +37,9 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack subtitle={ platform === "kook" ? "该配置专属页 · 连接状态 + KOOK 平台能力(好友/频道/私信/测试)+ 实时日志" - : "该配置专属页 · 实时日志 + 功能操作 + 平台能力壳(专属功能后续接入)" + : platform === "weixin-ilink" + ? "该配置专属页 · 连接状态 + 微信 iLink 扫码登录 + 实时日志" + : "该配置专属页 · 实时日志 + 功能操作 + 平台能力壳(专属功能后续接入)" } actions={ + {status === "waiting" ? ( + + ) : null} + + )} + + ); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index f98c038..3ba4ebe 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -38,6 +38,8 @@ import type { RagIndexRequest, Role, SessionMeta, + WeixinLoginQRResponse, + WeixinLoginStatusResponse, SessionRecord, } from "./types"; import { getClientId } from "./client"; @@ -640,6 +642,28 @@ export function adminChannelMessageTest( body: JSON.stringify({ targetId, content }), }); } +/** GET /api/v1/admin/channels/{platform}/login-qr — trigger/mint a weixin-iLink + * scan-login QR code (owner only, audited). */ +export function adminWeixinLoginQR( + platform: string, + accountId?: string, +): Promise { + const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; + return request( + `/admin/channels/${encodeURIComponent(platform)}/login-qr${q}`, + ); +} +/** GET /api/v1/admin/channels/{platform}/login-status — poll a weixin-iLink scan + * login. On confirmed the backend persists the token and hot-connects. */ +export function adminWeixinLoginStatus( + platform: string, + accountId?: string, +): Promise { + const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; + return request( + `/admin/channels/${encodeURIComponent(platform)}/login-status${q}`, + ); +} /** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */ export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise { return request("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) }); diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 808e361..3f2f8ff 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -289,6 +289,25 @@ export interface AdminChannelAccount { capabilities?: string[]; } +/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr). + * qrUrl is the QR image URL; qrImage is a base64 data URL the panel can embed + * directly. status is one of idle|waiting|confirmed|failed. The qrcode scan + * identifier is never exposed. */ +export interface WeixinLoginQRResponse { + qrUrl?: string; + qrImage?: string; + status: string; +} + +/** The weixin-iLink scan-login status payload (GET /admin/channels/{platform}/login-status). + * botToken is always masked (never a raw secret). */ +export interface WeixinLoginStatusResponse { + status: string; + botId?: string; + botToken?: string; + err?: string; +} + /** One message in the platform (IM channel) send/receive log. Mirrors the * historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */ export interface AdminChannelMessage { diff --git a/internal/config/runtimeconfig/channels.go b/internal/config/runtimeconfig/channels.go index 3e41693..dc2b3e9 100644 --- a/internal/config/runtimeconfig/channels.go +++ b/internal/config/runtimeconfig/channels.go @@ -217,9 +217,16 @@ func maskToken(s string) string { // channels.json. Other platforms (qq-obv11, webhook) are left alone so their // channel list stays stable while the bridge's configured() gate decides whether // to dial. +// +// weixin-ilink is deliberately NOT token-required here: an empty-token account is +// a legitimate pending scan-login (design/08 §4). The panel saves a bare weixin +// account, drives get_bot_qrcode/get_qrcode_status via the admin login endpoints, +// and the bridge's configured() gate keeps it disconnected until the scan +// confirms and the token is persisted — so such an account must survive loading +// and writing, not be dropped as a placeholder. func tokenRequiringPlatforms(platform string) bool { switch platform { - case "kook", "telegram", "weixin-ilink": + case "kook", "telegram": return true default: return false diff --git a/internal/services/gateway/admin_channels_weixin.go b/internal/services/gateway/admin_channels_weixin.go new file mode 100644 index 0000000..124a132 --- /dev/null +++ b/internal/services/gateway/admin_channels_weixin.go @@ -0,0 +1,285 @@ +package gateway + +// This file adds the weixin-iLink scan-login admin endpoints (design/08 §4), +// owner only and audited. The panel triggers a QR bind (login-qr), then polls the +// bind status (login-status); on confirmation the handler persists the newly +// obtained bot_token / bot_id into channels.json and hot-reloads the platform so +// the bridge connects the now-configured adapter — no restart required. +// +// The adapter owns the token: it is never logged, and it is returned to the panel +// only masked (**** + trailing 4). Identity stays on the adapter's actor metadata, +// never a prompt. + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strings" + + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" +) + +// weixinLoginAdmin is the subset of the weixin-ilink adapter's scan-login surface +// the gateway panel endpoints invoke. It is implemented by *weixin_ilink.Adapter +// and reached by unwrapping the registration wrapper (see asWeixinLoginAdmin). +type weixinLoginAdmin interface { + StartLogin(ctx context.Context) (weixin_ilink.LoginQR, error) + LoginStatus(ctx context.Context) (status, detail string) + Config() map[string]any +} + +// weixinLoginQRResponse is the wire shape of GET /admin/channels/{platform}/login-qr. +type weixinLoginQRResponse struct { + QrUrl string `json:"qrUrl,omitempty"` + QrImage string `json:"qrImage,omitempty"` + Status string `json:"status"` +} + +// weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status. +type weixinLoginStatusResponse struct { + Status string `json:"status"` + BotID string `json:"botId,omitempty"` + BotToken string `json:"botToken,omitempty"` + Err string `json:"err,omitempty"` +} + +// adminWeixinLoginQR triggers (or returns the in-progress) QR code for a +// weixin-ilink account. It is the panel's "扫码登录" entry point; it never blocks +// on the user's scan — LoginStatus is polled separately. +func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) { + platform := r.PathValue("platform") + accountID := r.URL.Query().Get("accountId") + wx, found := s.weixinTarget(r, w, "channel.weixin.login.qr", platform, accountID) + if !found { + return + } + qr, err := wx.StartLogin(r.Context()) + if err != nil { + if errors.Is(err, weixin_ilink.ErrAlreadyLoggedIn) { + s.auditClaims(r, "channel.weixin.login.qr", "ok") + writeJSON(w, http.StatusOK, weixinLoginQRResponse{Status: weixin_ilink.LoginStatusConfirmed}) + return + } + s.auditClaims(r, "channel.weixin.login.qr", "error") + s.log.Error("weixin login qr", "platform", platform, "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "weixin_login_failed", "获取登录二维码失败:%s", err.Error())) + return + } + status, _ := wx.LoginStatus(r.Context()) + s.auditClaims(r, "channel.weixin.login.qr", "ok") + writeJSON(w, http.StatusOK, weixinLoginQRResponse{ + QrUrl: qr.Image, + QrImage: qrImageDataURL(r.Context(), qr.Image), + Status: status, + }) +} + +// adminWeixinLoginStatus reports the scan-login state. On confirmation it also +// persists the freshly obtained token to channels.json and hot-reloads the +// platform, so the bridge connects the now-configured adapter. +func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) { + platform := r.PathValue("platform") + accountID := r.URL.Query().Get("accountId") + wx, found := s.weixinTarget(r, w, "channel.weixin.login.status", platform, accountID) + if !found { + return + } + status, detail := wx.LoginStatus(r.Context()) + resp := weixinLoginStatusResponse{Status: status} + if status == weixin_ilink.LoginStatusConfirmed { + cfg := wx.Config() + token, _ := cfg["bot_token"].(string) + if token != "" { + botID, _ := cfg["bot_id"].(string) + ownerID, _ := cfg["owner_id"].(string) + resp.BotID = botID + resp.BotToken = maskToken(token) + if err := s.persistWeixinConfirmed(platform, accountID, token, botID, ownerID); err != nil { + s.log.Error("weixin login persist", "platform", platform, "err", err) + } else { + s.auditClaims(r, "channel.weixin.login.persist", "ok") + } + } + } else if status == weixin_ilink.LoginStatusFailed { + resp.Err = detail + } + s.auditClaims(r, "channel.weixin.login.status", "ok") + writeJSON(w, http.StatusOK, resp) +} + +// persistWeixinConfirmed writes the login-obtained credentials into the account's +// channels.json config (merging onto the user's curated base_url/cdn fields and +// preserving its allow/deny/limit lists) and hot-reloads the platform so the +// bridge connects the now-configured adapter. +func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, ownerID string) error { + if s.channels == nil { + return fmt.Errorf("weixin login: channel config store not injected") + } + accounts := s.channels.Load() + var base config.ChannelAccount + for _, acct := range accounts[platform] { + if accountID == "" || acct.ID == accountID { + base = acct + break + } + } + if base.ID == "" && accountID != "" { + base.ID = accountID + } + cfg := make(map[string]any, len(base.Config)+3) + for k, v := range base.Config { + cfg[k] = v + } + cfg["bot_token"] = token + if botID != "" { + cfg["bot_id"] = botID + } + if ownerID != "" { + cfg["owner_id"] = ownerID + } + acct := config.ChannelAccount{ + ID: base.ID, + Config: cfg, + AllowGroups: base.AllowGroups, + DenyGroups: base.DenyGroups, + AllowUsers: base.AllowUsers, + DenyUsers: base.DenyUsers, + GroupMsgPerMin: base.GroupMsgPerMin, + MsgPerMin: base.MsgPerMin, + MaxMessages: base.MaxMessages, + } + if acct.ID == "" { + acct.ID = runtimeconfig.NewID() + } + updated := runtimeconfig.UpsertChannel(accounts, platform, acct) + if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil { + return err + } + return s.channels.Reload(platform) +} + +// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the +// platform/account. It is nil for an unknown account or a wrapped adapter that +// does not expose the login surface. +func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) { + if caps == nil { + return nil, false + } + for _, cap := range caps.List(capreg.KindChannel) { + ch, ok := cap.Value.(sdk.Channel) + if !ok { + continue + } + plat, acct := splitAdapterName(ch.Name()) + if plat != platform { + continue + } + if accountID != "" && acct != accountID { + continue + } + if w, ok := asWeixinLoginAdmin(cap.Value); ok { + return w, true + } + } + return nil, false +} + +// asWeixinLoginAdmin unwraps a registered channel value to its inner weixin-ilink +// adapter, mirroring asKookAdmin: the registration wraps the adapter in cmd/cyrene's +// namedAdapter (an unexported struct embedding adapter.PlatformAdapter); reflection +// reads the embedded field and type-asserts to the login surface. A direct +// *weixin_ilink.Adapter value (tests) is also accepted. +func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) { + if w, ok := v.(weixinLoginAdmin); ok { + return w, true + } + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return nil, false + } + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return nil, false + } + f := rv.FieldByName("PlatformAdapter") + if !f.IsValid() || !f.CanInterface() { + return nil, false + } + if w, ok := f.Interface().(weixinLoginAdmin); ok { + return w, true + } + return nil, false +} + +// weixinTarget resolves the weixin-ilink adapter for a platform/account, writing +// an honest error and reporting found=false when it cannot. +func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (weixinLoginAdmin, bool) { + platform = strings.TrimSpace(platform) + if channelBasePlatform(platform) != "weixin-ilink" { + s.auditClaims(r, what, "error") + writeError(w, &apperr.AppError{Code: "not_implemented", Message: "仅微信 iLink 平台提供扫码登录端点", HTTPCode: http.StatusNotImplemented}) + return nil, false + } + wx, found := weixinAccountFor(s.caps, "weixin-ilink", accountID) + if !found { + s.auditClaims(r, what, "error") + writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", + "weixin-ilink 账号 %q 未找到(请先在「渠道配置」保存一个 weixin-ilink 账号)", accountID)) + return nil, false + } + return wx, true +} + +// maskToken redacts a secret to "****" + the trailing 4 characters, matching the +// project's maskToken so a token never lands verbatim in a wire response. +func maskToken(s string) string { + if len(s) <= 4 { + return "****" + } + return "****" + s[len(s)-4:] +} + +// qrImageDataURL turns a QR image's raw content into an embeddable data URL. When +// the content is an http(s) URL it fetches and base64-encodes it (best effort); on +// any failure it returns "" so the caller keeps qrUrl. A content already carrying a +// data: prefix is passed through; anything else is treated as raw base64. +func qrImageDataURL(ctx context.Context, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "data:") { + return raw + } + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "" + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil || len(body) == 0 { + return "" + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(body) + } + return "data:image/png;base64," + raw +} diff --git a/internal/services/gateway/admin_channels_weixin_test.go b/internal/services/gateway/admin_channels_weixin_test.go new file mode 100644 index 0000000..727d4c5 --- /dev/null +++ b/internal/services/gateway/admin_channels_weixin_test.go @@ -0,0 +1,329 @@ +package gateway + +// Exercises the weixin-iLink scan-login admin endpoints (design/08 §4). The +// adapter is registered through a wrapper that embeds adapter.PlatformAdapter — +// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection +// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter. + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" +) + +// gatewayWeixinWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter +// does, so the gateway's reflection-based unwrap (asWeixinLoginAdmin) is exercised. +type gatewayWeixinWrap struct { + adapter.PlatformAdapter + name string +} + +func (w *gatewayWeixinWrap) Name() string { return w.name } + +// fakeWeixinChannels is a minimal ChannelConfigStore for the persist-on-confirm path. +type fakeWeixinChannels struct { + path string + data map[string][]config.ChannelAccount + reloaded []string +} + +func (f *fakeWeixinChannels) Path() string { return f.path } +func (f *fakeWeixinChannels) Load() map[string][]config.ChannelAccount { + return f.data +} +func (f *fakeWeixinChannels) Reload(platform string) error { + f.reloaded = append(f.reloaded, platform) + return nil +} + +// weixinLoginServer answers the scan-login surface: a fixed QR and an immediate +// confirmed status (so the background poll confirms within a poll interval). +func weixinLoginServer(t *testing.T) *httptest.Server { + t.Helper() + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/ilink/bot/get_bot_qrcode": + _ = json.NewEncoder(w).Encode(map[string]any{ + "qrcode": "qr-x", "qrcode_img_content": "data:image/png;base64,QUJD", + }) + case "/ilink/bot/get_qrcode_status": + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "confirmed", "bot_token": "tok-x", + "ilink_bot_id": "bot-x", "ilink_user_id": "o-1", + }) + default: + http.NotFound(w, r) + } + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv +} + +// newWeixinGateway wires a registry whose weixin channel wraps a real adapter, and +// returns the httptest URL + an owner JWT. channels may be nil (no persist test). +func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (string, string) { + t.Helper() + wsrv := weixinLoginServer(t) + wa, err := weixin_ilink.New( + weixin_ilink.Config{ + BaseURL: wsrv.URL, + LoginInterval: time.Millisecond, + LoginTimeout: 5 * time.Second, + }, + wsrv.Client(), + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("weixin new: %v", err) + } + reg := registry.New() + if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: name}); err != nil { + t.Fatalf("register channel: %v", err) + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg, Channels: channels}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + return ts.URL, tok +} + +// newWeixinGatewayPresetToken builds a gateway whose weixin adapter already holds a +// token, so login endpoints report confirmed without any network. +func newWeixinGatewayPresetToken(t *testing.T, name string) (string, string) { + t.Helper() + wa, err := weixin_ilink.New( + weixin_ilink.Config{BaseURL: "https://example.invalid", BotToken: "tok-pre", BotID: "bot-pre"}, + nil, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("weixin new: %v", err) + } + reg := registry.New() + if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: name}); err != nil { + t.Fatalf("register channel: %v", err) + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + return ts.URL, tok +} + +// weixinLoginWaitingServer answers the QR but keeps the status "wait" (never +// confirms), so the panel polls a login that is still in progress. +func weixinLoginWaitingServer(t *testing.T) *httptest.Server { + t.Helper() + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/ilink/bot/get_bot_qrcode": + _ = json.NewEncoder(w).Encode(map[string]any{ + "qrcode": "qr-w", "qrcode_img_content": "data:image/png;base64,V0FJVElORw==", + }) + case "/ilink/bot/get_qrcode_status": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "wait"}) + default: + http.NotFound(w, r) + } + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv +} + +// newWeixinGatewayWaiting wires a weixin adapter whose status poll never confirms. +func newWeixinGatewayWaiting(t *testing.T, name string) (string, string) { + t.Helper() + wsrv := weixinLoginWaitingServer(t) + wa, err := weixin_ilink.New( + weixin_ilink.Config{BaseURL: wsrv.URL, LoginInterval: 50 * time.Millisecond, LoginTimeout: 30 * time.Second}, + wsrv.Client(), + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("weixin new: %v", err) + } + reg := registry.New() + if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: name}); err != nil { + t.Fatalf("register channel: %v", err) + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + return ts.URL, tok +} + +func TestAdminWeixinLoginStatus_Waiting(t *testing.T) { + t.Parallel() + base, tok := newWeixinGatewayWaiting(t, "weixin-ilink:conf0") + + if resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok); resp.StatusCode != http.StatusOK { + t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body) + } + resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) + } + var out struct { + Data struct{ Status string `json:"status"` } `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode login-status: %v", err) + } + if out.Data.Status != weixin_ilink.LoginStatusWaiting { + t.Errorf("login-status = %q, want waiting", out.Data.Status) + } +} + +func TestAdminWeixinLoginQR_ReturnsQRAndStatus(t *testing.T) { + t.Parallel() + base, tok := newWeixinGateway(t, "weixin-ilink:conf0", nil) + + resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body) + } + var out struct { + Data struct { + QrUrl string `json:"qrUrl"` + QrImage string `json:"qrImage"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode login-qr: %v", err) + } + if out.Data.QrUrl == "" { + t.Errorf("qrUrl empty, want the QR image content") + } + if !strings.HasPrefix(out.Data.QrImage, "data:image/png;base64,") { + t.Errorf("qrImage = %q, want a base64 data URL", out.Data.QrImage) + } + if out.Data.Status != weixin_ilink.LoginStatusWaiting && out.Data.Status != weixin_ilink.LoginStatusConfirmed { + t.Errorf("status = %q, want waiting|confirmed", out.Data.Status) + } +} + +func TestAdminWeixinLoginStatus_ConfirmPersists(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + chs := &fakeWeixinChannels{ + path: path, + data: map[string][]config.ChannelAccount{ + "weixin-ilink": {{ID: "conf0", Config: map[string]any{"base_url": "https://base"}}}, + }, + } + base, tok := newWeixinGateway(t, "weixin-ilink:conf0", chs) + + // Trigger the QR, then poll until the background login confirms and persists. + if resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok); resp.StatusCode != http.StatusOK { + t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body) + } + + var out struct { + Data struct { + Status string `json:"status"` + BotID string `json:"botId"` + BotToken string `json:"botToken"` + } `json:"data"` + } + deadline := time.Now().Add(3 * time.Second) + for { + resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) + } + out = struct { + Data struct { + Status string `json:"status"` + BotID string `json:"botId"` + BotToken string `json:"botToken"` + } `json:"data"` + }{} + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode login-status: %v", err) + } + if out.Data.Status == weixin_ilink.LoginStatusConfirmed { + break + } + if time.Now().After(deadline) { + t.Fatalf("login never confirmed, last status=%q body=%s", out.Data.Status, body) + } + time.Sleep(10 * time.Millisecond) + } + + if out.Data.BotID != "bot-x" { + t.Errorf("botId = %q, want bot-x", out.Data.BotID) + } + if out.Data.BotToken != "****ok-x" { + t.Errorf("botToken = %q, want masked ****ok-x", out.Data.BotToken) + } + if len(chs.reloaded) != 1 || chs.reloaded[0] != "weixin-ilink" { + t.Errorf("reloaded = %v, want [weixin-ilink]", chs.reloaded) + } + persisted, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read persisted channels.json: %v", err) + } + if !strings.Contains(string(persisted), "tok-x") || !strings.Contains(string(persisted), "bot-x") { + t.Errorf("channels.json did not persist token/bot_id:\n%s", persisted) + } +} + +func TestAdminWeixinLoginStatus_AlreadyConfirmed(t *testing.T) { + t.Parallel() + base, tok := newWeixinGatewayPresetToken(t, "weixin-ilink:conf0") + + resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body) + } + var qr struct { + Data struct{ Status string `json:"status"` } `json:"data"` + } + if err := json.Unmarshal(body, &qr); err != nil { + t.Fatalf("decode login-qr: %v", err) + } + if qr.Data.Status != weixin_ilink.LoginStatusConfirmed { + t.Errorf("login-qr status = %q, want confirmed (already logged in)", qr.Data.Status) + } + + resp, body = doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) + } + var out struct { + Data struct { + Status string `json:"status"` + BotID string `json:"botId"` + } `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode login-status: %v", err) + } + if out.Data.Status != weixin_ilink.LoginStatusConfirmed || out.Data.BotID != "bot-pre" { + t.Errorf("login-status = %+v, want confirmed + bot-pre", out.Data) + } +} + +func TestAdminWeixinLogin_NonWeixinNotImplemented(t *testing.T) { + t.Parallel() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/telegram/login-qr", nil, tok) + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body) + } +} diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 89a557e..26fb920 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -419,6 +419,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen))) mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose))) mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest))) + mux.Handle("GET /api/v1/admin/channels/{platform}/login-qr", ownerAuth(http.HandlerFunc(s.adminWeixinLoginQR))) + mux.Handle("GET /api/v1/admin/channels/{platform}/login-status", ownerAuth(http.HandlerFunc(s.adminWeixinLoginStatus))) mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView))) mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends))) mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle))) diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index 55b6739..bf387f8 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -160,6 +160,15 @@ type Adapter struct { contexts map[string]string lastMsgAt map[string]time.Time handler adapter.MessageHandler + + // loginMu serializes the scan-login session lifecycle (StartLogin/Connect), + // so only one QR bind runs at a time. loginSession / loginStatus / loginDetail + // carry the panel-observable login state; loginStatus/loginDetail are guarded + // by mu, loginSession by loginMu. + loginMu sync.Mutex + loginSession *loginSession + loginStatus string + loginDetail string } // New builds an Adapter. client may be nil to use http.DefaultClient; log may be @@ -179,12 +188,13 @@ func New(cfg Config, client *http.Client, log *slog.Logger) (*Adapter, error) { log = logger.WithSource(log, source) return &Adapter{ - cfg: cfg, - httpClient: client, - log: log, - clock: time.Now, - contexts: map[string]string{}, - lastMsgAt: map[string]time.Time{}, + cfg: cfg, + httpClient: client, + log: log, + clock: time.Now, + contexts: map[string]string{}, + lastMsgAt: map[string]time.Time{}, + loginStatus: LoginStatusIdle, }, nil } diff --git a/plugins/channels/weixin_ilink/login.go b/plugins/channels/weixin_ilink/login.go index 8395bac..7eee6d1 100644 --- a/plugins/channels/weixin_ilink/login.go +++ b/plugins/channels/weixin_ilink/login.go @@ -2,6 +2,7 @@ package weixin_ilink import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -30,61 +31,107 @@ type qrcodeStatusResp struct { BaseURL string `json:"baseurl"` } -// login runs the one-time scan-code binding (design/08 §4): request a QR code, -// then poll get_qrcode_status until the user confirms. On confirmation it -// persists bot_token, bot_id, owner_id and any returned baseurl, so later -// requests carry the Bearer token. +// login is the Connect-side scan-code binding (design/08 §4). It joins (or +// starts) a scan-login via StartLogin and blocks until the user confirms or the +// window elapses. Because it shares the session machinery with the panel's +// StartLogin, a panel-initiated login and Connect never run two QR binds at once. func (a *Adapter) login(ctx context.Context) error { - loginCtx, cancel := context.WithTimeout(ctx, a.cfg.LoginTimeout) - defer cancel() - - qr, err := a.fetchQRCode(loginCtx) + _, err := a.StartLogin(ctx) + if errors.Is(err, ErrAlreadyLoggedIn) { + return nil + } if err != nil { - if loginCtx.Err() != nil { - return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err()) - } - return fmt.Errorf("weixin_ilink: fetch qrcode: %w", err) + return err } - a.mu.Lock() - a.qrcode = qr.Qrcode - a.mu.Unlock() - // Log only the renderable URL, never the raw qrcode (it is a secret). - a.log.Info("scan the qr code to bind the bot", "url", qr.QrcodeImgContent) + a.loginMu.Lock() + sess := a.loginSession + a.loginMu.Unlock() + if sess == nil { + return fmt.Errorf("weixin_ilink: login session missing") + } + select { + case lerr := <-sess.done: + return lerr + case <-ctx.Done(): + sess.cancel() + return fmt.Errorf("%w: %w", ErrLoginTimeout, ctx.Err()) + } +} + +// runLogin polls get_qrcode_status until the QR is confirmed or the login window +// elapses, then records the outcome under the session. It runs in a goroutine so +// StartLogin returns immediately while the panel polls LoginStatus. +func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) { + timeoutCtx, cancel := context.WithTimeout(sessCtx, a.cfg.LoginTimeout) + defer cancel() ticker := time.NewTicker(a.cfg.LoginInterval) defer ticker.Stop() for { - status, err := a.pollQRStatus(loginCtx, qr.Qrcode) + status, err := a.pollQRStatus(timeoutCtx, sess.qrcode) if err != nil { - // A deadline/cancel during the poll means the overall window - // elapsed; surface ErrLoginTimeout rather than the raw transport - // error. - if loginCtx.Err() != nil { - return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err()) + // A deadline/cancel during the poll means the whole window elapsed; + // surface ErrLoginTimeout rather than the raw transport error. + if timeoutCtx.Err() != nil { + a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err())) + } else { + a.failLogin(sess, fmt.Errorf("weixin_ilink: poll qr status: %w", err)) } - return fmt.Errorf("weixin_ilink: poll qr status: %w", err) + return } if status.Status == "confirmed" && status.BotToken != "" { - a.mu.Lock() - a.cfg.BotToken = status.BotToken - a.cfg.BotID = status.BotID - a.cfg.OwnerID = status.UserID - if status.BaseURL != "" { - a.cfg.BaseURL = status.BaseURL - } - a.mu.Unlock() - return nil + a.completeLogin(sess, status) + return } select { - case <-loginCtx.Done(): - return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err()) + case <-timeoutCtx.Done(): + a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err())) + return case <-ticker.C: } } } +// completeLogin persists the confirmed credentials and marks the session live. +// It writes cfg under a.mu, then clears the session under loginMu (never nesting +// the two locks), and finally signals the waiting Connect path. +func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) { + a.mu.Lock() + a.cfg.BotToken = status.BotToken + a.cfg.BotID = status.BotID + a.cfg.OwnerID = status.UserID + if status.BaseURL != "" { + a.cfg.BaseURL = status.BaseURL + } + a.connected = true + a.loginStatus = LoginStatusConfirmed + a.loginDetail = "" + a.mu.Unlock() + + a.loginMu.Lock() + if a.loginSession == sess { + a.loginSession = nil + } + a.loginMu.Unlock() + + sess.done <- nil +} + +// failLogin records a failed scan-login and signals the waiting Connect path. +func (a *Adapter) failLogin(sess *loginSession, err error) { + a.setLoginState(LoginStatusFailed, err.Error()) + + a.loginMu.Lock() + if a.loginSession == sess { + a.loginSession = nil + } + a.loginMu.Unlock() + + sess.done <- err +} + // fetchQRCode requests a new bot login QR code. func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) { path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType)) diff --git a/plugins/channels/weixin_ilink/login_admin.go b/plugins/channels/weixin_ilink/login_admin.go new file mode 100644 index 0000000..50d9ced --- /dev/null +++ b/plugins/channels/weixin_ilink/login_admin.go @@ -0,0 +1,119 @@ +package weixin_ilink + +// This file exposes the non-blocking scan-login surface the admin panel drives +// (design/08 §4): StartLogin mints (or returns the in-progress) QR code without +// blocking, and LoginStatus reports where the bind stands so the panel can poll +// idle → waiting → confirmed. Connect (login.go) reuses StartLogin internally and +// then waits, so a panel-initiated login and the bridge's Connect never start two +// concurrent QR binds. + +import ( + "context" + "errors" + "fmt" +) + +// Login status values reported by Adapter.LoginStatus. +const ( + // LoginStatusIdle means no bot token and no scan-login in progress. + LoginStatusIdle = "idle" + // LoginStatusWaiting means a QR code is displayed and the user has not yet + // confirmed the bind. + LoginStatusWaiting = "waiting" + // LoginStatusConfirmed means a bot token is present (either a fresh scan or a + // pre-configured one). + LoginStatusConfirmed = "confirmed" + // LoginStatusFailed means the last scan/login attempt errored or timed out. + LoginStatusFailed = "failed" +) + +// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already +// configured, so the scan flow is moot. +var ErrAlreadyLoggedIn = errors.New("weixin_ilink: already logged in") + +// LoginQR is the artifact StartLogin returns for the panel to render: Qrcode is +// the raw scan identifier the adapter echoes to get_qrcode_status, and Image is +// the QR image content (currently the image URL returned by get_bot_qrcode). +type LoginQR struct { + Qrcode string + Image string +} + +// loginSession tracks one scan-login run. It is guarded by Adapter.loginMu. +type loginSession struct { + qrcode string + image string + // cancel stops the poll goroutine early (e.g. Connect's deadline elapsed). + cancel context.CancelFunc + // done carries the login outcome; the poll goroutine writes exactly once. + done chan error +} + +// StartLogin begins a scan-login and returns the QR code to display. It is +// idempotent: when a login is already in progress it returns the current QR +// without issuing a new get_bot_qrcode, and when a bot token is already present it +// returns ErrAlreadyLoggedIn. It never blocks on the user's scan — the status poll +// runs in a background goroutine that LoginStatus reports on. +func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) { + a.loginMu.Lock() + defer a.loginMu.Unlock() + + // Already bound → scanning is moot. + if a.hasToken() { + return LoginQR{}, ErrAlreadyLoggedIn + } + if sess := a.loginSession; sess != nil { + return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil + } + + raw, err := a.fetchQRCode(ctx) + if err != nil { + a.setLoginState(LoginStatusFailed, err.Error()) + return LoginQR{}, fmt.Errorf("weixin_ilink: fetch qrcode: %w", err) + } + + sessCtx, cancel := context.WithCancel(context.Background()) + sess := &loginSession{ + qrcode: raw.Qrcode, + image: raw.QrcodeImgContent, + cancel: cancel, + done: make(chan error, 1), + } + a.loginSession = sess + a.setLoginState(LoginStatusWaiting, "") + // Log only the renderable URL, never the raw qrcode (it is a secret). + a.log.Info("scan the qr code to bind the bot", "url", raw.QrcodeImgContent) + + go a.runLogin(sess, sessCtx) + return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil +} + +// LoginStatus reports the current scan-login state. A non-empty bot token +// overrides to "confirmed" even if a stale session is still recorded, so the panel +// always sees the true binding. detail provides the bot id on confirmation and the +// failure reason on failure. +func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) { + a.mu.RLock() + defer a.mu.RUnlock() + if a.cfg.BotToken != "" { + return LoginStatusConfirmed, a.cfg.BotID + } + return a.loginStatus, a.loginDetail +} + +// hasToken reports whether a bot token is configured. It reads cfg, so callers +// must not rely on it while holding a.mu. +func (a *Adapter) hasToken() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.cfg.BotToken != "" +} + +// setLoginState writes the observable login status under a.mu. It may be called +// with loginMu held (StartLogin) or from the poll goroutine; a.mu outlives both. +func (a *Adapter) setLoginState(status, detail string) { + a.mu.Lock() + a.loginStatus = status + a.loginDetail = detail + a.mu.Unlock() +} diff --git a/plugins/channels/weixin_ilink/login_admin_test.go b/plugins/channels/weixin_ilink/login_admin_test.go new file mode 100644 index 0000000..d01dd2b --- /dev/null +++ b/plugins/channels/weixin_ilink/login_admin_test.go @@ -0,0 +1,106 @@ +package weixin_ilink + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestStartLogin_WaitThenConfirm drives the panel flow: StartLogin returns the QR, +// the background poll confirms the bind, and LoginStatus transitions. +func TestStartLogin_WaitThenConfirm(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + srv := newJSONServer(t, rec, func(req recordedReq) handlerResult { + switch req.path { + case "/ilink/bot/get_bot_qrcode": + return handlerResult{body: jsonBody(map[string]any{ + "qrcode": "qr-1", "qrcode_img_content": "data:image/png;base64,QUJD", + })} + case "/ilink/bot/get_qrcode_status": + return handlerResult{body: jsonBody(map[string]any{ + "status": "confirmed", "bot_token": "tok-x", + "ilink_bot_id": "bot-x", "ilink_user_id": "o-1", + })} + } + return handlerResult{status: 404} + }) + + cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second} + ad := newAdapter(t, cfg, srv) + + qr, err := ad.StartLogin(context.Background()) + if err != nil { + t.Fatalf("StartLogin: %v", err) + } + if qr.Qrcode != "qr-1" || qr.Image != "data:image/png;base64,QUJD" { + t.Fatalf("StartLogin returned %+v, want qr-1 + image", qr) + } + if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting { + t.Fatalf("LoginStatus right after StartLogin = %q, want waiting", status) + } + + waitFor(t, func() bool { + s, _ := ad.LoginStatus(context.Background()) + return s == LoginStatusConfirmed + }) + if got := ad.Config()["bot_token"]; got != "tok-x" { + t.Fatalf("bot_token = %v, want tok-x", got) + } +} + +// TestStartLogin_IdempotentWhileWaiting verifies a second StartLogin while a login +// is in progress returns the same QR and never issues a second get_bot_qrcode. +func TestStartLogin_IdempotentWhileWaiting(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + srv := newJSONServer(t, rec, func(req recordedReq) handlerResult { + if req.path == "/ilink/bot/get_bot_qrcode" { + return handlerResult{body: jsonBody(map[string]any{ + "qrcode": "qr-2", "qrcode_img_content": "https://img/qr", + })} + } + return handlerResult{body: jsonBody(map[string]any{"status": "wait"})} + }) + cfg := Config{BaseURL: srv.URL, LoginInterval: time.Hour, LoginTimeout: 5 * time.Second} + ad := newAdapter(t, cfg, srv) + + first, err := ad.StartLogin(context.Background()) + if err != nil { + t.Fatalf("first StartLogin: %v", err) + } + second, err := ad.StartLogin(context.Background()) + if err != nil { + t.Fatalf("second StartLogin: %v", err) + } + if first.Qrcode != second.Qrcode { + t.Fatalf("second StartLogin qrcode = %q, want %q (idempotent)", second.Qrcode, first.Qrcode) + } + if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 1 { + t.Fatalf("get_bot_qrcode called %d times, want 1", got) + } +} + +// TestStartLogin_AlreadyLoggedIn verifies StartLogin refuses when a token exists and +// LoginStatus reports confirmed. +func TestStartLogin_AlreadyLoggedIn(t *testing.T) { + t.Parallel() + ad := newAdapter(t, Config{BaseURL: "https://example.invalid", BotToken: "tok-pre"}, nil) + _, err := ad.StartLogin(context.Background()) + if !errors.Is(err, ErrAlreadyLoggedIn) { + t.Fatalf("StartLogin err = %v, want ErrAlreadyLoggedIn", err) + } + if status, detail := ad.LoginStatus(context.Background()); status != LoginStatusConfirmed || detail != "" { + t.Fatalf("LoginStatus = (%q, %q), want confirmed/empty", status, detail) + } +} + +// TestLoginStatus_Idle verifies a fresh adapter reports idle. +func TestLoginStatus_Idle(t *testing.T) { + t.Parallel() + ad := newAdapter(t, Config{BaseURL: "https://example.invalid"}, nil) + if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusIdle { + t.Fatalf("LoginStatus = %q, want idle", status) + } +}