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) } }