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" ) // wsProactiveMessage is the WS event type for an autonomous proactive push // (design/12 ยง3): a message Cyrene decided to send without a recent user input. const wsProactiveMessage = "proactive_message" // proactivePayload is the sanitized wire shape of a proactive message. It carries // only the fields a client needs to render a bubble (content, platform, channel, // sender label); it deliberately omits RawData / media bytes / tokens so a // proactive push can never leak a credential or payload into the frontend. 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"` } // SendProactive delivers msg to every live owner/admin connection and returns how // many accepted it. A zero count means no owner/admin client is online, so the // caller falls back to the IM bridge. Only owner/admin connections are targeted // (red line: proactive is owner-only); a viewer or unauthenticated connection is // never reached. It never blocks: a full send buffer drops the frame for that // slow client rather than stalling the backthink push. func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) { if err := ctx.Err(); err != nil { return 0, err } b, err := json.Marshal(wsEvent{Type: wsProactiveMessage, Data: proactivePayloadFrom(msg)}) if err != nil { return 0, 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 n, nil } // Server.SendProactive satisfies the backthink ActiveClient seam by delegating to // the hub, so the composition root can hand the gateway itself to the sink setter. func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) { return s.hub.SendProactive(ctx, msg) } // proactivePayloadFrom projects a UnifiedMessage into the sanitized wire payload. func proactivePayloadFrom(msg adapter.UnifiedMessage) 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, } } // ownerAdminClaims reports whether claims authorize a proactive push (owner or // admin). Nil claims (unauth / bare test connections) are never targeted. func ownerAdminClaims(claims *auth.Claims) bool { if claims == nil { return false } return claims.Role == auth.RoleOwner || claims.Role == auth.RoleAdmin }