Compare commits

...

6 Commits

Author SHA1 Message Date
cottongin
4188ae29af chore: cleanup repo 2026-04-05 06:06:36 -04:00
cottongin
54dd9dc999 chore: add LICENSE and AI warning 2026-04-05 05:58:34 -04:00
cottongin
d533bd5f3e Anchor binary patterns in .gitignore to repo root
Prefix binary names with / so they only match compiled binaries at the
repo root, not cmd/ source directories of the same name (e.g. test-kosmi
was hiding cmd/test-kosmi/). Also add get-kosmi-token binary entry.

Made-with: Cursor
2026-04-05 05:51:30 -04:00
cottongin
d314193540 Fix stale cmd/ scripts, export LoginWithChromedp, clean up vet warnings
Update 5 cmd/ test utilities that referenced APIs that drifted after
bridge refactors (NewBrowserAuthManager, bridge.NewConfig, changed
function signatures). Rewrite test-kosmi and test-native to use
NewGraphQLWSClient directly. Export LoginWithChromedp for use by cmd
scripts. Fix redundant-newline vet warnings across 5 cmd/ files.
Remove unreachable code and replace deprecated ioutil calls in
bridge/helper/lottie_convert.go.

Made-with: Cursor
2026-04-05 05:49:26 -04:00
cottongin
4fc7f08b24 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
2026-04-05 05:30:39 -04:00
cottongin
bec3615d2b Add ticker symbol voting (e.g. QPL3++, TMP2--)
Extend vote detection to recognize game ticker symbols alongside the
existing thisgame++/-- syntax. Each symbol maps to a specific game so
users can vote for any game by its stock-style ticker. The matched
ticker is sent to the API via a new optional `ticker` field in the
vote request.

Made-with: Cursor
2026-04-05 04:54:05 -04:00
38 changed files with 674 additions and 145 deletions

35
.gitignore vendored
View File

@@ -1,18 +1,19 @@
# Binaries
matterbridge
test-kosmi
capture-auth
monitor-ws
test-image-upload
test-long-title
test-proper-roomcodes
test-roomcode-image
test-session
test-upload
test-websocket
test-websocket-direct
cmd/kosmi-client/kosmi-clien
cmd/kosmi-client/kosmi-client
# Binaries (anchored to repo root so cmd/ source dirs aren't excluded)
/matterbridge
/test-kosmi
/capture-auth
/monitor-ws
/test-image-upload
/test-long-title
/test-proper-roomcodes
/test-roomcode-image
/test-session
/test-upload
/test-websocket
/test-websocket-direct
/cmd/kosmi-client/kosmi-clien
/cmd/kosmi-client/kosmi-client
/cmd/get-kosmi-token/get-kosmi-token
*.exe
*.dll
*.so
@@ -29,6 +30,7 @@ vendor/
# Go workspace file
go.work
go.work.sum
# IDE
.vscode/
@@ -80,6 +82,9 @@ build/
.examples/
chat-summaries/
bin/
tests/
utils/
.archive/
# Persistent data directory (contains cached tokens)
data/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,3 +1,6 @@
> [!IMPORTANT]
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
# Kosmi-IRC Relay via Matterbridge
A Matterbridge plugin that bridges Kosmi chat rooms with IRC channels, enabling bidirectional message relay.

BIN
blurt.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -29,6 +29,8 @@ const (
EventGetChannelMembers = "get_channel_members"
EventNoticeIRC = "notice_irc"
EventReconnectKosmi = "reconnect_kosmi"
EventReconnectJackbox = "reconnect_jackbox"
EventReconnectAll = "reconnect_all"
EventVotesQuery = "votes_query"
)

View File

