package poster import ( "log/slog" "sync" "time" "pete/internal/storage" "maunium.net/go/mautrix/id" ) // ReactionCallback is an optional hook fired after a reaction is recorded. // Used to wire reaction-driven features (e.g. ❓ → summary) without coupling // poster to those packages. type ReactionCallback func(roomID id.RoomID, targetEventID id.EventID, guid, channel, emoji string) var ( reactionCallbackMu sync.RWMutex reactionCallback ReactionCallback // reactionSem bounds concurrent in-flight reaction callbacks. Each callback // may do DB / Matrix / LLM I/O, so an unbounded fan-out (`go cb(...)` per // reaction) lets a reaction flood — legitimate burst or an abusive room // member spamming emoji — exhaust goroutines, connections, and memory. // Tries to acquire non-blocking: if the cap is saturated we drop the // callback with a warn rather than queueing, on the theory that the room // is already busy enough. reactionSem = make(chan struct{}, 8) ) // SetReactionCallback installs a hook called after every recorded reaction. // Pass nil to clear. Safe to call before or after the matrix client starts. func SetReactionCallback(fn ReactionCallback) { reactionCallbackMu.Lock() reactionCallback = fn reactionCallbackMu.Unlock() } // HandleReaction processes a reaction event by mapping it back to the story GUID. func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID) { guid, channel, found := storage.LookupPostGUID(string(targetEventID)) if !found { slog.Info("reaction on event not in post_log, ignoring", "target_event", targetEventID, "emoji", emoji, "user", userID) return } storage.InsertReaction(guid, channel, string(eventID), emoji, string(userID), time.Now().Unix()) slog.Debug("reaction recorded", "guid", guid, "channel", channel, "emoji", emoji, "user", userID, ) reactionCallbackMu.RLock() cb := reactionCallback reactionCallbackMu.RUnlock() if cb == nil { return } select { case reactionSem <- struct{}{}: default: slog.Warn("reaction callback dropped: worker pool saturated", "guid", guid, "channel", channel, "emoji", emoji) return } go func() { defer func() { <-reactionSem }() cb(roomID, targetEventID, guid, channel, emoji) }() }