feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

后端(KOOK REST, 复用 httpclient WithAuthScheme(Bot), token不打印):
- kook/admin.go(新): ListFriends/HandleFriendRequest/DeleteFriend/BlockFriend/
  ListUserChats/CreateUserChat/ListDirectMessages
- kook/admin_guild.go(新): ListGuilds/ListGuildChannels/ListGuildUsers/ListChannelMsgs/SendTestMessage
- gateway/admin_channels_platform.go(新): kookAdmin(反射解包namedAdapter命中内嵌PlatformAdapter)
  + 4 handler + adminKookView聚合(子项失败记errors不伪造)
- server.go 注册 4 条 ownerAuth 路由: /view /friends /friend/handle /message/test
- admin.go/admin_write_channel_config.go: AdminChannelAccount 加 Capabilities
前端(KOOK 专属页):
- KookPanel.tsx(新): 好友&申请/服务器/频道/成员/私信会话/发消息测试卡片
- AdapterDetailPage.tsx: platform==='kook' 渲染 KookPanel, 否则通用
- types.ts/api.ts: KOOK 类型 + adminChannelPlatformView/Friends/FriendHandle/MessageTest
文档: Docs/api-client.md 新增4端点契约(owner-only)+capabilities+v0.17
验收: go build/vet/test ./... 全绿 + npm run build 97模块全绿; 零新依赖
注: api.ts/types.ts 集中契约文件>500行属既有结构(未新增违规); KOOK其余REST方法留能力面待用
This commit is contained in:
Aska committed 2026-09-07 18:19:31 +08:00
1 parent 8eaf81bd0d
commit 5ec4c9814d
13 files changed
+1714 -2

No files matched your search

