Files
Cyrene_v2/frontend/web/src/lib/api/adminChannels.ts
T
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

206 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AdminChannelMessage,
ChannelTestResult,
KookFriendList,
KookMessageTestResult,
KookViewResponse,
WeixinLoginQRResponse,
WeixinLoginStatusResponse,
} from "../types";
import { request } from "./core";
/** Result of saving/deleting a channel account config (persisted to channels.json). */
export interface ChannelConfigSaveResult {
persisted: boolean;
path: string;
platform: string;
accountId: string;
/** 仅 `?empty=1` 的删除会带上:本次实际删掉的无 id 条目数。 */
removed?: number;
empty?: boolean;
}
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
* the request's `name` alone never tells a multi-account platform which account
* was touched.
*
* `connected` 说的是**现在通不通**,不是「这次请求成没成功」:ws+client 的首连失败
* 刻意非致命(OneBot 实现可能比 Cyrene 后启动),因此 `Connect` 返回 nil 并不等于连上了。
* `pending`/`ready`(后端可选,契约 v0.68)进一步说明「没连上是哪种没连上」:
*
* - `pending: true` → 已在后台重试、当前未连上 ⇒ 提示须是「正在连接(reason)」,
* **绝不是**「已连接」,也不是一句静态的「未连接」;
* - `ready: true` → 已就绪、等待对端接入(server 模式)⇒ 同样不是失败;
* - `reason` → 未连上的原因(已脱敏)。
*
* 三者缺省 = 该适配器没有这条状态面 ⇒ 只看 `connected`。 */
export interface ChannelActionResult {
name: string;
resolved?: string;
accountId?: string;
connected: boolean;
pending?: boolean;
ready?: boolean;
reason?: string;
}
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
* 仍互通);给出时才是多账号定位,后端据此**只**驱动该账号。 */
export function channelActionBody(name: string, accountId?: string): { name: string; accountId?: string } {
const id = accountId?.trim();
return id ? { name, accountId: id } : { name };
}
/** POST /api/v1/admin/channels/open — connects a registered channel adapter.
* Pass `accountId` on a multi-account platform so the panel connects the account
* the user actually clicked instead of whatever the backend picks first. */
export function adminChannelOpen(name: string, accountId?: string): Promise<ChannelActionResult> {
return request<ChannelActionResult>("/admin/channels/open", {
method: "POST",
body: JSON.stringify(channelActionBody(name, accountId)),
});
}
/** POST /api/v1/admin/channels/close — disconnects a registered channel adapter. */
export function adminChannelClose(name: string, accountId?: string): Promise<ChannelActionResult> {
return request<ChannelActionResult>("/admin/channels/close", {
method: "POST",
body: JSON.stringify(channelActionBody(name, accountId)),
});
}
/** POST /api/v1/admin/channels/test — structural identity test (actor × whitelist). */
export function adminChannelTest(req: {
platform: string;
accountId: string;
isGroup: boolean;
}): Promise<ChannelTestResult> {
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
}
/** GET /api/v1/admin/channels/messages?adapter=<composite> — one adapter config's
* (platform:account) send/receive message log, newest first. */
export function adminChannelMessages(adapter: string, limit?: number): Promise<AdminChannelMessage[]> {
const q = new URLSearchParams({ adapter });
if (limit && limit > 0) q.set("limit", String(limit));
return request<AdminChannelMessage[]>(`/admin/channels/messages?${q.toString()}`);
}
/** GET /api/v1/admin/channels/messages — adapter overview log across all adapters. */
export function adminChannelLogOverview(limit?: number): Promise<AdminChannelMessage[]> {
const q = limit && limit > 0 ? `?limit=${limit}` : "";
return request<AdminChannelMessage[]>(`/admin/channels/messages${q}`);
}
/** PUT /api/v1/admin/channels/{platform}/config — save an account config + hot-reload. */
export function adminSaveChannelConfig(
platform: string,
req: {
id?: string;
token?: string;
baseURL?: string;
remoteURL?: string;
listenAddr?: string;
mode?: string;
accessToken?: string;
selfID?: string;
config?: Record<string, unknown>;
allowGroups?: string[];
denyGroups?: string[];
allowUsers?: string[];
denyUsers?: string[];
groupMsgPerMin?: number;
msgPerMin?: number;
maxMessages?: number;
/** 非主人(陌生人)私聊每分钟每发送者上限(裁决 13)。缺省 / 0 = 保护性默认
* 6 条/分、负值 = 关闭该限制、正值 = 该值(口径同 groupMsgPerMin)。 */
directMsgPerMin?: number;
/** Role bound to this channel account (design/22 §4.4). Omit to leave the
* stored binding untouched; `''` clears it (「跟随默认角色」). It only affects
* the account's non-isolated sessions — 群聊/陌生人始终用默认角色. */
personaId?: string;
},
): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config`, {
method: "PUT",
body: JSON.stringify(req),
});
}
/** DELETE /api/v1/admin/channels/{platform}/config?id=… — delete an account + hot-reload. */
export function adminDeleteChannelAccount(platform: string, id: string): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** DELETE /api/v1/admin/channels/{platform}/config?empty=1 — 删除该平台**无 id 的历史条目**
* (面板既改不动也删不掉它们:带空 id 保存会被后端当成新账号另开一条)。
* 这个 `empty=1` 是**显式**的:绝不能靠「id 为空」来表达同一意图,那样一次误发的
* 空请求就会静默批量删除。 */
export function adminDeleteEmptyChannelAccounts(platform: string): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config?empty=1`, {
method: "DELETE",
});
}
/** GET /api/v1/admin/channels/{platform}/view — one platform account's aggregated
* panel data (friends / servers / channels / members / private chats), owner only. */
export function adminChannelPlatformView(platform: string, accountId?: string): Promise<KookViewResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<KookViewResponse>(`/admin/channels/${encodeURIComponent(platform)}/view${q}`);
}
/** GET /api/v1/admin/channels/{platform}/friends?type= — friend/request/blocked list. */
export function adminChannelPlatformFriends(
platform: string,
type: string,
accountId?: string,
): Promise<KookFriendList> {
const q = new URLSearchParams();
if (type) q.set("type", type);
if (accountId) q.set("accountId", accountId);
const qs = q.toString();
return request<KookFriendList>(`/admin/channels/${encodeURIComponent(platform)}/friends${qs ? `?${qs}` : ""}`);
}
/** POST /api/v1/admin/channels/{platform}/friend/handle — accept/reject a friend request. */
export function adminChannelFriendHandle(
platform: string,
id: number,
accept: boolean,
accountId?: string,
): Promise<{ handled: boolean; id: number; accept: boolean }> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<{ handled: boolean; id: number; accept: boolean }>(
`/admin/channels/${encodeURIComponent(platform)}/friend/handle${q}`,
{ method: "POST", body: JSON.stringify({ id, accept }) },
);
}
/** POST /api/v1/admin/channels/{platform}/message/test — send one test message. */
export function adminChannelMessageTest(
platform: string,
targetId: string,
content: string,
accountId?: string,
): Promise<KookMessageTestResult> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<KookMessageTestResult>(`/admin/channels/${encodeURIComponent(platform)}/message/test${q}`, {
method: "POST",
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}`,
);
}