feat: announce poll lifecycle events to IRC and Kosmi

Handle poll.start, poll.ending, voting.ended, and poll.ending.cancelled
WebSocket messages from the upstream GamePicker API. Broadcasts opening,
closing-countdown, and closed announcements with per-bridge bold
formatting (IRC control codes vs asterisks for Kosmi).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-05-12 22:48:01 -04:00
parent c88b75f30d
commit c5e0fe06b0
4 changed files with 288 additions and 48 deletions

View File

@@ -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)

View File

@@ -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{}) {