diff --git a/bot/bot.go b/bot/bot.go index bb71cec..266d7c5 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -5,6 +5,7 @@ import ( "context" "log" "matrix-bot/game" + database "matrix-bot/persistent" "matrix-bot/wordleimg" "matrix-bot/words" "os" @@ -46,6 +47,11 @@ func HandleCommand(body string, sender string, roomID string, pair := roomUserPair{roomID, sender} part := strings.Fields(strings.ToLower(body)) + db, err := database.InitDB("wordle.db") + if err != nil { + log.Fatal(err) + } + defer db.Close() _, e := userSettings[sender] if !e { userSettings[sender] = make(map[string]string) @@ -133,8 +139,16 @@ func HandleCommand(body string, sender string, roomID string, delete(wordleGames, pair) switch sig { case -1: + err = db.IncrementLosses(sender) + if err != nil { + log.Fatal(err) + } return jia(txt("Wordle:\nEnded in lost, word was "+wg.GetSecret(), replyTo), wordleState()) case 1: + err = db.IncrementWins(sender) + if err != nil { + log.Fatal(err) + } return jia(txt("Wordle:\nYou guessed right!", replyTo), wordleState()) } } else if equals(part[1], "status", "stat", "s") { @@ -146,8 +160,18 @@ func HandleCommand(body string, sender string, roomID string, if !wgEx { return jia(txt("Wordle:\nNone to end", replyTo)) } + + err = db.IncrementGamesPlayed(sender) + if err != nil { + log.Fatal(err) + } + delete(wordleGames, pair) return jia(txt("Wordle:\nEnded", replyTo)) + } else if equals(part[1], "leaderboard", "l", "lead") { + stats := db.FormatLeaderboard() + + return jia(txt(stats, nil)) } } return nil diff --git a/go.mod b/go.mod index 3e8bc48..eb766b1 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,21 @@ go 1.25.5 require ( github.com/fogleman/gg v1.3.0 + github.com/mattn/go-sqlite3 v1.14.34 maunium.net/go/mautrix v0.26.2 + modernc.org/sqlite v1.50.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 @@ -24,6 +29,9 @@ require ( golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/image v0.36.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index be23ec0..1d67ae1 100644 --- a/go.sum +++ b/go.sum @@ -5,11 +5,19 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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= @@ -19,11 +27,15 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= @@ -47,16 +59,50 @@ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7 golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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.26.2 h1:rLiZLQoSKCJDZ+mF1gBQS4p74h3jZXs83g8D4W6Te8g= maunium.net/go/mautrix v0.26.2/go.mod h1:CUxSZcjPtQNxsZLRQqETAxg2hiz7bjWT+L1HCYoMMKo= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index 5d5ed7b..9fbaaea 100644 --- a/main.go +++ b/main.go @@ -2,29 +2,31 @@ package main import ( "context" + "database/sql" "encoding/json" "errors" + "fmt" "log" "matrix-bot/bot" "os" "path/filepath" "strings" "sync" + "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + _ "github.com/mattn/go-sqlite3" ) var homeserver string var username string var password string var roomID string -var userId string -var accessToken string -var deviceId string var pickleKeyString string var recoveryKey string var cryptoDBPath string @@ -37,8 +39,114 @@ type storedCredentials struct { DeviceID string `json:"device_id"` } +type authManager struct { + mu sync.Mutex + client *mautrix.Client + credentialsPath string + + username string + password string + + afterLogin func(ctx context.Context) error +} + +func isInvalidToken(err error) bool { + if err == nil { + return false + } + if errors.Is(err, mautrix.MUnknownToken) || errors.Is(err, mautrix.MMissingToken) { + return true + } + var httpErr mautrix.HTTPError + if errors.As(err, &httpErr) { + // Some servers return 401 without a Matrix errcode. + return httpErr.IsStatus(401) + } + return false +} + +func (a *authManager) relogin(ctx context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.username == "" || a.password == "" { + return errors.New("missing MATRIX_USERNAME or MATRIX_PASSWORD for re-login") + } + + preferredDeviceID := a.client.DeviceID + log.Printf("Re-logging in to Matrix (preferred device_id=%q)", preferredDeviceID) + _, err := a.client.Login(ctx, &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: a.username, + }, + Password: a.password, + DeviceID: preferredDeviceID, + StoreCredentials: true, + }) + if err != nil { + return err + } + + if err := saveStoredCredentials(a.credentialsPath, &storedCredentials{ + UserID: a.client.UserID.String(), + AccessToken: a.client.AccessToken, + DeviceID: a.client.DeviceID.String(), + }); err != nil { + return err + } + log.Println("Updated credentials in", a.credentialsPath) + + if a.afterLogin != nil { + if err := a.afterLogin(ctx); err != nil { + if isOlmAccountMismatch(err) { + log.Println("Detected olm account mismatch with server keys") + if cryptoResetOnMismatch { + if resetErr := resetCryptoState(); resetErr != nil { + log.Fatal(resetErr) + } + log.Fatal("Reset crypto state due to mismatch. Restart the container to re-login.") + } + log.Fatal("Crypto mismatch. Remove crypto DB and credentials, then restart.") + } + log.Println("Post-login recovery key verification failed:", err) + } + } + + return nil +} + +func runSyncWithAutoRelogin(ctx context.Context, client *mautrix.Client, auth *authManager) { + backoff := 2 * time.Second + maxBackoff := 30 * time.Second + for { + err := client.SyncWithContext(ctx) + if err == nil { + return + } + if ctx.Err() != nil { + return + } + if isInvalidToken(err) { + log.Println("Matrix token invalid/expired; attempting re-login:", err) + if err2 := auth.relogin(ctx); err2 != nil { + log.Println("Re-login failed:", err2) + time.Sleep(backoff) + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } + backoff = 2 * time.Second + continue + } + log.Fatal(err) + } +} + func setupCryptoHelper(cli *mautrix.Client) (*cryptohelper.CryptoHelper, error) { - // remember to use a secure key for the pickle key in production pickleKey := []byte(pickleKeyString) if cryptoDBPath != "" { @@ -138,14 +246,46 @@ func saveStoredCredentials(path string, creds *storedCredentials) error { return os.WriteFile(path, data, 0o600) } +func readDeviceIDFromCryptoDB(path string) (id.DeviceID, error) { + if path == "" { + return "", nil + } + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + + // Match mautrix's default DSN style. + dsn := fmt.Sprintf("file:%s?_txlock=immediate", path) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return "", err + } + defer db.Close() + + var deviceID id.DeviceID + // CryptoHelper defaults DBAccountID to "", so account_id is "" unless explicitly set. + err = db.QueryRow("SELECT device_id FROM crypto_account WHERE account_id = '' LIMIT 1").Scan(&deviceID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + // Fresh/invalid DB. + if strings.Contains(err.Error(), "no such table") { + return "", nil + } + return "", err + } + return deviceID, nil +} + func loadConfig() { homeserver = envOrFatal("MATRIX_HOMESERVER") - username = envOrDefault("MATRIX_USERNAME", "") - password = envOrDefault("MATRIX_PASSWORD", "") + username = envOrFatal("MATRIX_USERNAME") + password = envOrFatal("MATRIX_PASSWORD") roomID = envOrDefault("MATRIX_ROOM_ID", "") - userId = envOrDefault("MATRIX_USER_ID", "") - accessToken = envOrDefault("MATRIX_ACCESS_TOKEN", "") - deviceId = envOrDefault("MATRIX_DEVICE_ID", "") pickleKeyString = envOrFatal("MATRIX_PICKLE_KEY") recoveryKey = envOrFatal("MATRIX_RECOVERY_KEY") cryptoDBPath = envOrDefault("MATRIX_CRYPTO_DB", "crypto.db") @@ -181,35 +321,48 @@ func main() { loadConfig() bot.Load() + cryptoDeviceID, err := readDeviceIDFromCryptoDB(cryptoDBPath) + if err != nil { + log.Fatal(err) + } + forceLogin := false + stored, err := loadStoredCredentials(credentialsPath) if err != nil { log.Fatal(err) } + var cachedUserID string + var cachedAccessToken string + var cachedDeviceID string if stored != nil { - if accessToken == "" { - accessToken = stored.AccessToken - } - if deviceId == "" { - deviceId = stored.DeviceID - } - if userId == "" { - userId = stored.UserID - } - if accessToken != "" || deviceId != "" || userId != "" { + cachedUserID = stored.UserID + cachedAccessToken = stored.AccessToken + cachedDeviceID = stored.DeviceID + if cachedUserID != "" || cachedAccessToken != "" || cachedDeviceID != "" { log.Println("Loaded credentials from", credentialsPath) } } - client, err := mautrix.NewClient(homeserver, id.UserID(userId), accessToken) + client, err := mautrix.NewClient(homeserver, id.UserID(cachedUserID), cachedAccessToken) if err != nil { log.Fatal(err) } - if accessToken == "" || deviceId == "" { - if username == "" || password == "" { - log.Fatal("missing MATRIX_USERNAME or MATRIX_PASSWORD for credential bootstrap") + // Device ID source of truth: + // 1. crypto DB (if present) + // 2. cached credentials + if cryptoDeviceID != "" { + client.DeviceID = cryptoDeviceID + if cachedDeviceID != "" && cachedDeviceID != cryptoDeviceID.String() { + log.Printf("Device ID mismatch between credentials and crypto DB (%q != %q). Will re-login using crypto DB device ID.", cachedDeviceID, cryptoDeviceID) + forceLogin = true } - log.Println("Logging in to Matrix to bootstrap credentials") + } else if cachedDeviceID != "" { + client.DeviceID = id.DeviceID(cachedDeviceID) + } + + if client.AccessToken == "" || client.UserID == "" || client.DeviceID == "" || forceLogin { + log.Println("Logging in to Matrix") _, err = client.Login(context.Background(), &mautrix.ReqLogin{ Type: mautrix.AuthTypePassword, Identifier: mautrix.UserIdentifier{ @@ -217,30 +370,28 @@ func main() { User: username, }, Password: password, + DeviceID: client.DeviceID, StoreCredentials: true, }) if err != nil { log.Fatal(err) } - accessToken = client.AccessToken - userId = client.UserID.String() - deviceId = client.DeviceID.String() if err := saveStoredCredentials(credentialsPath, &storedCredentials{ - UserID: userId, - AccessToken: accessToken, - DeviceID: deviceId, + UserID: client.UserID.String(), + AccessToken: client.AccessToken, + DeviceID: client.DeviceID.String(), }); err != nil { log.Fatal(err) } log.Println("Saved credentials to", credentialsPath) } - - if userId == "" { - log.Fatal("missing MATRIX_USER_ID and no stored credentials") + auth := &authManager{ + client: client, + credentialsPath: credentialsPath, + username: username, + password: password, } - client.DeviceID = id.DeviceID(deviceId) - syncer := mautrix.NewDefaultSyncer() client.Syncer = syncer @@ -259,10 +410,13 @@ func main() { log.Fatal(err) } client.Crypto = cryptoHelper + auth.afterLogin = func(ctx context.Context) error { + return verifyWithRecoveryKey(cryptoHelper.Machine()) + } syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { // Ignore our own messages - if evt.Sender.String() == userId { + if client.UserID != "" && evt.Sender == client.UserID { return } @@ -301,9 +455,7 @@ func main() { }) go func() { - if err := client.Sync(); err != nil { - log.Fatal(err) - } + runSyncWithAutoRelogin(context.Background(), client, auth) }() log.Println("Waiting for initial sync...") diff --git a/persistent/database.go b/persistent/database.go new file mode 100644 index 0000000..f14e18b --- /dev/null +++ b/persistent/database.go @@ -0,0 +1,230 @@ +package database + +import ( + "database/sql" + "fmt" + "strings" + + _ "modernc.org/sqlite" +) + +type DB struct { + conn *sql.DB +} + +type UserStats struct { + UserID string + Wins int + Losses int + GamesPlayed int +} + +// InitDB opens the database and creates tables if needed +func InitDB(path string) (*DB, error) { + conn, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + createTable := ` + CREATE TABLE IF NOT EXISTS user_scores ( + user_id TEXT PRIMARY KEY, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + games_played INTEGER NOT NULL DEFAULT 0 + ); + ` + + _, err = conn.Exec(createTable) + if err != nil { + return nil, err + } + + return &DB{ + conn: conn, + }, nil +} + +// ensureUser creates the user row if it doesn't exist +func (db *DB) ensureUser(userID string) error { + query := ` + INSERT INTO user_scores (user_id) + VALUES (?) + ON CONFLICT(user_id) DO NOTHING; + ` + + _, err := db.conn.Exec(query, userID) + return err +} + +// IncrementWins increments wins and games played +func (db *DB) IncrementWins(userID string) error { + if err := db.ensureUser(userID); err != nil { + return err + } + + query := ` + UPDATE user_scores + SET wins = wins + 1, + games_played = games_played + 1 + WHERE user_id = ?; + ` + + _, err := db.conn.Exec(query, userID) + return err +} + +// IncrementLosses increments losses and games played +func (db *DB) IncrementLosses(userID string) error { + if err := db.ensureUser(userID); err != nil { + return err + } + + query := ` + UPDATE user_scores + SET losses = losses + 1, + games_played = games_played + 1 + WHERE user_id = ?; + ` + + _, err := db.conn.Exec(query, userID) + return err +} + +// IncrementGamesPlayed increments only games played +func (db *DB) IncrementGamesPlayed(userID string) error { + if err := db.ensureUser(userID); err != nil { + return err + } + + query := ` + UPDATE user_scores + SET games_played = games_played + 1 + WHERE user_id = ?; + ` + + _, err := db.conn.Exec(query, userID) + return err +} + +// GetUserStats returns stats for a user +func (db *DB) GetUserStats(userID string) (*UserStats, error) { + if err := db.ensureUser(userID); err != nil { + return nil, err + } + + query := ` + SELECT user_id, wins, losses, games_played + FROM user_scores + WHERE user_id = ?; + ` + + row := db.conn.QueryRow(query, userID) + + stats := &UserStats{} + + err := row.Scan( + &stats.UserID, + &stats.Wins, + &stats.Losses, + &stats.GamesPlayed, + ) + + if err != nil { + return nil, err + } + + return stats, nil +} +func (db *DB) FormatLeaderboard() string { + var b strings.Builder + + users, err := db.GetUsersStats() + if err != nil { + return "failed to load leaderboard" + } + + b.WriteString("🏆 WORDLE LEADERBOARD 🏆\n\n") + + for i, u := range users { + rank := fmt.Sprintf("#%d", i+1) + + switch i { + case 0: + rank = "🥇" + case 1: + rank = "🥈" + case 2: + rank = "🥉" + } + + username := u.UserID + + row := fmt.Sprintf( + "%s %s\nWins: %d | Losses: %d | Games: %d\n\n", + rank, + username, + u.Wins, + u.Losses, + u.GamesPlayed, + ) + + b.WriteString(row) + } + + return b.String() +} + +func (db *DB) GetUsersStats() ([]UserStats, error) { + query := ` + SELECT user_id, wins, losses, games_played + FROM user_scores + ORDER BY wins DESC; + ` + + rows, err := db.conn.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []UserStats + + for rows.Next() { + var user UserStats + + err := rows.Scan( + &user.UserID, + &user.Wins, + &user.Losses, + &user.GamesPlayed, + ) + if err != nil { + return nil, err + } + + users = append(users, user) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return users, nil +} + +// Close closes the database connection +func (db *DB) Close() error { + return db.conn.Close() +} + +// Optional helper to print stats +func (s UserStats) String() string { + return fmt.Sprintf( + "User: %s | Wins: %d | Losses: %d | Games: %d", + s.UserID, + s.Wins, + s.Losses, + s.GamesPlayed, + ) +}