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:
@@ -19,6 +19,7 @@ type Manager struct {
|
|||||||
enabled bool
|
enabled bool
|
||||||
useWebSocket bool
|
useWebSocket bool
|
||||||
messageCallback func(string)
|
messageCallback func(string)
|
||||||
|
formattedMessageCallback func(string, string)
|
||||||
muted bool
|
muted bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -67,15 +68,15 @@ func (m *Manager) Initialize() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartWebhookServer starts the webhook server with the provided message callback
|
// StartWebhookServer starts the webhook server with the provided message callbacks
|
||||||
func (m *Manager) StartWebhookServer(messageCallback func(string)) error {
|
func (m *Manager) StartWebhookServer(messageCallback func(string), formattedCallback func(string, string)) error {
|
||||||
if !m.enabled {
|
if !m.enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use WebSocket if enabled, otherwise fall back to webhook
|
// Use WebSocket if enabled, otherwise fall back to webhook
|
||||||
if m.useWebSocket {
|
if m.useWebSocket {
|
||||||
return m.startWebSocketClient(messageCallback)
|
return m.startWebSocketClient(messageCallback, formattedCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookPort := m.config.Viper().GetInt("jackbox.WebhookPort")
|
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
|
// 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")
|
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.messageCallback = messageCallback
|
||||||
|
m.formattedMessageCallback = formattedCallback
|
||||||
|
|
||||||
// Wrap the callback to check mute status
|
// Wrap the callback to check mute status
|
||||||
wrappedCallback := func(message string) {
|
wrappedCallback := func(message string) {
|
||||||
@@ -117,6 +119,15 @@ func (m *Manager) startWebSocketClient(messageCallback func(string)) error {
|
|||||||
messageCallback(message)
|
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
|
// Set wrapped callback on client for vote broadcasts
|
||||||
m.client.SetMessageCallback(wrappedCallback)
|
m.client.SetMessageCallback(wrappedCallback)
|
||||||
|
|
||||||
@@ -141,7 +152,7 @@ func (m *Manager) startWebSocketClient(messageCallback func(string)) error {
|
|||||||
m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay"))
|
m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay"))
|
||||||
|
|
||||||
// Create WebSocket client (pass the API client for vote tracking)
|
// 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
|
// Connect to WebSocket
|
||||||
if err := m.wsClient.Connect(); err != nil {
|
if err := m.wsClient.Connect(); err != nil {
|
||||||
@@ -265,12 +276,12 @@ func (m *Manager) Reconnect() error {
|
|||||||
return fmt.Errorf("re-authentication failed: %w", err)
|
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 {
|
if m.messageCallback == nil {
|
||||||
return fmt.Errorf("no message callback registered")
|
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)
|
// GetClient returns the Jackbox API client (may be nil if disabled)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type WebSocketClient struct {
|
|||||||
token string
|
token string
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
messageCallback func(string)
|
messageCallback func(string)
|
||||||
|
formattedMessageCallback func(string, string) // (ircMsg, plainMsg)
|
||||||
apiClient *Client // Reference to API client for vote tracking
|
apiClient *Client // Reference to API client for vote tracking
|
||||||
log *logrus.Entry
|
log *logrus.Entry
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -74,12 +75,20 @@ type GameAddedData struct {
|
|||||||
} `json:"game"`
|
} `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
|
// 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{
|
return &WebSocketClient{
|
||||||
apiURL: apiURL,
|
apiURL: apiURL,
|
||||||
token: token,
|
token: token,
|
||||||
messageCallback: messageCallback,
|
messageCallback: messageCallback,
|
||||||
|
formattedMessageCallback: formattedMessageCallback,
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
enableRoomCodeImage: enableRoomCodeImage,
|
enableRoomCodeImage: enableRoomCodeImage,
|
||||||
roomCodeImageDelay: roomCodeImageDelay,
|
roomCodeImageDelay: roomCodeImageDelay,
|
||||||
@@ -249,6 +258,18 @@ func (c *WebSocketClient) handleMessage(data []byte) {
|
|||||||
case "session.ended":
|
case "session.ended":
|
||||||
c.handleSessionEnded(msg.Data)
|
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":
|
case "pong":
|
||||||
c.log.Debug("Heartbeat pong received")
|
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
|
// startHeartbeat sends ping messages periodically. It exits when listenDone
|
||||||
// is closed (current connection ended) or stopChan is closed (full shutdown).
|
// is closed (current connection ended) or stopChan is closed (full shutdown).
|
||||||
func (c *WebSocketClient) startHeartbeat(listenDone <-chan struct{}) {
|
func (c *WebSocketClient) startHeartbeat(listenDone <-chan struct{}) {
|
||||||
|
|||||||
137
docs/POLL.md
Normal file
137
docs/POLL.md
Normal file
@@ -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": "<message-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.
|
||||||
@@ -119,7 +119,7 @@ func (r *Router) Start() error {
|
|||||||
|
|
||||||
// Start webhook server if Jackbox is enabled
|
// Start webhook server if Jackbox is enabled
|
||||||
if r.JackboxManager.IsEnabled() {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user