@@ -3,7 +3,6 @@
package helper
import (
"io/ioutil"
"os"
"os/exec"
@@ -23,7 +22,7 @@ func CanConvertTgsToX() error {
// This relies on an external command, which is ugly, but works.
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
// lottie can't handle input from a pipe, so write to a temporary file:
tmpInFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-input-*.tgs")
tmpInFile, err := os.CreateTemp(os.TempDir(), "matterbridge-lottie-input-*.tgs")
if err != nil {
return err
}
@@ -35,7 +34,7 @@ func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) erro
}()
// lottie can handle writing to a pipe, but there is no way to do that platform-independently.
// "/dev/stdout" won't work on Windows, and "-" upsets Cairo for some reason. So we need another file:
tmpOutFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-output-*.data")
tmpOutFile, err := os.CreateTemp(os.TempDir(), "matterbridge-lottie-output-*.data")
if err != nil {
return err
}
@@ -64,7 +63,7 @@ func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) erro
// 'stderr' already contains some parts of Stderr, because it was set to 'nil'.
return stderr
}
dataContents, err := ioutil.ReadFile(tmpOutFileName)
dataContents, err := os.ReadFile(tmpOutFileName)
if err != nil {
return err
}
@@ -82,7 +81,6 @@ func SupportsFormat(format string) bool {
default:
return false
}
return false
}
func LottieBackend() string {

View File

@@ -252,16 +252,15 @@ func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
rmsg.Text = string(output)
}
// Check for votes (thisgame++ or thisgame--)
// Check for votes (thisgame++/-- or ticker symbol++/--)
// Only process votes from non-relayed messages
if !jackbox.IsRelayedMessage(rmsg.Text) {
if isVote, voteType := jackbox.DetectVote(rmsg.Text); isVote {
b.Log.Debugf("Detected vote from %s: %s", event.Source.Name, voteType)
if isVote, voteType, ticker := jackbox.DetectVote(rmsg.Text); isVote {
b.Log.Debugf("Detected vote from %s: %s (ticker=%q)", event.Source.Name, voteType, ticker)
if b.jackboxClient != nil {
go func() {
// Use current time as timestamp for IRC messages
timestamp := time.Now()
if err := b.jackboxClient.SendVote(event.Source.Name, voteType, timestamp); err != nil {
if err := b.jackboxClient.SendVote(event.Source.Name, voteType, timestamp, ticker); err != nil {
b.Log.Errorf("Failed to send vote to Jackbox API: %v", err)
}
}()
@@ -269,8 +268,10 @@ func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
}
}
// Handle !kreconnect command: trigger Kosmi bridge reconnection
if strings.TrimSpace(rmsg.Text) == "!kreconnect" {
// Handle reconnect commands
trimmedText := strings.TrimSpace(rmsg.Text)
switch trimmedText {
case "!kreconnect":
b.Log.Infof("!kreconnect command from %s on %s", event.Source.Name, rmsg.Channel)
b.Remote <- config.Message{
Username: "system",
@@ -280,6 +281,26 @@ func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
Event: config.EventReconnectKosmi,
}
return
case "!jreconnect":
b.Log.Infof("!jreconnect command from %s on %s", event.Source.Name, rmsg.Channel)
b.Remote <- config.Message{
Username: "system",
Text: "jreconnect",
Channel: rmsg.Channel,
Account: b.Account,
Event: config.EventReconnectJackbox,
}
return
case "!reconnect":
b.Log.Infof("!reconnect command from %s on %s", event.Source.Name, rmsg.Channel)
b.Remote <- config.Message{
Username: "system",
Text: "reconnect",
Channel: rmsg.Channel,
Account: b.Account,
Event: config.EventReconnectAll,
}
return
}
// Handle !votes command: query current game vote tally

View File

@@ -36,8 +36,9 @@ type AuthResponse struct {
// VoteRequest represents a vote submission to the API
type VoteRequest struct {
Username string `json:"username"`
Vote string `json:"vote"` // "up" or "down"
Vote string `json:"vote"` // "up" or "down"
Timestamp string `json:"timestamp"`
Ticker string `json:"ticker,omitempty"` // ticker symbol targeting a specific game
}
// VoteResponse represents the API response to a vote submission
@@ -214,8 +215,9 @@ func (c *Client) ensureAuthenticated() error {
return c.Authenticate()
}
// SendVote sends a vote to the Jackbox API
func (c *Client) SendVote(username, voteType string, timestamp time.Time) error {
// SendVote sends a vote to the Jackbox API.
// ticker is optional; when non-empty the API resolves the target game by symbol.
func (c *Client) SendVote(username, voteType string, timestamp time.Time, ticker string) error {
// Ensure we're authenticated
if err := c.ensureAuthenticated(); err != nil {
return fmt.Errorf("authentication failed: %w", err)
@@ -230,6 +232,7 @@ func (c *Client) SendVote(username, voteType string, timestamp time.Time) error
Username: username,
Vote: voteType,
Timestamp: timestamp.Format(time.RFC3339),
Ticker: ticker,
}
jsonBody, err := json.Marshal(voteReq)
@@ -266,7 +269,7 @@ func (c *Client) SendVote(username, voteType string, timestamp time.Time) error
return fmt.Errorf("re-authentication failed: %w", err)
}
// Retry the vote
return c.SendVote(username, voteType, timestamp)
return c.SendVote(username, voteType, timestamp, ticker)
}
if resp.StatusCode == http.StatusConflict {

View File

@@ -242,6 +242,37 @@ func (m *Manager) monitorActiveSessions() {
}
}
// Reconnect tears down the existing WebSocket client and establishes a new
// connection (re-authenticating for a fresh JWT). Safe to call even when
// already disconnected.
func (m *Manager) Reconnect() error {
if !m.enabled || !m.useWebSocket {
return fmt.Errorf("Jackbox WebSocket is not enabled")
}
m.log.Info("Forcing Jackbox WebSocket reconnection...")
// Tear down existing client
if m.wsClient != nil {
if err := m.wsClient.Close(); err != nil {
m.log.Errorf("Error closing existing WebSocket client: %v", err)
}
m.wsClient = nil
}
// Re-authenticate for a fresh JWT
if err := m.client.Authenticate(); err != nil {
return fmt.Errorf("re-authentication failed: %w", err)
}
// Rebuild the WebSocket client using the original callback
if m.messageCallback == nil {
return fmt.Errorf("no message callback registered")
}
return m.startWebSocketClient(m.messageCallback)
}
// GetClient returns the Jackbox API client (may be nil if disabled)
func (m *Manager) GetClient() *Client {
return m.client

72
bridge/jackbox/tickers.go Normal file
View File

@@ -0,0 +1,72 @@
package jackbox
import "strings"
// tickerSymbols maps uppercase ticker symbols to their canonical game titles.
var tickerSymbols = map[string]string{
"QPL3": "Quiplash 3",
"QPL2": "Quiplash 2",
"QLXL": "Quiplash XL",
"FBXL": "Fibbage XL",
"FBG2": "Fibbage 2",
"FBG3": "Fibbage 3",
"FBG4": "Fibbage 4",
"TMP1": "Trivia Murder Party",
"TMP2": "Trivia Murder Party 2",
"DRWF": "Drawful",
"DRWA": "Drawful Animate",
"DD": "Dirty Drawful",
"DOOM": "Doominate",
"JJ": "Job Job",
"TKO2": "Tee K.O. 2",
"TKOX": "Tee K.O. T-Shirt Knock Out",
"CU": "Champ'd Up",
"BR": "Blather 'Round",
"STR": "Split the Room",
"ROOM": "Roomerang",
"BRKT": "Bracketeering",
"NNSR": "Nonsensory",
"QXRT": "Quixort",
"JNKT": "Junktopia",
"TP": "Talking Points",
"PS": "Patently Stupid",
"PTB": "Push the Button",
"WD": "Weapons Drawn",
"HPNT": "Hypnotorious",
"DCTN": "Dictionarium",
"RM": "Role Models",
"JB": "Joke Boat",
"GSPN": "Guesspionage",
"MVC": "Mad Verse City",
"HRSY": "Hear Say",
"CH": "Cookie Haus",
"SPCT": "Suspectives",
"LOT": "Legends of Trivia",
"STI": "Survive the Internet",
"CVDL": "Civic Doodle",
"MSM": "Monster Seeking Monster",
"TPM": "The Poll Mine",
"TWEP": "The Wheel of Enormous Proportions",
"TJ": "Time Jinx",
"DRM": "Dodo Re Mi",
"FT": "Fixy Text",
"SS": "Survey Scramble",
"WS": "Word Spud",
"LS": "Lie Swatter",
"FI": "Fakin' It!",
"FANL": "Fakin' It All Night Long",
"LMF": "Let Me Finish",
"BDTS": "Bidiots",
"BC": "Bomb Corp.",
"YDK1": "You Don't Know Jack® 2015",
"YDKJ": "You Don't Know Jack® Full Stream",
"ZPDM": "Zeeple Dome",
"EW": "Earwax™",
}
// LookupTicker returns the game title for a ticker symbol.
// The symbol is matched case-insensitively.
func LookupTicker(symbol string) (title string, ok bool) {
title, ok = tickerSymbols[strings.ToUpper(symbol)]
return
}

View File

@@ -1,23 +1,42 @@
package jackbox
import "strings"
import (
"regexp"
"strings"
)
// tickerVoteRe matches a ticker symbol (2-4 uppercase alphanumeric chars)
// immediately followed by ++ or --.
var tickerVoteRe = regexp.MustCompile(`(?i)\b([A-Z0-9]{2,4})(\+\+|--)`)
// DetectVote checks if a message contains a vote and returns the vote type
// Returns (true, "up") for thisgame++
// Returns (true, "down") for thisgame--
// Returns (false, "") for non-vote messages
func DetectVote(text string) (isVote bool, voteType string) {
// and optional ticker symbol.
//
// For "thisgame++" / "thisgame--": returns (true, "up"/"down", "")
// For "QPL3++" / "tmp2--": returns (true, "up"/"down", "QPL3"/"TMP2")
// For non-vote messages: returns (false, "", "")
func DetectVote(text string) (isVote bool, voteType string, ticker string) {
lower := strings.ToLower(text)
if strings.Contains(lower, "thisgame++") {
return true, "up"
return true, "up", ""
}
if strings.Contains(lower, "thisgame--") {
return true, "down"
return true, "down", ""
}
return false, ""
if m := tickerVoteRe.FindStringSubmatch(text); m != nil {
sym := strings.ToUpper(m[1])
if _, ok := LookupTicker(sym); ok {
if m[2] == "++" {
return true, "up", sym
}
return true, "down", sym
}
}
return false, "", ""
}
// IsRelayedMessage checks if a message is relayed from another chat

View File

@@ -0,0 +1,118 @@
package jackbox
import "testing"
func TestDetectVote_ThisGame(t *testing.T) {
tests := []struct {
input string
isVote bool
voteType string
ticker string
}{
{"thisgame++", true, "up", ""},
{"thisgame--", true, "down", ""},
{"THISGAME++", true, "up", ""},
{"ThisGame--", true, "down", ""},
{"I love thisgame++ so much", true, "up", ""},
{"honestly thisgame-- was rough", true, "down", ""},
}
for _, tt := range tests {
isVote, voteType, ticker := DetectVote(tt.input)
if isVote != tt.isVote || voteType != tt.voteType || ticker != tt.ticker {
t.Errorf("DetectVote(%q) = (%v, %q, %q), want (%v, %q, %q)",
tt.input, isVote, voteType, ticker, tt.isVote, tt.voteType, tt.ticker)
}
}
}
func TestDetectVote_Ticker(t *testing.T) {
tests := []struct {
input string
isVote bool
voteType string
ticker string
}{
{"QPL3++", true, "up", "QPL3"},
{"qpl3++", true, "up", "QPL3"},
{"TMP2--", true, "down", "TMP2"},
{"tmp2--", true, "down", "TMP2"},
{"DD++", true, "up", "DD"},
{"dd--", true, "down", "DD"},
{"YDKJ++", true, "up", "YDKJ"},
{"EW--", true, "down", "EW"},
{"let's go FBG4++", true, "up", "FBG4"},
{"TWEP++ is great", true, "up", "TWEP"},
}
for _, tt := range tests {
isVote, voteType, ticker := DetectVote(tt.input)
if isVote != tt.isVote || voteType != tt.voteType || ticker != tt.ticker {
t.Errorf("DetectVote(%q) = (%v, %q, %q), want (%v, %q, %q)",
tt.input, isVote, voteType, ticker, tt.isVote, tt.voteType, tt.ticker)
}
}
}
func TestDetectVote_UnknownSymbol(t *testing.T) {
tests := []string{
"ZZZZ++",
"ABCD--",
"XY++",
"NOPE--",
}
for _, input := range tests {
isVote, voteType, ticker := DetectVote(input)
if isVote {
t.Errorf("DetectVote(%q) = (%v, %q, %q), want (false, \"\", \"\")",
input, isVote, voteType, ticker)
}
}
}
func TestDetectVote_NoVote(t *testing.T) {
tests := []string{
"hello world",
"this game is fun",
"QPL3",
"++QPL3",
"",
}
for _, input := range tests {
isVote, voteType, ticker := DetectVote(input)
if isVote {
t.Errorf("DetectVote(%q) = (%v, %q, %q), want (false, \"\", \"\")",
input, isVote, voteType, ticker)
}
}
}
func TestDetectVote_ThisGameTakesPriority(t *testing.T) {
// When a message contains both thisgame++ and a ticker, thisgame wins
isVote, voteType, ticker := DetectVote("thisgame++ QPL3++")
if !isVote || voteType != "up" || ticker != "" {
t.Errorf("DetectVote with both patterns = (%v, %q, %q), want (true, \"up\", \"\")",
isVote, voteType, ticker)
}
}
func TestLookupTicker(t *testing.T) {
tests := []struct {
symbol string
title string
ok bool
}{
{"QPL3", "Quiplash 3", true},
{"qpl3", "Quiplash 3", true},
{"DD", "Dirty Drawful", true},
{"EW", "Earwax™", true},
{"TWEP", "The Wheel of Enormous Proportions", true},
{"ZZZZ", "", false},
{"", "", false},
}
for _, tt := range tests {
title, ok := LookupTicker(tt.symbol)
if ok != tt.ok || title != tt.title {
t.Errorf("LookupTicker(%q) = (%q, %v), want (%q, %v)",
tt.symbol, title, ok, tt.title, tt.ok)
}
}
}

View File

@@ -10,6 +10,11 @@ import (
"github.com/sirupsen/logrus"
)
const (
jackboxPingInterval = 30 * time.Second
jackboxReadTimeout = 90 * time.Second
)
// WebSocketClient handles WebSocket connection to Jackbox API
type WebSocketClient struct {
apiURL string
@@ -22,6 +27,7 @@ type WebSocketClient struct {
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
@@ -109,8 +115,11 @@ func (c *WebSocketClient) Connect() error {
c.conn = conn
c.connected = true
c.listenDone = make(chan struct{})
c.log.Info("WebSocket connected")
conn.SetReadDeadline(time.Now().Add(jackboxReadTimeout))
// Start message listener
go c.listen()
@@ -181,9 +190,9 @@ func (c *WebSocketClient) Unsubscribe(sessionID int) error {
// listen handles incoming WebSocket messages
func (c *WebSocketClient) listen() {
defer c.handleDisconnect()
defer close(c.listenDone)
// Start heartbeat
go c.startHeartbeat()
go c.startHeartbeat(c.listenDone)
for {
select {
@@ -196,6 +205,9 @@ func (c *WebSocketClient) listen() {
return
}
// Reset read deadline on every successful read
c.conn.SetReadDeadline(time.Now().Add(jackboxReadTimeout))
c.handleMessage(message)
}
}
@@ -438,15 +450,18 @@ func (c *WebSocketClient) AnnounceSessionEnd() {
}
}
// startHeartbeat sends ping messages periodically
func (c *WebSocketClient) startHeartbeat() {
ticker := time.NewTicker(30 * time.Second)
// 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{}) {
ticker := time.NewTicker(jackboxPingInterval)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-listenDone:
return
case <-ticker.C:
c.mu.Lock()
if c.connected && c.conn != nil {
@@ -474,7 +489,9 @@ func (c *WebSocketClient) sendMessage(msg WSMessage) error {
return c.conn.WriteMessage(websocket.TextMessage, data)
}
// handleDisconnect handles connection loss and attempts reconnection
// handleDisconnect handles connection loss and attempts reconnection with
// exponential backoff. Before each attempt it re-authenticates via the HTTP
// API to obtain a fresh JWT (the old token may be invalid after a server restart).
func (c *WebSocketClient) handleDisconnect() {
c.mu.Lock()
c.connected = false
@@ -487,7 +504,6 @@ func (c *WebSocketClient) handleDisconnect() {
c.log.Warn("WebSocket disconnected, attempting to reconnect...")
// Exponential backoff reconnection
delay := c.reconnectDelay
for {
select {
@@ -496,21 +512,27 @@ func (c *WebSocketClient) handleDisconnect() {
case <-time.After(delay):
c.log.Infof("Reconnecting... (delay: %v)", delay)
// Re-authenticate to get a fresh JWT token before reconnecting.
if c.apiClient != nil {
if err := c.apiClient.Authenticate(); err != nil {
c.log.Errorf("Re-authentication failed: %v (will retry)", err)
delay = c.bumpDelay(delay)
continue
}
c.mu.Lock()
c.token = c.apiClient.GetToken()
c.mu.Unlock()
c.log.Info("Re-authenticated with fresh JWT token")
}
if err := c.Connect(); err != nil {
c.log.Errorf("Reconnection failed: %v", err)
// Increase delay with exponential backoff
delay *= 2
if delay > c.maxReconnect {
delay = c.maxReconnect
}
delay = c.bumpDelay(delay)
continue
}
// Reconnected successfully
c.log.Info("Reconnected successfully")
// Re-subscribe if we were subscribed before
if c.subscribedSession > 0 {
if err := c.Subscribe(c.subscribedSession); err != nil {
c.log.Errorf("Failed to re-subscribe: %v", err)
@@ -522,6 +544,14 @@ func (c *WebSocketClient) handleDisconnect() {
}
}
func (c *WebSocketClient) bumpDelay(d time.Duration) time.Duration {
d *= 2
if d > c.maxReconnect {
d = c.maxReconnect
}
return d
}
// Close closes the WebSocket connection
func (c *WebSocketClient) Close() error {
c.mu.Lock()

View File

@@ -9,9 +9,9 @@ import (
"github.com/sirupsen/logrus"
)
// loginWithChromedp uses browser automation to log in and extract the JWT token.
// LoginWithChromedp uses browser automation to log in and extract the JWT token.
// This is the proven implementation that successfully authenticates users.
func loginWithChromedp(email, password string, log *logrus.Entry) (string, error) {
func LoginWithChromedp(email, password string, log *logrus.Entry) (string, error) {
log.Info("Starting browser automation for authentication...")
// Create context with timeout

View File

@@ -82,7 +82,7 @@ func (b *Bkosmi) Connect() error {
} else {
// No valid cache, authenticate with browser
b.Log.Info("Authenticating with email/password...")
token, err = loginWithChromedp(email, password, b.Log)
token, err = LoginWithChromedp(email, password, b.Log)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
@@ -204,14 +204,14 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
return
}
// Check for votes (thisgame++ or thisgame--)
// Check for votes (thisgame++/-- or ticker symbol++/--)
// Only process votes from non-relayed messages
if !jackbox.IsRelayedMessage(body) {
if isVote, voteType := jackbox.DetectVote(body); isVote {
b.Log.Debugf("Detected vote from %s: %s", username, voteType)
if isVote, voteType, ticker := jackbox.DetectVote(body); isVote {
b.Log.Debugf("Detected vote from %s: %s (ticker=%q)", username, voteType, ticker)
if b.jackboxClient != nil {
go func() {
if err := b.jackboxClient.SendVote(username, voteType, timestamp); err != nil {
if err := b.jackboxClient.SendVote(username, voteType, timestamp, ticker); err != nil {
b.Log.Errorf("Failed to send vote to Jackbox API: %v", err)
}
}()

View File

@@ -1,11 +1,14 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
"github.com/sirupsen/logrus"
@@ -36,10 +39,9 @@ func main() {
fmt.Println(strings.Repeat("=", 80))
fmt.Println()
// Get anonymous token
// Get anonymous token via HTTP (same mutation the client uses internally)
fmt.Println("📝 Step 1: Getting anonymous token...")
client := bkosmi.NewGraphQLWSClient("https://app.kosmi.io/room/@test", "@test", entry)
anonToken, err := client.GetAnonymousTokenForTest()
anonToken, err := getAnonymousToken()
if err != nil {
fmt.Printf("❌ Failed to get anonymous token: %v\n", err)
os.Exit(1)
@@ -47,10 +49,9 @@ func main() {
fmt.Printf("✅ Anonymous token obtained (length: %d)\n", len(anonToken))
fmt.Println()
// Get authenticated token
// Get authenticated token via browser automation
fmt.Println("📝 Step 2: Getting authenticated token via browser...")
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
authToken, err := browserAuth.GetToken()
authToken, err := bkosmi.LoginWithChromedp(email, password, entry)
if err != nil {
fmt.Printf("❌ Failed to get authenticated token: %v\n", err)
os.Exit(1)
@@ -134,6 +135,49 @@ func printClaims(claims map[string]interface{}) {
}
}
func getAnonymousToken() (string, error) {
mutation := map[string]interface{}{
"query": `mutation { anonLogin { token } }`,
}
jsonBody, err := json.Marshal(mutation)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewReader(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://app.kosmi.io/")
req.ContentLength = int64(len(jsonBody))
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if data, ok := result["data"].(map[string]interface{}); ok {
if anonLogin, ok := data["anonLogin"].(map[string]interface{}); ok {
if token, ok := anonLogin["token"].(string); ok {
return token, nil
}
}
}
return "", fmt.Errorf("no token in response")
}
func compareClaims(anon, auth map[string]interface{}) {
allKeys := make(map[string]bool)
for k := range anon {

View File

@@ -36,7 +36,7 @@ func main() {
os.Exit(1)
}
fmt.Println("=== WebSocket Messages ===\n")
fmt.Println("\n=== WebSocket Messages ===")
for _, entry := range har.Log.Entries {
for i, msg := range entry.WebSocketMessages {

View File

@@ -68,7 +68,7 @@ func main() {
}
}
fmt.Println("=== WebSocket Operations (in order) ===\n")
fmt.Println("\n=== WebSocket Operations (in order) ===")
msgCount := 0
for _, entry := range har.Log.Entries {

View File

@@ -1,8 +1,11 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
"github.com/sirupsen/logrus"
@@ -23,7 +26,6 @@ func main() {
email := os.Args[1]
password := os.Args[2]
// Set up logging
log := logrus.New()
log.SetLevel(logrus.DebugLevel)
entry := logrus.NewEntry(log)
@@ -31,11 +33,7 @@ func main() {
fmt.Println("🚀 Testing browser-based authentication...")
fmt.Println()
// Create browser auth manager
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
// Get token
token, err := browserAuth.GetToken()
token, err := bkosmi.LoginWithChromedp(email, password, entry)
if err != nil {
fmt.Printf("❌ Authentication failed: %v\n", err)
os.Exit(1)
@@ -48,27 +46,32 @@ func main() {
fmt.Printf("Token length: %d characters\n", len(token))
fmt.Println()
// Check if authenticated
if browserAuth.IsAuthenticated() {
fmt.Println("✅ Token is valid")
} else {
fmt.Println("❌ Token is invalid or expired")
}
// Get user ID
userID := browserAuth.GetUserID()
userID := extractUserIDFromJWT(token)
if userID != "" {
fmt.Printf("User ID: %s\n", userID)
} else {
fmt.Println("(Could not extract user ID from token)")
}
fmt.Println()
fmt.Println("🎉 Test completed successfully!")
}
func min(a, b int) int {
if a < b {
return a
func extractUserIDFromJWT(token string) string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return ""
}
return b
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
}
if sub, ok := claims["sub"].(string); ok {
return sub
}
return ""
}

View File

@@ -9,11 +9,11 @@ import (
)
func main() {
fmt.Println("=== Kosmi Image Upload Test ===\n")
fmt.Println("=== Kosmi Image Upload Test ===")
// Test 1: Generate a room code image
fmt.Println("1. Generating room code image for 'TEST'...")
imageData, err := jackbox.GenerateRoomCodeImage("TEST")
imageData, err := jackbox.GenerateRoomCodeImage("TEST", "Test Game")
if err != nil {
log.Fatalf("Failed to generate image: %v", err)
}

90
cmd/test-kosmi/main.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
"github.com/sirupsen/logrus"
)
func main() {
roomURL := flag.String("room", "https://app.kosmi.io/room/@hyperspaceout", "Kosmi room URL")
debug := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
log := logrus.New()
if *debug {
log.SetLevel(logrus.DebugLevel)
} else {
log.SetLevel(logrus.InfoLevel)
}
log.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
logger := log.WithField("bridge", "kosmi-test")
logger.Info("Starting Kosmi bridge test")
logger.Infof("Room URL: %s", *roomURL)
roomID, err := extractRoomID(*roomURL)
if err != nil {
logger.Fatalf("Failed to extract room ID: %v", err)
}
// Empty token = anonymous access
client := bkosmi.NewGraphQLWSClient(*roomURL, roomID, "", logger)
client.OnMessage(func(payload *bkosmi.NewMessagePayload) {
username := payload.Data.NewMessage.User.DisplayName
if username == "" {
username = payload.Data.NewMessage.User.Username
}
ts := time.Unix(payload.Data.NewMessage.Time, 0)
logger.Infof("Received message: [%s] %s: %s",
ts.Format("15:04:05"), username, payload.Data.NewMessage.Body)
})
logger.Info("Connecting to Kosmi...")
if err := client.Connect(); err != nil {
logger.Fatalf("Failed to connect to Kosmi: %v", err)
}
logger.Info("Successfully connected to Kosmi!")
logger.Info("Listening for messages... Press Ctrl+C to exit")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
time.Sleep(5 * time.Second)
logger.Info("Bridge is running. Messages from Kosmi will appear above.")
}()
<-sigChan
logger.Info("Shutting down...")
if err := client.Disconnect(); err != nil {
logger.Errorf("Error disconnecting: %v", err)
}
logger.Info("Goodbye!")
}
func extractRoomID(url string) (string, error) {
url = strings.TrimSuffix(url, "/")
re := regexp.MustCompile(`/room/(@?[a-zA-Z0-9_-]+)`)
matches := re.FindStringSubmatch(url)
if len(matches) >= 2 {
roomID := matches[1]
if !strings.HasPrefix(roomID, "@") {
roomID = "@" + roomID
}
return roomID, nil
}
return "", fmt.Errorf("could not extract room ID from URL: %s", url)
}

View File

@@ -2,84 +2,88 @@ package main
import (
"flag"
"fmt"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
"github.com/42wim/matterbridge/bridge"
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
"github.com/sirupsen/logrus"
)
func main() {
// Parse command line flags
roomURL := flag.String("room", "https://app.kosmi.io/room/@hyperspaceout", "Kosmi room URL")
debug := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
// Set up logger
log := logrus.New()
if *debug {
log.SetLevel(logrus.DebugLevel)
} else {
log.SetLevel(logrus.InfoLevel)
}
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
log.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
logger := log.WithField("bridge", "kosmi-test")
logger := log.WithField("bridge", "kosmi-native-test")
logger.Info("Starting Kosmi bridge test")
logger.Info("Starting Kosmi native WebSocket test")
logger.Infof("Room URL: %s", *roomURL)
// Create bridge configuration
cfg := bridge.NewConfig("kosmi.test", logger)
cfg.SetString("RoomURL", *roomURL)
cfg.SetBool("Debug", *debug)
// Create Kosmi bridge
b := bkosmi.New(cfg)
// Connect to Kosmi
logger.Info("Connecting to Kosmi...")
if err := b.Connect(); err != nil {
logger.Fatalf("Failed to connect to Kosmi: %v", err)
roomID, err := extractRoomID(*roomURL)
if err != nil {
logger.Fatalf("Failed to extract room ID: %v", err)
}
logger.Info("Successfully connected to Kosmi!")
client := bkosmi.NewGraphQLWSClient(*roomURL, roomID, "", logger)
// Start message listener
go func() {
for msg := range cfg.Remote {
logger.Infof("Received message: [%s] %s: %s",
msg.Timestamp.Format("15:04:05"),
msg.Username,
msg.Text)
client.OnMessage(func(payload *bkosmi.NewMessagePayload) {
username := payload.Data.NewMessage.User.DisplayName
if username == "" {
username = payload.Data.NewMessage.User.Username
}
}()
ts := time.Unix(payload.Data.NewMessage.Time, 0)
logger.Infof("Received message: [%s] %s: %s",
ts.Format("15:04:05"), username, payload.Data.NewMessage.Body)
})
logger.Info("Connecting to Kosmi via native WebSocket...")
if err := client.Connect(); err != nil {
logger.Fatalf("Failed to connect: %v", err)
}
logger.Info("Successfully connected!")
// Wait for interrupt signal
logger.Info("Listening for messages... Press Ctrl+C to exit")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Optional: Send a test message after 5 seconds
go func() {
time.Sleep(5 * time.Second)
logger.Info("Bridge is running. Messages from Kosmi will appear above.")
logger.Info("To test sending messages, integrate with IRC or use the full Matterbridge setup")
}()
<-sigChan
logger.Info("Shutting down...")
// Disconnect
if err := b.Disconnect(); err != nil {
if err := client.Disconnect(); err != nil {
logger.Errorf("Error disconnecting: %v", err)
}
logger.Info("Goodbye!")
}
func extractRoomID(url string) (string, error) {
url = strings.TrimSuffix(url, "/")
re := regexp.MustCompile(`/room/(@?[a-zA-Z0-9_-]+)`)
matches := re.FindStringSubmatch(url)
if len(matches) >= 2 {
roomID := matches[1]
if !strings.HasPrefix(roomID, "@") {
roomID = "@" + roomID
}
return roomID, nil
}
return "", fmt.Errorf("could not extract room ID from URL: %s", url)
}

View File

@@ -68,7 +68,7 @@ func main() {
fmt.Println("✅ WebSocket connected!")
// Step 3: Listen for messages
fmt.Println("\n👂 Listening for messages (press Ctrl+C to exit)...\n")
fmt.Println("\n👂 Listening for messages (press Ctrl+C to exit)...")
messageCount := 0
for {

View File

@@ -61,7 +61,7 @@ func main() {
fmt.Println("✅ Subscribed!")
// Listen for messages
fmt.Println("\n👂 Listening for messages (press Ctrl+C to exit)...\n")
fmt.Println("\n👂 Listening for messages (press Ctrl+C to exit)...")
messageCount := 0
for {

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
}