feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
       login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
       (锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
       channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
       token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
       AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
       tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
       仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
This commit is contained in:
Aska committed 2026-09-07 20:50:28 +08:00
1 parent 0a597cfa42
commit e037fab7d0
13 files changed
+1155 -46

No files matched your search

+5 -3
View File
@@ -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)
@@ -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={
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onBack}>
@@ -78,6 +81,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
{platform === "kook" ? (
<KookPanel platform={platform} accountId={accountId} />
) : platform === "weixin-ilink" ? (
<WeixinLogin platform={platform} accountId={accountId} />
) : (
<GenericAdapterPanel platform={platform} accountId={accountId} record={record} />
)}
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from "react";
import { adminWeixinLoginQR, adminWeixinLoginStatus } from "../../../lib/api";
import { useAuditStore } from "../../audit";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
/** weixin-iLink scan-login panel (design/08 §4). On an account without a bot_token
* it shows a 扫码登录 button; clicking it mints a QR via GET /login-qr, renders the
* QR image, and polls GET /login-status until confirmation. On confirmation the
* backend persists the token and hot-connects the adapter, so the panel just
* flags "已登录". An account that already has a token never shows the button. */
export function WeixinLogin({
platform,
accountId,
}: {
platform: string;
accountId?: string;
}): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [status, setStatus] = useState<string>("idle");
const [qrImage, setQrImage] = useState<string>("");
const [qrUrl, setQrUrl] = useState<string>("");
const [botId, setBotId] = useState<string>("");
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
const applyStatus = (r: { status: string; botId?: string; err?: string }) => {
setStatus(r.status);
if (r.status === "confirmed") {
setBotId(r.botId ?? "");
stopPolling();
setNotice("已登录(微信 iLink),适配器已热连接。");
} else if (r.status === "failed") {
stopPolling();
setNotice(r.err ?? "扫码登录失败,请重试。");
}
};
const fetchStatus = () => {
adminWeixinLoginStatus(platform, accountId)
.then(applyStatus)
.catch((e: unknown) => {
log("channel.weixin.login.status", "error");
setNotice(e instanceof Error ? e.message : "查询登录状态失败");
});
};
// Reflect the current bind state on mount (confirmed with no button, or idle
// ready to scan). A stale interval from a previous mount is cleaned up.
useEffect(() => {
adminWeixinLoginStatus(platform, accountId).then(applyStatus).catch(() => setStatus("idle"));
return stopPolling;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform, accountId]);
const start = () => {
setBusy("__qr__");
setNotice(null);
setQrImage("");
setQrUrl("");
adminWeixinLoginQR(platform, accountId)
.then((r) => {
log("channel.weixin.login.qr", "ok");
setQrImage(r.qrImage ?? "");
setQrUrl(r.qrUrl ?? "");
if (r.status === "confirmed") {
setStatus("confirmed");
stopPolling();
fetchStatus();
return;
}
setStatus("waiting");
// Poll until confirmed / failed / timeout.
const started = Date.now();
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(() => {
fetchStatus();
if (Date.now() - started > 8 * 60 * 1000) {
stopPolling();
setNotice("等待扫码超时,请重试。");
}
}, 1500);
})
.catch((e: unknown) => {
log("channel.weixin.login.qr", "error");
setNotice(e instanceof Error ? e.message : "获取二维码失败");
setStatus("idle");
})
.finally(() => setBusy(null));
};
const showQR = status === "waiting" || (qrImage !== "" || qrUrl !== "");
return (
<Surface gap="sm" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">微信 iLink 扫码登录</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">owner 专属 · 已审计</span>
{status === "confirmed" ? <Tag tone="ok">已登录</Tag> : null}
{status === "waiting" ? <Tag tone="warn">等待扫码</Tag> : null}
{botId ? <Tag tone="neutral">bot_id:{botId}</Tag> : null}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
GET /api/v1/admin/channels/{platform}/login-qr · /login-status
</span>
</div>
{notice ? (
<Notice tone={notice.includes("失败") || notice.includes("超时") || notice.includes("错误") ? "err" : "ok"}>
{notice}
</Notice>
) : null}
{status === "confirmed" && !showQR ? (
<Notice>该账号已绑定 bot_token,无需再次扫码。二维码 / token 均不在此回显。</Notice>
) : null}
{showQR ? (
<div className="space-y-2">
{qrImage || qrUrl ? (
<img
src={qrImage || qrUrl}
alt="微信 iLink 登录二维码"
className="h-48 w-48 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] object-contain p-2"
/>
) : (
<Notice tone="warn">二维码图片暂不可用,请点击「刷新二维码」重试。</Notice>
)}
<p className="text-xs text-[var(--mu-text-lo)]">请用微信扫描上方二维码,确认后自动连接(最多等待约 8 分钟)。</p>
</div>
) : null}
{status === "confirmed" ? null : (
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={start}>
{busy === "__qr__" ? "生成中…" : status === "waiting" ? "刷新二维码" : "扫码登录"}
</Button>
{status === "waiting" ? (
<Button variant="ghost" disabled={busy !== null} onClick={stopPolling}>
取消等待
</Button>
) : null}
</div>
)}
</Surface>
);
}
+24
View File
@@ -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<WeixinLoginQRResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<WeixinLoginQRResponse>(
`/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<WeixinLoginStatusResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<WeixinLoginStatusResponse>(
`/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<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
+19
View File
@@ -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 {
+8 -1
View File
@@ -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
@@ -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
}
@@ -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)
}
}
+2
View File
@@ -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)))
+16 -6
View File
@@ -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
}
+82 -35
View File
@@ -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))
@@ -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()
}
@@ -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)
}
}