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
+1142
-33
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 {
|
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
|
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.BotToken) == "" {
|
// A weixin-ilink account may carry an empty bot_token: it is a pending
|
||||||
return nil, fmt.Errorf("%s: missing bot token", platform)
|
// 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"))
|
w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build weixin-ilink: %w", err)
|
return nil, fmt.Errorf("build weixin-ilink: %w", err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PLATFORM_LABEL } from "../channelFields";
|
|||||||
import { ChannelLogList } from "./channelLogList";
|
import { ChannelLogList } from "./channelLogList";
|
||||||
import { ChannelIdentityTest } from "./channelIdentityTest";
|
import { ChannelIdentityTest } from "./channelIdentityTest";
|
||||||
import { KookPanel } from "./KookPanel";
|
import { KookPanel } from "./KookPanel";
|
||||||
|
import { WeixinLogin } from "./WeixinLogin";
|
||||||
import { GenericAdapterPanel } from "./GenericAdapterPanel";
|
import { GenericAdapterPanel } from "./GenericAdapterPanel";
|
||||||
import { platformOf } from "./channelUtils";
|
import { platformOf } from "./channelUtils";
|
||||||
import { useChannelActions } from "./useChannelActions";
|
import { useChannelActions } from "./useChannelActions";
|
||||||
@@ -36,6 +37,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
|
|||||||
subtitle={
|
subtitle={
|
||||||
platform === "kook"
|
platform === "kook"
|
||||||
? "该配置专属页 · 连接状态 + KOOK 平台能力(好友/频道/私信/测试)+ 实时日志"
|
? "该配置专属页 · 连接状态 + KOOK 平台能力(好友/频道/私信/测试)+ 实时日志"
|
||||||
|
: platform === "weixin-ilink"
|
||||||
|
? "该配置专属页 · 连接状态 + 微信 iLink 扫码登录 + 实时日志"
|
||||||
: "该配置专属页 · 实时日志 + 功能操作 + 平台能力壳(专属功能后续接入)"
|
: "该配置专属页 · 实时日志 + 功能操作 + 平台能力壳(专属功能后续接入)"
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
@@ -78,6 +81,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
|
|||||||
|
|
||||||
{platform === "kook" ? (
|
{platform === "kook" ? (
|
||||||
<KookPanel platform={platform} accountId={accountId} />
|
<KookPanel platform={platform} accountId={accountId} />
|
||||||
|
) : platform === "weixin-ilink" ? (
|
||||||
|
<WeixinLogin platform={platform} accountId={accountId} />
|
||||||
) : (
|
) : (
|
||||||
<GenericAdapterPanel platform={platform} accountId={accountId} record={record} />
|
<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,
|
RagIndexRequest,
|
||||||
Role,
|
Role,
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
|
WeixinLoginQRResponse,
|
||||||
|
WeixinLoginStatusResponse,
|
||||||
SessionRecord,
|
SessionRecord,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getClientId } from "./client";
|
import { getClientId } from "./client";
|
||||||
@@ -640,6 +642,28 @@ export function adminChannelMessageTest(
|
|||||||
body: JSON.stringify({ targetId, content }),
|
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). */
|
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
|
||||||
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
||||||
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
||||||
|
|||||||
@@ -289,6 +289,25 @@ export interface AdminChannelAccount {
|
|||||||
capabilities?: string[];
|
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
|
/** One message in the platform (IM channel) send/receive log. Mirrors the
|
||||||
* historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */
|
* historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */
|
||||||
export interface AdminChannelMessage {
|
export interface AdminChannelMessage {
|
||||||
|
|||||||
@@ -217,9 +217,16 @@ func maskToken(s string) string {
|
|||||||
// channels.json. Other platforms (qq-obv11, webhook) are left alone so their
|
// channels.json. Other platforms (qq-obv11, webhook) are left alone so their
|
||||||
// channel list stays stable while the bridge's configured() gate decides whether
|
// channel list stays stable while the bridge's configured() gate decides whether
|
||||||
// to dial.
|
// 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 {
|
func tokenRequiringPlatforms(platform string) bool {
|
||||||
switch platform {
|
switch platform {
|
||||||
case "kook", "telegram", "weixin-ilink":
|
case "kook", "telegram":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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/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/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
||||||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
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}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView)))
|
||||||
mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends)))
|
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)))
|
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
|
contexts map[string]string
|
||||||
lastMsgAt map[string]time.Time
|
lastMsgAt map[string]time.Time
|
||||||
handler adapter.MessageHandler
|
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
|
// New builds an Adapter. client may be nil to use http.DefaultClient; log may be
|
||||||
@@ -185,6 +194,7 @@ func New(cfg Config, client *http.Client, log *slog.Logger) (*Adapter, error) {
|
|||||||
clock: time.Now,
|
clock: time.Now,
|
||||||
contexts: map[string]string{},
|
contexts: map[string]string{},
|
||||||
lastMsgAt: map[string]time.Time{},
|
lastMsgAt: map[string]time.Time{},
|
||||||
|
loginStatus: LoginStatusIdle,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package weixin_ilink
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -30,43 +31,73 @@ type qrcodeStatusResp struct {
|
|||||||
BaseURL string `json:"baseurl"`
|
BaseURL string `json:"baseurl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// login runs the one-time scan-code binding (design/08 §4): request a QR code,
|
// login is the Connect-side scan-code binding (design/08 §4). It joins (or
|
||||||
// then poll get_qrcode_status until the user confirms. On confirmation it
|
// starts) a scan-login via StartLogin and blocks until the user confirms or the
|
||||||
// persists bot_token, bot_id, owner_id and any returned baseurl, so later
|
// window elapses. Because it shares the session machinery with the panel's
|
||||||
// requests carry the Bearer token.
|
// StartLogin, a panel-initiated login and Connect never run two QR binds at once.
|
||||||
func (a *Adapter) login(ctx context.Context) error {
|
func (a *Adapter) login(ctx context.Context) error {
|
||||||
loginCtx, cancel := context.WithTimeout(ctx, a.cfg.LoginTimeout)
|
_, err := a.StartLogin(ctx)
|
||||||
defer cancel()
|
if errors.Is(err, ErrAlreadyLoggedIn) {
|
||||||
|
return nil
|
||||||
qr, err := a.fetchQRCode(loginCtx)
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if loginCtx.Err() != nil {
|
return err
|
||||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
|
||||||
}
|
|
||||||
return fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
a.loginMu.Lock()
|
||||||
a.qrcode = qr.Qrcode
|
sess := a.loginSession
|
||||||
a.mu.Unlock()
|
a.loginMu.Unlock()
|
||||||
// Log only the renderable URL, never the raw qrcode (it is a secret).
|
if sess == nil {
|
||||||
a.log.Info("scan the qr code to bind the bot", "url", qr.QrcodeImgContent)
|
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)
|
ticker := time.NewTicker(a.cfg.LoginInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
status, err := a.pollQRStatus(loginCtx, qr.Qrcode)
|
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A deadline/cancel during the poll means the overall window
|
// A deadline/cancel during the poll means the whole window elapsed;
|
||||||
// elapsed; surface ErrLoginTimeout rather than the raw transport
|
// surface ErrLoginTimeout rather than the raw transport error.
|
||||||
// error.
|
if timeoutCtx.Err() != nil {
|
||||||
if loginCtx.Err() != nil {
|
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
|
||||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.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 != "" {
|
if status.Status == "confirmed" && status.BotToken != "" {
|
||||||
|
a.completeLogin(sess, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
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.mu.Lock()
|
||||||
a.cfg.BotToken = status.BotToken
|
a.cfg.BotToken = status.BotToken
|
||||||
a.cfg.BotID = status.BotID
|
a.cfg.BotID = status.BotID
|
||||||
@@ -74,15 +105,31 @@ func (a *Adapter) login(ctx context.Context) error {
|
|||||||
if status.BaseURL != "" {
|
if status.BaseURL != "" {
|
||||||
a.cfg.BaseURL = status.BaseURL
|
a.cfg.BaseURL = status.BaseURL
|
||||||
}
|
}
|
||||||
|
a.connected = true
|
||||||
|
a.loginStatus = LoginStatusConfirmed
|
||||||
|
a.loginDetail = ""
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return nil
|
|
||||||
|
a.loginMu.Lock()
|
||||||
|
if a.loginSession == sess {
|
||||||
|
a.loginSession = nil
|
||||||
}
|
}
|
||||||
select {
|
a.loginMu.Unlock()
|
||||||
case <-loginCtx.Done():
|
|
||||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
sess.done <- nil
|
||||||
case <-ticker.C:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// fetchQRCode requests a new bot login QR code.
|
||||||
|
|||||||
@@ -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