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
|
# Binaries (anchored to repo root so cmd/ source dirs aren't excluded)
|
||||||
matterbridge
|
/matterbridge
|
||||||
test-kosmi
|
/test-kosmi
|
||||||
capture-auth
|
/capture-auth
|
||||||
monitor-ws
|
/monitor-ws
|
||||||
test-image-upload
|
/test-image-upload
|
||||||
test-long-title
|
/test-long-title
|
||||||
test-proper-roomcodes
|
/test-proper-roomcodes
|
||||||
test-roomcode-image
|
/test-roomcode-image
|
||||||
test-session
|
/test-session
|
||||||
test-upload
|
/test-upload
|
||||||
test-websocket
|
/test-websocket
|
||||||
test-websocket-direct
|
/test-websocket-direct
|
||||||
cmd/kosmi-client/kosmi-clien
|
/cmd/kosmi-client/kosmi-clien
|
||||||
cmd/kosmi-client/kosmi-client
|
/cmd/kosmi-client/kosmi-client
|
||||||
|
/cmd/get-kosmi-token/get-kosmi-token
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
@@ -29,6 +30,7 @@ vendor/
|
|||||||
|
|
||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -80,6 +82,9 @@ build/
|
|||||||
.examples/
|
.examples/
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
bin/
|
bin/
|
||||||
|
tests/
|
||||||
|
utils/
|
||||||
|
.archive/
|
||||||
|
|
||||||
# Persistent data directory (contains cached tokens)
|
# Persistent data directory (contains cached tokens)
|
||||||
data/
|
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
|
# Kosmi-IRC Relay via Matterbridge
|
||||||
|
|
||||||
A Matterbridge plugin that bridges Kosmi chat rooms with IRC channels, enabling bidirectional message relay.
|
A Matterbridge plugin that bridges Kosmi chat rooms with IRC channels, enabling bidirectional message relay.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const (
|
|||||||
EventGetChannelMembers = "get_channel_members"
|
EventGetChannelMembers = "get_channel_members"
|
||||||
EventNoticeIRC = "notice_irc"
|
EventNoticeIRC = "notice_irc"
|
||||||
EventReconnectKosmi = "reconnect_kosmi"
|
EventReconnectKosmi = "reconnect_kosmi"
|
||||||
|
EventReconnectJackbox = "reconnect_jackbox"
|
||||||
|
EventReconnectAll = "reconnect_all"
|
||||||
EventVotesQuery = "votes_query"
|
EventVotesQuery = "votes_query"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package helper
|
package helper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ func CanConvertTgsToX() error {
|
|||||||
// This relies on an external command, which is ugly, but works.
|
// This relies on an external command, which is ugly, but works.
|
||||||
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
|
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
|
||||||
// lottie can't handle input from a pipe, so write to a temporary file:
|
// 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 {
|
if err != nil {
|
||||||
return err
|
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.
|
// 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:
|
// "/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 {
|
if err != nil {
|
||||||
return err
|
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'.
|
// 'stderr' already contains some parts of Stderr, because it was set to 'nil'.
|
||||||
return stderr
|
return stderr
|
||||||
}
|
}
|
||||||
dataContents, err := ioutil.ReadFile(tmpOutFileName)
|
dataContents, err := os.ReadFile(tmpOutFileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -82,7 +81,6 @@ func SupportsFormat(format string) bool {
|
|||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LottieBackend() string {
|
func LottieBackend() string {
|
||||||
|
|||||||
@@ -252,16 +252,15 @@ func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
|
|||||||
rmsg.Text = string(output)
|
rmsg.Text = string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for votes (thisgame++ or thisgame--)
|
// Check for votes (thisgame++/-- or ticker symbol++/--)
|
||||||
// Only process votes from non-relayed messages
|
// Only process votes from non-relayed messages
|
||||||
if !jackbox.IsRelayedMessage(rmsg.Text) {
|
if !jackbox.IsRelayedMessage(rmsg.Text) {
|
||||||
if isVote, voteType := jackbox.DetectVote(rmsg.Text); isVote {
|
if isVote, voteType, ticker := jackbox.DetectVote(rmsg.Text); isVote {
|
||||||
b.Log.Debugf("Detected vote from %s: %s", event.Source.Name, voteType)
|
b.Log.Debugf("Detected vote from %s: %s (ticker=%q)", event.Source.Name, voteType, ticker)
|
||||||
if b.jackboxClient != nil {
|
if b.jackboxClient != nil {
|
||||||
go func() {
|
go func() {
|
||||||
// Use current time as timestamp for IRC messages
|
|
||||||
timestamp := time.Now()
|
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)
|
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
|
// Handle reconnect commands
|
||||||
if strings.TrimSpace(rmsg.Text) == "!kreconnect" {
|
trimmedText := strings.TrimSpace(rmsg.Text)
|
||||||
|
switch trimmedText {
|
||||||
|
case "!kreconnect":
|
||||||
b.Log.Infof("!kreconnect command from %s on %s", event.Source.Name, rmsg.Channel)
|
b.Log.Infof("!kreconnect command from %s on %s", event.Source.Name, rmsg.Channel)
|
||||||
b.Remote <- config.Message{
|
b.Remote <- config.Message{
|
||||||
Username: "system",
|
Username: "system",
|
||||||
@@ -280,6 +281,26 @@ func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
|
|||||||
Event: config.EventReconnectKosmi,
|
Event: config.EventReconnectKosmi,
|
||||||
}
|
}
|
||||||
return
|
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
|
// Handle !votes command: query current game vote tally
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ type AuthResponse struct {
|
|||||||
// VoteRequest represents a vote submission to the API
|
// VoteRequest represents a vote submission to the API
|
||||||
type VoteRequest struct {
|
type VoteRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Vote string `json:"vote"` // "up" or "down"
|
Vote string `json:"vote"` // "up" or "down"
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
|
Ticker string `json:"ticker,omitempty"` // ticker symbol targeting a specific game
|
||||||
}
|
}
|
||||||
|
|
||||||
// VoteResponse represents the API response to a vote submission
|
// VoteResponse represents the API response to a vote submission
|
||||||
@@ -214,8 +215,9 @@ func (c *Client) ensureAuthenticated() error {
|
|||||||
return c.Authenticate()
|
return c.Authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVote sends a vote to the Jackbox API
|
// SendVote sends a vote to the Jackbox API.
|
||||||
func (c *Client) SendVote(username, voteType string, timestamp time.Time) error {
|
// 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
|
// Ensure we're authenticated
|
||||||
if err := c.ensureAuthenticated(); err != nil {
|
if err := c.ensureAuthenticated(); err != nil {
|
||||||
return fmt.Errorf("authentication failed: %w", err)
|
return fmt.Errorf("authentication failed: %w", err)
|
||||||
@@ -230,6 +232,7 @@ func (c *Client) SendVote(username, voteType string, timestamp time.Time) error
|
|||||||
Username: username,
|
Username: username,
|
||||||
Vote: voteType,
|
Vote: voteType,
|
||||||
Timestamp: timestamp.Format(time.RFC3339),
|
Timestamp: timestamp.Format(time.RFC3339),
|
||||||
|
Ticker: ticker,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(voteReq)
|
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)
|
return fmt.Errorf("re-authentication failed: %w", err)
|
||||||
}
|
}
|
||||||
// Retry the vote
|
// Retry the vote
|
||||||
return c.SendVote(username, voteType, timestamp)
|
return c.SendVote(username, voteType, timestamp, ticker)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusConflict {
|
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)
|
// GetClient returns the Jackbox API client (may be nil if disabled)
|
||||||
func (m *Manager) GetClient() *Client {
|
func (m *Manager) GetClient() *Client {
|
||||||
return m.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
|
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
|
// DetectVote checks if a message contains a vote and returns the vote type
|
||||||
// Returns (true, "up") for thisgame++
|
// and optional ticker symbol.
|
||||||
// Returns (true, "down") for thisgame--
|
//
|
||||||
// Returns (false, "") for non-vote messages
|
// For "thisgame++" / "thisgame--": returns (true, "up"/"down", "")
|
||||||
func DetectVote(text string) (isVote bool, voteType string) {
|
// 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)
|
lower := strings.ToLower(text)
|
||||||
|
|
||||||
if strings.Contains(lower, "thisgame++") {
|
if strings.Contains(lower, "thisgame++") {
|
||||||
return true, "up"
|
return true, "up", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(lower, "thisgame--") {
|
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
|
// 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"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
jackboxPingInterval = 30 * time.Second
|
||||||
|
jackboxReadTimeout = 90 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// WebSocketClient handles WebSocket connection to Jackbox API
|
// WebSocketClient handles WebSocket connection to Jackbox API
|
||||||
type WebSocketClient struct {
|
type WebSocketClient struct {
|
||||||
apiURL string
|
apiURL string
|
||||||
@@ -22,6 +27,7 @@ type WebSocketClient struct {
|
|||||||
reconnectDelay time.Duration
|
reconnectDelay time.Duration
|
||||||
maxReconnect time.Duration
|
maxReconnect time.Duration
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
listenDone chan struct{} // closed when the current listen() goroutine exits
|
||||||
connected bool
|
connected bool
|
||||||
authenticated bool
|
authenticated bool
|
||||||
subscribedSession int
|
subscribedSession int
|
||||||
@@ -109,8 +115,11 @@ func (c *WebSocketClient) Connect() error {
|
|||||||
|
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.connected = true
|
c.connected = true
|
||||||
|
c.listenDone = make(chan struct{})
|
||||||
c.log.Info("WebSocket connected")
|
c.log.Info("WebSocket connected")
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(jackboxReadTimeout))
|
||||||
|
|
||||||
// Start message listener
|
// Start message listener
|
||||||
go c.listen()
|
go c.listen()
|
||||||
|
|
||||||
@@ -181,9 +190,9 @@ func (c *WebSocketClient) Unsubscribe(sessionID int) error {
|
|||||||
// listen handles incoming WebSocket messages
|
// listen handles incoming WebSocket messages
|
||||||
func (c *WebSocketClient) listen() {
|
func (c *WebSocketClient) listen() {
|
||||||
defer c.handleDisconnect()
|
defer c.handleDisconnect()
|
||||||
|
defer close(c.listenDone)
|
||||||
|
|
||||||
// Start heartbeat
|
go c.startHeartbeat(c.listenDone)
|
||||||
go c.startHeartbeat()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -196,6 +205,9 @@ func (c *WebSocketClient) listen() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset read deadline on every successful read
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(jackboxReadTimeout))
|
||||||
|
|
||||||
c.handleMessage(message)
|
c.handleMessage(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,15 +450,18 @@ func (c *WebSocketClient) AnnounceSessionEnd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startHeartbeat sends ping messages periodically
|
// startHeartbeat sends ping messages periodically. It exits when listenDone
|
||||||
func (c *WebSocketClient) startHeartbeat() {
|
// is closed (current connection ended) or stopChan is closed (full shutdown).
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
func (c *WebSocketClient) startHeartbeat(listenDone <-chan struct{}) {
|
||||||
|
ticker := time.NewTicker(jackboxPingInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.stopChan:
|
case <-c.stopChan:
|
||||||
return
|
return
|
||||||
|
case <-listenDone:
|
||||||
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.connected && c.conn != nil {
|
if c.connected && c.conn != nil {
|
||||||
@@ -474,7 +489,9 @@ func (c *WebSocketClient) sendMessage(msg WSMessage) error {
|
|||||||
return c.conn.WriteMessage(websocket.TextMessage, data)
|
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() {
|
func (c *WebSocketClient) handleDisconnect() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.connected = false
|
c.connected = false
|
||||||
@@ -487,7 +504,6 @@ func (c *WebSocketClient) handleDisconnect() {
|
|||||||
|
|
||||||
c.log.Warn("WebSocket disconnected, attempting to reconnect...")
|
c.log.Warn("WebSocket disconnected, attempting to reconnect...")
|
||||||
|
|
||||||
// Exponential backoff reconnection
|
|
||||||
delay := c.reconnectDelay
|
delay := c.reconnectDelay
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -496,21 +512,27 @@ func (c *WebSocketClient) handleDisconnect() {
|
|||||||
case <-time.After(delay):
|
case <-time.After(delay):
|
||||||
c.log.Infof("Reconnecting... (delay: %v)", 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 {
|
if err := c.Connect(); err != nil {
|
||||||
c.log.Errorf("Reconnection failed: %v", err)
|
c.log.Errorf("Reconnection failed: %v", err)
|
||||||
|
delay = c.bumpDelay(delay)
|
||||||
// Increase delay with exponential backoff
|
|
||||||
delay *= 2
|
|
||||||
if delay > c.maxReconnect {
|
|
||||||
delay = c.maxReconnect
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnected successfully
|
|
||||||
c.log.Info("Reconnected successfully")
|
c.log.Info("Reconnected successfully")
|
||||||
|
|
||||||
// Re-subscribe if we were subscribed before
|
|
||||||
if c.subscribedSession > 0 {
|
if c.subscribedSession > 0 {
|
||||||
if err := c.Subscribe(c.subscribedSession); err != nil {
|
if err := c.Subscribe(c.subscribedSession); err != nil {
|
||||||
c.log.Errorf("Failed to re-subscribe: %v", err)
|
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
|
// Close closes the WebSocket connection
|
||||||
func (c *WebSocketClient) Close() error {
|
func (c *WebSocketClient) Close() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"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.
|
// 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...")
|
log.Info("Starting browser automation for authentication...")
|
||||||
|
|
||||||
// Create context with timeout
|
// Create context with timeout
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (b *Bkosmi) Connect() error {
|
|||||||
} else {
|
} else {
|
||||||
// No valid cache, authenticate with browser
|
// No valid cache, authenticate with browser
|
||||||
b.Log.Info("Authenticating with email/password...")
|
b.Log.Info("Authenticating with email/password...")
|
||||||
token, err = loginWithChromedp(email, password, b.Log)
|
token, err = LoginWithChromedp(email, password, b.Log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("authentication failed: %w", err)
|
return fmt.Errorf("authentication failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -204,14 +204,14 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for votes (thisgame++ or thisgame--)
|
// Check for votes (thisgame++/-- or ticker symbol++/--)
|
||||||
// Only process votes from non-relayed messages
|
// Only process votes from non-relayed messages
|
||||||
if !jackbox.IsRelayedMessage(body) {
|
if !jackbox.IsRelayedMessage(body) {
|
||||||
if isVote, voteType := jackbox.DetectVote(body); isVote {
|
if isVote, voteType, ticker := jackbox.DetectVote(body); isVote {
|
||||||
b.Log.Debugf("Detected vote from %s: %s", username, voteType)
|
b.Log.Debugf("Detected vote from %s: %s (ticker=%q)", username, voteType, ticker)
|
||||||
if b.jackboxClient != nil {
|
if b.jackboxClient != nil {
|
||||||
go func() {
|
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)
|
b.Log.Errorf("Failed to send vote to Jackbox API: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -36,10 +39,9 @@ func main() {
|
|||||||
fmt.Println(strings.Repeat("=", 80))
|
fmt.Println(strings.Repeat("=", 80))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Get anonymous token
|
// Get anonymous token via HTTP (same mutation the client uses internally)
|
||||||
fmt.Println("📝 Step 1: Getting anonymous token...")
|
fmt.Println("📝 Step 1: Getting anonymous token...")
|
||||||
client := bkosmi.NewGraphQLWSClient("https://app.kosmi.io/room/@test", "@test", entry)
|
anonToken, err := getAnonymousToken()
|
||||||
anonToken, err := client.GetAnonymousTokenForTest()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("❌ Failed to get anonymous token: %v\n", err)
|
fmt.Printf("❌ Failed to get anonymous token: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -47,10 +49,9 @@ func main() {
|
|||||||
fmt.Printf("✅ Anonymous token obtained (length: %d)\n", len(anonToken))
|
fmt.Printf("✅ Anonymous token obtained (length: %d)\n", len(anonToken))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Get authenticated token
|
// Get authenticated token via browser automation
|
||||||
fmt.Println("📝 Step 2: Getting authenticated token via browser...")
|
fmt.Println("📝 Step 2: Getting authenticated token via browser...")
|
||||||
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
|
authToken, err := bkosmi.LoginWithChromedp(email, password, entry)
|
||||||
authToken, err := browserAuth.GetToken()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("❌ Failed to get authenticated token: %v\n", err)
|
fmt.Printf("❌ Failed to get authenticated token: %v\n", err)
|
||||||
os.Exit(1)
|
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{}) {
|
func compareClaims(anon, auth map[string]interface{}) {
|
||||||
allKeys := make(map[string]bool)
|
allKeys := make(map[string]bool)
|
||||||
for k := range anon {
|
for k := range anon {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("=== WebSocket Messages ===\n")
|
fmt.Println("\n=== WebSocket Messages ===")
|
||||||
|
|
||||||
for _, entry := range har.Log.Entries {
|
for _, entry := range har.Log.Entries {
|
||||||
for i, msg := range entry.WebSocketMessages {
|
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
|
msgCount := 0
|
||||||
for _, entry := range har.Log.Entries {
|
for _, entry := range har.Log.Entries {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -23,7 +26,6 @@ func main() {
|
|||||||
email := os.Args[1]
|
email := os.Args[1]
|
||||||
password := os.Args[2]
|
password := os.Args[2]
|
||||||
|
|
||||||
// Set up logging
|
|
||||||
log := logrus.New()
|
log := logrus.New()
|
||||||
log.SetLevel(logrus.DebugLevel)
|
log.SetLevel(logrus.DebugLevel)
|
||||||
entry := logrus.NewEntry(log)
|
entry := logrus.NewEntry(log)
|
||||||
@@ -31,11 +33,7 @@ func main() {
|
|||||||
fmt.Println("🚀 Testing browser-based authentication...")
|
fmt.Println("🚀 Testing browser-based authentication...")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Create browser auth manager
|
token, err := bkosmi.LoginWithChromedp(email, password, entry)
|
||||||
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
|
|
||||||
|
|
||||||
// Get token
|
|
||||||
token, err := browserAuth.GetToken()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("❌ Authentication failed: %v\n", err)
|
fmt.Printf("❌ Authentication failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -48,27 +46,32 @@ func main() {
|
|||||||
fmt.Printf("Token length: %d characters\n", len(token))
|
fmt.Printf("Token length: %d characters\n", len(token))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Check if authenticated
|
userID := extractUserIDFromJWT(token)
|
||||||
if browserAuth.IsAuthenticated() {
|
|
||||||
fmt.Println("✅ Token is valid")
|
|
||||||
} else {
|
|
||||||
fmt.Println("❌ Token is invalid or expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user ID
|
|
||||||
userID := browserAuth.GetUserID()
|
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
fmt.Printf("User ID: %s\n", userID)
|
fmt.Printf("User ID: %s\n", userID)
|
||||||
|
} else {
|
||||||
|
fmt.Println("(Could not extract user ID from token)")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("🎉 Test completed successfully!")
|
fmt.Println("🎉 Test completed successfully!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func extractUserIDFromJWT(token string) string {
|
||||||
if a < b {
|
parts := strings.Split(token, ".")
|
||||||
return a
|
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() {
|
func main() {
|
||||||
fmt.Println("=== Kosmi Image Upload Test ===\n")
|
fmt.Println("=== Kosmi Image Upload Test ===")
|
||||||
|
|
||||||
// Test 1: Generate a room code image
|
// Test 1: Generate a room code image
|
||||||
fmt.Println("1. Generating room code image for 'TEST'...")
|
fmt.Println("1. Generating room code image for 'TEST'...")
|
||||||
imageData, err := jackbox.GenerateRoomCodeImage("TEST")
|
imageData, err := jackbox.GenerateRoomCodeImage("TEST", "Test Game")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to generate image: %v", err)
|
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 (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/42wim/matterbridge/bridge"
|
|
||||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse command line flags
|
|
||||||
roomURL := flag.String("room", "https://app.kosmi.io/room/@hyperspaceout", "Kosmi room URL")
|
roomURL := flag.String("room", "https://app.kosmi.io/room/@hyperspaceout", "Kosmi room URL")
|
||||||
debug := flag.Bool("debug", false, "Enable debug logging")
|
debug := flag.Bool("debug", false, "Enable debug logging")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Set up logger
|
|
||||||
log := logrus.New()
|
log := logrus.New()
|
||||||
if *debug {
|
if *debug {
|
||||||
log.SetLevel(logrus.DebugLevel)
|
log.SetLevel(logrus.DebugLevel)
|
||||||
} else {
|
} else {
|
||||||
log.SetLevel(logrus.InfoLevel)
|
log.SetLevel(logrus.InfoLevel)
|
||||||
}
|
}
|
||||||
log.SetFormatter(&logrus.TextFormatter{
|
log.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
|
||||||
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)
|
logger.Infof("Room URL: %s", *roomURL)
|
||||||
|
|
||||||
// Create bridge configuration
|
roomID, err := extractRoomID(*roomURL)
|
||||||
cfg := bridge.NewConfig("kosmi.test", logger)
|
if err != nil {
|
||||||
cfg.SetString("RoomURL", *roomURL)
|
logger.Fatalf("Failed to extract room ID: %v", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Successfully connected to Kosmi!")
|
client := bkosmi.NewGraphQLWSClient(*roomURL, roomID, "", logger)
|
||||||
|
|
||||||
// Start message listener
|
client.OnMessage(func(payload *bkosmi.NewMessagePayload) {
|
||||||
go func() {
|
username := payload.Data.NewMessage.User.DisplayName
|
||||||
for msg := range cfg.Remote {
|
if username == "" {
|
||||||
logger.Infof("Received message: [%s] %s: %s",
|
username = payload.Data.NewMessage.User.Username
|
||||||
msg.Timestamp.Format("15:04:05"),
|
|
||||||
msg.Username,
|
|
||||||
msg.Text)
|
|
||||||
}
|
}
|
||||||
}()
|
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")
|
logger.Info("Listening for messages... Press Ctrl+C to exit")
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
// Optional: Send a test message after 5 seconds
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
logger.Info("Bridge is running. Messages from Kosmi will appear above.")
|
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
|
<-sigChan
|
||||||
logger.Info("Shutting down...")
|
logger.Info("Shutting down...")
|
||||||
|
|
||||||
// Disconnect
|
if err := client.Disconnect(); err != nil {
|
||||||
if err := b.Disconnect(); err != nil {
|
|
||||||
logger.Errorf("Error disconnecting: %v", err)
|
logger.Errorf("Error disconnecting: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Goodbye!")
|
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!")
|
fmt.Println("✅ WebSocket connected!")
|
||||||
|
|
||||||
// Step 3: Listen for messages
|
// 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
|
messageCount := 0
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func main() {
|
|||||||
fmt.Println("✅ Subscribed!")
|
fmt.Println("✅ Subscribed!")
|
||||||
|
|
||||||
// Listen for messages
|
// 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
|
messageCount := 0
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -57,26 +57,12 @@ func (r *Router) handleEventReconnectKosmi(msg *config.Message) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
originChannel := msg.Channel
|
r.sendConfirmation(msg, "Reconnecting Kosmi...")
|
||||||
originAccount := msg.Account
|
|
||||||
|
|
||||||
for _, gw := range r.Gateways {
|
for _, gw := range r.Gateways {
|
||||||
for _, br := range gw.Bridges {
|
for _, br := range gw.Bridges {
|
||||||
if br.Protocol == "kosmi" {
|
if br.Protocol == "kosmi" {
|
||||||
r.logger.Infof("Reconnecting Kosmi bridge %s (requested via !kreconnect)", br.Account)
|
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)
|
go gw.reconnectBridge(br)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -87,6 +73,79 @@ func (r *Router) handleEventReconnectKosmi(msg *config.Message) bool {
|
|||||||
return true
|
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
|
// handleEventVotesQuery handles a !votes command by fetching vote data for the
|
||||||
// currently playing game and broadcasting the result to all bridges.
|
// currently playing game and broadcasting the result to all bridges.
|
||||||
// Returns true if the event was consumed.
|
// Returns true if the event was consumed.
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ func (r *Router) handleReceive() {
|
|||||||
if r.handleEventReconnectKosmi(&msg) {
|
if r.handleEventReconnectKosmi(&msg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.handleEventReconnectJackbox(&msg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.handleEventReconnectAll(&msg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if r.handleEventVotesQuery(&msg) {
|
if r.handleEventVotesQuery(&msg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user