feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: 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:
1 parent
0a597cfa42
commit
e037fab7d0
13 files changed
+1155
-46
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user