根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
206 lines
9.1 KiB
TypeScript
206 lines
9.1 KiB
TypeScript
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}`,
|
||
);
|
||
}
|