diff --git a/Docs/api-client.md b/Docs/api-client.md index 95b8a7d..84df15b 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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` 快照到 `/backup-.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 不打印不落日志,子项失败记入 `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)。 diff --git a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx index 3993f66..325c64c 100644 --- a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx +++ b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx @@ -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
返回列表 @@ -69,6 +74,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack + {platform === "kook" ? : null} +
diff --git a/frontend/web/src/admin/modules/channels/KookPanel.tsx b/frontend/web/src/admin/modules/channels/KookPanel.tsx new file mode 100644 index 0000000..bd4128b --- /dev/null +++ b/frontend/web/src/admin/modules/channels/KookPanel.tsx @@ -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(null); + const [notice, setNotice] = useState(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 => { + 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 ( +
+
+ KOOK 平台能力 + owner 专属 · 已审计 + + GET /api/v1/admin/channels/{platform}/view + +
+ + {error ? {error} : null} + {notice ? {notice} : null} + {loading ? 加载中… : null} + + {data ? ( + <> + {Object.keys(data.errors ?? {}).length > 0 ? ( + 部分子项拉取失败:{Object.entries(data.errors ?? {}).map(([k, v]) => `${k}(${v})`).join(";")} + ) : null} + + + + + + ) : null} +
+ ); +} + +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 ( + +

好友 & 申请

+ {requests.length === 0 && list.length === 0 ? ( + 暂无好友/申请数据(未连接或账号无好友)。 + ) : null} + {requests.length > 0 ? ( +
+ {requests.map((r) => ( +
+ 申请 + {r.friend.nickname || r.friend.username || r.friend.user_id} + #{r.friend.user_id} + + +
+ ))} +
+ ) : null} + {list.length > 0 ? ( + <> +
好友列表({list.length})
+
+ {list.slice(0, 40).map((f) => ( + + + {f.nickname || f.username || f.user_id} + + ))} +
+ + ) : null} + {blocked.length > 0 ?
已屏蔽 {blocked.length} 人
: null} +
+ ); +} + +function KookGuildCard({ + guilds, + channels, + members, +}: { + guilds?: KookGuild[]; + channels?: KookChannel[]; + members?: KookGuildUser[]; +}): React.ReactNode { + const hasChannels = (channels ?? []).length > 0 || (members ?? []).length > 0; + return ( + +

服务器 / 频道 / 成员

+ {!guilds || guilds.length === 0 ? ( + 暂无服务器数据(bot 未加入任何服务器或未连接)。 + ) : ( +
+ {guilds.map((g) => ( + + {g.name || g.id} + {g.id} + + ))} +
+ )} + {hasChannels ? ( +
+
频道(首个服务器)
+
+ {(channels ?? []).map((c) => ( + + {c.is_category ? 分组 : c.type === 2 ? 语音 : 文字} + {c.name || c.id} + + ))} +
+
+ ) : null} + {members && members.length > 0 ? ( + <> +
成员(前 {members.length},首个服务器)
+
+ {members.slice(0, 40).map((m) => ( + + + {m.nickname || m.username || m.user_id} + {m.bot ? bot : null} + + ))} +
+ + ) : null} +
+ ); +} + +function KookChatsCard({ chats }: { chats?: KookUserChat[] }): React.ReactNode { + const list = chats ?? []; + return ( + +

私信会话

+ {list.length === 0 ? ( + 暂无私信会话。 + ) : ( +
+
+ {list.map((c) => ( +
+ {c.target_name || c.target_id} + code:{c.code} + {c.unread_count > 0 ? {c.unread_count} 未读 : null} + {new Date(c.latest_msg_time).toLocaleString()} +
+ ))} +
+
+ )} +

仅列出会话;消息内容与发送请通过 /direct-message 或消息链路。

+
+ ); +} + +function KookTestCard({ + busy, + onSend, +}: { + busy: boolean; + onSend: (target: string, content: string) => Promise; +}): React.ReactNode { + const [target, setTarget] = useState(""); + const [content, setContent] = useState(""); + const [sending, setSending] = useState(false); + const [result, setResult] = useState(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 ( + +

发消息测试(POST /message/create)

+
+ 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)]" + /> + 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)]" + /> + +
+ {result ? {result} : null} +

这条消息会真实发送到所选频道,用于验证 bot 可达性。

+
+ ); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 0d28d5d..b122687 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -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 { + const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; + return request(`/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 { + const q = new URLSearchParams(); + if (type) q.set("type", type); + if (accountId) q.set("accountId", accountId); + const qs = q.toString(); + return request(`/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 { + const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; + return request(`/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 { return request("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) }); diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index eb63404..e264fed 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -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; +} diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index a1bc601..55a44eb 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -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 { diff --git a/internal/services/gateway/admin_channels_platform.go b/internal/services/gateway/admin_channels_platform.go new file mode 100644 index 0000000..a625ff5 --- /dev/null +++ b/internal/services/gateway/admin_channels_platform.go @@ -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 +} diff --git a/internal/services/gateway/admin_channels_platform_test.go b/internal/services/gateway/admin_channels_platform_test.go new file mode 100644 index 0000000..bb028fa --- /dev/null +++ b/internal/services/gateway/admin_channels_platform_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index c125af6..6dedb2f 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -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. diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 5874452..4831f20 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -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))) diff --git a/plugins/channels/kook/admin.go b/plugins/channels/kook/admin.go new file mode 100644 index 0000000..329bace --- /dev/null +++ b/plugins/channels/kook/admin.go @@ -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 `), 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 +} diff --git a/plugins/channels/kook/admin_guild.go b/plugins/channels/kook/admin_guild.go new file mode 100644 index 0000000..b092bdd --- /dev/null +++ b/plugins/channels/kook/admin_guild.go @@ -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 +} diff --git a/plugins/channels/kook/admin_test.go b/plugins/channels/kook/admin_test.go new file mode 100644 index 0000000..c04230c --- /dev/null +++ b/plugins/channels/kook/admin_test.go @@ -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") + } +}