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,6 +14,7 @@ import (
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/jackbox"
"github.com/42wim/matterbridge/gateway/bridgemap"
)
@@ -49,6 +50,131 @@ func (r *Router) handleEventGetChannelMembers(msg *config.Message) {
}
}
// handleEventReconnectKosmi handles a manual Kosmi reconnect request (e.g. from !kreconnect).
// Returns true if the event was consumed and should not be routed further.
func (r *Router) handleEventReconnectKosmi(msg *config.Message) bool {
if msg.Event != config.EventReconnectKosmi {
return false
}
originChannel := msg.Channel
originAccount := msg.Account
for _, gw := range r.Gateways {
for _, br := range gw.Bridges {
if br.Protocol == "kosmi" {
r.logger.Infof("Reconnecting Kosmi bridge %s (requested via !kreconnect)", br.Account)
// Send confirmation to the IRC channel that requested it
if originAccount != "" && originChannel != "" {
if ircBr, ok := gw.Bridges[originAccount]; ok {
ircBr.Send(config.Message{
Text: "Reconnecting Kosmi...",
Channel: originChannel,
Username: "system",
Account: originAccount,
})
}
}
go gw.reconnectBridge(br)
return true
}
}
}
r.logger.Warn("!kreconnect: no Kosmi bridge found")
return true
}
// handleEventVotesQuery handles a !votes command by fetching vote data for the
// currently playing game and broadcasting the result to all bridges.
// Returns true if the event was consumed.
func (r *Router) handleEventVotesQuery(msg *config.Message) bool {
if msg.Event != config.EventVotesQuery {
return false
}
client := r.JackboxManager.GetClient()
if client == nil {
r.logger.Warn("!votes: Jackbox client not available")
return true
}
session, err := client.GetActiveSession()
if err != nil {
r.logger.Warnf("!votes: failed to get active session: %v", err)
return true
}
if session == nil {
r.logger.Warn("!votes: no active session")
return true
}
games, err := client.GetSessionGames(session.ID)
if err != nil {
r.logger.Warnf("!votes: failed to get session games: %v", err)
return true
}
var playingGame *jackbox.SessionGame
for i := range games {
if games[i].Status == "playing" {
playingGame = &games[i]
break
}
}
if playingGame == nil {
r.logger.Warn("!votes: no game currently playing in session")
return true
}
r.logger.Infof("!votes: session=%d, playing game ID=%d (session_games.id=%d) title=%q",
session.ID, playingGame.GameID, playingGame.ID, playingGame.Title)
votesResp, err := client.GetSessionVotes(session.ID)
if err != nil {
r.logger.Warnf("!votes: failed to get session votes: %v", err)
return true
}
var sessionUp, sessionDown, sessionNet int
if votesResp != nil {
r.logger.Infof("!votes: session votes response has %d entries", len(votesResp.Votes))
for _, v := range votesResp.Votes {
r.logger.Infof("!votes: vote entry game_id=%d title=%q up=%d down=%d net=%d",
v.GameID, v.Title, v.Upvotes, v.Downvotes, v.NetScore)
if v.GameID == playingGame.GameID {
sessionUp = v.Upvotes
sessionDown = v.Downvotes
sessionNet = v.NetScore
break
}
}
} else {
r.logger.Info("!votes: session votes response is nil")
}
game, err := client.GetGame(playingGame.GameID)
if err != nil {
r.logger.Warnf("!votes: failed to get game %d: %v", playingGame.GameID, err)
return true
}
var allTimeUp, allTimeDown, allTimeScore int
if game != nil {
allTimeUp = game.Upvotes
allTimeDown = game.Downvotes
allTimeScore = game.PopularityScore
}
message := fmt.Sprintf("🗳️ %s • Today: %d👍 %d👎 (Score: %d) • All-time: %d👍 %d👎 (Score: %d)",
playingGame.Title, sessionUp, sessionDown, sessionNet, allTimeUp, allTimeDown, allTimeScore)
r.broadcastJackboxMessage(message)
return true
}
// handleEventRejoinChannels handles rejoining of channels.
func (r *Router) handleEventRejoinChannels(msg *config.Message) {
if msg.Event != config.EventRejoinChannels {

View File

@@ -155,6 +155,12 @@ func (r *Router) getBridge(account string) *bridge.Bridge {
func (r *Router) handleReceive() {
for msg := range r.Message {
msg := msg // scopelint
if r.handleEventReconnectKosmi(&msg) {
continue
}
if r.handleEventVotesQuery(&msg) {
continue
}
r.handleEventGetChannelMembers(&msg)
r.handleEventFailure(&msg)
r.handleEventRejoinChannels(&msg)