commit 0cef3e0a906afcb01698265d90787184e3e4e0e3 Author: phlux Date: Fri May 9 03:16:10 2025 +0000 First commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cea6464 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Dockerfile for Matrix Sports Bot + +# --- Build Stage --- +FROM golang:1.20-alpine AS builder + +# Install git for module downloads +RUN apk add --no-cache git + +WORKDIR /app + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Build binary +RUN go build -o matrix-scores-bot ./main.go + +# --- Final Stage --- +FROM alpine:3.18 + +# Install certificates +RUN apk add --no-cache ca-certificates + +WORKDIR /root/ + +# Copy the built binary +COPY --from=builder /app/matrix-scores-bot . + +# Expose any port if needed (Matrix client doesn't listen) +# EXPOSE 0 + +# Set entrypoint +ENTRYPOINT ["./matrix-scores-bot"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3ebcfc --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Matrix Sports Bot + +A Matrix bot written in Go that retrieves and displays college football (CFB) and basketball (CBB) scores using the `ncaa.ewnix.net` API. It listens for `!cfb` and `!cbb` commands in Matrix rooms and replies with formatted game information. + +## Features + +- **CFB Scores**: `!cfb ` +- **CBB Scores**: `!cbb [MM/DD]` +- Automatic conversion of dates to Central Time +- Custom team name aliases via configuration +- HTML formatting for bold scores in Matrix +- Dockerized for easy deployment to Kubernetes (K3s) + +## Prerequisites + +- Go 1.20+ (for local development) +- Docker (for building the container) +- Access to a Matrix homeserver (e.g., Synapse) +- A bot user created on your Matrix server with an access token + +## Configuration + +1. Open `config/config.go`. +2. Add your Matrix credentials: + + ```go + return &Config{ + Homeserver: "https://matrix.your-server.net", + UserID: "@your-bot:your-server.net", + AccessToken: "YOUR_ACCESS_TOKEN", + } + ``` + +3. (Optional) Define custom team name aliases: + + ```go + var CustomTeamNames = map[string]string{ + "bama": "alabama", + "osu": "ohio state", + } + ``` + +## Local Development + +1. Clone the repository and navigate to its root: + + ```bash + git clone https://github.com/your-org/matrix-scores-bot.git + cd matrix-scores-bot + ``` + +2. Initialize Go modules and download dependencies: + + ```bash + go mod init matrix-scores-bot + go mod tidy + ``` + +3. Build and run: + + ```bash + go run main.go + ``` + +4. Invite the bot to a Matrix room and send commands: + + ```text + !cfb alabama + !cbb duke 12/05 + ``` + +## Docker Deployment + +1. Build the Docker image: + + ```bash + docker build -t matrix-scores-bot:latest . + ``` + +2. Run the container: + + ```bash + docker run -d --name matrix-scores-bot matrix-scores-bot:latest + ``` + +> **Note:** The bot’s credentials are hardcoded in `config/config.go`. Rebuild the image after updating credentials. + +## Kubernetes (K3s) + +You can deploy the bot as a Deployment in K3s. Create a Secret for your access token and mount it, or update `config/config.go` before building. Follow standard practices for containerized services. + +## Command Reference + +- `!cfb `: Retrieves the latest college football game info for the specified team. +- `!cbb [MM/DD]`: Retrieves college basketball game info for the specified team on the given date (defaults to today in Central Time). + +## License + +MIT © Kevin M. Thompson + diff --git a/cbb/cbb_client.go b/cbb/cbb_client.go new file mode 100644 index 0000000..bc952bf --- /dev/null +++ b/cbb/cbb_client.go @@ -0,0 +1,124 @@ +package cbb + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "strings" + "time" + "unicode" +) + +// Game represents the nested game structure from the API response. +type Game struct { + Game struct { + GameID string `json:"gameID"` + StartDate string `json:"startDate"` + StartTime string `json:"startTime"` + GameState string `json:"gameState"` + CurrentPeriod string `json:"currentPeriod"` + ContestClock string `json:"contestClock"` + Home Team `json:"home"` + Away Team `json:"away"` + FinalMessage string `json:"finalMessage"` + } `json:"game"` +} + +// Team holds score and name details. +type Team struct { + Score string `json:"score"` + Names struct { + Short string `json:"short"` + Full string `json:"full"` + } `json:"names"` +} + +// GetGameInfo fetches and returns basketball game info for a team on a given date. +func GetGameInfo(teamName, date string) string { + teamKey := strings.ToLower(strings.TrimSpace(teamName)) + + // Determine date: today in Central Time if none provided + if date == "" { + loc, err := time.LoadLocation("America/Chicago") + if err != nil { + loc = time.FixedZone("CST", -6*60*60) + } + date = time.Now().In(loc).Format("2006/01/02") + } else { + // Accept MM/DD and convert + r := []rune(date) + if len(r) == 5 && unicode.IsDigit(r[0]) && unicode.IsDigit(r[1]) && r[2] == '/' && unicode.IsDigit(r[3]) && unicode.IsDigit(r[4]) { + date = fmt.Sprintf("2025/%s", date) + } else { + return "Invalid date format. Please use MM/DD." + } + } + + apiURL := fmt.Sprintf("https://ncaa.ewnix.net/scoreboard/basketball-men/d1/%s", date) + log.Printf("Fetching CBB data from API: %s", apiURL) + + resp, err := http.Get(apiURL) + if err != nil { + return fmt.Sprintf("Failed to reach the scoreboard API: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf("API error: status code %d", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Sprintf("Error reading API response: %v", err) + } + + var apiResponse struct { + Games []Game `json:"games"` + } + if err := json.Unmarshal(body, &apiResponse); err != nil { + return "Error parsing the API response." + } + + results := []string{} + for _, g := range apiResponse.Games { + h := strings.ToLower(g.Game.Home.Names.Short) + a := strings.ToLower(g.Game.Away.Names.Short) + if h == teamKey || a == teamKey { + s := g.Game + var info string + if s.GameState == "live" { + period := s.CurrentPeriod + if period == "" { + period = "HALFTIME" + } + info = fmt.Sprintf("Live: %s: %s %s: %s | Half: %s | Time Remaining: %s", + s.Away.Names.Short, s.Away.Score, + s.Home.Names.Short, s.Home.Score, + period, s.ContestClock) + } else if s.GameState == "pre" { + startTime, err := time.Parse("03:04PM ET", s.StartTime) + if err == nil { + startTime = startTime.Add(-1 * time.Hour) + info = fmt.Sprintf("Upcoming: %s @ %s on %s at %s CT", + s.Away.Names.Short, s.Home.Names.Short, s.StartDate, startTime.Format("03:04 PM CT")) + } else { + info = fmt.Sprintf("Upcoming: %s @ %s on %s at %s", + s.Away.Names.Short, s.Home.Names.Short, s.StartDate, s.StartTime) + } + } else if s.GameState == "final" { + info = fmt.Sprintf("Final: %s: %s %s: %s", + s.Away.Names.Short, s.Away.Score, + s.Home.Names.Short, s.Home.Score) + } + results = append(results, info) + } + } + + if len(results) > 0 { + return strings.Join(results, "\n") + } + + return "No game found for the specified team." +} diff --git a/cfb/cfb_client.go b/cfb/cfb_client.go new file mode 100644 index 0000000..0a41f9e --- /dev/null +++ b/cfb/cfb_client.go @@ -0,0 +1,146 @@ +package cfb + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "strconv" + "strings" + "time" + "unicode" + + "matrix-scores-bot/config" +) + +// Game holds the nested game data structure from the API. +type Game struct { + Game struct { + GameID string `json:"gameID"` + StartDate string `json:"startDate"` + StartTime string `json:"startTime"` + StartTimeEpoch string `json:"startTimeEpoch"` + GameState string `json:"gameState"` + CurrentPeriod string `json:"currentPeriod"` + ContestClock string `json:"contestClock"` + Home Team `json:"home"` + Away Team `json:"away"` + FinalMessage string `json:"finalMessage"` + } `json:"game"` +} + +// Team holds the team's score and names. +type Team struct { + Score string `json:"score"` + Names struct { + Short string `json:"short"` + Full string `json:"full"` + } `json:"names"` +} + +// GetGameInfo fetches and returns information for a specific team or week. +func GetGameInfo(input string) string { + teamName := strings.TrimSpace(strings.ToLower(input)) + week := "" + + // Replace custom team name if configured + if custom, ok := config.CustomTeamNames[teamName]; ok { + log.Printf("Custom team name detected: %s -> %s", teamName, custom) + teamName = custom + } + + // Detect week if last two chars are digits + if len(teamName) >= 2 { + last2 := teamName[len(teamName)-2:] + if unicode.IsDigit(rune(last2[0])) && unicode.IsDigit(rune(last2[1])) { + week = last2 + teamName = strings.TrimSpace(teamName[:len(teamName)-2]) + log.Printf("Week detected: %s", week) + } + } + + // Build URL + url := fmt.Sprintf("https://ncaa.ewnix.net/scoreboard/football/fbs/2024/%s/all-conf", week) + if week == "" { + url = "https://ncaa.ewnix.net/scoreboard/football/fbs/2024/current/all-conf" + } + log.Printf("Fetching CFB data from API: %s", url) + + resp, err := http.Get(url) + if err != nil { + return fmt.Sprintf("Failed to reach the scoreboard API: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf("API error: status code %d", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Sprintf("Error reading API response: %v", err) + } + + var apiResponse struct { + Games []Game `json:"games"` + } + if err := json.Unmarshal(body, &apiResponse); err != nil { + return "Error parsing the API response." + } + + results := []string{} + teamKey := strings.ToLower(teamName) + + for _, g := range apiResponse.Games { + h := strings.ToLower(g.Game.Home.Names.Short) + a := strings.ToLower(g.Game.Away.Names.Short) + + if h == teamKey || a == teamKey { + var info string + s := g.Game + if s.GameState == "live" { + period := s.CurrentPeriod + if period == "" { + period = "HALFTIME" + } + info = fmt.Sprintf("%s: %s %s: %s | Quarter: %s | Time Remaining: %s", + s.Away.Names.Short, s.Away.Score, + s.Home.Names.Short, s.Home.Score, + period, s.ContestClock) + } else if s.GameState == "pre" { + timeCT, err := convertEpochToCentralTime(s.StartTimeEpoch) + if err != nil { + timeCT = s.StartTime + } + info = fmt.Sprintf("Upcoming: %s @ %s on %s at %s CT", + s.Away.Names.Short, s.Home.Names.Short, s.StartDate, timeCT) + } else if s.GameState == "final" { + info = fmt.Sprintf("Final: %s: %s %s: %s", + s.Away.Names.Short, s.Away.Score, + s.Home.Names.Short, s.Home.Score) + } + results = append(results, info) + } + } + + if len(results) > 0 { + return strings.Join(results, "\n") + } + + return "No game found for the specified team." +} + +func convertEpochToCentralTime(epochStr string) (string, error) { + epoch, err := strconv.ParseInt(epochStr, 10, 64) + if err != nil { + return "", fmt.Errorf("error parsing epoch: %w", err) + } + + t := time.Unix(epoch, 0).UTC() + loc, err := time.LoadLocation("America/Chicago") + if err != nil { + return "", fmt.Errorf("error loading location: %w", err) + } + return t.In(loc).Format("03:04 PM"), nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..6fe15ec --- /dev/null +++ b/config/config.go @@ -0,0 +1,24 @@ +package config + +// CustomTeamNames maps lowercase custom inputs to API-recognized team codes. +// Add aliases here, e.g.: "bama": "alabama", "osu": "ohio state" +var CustomTeamNames = map[string]string{ + "bama": "alabama", + "barn": "auburn", +} + +// Config holds the Matrix homeserver URL, bot user ID, and access token. +type Config struct { + Homeserver string + UserID string + AccessToken string +} + +// Load returns configuration with hardcoded values; replace with your bot's credentials. +func Load() *Config { + return &Config{ + Homeserver: "", // the URL to your Homeserver + UserID: "", // your bot's user ID + AccessToken: "", // your bot's access token + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b3bbc4a --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module matrix-scores-bot + +go 1.24.2 + +require maunium.net/go/mautrix v0.23.3 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.8.6 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c1c0ef6 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= +go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.23.3 h1:U+fzdcLhFKLUm5gf2+Q0hEUqWkwDMRfvE+paUH9ogSk= +maunium.net/go/mautrix v0.23.3/go.mod h1:LX+3evXVKSvh/b43BVC3rkvN2qV7b0bkIV4fY7Snn/4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..57d04fe --- /dev/null +++ b/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "matrix-scores-bot/config" + "matrix-scores-bot/matrixbot" + + mautrix "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +func main() { + cfg := config.Load() + + // Initialize Matrix client with homeserver URL, bot user ID, and access token + client, err := mautrix.NewClient(cfg.Homeserver, id.UserID(cfg.UserID), cfg.AccessToken) + if err != nil { + log.Fatalf("Failed to create Matrix client: %v", err) + } + + bot := matrixbot.NewBot(client) + log.Println("Matrix sports bot started...") + if err := bot.Start(); err != nil { + log.Fatalf("Bot failed: %v", err) + } +} diff --git a/matrixbot/bot.go b/matrixbot/bot.go new file mode 100644 index 0000000..fae95ee --- /dev/null +++ b/matrixbot/bot.go @@ -0,0 +1,37 @@ +package matrixbot + +import ( + "context" + "time" + + mautrix "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// Bot encapsulates a Matrix client. +type Bot struct { + Client *mautrix.Client +} + +// NewBot creates a new Bot instance. +func NewBot(client *mautrix.Client) *Bot { + return &Bot{Client: client} +} + +// Start begins syncing with the Matrix server and sets up event handlers. +// It waits 30 seconds after connecting before registering the message handler +// to allow historical messages to be skipped. +func (b *Bot) Start() error { + syncer := b.Client.Syncer.(*mautrix.DefaultSyncer) + + // Pause for 30 seconds to skip over historical messages + time.Sleep(30 * time.Second) + + // Register message handler for new events only + syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + go HandleMessage(evt, b.Client) + }) + + // Start the sync loop (blocks until error). + return b.Client.Sync() +} diff --git a/matrixbot/handler.go b/matrixbot/handler.go new file mode 100644 index 0000000..183859c --- /dev/null +++ b/matrixbot/handler.go @@ -0,0 +1,70 @@ +package matrixbot + +import ( + "context" + "strings" + + "matrix-scores-bot/cbb" + "matrix-scores-bot/cfb" + + mautrix "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// HandleMessage processes incoming Matrix messages and dispatches commands with HTML formatting. +func HandleMessage(evt *event.Event, client *mautrix.Client) { + msg := evt.Content.AsMessage() + if msg.MsgType != event.MsgText { + return + } + fields := strings.Fields(msg.Body) + if len(fields) == 0 { + return + } + + cmd := fields[0] + var plain, html string + + switch cmd { + case "!cfb": + if len(fields) < 2 { + plain = "Usage: !cfb " + html = plain + } else { + team := strings.Join(fields[1:], " ") + html = cfb.GetGameInfo(team) + plain = strings.ReplaceAll(html, "", "") + plain = strings.ReplaceAll(plain, "", "") + } + + case "!cbb": + if len(fields) < 2 { + plain = "Usage: !cbb [MM/DD]" + html = plain + } else { + team := fields[1] + date := "" + if len(fields) >= 3 { + date = fields[2] + } + html = cbb.GetGameInfo(team, date) + plain = strings.ReplaceAll(html, "", "") + plain = strings.ReplaceAll(plain, "", "") + } + + default: + return + } + + content := event.MessageEventContent{ + MsgType: event.MsgText, + Body: plain, + Format: "org.matrix.custom.html", + FormattedBody: html, + } + + if _, err := client.SendMessageEvent(context.Background(), evt.RoomID, event.EventMessage, content); err != nil { + // Log failure but do not panic + println("Failed to send formatted message:", err.Error()) + } +}