]> git.xonotic.org Git - xonotic/xonotic.git/commitdiff
Add our power levels Matrix bot.
authorRudolf Polzer <divVerent@gmail.com>
Mon, 7 Feb 2022 21:38:29 +0000 (22:38 +0100)
committerRudolf Polzer <divVerent@gmail.com>
Mon, 7 Feb 2022 21:38:29 +0000 (22:38 +0100)
misc/infrastructure/powerbot/bot.go [new file with mode: 0644]
misc/infrastructure/powerbot/db.go [new file with mode: 0644]
misc/infrastructure/powerbot/go.mod [new file with mode: 0644]
misc/infrastructure/powerbot/go.sum [new file with mode: 0644]
misc/infrastructure/powerbot/powerlevels.go [new file with mode: 0644]

diff --git a/misc/infrastructure/powerbot/bot.go b/misc/infrastructure/powerbot/bot.go
new file mode 100644 (file)
index 0000000..2917c1d
--- /dev/null
@@ -0,0 +1,267 @@
+package main
+
+import (
+       "encoding/json"
+       "fmt"
+       "io/ioutil"
+       "log"
+       "maunium.net/go/mautrix"
+       "maunium.net/go/mautrix/event"
+       "maunium.net/go/mautrix/id"
+       "strings"
+       "sync"
+       "time"
+)
+
+const (
+       syncInterval       = time.Minute
+       syncForceFrequency = 24 * 60
+)
+
+type Config struct {
+       Homeserver  string        `json:"homeserver"`
+       UserID      id.UserID     `json:"user_id"`
+       Password    string        `json:"password,omitempty"`
+       DeviceID    id.DeviceID   `json:"device_id,omitempty"`
+       AccessToken string        `json:"access_token,omitempty"`
+       Rooms       [][]id.RoomID `json:"rooms"`
+}
+
+func (c *Config) Load() error {
+       log.Printf("Loading config.")
+       data, err := ioutil.ReadFile("config.json")
+       if err != nil {
+               return err
+       }
+       return json.Unmarshal(data, c)
+}
+
+func (c *Config) Save() error {
+       log.Printf("Saving config.")
+       data, err := json.MarshalIndent(c, "", "\t")
+       if err != nil {
+               return err
+       }
+       return ioutil.WriteFile("config.json", data, 0700)
+}
+
+func Login(config *Config) (*mautrix.Client, error) {
+       // Note: we have to lower case the user ID for Matrix protocol communication.
+       uid := id.UserID(strings.ToLower(string(config.UserID)))
+       client, err := mautrix.NewClient(config.Homeserver, uid, config.AccessToken)
+       if err != nil {
+               return nil, fmt.Errorf("failed to create client: %v", err)
+       }
+       if config.AccessToken == "" {
+               resp, err := client.Login(&mautrix.ReqLogin{
+                       Type: mautrix.AuthTypePassword,
+                       Identifier: mautrix.UserIdentifier{
+                               Type: mautrix.IdentifierTypeUser,
+                               User: string(client.UserID),
+                       },
+                       Password:                 config.Password,
+                       InitialDeviceDisplayName: "matrixbot",
+                       StoreCredentials:         true,
+               })
+               if err != nil {
+                       return nil, fmt.Errorf("failed to authenticate: %v", err)
+               }
+               config.Password = ""
+               config.DeviceID = resp.DeviceID
+               config.AccessToken = resp.AccessToken
+               err = config.Save()
+               if err != nil {
+                       return nil, fmt.Errorf("failed to save config: %v", err)
+               }
+       } else {
+               client.DeviceID = config.DeviceID
+       }
+       return client, nil
+}
+
+var (
+       roomUsers       = map[id.RoomID]map[id.UserID]struct{}{}
+       roomUsersMu     sync.RWMutex
+       fullySynced     bool
+       roomPowerLevels = map[id.RoomID]*event.PowerLevelsEventContent{}
+)
+
+func setUserStateAt(room id.RoomID, user id.UserID, now time.Time, maxPrevState, state State) {
+       err := writeUserStateAt(room, user, now, maxPrevState, state)
+       if err != nil {
+               log.Fatalf("failed to write user state: %v", err)
+       }
+}
+
+func handleMessage(now time.Time, room id.RoomID, sender id.UserID, raw *event.Event) {
+       // log.Printf("[%v] Message from %v to %v", now, sender, room)
+       roomUsersMu.Lock()
+       roomUsers[room][sender] = struct{}{}
+       roomUsersMu.Unlock()
+       setUserStateAt(room, sender, now.Add(-activeTime), Active, Active)
+       setUserStateAt(room, sender, now, Active, Idle)
+}
+
+func handleJoin(now time.Time, room id.RoomID, member id.UserID, raw *event.Event) {
+       log.Printf("[%v] Join from %v to %v", now, member, room)
+       roomUsersMu.Lock()
+       roomUsers[room][member] = struct{}{}
+       roomUsersMu.Unlock()
+       setUserStateAt(room, member, now, NotActive, Idle)
+}
+
+func handleLeave(now time.Time, room id.RoomID, member id.UserID, raw *event.Event) {
+       log.Printf("[%v] Leave from %v to %v", now, member, room)
+       roomUsersMu.Lock()
+       delete(roomUsers[room], member)
+       roomUsersMu.Unlock()
+       setUserStateAt(room, member, now, Active, NotActive)
+}
+
+func handlePowerLevels(now time.Time, room id.RoomID, levels *event.PowerLevelsEventContent, raw *event.Event) {
+       // log.Printf("[%v] Power levels for %v are %v", now, room, levels)
+       levelsCopy := *levels // Looks like mautrix always passes the same pointer here.
+       roomUsersMu.Lock()
+       roomPowerLevels[room] = &levelsCopy
+       roomUsersMu.Unlock()
+}
+
+func eventTime(evt *event.Event) time.Time {
+       return time.Unix(0, evt.Timestamp*1000000)
+}
+
+type MoreMessagesSyncer struct {
+       *mautrix.DefaultSyncer
+}
+
+func newSyncer() *MoreMessagesSyncer {
+       return &MoreMessagesSyncer{
+               DefaultSyncer: mautrix.NewDefaultSyncer(),
+       }
+}
+
+func (s *MoreMessagesSyncer) GetFilterJSON(userID id.UserID) *mautrix.Filter {
+       f := s.DefaultSyncer.GetFilterJSON(userID)
+       // Same filters as Element.
+       f.Room.Timeline.Limit = 20
+       // Only include our rooms.
+       f.Room.Rooms = make([]id.RoomID, 0, len(roomUsers))
+       for room := range roomUsers {
+               f.Room.Rooms = append(f.Room.Rooms, room)
+       }
+       return f
+}
+
+func isRoom(room id.RoomID) bool {
+       roomUsersMu.RLock()
+       defer roomUsersMu.RUnlock()
+       _, found := roomUsers[room]
+       return found
+}
+
+func Run() (err error) {
+       err = InitDatabase()
+       if err != nil {
+               return fmt.Errorf("failed to init database: %v", err)
+       }
+       defer func() {
+               err2 := CloseDatabase()
+               if err2 != nil && err == nil {
+                       err = fmt.Errorf("failed to close database: %v", err)
+               }
+       }()
+       logPowerLevelBounds()
+       config := &Config{}
+       err = config.Load()
+       if err != nil {
+               return fmt.Errorf("failed to load config: %v", err)
+       }
+       for _, group := range config.Rooms {
+               for _, room := range group {
+                       roomUsers[room] = map[id.UserID]struct{}{}
+               }
+       }
+       client, err := Login(config)
+       if err != nil {
+               return fmt.Errorf("failed to login: %v", err)
+       }
+       syncer := newSyncer()
+       syncer.OnEventType(event.EventMessage, func(source mautrix.EventSource, evt *event.Event) {
+               if !isRoom(evt.RoomID) {
+                       return
+               }
+               handleMessage(eventTime(evt), evt.RoomID, evt.Sender, evt)
+       })
+       syncer.OnEventType(event.StateMember, func(source mautrix.EventSource, evt *event.Event) {
+               if !isRoom(evt.RoomID) {
+                       return
+               }
+               mem := evt.Content.AsMember()
+               switch mem.Membership {
+               case event.MembershipJoin:
+                       handleJoin(eventTime(evt), evt.RoomID, evt.Sender, evt)
+               case event.MembershipLeave:
+                       handleLeave(eventTime(evt), evt.RoomID, evt.Sender, evt)
+               default: // Ignore.
+               }
+       })
+       syncer.OnEventType(event.StatePowerLevels, func(source mautrix.EventSource, evt *event.Event) {
+               if !isRoom(evt.RoomID) {
+                       return
+               }
+               handlePowerLevels(eventTime(evt), evt.RoomID, evt.Content.AsPowerLevels(), evt)
+       })
+       syncer.OnSync(func(resp *mautrix.RespSync, since string) bool {
+               // j, _ := json.MarshalIndent(resp, "", "  ")
+               // log.Print(string(j))
+               roomUsersMu.Lock()
+               if since != "" && !fullySynced {
+                       log.Print("Fully synced.")
+                       for room, users := range roomUsers {
+                               if len(users) == 0 {
+                                       log.Printf("Not actually joined %v yet...", room)
+                                       _, err := client.JoinRoom(string(room), "", nil)
+                                       if err != nil {
+                                               log.Printf("Failed to join %v: %v", room, err)
+                                       }
+                               }
+                       }
+                       fullySynced = true
+               }
+               roomUsersMu.Unlock()
+               return true
+       })
+       client.Syncer = syncer
+       ticker := time.NewTicker(syncInterval)
+       defer ticker.Stop()
+       go func() {
+               counter := 0
+               for range ticker.C {
+                       roomUsersMu.RLock()
+                       scoreData := map[id.RoomID]map[id.UserID]*Score{}
+                       now := time.Now()
+                       for room := range roomUsers {
+                               scores, err := queryUserScores(room, now)
+                               if err != nil {
+                                       log.Fatalf("failed to query user scores: %v", err)
+                               }
+                               scoreData[room] = scores
+                       }
+                       for _, group := range config.Rooms {
+                               for _, room := range group {
+                                       syncPowerLevels(client, room, group, scoreData, counter%syncForceFrequency == 0)
+                               }
+                       }
+                       roomUsersMu.RUnlock()
+                       counter++
+               }
+       }()
+       return client.Sync()
+}
+
+func main() {
+       err := Run()
+       if err != nil {
+               log.Fatalf("Program failed: %v", err)
+       }
+}
diff --git a/misc/infrastructure/powerbot/db.go b/misc/infrastructure/powerbot/db.go
new file mode 100644 (file)
index 0000000..d16e11a
--- /dev/null
@@ -0,0 +1,182 @@
+package main
+
+import (
+       "database/sql"
+       "fmt"
+       "maunium.net/go/mautrix/id"
+       _ "modernc.org/sqlite"
+       "time"
+)
+
+type State int
+
+const (
+       NotActive State = iota
+       Idle
+       Active
+)
+
+type Score struct {
+       LastEvent    time.Time
+       CurrentState State
+       Idle         time.Duration
+       Active       time.Duration
+}
+
+const dbSchema = `
+CREATE TABLE IF NOT EXISTS room_users (
+  room_id STRING NOT NULL,
+  user_id STRING NOT NULL,
+  state_time TIMESTAMP NOT NULL,
+  state INT NOT NULL,
+  idle_nsec INT64 NOT NULL,
+  active_nsec INT64 NOT NULL,
+  PRIMARY KEY(room_id, user_id)
+);
+`
+
+const fetchStateQuery = `
+SELECT state_time, state, idle_nsec, active_nsec
+FROM room_users
+WHERE room_id = ?
+  AND user_id = ?
+`
+
+const insertStateQuery = `
+INSERT INTO room_users(room_id, user_id, state_time, state, idle_nsec, active_nsec)
+VALUES(?, ?, ?, ?, 0.0, 0.0)
+`
+
+const updateStateQuery = `
+UPDATE room_users
+SET state_time = ?, state = ?, idle_nsec = ?, active_nsec = ?
+WHERE room_id = ?
+  AND user_id = ?
+`
+
+const fetchUserScoresQuery = `
+SELECT user_id, state_time, state, idle_nsec, active_nsec
+FROM room_users
+WHERE room_id = ?
+`
+
+var db *sql.DB
+
+func InitDatabase() error {
+       var err error
+       db, err = sql.Open("sqlite", "users.sqlite")
+       if err != nil {
+               return fmt.Errorf("could not open SQLite database: %v", err)
+       }
+       _, err = db.Exec(dbSchema)
+       if err != nil {
+               return fmt.Errorf("could not set SQLite database schema: %v", err)
+       }
+       return nil
+}
+
+func CloseDatabase() error {
+       return db.Close()
+}
+
+func queryUserScores(room id.RoomID, now time.Time) (map[id.UserID]*Score, error) {
+       var users map[id.UserID]*Score
+       err := retryPolicy(func() error {
+               rows, err := db.Query(fetchUserScoresQuery, room)
+               if err != nil {
+                       return fmt.Errorf("could not query users: %v", err)
+               }
+               users = map[id.UserID]*Score{}
+               for rows.Next() {
+                       var user id.UserID
+                       var score Score
+                       if err := rows.Scan(&user, &score.LastEvent, &score.CurrentState, &score.Idle, &score.Active); err != nil {
+                               return fmt.Errorf("could not scan users query result: %v", err)
+                       }
+                       newScore := advanceScore(score, now)
+                       users[user] = &newScore
+               }
+               if err := rows.Err(); err != nil {
+                       return fmt.Errorf("could not read users: %v", err)
+               }
+               return nil
+       })
+       return users, err
+}
+
+func advanceScore(score Score, now time.Time) Score {
+       if !now.After(score.LastEvent) {
+               return score
+       }
+       dt := now.Sub(score.LastEvent)
+       switch score.CurrentState {
+       case Idle:
+               score.Idle += dt
+       case Active:
+               score.Active += dt
+       }
+       return score
+}
+
+func retryPolicy(f func() error) error {
+       var err error
+       for attempt := 0; attempt < 12; attempt++ {
+               err = f()
+               if err == nil {
+                       return nil
+               }
+               time.Sleep(time.Millisecond * time.Duration(1<<attempt))
+       }
+       return err
+}
+
+func inTx(db *sql.DB, f func(tx *sql.Tx) error) error {
+       tx, err := db.Begin()
+       if err != nil {
+               return fmt.Errorf("failed to create transaction: %v", err)
+       }
+       err = f(tx)
+       if err != nil {
+               tx.Rollback()
+               return err
+       }
+       return tx.Commit()
+}
+
+func writeUserStateAt(room id.RoomID, user id.UserID, now time.Time, maxPrevState, state State) error {
+       return retryPolicy(func() error {
+               return inTx(db, func(tx *sql.Tx) error {
+                       row := tx.QueryRow(fetchStateQuery, room, user)
+                       var score Score
+                       err := row.Scan(&score.LastEvent, &score.CurrentState, &score.Idle, &score.Active)
+                       if err == sql.ErrNoRows {
+                               _, err = tx.Exec(insertStateQuery, room, user, now, state)
+                               if err != nil {
+                                       return fmt.Errorf("failed to set state for new user: %v", err)
+                               }
+                               return nil
+                       } else {
+                               if err != nil {
+                                       return fmt.Errorf("failed to fetch state for user: %v", err)
+                               }
+                               if now.After(score.LastEvent) {
+                                       if score.CurrentState > maxPrevState {
+                                               score.CurrentState = maxPrevState
+                                       }
+                                       score = advanceScore(score, now)
+                                       _, err = tx.Exec(updateStateQuery, now, state, score.Idle, score.Active, room, user)
+                                       if err != nil {
+                                               return fmt.Errorf("failed to update state for new user: %v", err)
+                                       }
+                                       return nil
+                               } else {
+                                       _, err = tx.Exec(updateStateQuery, score.LastEvent, state, score.Idle, score.Active, room, user)
+                                       if err != nil {
+                                               return fmt.Errorf("failed to update state for new user: %v", err)
+                                       }
+                                       return nil
+                               }
+                       }
+               })
+       })
+}
diff --git a/misc/infrastructure/powerbot/go.mod b/misc/infrastructure/powerbot/go.mod
new file mode 100644 (file)
index 0000000..bd6c064
--- /dev/null
@@ -0,0 +1,43 @@
+module github.com/divVerent/matrixbot
+
+go 1.15
+
+require (
+       github.com/btcsuite/btcutil v1.0.2 // indirect
+       github.com/davecgh/go-spew v1.1.1 // indirect
+       github.com/dustin/go-humanize v1.0.0 // indirect
+       github.com/google/go-cmp v0.5.7
+       github.com/google/uuid v1.3.0 // indirect
+       github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+       github.com/kr/pretty v0.3.0 // indirect
+       github.com/kr/text v0.2.0 // indirect
+       github.com/mattn/go-isatty v0.0.14 // indirect
+       github.com/mattn/go-sqlite3 v1.14.11 // indirect
+       github.com/pmezard/go-difflib v1.0.0 // indirect
+       github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
+       github.com/rogpeppe/go-internal v1.8.1 // indirect
+       github.com/stretchr/testify v1.7.0 // indirect
+       golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed // indirect
+       golang.org/x/mod v0.5.1 // indirect
+       golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
+       golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
+       golang.org/x/tools v0.1.9 // indirect
+       golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+       gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+       gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+       lukechampine.com/uint128 v1.2.0 // indirect
+       maunium.net/go/mautrix v0.10.10
+       modernc.org/cc/v3 v3.35.22 // indirect
+       modernc.org/ccgo/v3 v3.15.13 // indirect
+       modernc.org/ccorpus v1.11.4 // indirect
+       modernc.org/httpfs v1.0.6 // indirect
+       modernc.org/libc v1.14.5 // indirect
+       modernc.org/mathutil v1.4.1 // indirect
+       modernc.org/memory v1.0.5 // indirect
+       modernc.org/opt v0.1.1 // indirect
+       modernc.org/sqlite v1.14.5
+       modernc.org/strutil v1.1.1 // indirect
+       modernc.org/tcl v1.11.0 // indirect
+       modernc.org/token v1.0.0 // indirect
+       modernc.org/z v1.3.0 // indirect
+)
diff --git a/misc/infrastructure/powerbot/go.sum b/misc/infrastructure/powerbot/go.sum
new file mode 100644 (file)
index 0000000..2d64a35
--- /dev/null
@@ -0,0 +1,308 @@
+github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
+github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
+github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
+github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
+github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
+github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
+github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
+github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
+github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
+github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
+github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
+github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.11 h1:gt+cp9c0XGqe9S/wAHTL3n/7MqY+siPWgWJgqdsFrzQ=
+github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+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-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
+github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+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/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed h1:YoWVYYAfvQ4ddHv3OKmIvX7NCAhFGTj62VP2l2kfBbA=
+golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
+golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w=
+golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8=
+golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
+lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+maunium.net/go/maulogger/v2 v2.3.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
+maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
+maunium.net/go/mautrix v0.10.7 h1:QV5vbCY4g50N7r1ihdG6zEPfaPn/EVYjM5H+qfLy4RM=
+maunium.net/go/mautrix v0.10.7/go.mod h1:k4Ng5oci83MEbqPL6KOjPdbU7f8v01KlMjR/zTQ+7mA=
+maunium.net/go/mautrix v0.10.10 h1:aaEuVopM3rkgOxL8Ldn2E8vcIIfKDE+tBfX/uPCRFWs=
+maunium.net/go/mautrix v0.10.10/go.mod h1:4XljZZGZiIlpfbQ+Tt2ykjapskJ8a7Z2i9y/+YaceF8=
+modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.4/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.5/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.7/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.8/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.10/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.15/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.16/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.17/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.18/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.19 h1:6ODWsJZWi6EwWeuC35hFBhin++9WWY3nThiS29Zl78U=
+modernc.org/cc/v3 v3.35.19/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.20/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/cc/v3 v3.35.22 h1:BzShpwCAP7TWzFppM4k2t03RhXhgYqaibROWkrWq7lE=
+modernc.org/cc/v3 v3.35.22/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
+modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60=
+modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw=
+modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI=
+modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag=
+modernc.org/ccgo/v3 v3.11.3/go.mod h1:0oHunRBMBiXOKdaglfMlRPBALQqsfrCKXgw9okQ3GEw=
+modernc.org/ccgo/v3 v3.12.4/go.mod h1:Bk+m6m2tsooJchP/Yk5ji56cClmN6R1cqc9o/YtbgBQ=
+modernc.org/ccgo/v3 v3.12.6/go.mod h1:0Ji3ruvpFPpz+yu+1m0wk68pdr/LENABhTrDkMDWH6c=
+modernc.org/ccgo/v3 v3.12.8/go.mod h1:Hq9keM4ZfjCDuDXxaHptpv9N24JhgBZmUG5q60iLgUo=
+modernc.org/ccgo/v3 v3.12.11/go.mod h1:0jVcmyDwDKDGWbcrzQ+xwJjbhZruHtouiBEvDfoIsdg=
+modernc.org/ccgo/v3 v3.12.14/go.mod h1:GhTu1k0YCpJSuWwtRAEHAol5W7g1/RRfS4/9hc9vF5I=
+modernc.org/ccgo/v3 v3.12.18/go.mod h1:jvg/xVdWWmZACSgOiAhpWpwHWylbJaSzayCqNOJKIhs=
+modernc.org/ccgo/v3 v3.12.20/go.mod h1:aKEdssiu7gVgSy/jjMastnv/q6wWGRbszbheXgWRHc8=
+modernc.org/ccgo/v3 v3.12.21/go.mod h1:ydgg2tEprnyMn159ZO/N4pLBqpL7NOkJ88GT5zNU2dE=
+modernc.org/ccgo/v3 v3.12.22/go.mod h1:nyDVFMmMWhMsgQw+5JH6B6o4MnZ+UQNw1pp52XYFPRk=
+modernc.org/ccgo/v3 v3.12.25/go.mod h1:UaLyWI26TwyIT4+ZFNjkyTbsPsY3plAEB6E7L/vZV3w=
+modernc.org/ccgo/v3 v3.12.29/go.mod h1:FXVjG7YLf9FetsS2OOYcwNhcdOLGt8S9bQ48+OP75cE=
+modernc.org/ccgo/v3 v3.12.36/go.mod h1:uP3/Fiezp/Ga8onfvMLpREq+KUjUmYMxXPO8tETHtA8=
+modernc.org/ccgo/v3 v3.12.38/go.mod h1:93O0G7baRST1vNj4wnZ49b1kLxt0xCW5Hsa2qRaZPqc=
+modernc.org/ccgo/v3 v3.12.43/go.mod h1:k+DqGXd3o7W+inNujK15S5ZYuPoWYLpF5PYougCmthU=
+modernc.org/ccgo/v3 v3.12.46/go.mod h1:UZe6EvMSqOxaJ4sznY7b23/k13R8XNlyWsO5bAmSgOE=
+modernc.org/ccgo/v3 v3.12.47/go.mod h1:m8d6p0zNps187fhBwzY/ii6gxfjob1VxWb919Nk1HUk=
+modernc.org/ccgo/v3 v3.12.50/go.mod h1:bu9YIwtg+HXQxBhsRDE+cJjQRuINuT9PUK4orOco/JI=
+modernc.org/ccgo/v3 v3.12.51/go.mod h1:gaIIlx4YpmGO2bLye04/yeblmvWEmE4BBBls4aJXFiE=
+modernc.org/ccgo/v3 v3.12.53/go.mod h1:8xWGGTFkdFEWBEsUmi+DBjwu/WLy3SSOrqEmKUjMeEg=
+modernc.org/ccgo/v3 v3.12.54/go.mod h1:yANKFTm9llTFVX1FqNKHE0aMcQb1fuPJx6p8AcUx+74=
+modernc.org/ccgo/v3 v3.12.55/go.mod h1:rsXiIyJi9psOwiBkplOaHye5L4MOOaCjHg1Fxkj7IeU=
+modernc.org/ccgo/v3 v3.12.56/go.mod h1:ljeFks3faDseCkr60JMpeDb2GSO3TKAmrzm7q9YOcMU=
+modernc.org/ccgo/v3 v3.12.57/go.mod h1:hNSF4DNVgBl8wYHpMvPqQWDQx8luqxDnNGCMM4NFNMc=
+modernc.org/ccgo/v3 v3.12.60/go.mod h1:k/Nn0zdO1xHVWjPYVshDeWKqbRWIfif5dtsIOCUVMqM=
+modernc.org/ccgo/v3 v3.12.66/go.mod h1:jUuxlCFZTUZLMV08s7B1ekHX5+LIAurKTTaugUr/EhQ=
+modernc.org/ccgo/v3 v3.12.67/go.mod h1:Bll3KwKvGROizP2Xj17GEGOTrlvB1XcVaBrC90ORO84=
+modernc.org/ccgo/v3 v3.12.73/go.mod h1:hngkB+nUUqzOf3iqsM48Gf1FZhY599qzVg1iX+BT3cQ=
+modernc.org/ccgo/v3 v3.12.81/go.mod h1:p2A1duHoBBg1mFtYvnhAnQyI6vL0uw5PGYLSIgF6rYY=
+modernc.org/ccgo/v3 v3.12.84/go.mod h1:ApbflUfa5BKadjHynCficldU1ghjen84tuM5jRynB7w=
+modernc.org/ccgo/v3 v3.12.86/go.mod h1:dN7S26DLTgVSni1PVA3KxxHTcykyDurf3OgUzNqTSrU=
+modernc.org/ccgo/v3 v3.12.88/go.mod h1:0MFzUHIuSIthpVZyMWiFYMwjiFnhrN5MkvBrUwON+ZM=
+modernc.org/ccgo/v3 v3.12.90/go.mod h1:obhSc3CdivCRpYZmrvO88TXlW0NvoSVvdh/ccRjJYko=
+modernc.org/ccgo/v3 v3.12.92/go.mod h1:5yDdN7ti9KWPi5bRVWPl8UNhpEAtCjuEE7ayQnzzqHA=
+modernc.org/ccgo/v3 v3.12.95 h1:Ym2JG2G3P4IyZqjTTojHTl7qO0RysXeGSYPSoKPSBxc=
+modernc.org/ccgo/v3 v3.12.95/go.mod h1:ZcLyvtocXYi8uF+9Ebm3G8EF8HNY5hGomBqthDp4eC8=
+modernc.org/ccgo/v3 v3.13.1/go.mod h1:aBYVOUfIlcSnrsRVU8VRS35y2DIfpgkmVkYZ0tpIXi4=
+modernc.org/ccgo/v3 v3.14.0/go.mod h1:hBrkiBlUwvr5vV/ZH9YzXIp982jKE8Ek8tR1ytoAL6Q=
+modernc.org/ccgo/v3 v3.15.1/go.mod h1:md59wBwDT2LznX/OTCPoVS6KIsdRgY8xqQwBV+hkTH0=
+modernc.org/ccgo/v3 v3.15.9/go.mod h1:md59wBwDT2LznX/OTCPoVS6KIsdRgY8xqQwBV+hkTH0=
+modernc.org/ccgo/v3 v3.15.10/go.mod h1:wQKxoFn0ynxMuCLfFD09c8XPUCc8obfchoVR9Cn0fI8=
+modernc.org/ccgo/v3 v3.15.12/go.mod h1:VFePOWoCd8uDGRJpq/zfJ29D0EVzMSyID8LCMWYbX6I=
+modernc.org/ccgo/v3 v3.15.13 h1:hqlCzNJTXLrhS70y1PqWckrF9x1btSQRC7JFuQcBg5c=
+modernc.org/ccgo/v3 v3.15.13/go.mod h1:QHtvdpeODlXjdK3tsbpyK+7U9JV4PQsrPGIbtmc0KfY=
+modernc.org/ccorpus v1.11.1 h1:K0qPfpVG1MJh5BYazccnmhywH4zHuOgJXgbjzyp6dWA=
+modernc.org/ccorpus v1.11.1/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/ccorpus v1.11.4 h1:YOmQBBzE8GC/puUx76D5j/gJYIZQsydrh6VMJVfXF0M=
+modernc.org/ccorpus v1.11.4/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
+modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
+modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg=
+modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M=
+modernc.org/libc v1.11.5/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU=
+modernc.org/libc v1.11.6/go.mod h1:ddqmzR6p5i4jIGK1d/EiSw97LBcE3dK24QEwCFvgNgE=
+modernc.org/libc v1.11.11/go.mod h1:lXEp9QOOk4qAYOtL3BmMve99S5Owz7Qyowzvg6LiZso=
+modernc.org/libc v1.11.13/go.mod h1:ZYawJWlXIzXy2Pzghaf7YfM8OKacP3eZQI81PDLFdY8=
+modernc.org/libc v1.11.16/go.mod h1:+DJquzYi+DMRUtWI1YNxrlQO6TcA5+dRRiq8HWBWRC8=
+modernc.org/libc v1.11.19/go.mod h1:e0dgEame6mkydy19KKaVPBeEnyJB4LGNb0bBH1EtQ3I=
+modernc.org/libc v1.11.24/go.mod h1:FOSzE0UwookyT1TtCJrRkvsOrX2k38HoInhw+cSCUGk=
+modernc.org/libc v1.11.26/go.mod h1:SFjnYi9OSd2W7f4ct622o/PAYqk7KHv6GS8NZULIjKY=
+modernc.org/libc v1.11.27/go.mod h1:zmWm6kcFXt/jpzeCgfvUNswM0qke8qVwxqZrnddlDiE=
+modernc.org/libc v1.11.28/go.mod h1:Ii4V0fTFcbq3qrv3CNn+OGHAvzqMBvC7dBNyC4vHZlg=
+modernc.org/libc v1.11.31/go.mod h1:FpBncUkEAtopRNJj8aRo29qUiyx5AvAlAxzlx9GNaVM=
+modernc.org/libc v1.11.34/go.mod h1:+Tzc4hnb1iaX/SKAutJmfzES6awxfU1BPvrrJO0pYLg=
+modernc.org/libc v1.11.37/go.mod h1:dCQebOwoO1046yTrfUE5nX1f3YpGZQKNcITUYWlrAWo=
+modernc.org/libc v1.11.39/go.mod h1:mV8lJMo2S5A31uD0k1cMu7vrJbSA3J3waQJxpV4iqx8=
+modernc.org/libc v1.11.42/go.mod h1:yzrLDU+sSjLE+D4bIhS7q1L5UwXDOw99PLSX0BlZvSQ=
+modernc.org/libc v1.11.44/go.mod h1:KFq33jsma7F5WXiYelU8quMJasCCTnHK0mkri4yPHgA=
+modernc.org/libc v1.11.45/go.mod h1:Y192orvfVQQYFzCNsn+Xt0Hxt4DiO4USpLNXBlXg/tM=
+modernc.org/libc v1.11.47/go.mod h1:tPkE4PzCTW27E6AIKIR5IwHAQKCAtudEIeAV1/SiyBg=
+modernc.org/libc v1.11.49/go.mod h1:9JrJuK5WTtoTWIFQ7QjX2Mb/bagYdZdscI3xrvHbXjE=
+modernc.org/libc v1.11.51/go.mod h1:R9I8u9TS+meaWLdbfQhq2kFknTW0O3aw3kEMqDDxMaM=
+modernc.org/libc v1.11.53/go.mod h1:5ip5vWYPAoMulkQ5XlSJTy12Sz5U6blOQiYasilVPsU=
+modernc.org/libc v1.11.54/go.mod h1:S/FVnskbzVUrjfBqlGFIPA5m7UwB3n9fojHhCNfSsnw=
+modernc.org/libc v1.11.55/go.mod h1:j2A5YBRm6HjNkoSs/fzZrSxCuwWqcMYTDPLNx0URn3M=
+modernc.org/libc v1.11.56/go.mod h1:pakHkg5JdMLt2OgRadpPOTnyRXm/uzu+Yyg/LSLdi18=
+modernc.org/libc v1.11.58/go.mod h1:ns94Rxv0OWyoQrDqMFfWwka2BcaF6/61CqJRK9LP7S8=
+modernc.org/libc v1.11.71/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw=
+modernc.org/libc v1.11.75/go.mod h1:dGRVugT6edz361wmD9gk6ax1AbDSe0x5vji0dGJiPT0=
+modernc.org/libc v1.11.82/go.mod h1:NF+Ek1BOl2jeC7lw3a7Jj5PWyHPwWD4aq3wVKxqV1fI=
+modernc.org/libc v1.11.86/go.mod h1:ePuYgoQLmvxdNT06RpGnaDKJmDNEkV7ZPKI2jnsvZoE=
+modernc.org/libc v1.11.87/go.mod h1:Qvd5iXTeLhI5PS0XSyqMY99282y+3euapQFxM7jYnpY=
+modernc.org/libc v1.11.88/go.mod h1:h3oIVe8dxmTcchcFuCcJ4nAWaoiwzKCdv82MM0oiIdQ=
+modernc.org/libc v1.11.90/go.mod h1:ynK5sbjsU77AP+nn61+k+wxUGRx9rOFcIqWYYMaDZ4c=
+modernc.org/libc v1.11.98/go.mod h1:ynK5sbjsU77AP+nn61+k+wxUGRx9rOFcIqWYYMaDZ4c=
+modernc.org/libc v1.11.99/go.mod h1:wLLYgEiY2D17NbBOEp+mIJJJBGSiy7fLL4ZrGGZ+8jI=
+modernc.org/libc v1.11.101/go.mod h1:wLLYgEiY2D17NbBOEp+mIJJJBGSiy7fLL4ZrGGZ+8jI=
+modernc.org/libc v1.11.104/go.mod h1:2MH3DaF/gCU8i/UBiVE1VFRos4o523M7zipmwH8SIgQ=
+modernc.org/libc v1.12.0 h1:imI0tde8UeIAyoU/C09Pm6CmTZkJrO+QvthHRpf1rj0=
+modernc.org/libc v1.12.0/go.mod h1:2MH3DaF/gCU8i/UBiVE1VFRos4o523M7zipmwH8SIgQ=
+modernc.org/libc v1.13.1/go.mod h1:npFeGWjmZTjFeWALQLrvklVmAxv4m80jnG3+xI8FdJk=
+modernc.org/libc v1.13.2/go.mod h1:npFeGWjmZTjFeWALQLrvklVmAxv4m80jnG3+xI8FdJk=
+modernc.org/libc v1.14.1/go.mod h1:npFeGWjmZTjFeWALQLrvklVmAxv4m80jnG3+xI8FdJk=
+modernc.org/libc v1.14.2/go.mod h1:MX1GBLnRLNdvmK9azU9LCxZ5lMyhrbEMK8rG3X/Fe34=
+modernc.org/libc v1.14.3/go.mod h1:GPIvQVOVPizzlqyRX3l756/3ppsAgg1QgPxjr5Q4agQ=
+modernc.org/libc v1.14.5 h1:DAHvwGoVRDZs5iJXnX9RJrgXSsorupCWmJ2ac964Owk=
+modernc.org/libc v1.14.5/go.mod h1:2PJHINagVxO4QW/5OQdRrvMYo+bm5ClpUFfyXCYl9ak=
+modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
+modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
+modernc.org/memory v1.0.5 h1:XRch8trV7GgvTec2i7jc33YlUI0RKVDBvZ5eZ5m8y14=
+modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM=
+modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
+modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.14.3 h1:psrTwgpEujgWEP3FNdsC9yNh5tSeA77U0GeWhHH4XmQ=
+modernc.org/sqlite v1.14.3/go.mod h1:xMpicS1i2MJ4C8+Ap0vYBqTwYfpFvdnPE6brbFOtV2Y=
+modernc.org/sqlite v1.14.5 h1:bYrrjwH9Y7QUGk1MbchZDhRfmpGuEAs/D45sVjNbfvs=
+modernc.org/sqlite v1.14.5/go.mod h1:YyX5Rx0WbXokitdWl2GJIDy4BrPxBP0PwwhpXOHCDLE=
+modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
+modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
+modernc.org/tcl v1.9.2 h1:YA87dFLOsR2KqMka371a2Xgr+YsyUwo7OmHVSv/kztw=
+modernc.org/tcl v1.9.2/go.mod h1:aw7OnlIoiuJgu1gwbTZtrKnGpDqH9wyH++jZcxdqNsg=
+modernc.org/tcl v1.10.0/go.mod h1:WzWapmP/7dHVhFoyPpEaNSVTL8xtewhouN/cqSJ5A2s=
+modernc.org/tcl v1.11.0 h1:B/zzEYjINeaki38KcIqdQRQx7W3WE7TkrlTwGnbm2II=
+modernc.org/tcl v1.11.0/go.mod h1:zsTUpbQ+NxQEjOjCUlImDLPv1sG8Ww0qp66ZvyOxCgw=
+modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
+modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.2.20 h1:DyboxM1sJR2NB803j2StnbnL6jcQXz273OhHDGu8dGk=
+modernc.org/z v1.2.20/go.mod h1:zU9FiF4PbHdOTUxw+IF8j7ArBMRPsHgq10uVPt6xTzo=
+modernc.org/z v1.2.21/go.mod h1:uXrObx4pGqXWIMliC5MiKuwAyMrltzwpteOFUP1PWCc=
+modernc.org/z v1.3.0 h1:4RWULo1Nvaq5ZBhbLe74u8p6tV4Mmm0ZrPBXYPm/xjM=
+modernc.org/z v1.3.0/go.mod h1:+mvgLH814oDjtATDdT3rs84JnUIpkvAF5B8AVkNlE2g=
diff --git a/misc/infrastructure/powerbot/powerlevels.go b/misc/infrastructure/powerbot/powerlevels.go
new file mode 100644 (file)
index 0000000..0f4ab97
--- /dev/null
@@ -0,0 +1,176 @@
+package main
+
+import (
+       "github.com/google/go-cmp/cmp"
+       "log"
+       "math"
+       "maunium.net/go/mautrix"
+       "maunium.net/go/mautrix/event"
+       "maunium.net/go/mautrix/id"
+       "time"
+)
+
+const (
+       idleScore   = 1
+       activeScore = 100
+       activeTime  = 5 * time.Minute
+       // 15 minutes idling = PL 1.
+       minPowerScore = 15 * 60 * idleScore
+       minPowerLevel = 1
+       // 1 year fulltime active dev = PL 10.
+       maxPowerScore = 3600 * (365*24*idleScore + 8*261*(activeScore-idleScore))
+       maxPowerLevel = 9
+       // Expire power level if no event for 1 month. Level comes back on next event, including join.
+       powerExpireTime = time.Hour * 24 * 30
+       // Maximum count of ACL entries. Should avoid hitting the 64k limit.
+       maxPowerLevelEntries = 2048
+)
+
+func logPowerLevelBounds() {
+       for i := minPowerLevel; i <= maxPowerLevel; i++ {
+               score := minPowerScore * math.Pow(maxPowerScore/minPowerScore, float64(i-minPowerLevel)/float64(maxPowerLevel-minPowerLevel))
+               log.Printf("Power level %d requires score %v (= %v idle or %v active).",
+                       i, score,
+                       time.Duration(float64(time.Second)*score/idleScore),
+                       time.Duration(float64(time.Second)*score/activeScore),
+               )
+       }
+}
+
+func computePowerLevel(def int, score Score) (int, float64) {
+       points := score.Idle.Seconds()*idleScore + score.Active.Seconds()*activeScore
+       if points <= 0 {
+               return def, math.Inf(-1)
+       }
+       raw := minPowerLevel + (maxPowerLevel-minPowerLevel)*math.Log(points/minPowerScore)/math.Log(maxPowerScore/minPowerScore)
+       if raw < minPowerLevel {
+               return def, raw
+       }
+       if points > maxPowerScore {
+               return maxPowerLevel, raw
+       }
+       return int(math.Floor(raw)), raw
+}
+
+func allPowerLevels(roomLevels *event.PowerLevelsEventContent) []int {
+       ret := make([]int, 0, len(roomLevels.Events)+5)
+       for _, level := range roomLevels.Events {
+               ret = append(ret, level)
+       }
+       ret = append(ret, roomLevels.EventsDefault)
+       if roomLevels.InvitePtr != nil {
+               ret = append(ret, *roomLevels.InvitePtr)
+       }
+       if roomLevels.KickPtr != nil {
+               ret = append(ret, *roomLevels.KickPtr)
+       }
+       if roomLevels.BanPtr != nil {
+               ret = append(ret, *roomLevels.BanPtr)
+       }
+       if roomLevels.RedactPtr != nil {
+               ret = append(ret, *roomLevels.RedactPtr)
+       }
+       return ret
+}
+
+func syncPowerLevels(client *mautrix.Client, room id.RoomID, roomGroup []id.RoomID, scores map[id.RoomID]map[id.UserID]*Score, force bool) {
+       roomLevels := roomPowerLevels[room]
+       if roomLevels == nil {
+               log.Printf("trying to ensure power levels for room %v, but did not get power level map yet", room)
+               return
+       }
+       tryUpdate := force
+       for _, level := range allPowerLevels(roomLevels) {
+               if minPowerLevel <= level && level <= maxPowerLevel {
+                       tryUpdate = true
+               }
+       }
+       if !tryUpdate {
+               log.Printf("room %v skipping because PLs currently do not matter", room)
+               return
+       }
+       log.Printf("room %v considering to update PLs", room)
+       if fullySynced {
+               for user, score := range scores[room] {
+                       // Expire users that for some reason did not get pruned from the database.
+                       // This may cause them to lose their power level below.
+                       if _, found := roomUsers[room][user]; !found && score.CurrentState != NotActive {
+                               log.Printf("Pruning long inactive user %v from room %v.", user, room)
+                               setUserStateAt(room, user, time.Now(), NotActive, NotActive)
+                               score.CurrentState = NotActive
+                       }
+               }
+       }
+       newRoomLevels := *roomLevels
+       newRoomLevels.Users = make(map[id.UserID]int)
+       for user, level := range roomLevels.Users {
+               if level == roomLevels.UsersDefault {
+                       continue
+               }
+               // TODO: Also skip users who aren't in the room for ages.
+               score := scores[room][user]
+               if level >= minPowerLevel && level <= maxPowerLevel && score.CurrentState == NotActive && time.Now().After(score.LastEvent.Add(powerExpireTime)) {
+                       // User is inactive - prune them from the power level list. Saves space.
+                       // But this doesn't mark the list dirty as there is no need to send an update.
+                       log.Printf("room %v user %v power level: PRUNE %v (%v)", room, user, level, score)
+                       continue
+               }
+               newRoomLevels.Users[user] = level
+       }
+       dirty := false
+       log.Printf("room %v", room)
+       for user, score := range scores[room] {
+               if score.CurrentState == NotActive {
+                       // Do not add/bump power levels for users not in the room.
+                       continue
+               }
+               prevLevel := roomLevels.Users[user]
+               level, raw := computePowerLevel(roomLevels.UsersDefault, *score)
+               for _, otherRoom := range roomGroup {
+                       if otherRoom == room {
+                               continue
+                       }
+                       otherScore := scores[otherRoom][user]
+                       if otherScore == nil {
+                               continue
+                       }
+                       otherLevel, otherRaw := computePowerLevel(roomLevels.UsersDefault, *otherScore)
+                       if otherLevel > level {
+                               level = otherLevel
+                       }
+                       if otherRaw > raw {
+                               raw = otherRaw
+                       }
+               }
+               if level > prevLevel {
+                       log.Printf("room %v user %v power level: INCREASE %v -> %v (%v, %v)", room, user, prevLevel, level, raw, score)
+                       newRoomLevels.Users[user] = level
+                       dirty = true
+               } else if level < prevLevel {
+                       log.Printf("room %v user %v power level: SKIP %v -> %v (%v, %v)", room, user, prevLevel, level, raw, score)
+               } else {
+                       log.Printf("room %v user %v power level: KEEP %v -> %v (%v, %v)", room, user, prevLevel, level, raw, score)
+               }
+       }
+       clearPowerLevel := minPowerLevel
+       for len(newRoomLevels.Users) > maxPowerLevelEntries && clearPowerLevel <= maxPowerLevel {
+               log.Printf("room %v not including power level %d to reduce message size", clearPowerLevel)
+               for user, level := range newRoomLevels.Users {
+                       if level == clearPowerLevel {
+                               delete(newRoomLevels.Users, user)
+                               dirty = true
+                       }
+               }
+               clearPowerLevel++
+       }
+       if dirty {
+               diff := cmp.Diff(roomLevels.Users, newRoomLevels.Users)
+               log.Printf("room %v power level update:\n%v", room, diff)
+               _, err := client.SendStateEvent(room, event.StatePowerLevels, "", newRoomLevels)
+               if err != nil {
+                       log.Printf("Failed to update power levels: %v", err)
+               }
+       } else {
+               log.Printf("room %v nothing to update", room)
+       }
+}