- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
223 lines
8.0 KiB
Go
223 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
func testLog() *slog.Logger {
|
|
return logger.New(logger.Options{Out: io.Discard})
|
|
}
|
|
|
|
// testFactory builds a channelFactory pointing at a temp (absent) channels.json,
|
|
// seeded by the env fallback, so the test reads the env the same way boot does.
|
|
func testFactory(t *testing.T) *channelFactory {
|
|
t.Helper()
|
|
return &channelFactory{
|
|
path: filepath.Join(t.TempDir(), "channels.json"),
|
|
fallback: channelAccountsFromEnv(),
|
|
log: testLog(),
|
|
}
|
|
}
|
|
|
|
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
|
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
|
adapters := buildChannelAdapters(testFactory(t))
|
|
if !hasAdapter(adapters, "kook") {
|
|
t.Fatalf("expected a kook adapter, got %v", names(adapters))
|
|
}
|
|
}
|
|
|
|
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
|
|
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
|
|
adapters := buildChannelAdapters(testFactory(t))
|
|
if !hasAdapter(adapters, "kook:conf0") {
|
|
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
|
|
}
|
|
// The named adapter must expose its allow/deny lists via Config().
|
|
for _, a := range adapters {
|
|
if a.Name() == "kook:conf0" {
|
|
cfg := a.Config()
|
|
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
|
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile verifies a stale
|
|
// channels.json with both a bare empty-token kook and a real token account builds
|
|
// only the token account, so the bare "kook" never registers (and never wins a
|
|
// bare-name open).
|
|
func TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
raw := `{"kook":[
|
|
{"id":"","config":{"base_url":"","bot_token":""}},
|
|
{"id":"3fc8","config":{"bot_token":"real-token"}}
|
|
]}`
|
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
|
adapters := buildChannelAdapters(f)
|
|
if hasAdapter(adapters, "kook") {
|
|
t.Fatalf("bare empty-token kook should not be built, got %v", names(adapters))
|
|
}
|
|
if !hasAdapter(adapters, "kook:3fc8") {
|
|
t.Fatalf("expected kook:3fc8, got %v", names(adapters))
|
|
}
|
|
}
|
|
|
|
// TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders verifies the env fallback
|
|
// does not synthesize an empty-token kook/telegram/weixin account (only a real
|
|
// token produces the account), so a bare placeholder is never registered.
|
|
func TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders(t *testing.T) {
|
|
for _, k := range []string{"CHANNEL_KOOK_TOKEN", "CHANNEL_TELEGRAM_BOT_TOKEN", "CHANNEL_WEIXIN_ILINK_BOT_TOKEN", "CHANNELS_JSON"} {
|
|
t.Setenv(k, "")
|
|
}
|
|
env := channelAccountsFromEnv()
|
|
for _, platform := range []string{"kook", "telegram", "weixin-ilink"} {
|
|
if _, ok := env[platform]; ok {
|
|
t.Fatalf("empty-token %s should not be synthesized", platform)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChannelAccountsFromEnv_KookPresentWhenTokenSet verifies a set token yields
|
|
// the account.
|
|
func TestChannelAccountsFromEnv_KookPresentWhenTokenSet(t *testing.T) {
|
|
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
|
env := channelAccountsFromEnv()
|
|
list, ok := env["kook"]
|
|
if !ok || len(list) != 1 {
|
|
t.Fatalf("expected a synthesized kook account, got %+v", env["kook"])
|
|
}
|
|
if list[0].Config["bot_token"] != "kook-secret" {
|
|
t.Fatalf("bot_token = %v, want kook-secret", list[0].Config["bot_token"])
|
|
}
|
|
}
|
|
|
|
func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
|
|
for _, a := range adapters {
|
|
if a.Name() == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestChannelFactoryBuildFromFile exercises the reload factory against a real
|
|
// channels.json file: the factory reads the file (file beats env) and builds a
|
|
// named adapter carrying the account's token and black-or-white lists, so a panel
|
|
// save → Reload path uses exactly this builder.
|
|
func TestChannelFactoryBuildFromFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3,"directMsgPerMin":-1}]}`), 0o600); err != nil {
|
|
t.Fatalf("write channels.json: %v", err)
|
|
}
|
|
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
|
adpts, err := f.Build("kook")
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if !hasAdapter(adpts, "kook:conf0") {
|
|
t.Fatalf("kook:conf0 not built, got %v", names(adpts))
|
|
}
|
|
for _, a := range adpts {
|
|
if a.Name() != "kook:conf0" {
|
|
continue
|
|
}
|
|
cfg := a.Config()
|
|
if cfg["bot_token"] != "bt" {
|
|
t.Errorf("bot_token = %v, want bt", cfg["bot_token"])
|
|
}
|
|
if got, _ := cfg["denyGroups"].([]string); len(got) != 1 || got[0] != "g-1" {
|
|
t.Errorf("denyGroups = %v, want [g-1]", cfg["denyGroups"])
|
|
}
|
|
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
|
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
|
}
|
|
if got, _ := cfg["groupMsgPerMin"].(int); got != 3 {
|
|
t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"])
|
|
}
|
|
// directMsgPerMin 同一条链:channels.json 的账号顶层字段 → namedAdapter.Config() →
|
|
// 桥的 gateOptions。断桥这一环就等于「面板/文件配了却没人读」(负值必须原样透出)。
|
|
if got, _ := cfg["directMsgPerMin"].(int); got != -1 {
|
|
t.Errorf("directMsgPerMin = %v, want -1", cfg["directMsgPerMin"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a
|
|
// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge,
|
|
// 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) {
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"webhook","webhook_path":"/webhook/kook"}}]}`
|
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
|
t.Fatalf("write channels.json: %v", err)
|
|
}
|
|
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
|
adpts, err := f.Build("kook")
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
var found *namedAdapter
|
|
for _, a := range adpts {
|
|
if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" {
|
|
found = nwa
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("kook named adapter not found, got %v", names(adpts))
|
|
}
|
|
if p := found.WebhookPath(); p != "/webhook/kook" {
|
|
t.Errorf("WebhookPath = %q, want /webhook/kook", p)
|
|
}
|
|
if found.InboundHandler() == nil {
|
|
t.Error("InboundHandler() = nil, want a mounted handler")
|
|
}
|
|
}
|
|
|
|
// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook
|
|
// platforms) does not surface a webhook handler through the wrapper.
|
|
func TestNamedAdapterNoWebhookInWSMode(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"ws"}}]}`
|
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
|
t.Fatalf("write channels.json: %v", err)
|
|
}
|
|
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" {
|
|
continue
|
|
}
|
|
if nwa, ok := a.(*namedAdapter); ok {
|
|
if p := nwa.WebhookPath(); p != "" {
|
|
t.Errorf("ws-mode kook WebhookPath = %q, want empty", p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func names(adapters []adapter.PlatformAdapter) []string {
|
|
out := make([]string, 0, len(adapters))
|
|
for _, a := range adapters {
|
|
out = append(out, a.Name())
|
|
}
|
|
return out
|
|
}
|