Compare commits
6 Commits
88cc140087
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4188ae29af
|
||
|
|
54dd9dc999
|
||
|
|
d533bd5f3e
|
||
|
|
d314193540
|
||
|
|
4fc7f08b24
|
||
|
|
bec3615d2b
|
35
.gitignore
vendored
35
.gitignore
vendored
@@ -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
21
LICENSE
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -29,6 +29,8 @@ const (
|
||||
EventGetChannelMembers = "get_channel_members"
|
||||
EventNoticeIRC = "notice_irc"
|
||||
EventReconnectKosmi = "reconnect_kosmi"
|
||||
EventReconnectJackbox = "reconnect_jackbox"
|
||||
EventReconnectAll = "reconnect_all"
|
||||
EventVotesQuery = "votes_query"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
72
bridge/jackbox/tickers.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
118
bridge/jackbox/votes_test.go
Normal file
118
bridge/jackbox/votes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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
90
cmd/test-kosmi/main.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user