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. // qrUrl is the WeChat QR image URL (qrcode_img_content) and qrCode is the raw scan // identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the // liteapp URL is an HTML page that cannot be fetched as an image. type weixinLoginQRResponse struct { QrUrl string `json:"qrUrl,omitempty"` QrCode string `json:"qrCode,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, QrCode: qr.Qrcode, 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 } // A weixin adapter may be registered with the bare "weixin-ilink" name (single // account) rather than "weixin-ilink:", so first try a composite match, and // fall back to a bare weixin adapter when the caller names a specific account but // no composite name carries it (the panel passes the config account id). var bare weixinLoginAdmin var bareFound bool 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 } w, ok := asWeixinLoginAdmin(cap.Value) if !ok { continue } if accountID == "" || acct == accountID { return w, true } if acct == "" { bare, bareFound = w, true } } if bareFound { return bare, 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 }