package channelbridge import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // This file holds the channelbridge's chat-history persistence hooks (design/16 // §4.4): an owner private chat is routed INTO the single main web session and // tagged with the channel platform, then broadcast so the Web client mirrors it // without a refresh. Kept separate from channelbridge.go so the bridge's inbound // pipeline file stays under the module size bound. // SetOnPersisted wires (or replaces) the transport hook called after an // owner-private message is persisted into the main web session. The gateway is // built after the bridge in the composition root, so the hook is attached post-New // through this setter; it is a no-op-safe call and is safe for concurrent use with // in-flight processing. func (b *Bridge) SetOnPersisted(fn func(history.Message)) { b.mu.Lock() defer b.mu.Unlock() b.onPersisted = fn } // SetOnRejected wires (or replaces) the hook called after a message is dropped by // the allow/deny black-or-white lists (design/16 §4.6 §6). The composition root // routes it into the gateway audit trail. It is a no-op-safe call and is safe for // concurrent use with in-flight processing. func (b *Bridge) SetOnRejected(fn func(adapter.UnifiedMessage, string)) { b.mu.Lock() defer b.mu.Unlock() b.onRejected = fn } // notifyPersisted hands a just-persisted message to the transport hook when one is // wired, so the gateway can broadcast a `message_persisted` (web chat) or // `channel_persisted` (IM adapter log) event for cross-end realtime sync. func (b *Bridge) notifyPersisted(msg history.Message) { b.mu.RLock() fn := b.onPersisted b.mu.RUnlock() if fn != nil { fn(msg) } } // persistToMain writes one message into the main web session with an explicit source // platform and client deviceID, then notifies the transport hook so Web clients receive // a `message_persisted` event without a refresh (design/16 §4.4). deviceID is the // concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid" // for IM. adapterID is the account adapter registration name that received the message // (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream; // it is empty for Web-originated messages. It is a no-op when no history store is wired, // so the bridge runs unchanged without storage. func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, deviceID, adapterID string) { if b.history == nil { return } full, err := b.history.AppendFull(ctx, history.Message{ SessionID: history.MainSessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, }) if err != nil { b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID) return } b.notifyPersisted(full) } // persistIsolated writes one message into a restricted (group / stranger) session's // own platform log (design/16 §4.6 §4): the message lands in the isolated sess-xxx // session (NOT the main web session) and carries the channel platform, the client // deviceID, the sender's display name and the account adapter registration name, so // the IM adapter log page can render the non-owner message stream per adapter config. // The message is still NOT mirrored into the Web main session (only owner-private // messages mirror to the Web); it is merely handed to the transport hook so the // gateway broadcasts a `channel_persisted` event and the IM adapter log page appends // it without a refresh. It is a no-op when no history store is wired, so the bridge // runs unchanged without storage. func (b *Bridge) persistIsolated(ctx context.Context, role, content, deviceID string, sess session.Session, platform, senderName, adapterID string) { if b.history == nil { return } full, err := b.history.AppendFull(ctx, history.Message{ SessionID: sess.SessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID, SenderName: senderName, AdapterID: adapterID, }) if err != nil { b.log.Warn("persist isolated session", "err", err, "role", role, "session", sess.SessionID, "platform", platform, "sender_name", senderName, "adapter", adapterID) return } // Broadcast the isolated write as a `channel_persisted` event so the IM adapter // log page appends it in realtime. The gateway splits the event type by session: // this is NOT the main web session, so the web chat has no handler for it and the // message itself still lands only in the isolated sess-xxx session (never the // main/`web` session, design/16 §4.6 §3). b.notifyPersisted(full) }