feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
后端(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:
1 parent
8eaf81bd0d
commit
5ec4c9814d
13 files changed
+1714
-2
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user