4 处注释写「帧顶」而代码放在 data[proactive](+ 测试文件 6 处同源表述)。 安卓端两层都读所以不受影响,但注释与代码相反会误导下一个按注释写的人。 零行为改动(仅注释)。
147 lines
6.4 KiB
Go
147 lines
6.4 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// 本文件锁定切片 P0b:**IM 路由的主动消息也要在「非前台」补一发浏览器通知**。
|
||
//
|
||
// 背景(P0 如实报回的边界):`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM
|
||
// 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上,于是
|
||
// **实际最可能发生的路径恰恰不弹**。主人裁定「她说话该不该提醒我,与走哪条路由无关」。
|
||
//
|
||
// 判据是**结构化**的:主动推送在落库之后追加一条**帧的 `data` 里带 `proactive:true`** 的
|
||
// `message_persisted`(gateway.BroadcastProactivePersisted)。普通消息(Web 回合 / IM
|
||
// 回复 / IM 镜像)走的仍是 BroadcastPersisted,那条路**永不**放该键 —— 因此普通消息
|
||
// 不可能被误判成主动消息。下面分别钉住两端:IM 路由真的发得出这条带判据的帧,而普通
|
||
// 广播路径永远发不出它。
|
||
|
||
// dataFlag 报告帧的 `data` 里是否带 proactive 判据(以及它的值)。
|
||
func dataFlag(t *testing.T, ev wsEvent) (any, bool) {
|
||
t.Helper()
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
|
||
}
|
||
v, has := data["proactive"]
|
||
return v, has
|
||
}
|
||
|
||
// frameMessageID 取帧内消息 DTO 的 id。
|
||
func frameMessageID(t *testing.T, ev wsEvent) string {
|
||
t.Helper()
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
|
||
}
|
||
msg, ok := data["message"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame message type = %T, want map[string]any", data["message"])
|
||
}
|
||
id, _ := msg["id"].(string)
|
||
return id
|
||
}
|
||
|
||
// TestChannelBridge_SendProactive_EmitsProactiveFlaggedFrame 是切片 P0b 的端到端证据:
|
||
// IM 路由的主动消息**既**照旧广播普通镜像帧(P0 起的行为,任何客户端都能渲染),**又**
|
||
// 追加一条帧的 `data` 里带 `proactive:true`、并携带落库行权威 id 的帧 —— 前端据此走与
|
||
// `proactive_message` 同一套提醒逻辑(非前台补一发通知,两帧同 id ⇒ 去重后一个气泡、
|
||
// 一次提醒)。它同时守住 P0 的红线:主会话里仍**恰好一条**(绝无双份)。
|
||
func TestChannelBridge_SendProactive_EmitsProactiveFlaggedFrame(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
store := newHistoryStoreForGateway(t)
|
||
env.srv.hub.history = store
|
||
owner := newOwnerConn(env.srv.hub, 8)
|
||
env.srv.hub.register(owner)
|
||
// claims 为 nil 的裸连接是 fail-closed 的,与普通持久化帧同一条角色门。
|
||
bare := newFakeConn(env.srv.hub, 8)
|
||
env.srv.hub.register(bare)
|
||
|
||
ownerProfile := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}}
|
||
bridge, err := channelbridge.New(channelbridge.Options{
|
||
Log: gwTestLogger(),
|
||
Loop: stubBridgeChatter{},
|
||
Sessions: stubBridgeSessions{},
|
||
Owner: ownerProfile,
|
||
History: store,
|
||
OnPersisted: env.srv.BroadcastPersisted,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("new bridge: %v", err)
|
||
}
|
||
bridge.SetOnProactivePersisted(env.srv.BroadcastProactivePersisted)
|
||
bridge.Add(&fakeIMAdapter{platform: "telegram"})
|
||
|
||
if err := bridge.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦")); err != nil {
|
||
t.Fatalf("SendProactive err = %v", err)
|
||
}
|
||
rows := mainSessionRows(t, store)
|
||
if len(rows) != 1 {
|
||
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
|
||
}
|
||
|
||
// 第一帧:普通镜像帧(P0 起的行为),**不带**判据。
|
||
first := readFakeFrame(t, owner)
|
||
if first.Type != "message_persisted" {
|
||
t.Fatalf("frame 1 type = %q, want message_persisted", first.Type)
|
||
}
|
||
if v, has := dataFlag(t, first); has {
|
||
t.Fatalf("普通镜像帧带上了 proactive=%v,want 该键缺席(判据只属于主动那条)", v)
|
||
}
|
||
|
||
// 第二帧:主动帧 —— 帧的 `data` 里带判据,且消息 id 就是落库行的权威 id。
|
||
second := readFakeFrame(t, owner)
|
||
if second.Type != "message_persisted" {
|
||
t.Fatalf("frame 2 type = %q, want message_persisted(帧类型不变,只多一个结构化键)", second.Type)
|
||
}
|
||
if v, has := dataFlag(t, second); !has || v != true {
|
||
t.Fatalf("主动帧的 proactive 判据 = %v(present=%v),want true", v, has)
|
||
}
|
||
if got := frameMessageID(t, second); got != rows[0].ID {
|
||
t.Fatalf("主动帧的消息 id = %q, want 落库行的权威 id %q(前端去重与对齐都靠它)", got, rows[0].ID)
|
||
}
|
||
if err := connHasNoFrame(owner); err != nil {
|
||
t.Fatalf("owner 连接应恰好收到两帧(普通 + 主动),多出来的:%v", err)
|
||
}
|
||
if err := connHasNoFrame(bare); err != nil {
|
||
t.Fatalf("主动帧只投递给 owner/admin,裸连接不该收到:%v", err)
|
||
}
|
||
}
|
||
|
||
// TestServer_BroadcastPersisted_NeverCarriesProactiveFlag 是「不误伤」那一条的后端对照:
|
||
// 普通广播路径(Web 回合 / IM 回复 / IM 镜像全走它)**永远**不在帧的 `data` 里放 proactive 判据,
|
||
// 因此客户端不可能把普通消息误判成主动消息去弹通知。同一行改走主动那条入口时,判据才
|
||
// 出现 —— 两条入口的差别只有这一个键。
|
||
func TestServer_BroadcastPersisted_NeverCarriesProactiveFlag(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
owner := newOwnerConn(env.srv.hub, 8)
|
||
env.srv.hub.register(owner)
|
||
|
||
row := history.Message{
|
||
ID: "row-plain", SessionID: history.MainSessionID, Role: history.RoleAssistant,
|
||
Content: "普通 IM 回复", Platform: "telegram", AdapterID: "telegram:100", TurnID: "turn-abc", CreatedAt: 1_760_000_000_000,
|
||
}
|
||
env.srv.BroadcastPersisted(row)
|
||
plain := readFakeFrame(t, owner)
|
||
if plain.Type != "message_persisted" {
|
||
t.Fatalf("frame type = %q, want message_persisted", plain.Type)
|
||
}
|
||
if v, has := dataFlag(t, plain); has {
|
||
t.Fatalf("普通消息的帧的 `data` 里带上了 proactive=%v,want 该键缺席(否则会被误判成主动消息)", v)
|
||
}
|
||
|
||
env.srv.BroadcastProactivePersisted(row)
|
||
flagged := readFakeFrame(t, owner)
|
||
if v, has := dataFlag(t, flagged); !has || v != true {
|
||
t.Fatalf("主动入口的 proactive 判据 = %v(present=%v),want true", v, has)
|
||
}
|
||
if got := frameMessageID(t, flagged); got != "row-plain" {
|
||
t.Fatalf("主动帧的消息 id = %q, want row-plain", got)
|
||
}
|
||
}
|