Add !votes command, fix vote tally timing, and improve Kosmi stability

- Add !votes command (IRC + Kosmi) showing per-session and all-time vote
  breakdowns for the current game via new Jackbox API endpoints
  (GET sessions/{id}/games, sessions/{id}/votes, games/{id})
- Fix vote tally broadcasting: remove debounce timer, announce tallies
  only at game transitions or session end instead of after every vote
- Add !kreconnect IRC command to manually trigger Kosmi reconnection
- Add WebSocket ping/pong keepalive and write mutex to Kosmi client
  for connection stability
- Add watchConnection() auto-reconnect on unexpected Kosmi disconnects
- Remove old 2025-10-31 chat summaries; add votes command design doc

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-16 20:56:18 -04:00
parent 1831b0e923
commit 88cc140087
15 changed files with 536 additions and 1398 deletions

View File

@@ -14,10 +14,14 @@ import (
)
const (
kosmiWSURL = "wss://engine.kosmi.io/gql-ws"
kosmiWSURL = "wss://engine.kosmi.io/gql-ws"
kosmiHTTPURL = "https://engine.kosmi.io/"
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
appVersion = "4364"
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
appVersion = "4364"
pingInterval = 30 * time.Second
pongTimeout = 90 * time.Second
writeWait = 10 * time.Second
)
// GraphQL-WS Protocol message types
@@ -40,6 +44,7 @@ type GraphQLWSClient struct {
messageCallback func(*NewMessagePayload)
connected bool
mu sync.RWMutex
writeMu sync.Mutex
done chan struct{}
}
@@ -208,9 +213,17 @@ func (c *GraphQLWSClient) Connect() error {
c.connected = true
c.mu.Unlock()
// Set up ping/pong keepalive
conn.SetReadDeadline(time.Now().Add(pongTimeout))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongTimeout))
return nil
})
c.log.Info("Native WebSocket client connected and ready")
// Start message listener
// Start keepalive pinger and message listener
go c.startPing()
go c.listenForMessages()
return nil
@@ -359,7 +372,10 @@ func (c *GraphQLWSClient) SendMessage(text string) error {
},
}
if err := c.conn.WriteJSON(msg); err != nil {
c.writeMu.Lock()
err := c.conn.WriteJSON(msg)
c.writeMu.Unlock()
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
@@ -396,3 +412,33 @@ func (c *GraphQLWSClient) IsConnected() bool {
return c.connected
}
// Done returns a channel that is closed when the client disconnects
func (c *GraphQLWSClient) Done() <-chan struct{} {
return c.done
}
// startPing sends WebSocket ping frames at a regular interval to keep the
// connection alive and detect stale connections early.
func (c *GraphQLWSClient) startPing() {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.writeMu.Lock()
err := c.conn.WriteControl(
websocket.PingMessage, nil, time.Now().Add(writeWait),
)
c.writeMu.Unlock()
if err != nil {
c.log.Warnf("Ping failed, connection likely dead: %v", err)
c.conn.Close()
return
}
case <-c.done:
return
}
}
}

View File

@@ -22,25 +22,25 @@ type KosmiClient interface {
SendMessage(text string) error
OnMessage(callback func(*NewMessagePayload))
IsConnected() bool
Done() <-chan struct{}
}
// Bkosmi represents the Kosmi bridge
type Bkosmi struct {
*bridge.Config
client KosmiClient
roomID string
roomURL string
connected bool
authDone bool // Signals that authentication is complete (like IRC bridge)
msgChannel chan config.Message
jackboxClient *jackbox.Client
client KosmiClient
roomID string
roomURL string
connected bool
intentionalDisconnect bool
authDone bool // Signals that authentication is complete (like IRC bridge)
jackboxClient *jackbox.Client
}
// New creates a new Kosmi bridge instance
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bkosmi{
Config: cfg,
msgChannel: make(chan config.Message, 100),
Config: cfg,
}
return b
@@ -110,9 +110,12 @@ func (b *Bkosmi) Connect() error {
}
b.connected = true
b.intentionalDisconnect = false
b.authDone = true // Signal that authentication is complete
b.Log.Info("Successfully connected to Kosmi")
go b.watchConnection()
return nil
}
@@ -120,15 +123,15 @@ func (b *Bkosmi) Connect() error {
func (b *Bkosmi) Disconnect() error {
b.Log.Info("Disconnecting from Kosmi")
b.intentionalDisconnect = true
b.connected = false
if b.client != nil {
if err := b.client.Disconnect(); err != nil {
b.Log.Errorf("Error closing Kosmi client: %v", err)
}
}
close(b.msgChannel)
b.connected = false
return nil
}
@@ -216,6 +219,19 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
}
}
// Handle !votes command: query current game vote tally
if strings.TrimSpace(body) == "!votes" {
b.Log.Infof("!votes command from %s", username)
b.Remote <- config.Message{
Username: "system",
Text: "votes",
Channel: "main",
Account: b.Account,
Event: config.EventVotesQuery,
}
return
}
// Create Matterbridge message
// Use "main" as the channel name for gateway matching
// Don't add prefix here - let the gateway's RemoteNickFormat handle it
@@ -240,6 +256,30 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
b.Remote <- rmsg
}
// watchConnection monitors the WebSocket client and sends EventFailure
// to the gateway when an unexpected disconnect occurs, triggering automatic
// reconnection via the gateway's reconnectBridge() mechanism.
func (b *Bkosmi) watchConnection() {
<-b.client.Done()
if b.intentionalDisconnect {
return
}
b.Log.Warn("Kosmi connection lost unexpectedly, requesting reconnection")
b.connected = false
if b.Remote != nil {
b.Remote <- config.Message{
Username: "system",
Text: "reconnect",
Channel: "",
Account: b.Account,
Event: config.EventFailure,
}
}
}
// extractRoomID extracts the room ID from a Kosmi room URL
// Supports formats:
// - https://app.kosmi.io/room/@roomname