Files
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

291 lines
9.3 KiB
Go

package qq_obv11
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestToUnifiedPrivateTextAndFace(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
raw := []byte(`{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":5,"user_id":200,"message":[{"type":"text","data":{"text":"hi "}},{"type":"face","data":{"id":1}}],"sender":{"user_id":200,"nickname":"Alice","role":"owner"}}`)
ev, err := parseEvent(raw)
if err != nil {
t.Fatalf("parseEvent error: %v", err)
}
msg := a.toUnified(ev, raw)
if msg.Platform != platformName {
t.Errorf("Platform = %q, want %q", msg.Platform, platformName)
}
if msg.ChannelType != adapter.ChannelDirect {
t.Errorf("ChannelType = %q, want %q", msg.ChannelType, adapter.ChannelDirect)
}
if msg.ChannelID != "200" || msg.SenderID != "200" {
t.Errorf("ChannelID/SenderID = %q/%q, want 200/200", msg.ChannelID, msg.SenderID)
}
if msg.SenderName != "Alice" {
t.Errorf("SenderName = %q, want Alice", msg.SenderName)
}
if msg.Content != "hi [表情]" {
t.Errorf("Content = %q, want %q", msg.Content, "hi [表情]")
}
if msg.ContentType != adapter.ContentText {
t.Errorf("ContentType = %q, want text", msg.ContentType)
}
if msg.MessageID != "5" {
t.Errorf("MessageID = %q, want 5", msg.MessageID)
}
if !msg.Timestamp.Equal(time.Unix(1700000000, 0)) {
t.Errorf("Timestamp = %v, want %v", msg.Timestamp, time.Unix(1700000000, 0))
}
// 私聊帧里的 role=owner 是**伪造**的主人标记:适配器只把它当群内/平台身份,判主
// 由白名单决定(裁决 06.4.2),所以这里必须是 RoleMember、IsOwner()=false。
if msg.Sender.Role != adapter.RoleMember || msg.Sender.IsOwner() {
t.Errorf("Sender.Role = %q (IsOwner=%v), want member (伪造的 role=owner 不得判主)",
msg.Sender.Role, msg.Sender.IsOwner())
}
}
func TestToUnifiedGroupWithMediaAndMention(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: "server"})
raw := []byte(`{"post_type":"message","message_type":"group","time":1700000001,"self_id":100,"message_id":9,"user_id":42,"group_id":123,"group_name":"Team","message":[{"type":"at","data":{"qq":"300"}},{"type":"text","data":{"text":" look "}},{"type":"image","data":{"file":"p.jpg","url":"http://cdn/p.jpg"}},{"type":"record","data":{"file":"v.amr"}}],"sender":{"user_id":42,"nickname":"Bob","card":"Bobby","role":"member"}}`)
ev, err := parseEvent(raw)
if err != nil {
t.Fatalf("parseEvent error: %v", err)
}
msg := a.toUnified(ev, raw)
if !msg.IsGroup() {
t.Fatal("expected group message")
}
if msg.ChannelType != adapter.ChannelGroup || msg.ChannelID != "123" || msg.GroupID != "123" {
t.Errorf("group fields = %q/%q/%q, want group/123/123", msg.ChannelType, msg.ChannelID, msg.GroupID)
}
if msg.GroupName != "Team" {
t.Errorf("GroupName = %q, want Team", msg.GroupName)
}
if msg.SenderName != "Bobby" {
t.Errorf("SenderName = %q, want Bobby (card)", msg.SenderName)
}
if msg.Sender.Role != adapter.RoleMember {
t.Errorf("Sender.Role = %q, want member", msg.Sender.Role)
}
if len(msg.Mentions) != 1 || msg.Mentions[0].UserID != "300" {
t.Errorf("Mentions = %+v, want one @300", msg.Mentions)
}
if len(msg.Media) != 2 {
t.Fatalf("Media len = %d, want 2", len(msg.Media))
}
if msg.Media[0].Kind != adapter.MediaImage || msg.Media[0].URL != "http://cdn/p.jpg" || msg.Media[0].Name != "p.jpg" {
t.Errorf("Media[0] = %+v, want image p.jpg", msg.Media[0])
}
if msg.Media[1].Kind != adapter.MediaVoice {
t.Errorf("Media[1].Kind = %q, want voice", msg.Media[1].Kind)
}
if msg.Content != "@300 look [图片][语音]" {
t.Errorf("Content = %q, want %q", msg.Content, "@300 look [图片][语音]")
}
}
func TestToUnifiedCQString(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
raw := []byte(`{"post_type":"message","message_type":"private","time":1700000002,"self_id":100,"message_id":7,"user_id":200,"message":"[CQ:at,qq=300]hello[CQ:image,file=a.jpg,url=http://cdn/a.jpg]","sender":{"user_id":200,"nickname":"Alice","role":""}}`)
ev, err := parseEvent(raw)
if err != nil {
t.Fatalf("parseEvent error: %v", err)
}
msg := a.toUnified(ev, raw)
if msg.Content != "@300hello[图片]" {
t.Errorf("Content = %q, want %q", msg.Content, "@300hello[图片]")
}
if len(msg.Mentions) != 1 || msg.Mentions[0].UserID != "300" {
t.Errorf("Mentions = %+v, want @300", msg.Mentions)
}
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage || msg.Media[0].URL != "http://cdn/a.jpg" {
t.Errorf("Media = %+v, want image a.jpg", msg.Media)
}
if msg.Sender.Role != adapter.RoleUnknown {
t.Errorf("Sender.Role = %q, want unknown for empty role", msg.Sender.Role)
}
}
func TestContentTypePureImage(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
raw := []byte(`{"post_type":"message","message_type":"private","time":1700000003,"self_id":100,"message_id":8,"user_id":200,"message":[{"type":"image","data":{"file":"a.jpg"}}],"sender":{"user_id":200,"nickname":"Alice"}}`)
ev, err := parseEvent(raw)
if err != nil {
t.Fatalf("parseEvent error: %v", err)
}
msg := a.toUnified(ev, raw)
if msg.ContentType != adapter.ContentImage {
t.Errorf("ContentType = %q, want image", msg.ContentType)
}
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage {
t.Errorf("Media = %+v, want one image", msg.Media)
}
}
func TestOutboundSegmentsTextAndImage(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Content: "pong",
Media: []adapter.Media{{Kind: adapter.MediaImage, URL: "http://cdn/x.jpg"}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 2 {
t.Fatalf("segs len = %d, want 2", len(segs))
}
if segs[0].Type != "text" || segs[0].Data["text"] != "pong" {
t.Errorf("segs[0] = %+v, want text pong", segs[0])
}
if segs[1].Type != "image" || segs[1].Data["file"] != "http://cdn/x.jpg" {
t.Errorf("segs[1] = %+v, want image", segs[1])
}
}
func TestOutboundSegmentsEmptyErrors(t *testing.T) {
t.Parallel()
_, err := outboundSegments(adapter.UnifiedMessage{})
if err == nil {
t.Error("empty message: expected error, got nil")
}
}
func TestResolveTargetGroupAndPrivate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg adapter.UnifiedMessage
wantType string
wantUser int64
wantGroup int64
wantErr bool
}{
{
name: "group",
msg: adapter.UnifiedMessage{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "x"},
wantType: "group",
wantGroup: 123,
},
{
name: "private by channel id",
msg: adapter.UnifiedMessage{ChannelType: adapter.ChannelDirect, ChannelID: "200", Content: "x"},
wantType: "private",
wantUser: 200,
},
{
name: "private by sender",
msg: adapter.UnifiedMessage{ChannelType: adapter.ChannelDirect, SenderID: "300", Content: "x"},
wantType: "private",
wantUser: 300,
},
{
name: "group missing target",
msg: adapter.UnifiedMessage{ChannelType: adapter.ChannelGroup, Content: "x"},
wantErr: true,
},
{
name: "private missing target",
msg: adapter.UnifiedMessage{ChannelType: adapter.ChannelDirect, Content: "x"},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotType, gotUser, gotGroup, err := resolveTarget(tt.msg)
if (err != nil) != tt.wantErr {
t.Fatalf("resolveTarget error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil {
return
}
if gotType != tt.wantType || gotUser != tt.wantUser || gotGroup != tt.wantGroup {
t.Errorf("resolveTarget = %q/%d/%d, want %q/%d/%d", gotType, gotUser, gotGroup, tt.wantType, tt.wantUser, tt.wantGroup)
}
})
}
}
func TestRoleFromOneBot(t *testing.T) {
t.Parallel()
// 群主(owner)与群管理员(admin)都必须是 RoleMember(裁决 06.4.2):平台角色字段
// 不是主人车道凭据,主人只认白名单;未知角色值保守归入 RoleUnknown。
tests := []struct {
raw string
want adapter.Role
}{
{"owner", adapter.RoleMember},
{"admin", adapter.RoleMember},
{"member", adapter.RoleMember},
{"", adapter.RoleUnknown},
{"bogus", adapter.RoleUnknown},
}
for _, tt := range tests {
tt := tt
t.Run(tt.raw, func(t *testing.T) {
t.Parallel()
if got := roleFromOneBot(tt.raw); got != tt.want {
t.Errorf("roleFromOneBot(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestBuildSendReq(t *testing.T) {
t.Parallel()
segs := []obSegment{{Type: "text", Data: map[string]any{"text": "hi"}}}
payload, err := buildSendReq("group", 0, 123, segs, "echo_1")
if err != nil {
t.Fatalf("buildSendReq error: %v", err)
}
if string(payload) == "" {
t.Fatal("empty payload")
}
}
func TestListChannelsCachesGroups(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: "server"})
a.rememberGroup("123", "Team")
a.rememberGroup("456", "Gamer")
channels, err := a.ListChannels(context.Background())
if err != nil {
t.Fatalf("ListChannels error: %v", err)
}
if len(channels) != 2 {
t.Fatalf("channels len = %d, want 2", len(channels))
}
byID := map[string]adapter.ChannelInfo{}
for _, c := range channels {
byID[c.ChannelID] = c
}
if c := byID["123"]; c.GroupName != "Team" || c.ChannelType != adapter.ChannelGroup || c.GroupID != "123" {
t.Errorf("channel 123 = %+v", c)
}
}