leaderboard added
All checks were successful
Deploy Matrix Bot / deploy (push) Successful in 10s

This commit is contained in:
shinya 2026-05-16 09:58:43 +02:00
parent d7274d723d
commit f35433af77
5 changed files with 503 additions and 43 deletions

View File

@ -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

12
go.mod
View File

@ -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
)

50
go.sum
View File

@ -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=

230
main.go
View File

@ -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...")

230
persistent/database.go Normal file
View File

@ -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,
)
}