fix: KOOK 连不上根因——空token裸账号不注册/占名 + open指向带token账号 + 前端token不覆盖真值
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

agent-119 后端:
- buildAccountAdapter: token平台(kook/telegram/weixin)BotToken空→跳过build(不注册不占名), channelAccountsFromEnv 无tokenenv不合成账号(杜绝裸kook id="")
- runtimeconfig cleanAccounts: LoadChannels/SaveChannels 丢弃token平台空token账号(清理channels.json现有裸kook, 下次保存抹掉)
- findChannel: 裸平台名(kook)回退到该平台第一个带token账号, open"kook"不再命中裸的missing token

我补 前端token掩码不就覆真值:
- openConfig 不再把'(已配置,仅掩码显示)'当token回填(setToken空)→save token空不写bot_token保留真值
- 加 tokenHint(显示'已配置留空不改'/重填换); 修之前channels.json kook:3fc8 token变掩码串的bug
全仓 build/vet/test绿+前端build绿(463行)
This commit is contained in:
Aska committed 2026-09-07 16:01:47 +08:00
1 parent 5398486fdf
commit b614633432
7 files changed
+314 -30

No files matched your search

+61 -3
View File
@@ -40,11 +40,14 @@ func DefaultChannelsPath(dataDir string) string {
// present-and-valid channels.json it is authoritative; otherwise it falls back
// to envFallback (the .env/CHANNELS_JSON channel block computed by the caller).
// A present-but-invalid file falls back to env and returns a non-nil warning so
// the caller can log it. The returned map is non-nil.
// the caller can log it. The returned map is non-nil. A file-loaded account list
// is cleaned (bare empty-token placeholders dropped) so a stale channels.json
// never surfaces an account that cannot connect; the caller's envFallback is
// left untouched.
func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) {
accounts, readErr := loadChannelsFile(path)
if readErr == nil {
return accounts, nil
return cleanAccounts(accounts), nil
}
var warn error
if !os.IsNotExist(readErr) {
@@ -80,11 +83,13 @@ func loadChannelsFile(path string) (map[string][]config.ChannelAccount, error) {
// SaveChannels writes accounts as a pretty-printed JSON map to path with mode
// 0600 (the file holds bot tokens). It writes to a temp file then renames so a
// concurrent reader never sees a partial file.
// concurrent reader never sees a partial file. Bare empty-token placeholders are
// dropped before writing, so an unusable account never lands in the file.
func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error {
if accounts == nil {
accounts = map[string][]config.ChannelAccount{}
}
accounts = cleanAccounts(accounts)
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -204,3 +209,56 @@ func maskToken(s string) string {
}
return "****" + s[len(s)-4:]
}
// tokenRequiringPlatforms are the adapters whose connection needs a bot token
// (design/08 §4). An account for one of these with an empty bot_token is a bare
// placeholder: it can never connect and must not claim the bare platform name
// (design/16 §4.5), so it is dropped from the effective config and from
// channels.json. Other platforms (qq-obv11, webhook) are left alone so their
// channel list stays stable while the bridge's configured() gate decides whether
// to dial.
func tokenRequiringPlatforms(platform string) bool {
switch platform {
case "kook", "telegram", "weixin-ilink":
return true
default:
return false
}
}
// cleanAccounts drops unusable bare accounts from an accounts map. For a
// token-requiring platform any account with an empty bot_token is dropped (a
// bare "kook"/"telegram"/"weixin-ilink" placeholder can never connect). It never
// logs or returns a token.
func cleanAccounts(accounts map[string][]config.ChannelAccount) map[string][]config.ChannelAccount {
if accounts == nil {
return accounts
}
out := make(map[string][]config.ChannelAccount, len(accounts))
for platform, list := range accounts {
if !tokenRequiringPlatforms(platform) {
out[platform] = list
continue
}
kept := make([]config.ChannelAccount, 0, len(list))
for _, a := range list {
if strings.TrimSpace(accountBotToken(a)) == "" {
continue
}
kept = append(kept, a)
}
if len(kept) > 0 {
out[platform] = kept
}
}
return out
}
// accountBotToken returns the account's bot_token value (the credential the
// token-requiring platforms need). It never logs the token.
func accountBotToken(a config.ChannelAccount) string {
if s, ok := a.Config["bot_token"].(string); ok {
return s
}
return ""
}
@@ -90,6 +90,72 @@ func TestLoadChannelsEmptyMapFallsBackWithWarn(t *testing.T) {
}
}
func TestLoadChannelsCleansEmptyTokenKook(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
// A stale channels.json holding a bare empty-token kook plus a real account.
raw := `{"kook":[
{"id":"","config":{"base_url":"","bot_token":""}},
{"id":"3fc8ef9434bddfbbe4f4daa8da21c093","config":{"bot_token":"real-token"}}
]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
accounts, warn := LoadChannels(path, nil)
if warn != nil {
t.Fatalf("warn = %v, want nil", warn)
}
kook := accounts["kook"]
if len(kook) != 1 {
t.Fatalf("kook = %+v, want only the token-bearing account", kook)
}
if kook[0].ID != "3fc8ef9434bddfbbe4f4daa8da21c093" || kook[0].Config["bot_token"] != "real-token" {
t.Fatalf("kook = %+v, want the token-bearing account", kook[0])
}
}
func TestLoadChannelsKeepsNonTokenPlatforms(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
// qq-obv11/webhook carry no bot token and must be preserved.
raw := `{"qq-obv11":[{"id":"","config":{"mode":"server"}}],"webhook":[{"id":"","config":{"endpointPath":""}}]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
accounts, warn := LoadChannels(path, nil)
if warn != nil {
t.Fatalf("warn = %v", warn)
}
if len(accounts["qq-obv11"]) != 1 || len(accounts["webhook"]) != 1 {
t.Fatalf("non-token platforms should be preserved: %+v", accounts)
}
}
func TestSaveChannelsDropsEmptyTokenBareAccount(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
accts := map[string][]config.ChannelAccount{
"kook": {
{ID: "", Config: map[string]any{"bot_token": ""}},
{ID: "a", Config: map[string]any{"bot_token": "tk"}},
},
"webhook": {{ID: "", Config: map[string]any{"endpointPath": ""}}},
}
if err := SaveChannels(path, accts); err != nil {
t.Fatalf("save: %v", err)
}
got, warn := LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load warn = %v", warn)
}
if len(got["kook"]) != 1 || got["kook"][0].ID != "a" {
t.Fatalf("kook = %+v, want only the token-bearing account", got["kook"])
}
if _, ok := got["webhook"]; !ok || len(got["webhook"]) != 1 {
t.Fatalf("webhook should be preserved untouched: %+v", got["webhook"])
}
}
func TestSaveChannelsRoundTrip(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
@@ -283,15 +283,30 @@ func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string)
return req.Name, true
}
// findChannel returns the registered channel adapter named name, if any.
// findChannel returns the registered channel adapter named name, if any. An exact
// adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an
// exact name still resolves to that platform's first registered account, so the
// panel/CLI "connect kook" reaches the token-bearing account even when it is named
// "kook:conf0" (the empty-token bare placeholder is never registered).
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
if caps == nil {
return nil, false
}
var first sdk.Channel
for _, cap := range caps.List(capreg.KindChannel) {
if ch, ok := cap.Value.(sdk.Channel); ok && ch.Name() == name {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
if ch.Name() == name {
return ch, true
}
if first == nil && channelBasePlatform(ch.Name()) == name {
first = ch
}
}
if first != nil {
return first, true
}
return nil, false
}
@@ -0,0 +1,64 @@
package gateway
import (
"context"
"testing"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
)
// testChannel is a bare sdk.Channel used to exercise findChannel resolution.
type testChannel struct{ name string }
func (t testChannel) Name() string { return t.name }
func (t testChannel) Connect(context.Context) error {
return nil
}
func (t testChannel) Disconnect(context.Context) error { return nil }
func (t testChannel) IsConnected() bool { return false }
func (t testChannel) HealthCheck(context.Context) error { return nil }
// TestFindChannelExactMatch prefers an exact adapter name over a bare-name match.
func TestFindChannelExactMatch(t *testing.T) {
reg := capreg.New()
_ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}})
_ = reg.Register(capreg.Capability{Name: "telegram:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "telegram:conf0"}})
ch, ok := findChannel(reg, "kook:conf0")
if !ok || ch.Name() != "kook:conf0" {
t.Fatalf("exact kook:conf0 not found, got %v/%v", ch, ok)
}
ch, ok = findChannel(reg, "telegram:conf0")
if !ok || ch.Name() != "telegram:conf0" {
t.Fatalf("exact telegram:conf0 not found, got %v/%v", ch, ok)
}
}
// TestFindChannelBarePlatformResolvesToTokenAccount verifies a bare platform name
// ("kook") that is not an exact adapter name resolves to that platform's first
// registered account (the token-bearing one, since the empty-token bare account
// is never registered).
func TestFindChannelBarePlatformResolvesToTokenAccount(t *testing.T) {
reg := capreg.New()
_ = reg.Register(capreg.Capability{Name: "kook:3fc8", Kind: capreg.KindChannel, Value: testChannel{name: "kook:3fc8"}})
ch, ok := findChannel(reg, "kook")
if !ok || ch.Name() != "kook:3fc8" {
t.Fatalf("bare kook should resolve to kook:3fc8, got %v/%v", ch, ok)
}
}
// TestFindChannelUnknownOrBlank rejects a name that matches no channel.
func TestFindChannelUnknownOrBlank(t *testing.T) {
reg := capreg.New()
_ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}})
if _, ok := findChannel(reg, "unknown"); ok {
t.Fatal("unknown name should not be found")
}
if _, ok := findChannel(reg, ""); ok {
t.Fatal("blank name should not be found")
}
// Skipped: non-channel capabilities must not match.
_ = reg.Register(capreg.Capability{Name: "some-tool", Kind: capreg.KindTool, Value: testChannel{name: "some-tool"}})
if _, ok := findChannel(reg, "some-tool"); ok {
t.Fatal("non-channel capability must not match findChannel")
}
}