Add connection resilience and reconnect commands for Kosmi and Jackbox

Kosmi WebSocket would silently die after hours/days with no reconnection.
Jackbox WebSocket failed to reconnect after API server restarts (stale JWT)
and leaked heartbeat goroutines on each reconnect cycle.

Kosmi changes:
- Add WebSocket ping/pong keepalive (30s ping, 90s read deadline)
- Send EventFailure on unexpected disconnect to trigger gateway reconnectBridge()
- Add intentionalDisconnect flag to prevent false failure events on clean shutdown
- Fix Disconnect() to be safe for reconnect cycles

Jackbox changes:
- Add read deadline (90s) to detect stale connections
- Fix heartbeat goroutine leak via per-connection listenDone channel
- Re-authenticate for fresh JWT before each reconnect attempt
- Add Manager.Reconnect() for on-demand teardown and rebuild

IRC commands:
- !kreconnect - reconnect Kosmi bridge
- !jreconnect - reconnect Jackbox WebSocket
- !reconnect  - reconnect all services (Kosmi + Jackbox)

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-05 05:30:39 -04:00
parent bec3615d2b
commit 4fc7f08b24
6 changed files with 182 additions and 32 deletions

View File

@@ -57,26 +57,12 @@ func (r *Router) handleEventReconnectKosmi(msg *config.Message) bool {
return false
}
originChannel := msg.Channel
originAccount := msg.Account
r.sendConfirmation(msg, "Reconnecting Kosmi...")
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
}
@@ -87,6 +73,79 @@ func (r *Router) handleEventReconnectKosmi(msg *config.Message) bool {
return true
}
// handleEventReconnectJackbox handles a manual Jackbox reconnect request (e.g. from !jreconnect).
// Returns true if the event was consumed.
func (r *Router) handleEventReconnectJackbox(msg *config.Message) bool {
if msg.Event != config.EventReconnectJackbox {
return false
}
r.sendConfirmation(msg, "Reconnecting Jackbox...")
go func() {
if r.JackboxManager == nil || !r.JackboxManager.IsEnabled() {
r.logger.Warn("!jreconnect: Jackbox integration is not enabled")
return
}
r.logger.Info("Reconnecting Jackbox WebSocket (requested via !jreconnect)")
if err := r.JackboxManager.Reconnect(); err != nil {
r.logger.Errorf("Jackbox reconnection failed: %v", err)
}
}()
return true
}
// handleEventReconnectAll handles a general reconnect request (e.g. from !reconnect).
// It reconnects all non-IRC bridges (Kosmi, Jackbox).
// Returns true if the event was consumed.
func (r *Router) handleEventReconnectAll(msg *config.Message) bool {
if msg.Event != config.EventReconnectAll {
return false
}
r.sendConfirmation(msg, "Reconnecting all services...")
// Reconnect Kosmi bridges
for _, gw := range r.Gateways {
for _, br := range gw.Bridges {
if br.Protocol == "kosmi" {
r.logger.Infof("Reconnecting Kosmi bridge %s (requested via !reconnect)", br.Account)
go gw.reconnectBridge(br)
}
}
}
// Reconnect Jackbox
if r.JackboxManager != nil && r.JackboxManager.IsEnabled() {
go func() {
r.logger.Info("Reconnecting Jackbox WebSocket (requested via !reconnect)")
if err := r.JackboxManager.Reconnect(); err != nil {
r.logger.Errorf("Jackbox reconnection failed: %v", err)
}
}()
}
return true
}
// sendConfirmation sends a short confirmation message back to the IRC channel
// that originated a command event.
func (r *Router) sendConfirmation(msg *config.Message, text string) {
if msg.Account == "" || msg.Channel == "" {
return
}
for _, gw := range r.Gateways {
if ircBr, ok := gw.Bridges[msg.Account]; ok {
ircBr.Send(config.Message{
Text: text,
Channel: msg.Channel,
Username: "system",
Account: msg.Account,
})
return
}
}
}
// 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.

View File

@@ -158,6 +158,12 @@ func (r *Router) handleReceive() {
if r.handleEventReconnectKosmi(&msg) {
continue
}
if r.handleEventReconnectJackbox(&msg) {
continue
}
if r.handleEventReconnectAll(&msg) {
continue
}
if r.handleEventVotesQuery(&msg) {
continue
}