Files
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

94 lines
3.3 KiB
Go

package runtimeconfig
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// This file covers the channel-level role binding of design/22 §2 方案 b: the
// account's personaId is part of channels.json, so it must survive a save/load
// round trip, be omitted when unset (an unbound account is not written as
// "personaId": ""), and never be treated as a secret.
// TestChannelsPersonaIDRoundTrip proves the personaId is persisted verbatim and
// read back on the account it belongs to.
func TestChannelsPersonaIDRoundTrip(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
accts := map[string][]config.ChannelAccount{
"telegram": {{ID: "a", PersonaID: "yuki", Config: map[string]any{"bot_token": "tk"}}},
}
if err := SaveChannels(path, accts); err != nil {
t.Fatalf("save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(string(raw), `"personaId": "yuki"`) {
t.Fatalf("channels.json missing the bound role: %s", raw)
}
got, warn := LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load warn = %v", warn)
}
if len(got["telegram"]) != 1 || got["telegram"][0].PersonaID != "yuki" {
t.Fatalf("round trip personaId = %+v, want yuki", got["telegram"])
}
// A channel role is not a secret: masking the credentials must not hide it.
if masked := Masked(got["telegram"][0]); masked.PersonaID != "yuki" {
t.Fatalf("masked personaId = %q, want yuki", masked.PersonaID)
}
}
// TestChannelsPersonaIDOmittedWhenUnbound locks the omission semantics: an account
// with no channel binding writes no personaId field at all, so an existing account
// is not silently bound to an empty-named role.
func TestChannelsPersonaIDOmittedWhenUnbound(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
if err := SaveChannels(path, map[string][]config.ChannelAccount{
"telegram": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
}); err != nil {
t.Fatalf("save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.Contains(string(raw), "personaId") {
t.Fatalf("unbound account wrote a personaId: %s", raw)
}
got, _ := LoadChannels(path, nil)
if len(got["telegram"]) != 1 || got["telegram"][0].PersonaID != "" {
t.Fatalf("loaded personaId = %q, want empty", got["telegram"][0].PersonaID)
}
}
// TestUpsertChannelPersonaID pins the save semantics the panel relies on: an Upsert
// carries the whole account, so binding a role stores it, and a later save without
// a personaId clears the binding instead of leaving a stale one behind.
func TestUpsertChannelPersonaID(t *testing.T) {
t.Parallel()
accounts := map[string][]config.ChannelAccount{
"telegram": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
}
bound := UpsertChannel(accounts, "telegram", config.ChannelAccount{
ID: "a", PersonaID: "yuki", Config: map[string]any{"bot_token": "tk"},
})
if bound["telegram"][0].PersonaID != "yuki" {
t.Fatalf("upsert personaId = %q, want yuki", bound["telegram"][0].PersonaID)
}
cleared := UpsertChannel(bound, "telegram", config.ChannelAccount{
ID: "a", Config: map[string]any{"bot_token": "tk"},
})
if cleared["telegram"][0].PersonaID != "" {
t.Fatalf("personaId after unbinding save = %q, want empty", cleared["telegram"][0].PersonaID)
}
}