package gateway import ( "context" "encoding/json" "fmt" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) // wsProactiveMessage 是自主主动推送的 WS 事件类型 // (design/12 §3):Cyrene 在没有近期用户输入时决定发送的消息。 const wsProactiveMessage = "proactive_message" // proactivePayload 是主动消息消毒后的线上结构。它只 // 携带客户端渲染气泡所需的字段(content、platform、channel、 // 发送者标签);它刻意省略 RawData / 媒体字节 / token,使 // 主动推送绝不可能把凭据或载荷泄露进前端。 type proactivePayload struct { Platform string `json:"platform,omitempty"` ChannelID string `json:"channelId,omitempty"` ChannelType string `json:"channelType,omitempty"` Content string `json:"content"` ContentType string `json:"contentType,omitempty"` Sender string `json:"sender,omitempty"` Timestamp time.Time `json:"timestamp,omitempty"` MessageID string `json:"messageId,omitempty"` // ID 是这条主动消息在**主会话历史**里的权威行 id(切片 P0「气泡恒落」)。 // 客户端据此把实时气泡对齐到落库行:与握手回放 / REST 历史拉取按 id 去重 // (同一条绝不渲染两个气泡),刷新后仍在。落库失败时该键**省略** —— // 客户端因此知道这条只有实时帧、没有历史行,而不是拿到一个悬空的 id。 ID string `json:"id,omitempty"` // CreatedAt 是落库行的时间戳(Unix 毫秒),与 ID 同时出现/同时省略。它是 // 权威的排序时间(与 `timestamp` 是同一次推送的两个视角:前者是历史行的 // 时间,后者是这条统一消息自身的时间)。 CreatedAt int64 `json:"createdAt,omitempty"` } // SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回投递与落库 // 两件事的结果(切片 P0,`backthink.ProactiveDelivery`)。 // // 顺序是刻意的 —— **先落库、再发帧**:主动消息必须成为主会话 // (`history.MainSessionID`)里的一条真实消息(assistant 角色),否则刷新/ // 重载后什么都补不回来(这正是「气泡恒落」的根);而帧要带上落库行的 id, // 客户端才能把实时气泡对齐到历史行。 // // 落库失败**不静默**:记一条带来源的 WARN,并把错误返回给调用方,同时 // Persisted=false —— 帧照发(实时气泡不能丢),但记录里绝不说「已推送」。 // // 计数为零意味着没有 owner/admin 客户端在线;消息此时仍会作为一条真实记录 // 留在主会话里(下次打开就能看到),调用方据此回退到 IM bridge 把它**投出去**。 // 只有 owner/admin 连接会被作为目标(红线:主动推送仅 owner);viewer 或未认证 // 连接绝不被触达。它绝不阻塞:发送缓冲满时对该慢客户端丢弃该帧, // 而不是卡住后台思考推送。 func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) { if err := ctx.Err(); err != nil { return backthink.ProactiveDelivery{}, err } persisted, persistErr := h.persistProactiveToMain(ctx, msg) if persistErr != nil { h.log.Warn("persist proactive message to main session failed", "err", persistErr, "platform", msg.Platform) } b, err := json.Marshal(wsEvent{Type: wsProactiveMessage, Data: proactivePayloadFrom(msg, persisted)}) if err != nil { return backthink.ProactiveDelivery{Persisted: persisted.ID != ""}, fmt.Errorf("gateway: marshal proactive: %w", err) } h.mu.RLock() defer h.mu.RUnlock() n := 0 for c := range h.conns { if !ownerAdminClaims(c.claims) { continue } select { case c.send <- b: n++ default: } } return backthink.ProactiveDelivery{Connections: n, Persisted: persisted.ID != ""}, persistErr } // Server.SendProactive 通过委托给 hub 满足后台思考的 ActiveClient // 接缝,因此组合根能把网关自身交给汇聚点设置器。 func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) { return s.hub.SendProactive(ctx, msg) } // proactivePayloadFrom 把 UnifiedMessage 投影为消毒后的线上载荷。 // persisted 是刚落库的主会话消息(未接线历史存储或落库失败时为零值), // 它的 id/时间戳随之进入载荷,使客户端能把实时气泡对齐到历史行。 func proactivePayloadFrom(msg adapter.UnifiedMessage, persisted history.Message) proactivePayload { return proactivePayload{ Platform: msg.Platform, ChannelID: msg.ChannelID, ChannelType: string(msg.ChannelType), Content: msg.Content, ContentType: string(msg.ContentType), Sender: msg.Sender.DisplayName, Timestamp: msg.Timestamp, MessageID: msg.MessageID, ID: persisted.ID, CreatedAt: persisted.CreatedAt, } } // ownerAdminClaims 报告 claims 是否授权一次主动推送(owner 或 // admin)。nil claims(未认证 / 裸测试连接)绝不被作为目标。 func ownerAdminClaims(claims *auth.Claims) bool { if claims == nil { return false } return claims.Role == auth.RoleOwner || claims.Role == auth.RoleAdmin }