diff --git a/bridge/jackbox/manager.go b/bridge/jackbox/manager.go index ecc3653..622270b 100644 --- a/bridge/jackbox/manager.go +++ b/bridge/jackbox/manager.go @@ -11,16 +11,17 @@ import ( // Manager handles the Jackbox integration lifecycle type Manager struct { - client *Client - webhookServer *WebhookServer - wsClient *WebSocketClient - config config.Config - log *logrus.Entry - enabled bool - useWebSocket bool - messageCallback func(string) - muted bool - mu sync.RWMutex + client *Client + webhookServer *WebhookServer + wsClient *WebSocketClient + config config.Config + log *logrus.Entry + enabled bool + useWebSocket bool + messageCallback func(string) + formattedMessageCallback func(string, string) + muted bool + mu sync.RWMutex } // NewManager creates a new Jackbox manager @@ -67,15 +68,15 @@ func (m *Manager) Initialize() error { return nil } -// StartWebhookServer starts the webhook server with the provided message callback -func (m *Manager) StartWebhookServer(messageCallback func(string)) error { +// StartWebhookServer starts the webhook server with the provided message callbacks +func (m *Manager) StartWebhookServer(messageCallback func(string), formattedCallback func(string, string)) error { if !m.enabled { return nil } // Use WebSocket if enabled, otherwise fall back to webhook if m.useWebSocket { - return m.startWebSocketClient(messageCallback) + return m.startWebSocketClient(messageCallback, formattedCallback) } webhookPort := m.config.Viper().GetInt("jackbox.WebhookPort") @@ -102,11 +103,12 @@ func (m *Manager) StartWebhookServer(messageCallback func(string)) error { } // startWebSocketClient starts the WebSocket client connection -func (m *Manager) startWebSocketClient(messageCallback func(string)) error { +func (m *Manager) startWebSocketClient(messageCallback func(string), formattedCallback func(string, string)) error { apiURL := m.config.Viper().GetString("jackbox.APIURL") - // Store the callback for use in monitoring + // Store the callbacks for use in monitoring and reconnection m.messageCallback = messageCallback + m.formattedMessageCallback = formattedCallback // Wrap the callback to check mute status wrappedCallback := func(message string) { @@ -116,6 +118,15 @@ func (m *Manager) startWebSocketClient(messageCallback func(string)) error { } messageCallback(message) } + + // Wrap the formatted callback to check mute status + wrappedFormattedCallback := func(ircMsg, plainMsg string) { + if m.IsMuted() { + m.log.Debugf("Jackbox formatted message suppressed (muted): %s", plainMsg) + return + } + formattedCallback(ircMsg, plainMsg) + } // Set wrapped callback on client for vote broadcasts m.client.SetMessageCallback(wrappedCallback) @@ -141,7 +152,7 @@ func (m *Manager) startWebSocketClient(messageCallback func(string)) error { m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay")) // Create WebSocket client (pass the API client for vote tracking) - m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, m.client, enableRoomCodeImage, imageDelay, plaintextDelay, m.log) + m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, wrappedFormattedCallback, m.client, enableRoomCodeImage, imageDelay, plaintextDelay, m.log) // Connect to WebSocket if err := m.wsClient.Connect(); err != nil { @@ -265,12 +276,12 @@ func (m *Manager) Reconnect() error { return fmt.Errorf("re-authentication failed: %w", err) } - // Rebuild the WebSocket client using the original callback + // Rebuild the WebSocket client using the original callbacks if m.messageCallback == nil { return fmt.Errorf("no message callback registered") } - return m.startWebSocketClient(m.messageCallback) + return m.startWebSocketClient(m.messageCallback, m.formattedMessageCallback) } // GetClient returns the Jackbox API client (may be nil if disabled) diff --git a/bridge/jackbox/websocket_client.go b/bridge/jackbox/websocket_client.go index 980eacb..688f0d4 100644 --- a/bridge/jackbox/websocket_client.go +++ b/bridge/jackbox/websocket_client.go @@ -17,23 +17,24 @@ const ( // WebSocketClient handles WebSocket connection to Jackbox API type WebSocketClient struct { - apiURL string - token string - conn *websocket.Conn - messageCallback func(string) - apiClient *Client // Reference to API client for vote tracking - log *logrus.Entry - mu sync.Mutex - reconnectDelay time.Duration - maxReconnect time.Duration - stopChan chan struct{} - listenDone chan struct{} // closed when the current listen() goroutine exits - connected bool - authenticated bool - subscribedSession int - enableRoomCodeImage bool // Whether to upload room code images to Kosmi - roomCodeImageDelay time.Duration // Delay before sending image announcement - roomCodePlaintextDelay time.Duration // Delay before sending plaintext room code + apiURL string + token string + conn *websocket.Conn + messageCallback func(string) + formattedMessageCallback func(string, string) // (ircMsg, plainMsg) + apiClient *Client // Reference to API client for vote tracking + log *logrus.Entry + mu sync.Mutex + reconnectDelay time.Duration + maxReconnect time.Duration + stopChan chan struct{} + listenDone chan struct{} // closed when the current listen() goroutine exits + connected bool + authenticated bool + subscribedSession int + enableRoomCodeImage bool // Whether to upload room code images to Kosmi + roomCodeImageDelay time.Duration // Delay before sending image announcement + roomCodePlaintextDelay time.Duration // Delay before sending plaintext room code } // WebSocket message types @@ -74,20 +75,28 @@ type GameAddedData struct { } `json:"game"` } +// PollEndingData represents the poll.ending event data +type PollEndingData struct { + SessionID int `json:"sessionId"` + EndsAt string `json:"endsAt"` + DelaySeconds int `json:"delaySeconds"` +} + // NewWebSocketClient creates a new WebSocket client -func NewWebSocketClient(apiURL, token string, messageCallback func(string), apiClient *Client, enableRoomCodeImage bool, roomCodeImageDelay, roomCodePlaintextDelay time.Duration, log *logrus.Entry) *WebSocketClient { +func NewWebSocketClient(apiURL, token string, messageCallback func(string), formattedMessageCallback func(string, string), apiClient *Client, enableRoomCodeImage bool, roomCodeImageDelay, roomCodePlaintextDelay time.Duration, log *logrus.Entry) *WebSocketClient { return &WebSocketClient{ - apiURL: apiURL, - token: token, - messageCallback: messageCallback, - apiClient: apiClient, - enableRoomCodeImage: enableRoomCodeImage, - roomCodeImageDelay: roomCodeImageDelay, - roomCodePlaintextDelay: roomCodePlaintextDelay, - log: log, - reconnectDelay: 1 * time.Second, - maxReconnect: 30 * time.Second, - stopChan: make(chan struct{}), + apiURL: apiURL, + token: token, + messageCallback: messageCallback, + formattedMessageCallback: formattedMessageCallback, + apiClient: apiClient, + enableRoomCodeImage: enableRoomCodeImage, + roomCodeImageDelay: roomCodeImageDelay, + roomCodePlaintextDelay: roomCodePlaintextDelay, + log: log, + reconnectDelay: 1 * time.Second, + maxReconnect: 30 * time.Second, + stopChan: make(chan struct{}), } } @@ -249,6 +258,18 @@ func (c *WebSocketClient) handleMessage(data []byte) { case "session.ended": c.handleSessionEnded(msg.Data) + case "poll.start": + c.handlePollStart() + + case "poll.ending": + c.handlePollEnding(msg.Data) + + case "voting.ended": + c.handleVotingEnded() + + case "poll.ending.cancelled": + c.log.Info("Poll ending cancelled, voting remains open") + case "pong": c.log.Debug("Heartbeat pong received") @@ -450,6 +471,46 @@ func (c *WebSocketClient) AnnounceSessionEnd() { } } +// handlePollStart announces that a new poll has opened +func (c *WebSocketClient) handlePollStart() { + c.log.Info("Poll started") + + ircMsg := "🗳️ There is a new poll open! Your vote is \x02REQUIRED\x02 https://hso.ehsf.cc/vote" + plainMsg := "🗳️ There is a new poll open! Your vote is *REQUIRED* https://hso.ehsf.cc/vote" + + if c.formattedMessageCallback != nil { + c.formattedMessageCallback(ircMsg, plainMsg) + } +} + +// handlePollEnding announces that the poll is about to close +func (c *WebSocketClient) handlePollEnding(data json.RawMessage) { + var pollData PollEndingData + if err := json.Unmarshal(data, &pollData); err != nil { + c.log.Errorf("Failed to parse poll.ending data: %v", err) + return + } + + c.log.Infof("Poll ending in %d seconds", pollData.DelaySeconds) + + ircMsg := fmt.Sprintf("🗳️ \x02HURRY!\x02 https://hso.ehsf.cc/vote Go vote now, the poll is closing in %d seconds", pollData.DelaySeconds) + plainMsg := fmt.Sprintf("🗳️ *HURRY!* https://hso.ehsf.cc/vote Go vote now, the poll is closing in %d seconds", pollData.DelaySeconds) + + if c.formattedMessageCallback != nil { + c.formattedMessageCallback(ircMsg, plainMsg) + } +} + +// handleVotingEnded announces that the poll has closed +func (c *WebSocketClient) handleVotingEnded() { + c.log.Info("Voting ended") + + message := "🗳️ You missed your chance to vote, do your duty next time" + if c.messageCallback != nil { + c.messageCallback(message) + } +} + // startHeartbeat sends ping messages periodically. It exits when listenDone // is closed (current connection ended) or stopChan is closed (full shutdown). func (c *WebSocketClient) startHeartbeat(listenDone <-chan struct{}) { diff --git a/docs/POLL.md b/docs/POLL.md new file mode 100644 index 0000000..25e36af --- /dev/null +++ b/docs/POLL.md @@ -0,0 +1,137 @@ +# Poll WebSocket Messages + +## Overview + +Poll messages manage the lifecycle of viewer polls over the upstream WebSocket connection. They are session-scoped — you must be subscribed to the relevant session to send or receive them. + +All messages use the standard envelope: + +```json +{ + "type": "", + "data": { ... } +} +``` + +--- + +## Message Flow + +``` +poll.start ──► (poll is open, viewers are voting) + │ + poll.ending ──► (countdown active) + │ + ┌───────────┴───────────┐ + │ │ + voting.ended poll.ending.cancelled + (poll is closed) (countdown aborted, + poll stays open) +``` + +--- + +## Client → Server + +### poll.start + +Triggers poll generation for the active session. + +```json +{ + "type": "poll.start", + "sessionId": 3 +} +``` + +| Field | Type | Required | Description | +|-------------|--------|----------|-----------------------------------------------------------------------------| +| `type` | string | yes | `"poll.start"` | +| `sessionId` | number | no | Included for protocol consistency. The server uses its internally tracked session ID. | + +Any existing active poll is deactivated and replaced. + +### poll.leading + +Sent by the client to report the current leading option. Typically sent whenever the lead changes. + +```json +{ + "type": "poll.leading", + "sessionId": 3, + "gameId": 17, + "label": "Quiplash 3", + "votes": 12 +} +``` + +| Field | Type | Required | Description | +|-------------|--------|----------|---------------------------------------| +| `type` | string | yes | `"poll.leading"` | +| `sessionId` | number | yes | Active session ID | +| `gameId` | number | yes | Game ID of the leading option (0 for "Other") | +| `label` | string | yes | Display label of the leading option | +| `votes` | number | yes | Current vote count for the leader | + +--- + +## Server → Client + +### poll.ending + +Signals that voting will close after a countdown. Sent when the server initiates poll closure. + +```json +{ + "type": "poll.ending", + "data": { + "sessionId": 3, + "endsAt": "2026-05-13T02:20:30.123456789Z", + "delaySeconds": 30 + } +} +``` + +| Field | Type | Description | +|----------------|--------|--------------------------------------------------| +| `sessionId` | number | Active session ID | +| `endsAt` | string | RFC 3339 timestamp when voting closes | +| `delaySeconds` | number | Seconds remaining until voting closes | + +### poll.ending.cancelled + +The previously announced countdown has been cancelled. Voting remains open. + +```json +{ + "type": "poll.ending.cancelled", + "data": { + "sessionId": 3 + } +} +``` + +| Field | Type | Description | +|-------------|--------|-------------------| +| `sessionId` | number | Active session ID | + +### voting.ended + +Voting has closed. The active poll is finalized. + +```json +{ + "type": "voting.ended" +} +``` + +No `data` payload. The server does not include results — consumers should query their own tallies or rely on the preceding `poll.leading` updates for the final state. + +--- + +## Lifecycle Notes + +- A `poll.start` always replaces any existing active poll. +- `poll.ending` may be followed by either `voting.ended` (normal close) or `poll.ending.cancelled` (countdown aborted). +- After `voting.ended`, no further vote-related messages are sent until the next `poll.start`. +- `game.started` implicitly deactivates any active poll — no explicit poll message is sent. diff --git a/gateway/router.go b/gateway/router.go index 14a0454..0592eac 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -119,7 +119,7 @@ func (r *Router) Start() error { // Start webhook server if Jackbox is enabled if r.JackboxManager.IsEnabled() { - if err := r.JackboxManager.StartWebhookServer(r.broadcastJackboxMessage); err != nil { + if err := r.JackboxManager.StartWebhookServer(r.broadcastJackboxMessage, r.broadcastJackboxFormattedMessage); err != nil { r.logger.Errorf("Failed to start Jackbox webhook server: %v", err) } } @@ -274,3 +274,34 @@ func (r *Router) broadcastJackboxMessage(message string) { } } } + +// broadcastJackboxFormattedMessage broadcasts per-bridge formatted messages. +// IRC bridges receive ircMsg (with IRC control codes), all others receive plainMsg. +func (r *Router) broadcastJackboxFormattedMessage(ircMsg, plainMsg string) { + if r.JackboxManager != nil && r.JackboxManager.IsMuted() { + r.logger.Debugf("Jackbox formatted message suppressed (muted): %s", plainMsg) + return + } + + r.logger.Infof("Broadcasting formatted Jackbox message: %s", plainMsg) + + for _, gw := range r.Gateways { + for _, br := range gw.Bridges { + text := plainMsg + if br.Protocol == "irc" { + text = ircMsg + } + + msg := config.Message{ + Text: " " + text, + Username: "Jackbox", + Account: "jackbox", + Event: config.EventUserAction, + } + + if _, err := br.Send(msg); err != nil { + r.logger.Errorf("Failed to send formatted Jackbox message to %s: %v", br.Account, err) + } + } + } +}