feat: 渠道管理分两块(.env必要回退+删除同步+可用类型/已保存分开)
agent-120 .env只留必要回退: - .env.example KOOK移4可选业务字段(RECEIVE_MODE/WEBHOOK_PATH/VERIFY_TOKEN/ENCRYPT_KEY),只留token/base_url; channelAccountsFromEnv不读这4个; 说明业务字段走channelConfigForm.channels.json - 黑名单/限流/receive_mode等走管理面板热更新, env仅必要连接回退 agent-121 删除渠道列表同步(根因: adminListChannels枚举caps,删除不同步caps): - adminListChannels 以channels.json为准: 遍历caps时已删账号adapter跳过(s.channels注入时), 删除/保存后列表=channels.json, 删除即消失 - (capReg Remove后续可加,范围外标注) agent-122 渠道管理分两块显示(主人:不割裂): - ①可用适配器类型(顶部PLATFORM_TYPES全集grid卡片,已配置/未配置Tag,点新建配置) - ②已保存配置(下方AdminTable过滤accounts>0,账号+bot身份+掩码token提示+连接/断开/删除) - 拆 channelConfigForm.tsx(248行) 表单抽出; Channels.tsx 463→306行; token只回填hint不写值(保存留空不改secret) 全仓 build/vet/test绿+前端build绿(356KB)
This commit is contained in:
1 parent
b614633432
commit
0ed7984de3
8 files changed
+578
-290
No files matched your search
+4
-10
@@ -99,25 +99,19 @@ ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
|||||||
|
|
||||||
# ---- KOOK(KaiHeiLa)渠道 ----
|
# ---- KOOK(KaiHeiLa)渠道 ----
|
||||||
# 单账号走环境变量;多账号走下方 CHANNELS_JSON。
|
# 单账号走环境变量;多账号走下方 CHANNELS_JSON。
|
||||||
|
# .env 仅提供【必要连接兜底】;接收模式 / webhook 回调路由 / verify_token / encrypt_key 等
|
||||||
|
# 业务·高级项一律在管理面板「渠道管理」配置(写入 channels.json,热更新),不落 .env。
|
||||||
# CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。
|
# CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。
|
||||||
# CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。
|
# CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。
|
||||||
# CHANNEL_KOOK_RECEIVE_MODE 可选:ws(默认,连 KOOK gateway WebSocket 收事件)| webhook
|
|
||||||
# (在 Cyrene gateway 挂一条路由接收 KOOK 的 POST 回调;webhook 与 ws 互斥,需在 KOOK
|
|
||||||
# 开发者后台把机器人连接模式切到 WebHook 并填回调 URL)。
|
|
||||||
# CHANNEL_KOOK_WEBHOOK_PATH 可选,webhook 模式回调路由,默认 /webhook/kook。
|
|
||||||
# CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN 可选,KOOK 开发者后台 verify token(回调校验,不匹配 403)。
|
|
||||||
# CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY 可选,KOOK Encrypt Key(回调 AES-256-CBC 解密)。
|
|
||||||
# CHANNEL_KOOK_TOKEN=
|
# CHANNEL_KOOK_TOKEN=
|
||||||
# CHANNEL_KOOK_BASE_URL=
|
# CHANNEL_KOOK_BASE_URL=
|
||||||
# CHANNEL_KOOK_RECEIVE_MODE=ws
|
|
||||||
# CHANNEL_KOOK_WEBHOOK_PATH=
|
|
||||||
# CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN=
|
|
||||||
# CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY=
|
|
||||||
|
|
||||||
# ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ----
|
# ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ----
|
||||||
# ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 <DATA_DIR>/channels.json,已 gitignore),
|
# ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 <DATA_DIR>/channels.json,已 gitignore),
|
||||||
# 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。
|
# 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。
|
||||||
# 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL_<NAME>_*。
|
# 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL_<NAME>_*。
|
||||||
|
# 业务·高级项(接收模式 receive_mode / webhook 回调路由与 verify_token、encrypt_key / 黑白名单 / 限流)
|
||||||
|
# 一律在管理面板「渠道管理」配置:写 channels.json、热更新;.env 仅作无文件时的必要连接回退(token/base_url 等)。
|
||||||
# 单账号仍走上面 CHANNEL_<NAME>_* 环境变量;这里提供独立的 JSON 注册表,
|
# 单账号仍走上面 CHANNEL_<NAME>_* 环境变量;这里提供独立的 JSON 注册表,
|
||||||
# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截,
|
# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截,
|
||||||
# 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如:
|
# 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如:
|
||||||
|
|||||||
+12
-7
@@ -87,7 +87,11 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
|
|||||||
// channelAccountsFromEnv synthesizes the env fallback: the single-account
|
// channelAccountsFromEnv synthesizes the env fallback: the single-account
|
||||||
// CHANNEL_<NAME>_* adapters plus any CHANNELS_JSON multi-account entries. It is
|
// CHANNEL_<NAME>_* adapters plus any CHANNELS_JSON multi-account entries. It is
|
||||||
// the .env fallback passed to runtimeconfig.LoadChannels when channels.json is
|
// the .env fallback passed to runtimeconfig.LoadChannels when channels.json is
|
||||||
// absent or invalid.
|
// absent or invalid. .env only carries the necessary connection fallback
|
||||||
|
// (token/base_url/...); business and advanced per-account fields (receive_mode,
|
||||||
|
// webhook path/verify_token/encrypt_key, allow/deny lists, rate limits) are
|
||||||
|
// configured in the panel's channel management and live in channels.json, never
|
||||||
|
// in .env.
|
||||||
//
|
//
|
||||||
// A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only
|
// A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only
|
||||||
// when its token env var is set; with an unset token there is no account at all,
|
// when its token env var is set; with an unset token there is no account at all,
|
||||||
@@ -96,16 +100,17 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
|
|||||||
// gate decides whether to dial) so the channel list stays stable.
|
// gate decides whether to dial) so the channel list stays stable.
|
||||||
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
|
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
|
||||||
out := map[string][]config.ChannelAccount{}
|
out := map[string][]config.ChannelAccount{}
|
||||||
|
// KOOK: only the necessary connection fields. receive_mode / webhook_path /
|
||||||
|
// webhook_verify_token / webhook_encrypt_key are business·advanced fields that
|
||||||
|
// go to channels.json via the panel — .env does not fall back on them.
|
||||||
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
|
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
|
||||||
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
|
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
|
||||||
"bot_token": tok,
|
"bot_token": tok,
|
||||||
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
||||||
"receive_mode": os.Getenv("CHANNEL_KOOK_RECEIVE_MODE"),
|
|
||||||
"webhook_path": os.Getenv("CHANNEL_KOOK_WEBHOOK_PATH"),
|
|
||||||
"webhook_verify_token": os.Getenv("CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN"),
|
|
||||||
"webhook_encrypt_key": os.Getenv("CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY"),
|
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
// Telegram: token (necessary) + optional webhook_url (connection). Business
|
||||||
|
// tuning goes to channels.json.
|
||||||
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
|
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
|
||||||
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
|
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
|
||||||
"bot_token": tok,
|
"bot_token": tok,
|
||||||
|
|||||||
+26
-13
@@ -151,22 +151,29 @@ func TestChannelFactoryBuildFromFile(t *testing.T) {
|
|||||||
|
|
||||||
// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a
|
// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a
|
||||||
// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge,
|
// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge,
|
||||||
// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway.
|
// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. The
|
||||||
|
// webhook business fields (receive_mode/webhook_path) are sourced from
|
||||||
|
// channels.json — .env only carries the necessary connection fallback.
|
||||||
func TestNamedAdapterForwardsWebhook(t *testing.T) {
|
func TestNamedAdapterForwardsWebhook(t *testing.T) {
|
||||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
path := filepath.Join(t.TempDir(), "channels.json")
|
||||||
t.Setenv("CHANNEL_KOOK_RECEIVE_MODE", "webhook")
|
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"webhook","webhook_path":"/webhook/kook"}}]}`
|
||||||
t.Setenv("CHANNEL_KOOK_WEBHOOK_PATH", "/webhook/kook")
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatalf("write channels.json: %v", err)
|
||||||
adapters := buildChannelAdapters(testFactory(t))
|
}
|
||||||
|
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||||
|
adpts, err := f.Build("kook")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
var found *namedAdapter
|
var found *namedAdapter
|
||||||
for _, a := range adapters {
|
for _, a := range adpts {
|
||||||
if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" {
|
if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" {
|
||||||
found = nwa
|
found = nwa
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found == nil {
|
if found == nil {
|
||||||
t.Fatalf("kook named adapter not found, got %v", names(adapters))
|
t.Fatalf("kook named adapter not found, got %v", names(adpts))
|
||||||
}
|
}
|
||||||
if p := found.WebhookPath(); p != "/webhook/kook" {
|
if p := found.WebhookPath(); p != "/webhook/kook" {
|
||||||
t.Errorf("WebhookPath = %q, want /webhook/kook", p)
|
t.Errorf("WebhookPath = %q, want /webhook/kook", p)
|
||||||
@@ -179,11 +186,17 @@ func TestNamedAdapterForwardsWebhook(t *testing.T) {
|
|||||||
// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook
|
// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook
|
||||||
// platforms) does not surface a webhook handler through the wrapper.
|
// platforms) does not surface a webhook handler through the wrapper.
|
||||||
func TestNamedAdapterNoWebhookInWSMode(t *testing.T) {
|
func TestNamedAdapterNoWebhookInWSMode(t *testing.T) {
|
||||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
path := filepath.Join(t.TempDir(), "channels.json")
|
||||||
t.Setenv("CHANNEL_KOOK_RECEIVE_MODE", "ws")
|
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"ws"}}]}`
|
||||||
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||||
adapters := buildChannelAdapters(testFactory(t))
|
t.Fatalf("write channels.json: %v", err)
|
||||||
for _, a := range adapters {
|
}
|
||||||
|
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||||
|
adpts, err := f.Build("kook")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
for _, a := range adpts {
|
||||||
if barePlatform(a.Name()) != "kook" {
|
if barePlatform(a.Name()) != "kook" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,27 @@ import {
|
|||||||
adminChannelOpen,
|
adminChannelOpen,
|
||||||
adminChannelClose,
|
adminChannelClose,
|
||||||
adminChannelTest,
|
adminChannelTest,
|
||||||
adminSaveChannelConfig,
|
|
||||||
adminDeleteChannelAccount,
|
adminDeleteChannelAccount,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types";
|
import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types";
|
||||||
import { useAutoReload } from "../hooks";
|
import { useAutoReload } from "../hooks";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
import { Icon } from "../../components/Icon";
|
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_TYPES, PLATFORM_LABEL } from "./channelFields";
|
||||||
import { PLATFORM_FIELDS, PLATFORM_GUIDE } from "./channelFields";
|
import { ChannelConfigForm, type ChannelConfigInitial } from "./channelConfigForm";
|
||||||
|
|
||||||
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
||||||
function platformOf(name: string): string {
|
function platformOf(name: string): string {
|
||||||
return name.split(":")[0];
|
return name.split(":")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open-config target: the platform + the prefill bundle for its form. */
|
||||||
|
interface CfgTarget {
|
||||||
|
platform: string;
|
||||||
|
initial: ChannelConfigInitial;
|
||||||
|
}
|
||||||
|
|
||||||
export function ChannelsModule(): React.ReactNode {
|
export function ChannelsModule(): React.ReactNode {
|
||||||
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
|
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
|
||||||
useAutoReload(reload, 10000);
|
useAutoReload(reload, 10000);
|
||||||
@@ -28,37 +33,15 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
const [cfg, setCfg] = useState<CfgTarget | null>(null);
|
||||||
|
|
||||||
const [ownerId, setOwnerId] = useState("");
|
const [ownerId, setOwnerId] = useState("");
|
||||||
const [platform, setPlatform] = useState("");
|
const [platform, setPlatform] = useState("");
|
||||||
const [group, setGroup] = useState(false);
|
const [group, setGroup] = useState(false);
|
||||||
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
|
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
|
||||||
|
|
||||||
// Config form state (per selected platform).
|
const configured = (data ?? []).filter((c) => (c.accounts?.length ?? 0) > 0);
|
||||||
const [cfgPlatform, setCfgPlatform] = useState("");
|
const configuredSet = new Set(configured.map((c) => platformOf(c.name)));
|
||||||
const [cfgId, setCfgId] = useState("");
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [baseURL, setBaseURL] = useState("");
|
|
||||||
// Platform-specific config values keyed by the adapter Config json tag
|
|
||||||
// (receive_mode/webhook_path/mode/listen_addr/webhook_url/... ). Rendered only
|
|
||||||
// for the currently selected platform's fields.
|
|
||||||
const [platVals, setPlatVals] = useState<Record<string, string>>({});
|
|
||||||
const [allowGroups, setAllowGroups] = useState("");
|
|
||||||
const [denyGroups, setDenyGroups] = useState("");
|
|
||||||
const [allowUsers, setAllowUsers] = useState("");
|
|
||||||
const [denyUsers, setDenyUsers] = useState("");
|
|
||||||
const [groupMsgPerMin, setGroupMsgPerMin] = useState("");
|
|
||||||
const [msgPerMin, setMsgPerMin] = useState("");
|
|
||||||
const [maxMessages, setMaxMessages] = useState("");
|
|
||||||
const [extraConfig, setExtraConfig] = useState("");
|
|
||||||
// Advanced-options fold; default closed (values persist in state regardless).
|
|
||||||
const [advOpen, setAdvOpen] = useState(false);
|
|
||||||
// Business-policy fold (blacklist/allow + rate limits) is separate from the
|
|
||||||
// connection config — it governs admission/de-noise, not the endpoint.
|
|
||||||
const [polOpen, setPolOpen] = useState(false);
|
|
||||||
// Hint shown next to the Token field when an account already has a secret
|
|
||||||
// (masked): the token input stays empty so a save does NOT overwrite it.
|
|
||||||
const [tokenHint, setTokenHint] = useState("");
|
|
||||||
|
|
||||||
const toggle = (name: string, connect: boolean) => {
|
const toggle = (name: string, connect: boolean) => {
|
||||||
setBusy(name);
|
setBusy(name);
|
||||||
@@ -98,116 +81,44 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
const openConfig = (name: string) => {
|
const openConfig = (name: string) => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setNote(null);
|
setNote(null);
|
||||||
const platform = platformOf(name);
|
const plat = platformOf(name);
|
||||||
setCfgPlatform(platform);
|
const acct = (data ?? []).find((c) => platformOf(c.name) === plat)?.accounts?.[0];
|
||||||
// Prefill from the backend-provided account (if already configured) so a
|
const acctCfg = acct?.config ?? {};
|
||||||
// saved config is visible again instead of the form opening blank. token is
|
|
||||||
// masked by the backend (never raw), so we only echo it as a hint.
|
|
||||||
const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0];
|
|
||||||
setCfgId(acct?.id ?? "");
|
|
||||||
const cfg = acct?.config ?? {};
|
|
||||||
// Do NOT put the masked placeholder into `token`: it would be written back as
|
// Do NOT put the masked placeholder into `token`: it would be written back as
|
||||||
// the real bot_token (overwriting the stored secret with a demo string). Keep
|
// the real bot_token (overwriting the stored secret with a demo string). Keep
|
||||||
// token empty (save omits it → keeps the existing secret) and show a hint.
|
// token empty (save omits it → keeps the existing secret) and show a hint.
|
||||||
const hasToken = typeof cfg.bot_token === "string" && cfg.bot_token && !cfg.bot_token.includes("(已配置");
|
const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !acctCfg.bot_token.includes("(已配置");
|
||||||
setToken("");
|
const platVals: Record<string, string> = {};
|
||||||
setTokenHint(hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "");
|
for (const f of PLATFORM_FIELDS[plat] ?? []) {
|
||||||
setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : "");
|
const v = acctCfg[f.key];
|
||||||
const plat: Record<string, string> = {};
|
platVals[f.key] = v == null ? "" : String(v);
|
||||||
for (const f of PLATFORM_FIELDS[platform] ?? []) {
|
|
||||||
const v = cfg[f.key];
|
|
||||||
plat[f.key] = v == null ? "" : String(v);
|
|
||||||
}
|
}
|
||||||
setPlatVals(plat);
|
setCfg({
|
||||||
setAllowGroups((acct?.allowGroups ?? []).join(","));
|
platform: plat,
|
||||||
setDenyGroups((acct?.denyGroups ?? []).join(","));
|
initial: {
|
||||||
setAllowUsers((acct?.allowUsers ?? []).join(","));
|
accountId: acct?.id ?? "",
|
||||||
setDenyUsers(acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "");
|
tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "",
|
||||||
setGroupMsgPerMin(cfg.groupMsgPerMin == null ? "" : String(cfg.groupMsgPerMin));
|
baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "",
|
||||||
setMsgPerMin(cfg.msgPerMin == null ? "" : String(cfg.msgPerMin));
|
platVals,
|
||||||
setMaxMessages(cfg.maxMessages == null ? "" : String(cfg.maxMessages));
|
allowGroups: (acct?.allowGroups ?? []).join(","),
|
||||||
setExtraConfig("");
|
denyGroups: (acct?.denyGroups ?? []).join(","),
|
||||||
setAdvOpen(false); // fold stays closed on open; advanced values are prefilled.
|
allowUsers: (acct?.allowUsers ?? []).join(","),
|
||||||
setPolOpen(false); // business-policy fold also starts closed.
|
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
|
||||||
|
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
|
||||||
|
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
|
||||||
|
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const del = (plat: string, id: string) => {
|
||||||
if (!cfgPlatform) return;
|
|
||||||
setBusy("__save__");
|
|
||||||
setErr(null);
|
|
||||||
setNote(null);
|
|
||||||
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
|
|
||||||
// Curate platform-specific config keys, then let an explicit extraConfig JSON
|
|
||||||
// override any of them; these json tags are what the backend DecodeConfig reads.
|
|
||||||
const specificMap: Record<string, unknown> = {};
|
|
||||||
for (const f of PLATFORM_FIELDS[cfgPlatform] ?? []) {
|
|
||||||
const v = (platVals[f.key] ?? "").trim();
|
|
||||||
if (v) specificMap[f.key] = v;
|
|
||||||
}
|
|
||||||
let configMap: Record<string, unknown> | undefined;
|
|
||||||
if (extraConfig.trim()) {
|
|
||||||
let extra: unknown;
|
|
||||||
try {
|
|
||||||
extra = JSON.parse(extraConfig);
|
|
||||||
} catch {
|
|
||||||
setErr("额外配置 JSON 格式有误");
|
|
||||||
setBusy(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof extra !== "object" || extra === null || Array.isArray(extra)) {
|
|
||||||
setErr("额外配置 JSON 需为对象(如 {\"receive_mode\":\"ws\"})");
|
|
||||||
setBusy(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
configMap = { ...specificMap, ...(extra as Record<string, unknown>) };
|
|
||||||
} else if (Object.keys(specificMap).length) {
|
|
||||||
configMap = specificMap;
|
|
||||||
}
|
|
||||||
adminSaveChannelConfig(cfgPlatform, {
|
|
||||||
id: cfgId.trim() || undefined,
|
|
||||||
token: token.trim() || undefined,
|
|
||||||
baseURL: baseURL.trim() || undefined,
|
|
||||||
config: configMap,
|
|
||||||
allowGroups: toList(allowGroups),
|
|
||||||
denyGroups: toList(denyGroups),
|
|
||||||
allowUsers: toList(allowUsers),
|
|
||||||
denyUsers: toList(denyUsers),
|
|
||||||
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
|
|
||||||
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
|
|
||||||
maxMessages: maxMessages ? Number(maxMessages) : undefined,
|
|
||||||
})
|
|
||||||
.then((r) => {
|
|
||||||
log("channel.save", "ok");
|
|
||||||
setNote(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
|
|
||||||
setCfgId("");
|
|
||||||
setToken("");
|
|
||||||
setBaseURL("");
|
|
||||||
setPlatVals({});
|
|
||||||
setAllowGroups("");
|
|
||||||
setDenyGroups("");
|
|
||||||
setAllowUsers("");
|
|
||||||
setDenyUsers("");
|
|
||||||
setGroupMsgPerMin("");
|
|
||||||
setMsgPerMin("");
|
|
||||||
setMaxMessages("");
|
|
||||||
setExtraConfig("");
|
|
||||||
reload();
|
|
||||||
})
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
log("channel.save", "error");
|
|
||||||
setErr(e instanceof Error ? e.message : "保存渠道配置失败");
|
|
||||||
})
|
|
||||||
.finally(() => setBusy(null));
|
|
||||||
};
|
|
||||||
|
|
||||||
const del = (platform: string, id: string) => {
|
|
||||||
setBusy("__del__" + id);
|
setBusy("__del__" + id);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setNote(null);
|
setNote(null);
|
||||||
adminDeleteChannelAccount(platform, id)
|
adminDeleteChannelAccount(plat, id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
log("channel.delete", "ok");
|
log("channel.delete", "ok");
|
||||||
setNote(`已删除账号 ${id} 并热更新 ${platform}`);
|
setNote(`已删除账号 ${id} 并热更新 ${plat}`);
|
||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
@@ -218,7 +129,7 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: AdminColumn<AdminChannelRecord>[] = [
|
const columns: AdminColumn<AdminChannelRecord>[] = [
|
||||||
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
|
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</span> },
|
||||||
{
|
{
|
||||||
key: "connected",
|
key: "connected",
|
||||||
title: "状态",
|
title: "状态",
|
||||||
@@ -239,9 +150,13 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
// Show the connected bot's resolved identity (kook user/me) when the
|
// Show the connected bot's resolved identity (kook user/me) when the
|
||||||
// adapter fetched it, falling back to the account id.
|
// adapter fetched it, falling back to the account id.
|
||||||
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
||||||
|
const tokenMasked = typeof a.config?.bot_token === "string" && a.config.bot_token;
|
||||||
return (
|
return (
|
||||||
<span key={a.id || "default"} className="flex items-center gap-1">
|
<span key={a.id || "default"} className="flex items-center gap-1">
|
||||||
<Tag tone="neutral">{label}</Tag>
|
<Tag tone="neutral">{label}</Tag>
|
||||||
|
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
|
||||||
|
{tokenMasked ? "token 已配置" : "token 未填"}
|
||||||
|
</Tag>
|
||||||
{a.id ? (
|
{a.id ? (
|
||||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||||
删除
|
删除
|
||||||
@@ -289,109 +204,66 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
{err ? <Notice tone="err">{err}</Notice> : null}
|
{err ? <Notice tone="err">{err}</Notice> : null}
|
||||||
{note ? <Notice tone="ok">{note}</Notice> : null}
|
{note ? <Notice tone="ok">{note}</Notice> : null}
|
||||||
|
|
||||||
<Surface className="space-y-2">
|
{/* ① 可用适配器类型(新建):全部已知平台,未配置也可创建 */}
|
||||||
<AdminTable
|
<section className="space-y-2">
|
||||||
columns={columns}
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
rows={loading ? null : data}
|
<h3 className="text-sm font-semibold">可用适配器类型(新建)</h3>
|
||||||
loading={loading}
|
<span className="text-xs text-[var(--mu-text-lo)]">选择平台类型创建配置,或管理下方已保存配置</span>
|
||||||
error={error}
|
</div>
|
||||||
emptyTitle="无渠道数据"
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
rowKey={(c) => c.name}
|
{PLATFORM_TYPES.map((p) => {
|
||||||
/>
|
const has = configuredSet.has(p);
|
||||||
</Surface>
|
return (
|
||||||
|
<Surface key={p} gap="sm" className="flex flex-col gap-2">
|
||||||
{cfgPlatform ? (
|
<div className="flex items-center gap-2">
|
||||||
<Surface className="space-y-3">
|
<Tag tone="accent">{PLATFORM_LABEL[p] ?? p}</Tag>
|
||||||
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
|
<Tag tone={has ? "ok" : "neutral"}>{has ? "已配置" : "未配置"}</Tag>
|
||||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
|
||||||
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
|
|
||||||
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-accent)]/25 bg-[var(--mu-surface-4)] p-3">
|
|
||||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-[var(--mu-accent-hi)]">
|
|
||||||
<Icon name="check" size={16} className="text-[var(--mu-accent)]" />
|
|
||||||
核心必填
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
|
|
||||||
{PLATFORM_GUIDE[cfgPlatform].guide}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
<Field label="账号 ID(留空自动生成)" value={cfgId} onChange={setCfgId} placeholder="conf0" />
|
|
||||||
<label className="block space-y-1">
|
|
||||||
<span className="flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
|
||||||
Token(bot_token)
|
|
||||||
{PLATFORM_GUIDE[cfgPlatform].tokenRequired !== false ? (
|
|
||||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">必填</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={`${inputCls} border border-[var(--mu-accent)]/25`} />
|
|
||||||
{tokenHint ? <span className="block text-[11px] text-[var(--mu-text-lo)]">{tokenHint}</span> : null}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{PLATFORM_GUIDE[cfgPlatform].hint ? (
|
|
||||||
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[cfgPlatform].hint}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
|
|
||||||
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAdvOpen((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
|
||||||
>
|
|
||||||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${advOpen ? "rotate-90" : ""}`}>
|
|
||||||
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
|
|
||||||
</span>
|
|
||||||
高级选项(base_url / 平台特定字段 / 额外 config)
|
|
||||||
</button>
|
|
||||||
{advOpen ? (
|
|
||||||
<div className="space-y-3 px-3 pb-3 pt-1">
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
<Field label="baseURL(地址)" value={baseURL} onChange={setBaseURL} placeholder="https://…" />
|
|
||||||
{(PLATFORM_FIELDS[cfgPlatform] ?? []).map((f) => (
|
|
||||||
<Field key={f.key} label={f.label} value={platVals[f.key] ?? ""} onChange={(v) => setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<label className="block space-y-1">
|
<p className="min-h-10 text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[p].guide}</p>
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖平台特有字段如 webhook_url/endpointPath/secret)</span>
|
<div className="mt-auto">
|
||||||
<textarea value={extraConfig} onChange={(e) => setExtraConfig(e.target.value)} rows={2} placeholder='{"webhook_url":"…"}' className="w-full 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 size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(p)}>
|
||||||
</label>
|
{has ? "编辑配置" : "新建配置"}
|
||||||
</div>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 业务策略(独立于连接配置):黑白名单 / 限流——管准入与降噪,不参与端到端连接。 */}
|
{/* ② 已保存配置:仅显示已配置账号的平台 */}
|
||||||
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
<section className="space-y-2">
|
||||||
<button
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
type="button"
|
<h3 className="text-sm font-semibold">已保存配置</h3>
|
||||||
onClick={() => setPolOpen((v) => !v)}
|
<span className="text-xs text-[var(--mu-text-lo)]">仅显示已配置账号的平台(含连接/断开/删除)</span>
|
||||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
</div>
|
||||||
>
|
<Surface className="space-y-2">
|
||||||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${polOpen ? "rotate-90" : ""}`}>
|
<AdminTable
|
||||||
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
|
columns={columns}
|
||||||
</span>
|
rows={loading ? null : configured}
|
||||||
业务策略(黑白名单 / 群聊限流——不参与连接配置)
|
loading={loading}
|
||||||
</button>
|
error={error}
|
||||||
{polOpen ? (
|
emptyTitle="尚未保存任何渠道配置——请在上方选择平台类型创建"
|
||||||
<div className="grid grid-cols-1 gap-2 px-3 pb-3 pt-1 sm:grid-cols-2">
|
rowKey={(c) => c.name}
|
||||||
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
|
/>
|
||||||
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
|
|
||||||
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
|
|
||||||
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
|
|
||||||
<Field label="groupMsgPerMin(群聊限流,≤0 关闭)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
|
|
||||||
<Field label="msgPerMin" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
|
|
||||||
<Field label="maxMessages" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
|
|
||||||
<Button variant="ghost" disabled={busy !== null} onClick={() => setCfgPlatform("")}>取消</Button>
|
|
||||||
</div>
|
|
||||||
</Surface>
|
</Surface>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{cfg ? (
|
||||||
|
<ChannelConfigForm
|
||||||
|
key={cfg.platform}
|
||||||
|
platform={cfg.platform}
|
||||||
|
initial={cfg.initial}
|
||||||
|
busy={busy}
|
||||||
|
setBusy={setBusy}
|
||||||
|
onSaved={(msg) => {
|
||||||
|
setNote(msg);
|
||||||
|
setErr(null);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onError={(msg) => setErr(msg)}
|
||||||
|
onClose={() => setCfg(null)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
@@ -432,32 +304,3 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
|
||||||
"w-full 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)]";
|
|
||||||
|
|
||||||
/** Compact labeled input for the config form; `secret` masks the field. */
|
|
||||||
interface FieldProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
secret?: boolean;
|
|
||||||
inputMode?: "text" | "numeric";
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, value, onChange, placeholder, secret, inputMode = "text" }: FieldProps): React.ReactNode {
|
|
||||||
return (
|
|
||||||
<label className="block space-y-1">
|
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
|
|
||||||
<input
|
|
||||||
type={secret ? "password" : "text"}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
inputMode={inputMode}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { adminSaveChannelConfig } from "../../lib/api";
|
||||||
|
import { useAuditStore } from "../audit";
|
||||||
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
|
import { Icon } from "../../components/Icon";
|
||||||
|
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
|
||||||
|
|
||||||
|
/** Prefill bundle computed by the module's openConfig from the backend record
|
||||||
|
* (masked token is echoed only as a hint, never as a raw value). */
|
||||||
|
export interface ChannelConfigInitial {
|
||||||
|
accountId: string;
|
||||||
|
tokenHint: string;
|
||||||
|
baseURL: string;
|
||||||
|
platVals: Record<string, string>;
|
||||||
|
allowGroups: string;
|
||||||
|
denyGroups: string;
|
||||||
|
allowUsers: string;
|
||||||
|
denyUsers: string;
|
||||||
|
groupMsgPerMin: string;
|
||||||
|
msgPerMin: string;
|
||||||
|
maxMessages: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelConfigFormProps {
|
||||||
|
platform: string;
|
||||||
|
initial: ChannelConfigInitial;
|
||||||
|
busy: string | null;
|
||||||
|
setBusy: (v: string | null) => void;
|
||||||
|
onSaved: (msg: string) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channel account config form. Owns its own field state so the module stays
|
||||||
|
* under the 500-line bound; remounts per platform via key={platform}. */
|
||||||
|
export function ChannelConfigForm({
|
||||||
|
platform,
|
||||||
|
initial,
|
||||||
|
busy,
|
||||||
|
setBusy,
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
onClose,
|
||||||
|
}: ChannelConfigFormProps): React.ReactNode {
|
||||||
|
const log = useAuditStore((s) => s.log);
|
||||||
|
const [cfgId, setCfgId] = useState(initial.accountId);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [baseURL, setBaseURL] = useState(initial.baseURL);
|
||||||
|
const [platVals, setPlatVals] = useState<Record<string, string>>(initial.platVals);
|
||||||
|
const [allowGroups, setAllowGroups] = useState(initial.allowGroups);
|
||||||
|
const [denyGroups, setDenyGroups] = useState(initial.denyGroups);
|
||||||
|
const [allowUsers, setAllowUsers] = useState(initial.allowUsers);
|
||||||
|
const [denyUsers, setDenyUsers] = useState(initial.denyUsers);
|
||||||
|
const [groupMsgPerMin, setGroupMsgPerMin] = useState(initial.groupMsgPerMin);
|
||||||
|
const [msgPerMin, setMsgPerMin] = useState(initial.msgPerMin);
|
||||||
|
const [maxMessages, setMaxMessages] = useState(initial.maxMessages);
|
||||||
|
const [extraConfig, setExtraConfig] = useState("");
|
||||||
|
const [advOpen, setAdvOpen] = useState(false);
|
||||||
|
const [polOpen, setPolOpen] = useState(false);
|
||||||
|
const [tokenHint, setTokenHint] = useState(initial.tokenHint);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!platform) return;
|
||||||
|
setBusy("__save__");
|
||||||
|
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
|
||||||
|
const specificMap: Record<string, unknown> = {};
|
||||||
|
for (const f of PLATFORM_FIELDS[platform] ?? []) {
|
||||||
|
const v = (platVals[f.key] ?? "").trim();
|
||||||
|
if (v) specificMap[f.key] = v;
|
||||||
|
}
|
||||||
|
let configMap: Record<string, unknown> | undefined;
|
||||||
|
if (extraConfig.trim()) {
|
||||||
|
let extra: unknown;
|
||||||
|
try {
|
||||||
|
extra = JSON.parse(extraConfig);
|
||||||
|
} catch {
|
||||||
|
onError("额外配置 JSON 格式有误");
|
||||||
|
setBusy(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof extra !== "object" || extra === null || Array.isArray(extra)) {
|
||||||
|
onError("额外配置 JSON 需为对象(如 {\"receive_mode\":\"ws\"})");
|
||||||
|
setBusy(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configMap = { ...specificMap, ...(extra as Record<string, unknown>) };
|
||||||
|
} else if (Object.keys(specificMap).length) {
|
||||||
|
configMap = specificMap;
|
||||||
|
}
|
||||||
|
adminSaveChannelConfig(platform, {
|
||||||
|
id: cfgId.trim() || undefined,
|
||||||
|
token: token.trim() || undefined,
|
||||||
|
baseURL: baseURL.trim() || undefined,
|
||||||
|
config: configMap,
|
||||||
|
allowGroups: toList(allowGroups),
|
||||||
|
denyGroups: toList(denyGroups),
|
||||||
|
allowUsers: toList(allowUsers),
|
||||||
|
denyUsers: toList(denyUsers),
|
||||||
|
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
|
||||||
|
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
|
||||||
|
maxMessages: maxMessages ? Number(maxMessages) : undefined,
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
log("channel.save", "ok");
|
||||||
|
onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
|
||||||
|
setCfgId("");
|
||||||
|
setToken("");
|
||||||
|
setBaseURL("");
|
||||||
|
setPlatVals({});
|
||||||
|
setAllowGroups("");
|
||||||
|
setDenyGroups("");
|
||||||
|
setAllowUsers("");
|
||||||
|
setDenyUsers("");
|
||||||
|
setGroupMsgPerMin("");
|
||||||
|
setMsgPerMin("");
|
||||||
|
setMaxMessages("");
|
||||||
|
setExtraConfig("");
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
log("channel.save", "error");
|
||||||
|
onError(e instanceof Error ? e.message : "保存渠道配置失败");
|
||||||
|
})
|
||||||
|
.finally(() => setBusy(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Surface className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">配置账号 → {PLATFORM_LABEL[platform] ?? platform}</h3>
|
||||||
|
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||||
|
保存后写入 channels.json(本地、已 gitignore)并热更新 {platform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
|
||||||
|
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-accent)]/25 bg-[var(--mu-surface-4)] p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-[var(--mu-accent-hi)]">
|
||||||
|
<Icon name="check" size={16} className="text-[var(--mu-accent)]" />
|
||||||
|
核心必填
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
|
||||||
|
{PLATFORM_GUIDE[platform].guide}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<Field label="账号 ID(留空自动生成)" value={cfgId} onChange={setCfgId} placeholder="conf0" />
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
||||||
|
Token(bot_token)
|
||||||
|
{PLATFORM_GUIDE[platform].tokenRequired !== false ? (
|
||||||
|
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">必填</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={`${inputCls} border border-[var(--mu-accent)]/25`} />
|
||||||
|
{tokenHint ? <span className="block text-[11px] text-[var(--mu-text-lo)]">{tokenHint}</span> : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{PLATFORM_GUIDE[platform].hint ? (
|
||||||
|
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[platform].hint}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
|
||||||
|
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdvOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${advOpen ? "rotate-90" : ""}`}>
|
||||||
|
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
|
||||||
|
</span>
|
||||||
|
高级选项(base_url / 平台特定字段 / 额外 config)
|
||||||
|
</button>
|
||||||
|
{advOpen ? (
|
||||||
|
<div className="space-y-3 px-3 pb-3 pt-1">
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<Field label="baseURL(地址)" value={baseURL} onChange={setBaseURL} placeholder="https://…" />
|
||||||
|
{(PLATFORM_FIELDS[platform] ?? []).map((f) => (
|
||||||
|
<Field key={f.key} label={f.label} value={platVals[f.key] ?? ""} onChange={(v) => setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖平台特有字段如 webhook_url/endpointPath/secret)</span>
|
||||||
|
<textarea value={extraConfig} onChange={(e) => setExtraConfig(e.target.value)} rows={2} placeholder='{"webhook_url":"…"}' className="w-full 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)]" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 业务策略(独立于连接配置):黑白名单 / 限流——管准入与降噪,不参与端到端连接。 */}
|
||||||
|
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPolOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${polOpen ? "rotate-90" : ""}`}>
|
||||||
|
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
|
||||||
|
</span>
|
||||||
|
业务策略(黑白名单 / 群聊限流——不参与连接配置)
|
||||||
|
</button>
|
||||||
|
{polOpen ? (
|
||||||
|
<div className="grid grid-cols-1 gap-2 px-3 pb-3 pt-1 sm:grid-cols-2">
|
||||||
|
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
|
||||||
|
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
|
||||||
|
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
|
||||||
|
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
|
||||||
|
<Field label="groupMsgPerMin(群聊限流,≤0 关闭)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
|
||||||
|
<Field label="msgPerMin" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
|
||||||
|
<Field label="maxMessages" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
|
||||||
|
<Button variant="ghost" disabled={busy !== null} onClick={onClose}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full 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)]";
|
||||||
|
|
||||||
|
/** Compact labeled input for the config form; `secret` masks the field. */
|
||||||
|
interface FieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
secret?: boolean;
|
||||||
|
inputMode?: "text" | "numeric";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value, onChange, placeholder, secret, inputMode = "text" }: FieldProps): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
|
||||||
|
<input
|
||||||
|
type={secret ? "password" : "text"}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
inputMode={inputMode}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,3 +68,16 @@ export const PLATFORM_GUIDE: Record<string, PlatformGuide> = {
|
|||||||
tokenRequired: false,
|
tokenRequired: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Human-friendly platform display name (the map key is the adapter prefix). */
|
||||||
|
export const PLATFORM_LABEL: Record<string, string> = {
|
||||||
|
kook: "KOOK",
|
||||||
|
"qq-obv11": "QQ(OneBot v11)",
|
||||||
|
telegram: "Telegram",
|
||||||
|
"weixin-ilink": "微信(iLink)",
|
||||||
|
webhook: "Webhook",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The full set of known adapter platform types (whether or not configured yet).
|
||||||
|
* Drives the "可用适配器类型(新建)" grid — every known type is creatable. */
|
||||||
|
export const PLATFORM_TYPES: string[] = Object.keys(PLATFORM_GUIDE);
|
||||||
@@ -150,9 +150,20 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
platform, acctID := splitAdapterName(ch.Name())
|
||||||
|
// channels.json is the source of truth for which accounts exist. A
|
||||||
|
// registered adapter surfaces only while its account is still
|
||||||
|
// configured, so a deleted account disappears from the list. Without a
|
||||||
|
// config store (tests) every registered adapter is listed unchanged.
|
||||||
|
if s.channels != nil {
|
||||||
|
accts, ok := accountsByPlatform[platform]
|
||||||
|
if !ok || !platformHasAccountID(accts, acctID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
|
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
|
||||||
if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 {
|
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
|
||||||
sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[channelBasePlatform(ch.Name())])
|
sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[platform])
|
||||||
}
|
}
|
||||||
out = append(out, sum)
|
out = append(out, sum)
|
||||||
}
|
}
|
||||||
@@ -201,6 +212,20 @@ func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// platformHasAccountID reports whether the configured accounts of a platform still
|
||||||
|
// include the account an adapter is bound to. A bare adapter (kook) binds to that
|
||||||
|
// platform's default account (empty id); a multi-account adapter (kook:conf0)
|
||||||
|
// binds to exactly that account id. A mismatch means the adapter is stale — its
|
||||||
|
// account was deleted from channels.json — so it must not surface in the list.
|
||||||
|
func platformHasAccountID(accts []AdminChannelAccount, id string) bool {
|
||||||
|
for _, a := range accts {
|
||||||
|
if a.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// botInfo is the live, connected bot's resolved identity (kook fetches it from
|
// botInfo is the live, connected bot's resolved identity (kook fetches it from
|
||||||
// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so
|
// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so
|
||||||
// the admin list can show who the bot is without persisting it.
|
// the admin list can show who the bot is without persisting it.
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||||
|
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
|
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
|
||||||
@@ -33,6 +37,21 @@ func (s *stubChannelConfigStore) Reload(platform string) error {
|
|||||||
return s.reloadErr
|
return s.reloadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stubNamedChannel is an sdk.Channel with a caller-chosen adapter name, so a test
|
||||||
|
// can register a multi-account adapter (kook:3fc8) distinct from its bare-platorm
|
||||||
|
// sibling and observe which entries survive a config change.
|
||||||
|
type stubNamedChannel struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubNamedChannel) Name() string { return s.name }
|
||||||
|
func (s stubNamedChannel) Connect(ctx context.Context) error { return nil }
|
||||||
|
func (s stubNamedChannel) Disconnect(ctx context.Context) error { return nil }
|
||||||
|
func (s stubNamedChannel) IsConnected() bool { return true }
|
||||||
|
func (s stubNamedChannel) HealthCheck(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
var _ sdk.Channel = stubNamedChannel{}
|
||||||
|
|
||||||
func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
|
func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
path := filepath.Join(t.TempDir(), "channels.json")
|
path := filepath.Join(t.TempDir(), "channels.json")
|
||||||
@@ -147,3 +166,131 @@ func TestAdminDeleteChannelConfig(t *testing.T) {
|
|||||||
t.Fatalf("kook account still present after delete: %+v", accounts)
|
t.Fatalf("kook account still present after delete: %+v", accounts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminChannelsList is a small read helper that issues GET /admin/channels and
|
||||||
|
// decodes the data array, returning the raw body too so a caller can assert that a
|
||||||
|
// secret never leaked. It fails the test on any transport/parse error.
|
||||||
|
func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]ChannelSummary, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("get status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var items []ChannelSummary
|
||||||
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||||||
|
t.Fatalf("decode list: %v", err)
|
||||||
|
}
|
||||||
|
return items, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminDeleteChannelAccountRemovesFromList is the regression for the
|
||||||
|
// stale-adapter bug: deleting an account removes it from channels.json, but the
|
||||||
|
// adapter stays registered in caps. The list must still drop it (channels.json is
|
||||||
|
// the source of truth), so a deleted account never reappears on refresh.
|
||||||
|
func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
caps := capreg.New()
|
||||||
|
if err := caps.RegisterChannel(stubNamedChannel{name: "kook:3fc8"}); err != nil {
|
||||||
|
t.Fatalf("register channel: %v", err)
|
||||||
|
}
|
||||||
|
store := &stubChannelConfigStore{
|
||||||
|
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||||
|
accounts: map[string][]config.ChannelAccount{
|
||||||
|
"kook": {{ID: "3fc8", Config: map[string]any{"bot_token": "tk"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
// Before the delete the account shows up.
|
||||||
|
items, _ := adminChannelsList(t, ts, tok)
|
||||||
|
if len(items) != 1 || items[0].Name != "kook:3fc8" {
|
||||||
|
t.Fatalf("before delete items = %+v, want [kook:3fc8]", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=3fc8", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
|
||||||
|
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stale adapter remains in caps (the bridge reload does not touch the
|
||||||
|
// registry), yet the deleted account must not reappear in the list.
|
||||||
|
if items, _ := adminChannelsList(t, ts, tok); len(items) != 0 {
|
||||||
|
t.Fatalf("after delete items = %+v, want none (deleted account must vanish)", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminDeleteChannelAccountKeepsSibling verifies that deleting one account of a
|
||||||
|
// multi-account platform drops only that account: the sibling adapter and its
|
||||||
|
// account remain, and the deleted one disappears.
|
||||||
|
func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
caps := capreg.New()
|
||||||
|
for _, name := range []string{"kook:3fc8", "kook:conf1"} {
|
||||||
|
if err := caps.RegisterChannel(stubNamedChannel{name: name}); err != nil {
|
||||||
|
t.Fatalf("register %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store := &stubChannelConfigStore{
|
||||||
|
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||||
|
accounts: map[string][]config.ChannelAccount{
|
||||||
|
"kook": {
|
||||||
|
{ID: "3fc8", Config: map[string]any{"bot_token": "a"}},
|
||||||
|
{ID: "conf1", Config: map[string]any{"bot_token": "b"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=3fc8", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, _ := adminChannelsList(t, ts, tok)
|
||||||
|
if len(items) != 1 || items[0].Name != "kook:conf1" {
|
||||||
|
t.Fatalf("items = %+v, want only kook:conf1 (deleted 3fc8 must vanish)", items)
|
||||||
|
}
|
||||||
|
if len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "conf1" {
|
||||||
|
t.Fatalf("accounts = %+v, want only conf1", items[0].Accounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminSaveChannelConfigListMirrorsChannelsJSON verifies the save path: after
|
||||||
|
// persisting an account the list reflects it, while a stale caps adapter for a
|
||||||
|
// different account id (here kook:oldid, never in channels.json) is dropped. The
|
||||||
|
// account's token is never leaked.
|
||||||
|
func TestAdminSaveChannelConfigListMirrorsChannelsJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
caps := capreg.New()
|
||||||
|
if err := caps.RegisterChannel(stubNamedChannel{name: "kook:oldid"}); err != nil {
|
||||||
|
t.Fatalf("register channel: %v", err)
|
||||||
|
}
|
||||||
|
store := &stubChannelConfigStore{
|
||||||
|
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||||
|
accounts: map[string][]config.ChannelAccount{},
|
||||||
|
}
|
||||||
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
|
||||||
|
map[string]any{"id": "saved0", "token": "new-tok"}, tok)
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, listBody := adminChannelsList(t, ts, tok)
|
||||||
|
if len(items) != 1 || items[0].Name != "kook" {
|
||||||
|
t.Fatalf("items = %+v, want a single kook platform from channels.json", items)
|
||||||
|
}
|
||||||
|
if len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "saved0" {
|
||||||
|
t.Fatalf("accounts = %+v, want just saved0", items[0].Accounts)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(listBody), "new-tok") {
|
||||||
|
t.Fatalf("raw token leaked in list: %s", listBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user