diff --git a/bridge/irc/handlers.go b/bridge/irc/handlers.go index 8ed856f..96ade2e 100644 --- a/bridge/irc/handlers.go +++ b/bridge/irc/handlers.go @@ -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) } }() diff --git a/bridge/jackbox/client.go b/bridge/jackbox/client.go index b1943b1..8f248ab 100644 --- a/bridge/jackbox/client.go +++ b/bridge/jackbox/client.go @@ -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 { diff --git a/bridge/jackbox/tickers.go b/bridge/jackbox/tickers.go new file mode 100644 index 0000000..7e1b373 --- /dev/null +++ b/bridge/jackbox/tickers.go @@ -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 +} diff --git a/bridge/jackbox/votes.go b/bridge/jackbox/votes.go index 3e64a58..65a1e71 100644 --- a/bridge/jackbox/votes.go +++ b/bridge/jackbox/votes.go @@ -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 diff --git a/bridge/jackbox/votes_test.go b/bridge/jackbox/votes_test.go new file mode 100644 index 0000000..b90d871 --- /dev/null +++ b/bridge/jackbox/votes_test.go @@ -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) + } + } +} diff --git a/bridge/kosmi/kosmi.go b/bridge/kosmi/kosmi.go index f0701c9..ee42da3 100644 --- a/bridge/kosmi/kosmi.go +++ b/bridge/kosmi/kosmi.go @@ -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) } }()