+7
View File
@@ -75,6 +75,12 @@
- `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。
- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`500`(凭据缺失等连接失败)。
- `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。
- **KOOK 适配器面板端点**(design/17 §2,**owner-only + 审计**,仅 `kook` 平台实现;其它平台回 `501 not_implemented`,账号未找到回 `404`):这些端点均带可选 `?accountId=` 定位到具体 channels.json 账号(不传=该平台首个账号)。**token/密钥不打印、不落日志**;子项拉取失败不伪造、记入 `errors`。
- `GET /admin/channels/{platform}/view?accountId=` → `200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}`。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);`errors` 为 `{子项:原因}` 记录失败的子项。
- `GET /admin/channels/{platform}/friends?type=&accountId=` → `200 {data:{friends?,requests?,blocked?}}`。`type` ∈ `friend|request|block`(空=全部),调用 KOOK `GET /friend?type=`。
- `POST /admin/channels/{platform}/friend/handle?accountId=` body `{id,accept}` → `200 {data:{handled:true,id,accept}}`。同意/拒绝好友申请(KOOK `POST /friend/handle-request`)。
- `POST /admin/channels/{platform}/message/test?accountId=` body `{targetId,content}` → `200 {data:{msg_id,msg_timestamp}}`。真实向 `targetId`(频道 id)发一条 kmarkdown 测试消息(KOOK `POST /message/create`),验证 bot 可达性。
- `GET /admin/channels` 每条账号附 **`capabilities`**(可选 `[]string`,如 kook 的 `["friends","user_chats","channels","guilds","message_test"]`),供面板按平台渲染专属功能卡片。
- **KOOK「token 即用、其余自动获取」**(适配器契约,非端点):填 KOOK 渠道时**只必填 `token`**(bot_token)。`base_url` 留空即用官方默认 `https://www.kookapp.cn/api/v3`;`receive_mode` 空默认 `ws`(WS 模式地址由 `GET /api/v3/gateway/index?compress=0` 自动获取,已接入);`webhook_path` 空默认 `/webhook/kook`。高级值(改用 webhook、`webhook_verify_token`、`webhook_encrypt_key`,仅 webhook 模式需要)经 PUT 的 `config` JSON 传入(前端走「额外 config」,如 `{"receive_mode":"webhook","webhook_verify_token":"..."}`)。**Connect 拿到 token 后自动调用 `GET /api/v3/user/me`**(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 `GET /admin/channels` 账号的 `botId`/`botUsername`/`botNickname` 显示;user/me 失败仅记日志、bot 身份为空,**不中断**连接/收发。token 只在 header / 写文件,**不打印、不进日志**。
- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ `UpsertChannel` 该平台账号 → 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。
- `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。
@@ -173,6 +179,7 @@
## 5. 变更记录
- `v0.17`(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 `POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}`(均 **owner-only** + 审计)。`revoke-keys` 走 `auth.APIKey.RevokeAll()`(清空 key 表,返回 `{revoked:n}`);`rotate-jwt` 走 `auth.JWTService.RotateSecret`(≥32 位,**新密钥不回显/不落日志**,现有 token 立即失效);`backup` 用 SQLite `VACUUM INTO` 快照到 `<dataDir>/backup-<ts>.db`(含已提交 WAL 内容)返回 `{path,size}`;`restore` 先校验路径在 dataDir 内(**`filepath.Rel` 防路径穿越**,逃逸回 `400`)→ **先自动备份当前 DB** → 替换 DB 文件 → 返回 `{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 `Deps.DatabasePath`(composition root 传入实际打开的 SQLite 路径)。`编辑环境变量 / 重启服务 / 检查更新` 仍为后置,未实现、不伪造。
- `v0.17`(KOOK 适配器面板 + 每适配器专属页,design/17 §2):`GET /api/v1/admin/channels/{platform}/view`、`GET /api/v1/admin/channels/{platform}/friends?type=`、`POST /api/v1/admin/channels/{platform}/friend/handle`、`POST /api/v1/admin/channels/{platform}/message/test`(**新增**,均 owner-only + 审计,带可选 `?accountId=`,仅 `kook` 实现,其它平台 `501`);`GET /api/v1/admin/channels` 每条账号附 `capabilities`(面板按平台渲染专属卡片)。KOOK 面板:好友/申请、服务器/频道/成员、私信会话、发消息测试——经适配器 REST(`Authorization: Bot <token>`)拉真实数据,token 不打印不落日志,子项失败记入 `errors` 不伪造。
- `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → `UpsertChannel` → 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。
- `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。
- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。
@@ -6,6 +6,7 @@ import { Button, Surface, Tag } from "../../../components/primitives";
import { PLATFORM_LABEL } from "../channelFields";
import { ChannelLogList } from "./channelLogList";
import { ChannelIdentityTest } from "./channelIdentityTest";
import { KookPanel } from "./KookPanel";
import { platformOf } from "./channelUtils";
import { useChannelActions } from "./useChannelActions";
@@ -31,7 +32,11 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
<div className="space-y-5">
<PageHeader
title={`${label} · ${adapter}`}
subtitle="该配置专属页 · 实时日志 + 功能操作(完整配置字段见 P2)"
subtitle={
platform === "kook"
? "该配置专属页 · 连接状态 + KOOK 平台能力(好友/频道/私信/测试)+ 实时日志"
: "该配置专属页 · 实时日志 + 功能操作(完整配置字段见 P2)"
}
actions={
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onBack}>
返回列表
@@ -69,6 +74,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
<ChannelIdentityTest defaultPlatform={platform} disabled={busy !== null} />
{platform === "kook" ? <KookPanel platform={platform} accountId={accountId} /> : null}
<div className="rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)] p-5">
<ChannelLogList adapter={adapter} limit={200} />
</div>
@@ -0,0 +1,280 @@
import { useState } from "react";
import {
adminChannelFriendHandle,
adminChannelMessageTest,
adminChannelPlatformView,
} from "../../../lib/api";
import type {
KookChannel,
KookFriendList,
KookGuild,
KookGuildUser,
KookUserChat,
} from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAuditStore } from "../../audit";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
/** KOOK per-adapter panel (design/17 §2): friends/requests, servers/channels/
* members, private chats, and a test-send card. It renders only the elements the
* platform's account actually supports and surfaces per-sub-fetch errors rather
* than fabricating data. Non-KOOK platforms fall back to the generic detail page. */
export function KookPanel({
platform,
accountId,
}: {
platform: string;
accountId?: string;
}): React.ReactNode {
const log = useAuditStore((s) => s.log);
const { data, loading, error, reload } = useAsyncData(
() => adminChannelPlatformView(platform, accountId),
[platform, accountId],
);
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const handleRequest = (id: number, accept: boolean) => {
setBusy(`req-${id}`);
setNotice(null);
adminChannelFriendHandle(platform, id, accept, accountId)
.then(() => {
log("channel.friend.handle", "ok");
setNotice(accept ? `已同意好友申请 #${id}` : `已拒绝好友申请 #${id}`);
reload();
})
.catch((e: unknown) => {
log("channel.friend.handle", "error");
setNotice(e instanceof Error ? e.message : "处理好友申请失败");
})
.finally(() => setBusy(null));
};
const sendTest = (target: string, content: string): Promise<string> => {
return adminChannelMessageTest(platform, target, content, accountId)
.then((r) => {
log("channel.message.test", "ok");
return `已发送,消息 id:${r.msg_id}`;
})
.catch((e: unknown) => {
log("channel.message.test", "error");
throw e;
});
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">KOOK 平台能力</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">owner 专属 · 已审计</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
GET /api/v1/admin/channels/{platform}/view
</span>
</div>
{error ? <Notice tone="err">{error}</Notice> : null}
{notice ? <Notice tone={notice.startsWith("已") || notice.includes("成功") ? "ok" : "err"}>{notice}</Notice> : null}
{loading ? <Notice>加载中…</Notice> : null}
{data ? (
<>
{Object.keys(data.errors ?? {}).length > 0 ? (
<Notice tone="warn">部分子项拉取失败:{Object.entries(data.errors ?? {}).map(([k, v]) => `${k}(${v})`).join(";")}</Notice>
) : null}
<KookFriendsCard friends={data.friends} busy={busy} onHandle={handleRequest} />
<KookGuildCard guilds={data.guilds} channels={data.channels} members={data.members} />
<KookChatsCard chats={data.userChats} />
<KookTestCard busy={busy !== null} onSend={sendTest} />
</>
) : null}
</div>
);
}
function KookFriendsCard({
friends,
busy,
onHandle,
}: {
friends?: KookFriendList;
busy: string | null;
onHandle: (id: number, accept: boolean) => void;
}): React.ReactNode {
const requests = friends?.requests ?? [];
const list = friends?.friends ?? [];
const blocked = friends?.blocked ?? [];
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">好友 & 申请</h3>
{requests.length === 0 && list.length === 0 ? (
<Notice>暂无好友/申请数据(未连接或账号无好友)。</Notice>
) : null}
{requests.length > 0 ? (
<div className="flex flex-wrap gap-2">
{requests.map((r) => (
<div key={r.id} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5">
<Tag tone="warn">申请</Tag>
<span className="text-sm">{r.friend.nickname || r.friend.username || r.friend.user_id}</span>
<span className="text-xs text-[var(--mu-text-lo)]">#{r.friend.user_id}</span>
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => onHandle(r.id, true)}>
同意
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => onHandle(r.id, false)}>
拒绝
</Button>
</div>
))}
</div>
) : null}
{list.length > 0 ? (
<>
<div className="text-xs text-[var(--mu-text-lo)]">好友列表({list.length})</div>
<div className="flex flex-wrap gap-2">
{list.slice(0, 40).map((f) => (
<span key={f.user_id} className="flex items-center gap-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${f.online ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-text-lo)]"}`} />
{f.nickname || f.username || f.user_id}
</span>
))}
</div>
</>
) : null}
{blocked.length > 0 ? <div className="text-xs text-[var(--mu-text-lo)]">已屏蔽 {blocked.length} 人</div> : null}
</Surface>
);
}
function KookGuildCard({
guilds,
channels,
members,
}: {
guilds?: KookGuild[];
channels?: KookChannel[];
members?: KookGuildUser[];
}): React.ReactNode {
const hasChannels = (channels ?? []).length > 0 || (members ?? []).length > 0;
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">服务器 / 频道 / 成员</h3>
{!guilds || guilds.length === 0 ? (
<Notice>暂无服务器数据(bot 未加入任何服务器或未连接)。</Notice>
) : (
<div className="flex flex-wrap gap-2">
{guilds.map((g) => (
<span key={g.id} className="flex items-center gap-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs">
<Tag tone="accent">{g.name || g.id}</Tag>
<span className="text-[var(--mu-text-lo)]">{g.id}</span>
</span>
))}
</div>
)}
{hasChannels ? (
<div className="flex flex-wrap items-center gap-2">
<div className="text-xs text-[var(--mu-text-lo)]">频道(首个服务器)</div>
<div className="overflow-x-auto whitespace-nowrap">
{(channels ?? []).map((c) => (
<span key={c.id} className="mr-2 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-3)] px-2 py-1 text-xs">
{c.is_category ? <Tag tone="warn">分组</Tag> : c.type === 2 ? <Tag tone="neutral">语音</Tag> : <Tag tone="neutral">文字</Tag>}
{c.name || c.id}
</span>
))}
</div>
</div>
) : null}
{members && members.length > 0 ? (
<>
<div className="text-xs text-[var(--mu-text-lo)]">成员(前 {members.length},首个服务器)</div>
<div className="flex flex-wrap gap-2">
{members.slice(0, 40).map((m) => (
<span key={m.user_id} className="flex items-center gap-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${m.online ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-text-lo)]"}`} />
{m.nickname || m.username || m.user_id}
{m.bot ? <Tag tone="neutral">bot</Tag> : null}
</span>
))}
</div>
</>
) : null}
</Surface>
);
}
function KookChatsCard({ chats }: { chats?: KookUserChat[] }): React.ReactNode {
const list = chats ?? [];
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">私信会话</h3>
{list.length === 0 ? (
<Notice>暂无私信会话。</Notice>
) : (
<div className="overflow-x-auto">
<div className="min-w-[320px] space-y-1.5">
{list.map((c) => (
<div key={c.code} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5">
<Tag tone="neutral">{c.target_name || c.target_id}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">code:{c.code}</span>
{c.unread_count > 0 ? <Tag tone="warn">{c.unread_count} 未读</Tag> : null}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">{new Date(c.latest_msg_time).toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-[var(--mu-text-lo)]">仅列出会话;消息内容与发送请通过 /direct-message 或消息链路。</p>
</Surface>
);
}
function KookTestCard({
busy,
onSend,
}: {
busy: boolean;
onSend: (target: string, content: string) => Promise<string>;
}): React.ReactNode {
const [target, setTarget] = useState("");
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [result, setResult] = useState<string | null>(null);
const send = () => {
if (!target.trim() || !content.trim()) {
setResult("频道 target id 与内容均不能为空");
return;
}
setSending(true);
setResult(null);
onSend(target.trim(), content.trim())
.then(setResult)
.catch((e: unknown) => {
setResult(e instanceof Error ? e.message : "发送测试消息失败");
})
.finally(() => setSending(false));
};
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">发消息测试(POST /message/create)</h3>
<div className="flex flex-wrap items-center gap-2">
<input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="目标频道 id"
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<input
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="测试内容(kmarkdown)"
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<Button variant="primary" disabled={busy || sending} onClick={send}>
{sending ? "发送中…" : "发送"}
</Button>
</div>
{result ? <Notice tone={result.includes("失败") || result.includes("不能为空") ? "err" : "ok"}>{result}</Notice> : null}
<p className="text-xs text-[var(--mu-text-lo)]">这条消息会真实发送到所选频道,用于验证 bot 可达性。</p>
</Surface>
);
}
+47
View File
@@ -31,6 +31,9 @@ import type {
Mode,
PersonaRecord,
PersonaUpsertRequest,
KookFriendList,
KookMessageTestResult,
KookViewResponse,
RagIndexRequest,
Role,
SessionMeta,
@@ -592,6 +595,50 @@ export function adminDeleteChannelAccount(platform: string, id: string): Promise
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 }),
});
}
/** 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) });
+98
View File
@@ -273,6 +273,9 @@ export interface AdminChannelAccount {
botId?: string;
botUsername?: string;
botNickname?: string;
/** Account-level admin operations the platform adapter exposes (e.g. kook's
* ["friends","user_chats","channels","guilds","message_test"]). */
capabilities?: string[];
}
/** One message in the platform (IM channel) send/receive log. Mirrors the
@@ -562,3 +565,98 @@ export interface RagIndexRequest {
desc?: string;
content: string;
}
/* --- KOOK per-adapter panel data (design/17 §2; GET /admin/channels/kook/view). --- */
/** One friend / blocked user in GET /admin/channels/{platform}/friends. */
export interface KookFriendEntry {
user_id: string;
username?: string;
nickname?: string;
avatar?: string;
online?: boolean;
}
/** One pending friend request (has a top-level request id). */
export interface KookFriendRequestEntry {
id: number;
friend: KookFriendEntry;
}
/** GET /admin/channels/{platform}/friends parsed data. */
export interface KookFriendList {
friends?: KookFriendEntry[];
requests?: KookFriendRequestEntry[];
blocked?: KookFriendEntry[];
}
/** One private-chat session (GET /admin/channels/kook/view userChats). */
export interface KookUserChat {
code: string;
last_read_time: number;
latest_msg_time: number;
unread_count: number;
target_id: string;
target_name?: string;
is_friend?: boolean;
is_blocked?: boolean;
}
/** One server in the guild list. */
export interface KookGuild {
id: string;
name: string;
icon?: string;
topic?: string;
}
/** One channel in a guild. */
export interface KookChannel {
id: string;
user_id?: string;
guild_id: string;
parent_id?: string;
name: string;
type: number;
level?: number;
is_category?: boolean;
}
/** One guild member for the aggregate view. */
export interface KookGuildUser {
user_id: string;
username?: string;
nickname?: string;
avatar?: string;
online?: boolean;
bot?: boolean;
}
/** One channel message (GET /admin/channels/kook/view, not currently fetched). */
export interface KookChannelMessage {
id: string;
type: number;
content: string;
author_id: string;
author_name?: string;
create_at: number;
}
/** POST /admin/channels/{platform}/message/test result. */
export interface KookMessageTestResult {
msg_id: string;
msg_timestamp: number;
}
/** GET /api/v1/admin/channels/{platform}/view — the KOOK aggregate panel payload.
* Sub-fetches that fail are recorded in errors rather than aborting the view. */
export interface KookViewResponse {
platform: string;
accountId?: string;
friends?: KookFriendList;
userChats?: KookUserChat[];
guilds?: KookGuild[];
channels?: KookChannel[];
members?: KookGuildUser[];
errors?: Record<string, string>;
}
+13 -1
View File
@@ -198,7 +198,9 @@ func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
if !configuredAccount(a) {
continue
}
list = append(list, adminChannelAccountOf(runtimeconfig.Masked(a)))
account := adminChannelAccountOf(runtimeconfig.Masked(a))
account.Capabilities = platformAdminOps(platform)
list = append(list, account)
}
if len(list) > 0 {
out[platform] = list
@@ -207,6 +209,16 @@ func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
return out
}
// platformAdminOps returns the account-level admin operations the platform's
// adapter exposes, so the panel renders only the capabilities the account really
// has. Platforms without a specialized adapter surface return nil (omitted).
func platformAdminOps(platform string) []string {
if channelBasePlatform(platform) == "kook" {
return []string{"friends", "user_chats", "channels", "guilds", "message_test"}
}
return nil
}
// hasChannelSummary reports whether out already contains a summary for platform
// (so a platform with a registered adapter is not duplicated).
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
@@ -0,0 +1,267 @@
package gateway
// This file adds the per-platform adapter panel endpoints (design/17 §2), owner
// only and audited. They expose a platform's real account data (friends,
// channels, guilds, private chats) via the adapter's REST surface and a single
// controlled "test send" write. Only KOOK is wired; every other platform answers
// an honest 501 rather than fabricated data (a project red line). The bot token
// is never read or logged — the adapter owns the token and surfaces only its
// normalized REST results.
import (
"context"
"encoding/json"
"net/http"
"reflect"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
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/kook"
)
// kookAdmin is the subset of the KOOK adapter's admin REST surface the gateway
// panel endpoints invoke. It is implemented by *kook.Adapter and reached by
// unwrapping the registration wrapper.
type kookAdmin interface {
ListFriends(ctx context.Context, typ string) (kook.FriendList, error)
HandleFriendRequest(ctx context.Context, id int64, accept bool) error
ListUserChats(ctx context.Context, page, pageSize int) ([]kook.UserChat, error)
CreateUserChat(ctx context.Context, targetID string) (kook.UserChat, error)
ListDirectMessages(ctx context.Context, chatCode, targetID string, page, pageSize int) ([]kook.DirectMessage, error)
ListGuilds(ctx context.Context, page, pageSize int) ([]kook.Guild, error)
ListGuildChannels(ctx context.Context, guildID string, typ int) ([]kook.Channel, error)
ListGuildUsers(ctx context.Context, guildID string, page, pageSize int) ([]kook.GuildUser, error)
ListChannelMsgs(ctx context.Context, targetID string, pageSize int) ([]kook.ChannelMessage, error)
SendTestMessage(ctx context.Context, targetID, content string) (kook.TestMessageResult, error)
}
// adminKookView is the wire shape of GET /api/v1/admin/channels/{platform}/view.
type adminKookView struct {
Platform string `json:"platform"`
AccountID string `json:"accountId,omitempty"`
Friends *kook.FriendList `json:"friends,omitempty"`
UserChats []kook.UserChat `json:"userChats,omitempty"`
Guilds []kook.Guild `json:"guilds,omitempty"`
Channels []kook.Channel `json:"channels,omitempty"`
Members []kook.GuildUser `json:"members,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
// kookAccountFor resolves the *kook.Adapter registered for a platform/account.
// It is nil for a non-kook platform, an unknown account, or a wrapped adapter
// that does not expose the admin surface.
func kookAccountFor(caps CapabilityStore, platform, accountID string) (kookAdmin, 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 k, ok := asKookAdmin(cap.Value); ok {
return k, true
}
}
return nil, false
}
// asKookAdmin unwraps a registered channel value to its inner KOOK adapter. The
// registration wraps the adapter in cmd/cyrene's namedAdapter (an unexported
// struct embedding adapter.PlatformAdapter); the embedded field is exported, so
// reflection reads it and type-asserts to the KOOK surface. A direct *kook.Adapter
// value (tests) is also accepted.
func asKookAdmin(v any) (kookAdmin, bool) {
if k, ok := v.(kookAdmin); ok {
return k, 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 k, ok := f.Interface().(kookAdmin); ok {
return k, true
}
return nil, false
}
// adminChannelPlatformView aggregates a KOOK account's friends, private chats and
// guilds (with the first guild's channels). Each sub-fetch is best-effort; a
// failing one is recorded in errors rather than aborting the whole view.
func (s *Server) adminChannelPlatformView(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.platform.view", platform, accountID)
if !found {
return
}
view := s.buildKookView(r.Context(), k, platform, accountID)
s.auditClaims(r, "channel.platform.view", "ok")
writeJSON(w, http.StatusOK, view)
}
func (s *Server) buildKookView(ctx context.Context, k kookAdmin, platform, accountID string) adminKookView {
view := adminKookView{Platform: platform, AccountID: accountID, Errors: map[string]string{}}
if fl, err := k.ListFriends(ctx, kook.FriendTypeAll); err != nil {
view.Errors["friends"] = err.Error()
} else {
view.Friends = &fl
}
if chats, err := k.ListUserChats(ctx, 1, 50); err != nil {
view.Errors["user_chats"] = err.Error()
} else {
view.UserChats = chats
}
if guilds, err := k.ListGuilds(ctx, 1, 50); err != nil {
view.Errors["guilds"] = err.Error()
} else {
view.Guilds = guilds
if len(guilds) > 0 {
// Aggregate the first guild's channels and members so the panel has a
// concrete server to render; deeper servers are a separate fetch.
if channels, err := k.ListGuildChannels(ctx, guilds[0].ID, -1); err != nil {
view.Errors["channels"] = err.Error()
} else {
view.Channels = channels
}
if members, err := k.ListGuildUsers(ctx, guilds[0].ID, 1, 50); err != nil {
view.Errors["members"] = err.Error()
} else {
view.Members = members
}
}
}
return view
}
// adminChannelPlatformFriends returns a KOOK account's friends/requests/blocked
// list, filtered by the optional ?type= and ?accountId= params.
func (s *Server) adminChannelPlatformFriends(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.friends", platform, accountID)
if !found {
return
}
fl, err := k.ListFriends(r.Context(), r.URL.Query().Get("type"))
if err != nil {
s.auditClaims(r, "channel.friends", "error")
s.log.Error("kook friends", "platform", platform, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "kook_friends_failed", "拉取好友列表失败:%s", err.Error()))
return
}
s.auditClaims(r, "channel.friends", "ok")
writeJSON(w, http.StatusOK, fl)
}
// kookFriendHandleRequest is the POST /admin/channels/{platform}/friend/handle body.
type kookFriendHandleRequest struct {
ID int64 `json:"id"`
Accept bool `json:"accept"`
}
// adminChannelFriendHandle accepts or rejects a KOOK friend request (owner write).
func (s *Server) adminChannelFriendHandle(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.friend.handle", platform, accountID)
if !found {
return
}
var req kookFriendHandleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.ID == 0 {
s.auditClaims(r, "channel.friend.handle", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if err := k.HandleFriendRequest(r.Context(), req.ID, req.Accept); err != nil {
s.auditClaims(r, "channel.friend.handle", "error")
s.log.Error("kook friend handle", "platform", platform, "id", req.ID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "kook_friend_handle_failed", "处理好友申请失败:%s", err.Error()))
return
}
s.auditClaims(r, "channel.friend.handle", "ok")
writeJSON(w, http.StatusOK, map[string]any{"handled": true, "id": req.ID, "accept": req.Accept})
}
// kookMessageTestRequest is the POST /admin/channels/{platform}/message/test body.
type kookMessageTestRequest struct {
TargetID string `json:"targetId"`
Content string `json:"content"`
}
// adminChannelMessageTest sends one test message to a KOOK target channel (owner
// write), so the panel can confirm the bot can reach the channel.
func (s *Server) adminChannelMessageTest(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.message.test", platform, accountID)
if !found {
return
}
var req kookMessageTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.TargetID) == "" {
s.auditClaims(r, "channel.message.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "targetId is required"))
return
}
if strings.TrimSpace(req.Content) == "" {
s.auditClaims(r, "channel.message.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
res, err := k.SendTestMessage(r.Context(), strings.TrimSpace(req.TargetID), strings.TrimSpace(req.Content))
if err != nil {
s.auditClaims(r, "channel.message.test", "error")
s.log.Error("kook message test", "platform", platform, "target", req.TargetID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "kook_message_test_failed", "发送测试消息失败:%s", err.Error()))
return
}
s.auditClaims(r, "channel.message.test", "ok")
writeJSON(w, http.StatusOK, res)
}
// kookTarget resolves the KOOK adapter for a platform/account, writing an honest
// error and reporting found=false when it cannot.
func (s *Server) kookTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (kookAdmin, bool) {
platform = strings.TrimSpace(platform)
if platform != "kook" {
s.auditClaims(r, what, "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "仅 KOOK 平台实现了适配器面板数据端点", HTTPCode: http.StatusNotImplemented})
return nil, false
}
k, found := kookAccountFor(s.caps, platform, accountID)
if !found {
s.auditClaims(r, what, "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "kook 账号 %q 未找到", accountID))
return nil, false
}
return k, true
}
@@ -0,0 +1,157 @@
package gateway
// Exercises the per-platform KOOK adapter panel endpoints (design/17 §2). 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 *kook.Adapter.
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"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/plugins/channels/kook"
)
func gatewayKookLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// gatewayKookWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter
// does, so the gateway's reflection-based unwrap (asKookAdmin) is exercised.
type gatewayKookWrap struct {
adapter.PlatformAdapter
name string
}
func (w *gatewayKookWrap) Name() string { return w.name }
// gatewayKookServer answers the KOOK REST surface a panel view touches.
func gatewayKookServer(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 "/friend":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"friend": []any{map[string]any{"id": 1, "friend_info": map[string]any{"id": "u1", "username": "alice"}}},
}})
case "/user-chat/list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"code": "c1", "target_info": map[string]any{"id": "u9", "username": "zed"}}},
}})
case "/guild/list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"id": "g1", "name": "server"}},
}})
case "/channel/list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"id": "c1", "guild_id": "g1", "name": "general", "type": 1}},
}})
case "/guild/user-list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"id": "u1", "username": "alice", "online": true}},
}})
case "/friend/handle-request":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{}})
case "/message/create":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"msg_id": "m1"}})
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}
// newKookGateway wires a registry whose kook channel wraps a real adapter pointed
// at an httptest KOOK server, and returns the owner JWT.
func newKookGateway(t *testing.T, name string) (string, string) {
t.Helper()
kookSrv := gatewayKookServer(t)
ka := kook.New(
kook.Config{BotToken: "testtoken"},
kook.WithBaseURL(kookSrv.URL),
kook.WithHTTPClient(kookSrv.Client()),
kook.WithLogger(gatewayKookLogger()),
)
reg := registry.New()
if err := reg.RegisterChannel(&gatewayKookWrap{PlatformAdapter: ka, 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 TestAdminChannelPlatformView_Kook(t *testing.T) {
t.Parallel()
base, tok := newKookGateway(t, "kook:conf0")
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/kook/view?accountId=conf0", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("view status = %d, body=%s", resp.StatusCode, body)
}
var out struct {
Data struct {
Platform string `json:"platform"`
Friends *struct {
Friends []struct{ UserID string `json:"user_id"` } `json:"friends"`
} `json:"friends"`
Guilds []struct{ ID string `json:"id"` } `json:"guilds"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode view: %v", err)
}
if out.Data.Platform != "kook" {
t.Errorf("platform = %q, want kook", out.Data.Platform)
}
if out.Data.Friends == nil || len(out.Data.Friends.Friends) != 1 || out.Data.Friends.Friends[0].UserID != "u1" {
t.Errorf("friends = %+v, want [u1]", out.Data.Friends)
}
if len(out.Data.Guilds) != 1 || out.Data.Guilds[0].ID != "g1" {
t.Errorf("guilds = %+v, want [g1]", out.Data.Guilds)
}
}
func TestAdminChannelPlatformView_NonKookNotImplemented(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/view", nil, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
}
}
func TestAdminChannelFriendHandle_Kook(t *testing.T) {
t.Parallel()
base, tok := newKookGateway(t, "kook:conf0")
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/kook/friend/handle?accountId=conf0",
map[string]any{"id": 77, "accept": true}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("handle status = %d, body=%s", resp.StatusCode, body)
}
var out struct {
Data struct {
Handled bool `json:"handled"`
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode handle: %v", err)
}
if !out.Data.Handled || out.Data.ID != 77 {
t.Errorf("handle = %+v, want handled id 77", out.Data)
}
}
@@ -42,6 +42,11 @@ type AdminChannelAccount struct {
BotID string `json:"botId,omitempty"`
BotUsername string `json:"botUsername,omitempty"`
BotNickname string `json:"botNickname,omitempty"`
// Capabilities lists the account-level admin operations the platform's
// adapter exposes (friends/channels/user-chats/message-test) so the panel
// renders the right per-account cards. It is omitted for platforms without a
// specialized adapter surface.
Capabilities []string `json:"capabilities,omitempty"`
}
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
+4
View File
@@ -418,6 +418,10 @@ 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}/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)))
mux.Handle("POST /api/v1/admin/channels/{platform}/message/test", ownerAuth(http.HandlerFunc(s.adminChannelMessageTest)))
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
+349
View File
@@ -0,0 +1,349 @@
package kook
// This file adds the KOOK admin-view REST surface (design/17 §2): read-only
// listing and a small set of controlled writes for the per-adapter panel. The
// methods reuse the adapter's httpclient (already authenticated with
// `Authorization: Bot <token>`), return normalized structures, and never print
// or log the bot token. Writes go through the audited owner-only gateway
// handlers; this file is deliberately transport-only.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// Public admin-REST endpoints (relative to the KOOK API root).
const (
friendPath = "/friend"
friendHandlePath = "/friend/handle-request"
friendDeletePath = "/friend/delete"
friendBlockPath = "/friend/block"
userChatListPath = "/user-chat/list"
userChatCreatePath = "/user-chat/create"
directMsgsPath = "/direct-message/list"
)
// FriendEntry is one friend / blocked user from GET /friend.
type FriendEntry struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Online bool `json:"online"`
}
// FriendRequestEntry is one pending friend request (GET /friend?type=request).
type FriendRequestEntry struct {
ID int64 `json:"id"`
Friend FriendEntry `json:"friend"`
}
// FriendList is the parsed GET /friend data payload.
type FriendList struct {
Friends []FriendEntry `json:"friends,omitempty"`
Requests []FriendRequestEntry `json:"requests,omitempty"`
Blocked []FriendEntry `json:"blocked,omitempty"`
}
// UserChat is one private-chat session (GET /user-chat/list).
type UserChat struct {
Code string `json:"code"`
LastRead int64 `json:"last_read_time"`
LatestMsg int64 `json:"latest_msg_time"`
Unread int `json:"unread_count"`
TargetID string `json:"target_id"`
TargetName string `json:"target_name"`
IsFriend bool `json:"is_friend,omitempty"`
IsBlocked bool `json:"is_blocked,omitempty"`
}
// DirectMessage is one private message (GET /direct-message/list).
type DirectMessage struct {
ID string `json:"id"`
Type int `json:"type"`
Content string `json:"content"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_name"`
CreateAt int64 `json:"create_at"`
}
// FriendType is the type filter for GET /friend. KOOK accepts "friend",
// "request" and "block"; an empty value returns all three. It is passed through
// verbatim; unknown values are surfaced by the API as an error rather than
// guessed (design/17: no fabricated data).
const (
FriendTypeAll = ""
FriendTypeFriend = "friend"
FriendTypeRequest = "request"
FriendTypeBlock = "block"
)
// ListFriends returns the bot's friend list, pending requests and blocked users
// for the given type filter. type may be "friend", "request", "block" or "".
func (a *Adapter) ListFriends(ctx context.Context, typ string) (FriendList, error) {
q := url.Values{}
if typ != "" {
q.Set("type", typ)
}
raw, err := a.http.GetJSON(ctx, friendPath, q)
if err != nil {
return FriendList{}, fmt.Errorf("kook: list friends: %w", err)
}
var data rawFriendList
if err := json.Unmarshal(raw, &data); err != nil {
return FriendList{}, fmt.Errorf("kook: decode friend list: %w", err)
}
return FriendList{
Friends: friendEntries(data.Friend),
Requests: friendRequestEntries(data.Request),
Blocked: friendEntries(firstRaw(data.Blocked, data.Block)),
}, nil
}
// HandleFriendRequest accepts (accept=true) or rejects (accept=false) a pending
// friend request by its id.
func (a *Adapter) HandleFriendRequest(ctx context.Context, id int64, accept bool) error {
_, err := a.http.DoJSON(ctx, "POST", friendHandlePath, map[string]any{
"id": id,
"accept": accept,
})
if err != nil {
return fmt.Errorf("kook: handle friend request: %w", err)
}
return nil
}
// DeleteFriend removes userID from the bot's friend list.
func (a *Adapter) DeleteFriend(ctx context.Context, userID string) error {
_, err := a.http.DoJSON(ctx, "POST", friendDeletePath, map[string]any{"user_id": userID})
if err != nil {
return fmt.Errorf("kook: delete friend: %w", err)
}
return nil
}
// BlockFriend blocks userID for the bot.
func (a *Adapter) BlockFriend(ctx context.Context, userID string) error {
_, err := a.http.DoJSON(ctx, "POST", friendBlockPath, map[string]any{"user_id": userID})
if err != nil {
return fmt.Errorf("kook: block friend: %w", err)
}
return nil
}
// ListUserChats returns the bot's private-chat sessions, ordered by latest
// message. page/pageSize zero use KOOK's defaults.
func (a *Adapter) ListUserChats(ctx context.Context, page, pageSize int) ([]UserChat, error) {
raw, err := a.http.GetJSON(ctx, userChatListPath, pageQuery(page, pageSize))
if err != nil {
return nil, fmt.Errorf("kook: list user chats: %w", err)
}
var data rawUserChatList
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("kook: decode user chat list: %w", err)
}
out := make([]UserChat, 0, len(data.Items))
for _, it := range data.Items {
out = append(out, UserChat{
Code: it.Code,
LastRead: it.LastRead,
LatestMsg: it.LatestMsg,
Unread: it.Unread,
TargetID: it.Target.ID,
TargetName: it.Target.Username,
})
}
return out, nil
}
// CreateUserChat creates (or returns) the private-chat session for targetID. It
// only yields the chat code — sending a message still uses /direct-message/create.
func (a *Adapter) CreateUserChat(ctx context.Context, targetID string) (UserChat, error) {
raw, err := a.http.DoJSON(ctx, "POST", userChatCreatePath, map[string]any{"target_id": targetID})
if err != nil {
return UserChat{}, fmt.Errorf("kook: create user chat: %w", err)
}
var it rawUserChatItem
if err := json.Unmarshal(raw, &it); err != nil {
return UserChat{}, fmt.Errorf("kook: decode user chat: %w", err)
}
return UserChat{
Code: it.Code,
LastRead: it.LastRead,
LatestMsg: it.LatestMsg,
Unread: it.Unread,
TargetID: it.Target.ID,
TargetName: it.Target.Username,
IsFriend: it.IsFriend,
IsBlocked: it.IsBlocked,
}, nil
}
// ListDirectMessages returns the private messages of a chat. Either chatCode or
// targetID must be non-empty (KOOK auto-creates the session when targetID is
// given); the other fields use KOOK's defaults when zero.
func (a *Adapter) ListDirectMessages(ctx context.Context, chatCode, targetID string, page, pageSize int) ([]DirectMessage, error) {
q := pageQuery(page, pageSize)
if chatCode != "" {
q.Set("chat_code", chatCode)
}
if targetID != "" {
q.Set("target_id", targetID)
}
raw, err := a.http.GetJSON(ctx, directMsgsPath, q)
if err != nil {
return nil, fmt.Errorf("kook: list direct messages: %w", err)
}
var data rawDirectMessageList
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("kook: decode direct message list: %w", err)
}
out := make([]DirectMessage, 0, len(data.Items))
for _, it := range data.Items {
out = append(out, DirectMessage{
ID: it.ID,
Type: it.Type,
Content: it.Content,
AuthorID: it.AuthorID,
AuthorName: it.Author.Username,
CreateAt: it.CreateAt,
})
}
return out, nil
}
// rawFriendList is the GET /friend data payload (any subset of keys is present).
type rawFriendList struct {
Friend json.RawMessage `json:"friend"`
Request json.RawMessage `json:"request"`
Blocked json.RawMessage `json:"blocked"`
Block json.RawMessage `json:"block"`
}
// rawFriendItem is one entry of the friend/request/block arrays.
type rawFriendItem struct {
ID int64 `json:"id"`
Friend rawUserInfo `json:"friend_info"`
}
// rawUserInfo is the embedded user object in a friend entry.
type rawUserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Online bool `json:"online"`
}
func friendEntries(raw json.RawMessage) []FriendEntry {
items := make([]rawFriendItem, 0)
if len(raw) == 0 {
return []FriendEntry{}
}
if err := json.Unmarshal(raw, &items); err != nil {
return []FriendEntry{}
}
out := make([]FriendEntry, 0, len(items))
for _, it := range items {
out = append(out, FriendEntry{
UserID: it.Friend.ID,
Username: it.Friend.Username,
Nickname: it.Friend.Nickname,
Avatar: it.Friend.Avatar,
Online: it.Friend.Online,
})
}
return out
}
func friendRequestEntries(raw json.RawMessage) []FriendRequestEntry {
items := make([]rawFriendItem, 0)
if len(raw) == 0 {
return []FriendRequestEntry{}
}
if err := json.Unmarshal(raw, &items); err != nil {
return []FriendRequestEntry{}
}
out := make([]FriendRequestEntry, 0, len(items))
for _, it := range items {
out = append(out, FriendRequestEntry{
ID: it.ID,
Friend: friendEntryOf(it.Friend),
})
}
return out
}
func friendEntryOf(u rawUserInfo) FriendEntry {
return FriendEntry{
UserID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
Online: u.Online,
}
}
// firstRaw returns the first non-empty raw JSON, so the friend list tolerates
// KOOK's "blocked"/"block" key naming drift.
func firstRaw(a, b json.RawMessage) json.RawMessage {
if len(a) > 0 {
return a
}
return b
}
// rawUserChatList is the GET /user-chat/list data payload.
type rawUserChatList struct {
Items []rawUserChatItem `json:"items"`
}
// rawUserChatItem is one private-chat session.
type rawUserChatItem struct {
Code string `json:"code"`
LastRead int64 `json:"last_read_time"`
LatestMsg int64 `json:"latest_msg_time"`
Unread int `json:"unread_count"`
IsFriend bool `json:"is_friend"`
IsBlocked bool `json:"is_blocked"`
Target rawChatUser `json:"target_info"`
}
// rawChatUser is the target user object in a chat session.
type rawChatUser struct {
ID string `json:"id"`
Username string `json:"username"`
}
// rawDirectMessageList is the GET /direct-message/list data payload.
type rawDirectMessageList struct {
Items []rawDirectMessage `json:"items"`
}
// rawDirectMessage is one private message.
type rawDirectMessage struct {
ID string `json:"id"`
Type int `json:"type"`
Content string `json:"content"`
AuthorID string `json:"author_id"`
CreateAt int64 `json:"create_at"`
Author struct {
Username string `json:"username"`
} `json:"author"`
}
// pageQuery builds a page/page_size query, only setting the keys that are non-zero
// so KOOK's defaults apply when the caller passes zero values.
func pageQuery(page, pageSize int) url.Values {
q := url.Values{}
if page > 0 {
q.Set("page", strconv.Itoa(page))
}
if pageSize > 0 {
q.Set("page_size", strconv.Itoa(pageSize))
}
return q
}
+299
View File
@@ -0,0 +1,299 @@
package kook
// This file adds the KOOK guild/channel/message admin-REST surface (design/17
// §2), sharing the authenticated httpclient. Only read listing plus a single
// controlled "test send" write are exposed; the test send reuses the channel
// message endpoint so the panel can verify the bot can post.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// Public admin-REST endpoints (relative to the KOOK API root).
const (
guildListPath = "/guild/list"
guildUserListPath = "/guild/user-list"
channelListPath = "/channel/list"
channelMsgsPath = "/message/list"
guildChannelMsgPath = "/message/create"
)
// Guild is one server from GET /guild/list.
type Guild struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Topic string `json:"topic"`
}
// Channel is one channel from GET /channel/list.
type Channel struct {
ID string `json:"id"`
UserID string `json:"user_id"`
GuildID string `json:"guild_id"`
ParentID string `json:"parent_id"`
Name string `json:"name"`
Type int `json:"type"`
Level int `json:"level"`
IsCategory bool `json:"is_category"`
}
// GuildUser is one member from GET /guild/user-list.
type GuildUser struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Online bool `json:"online"`
Bot bool `json:"bot"`
}
// ChannelMessage is one channel message from GET /message/list.
type ChannelMessage struct {
ID string `json:"id"`
Type int `json:"type"`
Content string `json:"content"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_name"`
CreateAt int64 `json:"create_at"`
}
// TestMessageResult is the POST /message/create response.
type TestMessageResult struct {
MsgID string `json:"msg_id"`
MsgTimestamp int64 `json:"msg_timestamp"`
}
// ListGuilds returns the servers the bot has joined.
func (a *Adapter) ListGuilds(ctx context.Context, page, pageSize int) ([]Guild, error) {
raw, err := a.http.GetJSON(ctx, guildListPath, pageQuery(page, pageSize))
if err != nil {
return nil, fmt.Errorf("kook: list guilds: %w", err)
}
var data rawItemList
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("kook: decode guild list: %w", err)
}
out := make([]Guild, 0, len(data.Items))
for _, it := range data.Items {
item, ok := asMap(it)
if !ok {
continue
}
out = append(out, Guild{
ID: str(item, "id"),
Name: str(item, "name"),
Icon: str(item, "icon"),
Topic: str(item, "topic"),
})
}
return out, nil
}
// ListChannelMsgs returns the recent messages of a channel (GET /message/list).
// targetID is the channel id; it is required per the KOOK contract.
func (a *Adapter) ListChannelMsgs(ctx context.Context, targetID string, pageSize int) ([]ChannelMessage, error) {
q := url.Values{"target_id": {targetID}}
if pageSize > 0 {
q.Set("page_size", strconv.Itoa(pageSize))
}
raw, err := a.http.GetJSON(ctx, channelMsgsPath, q)
if err != nil {
return nil, fmt.Errorf("kook: list channel messages: %w", err)
}
var data rawItemList
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("kook: decode channel message list: %w", err)
}
out := make([]ChannelMessage, 0, len(data.Items))
for _, it := range data.Items {
item, ok := asMap(it)
if !ok {
continue
}
authorID := str(item, "author_id")
authorName := ""
if author, ok := asMap(item["author"]); ok {
authorID = firstNonEmpty(authorID, str(author, "id"))
authorName = firstNonEmpty(str(author, "nickname"), str(author, "username"))
}
out = append(out, ChannelMessage{
ID: str(item, "id"),
Type: intOf(item["type"]),
Content: str(item, "content"),
AuthorID: authorID,
AuthorName: authorName,
CreateAt: int64Of(item["create_at"]),
})
}
return out, nil
}
// ListGuildChannels returns the channels of a guild. typ 0 = text (KOOK
// default), 2 = voice, -1 = all types. (Named ListGuildChannels because the
// PlatformAdapter contract already reserves ListChannels.)
func (a *Adapter) ListGuildChannels(ctx context.Context, guildID string, typ int) ([]Channel, error) {
q := url.Values{"guild_id": {guildID}}
if typ >= 0 {
q.Set("type", strconv.Itoa(typ))
}
raw, err := a.http.GetJSON(ctx, channelListPath, q)
if err != nil {
return nil, fmt.Errorf("kook: list channels: %w", err)
}
var data rawItemList
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("kook: decode channel list: %w", err)
}
out := make([]Channel, 0, len(data.Items))
for _, it := range data.Items {
item, ok := asMap(it)
if !ok {
continue
}
out = append(out, Channel{
ID: str(item, "id"),
UserID: str(item, "user_id"),
GuildID: str(item, "guild_id"),
ParentID: str(item, "parent_id"),
Name: str(item, "name"),
Type: intOf(item["type"]),
Level: intOf(item["level"]),
IsCategory: boolOf(item["is_category"]),
})
}
return out, nil
}
// ListGuildUsers returns the members of a guild.
func (a *Adapter) ListGuildUsers(ctx context.Context, guildID string, page, pageSize int) ([]GuildUser, error) {
q := pageQuery(page, pageSize)
q.Set("guild_id", guildID)
raw, err := a.http.GetJSON(ctx, guildUserListPath, q)
if err != nil {
return nil, fmt.Errorf("kook: list guild users: %w", err)
}
var data rawItemList
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("kook: decode guild user list: %w", err)
}
out := make([]GuildUser, 0, len(data.Items))
for _, it := range data.Items {
item, ok := asMap(it)
if !ok {
continue
}
out = append(out, GuildUser{
UserID: firstNonEmpty(str(item, "id"), str(item, "user_id")),
Username: str(item, "username"),
Nickname: str(item, "nickname"),
Avatar: str(item, "avatar"),
Online: boolOf(item["online"]),
Bot: boolOf(item["bot"]),
})
}
return out, nil
}
// SendTestMessage posts one kmarkdown message to targetID (a channel id) and
// returns the server-assigned message id, so the panel can verify the bot can
// reach the channel. The token is never logged.
func (a *Adapter) SendTestMessage(ctx context.Context, targetID, content string) (TestMessageResult, error) {
if targetID == "" {
return TestMessageResult{}, fmt.Errorf("kook: %w", ErrNoChatTarget)
}
if content == "" {
return TestMessageResult{}, fmt.Errorf("kook: test message content empty")
}
raw, err := a.http.DoJSON(ctx, "POST", guildChannelMsgPath, kookPostRequest{
Type: kindKMarkdown,
TargetID: targetID,
Content: content,
})
if err != nil {
return TestMessageResult{}, fmt.Errorf("kook: send test message: %w", err)
}
var res TestMessageResult
if err := json.Unmarshal(raw, &res); err != nil {
return TestMessageResult{}, fmt.Errorf("kook: decode test message: %w", err)
}
return res, nil
}
// rawItemList is the generic paginated items payload shared by guild/channel/
// message/user-* list endpoints.
type rawItemList struct {
Items []json.RawMessage `json:"items"`
}
// asMap decodes an object value into a map; ok=false for non-object values. It
// accepts both the raw JSON of a list item and an already-decoded any map, so
// nested objects (e.g. a message's author) parse the same way.
func asMap(v any) (map[string]any, bool) {
switch m := v.(type) {
case map[string]any:
return m, true
case json.RawMessage:
var out map[string]any
if len(m) == 0 {
return nil, false
}
if err := json.Unmarshal(m, &out); err != nil {
return nil, false
}
return out, true
default:
return nil, false
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func intOf(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
func int64Of(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int:
return int64(n)
default:
return 0
}
}
func boolOf(v any) bool {
switch b := v.(type) {
case bool:
return b
case float64:
return b != 0
default:
return false
}
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
+180
View File
@@ -0,0 +1,180 @@
package kook
import (
"context"
"net/http"
"sync"
"testing"
)
func TestListFriends_ParsesAndSendsType(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var query string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != friendPath {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
return
}
mu.Lock()
query = r.URL.RawQuery
mu.Unlock()
kookOK(w, map[string]any{
"friend": []any{
map[string]any{"id": 1, "friend_info": map[string]any{"id": "u1", "username": "alice", "nickname": "Ali", "online": true}},
},
"request": []any{
map[string]any{"id": 77, "friend_info": map[string]any{"id": "u2", "username": "bob"}},
},
"blocked": []any{
map[string]any{"id": 2, "friend_info": map[string]any{"id": "u3", "username": "carol"}},
},
})
})
a, _ := newTestAdapter(t, h)
fl, err := a.ListFriends(context.Background(), FriendTypeRequest)
if err != nil {
t.Fatalf("ListFriends: %v", err)
}
mu.Lock()
defer mu.Unlock()
if query != "type=request" {
t.Errorf("query = %q, want type=request", query)
}
if len(fl.Friends) != 1 || fl.Friends[0].UserID != "u1" || !fl.Friends[0].Online || fl.Friends[0].Nickname != "Ali" {
t.Errorf("friends = %+v, want [u1 Ali online]", fl.Friends)
}
if len(fl.Requests) != 1 || fl.Requests[0].ID != 77 || fl.Requests[0].Friend.UserID != "u2" {
t.Errorf("requests = %+v, want [77 u2]", fl.Requests)
}
if len(fl.Blocked) != 1 || fl.Blocked[0].UserID != "u3" {
t.Errorf("blocked = %+v, want [u3]", fl.Blocked)
}
}
func TestHandleFriendRequest_Body(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var body map[string]any
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != friendHandlePath {
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
mu.Lock()
body = decodeBody(r)
mu.Unlock()
kookOK(w, map[string]any{})
})
a, _ := newTestAdapter(t, h)
if err := a.HandleFriendRequest(context.Background(), 42, true); err != nil {
t.Fatalf("HandleFriendRequest: %v", err)
}
mu.Lock()
defer mu.Unlock()
if body["id"] != float64(42) || body["accept"] != true {
t.Errorf("body = %v, want id 42 accept true", body)
}
}
func TestListUserChats(t *testing.T) {
t.Parallel()
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != userChatListPath {
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
kookOK(w, map[string]any{
"items": []any{
map[string]any{
"code": "c1", "last_read_time": 1, "latest_msg_time": 2, "unread_count": 3,
"target_info": map[string]any{"id": "u9", "username": "zed"},
},
},
})
})
a, _ := newTestAdapter(t, h)
chats, err := a.ListUserChats(context.Background(), 1, 50)
if err != nil {
t.Fatalf("ListUserChats: %v", err)
}
if len(chats) != 1 || chats[0].Code != "c1" || chats[0].TargetID != "u9" || chats[0].TargetName != "zed" {
t.Errorf("chats = %+v, want [c1 u9 zed]", chats)
}
}
func TestListGuildsAndChannels(t *testing.T) {
t.Parallel()
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guildListPath:
kookOK(w, map[string]any{
"items": []any{map[string]any{"id": "g1", "name": "server", "icon": "i", "topic": "t"}},
})
case channelListPath:
if r.URL.Query().Get("guild_id") != "g1" || r.URL.Query().Get("type") != "1" {
http.Error(w, "missing guild/type", http.StatusBadRequest)
return
}
kookOK(w, map[string]any{
"items": []any{map[string]any{"id": "c1", "guild_id": "g1", "name": "general", "type": 1, "is_category": false}},
})
default:
http.Error(w, "unexpected path", http.StatusNotFound)
}
})
a, _ := newTestAdapter(t, h)
guilds, err := a.ListGuilds(context.Background(), 1, 50)
if err != nil {
t.Fatalf("ListGuilds: %v", err)
}
if len(guilds) != 1 || guilds[0].ID != "g1" || guilds[0].Name != "server" {
t.Fatalf("guilds = %+v, want [g1 server]", guilds)
}
channels, err := a.ListGuildChannels(context.Background(), "g1", 1)
if err != nil {
t.Fatalf("ListGuildChannels: %v", err)
}
if len(channels) != 1 || channels[0].ID != "c1" || channels[0].Name != "general" || channels[0].IsCategory {
t.Errorf("channels = %+v, want [c1 general]", channels)
}
}
func TestSendTestMessage(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var body map[string]any
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != guildChannelMsgPath {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
return
}
mu.Lock()
body = decodeBody(r)
mu.Unlock()
kookOK(w, map[string]any{"msg_id": "m1", "msg_timestamp": 123})
})
a, _ := newTestAdapter(t, h)
res, err := a.SendTestMessage(context.Background(), "chan-1", "hello")
if err != nil {
t.Fatalf("SendTestMessage: %v", err)
}
if res.MsgID != "m1" || res.MsgTimestamp != 123 {
t.Errorf("res = %+v, want m1/123", res)
}
mu.Lock()
defer mu.Unlock()
if body["target_id"] != "chan-1" || body["content"] != "hello" {
t.Errorf("body = %v, want target_id chan-1 content hello", body)
}
}
func TestSendTestMessage_NoTarget(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unexpected", http.StatusNotFound)
}))
if _, err := a.SendTestMessage(context.Background(), "", "hi"); err == nil {
t.Fatal("expected error for empty target")
}
}