契约(internal/channel/adapter):UnifiedMessage 新增三个字段 —— SelfID(她自己在该 平台的账号 id)、MentionAll(是否 @全体,design/26 §2.4 的 mention_all)、AddressedToMe (本条是否指向她本人);判据的唯一出处是新增的 MentionedSelf(结构化 id 精确比对, 空 selfID 一律 false)与 AddressedToSelf(私聊恒 true;群聊 = 点名她或 @全体; 平台未给 SelfID ⇒ fail-closed)。全程不匹配正文文本。 接线(plugins/channels/qq_obv11):新增 addressed.go,把事件里早已解出、此前只用于 debug 日志与 notice 判定的 self_id 接进 toUnified,并从 at 段解出 @全体(qq=all), 在 ChannelType 判定之后统一回写三个字段。 design/18 §2 触发① 的旧口径「Mentions 非空」= @谁不论(实测群 672432767 里 @别人两次 被回);本片让消费侧(light.go:142,归 M12)能改用 AddressedToMe || MentionAll。 既有字段 Mentions/ReplyToID/ReplyToText 语义与形状不变。 测试:qq_obv11 十种帧形态(私聊/@她/@别人/@全体/无@/CQ 形态/fail-closed/纯回复) 逐条断言;adapter 侧覆盖 MentionedSelf 与 AddressedToSelf。变异自证:判据改回 「Mentions 非空」⇒ group_mention_other 红;摘掉 selfId 接线 ⇒ group_mention_me 红。
254 lines
6.2 KiB
Go
254 lines
6.2 KiB
Go
package adapter
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestUnifiedMessageConstruction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ts := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
|
msg := UnifiedMessage{
|
|
Platform: "telegram",
|
|
ChannelID: "ch-100",
|
|
ChannelType: ChannelGroup,
|
|
SenderID: "u-1",
|
|
SenderName: "Alice",
|
|
Sender: Actor{
|
|
Role: RoleOwner,
|
|
UserID: "u-1",
|
|
DisplayName: "Alice",
|
|
GroupID: "g-42",
|
|
},
|
|
Content: "hello @bob",
|
|
ContentType: ContentText,
|
|
Media: []Media{
|
|
{Kind: MediaImage, URL: "https://example.com/a.jpg", Name: "a.jpg", Size: 1024, MIMEType: "image/jpeg"},
|
|
},
|
|
Mentions: []Mention{
|
|
{UserID: "u-2", UserName: "Bob"},
|
|
},
|
|
ReplyToID: "m-9",
|
|
ReplyToText: "earlier",
|
|
GroupID: "g-42",
|
|
GroupName: "Team",
|
|
ThreadID: "t-7",
|
|
MessageID: "m-10",
|
|
Timestamp: ts,
|
|
RawData: []byte(`{"id":"m-10"}`),
|
|
}
|
|
|
|
if !msg.IsGroup() {
|
|
t.Errorf("IsGroup() = false, want true")
|
|
}
|
|
if msg.ChannelType != ChannelGroup {
|
|
t.Errorf("ChannelType = %q, want %q", msg.ChannelType, ChannelGroup)
|
|
}
|
|
if msg.ContentType != ContentText {
|
|
t.Errorf("ContentType = %q, want %q", msg.ContentType, ContentText)
|
|
}
|
|
if len(msg.Media) != 1 || msg.Media[0].Kind != MediaImage {
|
|
t.Errorf("Media = %+v, want one image", msg.Media)
|
|
}
|
|
if len(msg.Mentions) != 1 || msg.Mentions[0].UserID != "u-2" {
|
|
t.Errorf("Mentions = %+v, want one user u-2", msg.Mentions)
|
|
}
|
|
if msg.ThreadID != "t-7" {
|
|
t.Errorf("ThreadID = %q, want t-7", msg.ThreadID)
|
|
}
|
|
if msg.Sender.Role != RoleOwner || !msg.Sender.IsOwner() {
|
|
t.Errorf("Sender = %+v, want owner role", msg.Sender)
|
|
}
|
|
if !msg.Timestamp.Equal(ts) {
|
|
t.Errorf("Timestamp = %v, want %v", msg.Timestamp, ts)
|
|
}
|
|
}
|
|
|
|
func TestUnifiedMessageValidate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
msg UnifiedMessage
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid direct text",
|
|
msg: UnifiedMessage{ChannelType: ChannelDirect, ContentType: ContentText, Content: "hi"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid group text",
|
|
msg: UnifiedMessage{
|
|
ChannelType: ChannelGroup, ContentType: ContentText, GroupID: "g-1", Content: "hi",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "group missing id",
|
|
msg: UnifiedMessage{
|
|
ChannelType: ChannelGroup, ContentType: ContentText, Content: "hi",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid channel type",
|
|
msg: UnifiedMessage{ChannelType: ChannelType("bogus"), ContentType: ContentText},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid content type",
|
|
msg: UnifiedMessage{ChannelType: ChannelDirect, ContentType: ContentType("bogus")},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
err := tt.msg.Validate()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChannelTypeValid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []ChannelType{ChannelDirect, ChannelGroup}
|
|
for _, ct := range valid {
|
|
if !ct.Valid() {
|
|
t.Errorf("ChannelType(%q).Valid() = false, want true", ct)
|
|
}
|
|
}
|
|
if ChannelType("").Valid() {
|
|
t.Error("empty ChannelType.Valid() = true, want false")
|
|
}
|
|
if ChannelType("bogus").Valid() {
|
|
t.Error("bogus ChannelType.Valid() = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestContentTypeValid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []ContentType{ContentText, ContentImage, ContentVoice, ContentFile, ContentAction}
|
|
for _, ct := range valid {
|
|
if !ct.Valid() {
|
|
t.Errorf("ContentType(%q).Valid() = false, want true", ct)
|
|
}
|
|
}
|
|
if ContentType("").Valid() {
|
|
t.Error("empty ContentType.Valid() = true, want false")
|
|
}
|
|
if ContentType("bogus").Valid() {
|
|
t.Error("bogus ContentType.Valid() = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestMentionedSelf(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mentions := []Mention{{UserID: "all"}, {UserID: "u-2"}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
selfID string
|
|
mentions []Mention
|
|
want bool
|
|
}{
|
|
{name: "platform has not provided self id", selfID: "", mentions: mentions, want: false},
|
|
{name: "no mentions", selfID: "u-2", mentions: nil, want: false},
|
|
{name: "points at someone else", selfID: "u-9", mentions: mentions, want: false},
|
|
{name: "points at her", selfID: "u-2", mentions: mentions, want: true},
|
|
{name: "at-all marker is not a user id", selfID: "all", mentions: nil, want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := MentionedSelf(tt.selfID, tt.mentions); got != tt.want {
|
|
t.Errorf("MentionedSelf(%q, %+v) = %v, want %v", tt.selfID, tt.mentions, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAddressedToSelf(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
msg UnifiedMessage
|
|
want bool
|
|
}{
|
|
{
|
|
name: "private chat is always addressed to her",
|
|
msg: UnifiedMessage{ChannelType: ChannelDirect, SelfID: "u-1"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "private chat without platform self id stays addressed",
|
|
msg: UnifiedMessage{ChannelType: ChannelDirect},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "group mentions her",
|
|
msg: UnifiedMessage{
|
|
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1",
|
|
Mentions: []Mention{{UserID: "u-1"}},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "group mentions someone else",
|
|
msg: UnifiedMessage{
|
|
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1",
|
|
Mentions: []Mention{{UserID: "u-2"}},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "group mentions all",
|
|
msg: UnifiedMessage{
|
|
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1", MentionAll: true,
|
|
Mentions: []Mention{{UserID: "all"}},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "group without mention",
|
|
msg: UnifiedMessage{ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "group without platform self id is fail-closed",
|
|
msg: UnifiedMessage{
|
|
ChannelType: ChannelGroup, GroupID: "g-1",
|
|
Mentions: []Mention{{UserID: "u-1"}},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "zero value stays fail-closed for adapters that do not wire it",
|
|
msg: UnifiedMessage{},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := tt.msg.AddressedToSelf(); got != tt.want {
|
|
t.Errorf("AddressedToSelf() = %v, want %v (msg=%+v)", got, tt.want, tt.msg)
|
|
}
|
|
})
|
|
}
|
|
}
|