├── .editorconfig ├── .github └── workflows │ └── go.yml ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile.ci ├── LICENSE ├── README.md ├── bot ├── init.go ├── send.go └── verify.go ├── build.sh ├── cmd └── meowlnir │ ├── antispam.go │ ├── botmanagement.go │ ├── eventhandling.go │ ├── http.go │ ├── main.go │ ├── policyserver.go │ ├── reporting.go │ └── version.go ├── config ├── config.go ├── event.go ├── example-config.yaml └── upgrade.go ├── database ├── action.go ├── bot.go ├── db.go ├── managementroom.go └── upgrades │ ├── 00-latest.sql │ └── upgrades.go ├── go.mod ├── go.sum ├── policyeval ├── antispam.go ├── commands.go ├── evaluate.go ├── eventhandle.go ├── execute.go ├── main.go ├── messagehandle.go ├── policyserver.go ├── protectedrooms.go ├── report.go ├── roomhash │ └── roomhash.go ├── serveracl.go └── watchedlists.go ├── policylist ├── list.go ├── policy.go ├── room.go └── store.go ├── synapsedb └── db.go └── util └── hash.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yaml,yml,sql}] 15 | indent_style = space 16 | 17 | [{.gitlab-ci.yml,.github/workflows/*.yml}] 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GOTOOLCHAIN: local 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go-version: ["1.23", "1.24"] 15 | name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | cache: true 25 | 26 | - name: Install libolm 27 | run: sudo apt-get install libolm-dev libolm3 28 | 29 | - name: Install dependencies 30 | run: | 31 | go install golang.org/x/tools/cmd/goimports@latest 32 | go install honnef.co/go/tools/cmd/staticcheck@latest 33 | export PATH="$HOME/go/bin:$PATH" 34 | 35 | - name: Run pre-commit 36 | uses: pre-commit/action@v3.0.1 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | *.yaml 4 | !.pre-commit-config.yaml 5 | !config/example-config.yaml 6 | 7 | *.session 8 | *.json 9 | *.db 10 | *.log 11 | *.log.gz 12 | *.bak 13 | 14 | /meowlnir 15 | /start 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/gov2-as-default.yml' 4 | 5 | variables: 6 | BINARY_NAME: meowlnir 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/tekwizely/pre-commit-golang 12 | rev: v1.0.0-rc.1 13 | hooks: 14 | - id: go-imports-repo 15 | args: 16 | - "-local" 17 | - "go.mau.fi/meowlnir" 18 | - "-w" 19 | - id: go-vet-repo-mod 20 | - id: go-mod-tidy 21 | - id: go-staticcheck-repo-mod 22 | 23 | - repo: https://github.com/beeper/pre-commit-go 24 | rev: v0.4.2 25 | hooks: 26 | - id: zerolog-ban-msgf 27 | - id: zerolog-use-stringer 28 | - id: prevent-literal-http-methods 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.5.0 (2025-05-16) 2 | 3 | * Added option to suppress notifications of policy list changes. 4 | * Added config option for customizing which ban reasons trigger automatic 5 | redactions (thanks to [@nexy7574] in [#18]). 6 | * Added `!deactivate` command to deactivate local accounts using the Synapse 7 | admin API. 8 | * Added support for automatically suspending local accounts using the Synapse 9 | admin API when receiving a ban policy. 10 | * Must be enabled per-policy-list using the `auto_suspend` flag. 11 | * Added debouncing for server ACL updates to prevent spamming events when 12 | multiple changes are made quickly. 13 | * Added deduplication to management room commands to prevent accidentally 14 | sending bans that already exist. 15 | * Fixed removing hashed policies using commands. 16 | * Fixed fallback redaction mechanism not redacting state events 17 | (thanks to [@nexy7574] in [#19]). 18 | * Fixed the API returning an invalid response when creating a management room. 19 | * Switched to mautrix-go's new bot command framework for handling commands. 20 | * Removed policy reason from error messages returned by antispam API. 21 | 22 | [#18]: https://github.com/maunium/meowlnir/pull/18 23 | [#19]: https://github.com/maunium/meowlnir/pull/19 24 | 25 | # v0.4.0 (2025-04-16) 26 | 27 | * Added support for automatic unbans (thanks to [@nexy7574] in [#2]). 28 | * Merged separate user and server ban commands into one with validation to 29 | prevent banning invalid entities. 30 | * Added `!send-as-bot` command to send a message to a room as the 31 | moderation bot. 32 | * Added support for redacting individual events with `!redact` command. 33 | * Added `!redact-recent` command to redact all recent messages in a room. 34 | * Added `!powerlevel` command to change a power level in rooms. 35 | * Added `!help` command to view available commands. 36 | * Added `!search` command to search for policies using glob patterns. 37 | * Added support for redacting messages on all server implementations 38 | (thanks to [@nexy7574] in [#16]). 39 | * Fixed server ban evaluation to ignore port numbers as per 40 | [the spec](https://spec.matrix.org/v1.13/client-server-api/#mroomserver_acl). 41 | 42 | [#2]: https://github.com/maunium/meowlnir/pull/2 43 | [#16]: https://github.com/maunium/meowlnir/pull/16 44 | 45 | # v0.3.0 (2025-03-16) 46 | 47 | * Added support for managing server ACLs. 48 | * Added support for [MSC4194] as an alternative to database access for redacting 49 | messages from a user efficiently. 50 | * Made encryption and database access optional to allow running with 51 | non-Synapse homeservers. 52 | * Added `!kick` command to kick users from all protected rooms. 53 | * Added support for blocking incoming invites on Synapse. 54 | * Requires installing the [synapse-http-antispam] module to forward callbacks 55 | to Meowlnir. 56 | * Pending invites can also be automatically rejected using a double puppeting 57 | appservice if the ban comes in after the invite. 58 | * Added support for [MSC4204]: `m.takedown` moderation policy recommendation. 59 | * Added support for [MSC4205]: Hashed moderation policy entities. 60 | * Fixed events not being redacted if the user left before being banned. 61 | * Updated `!match` command to list protected rooms where the user is joined. 62 | * Changed report endpoint to fetch event using the user's token instead of the 63 | bot's (thanks to [@nexy7574] in [#3]). 64 | * Changed ban execution to ignore policies with the reason set to the string 65 | ``. The ban will be sent without a reason instead. 66 | * Changed management room to ignore unverified devices to implement [MSC4153]. 67 | * Changed API path prefix from `/_matrix/meowlnir` to `/_meowlnir`. 68 | 69 | [synapse-http-antispam]: https://github.com/maunium/synapse-http-antispam 70 | [MSC4153]: https://github.com/matrix-org/matrix-spec-proposals/pull/4153 71 | [MSC4194]: https://github.com/matrix-org/matrix-spec-proposals/pull/4194 72 | [MSC4204]: https://github.com/matrix-org/matrix-spec-proposals/pull/4204 73 | [MSC4205]: https://github.com/matrix-org/matrix-spec-proposals/pull/4205 74 | [@nexy7574]: https://github.com/nexy7574 75 | [#3]: https://github.com/maunium/meowlnir/pull/3 76 | 77 | # v0.2.0 (2024-10-16) 78 | 79 | * Added support for banning users via the report feature. 80 | * This requires setting `report_room` in the config and proxying the Matrix 81 | C-S report endpoint to Meowlnir. 82 | * Added support for notifying management room when the bot is pinged in a 83 | protected room. 84 | * Added `!ban` command to management rooms. 85 | * Added `hacky_rule_filter` to filter out policies which are too wide. 86 | * Fixed watched lists being evaluated in the wrong order. 87 | * Fixed newly added policy evaluation not considering existing unban policies. 88 | * Fixed handling redactions of policy events. 89 | 90 | # v0.1.0 (2024-09-16) 91 | 92 | Initial release. 93 | -------------------------------------------------------------------------------- /Dockerfile.ci: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | RUN apk add --no-cache ca-certificates jq curl 4 | 5 | ARG EXECUTABLE=./meowlnir 6 | COPY $EXECUTABLE /usr/bin/meowlnir 7 | VOLUME /data 8 | WORKDIR /data 9 | 10 | CMD ["/usr/bin/meowlnir"] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meowlnir 2 | An opinionated Matrix moderation bot. Optimized for Synapse, but works with 3 | other server implementations to some extent. 4 | 5 | ## Documentation 6 | All setup and usage instructions are located on [docs.mau.fi](https://docs.mau.fi/meowlnir/). 7 | Some quick links: 8 | 9 | * [Setup](https://docs.mau.fi/meowlnir/setup-manual.html) 10 | (or [with Docker](https://docs.mau.fi/meowlnir/setup-docker.html)) 11 | * [Configuration](https://docs.mau.fi/meowlnir/config.html) 12 | * [Creating bots](https://docs.mau.fi/meowlnir/bot-create.html) 13 | * [Configuring bots](https://docs.mau.fi/meowlnir/bot-config.html) 14 | -------------------------------------------------------------------------------- /bot/init.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "time" 8 | 9 | "github.com/rs/zerolog" 10 | "go.mau.fi/util/dbutil" 11 | "go.mau.fi/util/exerrors" 12 | "maunium.net/go/mautrix" 13 | "maunium.net/go/mautrix/appservice" 14 | "maunium.net/go/mautrix/crypto" 15 | "maunium.net/go/mautrix/crypto/cryptohelper" 16 | "maunium.net/go/mautrix/id" 17 | "maunium.net/go/mautrix/synapseadmin" 18 | 19 | "go.mau.fi/meowlnir/database" 20 | ) 21 | 22 | type Bot struct { 23 | Meta *database.Bot 24 | Log zerolog.Logger 25 | *mautrix.Client 26 | Intent *appservice.IntentAPI 27 | SynapseAdmin *synapseadmin.Client 28 | ServerName string 29 | CryptoStore *crypto.SQLCryptoStore 30 | CryptoHelper *cryptohelper.CryptoHelper 31 | Mach *crypto.OlmMachine 32 | eventProcessor *appservice.EventProcessor 33 | mainDB *database.Database 34 | } 35 | 36 | func NewBot( 37 | bot *database.Bot, 38 | intent *appservice.IntentAPI, 39 | log zerolog.Logger, 40 | db *database.Database, 41 | ep *appservice.EventProcessor, 42 | cryptoStoreDB *dbutil.Database, 43 | pickleKey string, 44 | adminToken string, 45 | ) *Bot { 46 | client := intent.Client 47 | client.SetAppServiceDeviceID = true 48 | var helper *cryptohelper.CryptoHelper 49 | var cryptoStore *crypto.SQLCryptoStore 50 | if cryptoStoreDB != nil { 51 | cryptoStore = &crypto.SQLCryptoStore{ 52 | DB: cryptoStoreDB, 53 | AccountID: client.UserID.String(), 54 | PickleKey: []byte(pickleKey), 55 | } 56 | cryptoStore.InitFields() 57 | // NewCryptoHelper only returns errors on invalid parameters 58 | helper = exerrors.Must(cryptohelper.NewCryptoHelper(client, cryptoStore.PickleKey, cryptoStore)) 59 | helper.DBAccountID = cryptoStore.AccountID 60 | helper.MSC4190 = true 61 | helper.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: "Meowlnir"} 62 | client.Crypto = helper 63 | } 64 | adminClient := &synapseadmin.Client{Client: client} 65 | if adminToken != "" { 66 | adminClient.Client = &mautrix.Client{ 67 | HomeserverURL: client.HomeserverURL, 68 | AccessToken: adminToken, 69 | UserAgent: client.UserAgent, 70 | Client: client.Client, 71 | Log: client.Log, 72 | DefaultHTTPRetries: client.DefaultHTTPRetries, 73 | DefaultHTTPBackoff: client.DefaultHTTPBackoff, 74 | IgnoreRateLimit: client.IgnoreRateLimit, 75 | } 76 | } 77 | return &Bot{ 78 | Meta: bot, 79 | Client: client, 80 | Intent: intent, 81 | Log: log, 82 | SynapseAdmin: adminClient, 83 | ServerName: client.UserID.Homeserver(), 84 | CryptoStore: cryptoStore, 85 | CryptoHelper: helper, 86 | eventProcessor: ep, 87 | mainDB: db, 88 | } 89 | } 90 | 91 | var MinSpecVersion = mautrix.SpecV111 92 | 93 | func (bot *Bot) Init(ctx context.Context) { 94 | for { 95 | resp, err := bot.Client.Versions(ctx) 96 | if err != nil { 97 | if errors.Is(err, mautrix.MForbidden) { 98 | bot.Log.Debug().Msg("M_FORBIDDEN in /versions, trying to register before retrying") 99 | bot.ensureRegistered(ctx) 100 | } 101 | bot.Log.Err(err).Msg("Failed to connect to homeserver, retrying in 10 seconds...") 102 | time.Sleep(10 * time.Second) 103 | } else if !resp.ContainsGreaterOrEqual(MinSpecVersion) { 104 | bot.Log.WithLevel(zerolog.FatalLevel). 105 | Stringer("minimum_required_spec", MinSpecVersion). 106 | Stringer("latest_supported_spec", resp.GetLatest()). 107 | Msg("Homeserver is outdated") 108 | os.Exit(31) 109 | } else { 110 | break 111 | } 112 | } 113 | bot.ensureRegistered(ctx) 114 | 115 | if bot.Meta.Displayname != "" { 116 | err := bot.Intent.SetDisplayName(ctx, bot.Meta.Displayname) 117 | if err != nil { 118 | bot.Log.Err(err).Msg("Failed to set displayname") 119 | } 120 | } 121 | if !bot.Meta.AvatarURL.IsEmpty() { 122 | err := bot.Intent.SetAvatarURL(ctx, bot.Meta.AvatarURL) 123 | if err != nil { 124 | bot.Log.Err(err).Msg("Failed to set avatar") 125 | } 126 | } 127 | 128 | if bot.CryptoHelper == nil { 129 | return 130 | } 131 | 132 | err := bot.CryptoHelper.Init(ctx) 133 | if err != nil { 134 | bot.Log.WithLevel(zerolog.FatalLevel).Err(err). 135 | Msg("Failed to initialize crypto") 136 | os.Exit(31) 137 | } 138 | bot.Mach = bot.CryptoHelper.Machine() 139 | bot.Mach.SendKeysMinTrust = id.TrustStateCrossSignedTOFU 140 | bot.Mach.ShareKeysMinTrust = id.TrustStateCrossSignedTOFU 141 | bot.eventProcessor.OnDeviceList(bot.Mach.HandleDeviceLists) 142 | 143 | hasKeys, isVerified, err := bot.GetVerificationStatus(ctx) 144 | if err != nil { 145 | bot.Log.Err(err).Msg("Failed to check verification status") 146 | } else if !hasKeys { 147 | bot.Log.Warn().Msg("No cross-signing keys found") 148 | } else if !isVerified { 149 | bot.Log.Warn().Msg("Device is not verified") 150 | } else { 151 | bot.Log.Debug().Msg("Device is verified") 152 | } 153 | } 154 | 155 | func (bot *Bot) ensureRegistered(ctx context.Context) { 156 | err := bot.Intent.EnsureRegistered(ctx) 157 | if err == nil { 158 | return 159 | } 160 | if errors.Is(err, mautrix.MUnknownToken) { 161 | bot.Log.WithLevel(zerolog.FatalLevel).Msg("The as_token was not accepted. Is the registration file installed in your homeserver correctly?") 162 | bot.Log.Info().Msg("See https://docs.mau.fi/faq/as-token for more info") 163 | } else if errors.Is(err, mautrix.MExclusive) { 164 | bot.Log.WithLevel(zerolog.FatalLevel).Msg("The as_token was accepted, but the /register request was not. Are the homeserver domain, bot username and username template in the config correct, and do they match the values in the registration?") 165 | bot.Log.Info().Msg("See https://docs.mau.fi/faq/as-register for more info") 166 | } else { 167 | bot.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to register") 168 | } 169 | os.Exit(30) 170 | } 171 | -------------------------------------------------------------------------------- /bot/send.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog" 8 | "maunium.net/go/mautrix/event" 9 | "maunium.net/go/mautrix/format" 10 | "maunium.net/go/mautrix/id" 11 | ) 12 | 13 | func (bot *Bot) SendNotice(ctx context.Context, roomID id.RoomID, message string, args ...any) id.EventID { 14 | if len(args) > 0 { 15 | message = fmt.Sprintf(message, args...) 16 | } 17 | return bot.SendNoticeOpts(ctx, roomID, message, nil) 18 | } 19 | 20 | type SendNoticeOpts struct { 21 | DisallowMarkdown bool 22 | AllowHTML bool 23 | Mentions *event.Mentions 24 | SendAsText bool 25 | Extra map[string]any 26 | } 27 | 28 | func (bot *Bot) SendNoticeOpts(ctx context.Context, roomID id.RoomID, message string, opts *SendNoticeOpts) id.EventID { 29 | if opts == nil { 30 | opts = &SendNoticeOpts{} 31 | } 32 | content := format.RenderMarkdown(message, !opts.DisallowMarkdown, opts.AllowHTML) 33 | if !opts.SendAsText { 34 | content.MsgType = event.MsgNotice 35 | } 36 | if opts.Mentions != nil { 37 | content.Mentions = opts.Mentions 38 | } 39 | var wrappedContent any = &content 40 | if opts.Extra != nil { 41 | wrappedContent = &event.Content{ 42 | Raw: opts.Extra, 43 | Parsed: &content, 44 | } 45 | } 46 | resp, err := bot.Client.SendMessageEvent(ctx, roomID, event.EventMessage, wrappedContent) 47 | if err != nil { 48 | zerolog.Ctx(ctx).Err(err). 49 | Msg("Failed to send management room message") 50 | return "" 51 | } else { 52 | return resp.EventID 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /bot/verify.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func (bot *Bot) GetVerificationStatus(ctx context.Context) (hasKeys, isVerified bool, err error) { 9 | pubkeys := bot.Mach.GetOwnCrossSigningPublicKeys(ctx) 10 | if pubkeys != nil { 11 | hasKeys = true 12 | isVerified, err = bot.Mach.CryptoStore.IsKeySignedBy( 13 | ctx, bot.Client.UserID, bot.Mach.GetAccount().SigningKey(), bot.Client.UserID, pubkeys.SelfSigningKey, 14 | ) 15 | if err != nil { 16 | err = fmt.Errorf("failed to check if current device is signed by own self-signing key: %w", err) 17 | } 18 | } 19 | return 20 | } 21 | 22 | func (bot *Bot) VerifyWithRecoveryKey(ctx context.Context, recoveryKey string) error { 23 | keyID, keyData, err := bot.Mach.SSSS.GetDefaultKeyData(ctx) 24 | if err != nil { 25 | return fmt.Errorf("failed to get default SSSS key data: %w", err) 26 | } 27 | key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey) 28 | if err != nil { 29 | return err 30 | } 31 | err = bot.Mach.FetchCrossSigningKeysFromSSSS(ctx, key) 32 | if err != nil { 33 | return fmt.Errorf("failed to fetch cross-signing keys from SSSS: %w", err) 34 | } 35 | err = bot.Mach.SignOwnDevice(ctx, bot.Mach.OwnIdentity()) 36 | if err != nil { 37 | return fmt.Errorf("failed to sign own device: %w", err) 38 | } 39 | err = bot.Mach.SignOwnMasterKey(ctx) 40 | if err != nil { 41 | return fmt.Errorf("failed to sign own master key: %w", err) 42 | } 43 | return nil 44 | } 45 | 46 | func (bot *Bot) GenerateRecoveryKey(ctx context.Context) (string, error) { 47 | recoveryKey, keys, err := bot.Mach.GenerateAndUploadCrossSigningKeys(ctx, nil, "") 48 | if err != nil { 49 | return "", fmt.Errorf("failed to generate and upload cross-signing keys: %w", err) 50 | } 51 | bot.Mach.CrossSigningKeys = keys 52 | err = bot.Mach.SignOwnDevice(ctx, bot.Mach.OwnIdentity()) 53 | if err != nil { 54 | return "", fmt.Errorf("failed to sign own device: %w", err) 55 | } 56 | err = bot.Mach.SignOwnMasterKey(ctx) 57 | if err != nil { 58 | return "", fmt.Errorf("failed to sign own master key: %w", err) 59 | } 60 | return recoveryKey, nil 61 | } 62 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') 3 | GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" 4 | go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/meowlnir "$@" 5 | -------------------------------------------------------------------------------- /cmd/meowlnir/antispam.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/rs/zerolog/hlog" 9 | "go.mau.fi/util/exhttp" 10 | "maunium.net/go/mautrix" 11 | "maunium.net/go/mautrix/id" 12 | ) 13 | 14 | type ReqUserMayInvite struct { 15 | Inviter id.UserID `json:"inviter"` 16 | Invitee id.UserID `json:"invitee"` 17 | Room id.RoomID `json:"room_id"` 18 | } 19 | 20 | type ReqUserMayJoinRoom struct { 21 | UserID id.UserID `json:"user"` 22 | RoomID id.RoomID `json:"room"` 23 | IsInvited bool `json:"is_invited"` 24 | } 25 | 26 | type ReqAcceptMakeJoin struct { 27 | RoomID id.RoomID `json:"room"` 28 | UserID id.UserID `json:"user"` 29 | } 30 | 31 | func (m *Meowlnir) PostCallback(w http.ResponseWriter, r *http.Request) { 32 | cbType := r.PathValue("callback") 33 | switch cbType { 34 | case "user_may_invite": 35 | m.PostUserMayInvite(w, r) 36 | case "accept_make_join": 37 | m.PostAcceptMakeJoin(w, r) 38 | case "user_may_join_room": 39 | m.PostUserMayJoinRoom(w, r) 40 | case "ping": 41 | m.PostAntispamPing(w, r) 42 | default: 43 | hlog.FromRequest(r).Warn().Str("callback", cbType).Msg("Unknown callback type") 44 | // Don't reject unknown callbacks, just ignore them 45 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 46 | } 47 | } 48 | 49 | type ReqPing struct { 50 | Status string `json:"status"` 51 | ID string `json:"id"` 52 | } 53 | 54 | func (m *Meowlnir) PostAntispamPing(w http.ResponseWriter, r *http.Request) { 55 | var req ReqPing 56 | err := json.NewDecoder(r.Body).Decode(&req) 57 | if err != nil { 58 | hlog.FromRequest(r).Err(err).Msg("Failed to parse request body") 59 | mautrix.MNotJSON.WithMessage("Antispam request error: invalid JSON").Write(w) 60 | return 61 | } 62 | req.Status = "ok" 63 | exhttp.WriteJSONResponse(w, http.StatusOK, req) 64 | hlog.FromRequest(r).Info().Str("ping_id", req.ID).Msg("Received ping from antispam client") 65 | } 66 | 67 | func (m *Meowlnir) PostUserMayJoinRoom(w http.ResponseWriter, r *http.Request) { 68 | var req ReqUserMayJoinRoom 69 | err := json.NewDecoder(r.Body).Decode(&req) 70 | if err != nil { 71 | hlog.FromRequest(r).Err(err).Msg("Failed to parse request body") 72 | mautrix.MNotJSON.WithMessage("Antispam request error: invalid JSON").Write(w) 73 | return 74 | } 75 | 76 | m.MapLock.RLock() 77 | mgmtRoom, ok := m.EvaluatorByManagementRoom[id.RoomID(r.PathValue("policyListID"))] 78 | m.MapLock.RUnlock() 79 | if !ok { 80 | mautrix.MNotFound.WithMessage("Antispam configuration issue: policy list not found").Write(w) 81 | return 82 | } 83 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 84 | ctx := context.WithoutCancel(r.Context()) 85 | go mgmtRoom.HandleUserMayJoinRoom(ctx, req.UserID, req.RoomID, req.IsInvited) 86 | go m.handlePotentialRoomBan(ctx, req.RoomID) 87 | } 88 | 89 | func (m *Meowlnir) handlePotentialRoomBan(ctx context.Context, roomID id.RoomID) { 90 | m.MapLock.RLock() 91 | mgmtRoom, ok := m.EvaluatorByManagementRoom[m.Config.Meowlnir.RoomBanRoom] 92 | m.MapLock.RUnlock() 93 | if !ok { 94 | return 95 | } 96 | if m.RoomHashes.Put(roomID) { 97 | mgmtRoom.EvaluateRoom(ctx, roomID, false) 98 | } 99 | } 100 | 101 | func (m *Meowlnir) PostUserMayInvite(w http.ResponseWriter, r *http.Request) { 102 | var req ReqUserMayInvite 103 | err := json.NewDecoder(r.Body).Decode(&req) 104 | if err != nil { 105 | hlog.FromRequest(r).Err(err).Msg("Failed to parse request body") 106 | mautrix.MNotJSON.WithMessage("Antispam request error: invalid JSON").Write(w) 107 | return 108 | } 109 | 110 | m.MapLock.RLock() 111 | mgmtRoom, ok := m.EvaluatorByManagementRoom[id.RoomID(r.PathValue("policyListID"))] 112 | m.MapLock.RUnlock() 113 | if !ok { 114 | mautrix.MNotFound.WithMessage("Antispam configuration issue: policy list not found").Write(w) 115 | return 116 | } 117 | errResp := mgmtRoom.HandleUserMayInvite(r.Context(), req.Inviter, req.Invitee, req.Room) 118 | if errResp != nil { 119 | errResp.Write(w) 120 | } else { 121 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 122 | } 123 | } 124 | 125 | func (m *Meowlnir) PostAcceptMakeJoin(w http.ResponseWriter, r *http.Request) { 126 | var req ReqAcceptMakeJoin 127 | err := json.NewDecoder(r.Body).Decode(&req) 128 | if err != nil { 129 | hlog.FromRequest(r).Err(err).Msg("Failed to parse request body") 130 | mautrix.MNotJSON.WithMessage("Antispam request error: invalid JSON").Write(w) 131 | return 132 | } 133 | 134 | m.MapLock.RLock() 135 | mgmtRoom, ok := m.EvaluatorByManagementRoom[id.RoomID(r.PathValue("policyListID"))] 136 | m.MapLock.RUnlock() 137 | if !ok { 138 | mautrix.MNotFound.WithMessage("Antispam configuration issue: policy list not found").Write(w) 139 | return 140 | } 141 | errResp := mgmtRoom.HandleAcceptMakeJoin(r.Context(), req.RoomID, req.UserID) 142 | if errResp != nil { 143 | errResp.Write(w) 144 | } else { 145 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /cmd/meowlnir/botmanagement.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/hmac" 6 | "encoding/json" 7 | "maps" 8 | "net/http" 9 | "slices" 10 | "strings" 11 | 12 | "github.com/rs/zerolog/hlog" 13 | "go.mau.fi/util/exhttp" 14 | "go.mau.fi/util/ptr" 15 | "maunium.net/go/mautrix" 16 | "maunium.net/go/mautrix/id" 17 | 18 | "go.mau.fi/meowlnir/database" 19 | "go.mau.fi/meowlnir/util" 20 | ) 21 | 22 | type RespManagementRoom struct { 23 | RoomID id.RoomID `json:"room_id"` 24 | ProtectedRooms []id.RoomID `json:"protected_rooms"` 25 | WatchedLists []id.RoomID `json:"watched_lists"` 26 | Admins []id.UserID `json:"admins"` 27 | } 28 | 29 | type RespBot struct { 30 | *database.Bot 31 | UserID id.UserID `json:"user_id"` 32 | DeviceID id.DeviceID `json:"device_id"` 33 | Verified bool `json:"verified"` 34 | CrossSigningSetUp bool `json:"cross_signing_set_up"` 35 | ManagementRooms []*RespManagementRoom `json:"management_rooms"` 36 | } 37 | 38 | type RespGetBots struct { 39 | Bots []*RespBot `json:"bots"` 40 | } 41 | 42 | func (m *Meowlnir) ManagementAuth(next http.Handler) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | authHash := util.SHA256String(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) 45 | if !hmac.Equal(authHash[:], m.ManagementSecret[:]) { 46 | mautrix.MUnknownToken.WithMessage("Invalid management secret").Write(w) 47 | return 48 | } 49 | next.ServeHTTP(w, r) 50 | }) 51 | } 52 | 53 | func (m *Meowlnir) AntispamAuth(next http.Handler) http.Handler { 54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 | authHash := util.SHA256String(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) 56 | if !hmac.Equal(authHash[:], m.AntispamSecret[:]) { 57 | mautrix.MUnknown.WithMessage("Invalid antispam secret").Write(w) 58 | return 59 | } 60 | next.ServeHTTP(w, r) 61 | }) 62 | } 63 | 64 | func (m *Meowlnir) GetBots(w http.ResponseWriter, r *http.Request) { 65 | m.MapLock.RLock() 66 | bots := slices.Collect(maps.Values(m.Bots)) 67 | mgmtRooms := slices.Collect(maps.Values(m.EvaluatorByManagementRoom)) 68 | m.MapLock.RUnlock() 69 | resp := &RespGetBots{Bots: make([]*RespBot, len(bots))} 70 | for i, bot := range bots { 71 | var verified, csSetUp bool 72 | if m.Config.Encryption.Enable { 73 | var err error 74 | verified, csSetUp, err = bot.GetVerificationStatus(r.Context()) 75 | if err != nil { 76 | hlog.FromRequest(r).Err(err).Str("bot_username", bot.Meta.Username).Msg("Failed to get bot verification status") 77 | mautrix.MUnknown.WithMessage("Failed to get bot verification status").Write(w) 78 | return 79 | } 80 | } 81 | botMgmtRooms := make([]*RespManagementRoom, 0) 82 | for _, room := range mgmtRooms { 83 | if room.Bot != bot { 84 | continue 85 | } 86 | botMgmtRooms = append(botMgmtRooms, &RespManagementRoom{ 87 | RoomID: room.ManagementRoom, 88 | ProtectedRooms: room.GetProtectedRooms(), 89 | WatchedLists: room.GetWatchedLists(), 90 | Admins: room.Admins.AsList(), 91 | }) 92 | } 93 | resp.Bots[i] = &RespBot{ 94 | Bot: bot.Meta, 95 | UserID: bot.Client.UserID, 96 | DeviceID: bot.Client.DeviceID, 97 | Verified: verified, 98 | CrossSigningSetUp: csSetUp, 99 | ManagementRooms: botMgmtRooms, 100 | } 101 | } 102 | exhttp.WriteJSONResponse(w, http.StatusOK, resp) 103 | } 104 | 105 | type ReqPutBot struct { 106 | Displayname *string `json:"displayname"` 107 | AvatarURL *id.ContentURI `json:"avatar_url"` 108 | } 109 | 110 | func (m *Meowlnir) PutBot(w http.ResponseWriter, r *http.Request) { 111 | var req ReqPutBot 112 | err := json.NewDecoder(r.Body).Decode(&req) 113 | if err != nil { 114 | mautrix.MNotJSON.WithMessage("Invalid JSON").Write(w) 115 | return 116 | } 117 | username := r.PathValue("username") 118 | userID := id.NewUserID(username, m.AS.HomeserverDomain) 119 | m.MapLock.Lock() 120 | defer m.MapLock.Unlock() 121 | bot, ok := m.Bots[userID] 122 | if !ok { 123 | dbBot := &database.Bot{ 124 | Username: username, 125 | Displayname: ptr.Val(req.Displayname), 126 | AvatarURL: ptr.Val(req.AvatarURL), 127 | } 128 | err = m.DB.Bot.Put(r.Context(), dbBot) 129 | if err != nil { 130 | hlog.FromRequest(r).Err(err).Msg("Failed to save bot to database") 131 | mautrix.MUnknown.WithMessage("Failed to save new bot to database").Write(w) 132 | return 133 | } 134 | bot = m.initBot(r.Context(), dbBot) 135 | } else { 136 | if req.Displayname != nil && bot.Meta.Displayname != *req.Displayname { 137 | err = bot.Intent.SetDisplayName(r.Context(), *req.Displayname) 138 | if err != nil { 139 | bot.Log.Err(err).Msg("Failed to set displayname") 140 | } else { 141 | bot.Meta.Displayname = *req.Displayname 142 | } 143 | } 144 | if req.AvatarURL != nil && bot.Meta.AvatarURL != *req.AvatarURL { 145 | err = bot.Intent.SetAvatarURL(r.Context(), *req.AvatarURL) 146 | if err != nil { 147 | bot.Log.Err(err).Msg("Failed to set avatar") 148 | } else { 149 | bot.Meta.AvatarURL = *req.AvatarURL 150 | } 151 | } 152 | err = m.DB.Bot.Put(r.Context(), bot.Meta) 153 | if err != nil { 154 | bot.Log.Err(err).Msg("Failed to save bot to database") 155 | mautrix.MUnknown.WithMessage("Failed to save updated bot to database").Write(w) 156 | return 157 | } 158 | } 159 | exhttp.WriteJSONResponse(w, http.StatusOK, bot.Meta) 160 | } 161 | 162 | var ( 163 | ErrAlreadyVerified = mautrix.RespError{ 164 | ErrCode: "FI.MAU.MEOWLNIR.ALREADY_VERIFIED", 165 | Err: "The bot is already verified.", 166 | StatusCode: http.StatusConflict, 167 | } 168 | ErrAlreadyHaveKeys = mautrix.RespError{ 169 | ErrCode: "FI.MAU.MEOWLNIR.ALREADY_HAS_KEYS", 170 | Err: "The bot already has cross-signing set up.", 171 | StatusCode: http.StatusConflict, 172 | } 173 | ) 174 | 175 | type ReqVerifyBot struct { 176 | RecoveryKey string `json:"recovery_key"` 177 | Generate bool `json:"generate"` 178 | ForceGenerate bool `json:"force_generate"` 179 | ForceVerify bool `json:"force_verify"` 180 | } 181 | 182 | type RespVerifyBot struct { 183 | RecoveryKey string `json:"recovery_key"` 184 | } 185 | 186 | func (m *Meowlnir) PostVerifyBot(w http.ResponseWriter, r *http.Request) { 187 | if !m.Config.Encryption.Enable { 188 | mautrix.MForbidden.WithMessage("Encryption is not enabled on this Meowlnir instance").Write(w) 189 | return 190 | } 191 | var req ReqVerifyBot 192 | err := json.NewDecoder(r.Body).Decode(&req) 193 | if err != nil { 194 | mautrix.MNotJSON.WithMessage("Invalid JSON").Write(w) 195 | return 196 | } else if !req.Generate && req.RecoveryKey == "" { 197 | mautrix.MBadJSON.WithMessage("Recovery key or generate flag must be provided").Write(w) 198 | return 199 | } 200 | userID := id.NewUserID(r.PathValue("username"), m.AS.HomeserverDomain) 201 | m.MapLock.RLock() 202 | bot, ok := m.Bots[userID] 203 | m.MapLock.RUnlock() 204 | if !ok { 205 | mautrix.MNotFound.WithMessage("Bot not found").Write(w) 206 | return 207 | } 208 | hasKeys, isVerified, err := bot.GetVerificationStatus(r.Context()) 209 | if err != nil { 210 | hlog.FromRequest(r).Err(err).Msg("Failed to get bot verification status") 211 | mautrix.MUnknown.WithMessage("Failed to get bot verification status").Write(w) 212 | return 213 | } else if isVerified && !req.ForceVerify { 214 | ErrAlreadyVerified.Write(w) 215 | return 216 | } else if hasKeys && req.Generate && !req.ForceGenerate { 217 | ErrAlreadyHaveKeys.Write(w) 218 | return 219 | } 220 | if req.Generate { 221 | recoveryKey, err := bot.GenerateRecoveryKey(r.Context()) 222 | if err != nil { 223 | hlog.FromRequest(r).Err(err).Msg("Failed to generate recovery key") 224 | mautrix.MUnknown.WithMessage("Failed to generate recovery key: " + err.Error()).Write(w) 225 | } else { 226 | exhttp.WriteJSONResponse(w, http.StatusCreated, &RespVerifyBot{RecoveryKey: recoveryKey}) 227 | } 228 | } else { 229 | err = bot.VerifyWithRecoveryKey(r.Context(), req.RecoveryKey) 230 | if err != nil { 231 | hlog.FromRequest(r).Err(err).Msg("Failed to verify bot with recovery key") 232 | mautrix.MUnknown.WithMessage("Failed to verify bot with recovery key: " + err.Error()).Write(w) 233 | } else { 234 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 235 | } 236 | } 237 | } 238 | 239 | type ReqPutManagementRoom struct { 240 | BotUsername string `json:"bot_username"` 241 | } 242 | 243 | func (m *Meowlnir) PutManagementRoom(w http.ResponseWriter, r *http.Request) { 244 | var req ReqPutManagementRoom 245 | err := json.NewDecoder(r.Body).Decode(&req) 246 | if err != nil { 247 | mautrix.MNotJSON.WithMessage("Invalid JSON").Write(w) 248 | return 249 | } 250 | userID := id.NewUserID(req.BotUsername, m.AS.HomeserverDomain) 251 | m.MapLock.RLock() 252 | bot, ok := m.Bots[userID] 253 | m.MapLock.RUnlock() 254 | if !ok { 255 | mautrix.MNotFound.WithMessage("Bot not found").Write(w) 256 | return 257 | } 258 | roomID := id.RoomID(r.PathValue("roomID")) 259 | _, err = bot.JoinRoomByID(r.Context(), roomID) 260 | if err != nil { 261 | hlog.FromRequest(r).Err(err).Msg("Failed to join room") 262 | } 263 | err = m.DB.ManagementRoom.Put(r.Context(), roomID, bot.Meta.Username) 264 | if err != nil { 265 | hlog.FromRequest(r).Err(err).Msg("Failed to save management room to database") 266 | mautrix.MUnknown.WithMessage("Failed to save management room to database").Write(w) 267 | return 268 | } 269 | didUpdate := m.loadManagementRoom(context.WithoutCancel(r.Context()), roomID, bot) 270 | if didUpdate { 271 | exhttp.WriteEmptyJSONResponse(w, http.StatusCreated) 272 | } else { 273 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /cmd/meowlnir/eventhandling.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rs/zerolog" 7 | "maunium.net/go/mautrix" 8 | "maunium.net/go/mautrix/event" 9 | "maunium.net/go/mautrix/id" 10 | 11 | "go.mau.fi/meowlnir/config" 12 | ) 13 | 14 | func (m *Meowlnir) AddEventHandlers() { 15 | // Crypto stuff 16 | if m.Config.Encryption.Enable { 17 | m.EventProcessor.OnOTK(m.HandleOTKCounts) 18 | m.EventProcessor.On(event.ToDeviceEncrypted, m.HandleToDeviceEvent) 19 | m.EventProcessor.On(event.ToDeviceRoomKeyRequest, m.HandleToDeviceEvent) 20 | m.EventProcessor.On(event.ToDeviceRoomKeyWithheld, m.HandleToDeviceEvent) 21 | m.EventProcessor.On(event.ToDeviceBeeperRoomKeyAck, m.HandleToDeviceEvent) 22 | m.EventProcessor.On(event.ToDeviceOrgMatrixRoomKeyWithheld, m.HandleToDeviceEvent) 23 | m.EventProcessor.On(event.ToDeviceVerificationRequest, m.HandleToDeviceEvent) 24 | m.EventProcessor.On(event.ToDeviceVerificationStart, m.HandleToDeviceEvent) 25 | m.EventProcessor.On(event.ToDeviceVerificationAccept, m.HandleToDeviceEvent) 26 | m.EventProcessor.On(event.ToDeviceVerificationKey, m.HandleToDeviceEvent) 27 | m.EventProcessor.On(event.ToDeviceVerificationMAC, m.HandleToDeviceEvent) 28 | m.EventProcessor.On(event.ToDeviceVerificationCancel, m.HandleToDeviceEvent) 29 | } 30 | 31 | // Policy list updating 32 | m.EventProcessor.On(event.StatePolicyUser, m.UpdatePolicyList) 33 | m.EventProcessor.On(event.StatePolicyRoom, m.UpdatePolicyList) 34 | m.EventProcessor.On(event.StatePolicyServer, m.UpdatePolicyList) 35 | m.EventProcessor.On(event.StateLegacyPolicyUser, m.UpdatePolicyList) 36 | m.EventProcessor.On(event.StateLegacyPolicyRoom, m.UpdatePolicyList) 37 | m.EventProcessor.On(event.StateLegacyPolicyServer, m.UpdatePolicyList) 38 | m.EventProcessor.On(event.StateUnstablePolicyUser, m.UpdatePolicyList) 39 | m.EventProcessor.On(event.StateUnstablePolicyRoom, m.UpdatePolicyList) 40 | m.EventProcessor.On(event.StateUnstablePolicyServer, m.UpdatePolicyList) 41 | m.EventProcessor.On(event.EventRedaction, m.UpdatePolicyList) 42 | // Management room config 43 | m.EventProcessor.On(config.StateWatchedLists, m.HandleConfigChange) 44 | m.EventProcessor.On(config.StateProtectedRooms, m.HandleConfigChange) 45 | m.EventProcessor.On(event.StatePowerLevels, m.HandleConfigChange) 46 | m.EventProcessor.On(event.StateRoomName, m.HandleConfigChange) 47 | m.EventProcessor.On(event.StateServerACL, m.HandleConfigChange) 48 | // General event handling 49 | m.EventProcessor.On(event.StateMember, m.HandleMember) 50 | m.EventProcessor.On(event.EventMessage, m.HandleMessage) 51 | m.EventProcessor.On(event.EventReaction, m.HandleReaction) 52 | m.EventProcessor.On(event.EventSticker, m.HandleMessage) 53 | m.EventProcessor.On(event.EventEncrypted, m.HandleEncrypted) 54 | } 55 | 56 | func (m *Meowlnir) HandleToDeviceEvent(ctx context.Context, evt *event.Event) { 57 | m.MapLock.RLock() 58 | bot, ok := m.Bots[evt.ToUserID] 59 | m.MapLock.RUnlock() 60 | if !ok { 61 | zerolog.Ctx(ctx).Warn(). 62 | Stringer("user_id", evt.ToUserID). 63 | Stringer("device_id", evt.ToDeviceID). 64 | Msg("Received to-device event for unknown user") 65 | } else { 66 | bot.Mach.HandleToDeviceEvent(ctx, evt) 67 | } 68 | } 69 | 70 | func (m *Meowlnir) HandleOTKCounts(ctx context.Context, evt *mautrix.OTKCount) { 71 | m.MapLock.RLock() 72 | bot, ok := m.Bots[evt.UserID] 73 | m.MapLock.RUnlock() 74 | if !ok { 75 | zerolog.Ctx(ctx).Warn(). 76 | Stringer("user_id", evt.UserID). 77 | Stringer("device_id", evt.DeviceID). 78 | Msg("Received OTK count for unknown user") 79 | } else { 80 | bot.Mach.HandleOTKCounts(ctx, evt) 81 | } 82 | } 83 | 84 | func (m *Meowlnir) UpdatePolicyList(ctx context.Context, evt *event.Event) { 85 | added, removed := m.PolicyStore.Update(evt) 86 | for _, eval := range m.EvaluatorByManagementRoom { 87 | eval.HandlePolicyListChange(ctx, evt.RoomID, added, removed) 88 | } 89 | } 90 | 91 | func (m *Meowlnir) HandleConfigChange(ctx context.Context, evt *event.Event) { 92 | // All room config events should have an empty state key 93 | if evt.StateKey == nil || *evt.StateKey != "" { 94 | return 95 | } 96 | m.MapLock.RLock() 97 | managementRoom, isManagement := m.EvaluatorByManagementRoom[evt.RoomID] 98 | protectedRoom, isProtected := m.EvaluatorByProtectedRoom[evt.RoomID] 99 | m.MapLock.RUnlock() 100 | if isManagement { 101 | managementRoom.HandleConfigChange(ctx, evt) 102 | } else if isProtected { 103 | protectedRoom.HandleProtectedRoomMeta(ctx, evt) 104 | } 105 | } 106 | 107 | func (m *Meowlnir) HandleMember(ctx context.Context, evt *event.Event) { 108 | content, ok := evt.Content.Parsed.(*event.MemberEventContent) 109 | if !ok { 110 | return 111 | } 112 | m.MapLock.RLock() 113 | bot, botOK := m.Bots[id.UserID(evt.GetStateKey())] 114 | managementRoom, managementOK := m.EvaluatorByManagementRoom[evt.RoomID] 115 | roomProtector, protectedOK := m.EvaluatorByProtectedRoom[evt.RoomID] 116 | m.MapLock.RUnlock() 117 | if botOK && managementOK && content.Membership == event.MembershipInvite { 118 | _, err := bot.Client.JoinRoomByID(ctx, evt.RoomID) 119 | if err != nil { 120 | zerolog.Ctx(ctx).Err(err). 121 | Stringer("room_id", evt.RoomID). 122 | Stringer("inviter", evt.Sender). 123 | Msg("Failed to join management room after invite") 124 | } else { 125 | zerolog.Ctx(ctx).Info(). 126 | Stringer("room_id", evt.RoomID). 127 | Stringer("inviter", evt.Sender). 128 | Msg("Joined management room after invite, loading room state") 129 | managementRoom.Load(ctx) 130 | } 131 | } 132 | if protectedOK { 133 | roomProtector.HandleMember(ctx, evt) 134 | } 135 | } 136 | 137 | func (m *Meowlnir) HandleEncrypted(ctx context.Context, evt *event.Event) { 138 | m.MapLock.RLock() 139 | _, isBot := m.Bots[evt.Sender] 140 | managementRoom, isManagement := m.EvaluatorByManagementRoom[evt.RoomID] 141 | //roomProtector, isProtected := m.EvaluatorByProtectedRoom[evt.RoomID] 142 | m.MapLock.RUnlock() 143 | if isBot { 144 | return 145 | } else if isManagement && managementRoom.Bot.CryptoHelper != nil { 146 | managementRoom.Bot.CryptoHelper.HandleEncrypted(ctx, evt) 147 | } 148 | //else if isProtected { 149 | // roomProtector.HandleMessage(ctx, evt) 150 | //} 151 | } 152 | 153 | func (m *Meowlnir) HandleMessage(ctx context.Context, evt *event.Event) { 154 | content, ok := evt.Content.Parsed.(*event.MessageEventContent) 155 | if !ok { 156 | return 157 | } 158 | m.MapLock.RLock() 159 | _, isBot := m.Bots[evt.Sender] 160 | managementRoom, isManagement := m.EvaluatorByManagementRoom[evt.RoomID] 161 | roomProtector, isProtected := m.EvaluatorByProtectedRoom[evt.RoomID] 162 | m.MapLock.RUnlock() 163 | if isBot { 164 | return 165 | } 166 | if isManagement { 167 | if content.MsgType == event.MsgText && managementRoom.Admins.Has(evt.Sender) { 168 | managementRoom.HandleCommand(ctx, evt) 169 | } 170 | } else if isProtected { 171 | roomProtector.HandleMessage(ctx, evt) 172 | } 173 | } 174 | 175 | func (m *Meowlnir) HandleReaction(ctx context.Context, evt *event.Event) { 176 | m.MapLock.RLock() 177 | _, isBot := m.Bots[evt.Sender] 178 | managementRoom, isManagement := m.EvaluatorByManagementRoom[evt.RoomID] 179 | //roomProtector, isProtected := m.EvaluatorByProtectedRoom[evt.RoomID] 180 | m.MapLock.RUnlock() 181 | if isBot { 182 | return 183 | } 184 | if isManagement && managementRoom.Admins.Has(evt.Sender) { 185 | managementRoom.HandleReaction(ctx, evt) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /cmd/meowlnir/http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "slices" 6 | 7 | "github.com/rs/zerolog/hlog" 8 | "go.mau.fi/util/exhttp" 9 | "go.mau.fi/util/requestlog" 10 | ) 11 | 12 | func (m *Meowlnir) AddHTTPEndpoints() { 13 | clientRouter := http.NewServeMux() 14 | clientRouter.HandleFunc("POST /v3/rooms/{roomID}/report/{eventID}", m.PostReport) 15 | clientRouter.HandleFunc("POST /v3/rooms/{roomID}/report", m.PostReport) 16 | clientRouter.HandleFunc("POST /v3/users/{userID}/report", m.PostReport) 17 | m.AS.Router.PathPrefix("/_matrix/client").Handler(applyMiddleware( 18 | http.StripPrefix("/_matrix/client", clientRouter), 19 | hlog.NewHandler(m.Log.With().Str("component", "reporting api").Logger()), 20 | hlog.RequestIDHandler("request_id", "X-Request-ID"), 21 | exhttp.CORSMiddleware, 22 | requestlog.AccessLogger(false), 23 | m.ClientAuth, 24 | )) 25 | 26 | policyServerRouter := http.NewServeMux() 27 | policyServerRouter.HandleFunc("POST /unstable/org.matrix.msc4284/event/{event_id}/check", m.PostMSC4284EventCheck) 28 | 29 | m.AS.Router.PathPrefix("/_matrix/policy").Handler(applyMiddleware( 30 | http.StripPrefix("/_matrix/policy", policyServerRouter), 31 | hlog.NewHandler(m.Log.With().Str("component", "policy server").Logger()), 32 | hlog.RequestIDHandler("request_id", "X-Request-ID"), 33 | requestlog.AccessLogger(false), 34 | m.PolicyServer.ServerAuth.AuthenticateMiddleware, 35 | )) 36 | 37 | antispamRouter := http.NewServeMux() 38 | antispamRouter.HandleFunc("POST /{policyListID}/{callback}", m.PostCallback) 39 | m.AS.Router.PathPrefix("/_meowlnir/antispam").Handler(applyMiddleware( 40 | http.StripPrefix("/_meowlnir/antispam", antispamRouter), 41 | hlog.NewHandler(m.Log.With().Str("component", "antispam api").Logger()), 42 | hlog.RequestIDHandler("request_id", "X-Request-ID"), 43 | requestlog.AccessLogger(false), 44 | m.AntispamAuth, 45 | )) 46 | 47 | managementRouter := http.NewServeMux() 48 | managementRouter.HandleFunc("GET /v1/bots", m.GetBots) 49 | managementRouter.HandleFunc("PUT /v1/bot/{username}", m.PutBot) 50 | managementRouter.HandleFunc("POST /v1/bot/{username}/verify", m.PostVerifyBot) 51 | managementRouter.HandleFunc("PUT /v1/management_room/{roomID}", m.PutManagementRoom) 52 | m.AS.Router.PathPrefix("/_meowlnir").Handler(applyMiddleware( 53 | http.StripPrefix("/_meowlnir", managementRouter), 54 | hlog.NewHandler(m.Log.With().Str("component", "management api").Logger()), 55 | hlog.RequestIDHandler("request_id", "X-Request-ID"), 56 | exhttp.CORSMiddleware, 57 | requestlog.AccessLogger(false), 58 | m.ManagementAuth, 59 | )) 60 | } 61 | 62 | func applyMiddleware(router http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { 63 | slices.Reverse(middleware) 64 | for _, m := range middleware { 65 | router = m(router) 66 | } 67 | return router 68 | } 69 | -------------------------------------------------------------------------------- /cmd/meowlnir/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/signal" 10 | "runtime" 11 | "strings" 12 | "sync" 13 | "syscall" 14 | "time" 15 | 16 | _ "github.com/lib/pq" 17 | "github.com/rs/zerolog" 18 | up "go.mau.fi/util/configupgrade" 19 | "go.mau.fi/util/dbutil" 20 | _ "go.mau.fi/util/dbutil/litestream" 21 | "go.mau.fi/util/exerrors" 22 | "go.mau.fi/util/exslices" 23 | "go.mau.fi/util/exzerolog" 24 | "go.mau.fi/util/glob" 25 | "go.mau.fi/util/ptr" 26 | "gopkg.in/yaml.v3" 27 | flag "maunium.net/go/mauflag" 28 | "maunium.net/go/mautrix" 29 | "maunium.net/go/mautrix/appservice" 30 | cryptoupgrade "maunium.net/go/mautrix/crypto/sql_store_upgrade" 31 | "maunium.net/go/mautrix/id" 32 | "maunium.net/go/mautrix/sqlstatestore" 33 | 34 | "go.mau.fi/meowlnir/bot" 35 | "go.mau.fi/meowlnir/config" 36 | "go.mau.fi/meowlnir/database" 37 | "go.mau.fi/meowlnir/policyeval" 38 | "go.mau.fi/meowlnir/policyeval/roomhash" 39 | "go.mau.fi/meowlnir/policylist" 40 | "go.mau.fi/meowlnir/synapsedb" 41 | "go.mau.fi/meowlnir/util" 42 | ) 43 | 44 | var configPath = flag.MakeFull("c", "config", "Path to the config file", "config.yaml").String() 45 | var noSaveConfig = flag.MakeFull("n", "no-update", "Don't update the config file", "false").Bool() 46 | var version = flag.MakeFull("v", "version", "Print the version and exit", "false").Bool() 47 | var writeExampleConfig = flag.MakeFull("e", "generate-example-config", "Save the example config to the config path and quit.", "false").Bool() 48 | var wantHelp, _ = flag.MakeHelpFlag() 49 | 50 | type Meowlnir struct { 51 | Config *config.Config 52 | Log *zerolog.Logger 53 | DB *database.Database 54 | SynapseDB *synapsedb.SynapseDB 55 | StateStore *sqlstatestore.SQLStateStore 56 | CryptoStoreDB *dbutil.Database 57 | AS *appservice.AppService 58 | EventProcessor *appservice.EventProcessor 59 | PolicyServer *policyeval.PolicyServer 60 | 61 | ManagementSecret [32]byte 62 | AntispamSecret [32]byte 63 | 64 | PolicyStore *policylist.Store 65 | MapLock sync.RWMutex 66 | Bots map[id.UserID]*bot.Bot 67 | EvaluatorByProtectedRoom map[id.RoomID]*policyeval.PolicyEvaluator 68 | EvaluatorByManagementRoom map[id.RoomID]*policyeval.PolicyEvaluator 69 | HackyAutoRedactPatterns []glob.Glob 70 | 71 | RoomHashes *roomhash.Map 72 | } 73 | 74 | func (m *Meowlnir) loadSecret(secret string) [32]byte { 75 | if strings.HasPrefix(secret, "sha256:") { 76 | var decoded []byte 77 | var err error 78 | decoded, err = hex.DecodeString(strings.TrimPrefix(secret, "sha256:")) 79 | if err != nil { 80 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to decode secret hash") 81 | os.Exit(10) 82 | } else if len(decoded) != 32 { 83 | m.Log.WithLevel(zerolog.FatalLevel).Msg("Secret hash is not 32 bytes long") 84 | os.Exit(10) 85 | } 86 | return [32]byte(decoded) 87 | } 88 | return util.SHA256String(secret) 89 | } 90 | 91 | func (m *Meowlnir) Init(configPath string, noSaveConfig bool) { 92 | var err error 93 | m.Config = loadConfig(configPath, noSaveConfig) 94 | 95 | policylist.HackyRuleFilter = m.Config.Meowlnir.HackyRuleFilter 96 | policylist.HackyRuleFilterHashes = exslices.CastFunc(policylist.HackyRuleFilter, func(s string) [32]byte { 97 | return util.SHA256String(s) 98 | }) 99 | 100 | m.Log, err = m.Config.Logging.Compile() 101 | if err != nil { 102 | _, _ = fmt.Fprintln(os.Stderr, "Failed to configure logger:", err) 103 | os.Exit(11) 104 | } 105 | exzerolog.SetupDefaults(m.Log) 106 | 107 | m.Log.Info(). 108 | Str("version", VersionWithCommit). 109 | Time("built_at", ParsedBuildTime). 110 | Str("go_version", runtime.Version()). 111 | Msg("Initializing Meowlnir") 112 | 113 | m.ManagementSecret = m.loadSecret(m.Config.Meowlnir.ManagementSecret) 114 | m.AntispamSecret = m.loadSecret(m.Config.Antispam.Secret) 115 | 116 | var mainDB, synapseDB *dbutil.Database 117 | mainDB, err = dbutil.NewFromConfig("meowlnir", m.Config.Database, dbutil.ZeroLogger(m.Log.With().Str("db_section", "main").Logger())) 118 | if err != nil { 119 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to connect to Meowlnir database") 120 | os.Exit(12) 121 | } 122 | if m.Config.SynapseDB.URI != "" { 123 | synapseDB, err = dbutil.NewFromConfig("", m.Config.SynapseDB, dbutil.ZeroLogger(m.Log.With().Str("db_section", "synapse").Logger())) 124 | if err != nil { 125 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to connect to Synapse database") 126 | os.Exit(12) 127 | } 128 | } 129 | 130 | m.RoomHashes = roomhash.NewMap() 131 | m.DB = database.New(mainDB) 132 | m.StateStore = sqlstatestore.NewSQLStateStore(mainDB, dbutil.ZeroLogger(m.Log.With().Str("db_section", "matrix_state").Logger()), false) 133 | if m.Config.Encryption.Enable { 134 | m.CryptoStoreDB = mainDB.Child(cryptoupgrade.VersionTableName, cryptoupgrade.Table, dbutil.ZeroLogger(m.Log.With().Str("db_section", "crypto").Logger())) 135 | } 136 | if synapseDB != nil { 137 | m.SynapseDB = &synapsedb.SynapseDB{DB: synapseDB} 138 | } 139 | 140 | m.Log.Debug().Msg("Preparing Matrix client") 141 | m.AS, err = appservice.CreateFull(appservice.CreateOpts{ 142 | Registration: &appservice.Registration{ 143 | ID: m.Config.Meowlnir.ID, 144 | URL: m.Config.Meowlnir.Address, 145 | AppToken: m.Config.Meowlnir.ASToken, 146 | ServerToken: m.Config.Meowlnir.HSToken, 147 | RateLimited: ptr.Ptr(false), 148 | SoruEphemeralEvents: true, 149 | EphemeralEvents: true, 150 | MSC3202: true, 151 | MSC4190: true, 152 | }, 153 | HomeserverDomain: m.Config.Homeserver.Domain, 154 | HomeserverURL: m.Config.Homeserver.Address, 155 | HostConfig: appservice.HostConfig{ 156 | Hostname: m.Config.Meowlnir.Hostname, 157 | Port: m.Config.Meowlnir.Port, 158 | }, 159 | }) 160 | if err != nil { 161 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to create Matrix appservice") 162 | os.Exit(13) 163 | } 164 | m.PolicyServer = policyeval.NewPolicyServer(m.Config.Homeserver.Domain) 165 | m.AS.Log = m.Log.With().Str("component", "matrix").Logger() 166 | m.AS.StateStore = m.StateStore 167 | m.EventProcessor = appservice.NewEventProcessor(m.AS) 168 | m.AddEventHandlers() 169 | m.AddHTTPEndpoints() 170 | 171 | m.PolicyStore = policylist.NewStore() 172 | m.Bots = make(map[id.UserID]*bot.Bot) 173 | m.EvaluatorByProtectedRoom = make(map[id.RoomID]*policyeval.PolicyEvaluator) 174 | m.EvaluatorByManagementRoom = make(map[id.RoomID]*policyeval.PolicyEvaluator) 175 | 176 | var compiledGlobs []glob.Glob 177 | for _, pattern := range m.Config.Meowlnir.HackyRedactPatterns { 178 | compiled := glob.Compile(pattern) 179 | compiledGlobs = append(compiledGlobs, compiled) 180 | } 181 | m.HackyAutoRedactPatterns = compiledGlobs 182 | 183 | m.Log.Info().Msg("Initialization complete") 184 | } 185 | 186 | func (m *Meowlnir) claimProtectedRoom(roomID id.RoomID, eval *policyeval.PolicyEvaluator, claim bool) *policyeval.PolicyEvaluator { 187 | m.MapLock.Lock() 188 | defer m.MapLock.Unlock() 189 | _, isManagement := m.EvaluatorByManagementRoom[roomID] 190 | if isManagement { 191 | return nil 192 | } 193 | if existing, ok := m.EvaluatorByProtectedRoom[roomID]; ok { 194 | if claim { 195 | return existing 196 | } 197 | if existing == eval { 198 | delete(m.EvaluatorByProtectedRoom, roomID) 199 | } 200 | return nil 201 | } else if !claim { 202 | return nil 203 | } 204 | m.EvaluatorByProtectedRoom[roomID] = eval 205 | return eval 206 | } 207 | 208 | func (m *Meowlnir) createPuppetClient(userID id.UserID) *mautrix.Client { 209 | cli := exerrors.Must(m.AS.NewExternalMautrixClient(userID, m.Config.Antispam.AutoRejectInvitesToken, "")) 210 | cli.SetAppServiceUserID = true 211 | return cli 212 | } 213 | 214 | func (m *Meowlnir) initBot(ctx context.Context, db *database.Bot) *bot.Bot { 215 | intent := m.AS.Intent(id.NewUserID(db.Username, m.AS.HomeserverDomain)) 216 | wrapped := bot.NewBot( 217 | db, intent, m.Log.With().Str("bot", db.Username).Logger(), 218 | m.DB, m.EventProcessor, m.CryptoStoreDB, m.Config.Encryption.PickleKey, 219 | m.Config.Meowlnir.AdminTokens[intent.UserID], 220 | ) 221 | wrapped.Init(ctx) 222 | if wrapped.CryptoHelper != nil { 223 | wrapped.CryptoHelper.CustomPostDecrypt = m.HandleMessage 224 | } 225 | m.Bots[wrapped.Client.UserID] = wrapped 226 | 227 | managementRooms, err := m.DB.ManagementRoom.GetAll(ctx, db.Username) 228 | if err != nil { 229 | wrapped.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get management room list") 230 | os.Exit(15) 231 | } 232 | for _, roomID := range managementRooms { 233 | m.EvaluatorByManagementRoom[roomID] = m.newPolicyEvaluator(wrapped, roomID) 234 | } 235 | return wrapped 236 | } 237 | 238 | func (m *Meowlnir) newPolicyEvaluator(bot *bot.Bot, roomID id.RoomID) *policyeval.PolicyEvaluator { 239 | var roomHashes *roomhash.Map 240 | if m.Config.Meowlnir.RoomBanRoom == roomID { 241 | roomHashes = m.RoomHashes 242 | } 243 | return policyeval.NewPolicyEvaluator( 244 | bot, m.PolicyStore, 245 | roomID, 246 | m.DB, 247 | m.SynapseDB, 248 | m.claimProtectedRoom, 249 | m.createPuppetClient, 250 | m.Config.Antispam.AutoRejectInvitesToken != "", 251 | m.Config.Antispam.FilterLocalInvites, 252 | m.Config.Meowlnir.DryRun, 253 | m.HackyAutoRedactPatterns, 254 | m.PolicyServer, 255 | roomHashes, 256 | ) 257 | } 258 | 259 | func (m *Meowlnir) loadManagementRoom(ctx context.Context, roomID id.RoomID, bot *bot.Bot) bool { 260 | m.MapLock.Lock() 261 | defer m.MapLock.Unlock() 262 | eval, ok := m.EvaluatorByManagementRoom[roomID] 263 | if ok { 264 | if eval.Bot == bot { 265 | return false 266 | } 267 | delete(m.EvaluatorByManagementRoom, roomID) 268 | for _, room := range m.EvaluatorByProtectedRoom { 269 | if room == eval { 270 | delete(m.EvaluatorByProtectedRoom, roomID) 271 | } 272 | } 273 | } 274 | eval = m.newPolicyEvaluator(bot, roomID) 275 | m.EvaluatorByManagementRoom[roomID] = eval 276 | go eval.Load(ctx) 277 | return true 278 | } 279 | 280 | func (m *Meowlnir) Run(ctx context.Context) { 281 | if m.SynapseDB != nil { 282 | err := m.SynapseDB.CheckVersion(ctx) 283 | if err != nil { 284 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to check Synapse database schema version") 285 | os.Exit(14) 286 | } 287 | } 288 | err := m.DB.Upgrade(ctx) 289 | if err != nil { 290 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to upgrade main db") 291 | os.Exit(14) 292 | } 293 | err = m.StateStore.Upgrade(ctx) 294 | if err != nil { 295 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to upgrade state store") 296 | os.Exit(14) 297 | } 298 | if m.CryptoStoreDB != nil { 299 | err = m.CryptoStoreDB.Upgrade(ctx) 300 | if err != nil { 301 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to upgrade crypto store") 302 | os.Exit(14) 303 | } 304 | } 305 | 306 | bots, err := m.DB.Bot.GetAll(ctx) 307 | if err != nil { 308 | m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get bot list") 309 | os.Exit(15) 310 | } 311 | for _, dbBot := range bots { 312 | m.initBot(ctx, dbBot) 313 | } 314 | 315 | m.EventProcessor.Start(ctx) 316 | go m.AS.Start() 317 | 318 | var wg sync.WaitGroup 319 | m.MapLock.Lock() 320 | wg.Add(len(m.EvaluatorByManagementRoom)) 321 | for _, room := range m.EvaluatorByManagementRoom { 322 | go func() { 323 | defer wg.Done() 324 | room.Load(ctx) 325 | }() 326 | } 327 | m.MapLock.Unlock() 328 | if m.Config.Meowlnir.RoomBanRoom != "" && m.Config.Meowlnir.LoadAllRoomHashes { 329 | wg.Add(1) 330 | go func() { 331 | defer wg.Done() 332 | m.LoadAllRoomHashes(ctx) 333 | }() 334 | } 335 | wg.Wait() 336 | 337 | m.Log.Info().Msg("Startup complete") 338 | m.AS.Ready = true 339 | 340 | <-ctx.Done() 341 | err = m.DB.Close() 342 | if err != nil { 343 | m.Log.Err(err).Msg("Failed to close database") 344 | } 345 | if m.SynapseDB != nil { 346 | err = m.SynapseDB.Close() 347 | if err != nil { 348 | m.Log.Err(err).Msg("Failed to close Synapse database") 349 | } 350 | } 351 | } 352 | 353 | func (m *Meowlnir) LoadAllRoomHashes(ctx context.Context) { 354 | if m.SynapseDB == nil { 355 | m.Log.Warn().Msg("Synapse database not configured, can't load all room hashes") 356 | return 357 | } 358 | start := time.Now() 359 | rooms := m.SynapseDB.GetAllRooms(ctx) 360 | count := 0 361 | err := rooms.Iter(func(roomID id.RoomID) (bool, error) { 362 | count++ 363 | m.RoomHashes.Put(roomID) 364 | return true, nil 365 | }) 366 | dur := time.Since(start) 367 | if err != nil { 368 | m.Log.Err(err).Dur("duration", dur).Msg("Failed to read room hashes from synapse database") 369 | } else { 370 | m.Log.Info().Dur("duration", dur).Int("count", count).Msg("Read all existing room IDs from synapse database") 371 | } 372 | } 373 | 374 | func loadConfig(path string, noSave bool) *config.Config { 375 | configData, _, err := up.Do(path, !noSave, config.Upgrader) 376 | if err != nil { 377 | _, _ = fmt.Fprintln(os.Stderr, "Failed to upgrade config:", err) 378 | os.Exit(10) 379 | } 380 | var cfg config.Config 381 | err = yaml.Unmarshal(configData, &cfg) 382 | if err != nil { 383 | _, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err) 384 | os.Exit(10) 385 | } 386 | return &cfg 387 | } 388 | 389 | func main() { 390 | initVersion() 391 | flag.SetHelpTitles( 392 | "meowlnir - An opinionated Matrix moderation bot.", 393 | "meowlnir [-hnve] [-c ]", 394 | ) 395 | err := flag.Parse() 396 | if err != nil { 397 | _, _ = fmt.Fprintln(os.Stderr, err) 398 | os.Exit(1) 399 | } else if *wantHelp { 400 | flag.PrintHelp() 401 | os.Exit(0) 402 | } else if *version { 403 | fmt.Println(VersionDescription) 404 | os.Exit(0) 405 | } else if *writeExampleConfig { 406 | if *configPath != "-" && *configPath != "/dev/stdout" && *configPath != "/dev/stderr" { 407 | if _, err = os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) { 408 | _, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example") 409 | os.Exit(1) 410 | } 411 | } 412 | if *configPath == "-" { 413 | fmt.Print(config.ExampleConfig) 414 | } else { 415 | exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(config.ExampleConfig), 0600)) 416 | fmt.Println("Wrote example config to", *configPath) 417 | } 418 | os.Exit(0) 419 | } 420 | var m Meowlnir 421 | ctx, cancel := context.WithCancel(context.Background()) 422 | m.Init(*configPath, *noSaveConfig) 423 | ctx = m.Log.WithContext(ctx) 424 | go func() { 425 | c := make(chan os.Signal, 1) 426 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 427 | <-c 428 | cancel() 429 | }() 430 | m.Run(ctx) 431 | m.Log.Info().Msg("Meowlnir stopped") 432 | } 433 | -------------------------------------------------------------------------------- /cmd/meowlnir/policyserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/rs/zerolog/hlog" 8 | "maunium.net/go/mautrix" 9 | "maunium.net/go/mautrix/event" 10 | "maunium.net/go/mautrix/id" 11 | 12 | "go.mau.fi/util/exhttp" 13 | ) 14 | 15 | func (m *Meowlnir) PostMSC4284EventCheck(w http.ResponseWriter, r *http.Request) { 16 | eventID := id.EventID(r.PathValue("event_id")) 17 | var req event.Event 18 | err := json.NewDecoder(r.Body).Decode(&req) 19 | if err != nil { 20 | hlog.FromRequest(r).Err(err).Msg("Failed to parse request body") 21 | mautrix.MNotJSON.WithMessage("Request body is not valid JSON").Write(w) 22 | return 23 | } 24 | 25 | m.MapLock.RLock() 26 | eval, ok := m.EvaluatorByProtectedRoom[req.RoomID] 27 | m.MapLock.RUnlock() 28 | if !ok { 29 | mautrix.MNotFound.WithMessage("Policy server error: room is not protected").Write(w) 30 | return 31 | } 32 | resp, err := m.PolicyServer.HandleCheck(r.Context(), eventID, &req, eval, m.Config.PolicyServer.AlwaysRedact) 33 | if err != nil { 34 | hlog.FromRequest(r).Err(err).Msg("Failed to handle check") 35 | mautrix.MUnknown.WithMessage("Policy server error: internal server error").Write(w) 36 | return 37 | } 38 | exhttp.WriteJSONResponse(w, http.StatusOK, resp) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/meowlnir/reporting.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/rs/zerolog/hlog" 11 | "go.mau.fi/util/exerrors" 12 | "go.mau.fi/util/exhttp" 13 | "maunium.net/go/mautrix" 14 | "maunium.net/go/mautrix/id" 15 | ) 16 | 17 | type contextKey int 18 | 19 | const contextKeyUserClient contextKey = iota 20 | 21 | func (m *Meowlnir) ClientAuth(next http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | authToken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") 24 | if authToken == "" { 25 | mautrix.MMissingToken.WithMessage("Missing access token").Write(w) 26 | return 27 | } 28 | client := exerrors.Must(m.AS.NewExternalMautrixClient("", authToken, "")) 29 | resp, err := client.Whoami(r.Context()) 30 | if err != nil { 31 | if errors.Is(err, mautrix.MUnknownToken) { 32 | mautrix.MUnknownToken.WithMessage("Unknown access token").Write(w) 33 | } else { 34 | mautrix.MUnknown.WithMessage("Failed to validate access token").Write(w) 35 | } 36 | return 37 | } 38 | client.UserID = resp.UserID 39 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKeyUserClient, client))) 40 | }) 41 | } 42 | 43 | func (m *Meowlnir) PostReport(w http.ResponseWriter, r *http.Request) { 44 | var req mautrix.ReqReport 45 | err := json.NewDecoder(r.Body).Decode(&req) 46 | if err != nil { 47 | mautrix.MBadJSON.WithMessage("Invalid JSON").Write(w) 48 | return 49 | } 50 | m.MapLock.RLock() 51 | mgmtRoom, ok := m.EvaluatorByManagementRoom[m.Config.Meowlnir.ReportRoom] 52 | m.MapLock.RUnlock() 53 | if !ok { 54 | mautrix.MUnrecognized.WithMessage("Reporting is not configured correctly").Write(w) 55 | return 56 | } 57 | 58 | roomID := id.RoomID(r.PathValue("roomID")) 59 | eventID := id.EventID(r.PathValue("eventID")) 60 | reportedUserID := id.UserID(r.PathValue("userID")) 61 | userClient := r.Context().Value(contextKeyUserClient).(*mautrix.Client) 62 | log := hlog.FromRequest(r).With(). 63 | Stringer("report_room_id", roomID). 64 | Stringer("report_event_id", eventID). 65 | Stringer("reported_user_id", reportedUserID). 66 | Stringer("reporter_sender", userClient.UserID). 67 | Str("action", "handle report"). 68 | Logger() 69 | ctx := context.WithoutCancel(log.WithContext(r.Context())) 70 | err = mgmtRoom.HandleReport(ctx, userClient, reportedUserID, roomID, eventID, req.Reason) 71 | if err != nil { 72 | log.Err(err).Msg("Failed to handle report") 73 | var respErr mautrix.RespError 74 | if errors.As(err, &respErr) { 75 | respErr.Write(w) 76 | } else { 77 | mautrix.MUnknown.WithMessage(err.Error()).Write(w) 78 | } 79 | } else { 80 | exhttp.WriteEmptyJSONResponse(w, http.StatusOK) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /cmd/meowlnir/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | "time" 8 | 9 | "maunium.net/go/mautrix" 10 | ) 11 | 12 | const ( 13 | Name = "Meowlnir" 14 | URL = "https://github.com/maunium/meowlnir" 15 | Version = "0.5.0" 16 | ) 17 | 18 | var ( 19 | BuildTime string 20 | Commit string 21 | Tag string 22 | 23 | ParsedBuildTime time.Time 24 | LinkifiedVersion string 25 | VersionWithCommit string 26 | VersionDescription string 27 | ) 28 | 29 | func initVersion() { 30 | Tag = strings.TrimPrefix(Tag, "v") 31 | LinkifiedVersion = fmt.Sprintf("v%s", Version) 32 | if Tag != Version { 33 | suffix := "" 34 | if !strings.HasSuffix(Version, "+dev") { 35 | suffix = "+dev" 36 | } 37 | if len(Commit) > 8 { 38 | VersionWithCommit = fmt.Sprintf("%s%s.%s", Version, suffix, Commit[:8]) 39 | LinkifiedVersion = fmt.Sprintf("[%s%s.%s](%s/commit/%s)", Version, suffix, Commit[:8], URL, Commit) 40 | } else { 41 | VersionWithCommit = fmt.Sprintf("%s%s.unknown", Version, suffix) 42 | } 43 | } else { 44 | VersionWithCommit = Version 45 | LinkifiedVersion = fmt.Sprintf("[v%s](%s/releases/v%s)", Version, URL, Tag) 46 | } 47 | if BuildTime != "" { 48 | ParsedBuildTime, _ = time.Parse(time.RFC3339, BuildTime) 49 | } 50 | var builtWith string 51 | if ParsedBuildTime.IsZero() { 52 | builtWith = runtime.Version() 53 | } else { 54 | builtWith = fmt.Sprintf("built at %s with %s", ParsedBuildTime.Format(time.RFC1123), runtime.Version()) 55 | } 56 | mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", Name, VersionWithCommit, mautrix.DefaultUserAgent) 57 | VersionDescription = fmt.Sprintf("%s %s (%s)", Name, VersionWithCommit, builtWith) 58 | } 59 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "go.mau.fi/util/dbutil" 7 | "go.mau.fi/zeroconfig" 8 | "maunium.net/go/mautrix/id" 9 | ) 10 | 11 | //go:embed example-config.yaml 12 | var ExampleConfig string 13 | 14 | type HomeserverConfig struct { 15 | Address string `yaml:"address"` 16 | Domain string `yaml:"domain"` 17 | } 18 | 19 | type MeowlnirConfig struct { 20 | ID string `yaml:"id"` 21 | ASToken string `yaml:"as_token"` 22 | HSToken string `yaml:"hs_token"` 23 | 24 | Address string `yaml:"address"` 25 | Hostname string `yaml:"hostname"` 26 | Port uint16 `yaml:"port"` 27 | 28 | ManagementSecret string `yaml:"management_secret"` 29 | DryRun bool `yaml:"dry_run"` 30 | 31 | ReportRoom id.RoomID `yaml:"report_room"` 32 | RoomBanRoom id.RoomID `yaml:"room_ban_room"` 33 | LoadAllRoomHashes bool `yaml:"load_all_room_hashes"` 34 | HackyRuleFilter []string `yaml:"hacky_rule_filter"` 35 | HackyRedactPatterns []string `yaml:"hacky_redact_patterns"` 36 | 37 | AdminTokens map[id.UserID]string `yaml:"admin_tokens"` 38 | } 39 | 40 | type PolicyServerConfig struct { 41 | AlwaysRedact bool `yaml:"always_redact"` 42 | } 43 | 44 | type AntispamConfig struct { 45 | Secret string `yaml:"secret"` 46 | FilterLocalInvites bool `yaml:"filter_local_invites"` 47 | AutoRejectInvitesToken string `yaml:"auto_reject_invites_token"` 48 | } 49 | 50 | type EncryptionConfig struct { 51 | Enable bool `yaml:"enable"` 52 | PickleKey string `yaml:"pickle_key"` 53 | } 54 | 55 | type Config struct { 56 | Homeserver HomeserverConfig `yaml:"homeserver"` 57 | Meowlnir MeowlnirConfig `yaml:"meowlnir"` 58 | Antispam AntispamConfig `yaml:"antispam"` 59 | PolicyServer PolicyServerConfig `yaml:"policy_server"` 60 | Encryption EncryptionConfig `yaml:"encryption"` 61 | Database dbutil.Config `yaml:"database"` 62 | SynapseDB dbutil.Config `yaml:"synapse_db"` 63 | Logging zeroconfig.Config `yaml:"logging"` 64 | } 65 | -------------------------------------------------------------------------------- /config/event.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | 6 | "maunium.net/go/mautrix/event" 7 | "maunium.net/go/mautrix/id" 8 | ) 9 | 10 | var ( 11 | StateWatchedLists = event.Type{Type: "fi.mau.meowlnir.watched_lists", Class: event.StateEventType} 12 | StateProtectedRooms = event.Type{Type: "fi.mau.meowlnir.protected_rooms", Class: event.StateEventType} 13 | ) 14 | 15 | type WatchedPolicyList struct { 16 | RoomID id.RoomID `json:"room_id"` 17 | Name string `json:"name"` 18 | Shortcode string `json:"shortcode"` 19 | DontApply bool `json:"dont_apply"` 20 | DontApplyACL bool `json:"dont_apply_acl"` 21 | AutoUnban bool `json:"auto_unban"` 22 | AutoSuspend bool `json:"auto_suspend"` 23 | 24 | DontNotifyOnChange bool `json:"dont_notify_on_change"` 25 | } 26 | 27 | type WatchedListsEventContent struct { 28 | Lists []WatchedPolicyList `json:"lists"` 29 | } 30 | 31 | type ProtectedRoomsEventContent struct { 32 | Rooms []id.RoomID `json:"rooms"` 33 | 34 | // TODO make this less hacky 35 | SkipACL []id.RoomID `json:"skip_acl"` 36 | } 37 | 38 | func init() { 39 | event.TypeMap[StateWatchedLists] = reflect.TypeOf(WatchedListsEventContent{}) 40 | event.TypeMap[StateProtectedRooms] = reflect.TypeOf(ProtectedRoomsEventContent{}) 41 | } 42 | -------------------------------------------------------------------------------- /config/example-config.yaml: -------------------------------------------------------------------------------- 1 | # Homeserver settings 2 | homeserver: 3 | # The address that Meowlnir can use to connect to the homeserver. 4 | address: http://localhost:8008 5 | # The server name of the homeserver. 6 | domain: example.com 7 | 8 | # Meowlnir server settings 9 | meowlnir: 10 | # The unique ID for the appservice. 11 | id: meowlnir 12 | # Set to generate to generate random tokens. 13 | as_token: generate 14 | hs_token: generate 15 | 16 | # The address that the homeserver can use to connect to Meowlnir. 17 | address: http://localhost:29339 18 | # The hostname and port where Meowlnir should listen 19 | hostname: 0.0.0.0 20 | port: 29339 21 | 22 | # Management secret used for the management API. If set to generate, a random secret will be generated. 23 | # If prefixed with sha256:, the rest of the string will be hex-decoded and used as the hash of the secret. 24 | management_secret: generate 25 | # If dry run is set to true, meowlnir won't take any actual actions, 26 | # but will do everything else as if it was going to take actions. 27 | dry_run: false 28 | 29 | # Which management room should handle requests to the Matrix report API? 30 | report_room: '!roomid:example.com' 31 | # Which management room should be in charge of deleting rooms from the server? 32 | # Room bans will not be processed in other management rooms. 33 | room_ban_room: null 34 | # If true, Meowlnir will load all room IDs from the Synapse database on startup. 35 | load_all_room_hashes: true 36 | # If a policy matches any of these entities, the policy is ignored entirely. 37 | # This can be used as a hacky way to protect against policies which are too wide. 38 | # 39 | # The example values can be left here and will already prevent banning everyone, 40 | # but you should also add some known-good users and servers that should never get banned. 41 | hacky_rule_filter: 42 | - "@user:example.com" 43 | - example.com 44 | # If a policy reason matches any of these patterns, the bot will automatically redact all messages from the banned 45 | # target. The reason `spam` is already implicit. Ignored for takedowns. 46 | # Uses a glob pattern to match. 47 | hacky_redact_patterns: 48 | - "spam" 49 | 50 | # If you don't want to or can't give your moderation bot the admin flag in Synapse, but still want 51 | # to be able to use admin API features, you can specify a custom admin access token here for each bot. 52 | # This is required when using MAS, as only special tokens have admin API access there. 53 | # If this is not specified, the bot will try to use its own as_token for admin API access. 54 | # 55 | # Example command for MAS-CLI how to generate an admin compatibility token: 56 | # mas-cli manage issue-compatibility-token --device-id --yes-i-want-to-grant-synapse-admin-privileges 57 | # https://element-hq.github.io/matrix-authentication-service/reference/cli/manage.html#manage-issue-compatibility-token 58 | admin_tokens: 59 | "@abuse:example.com": admin_token 60 | 61 | antispam: 62 | # Secret used for the synapse-http-antispam API. Same rules apply as for management_secret under meowlnir. 63 | secret: generate 64 | # If true, Meowlnir will check local invites for spam too instead of only federated ones. 65 | filter_local_invites: false 66 | # If set, Meowlnir will use this token to reject pending invites from users who get banned. 67 | # 68 | # This should be an appservice with access to all local users. If you have a double puppeting 69 | # appservice set up for bridges, you can reuse that token. If not, just follow the same 70 | # instructions: https://docs.mau.fi/bridges/general/double-puppeting.html 71 | auto_reject_invites_token: 72 | 73 | # Configuration for the policy server. 74 | policy_server: 75 | # If enabled, always issue redactions for events that are blocked by the policy server. 76 | # This is useful to prevent failed events from reaching servers that do not yet respect policy servers. 77 | always_redact: true 78 | 79 | # Encryption settings. 80 | encryption: 81 | # Should encryption be enabled? This requires MSC3202, MSC4190 and MSC4203 to be implemented on the server. 82 | # Meowlnir also implements MSC4153, which means only verified devices will be allowed to send/receive messages. 83 | enable: true 84 | # Pickle key used for encrypting encryption keys. 85 | # If set to generate, a random key will be generated. 86 | pickle_key: generate 87 | 88 | # Database config for meowlnir itself. 89 | database: 90 | # The database type. "sqlite3-fk-wal" and "postgres" are supported. 91 | type: postgres 92 | # The database URI. 93 | # SQLite: A raw file path is supported, but `file:?_txlock=immediate` is recommended. 94 | # https://github.com/mattn/go-sqlite3#connection-string 95 | # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable 96 | # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql 97 | uri: postgres://user:password@host/database?sslmode=disable 98 | # Maximum number of connections. Mostly relevant for Postgres. 99 | max_open_conns: 20 100 | max_idle_conns: 2 101 | # Maximum connection idle time and lifetime before they're closed. Disabled if null. 102 | # Parsed with https://pkg.go.dev/time#ParseDuration 103 | max_conn_idle_time: null 104 | max_conn_lifetime: null 105 | 106 | # Database config for accessing the Synapse database. Only postgres is supported. 107 | synapse_db: 108 | type: postgres 109 | uri: postgres://user:password@host/synapse?sslmode=disable 110 | max_open_conns: 2 111 | max_idle_conns: 1 112 | max_conn_idle_time: null 113 | max_conn_lifetime: null 114 | 115 | # Logging config. See https://github.com/tulir/zeroconfig for details. 116 | logging: 117 | min_level: debug 118 | writers: 119 | - type: stdout 120 | format: pretty-colored 121 | - type: file 122 | format: json 123 | filename: ./logs/meowlnir.log 124 | max_size: 100 125 | max_backups: 10 126 | compress: false 127 | -------------------------------------------------------------------------------- /config/upgrade.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | up "go.mau.fi/util/configupgrade" 5 | "go.mau.fi/util/random" 6 | ) 7 | 8 | var Upgrader = &up.StructUpgrader{ 9 | SimpleUpgrader: upgradeConfig, 10 | Blocks: SpacedBlocks, 11 | Base: ExampleConfig, 12 | } 13 | 14 | func generateOrCopy(helper up.Helper, path ...string) { 15 | if secret, ok := helper.Get(up.Str, path...); !ok || secret == "generate" { 16 | helper.Set(up.Str, random.String(64), path...) 17 | } else { 18 | helper.Copy(up.Str, path...) 19 | } 20 | } 21 | 22 | func upgradeConfig(helper up.Helper) { 23 | helper.Copy(up.Str, "homeserver", "address") 24 | helper.Copy(up.Str, "homeserver", "domain") 25 | 26 | helper.Copy(up.Str, "meowlnir", "id") 27 | generateOrCopy(helper, "meowlnir", "as_token") 28 | generateOrCopy(helper, "meowlnir", "hs_token") 29 | helper.Copy(up.Str, "meowlnir", "address") 30 | helper.Copy(up.Str, "meowlnir", "hostname") 31 | helper.Copy(up.Int, "meowlnir", "port") 32 | 33 | generateOrCopy(helper, "meowlnir", "management_secret") 34 | helper.Copy(up.Bool, "meowlnir", "dry_run") 35 | helper.Copy(up.Str|up.Null, "meowlnir", "report_room") 36 | helper.Copy(up.Str|up.Null, "meowlnir", "room_ban_room") 37 | helper.Copy(up.Bool, "meowlnir", "load_all_room_hashes") 38 | helper.Copy(up.List, "meowlnir", "hacky_rule_filter") 39 | helper.Copy(up.List, "meowlnir", "hacky_redact_patterns") 40 | helper.Copy(up.Map, "meowlnir", "admin_tokens") 41 | 42 | if secret, ok := helper.Get(up.Str, "meowlnir", "antispam_secret"); ok && secret != "generate" { 43 | helper.Set(up.Str, secret, "antispam", "secret") 44 | } else { 45 | generateOrCopy(helper, "antispam", "secret") 46 | } 47 | helper.Copy(up.Str|up.Null, "antispam", "auto_reject_invites_token") 48 | helper.Copy(up.Bool, "antispam", "filter_local_invites") 49 | 50 | helper.Copy(up.Bool, "policy_server", "always_redact") 51 | 52 | if secret, ok := helper.Get(up.Str, "meowlnir", "pickle_key"); ok && secret != "generate" { 53 | helper.Set(up.Str, secret, "encryption", "pickle_key") 54 | } else { 55 | generateOrCopy(helper, "encryption", "pickle_key") 56 | } 57 | helper.Copy(up.Bool, "encryption", "enable") 58 | 59 | helper.Copy(up.Str, "database", "type") 60 | helper.Copy(up.Str, "database", "uri") 61 | helper.Copy(up.Int, "database", "max_open_conns") 62 | helper.Copy(up.Int, "database", "max_idle_conns") 63 | helper.Copy(up.Str|up.Null, "database", "max_conn_idle_time") 64 | helper.Copy(up.Str|up.Null, "database", "max_conn_lifetime") 65 | 66 | helper.Copy(up.Str|up.Null, "synapse_db", "type") 67 | helper.Copy(up.Str|up.Null, "synapse_db", "uri") 68 | helper.Copy(up.Int|up.Null, "synapse_db", "max_open_conns") 69 | helper.Copy(up.Int|up.Null, "synapse_db", "max_idle_conns") 70 | helper.Copy(up.Str|up.Null, "synapse_db", "max_conn_idle_time") 71 | helper.Copy(up.Str|up.Null, "synapse_db", "max_conn_lifetime") 72 | 73 | helper.Copy(up.Map, "logging") 74 | } 75 | 76 | var SpacedBlocks = [][]string{ 77 | {"meowlnir"}, 78 | {"meowlnir", "address"}, 79 | {"meowlnir", "management_secret"}, 80 | {"meowlnir", "report_room"}, 81 | {"antispam"}, 82 | {"encryption"}, 83 | {"database"}, 84 | {"synapse_db"}, 85 | {"logging"}, 86 | } 87 | -------------------------------------------------------------------------------- /database/action.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "go.mau.fi/util/dbutil" 8 | "maunium.net/go/mautrix/event" 9 | "maunium.net/go/mautrix/id" 10 | ) 11 | 12 | const ( 13 | getTakenActionBaseQuery = ` 14 | SELECT target_user, in_room_id, action_type, policy_list, rule_entity, action, taken_at 15 | FROM taken_action 16 | ` 17 | getTakenActionsByPolicyListQuery = getTakenActionBaseQuery + `WHERE policy_list=$1` 18 | getTakenActionsByRuleEntityQuery = getTakenActionBaseQuery + `WHERE policy_list=$1 AND rule_entity=$2` 19 | getTakenActionByTargetUserQuery = getTakenActionBaseQuery + `WHERE target_user=$1 AND action_type=$2` 20 | insertTakenActionQuery = ` 21 | INSERT INTO taken_action (target_user, in_room_id, action_type, policy_list, rule_entity, action, taken_at) 22 | VALUES ($1, $2, $3, $4, $5, $6, $7) 23 | ON CONFLICT (target_user, in_room_id, action_type) DO UPDATE 24 | SET policy_list=excluded.policy_list, rule_entity=excluded.rule_entity, action=excluded.action, taken_at=excluded.taken_at 25 | ` 26 | deleteTakenActionQuery = `DELETE FROM taken_action WHERE target_user=$1 AND in_room_id=$2 AND action_type=$3` 27 | ) 28 | 29 | type TakenActionQuery struct { 30 | *dbutil.QueryHelper[*TakenAction] 31 | } 32 | 33 | func (taq *TakenActionQuery) Delete(ctx context.Context, targetUser id.UserID, inRoomID id.RoomID, actionType TakenActionType) error { 34 | return taq.Exec(ctx, deleteTakenActionQuery, targetUser, inRoomID, actionType) 35 | } 36 | 37 | func (taq *TakenActionQuery) Put(ctx context.Context, ta *TakenAction) error { 38 | return taq.Exec(ctx, insertTakenActionQuery, ta.sqlVariables()...) 39 | } 40 | 41 | func (taq *TakenActionQuery) GetAllByPolicyList(ctx context.Context, policyList id.RoomID) ([]*TakenAction, error) { 42 | return taq.QueryMany(ctx, getTakenActionsByPolicyListQuery, policyList) 43 | } 44 | 45 | func (taq *TakenActionQuery) GetAllByRuleEntity(ctx context.Context, policyList id.RoomID, ruleEntity string) ([]*TakenAction, error) { 46 | return taq.QueryMany(ctx, getTakenActionsByRuleEntityQuery, policyList, ruleEntity) 47 | } 48 | 49 | func (taq *TakenActionQuery) GetAllByTargetUser(ctx context.Context, userID id.UserID, actionType TakenActionType) ([]*TakenAction, error) { 50 | return taq.QueryMany(ctx, getTakenActionByTargetUserQuery, userID, actionType) 51 | } 52 | 53 | type TakenActionType string 54 | 55 | const ( 56 | TakenActionTypeBanOrUnban TakenActionType = "ban_or_unban" 57 | ) 58 | 59 | type TakenAction struct { 60 | TargetUser id.UserID 61 | InRoomID id.RoomID 62 | ActionType TakenActionType 63 | PolicyList id.RoomID 64 | RuleEntity string 65 | Action event.PolicyRecommendation 66 | TakenAt time.Time 67 | } 68 | 69 | func (t *TakenAction) sqlVariables() []any { 70 | return []any{t.TargetUser, t.InRoomID, t.ActionType, t.PolicyList, t.RuleEntity, t.Action, t.TakenAt.UnixMilli()} 71 | } 72 | 73 | func (t *TakenAction) Scan(row dbutil.Scannable) (*TakenAction, error) { 74 | var takenAt int64 75 | err := row.Scan(&t.TargetUser, &t.InRoomID, &t.ActionType, &t.PolicyList, &t.RuleEntity, &t.Action, &takenAt) 76 | if err != nil { 77 | return nil, err 78 | } 79 | t.TakenAt = time.UnixMilli(takenAt) 80 | return t, nil 81 | } 82 | -------------------------------------------------------------------------------- /database/bot.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "go.mau.fi/util/dbutil" 7 | "maunium.net/go/mautrix/id" 8 | ) 9 | 10 | const ( 11 | getAllBotsQuery = ` 12 | SELECT username, displayname, avatar_url 13 | FROM bot 14 | ` 15 | insertBotQuery = ` 16 | INSERT INTO bot (username, displayname, avatar_url) 17 | VALUES ($1, $2, $3) 18 | ON CONFLICT (username) DO UPDATE 19 | SET displayname=excluded.displayname, avatar_url=excluded.avatar_url 20 | ` 21 | ) 22 | 23 | type BotQuery struct { 24 | *dbutil.QueryHelper[*Bot] 25 | } 26 | 27 | func (bq *BotQuery) Put(ctx context.Context, bot *Bot) error { 28 | return bq.Exec(ctx, insertBotQuery, bot.sqlVariables()...) 29 | } 30 | 31 | func (bq *BotQuery) GetAll(ctx context.Context) ([]*Bot, error) { 32 | return bq.QueryMany(ctx, getAllBotsQuery) 33 | } 34 | 35 | type Bot struct { 36 | Username string `json:"username"` 37 | Displayname string `json:"displayname"` 38 | AvatarURL id.ContentURI `json:"avatar_url"` 39 | } 40 | 41 | func (b *Bot) sqlVariables() []any { 42 | return []any{b.Username, b.Displayname, &b.AvatarURL} 43 | } 44 | 45 | func (b *Bot) Scan(row dbutil.Scannable) (*Bot, error) { 46 | return dbutil.ValueOrErr(b, row.Scan(&b.Username, &b.Displayname, &b.AvatarURL)) 47 | } 48 | -------------------------------------------------------------------------------- /database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "go.mau.fi/util/dbutil" 5 | 6 | "go.mau.fi/meowlnir/database/upgrades" 7 | ) 8 | 9 | type Database struct { 10 | *dbutil.Database 11 | TakenAction *TakenActionQuery 12 | Bot *BotQuery 13 | ManagementRoom *ManagementRoomQuery 14 | } 15 | 16 | func New(db *dbutil.Database) *Database { 17 | db.UpgradeTable = upgrades.Table 18 | return &Database{ 19 | Database: db, 20 | TakenAction: &TakenActionQuery{ 21 | QueryHelper: dbutil.MakeQueryHelper(db, func(qh *dbutil.QueryHelper[*TakenAction]) *TakenAction { 22 | return &TakenAction{} 23 | }), 24 | }, 25 | Bot: &BotQuery{ 26 | QueryHelper: dbutil.MakeQueryHelper(db, func(qh *dbutil.QueryHelper[*Bot]) *Bot { 27 | return &Bot{} 28 | }), 29 | }, 30 | ManagementRoom: &ManagementRoomQuery{ 31 | Database: db, 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/managementroom.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "go.mau.fi/util/dbutil" 7 | "maunium.net/go/mautrix/id" 8 | ) 9 | 10 | const ( 11 | getAllManagementRoomsQuery = ` 12 | SELECT room_id FROM management_room WHERE bot_username=$1; 13 | ` 14 | putManagementRoomQuery = ` 15 | INSERT INTO management_room (room_id, bot_username) 16 | VALUES ($1, $2) 17 | ON CONFLICT (room_id) DO UPDATE 18 | SET bot_username=excluded.bot_username 19 | ` 20 | ) 21 | 22 | type ManagementRoomQuery struct { 23 | *dbutil.Database 24 | } 25 | 26 | func (mrq *ManagementRoomQuery) Put(ctx context.Context, roomID id.RoomID, botUsername string) error { 27 | _, err := mrq.Exec(ctx, putManagementRoomQuery, roomID, botUsername) 28 | return err 29 | } 30 | 31 | var roomIDScanner = dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) 32 | 33 | func (mrq *ManagementRoomQuery) GetAll(ctx context.Context, botUsername string) ([]id.RoomID, error) { 34 | return roomIDScanner.NewRowIter(mrq.Query(ctx, getAllManagementRoomsQuery, botUsername)).AsList() 35 | } 36 | -------------------------------------------------------------------------------- /database/upgrades/00-latest.sql: -------------------------------------------------------------------------------- 1 | -- v0 -> v1 (compatible with v1+): Latest schema 2 | CREATE TABLE bot ( 3 | username TEXT PRIMARY KEY NOT NULL, 4 | displayname TEXT NOT NULL, 5 | avatar_url TEXT NOT NULL 6 | ); 7 | 8 | CREATE TABLE management_room ( 9 | room_id TEXT PRIMARY KEY NOT NULL, 10 | bot_username TEXT NOT NULL, 11 | 12 | CONSTRAINT management_room_bot_fkey FOREIGN KEY (bot_username) REFERENCES bot (username) 13 | ON UPDATE CASCADE ON DELETE CASCADE 14 | ); 15 | 16 | CREATE TABLE taken_action ( 17 | target_user TEXT NOT NULL, 18 | in_room_id TEXT NOT NULL, 19 | action_type TEXT NOT NULL, 20 | policy_list TEXT NOT NULL, 21 | rule_entity TEXT NOT NULL, 22 | action TEXT NOT NULL, 23 | taken_at BIGINT NOT NULL, 24 | 25 | PRIMARY KEY (target_user, in_room_id, action_type) 26 | ); 27 | 28 | CREATE INDEX taken_action_list_idx ON taken_action (policy_list); 29 | CREATE INDEX taken_action_entity_idx ON taken_action (policy_list, rule_entity); 30 | -------------------------------------------------------------------------------- /database/upgrades/upgrades.go: -------------------------------------------------------------------------------- 1 | package upgrades 2 | 3 | import ( 4 | "embed" 5 | 6 | "go.mau.fi/util/dbutil" 7 | ) 8 | 9 | var Table dbutil.UpgradeTable 10 | 11 | //go:embed *.sql 12 | var rawUpgrades embed.FS 13 | 14 | func init() { 15 | Table.RegisterFS(rawUpgrades) 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.mau.fi/meowlnir 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/lib/pq v1.10.9 9 | github.com/prometheus/client_golang v1.22.0 10 | github.com/rs/zerolog v1.34.0 11 | go.mau.fi/util v0.8.7 12 | go.mau.fi/zeroconfig v0.1.3 13 | gopkg.in/yaml.v3 v3.0.1 14 | maunium.net/go/mauflag v1.0.0 15 | maunium.net/go/mautrix v0.24.1-0.20250527150456-f5746ee0f68d 16 | ) 17 | 18 | require ( 19 | filippo.io/edwards25519 v1.1.0 // indirect 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 23 | github.com/gorilla/mux v1.8.0 // indirect 24 | github.com/gorilla/websocket v1.5.0 // indirect 25 | github.com/kr/text v0.2.0 // indirect 26 | github.com/mattn/go-colorable v0.1.14 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 29 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 30 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect 31 | github.com/prometheus/client_model v0.6.1 // indirect 32 | github.com/prometheus/common v0.62.0 // indirect 33 | github.com/prometheus/procfs v0.15.1 // indirect 34 | github.com/rs/xid v1.6.0 // indirect 35 | github.com/tidwall/gjson v1.18.0 // indirect 36 | github.com/tidwall/match v1.1.1 // indirect 37 | github.com/tidwall/pretty v1.2.1 // indirect 38 | github.com/tidwall/sjson v1.2.5 // indirect 39 | github.com/yuin/goldmark v1.7.11 // indirect 40 | golang.org/x/crypto v0.38.0 // indirect 41 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 42 | golang.org/x/net v0.40.0 // indirect 43 | golang.org/x/sys v0.33.0 // indirect 44 | golang.org/x/text v0.25.0 // indirect 45 | google.golang.org/protobuf v1.36.6 // indirect 46 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 4 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 10 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 11 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 15 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 18 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 19 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 20 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 21 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 22 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 26 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 28 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 29 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 34 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 35 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 36 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 37 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 38 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= 39 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 40 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 44 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 45 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 46 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 47 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 48 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 49 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 50 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 51 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 52 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 53 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 54 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 55 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 56 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 57 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 58 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 59 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 60 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 61 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 62 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 63 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 64 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 65 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 66 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 67 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 68 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 69 | github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= 70 | github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 71 | go.mau.fi/util v0.8.7 h1:ywKarPxouJQEEijTs4mPlxC7F4AWEKokEpWc+2TYy6c= 72 | go.mau.fi/util v0.8.7/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= 73 | go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= 74 | go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= 75 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 76 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 77 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 78 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 79 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 80 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 81 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 85 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 86 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 87 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 88 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 89 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 91 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 92 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 93 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 94 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 95 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 96 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 | maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= 98 | maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 99 | maunium.net/go/mautrix v0.24.1-0.20250527150456-f5746ee0f68d h1:xm5Dhdl+0kAPjh5olHfnvmsGUdIEsa9fYBNL8p5HXdA= 100 | maunium.net/go/mautrix v0.24.1-0.20250527150456-f5746ee0f68d/go.mod h1:HqA1HUutQYJkrYRPkK64itARDz79PCec1oWVEB72HVQ= 101 | -------------------------------------------------------------------------------- /policyeval/antispam.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | 8 | "github.com/rs/zerolog" 9 | "go.mau.fi/util/exzerolog" 10 | "go.mau.fi/util/ptr" 11 | "maunium.net/go/mautrix" 12 | "maunium.net/go/mautrix/event" 13 | "maunium.net/go/mautrix/id" 14 | 15 | "go.mau.fi/meowlnir/bot" 16 | "go.mau.fi/meowlnir/policylist" 17 | "go.mau.fi/meowlnir/util" 18 | ) 19 | 20 | type pendingInvite struct { 21 | Inviter id.UserID 22 | Invitee id.UserID 23 | Room id.RoomID 24 | } 25 | 26 | func (pe *PolicyEvaluator) HandleUserMayInvite(ctx context.Context, inviter, invitee id.UserID, roomID id.RoomID) *mautrix.RespError { 27 | inviterServer := inviter.Homeserver() 28 | // We only care about federated invites. 29 | if inviterServer == pe.Bot.ServerName && !pe.FilterLocalInvites { 30 | return nil 31 | } 32 | 33 | log := zerolog.Ctx(ctx).With(). 34 | Stringer("inviter", inviter). 35 | Stringer("invitee", invitee). 36 | Stringer("room_id", roomID). 37 | Logger() 38 | if invitee.Homeserver() != pe.Bot.ServerName { 39 | // This shouldn't happen 40 | // TODO this check should be removed if multi-server support is added 41 | log.Warn().Msg("Ignoring invite to non-local user") 42 | return nil 43 | } 44 | lists := pe.GetWatchedLists() 45 | 46 | var rec *policylist.Policy 47 | 48 | defer func() { 49 | if rec != nil { 50 | go pe.Bot.SendNoticeOpts( 51 | context.WithoutCancel(ctx), 52 | pe.ManagementRoom, 53 | fmt.Sprintf( 54 | "Blocked ||[%s](%s)|| from inviting [%s](%s) to [%s](%s) due to policy banning ||`%s`|| for `%s`", 55 | inviter, inviter.URI().MatrixToURL(), 56 | invitee, invitee.URI().MatrixToURL(), 57 | roomID, roomID.URI().MatrixToURL(), 58 | rec.EntityOrHash(), rec.Reason, 59 | ), 60 | // Don't mention users 61 | &bot.SendNoticeOpts{Mentions: &event.Mentions{}}, 62 | ) 63 | } 64 | }() 65 | 66 | if rec = pe.Store.MatchUser(lists, inviter).Recommendations().BanOrUnban; rec != nil && rec.Recommendation != event.PolicyRecommendationUnban { 67 | log.Debug(). 68 | Str("policy_entity", rec.EntityOrHash()). 69 | Str("policy_reason", rec.Reason). 70 | Msg("Blocking invite from banned user") 71 | return ptr.Ptr(mautrix.MForbidden.WithMessage("You're not allowed to send invites")) 72 | } 73 | 74 | if rec = pe.Store.MatchRoom(lists, roomID).Recommendations().BanOrUnban; rec != nil && rec.Recommendation != event.PolicyRecommendationUnban { 75 | log.Debug(). 76 | Str("policy_entity", rec.EntityOrHash()). 77 | Str("policy_reason", rec.Reason). 78 | Msg("Blocking invite to banned room") 79 | return ptr.Ptr(mautrix.MForbidden.WithMessage("Inviting users to this room is not allowed")) 80 | } 81 | 82 | if rec = pe.Store.MatchServer(lists, inviterServer).Recommendations().BanOrUnban; rec != nil && rec.Recommendation != event.PolicyRecommendationUnban { 83 | log.Debug(). 84 | Str("policy_entity", rec.EntityOrHash()). 85 | Str("policy_reason", rec.Reason). 86 | Msg("Blocking invite from banned server") 87 | return ptr.Ptr(mautrix.MForbidden.WithMessage("You're not allowed to send invites")) 88 | } 89 | 90 | // Parsing room IDs is generally not allowed, but in this case, 91 | // if a room was created on a banned server, there's no reason to allow invites to it. 92 | _, _, roomServer := id.ParseCommonIdentifier(roomID) 93 | if rec = pe.Store.MatchServer(lists, roomServer).Recommendations().BanOrUnban; rec != nil && rec.Recommendation != event.PolicyRecommendationUnban { 94 | log.Debug(). 95 | Str("policy_entity", rec.EntityOrHash()). 96 | Str("policy_reason", rec.Reason). 97 | Msg("Blocking invite to room on banned server") 98 | return ptr.Ptr(mautrix.MForbidden.WithMessage("Inviting users to this room is not allowed")) 99 | } 100 | 101 | rec = nil 102 | log.Debug().Msg("Allowing invite") 103 | 104 | if pe.AutoRejectInvites { 105 | pe.pendingInvitesLock.Lock() 106 | pe.pendingInvites[pendingInvite{Inviter: inviter, Invitee: invitee, Room: roomID}] = struct{}{} 107 | pe.pendingInvitesLock.Unlock() 108 | 109 | pe.protectedRoomsLock.Lock() 110 | _, trackingMember := pe.protectedRoomMembers[inviter] 111 | if !trackingMember { 112 | // Add the inviter to the list of tracked members so that new policy evaluation 113 | // will catch them and call RejectPendingInvites. 114 | pe.protectedRoomMembers[inviter] = []id.RoomID{} 115 | pe.memberHashes[util.SHA256String(inviter)] = inviter 116 | } 117 | pe.protectedRoomsLock.Unlock() 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (pe *PolicyEvaluator) HandleAcceptMakeJoin(ctx context.Context, roomID id.RoomID, userID id.UserID) *mautrix.RespError { 124 | lists := pe.GetWatchedLists() 125 | rec := pe.Store.MatchUser(lists, userID).Recommendations().BanOrUnban 126 | if rec == nil { 127 | rec = pe.Store.MatchServer(lists, userID.Homeserver()).Recommendations().BanOrUnban 128 | } 129 | if rec != nil && rec.Recommendation != event.PolicyRecommendationUnban { 130 | zerolog.Ctx(ctx).Debug(). 131 | Stringer("user_id", userID). 132 | Stringer("room_id", roomID). 133 | Str("policy_entity", rec.EntityOrHash()). 134 | Str("policy_reason", rec.Reason). 135 | Msg("Blocking restricted join from banned user") 136 | go pe.sendNotice( 137 | context.WithoutCancel(ctx), 138 | "Blocked ||[%s](%s)|| from joining [%s](%s) due to policy banning ||`%s`|| for `%s`", 139 | userID, userID.URI().MatrixToURL(), 140 | roomID, roomID.URI().MatrixToURL(), 141 | rec.EntityOrHash(), rec.Reason, 142 | ) 143 | return ptr.Ptr(mautrix.MForbidden.WithMessage("You're banned from this room")) 144 | } 145 | 146 | zerolog.Ctx(ctx).Debug(). 147 | Stringer("user_id", userID). 148 | Stringer("room_id", roomID). 149 | Msg("Allowing restricted join") 150 | return nil 151 | } 152 | 153 | func (pe *PolicyEvaluator) HandleUserMayJoinRoom(ctx context.Context, userID id.UserID, roomID id.RoomID, isInvited bool) { 154 | if !pe.AutoRejectInvites { 155 | return 156 | } 157 | pe.pendingInvitesLock.Lock() 158 | defer pe.pendingInvitesLock.Unlock() 159 | wasInvite := false 160 | var inviter id.UserID 161 | for invite := range pe.pendingInvites { 162 | if invite.Invitee == userID && invite.Room == roomID { 163 | delete(pe.pendingInvites, invite) 164 | wasInvite = true 165 | inviter = invite.Inviter 166 | } 167 | } 168 | if !wasInvite { 169 | return 170 | } 171 | zerolog.Ctx(ctx).Debug(). 172 | Stringer("user_id", userID). 173 | Stringer("room_id", roomID). 174 | Stringer("inviter", inviter). 175 | Bool("is_invited", isInvited). 176 | Msg("User accepted pending invite") 177 | } 178 | 179 | func (pe *PolicyEvaluator) findPendingInvites(userID id.UserID) map[id.UserID][]id.RoomID { 180 | pe.pendingInvitesLock.Lock() 181 | defer pe.pendingInvitesLock.Unlock() 182 | output := make(map[id.UserID][]id.RoomID) 183 | for invite := range pe.pendingInvites { 184 | if invite.Inviter == userID { 185 | output[invite.Invitee] = append(output[invite.Invitee], invite.Room) 186 | delete(pe.pendingInvites, invite) 187 | } 188 | } 189 | return output 190 | } 191 | 192 | func (pe *PolicyEvaluator) RejectPendingInvites(ctx context.Context, inviter id.UserID, rec *policylist.Policy) { 193 | if !pe.AutoRejectInvites { 194 | return 195 | } 196 | log := zerolog.Ctx(ctx) 197 | invites := pe.findPendingInvites(inviter) 198 | for userID, rooms := range invites { 199 | log.Debug(). 200 | Stringer("inviter_user_id", inviter). 201 | Stringer("invited_user_id", userID). 202 | Array("room_ids", exzerolog.ArrayOfStrs(rooms)). 203 | Msg("Rejecting pending invites") 204 | client := pe.createPuppetClient(userID) 205 | resp, err := client.JoinedRooms(ctx) 206 | if err != nil { 207 | log.Err(err).Msg("Failed to get joined rooms to ensure accepted invites aren't rejected") 208 | } 209 | successfullyRejected := 0 210 | for _, roomID := range rooms { 211 | if resp != nil && slices.Contains(resp.JoinedRooms, roomID) { 212 | log.Debug(). 213 | Stringer("user_id", userID). 214 | Stringer("room_id", roomID). 215 | Msg("Room is already joined, not rejecting invite") 216 | } else if pe.DryRun { 217 | log.Debug(). 218 | Stringer("user_id", userID). 219 | Stringer("room_id", roomID). 220 | Msg("Dry run, not actually rejecting invite") 221 | successfullyRejected++ 222 | } else if _, err = client.LeaveRoom(ctx, roomID); err != nil { 223 | log.Err(err). 224 | Stringer("user_id", userID). 225 | Stringer("room_id", roomID). 226 | Msg("Failed to reject invite") 227 | } else { 228 | log.Debug(). 229 | Stringer("user_id", userID). 230 | Stringer("room_id", roomID). 231 | Msg("Rejected invite") 232 | successfullyRejected++ 233 | } 234 | } 235 | pe.sendNotice( 236 | ctx, 237 | "Rejected %d/%d invites to [%s](%s) from ||[%s](%s)|| due to policy banning ||`%s`|| for `%s`", 238 | successfullyRejected, len(rooms), 239 | userID, userID.URI().MatrixToURL(), 240 | inviter, inviter.URI().MatrixToURL(), 241 | rec.EntityOrHash(), rec.Reason, 242 | ) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /policyeval/evaluate.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "iter" 6 | "maps" 7 | "slices" 8 | 9 | "github.com/rs/zerolog" 10 | "go.mau.fi/util/glob" 11 | "maunium.net/go/mautrix/event" 12 | "maunium.net/go/mautrix/id" 13 | 14 | "go.mau.fi/meowlnir/database" 15 | "go.mau.fi/meowlnir/policylist" 16 | ) 17 | 18 | func (pe *PolicyEvaluator) getAllUsers() []id.UserID { 19 | pe.protectedRoomsLock.RLock() 20 | defer pe.protectedRoomsLock.RUnlock() 21 | return slices.Collect(maps.Keys(pe.protectedRoomMembers)) 22 | } 23 | 24 | func (pe *PolicyEvaluator) getUserIDFromHash(hash [32]byte) (id.UserID, bool) { 25 | pe.protectedRoomsLock.RLock() 26 | defer pe.protectedRoomsLock.RUnlock() 27 | userID, ok := pe.memberHashes[hash] 28 | return userID, ok 29 | } 30 | 31 | func (pe *PolicyEvaluator) findMatchingUsers(pattern glob.Glob, hash *[32]byte, onlyJoined bool) iter.Seq[id.UserID] { 32 | return func(yield func(id.UserID) bool) { 33 | if hash != nil { 34 | userID, ok := pe.getUserIDFromHash(*hash) 35 | if ok { 36 | if onlyJoined { 37 | pe.protectedRoomsLock.RLock() 38 | rooms, found := pe.protectedRoomMembers[userID] 39 | pe.protectedRoomsLock.RUnlock() 40 | if found && len(rooms) > 0 { 41 | yield(userID) 42 | } 43 | } else { 44 | yield(userID) 45 | } 46 | } 47 | return 48 | } 49 | exact, ok := pattern.(glob.ExactGlob) 50 | if ok { 51 | userID := id.UserID(exact) 52 | pe.protectedRoomsLock.RLock() 53 | rooms, found := pe.protectedRoomMembers[userID] 54 | pe.protectedRoomsLock.RUnlock() 55 | if found && (!onlyJoined || len(rooms) > 0) { 56 | yield(userID) 57 | } 58 | return 59 | } 60 | if onlyJoined { 61 | pe.protectedRoomsLock.RLock() 62 | defer pe.protectedRoomsLock.RUnlock() 63 | for userID, rooms := range pe.protectedRoomMembers { 64 | if len(rooms) > 0 && pattern.Match(string(userID)) { 65 | if !yield(userID) { 66 | return 67 | } 68 | } 69 | } 70 | } else { 71 | users := pe.getAllUsers() 72 | for _, userID := range users { 73 | if pattern.Match(string(userID)) { 74 | if !yield(userID) { 75 | return 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | func (pe *PolicyEvaluator) EvaluateAll(ctx context.Context) { 84 | pe.EvaluateAllMembers(ctx, pe.getAllUsers()) 85 | pe.UpdateACL(ctx) 86 | } 87 | 88 | func (pe *PolicyEvaluator) EvaluateAllMembers(ctx context.Context, members []id.UserID) { 89 | for _, member := range members { 90 | pe.EvaluateUser(ctx, member, false) 91 | } 92 | } 93 | 94 | func (pe *PolicyEvaluator) EvaluateUser(ctx context.Context, userID id.UserID, isNewRule bool) { 95 | match := pe.Store.MatchUser(pe.GetWatchedLists(), userID) 96 | if match == nil { 97 | return 98 | } 99 | pe.ApplyPolicy(ctx, userID, match, isNewRule) 100 | } 101 | 102 | func (pe *PolicyEvaluator) EvaluateRoom(ctx context.Context, roomID id.RoomID, isNewRule bool) { 103 | match := pe.Store.MatchRoom(pe.GetWatchedLists(), roomID) 104 | if match == nil { 105 | return 106 | } 107 | pe.PromptRoomPolicy(ctx, roomID, match, isNewRule) 108 | } 109 | 110 | func (pe *PolicyEvaluator) EvaluateRemovedRule(ctx context.Context, policy *policylist.Policy) { 111 | switch policy.EntityType { 112 | case policylist.EntityTypeUser: 113 | if policy.Recommendation == event.PolicyRecommendationUnban { 114 | // When an unban rule is removed, evaluate all joined users against the removed rule 115 | // to see if they should be re-evaluated against all rules (and possibly banned) 116 | for userID := range pe.findMatchingUsers(policy.Pattern, policy.EntityHash, false) { 117 | pe.EvaluateUser(ctx, userID, false) 118 | } 119 | } else { 120 | // For ban rules, find users who were banned by the rule and re-evaluate them. 121 | reevalTargets, err := pe.DB.TakenAction.GetAllByRuleEntity(ctx, policy.RoomID, policy.EntityOrHash()) 122 | if err != nil { 123 | zerolog.Ctx(ctx).Err(err).Str("policy_entity", policy.EntityOrHash()). 124 | Msg("Failed to get actions taken for removed policy") 125 | pe.sendNotice(ctx, "Database error in EvaluateRemovedRule (GetAllByRuleEntity): %v", err) 126 | } else if len(reevalTargets) > 0 { 127 | zerolog.Ctx(ctx).Debug(). 128 | Int("reeval_targets", len(reevalTargets)). 129 | Msg("Reevaluating actions as a result of removed policy") 130 | pe.ReevaluateActions(ctx, reevalTargets) 131 | } 132 | } 133 | case policylist.EntityTypeServer: 134 | pe.DeferredUpdateACL() 135 | case policylist.EntityTypeRoom: 136 | // Probably don't need to do anything here 137 | } 138 | } 139 | 140 | func (pe *PolicyEvaluator) EvaluateAddedRule(ctx context.Context, policy *policylist.Policy) { 141 | switch policy.EntityType { 142 | case policylist.EntityTypeUser: 143 | didEval := false 144 | for userID := range pe.findMatchingUsers(policy.Pattern, policy.EntityHash, false) { 145 | didEval = true 146 | // Do a full evaluation to ensure new policies don't bypass existing higher priority policies 147 | pe.EvaluateUser(ctx, userID, true) 148 | } 149 | if !didEval { 150 | exact, ok := policy.Pattern.(glob.ExactGlob) 151 | if ok && id.UserID(exact).Homeserver() == pe.Bot.ServerName { 152 | pe.EvaluateUser(ctx, id.UserID(exact), true) 153 | } 154 | } 155 | case policylist.EntityTypeServer: 156 | pe.DeferredUpdateACL() 157 | case policylist.EntityTypeRoom: 158 | if pe.RoomHashes == nil { 159 | // This management room doesn't handle room bans 160 | return 161 | } 162 | var roomID id.RoomID 163 | if policy.EntityHash != nil { 164 | roomID = pe.RoomHashes.Get(*policy.EntityHash) 165 | } else if _, ok := policy.Pattern.(glob.ExactGlob); ok { 166 | roomID = id.RoomID(policy.Entity) 167 | if !pe.RoomHashes.Has(roomID) { 168 | return 169 | } 170 | } else { 171 | // TODO glob room bans? 172 | return 173 | } 174 | pe.EvaluateRoom(ctx, roomID, true) 175 | } 176 | } 177 | 178 | func (pe *PolicyEvaluator) ReevaluateAffectedByLists(ctx context.Context, policyLists []id.RoomID) { 179 | var reevalTargets []*database.TakenAction 180 | for _, list := range policyLists { 181 | targets, err := pe.DB.TakenAction.GetAllByPolicyList(ctx, list) 182 | if err != nil { 183 | zerolog.Ctx(ctx).Err(err).Stringer("policy_list_id", list). 184 | Msg("Failed to get actions taken from policy list") 185 | pe.sendNotice(ctx, "Database error in ReevaluateAffectedByLists (GetAllByPolicyList): %v", err) 186 | continue 187 | } 188 | if reevalTargets == nil { 189 | reevalTargets = targets 190 | } else { 191 | reevalTargets = append(reevalTargets, targets...) 192 | } 193 | } 194 | pe.ReevaluateActions(ctx, reevalTargets) 195 | } 196 | 197 | func (pe *PolicyEvaluator) ReevaluateActions(ctx context.Context, actions []*database.TakenAction) { 198 | for _, action := range actions { 199 | if action.ActionType == database.TakenActionTypeBanOrUnban && action.Action == event.PolicyRecommendationBan { 200 | pe.ReevaluateBan(ctx, action) 201 | } 202 | } 203 | } 204 | 205 | func (pe *PolicyEvaluator) ReevaluateBan(ctx context.Context, action *database.TakenAction) { 206 | log := zerolog.Ctx(ctx).With().Any("action", action).Logger() 207 | ctx = log.WithContext(ctx) 208 | plist := pe.GetWatchedListMeta(action.PolicyList) 209 | // TODO should there be some way to configure the behavior when unsubscribing from a policy list? 210 | if plist != nil && !plist.AutoUnban { 211 | log.Debug().Msg("Policy list does not have auto-unban enabled, skipping") 212 | return 213 | } 214 | match := pe.Store.MatchUser(pe.GetWatchedLists(), action.TargetUser) 215 | if rec := match.Recommendations().BanOrUnban; rec != nil && rec.Recommendation != event.PolicyRecommendationUnban { 216 | action.PolicyList = rec.RoomID 217 | action.RuleEntity = rec.EntityOrHash() 218 | err := pe.DB.TakenAction.Put(ctx, action) 219 | if err != nil { 220 | log.Err(err).Msg("Failed to update taken action source") 221 | } else { 222 | log.Trace(). 223 | Stringer("new_room_id", rec.RoomID). 224 | Str("new_entity", rec.EntityOrHash()). 225 | Msg("Updated taken action source to new policy") 226 | } 227 | return 228 | } 229 | log.Debug().Msg("Unbanning user") 230 | ok := pe.UndoBan(ctx, action.TargetUser, action.InRoomID) 231 | if !ok { 232 | return 233 | } 234 | err := pe.DB.TakenAction.Delete(ctx, action.TargetUser, action.InRoomID, action.ActionType) 235 | if err != nil { 236 | log.Err(err).Msg("Failed to delete taken action after unbanning") 237 | } else { 238 | log.Trace().Msg("Deleted taken action after unbanning") 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /policyeval/eventhandle.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/rs/zerolog" 9 | "maunium.net/go/mautrix/event" 10 | "maunium.net/go/mautrix/id" 11 | 12 | "go.mau.fi/meowlnir/config" 13 | "go.mau.fi/meowlnir/policylist" 14 | ) 15 | 16 | func (pe *PolicyEvaluator) HandleConfigChange(ctx context.Context, evt *event.Event) { 17 | pe.configLock.Lock() 18 | defer pe.configLock.Unlock() 19 | var errorMsg, successMsg string 20 | switch evt.Type { 21 | case event.StatePowerLevels: 22 | errorMsg = pe.handlePowerLevels(evt) 23 | case config.StateWatchedLists: 24 | successMsgs, errorMsgs := pe.handleWatchedLists(ctx, evt, false) 25 | successMsg = strings.Join(successMsgs, "\n") 26 | errorMsg = strings.Join(errorMsgs, "\n") 27 | case config.StateProtectedRooms: 28 | successMsgs, errorMsgs := pe.handleProtectedRooms(ctx, evt, false) 29 | successMsg = strings.Join(successMsgs, "\n") 30 | errorMsg = strings.Join(errorMsgs, "\n") 31 | } 32 | var output string 33 | if successMsg != "" { 34 | if errorMsg != "" { 35 | output = fmt.Sprintf("Handled `%s` event with errors:\n\n%s\n%s", evt.Type.Type, successMsg, errorMsg) 36 | } else { 37 | output = fmt.Sprintf("Successfully handled `%s` event:\n\n%s", evt.Type.Type, successMsg) 38 | } 39 | } else if errorMsg != "" { 40 | output = fmt.Sprintf("Failed to handle `%s` event:\n\n%s", evt.Type.Type, errorMsg) 41 | } 42 | if output != "" { 43 | pe.sendNotice(ctx, output) 44 | } 45 | } 46 | 47 | func (pe *PolicyEvaluator) HandleMember(ctx context.Context, evt *event.Event) { 48 | userID := id.UserID(evt.GetStateKey()) 49 | content := evt.Content.AsMember() 50 | if userID == pe.Bot.UserID { 51 | pe.protectedRoomsLock.RLock() 52 | _, isProtecting := pe.protectedRooms[evt.RoomID] 53 | _, wantToProtect := pe.wantToProtect[evt.RoomID] 54 | _, isJoining := pe.isJoining[evt.RoomID] 55 | pe.protectedRoomsLock.RUnlock() 56 | if isJoining { 57 | return 58 | } 59 | if isProtecting && (content.Membership == event.MembershipLeave || content.Membership == event.MembershipBan) { 60 | pe.sendNotice(ctx, "⚠️ Bot was removed from [%s](%s)", evt.RoomID, evt.RoomID.URI().MatrixToURL()) 61 | } else if wantToProtect && (content.Membership == event.MembershipJoin || content.Membership == event.MembershipInvite) { 62 | _, err := pe.Bot.JoinRoomByID(ctx, evt.RoomID) 63 | if err != nil { 64 | pe.sendNotice(ctx, "Failed to join room [%s](%s): %v", evt.RoomID, evt.RoomID.URI().MatrixToURL(), err) 65 | } else if _, errMsg := pe.tryProtectingRoom(ctx, nil, evt.RoomID, true); errMsg != "" { 66 | pe.sendNotice(ctx, "Retried protecting room after joining room, but failed: %s", strings.TrimPrefix(errMsg, "* ")) 67 | } else { 68 | pe.sendNotice(ctx, "Bot was invited to room, now protecting [%s](%s)", evt.RoomID, evt.RoomID.URI().MatrixToURL()) 69 | } 70 | } 71 | } else { 72 | checkRules := pe.updateUser(userID, evt.RoomID, content.Membership) 73 | if checkRules { 74 | pe.EvaluateUser(ctx, userID, false) 75 | } 76 | } 77 | } 78 | 79 | func addActionString(rec event.PolicyRecommendation) string { 80 | switch rec { 81 | case event.PolicyRecommendationBan, event.PolicyRecommendationUnstableTakedown: 82 | return "banned" 83 | case event.PolicyRecommendationUnban: 84 | return "added a ban exclusion for" 85 | default: 86 | return fmt.Sprintf("added a `%s` rule for", rec) 87 | } 88 | } 89 | 90 | func changeActionString(rec event.PolicyRecommendation) string { 91 | switch rec { 92 | case event.PolicyRecommendationBan, event.PolicyRecommendationUnstableTakedown: 93 | return "ban" 94 | case event.PolicyRecommendationUnban: 95 | return "ban exclusion" 96 | default: 97 | return fmt.Sprintf("`%s`", rec) 98 | } 99 | } 100 | 101 | func removeActionString(rec event.PolicyRecommendation) string { 102 | switch rec { 103 | case event.PolicyRecommendationBan, event.PolicyRecommendationUnstableTakedown: 104 | return "unbanned" 105 | case event.PolicyRecommendationUnban: 106 | return "removed a ban exclusion for" 107 | default: 108 | return fmt.Sprintf("removed a `%s` rule for", rec) 109 | } 110 | } 111 | 112 | func noopSendNotice(_ context.Context, _ string, _ ...any) id.EventID { return "" } 113 | 114 | func (pe *PolicyEvaluator) HandlePolicyListChange(ctx context.Context, policyRoom id.RoomID, added, removed *policylist.Policy) { 115 | policyRoomMeta := pe.GetWatchedListMeta(policyRoom) 116 | if policyRoomMeta == nil { 117 | return 118 | } 119 | zerolog.Ctx(ctx).Info(). 120 | Bool("dont_apply", policyRoomMeta.DontApply). 121 | Any("added", added). 122 | Any("removed", removed). 123 | Msg("Policy list change") 124 | removedAndAddedAreEquivalent := removed != nil && added != nil && removed.EntityOrHash() == added.EntityOrHash() && removed.Recommendation == added.Recommendation 125 | sendNotice := pe.sendNotice 126 | if policyRoomMeta.DontNotifyOnChange { 127 | sendNotice = noopSendNotice 128 | } 129 | if removedAndAddedAreEquivalent { 130 | if removed.Reason == added.Reason { 131 | sendNotice(ctx, 132 | "[%s] [%s](%s) re-%s ||`%s`|| for `%s`", 133 | policyRoomMeta.Name, added.Sender, added.Sender.URI().MatrixToURL(), 134 | addActionString(added.Recommendation), added.EntityOrHash(), added.Reason) 135 | } else { 136 | sendNotice(ctx, 137 | "[%s] [%s](%s) changed the %s reason for ||`%s`|| from `%s` to `%s`", 138 | policyRoomMeta.Name, added.Sender, added.Sender.URI().MatrixToURL(), 139 | changeActionString(added.Recommendation), added.EntityOrHash(), removed.Reason, added.Reason) 140 | } 141 | } else { 142 | if removed != nil { 143 | sendNotice(ctx, 144 | "[%s] [%s](%s) %s %ss matching ||`%s`|| for `%s`", 145 | policyRoomMeta.Name, removed.Sender, removed.Sender.URI().MatrixToURL(), 146 | removeActionString(removed.Recommendation), removed.EntityType, removed.EntityOrHash(), removed.Reason, 147 | ) 148 | if !policyRoomMeta.DontApply { 149 | pe.EvaluateRemovedRule(ctx, removed) 150 | } 151 | } 152 | if added != nil { 153 | var suffix string 154 | if added.Ignored { 155 | suffix = " (rule was ignored)" 156 | } 157 | sendNotice(ctx, 158 | "[%s] [%s](%s) %s %ss matching ||`%s`|| for `%s`%s", 159 | policyRoomMeta.Name, added.Sender, added.Sender.URI().MatrixToURL(), 160 | addActionString(added.Recommendation), added.EntityType, added.EntityOrHash(), added.Reason, 161 | suffix, 162 | ) 163 | if !policyRoomMeta.DontApply { 164 | pe.EvaluateAddedRule(ctx, added) 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /policyeval/execute.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/rs/zerolog" 12 | "maunium.net/go/mautrix" 13 | "maunium.net/go/mautrix/commands" 14 | "maunium.net/go/mautrix/event" 15 | "maunium.net/go/mautrix/format" 16 | "maunium.net/go/mautrix/id" 17 | "maunium.net/go/mautrix/synapseadmin" 18 | 19 | "go.mau.fi/meowlnir/bot" 20 | "go.mau.fi/meowlnir/database" 21 | "go.mau.fi/meowlnir/policylist" 22 | ) 23 | 24 | func (pe *PolicyEvaluator) getRoomsUserIsIn(userID id.UserID) []id.RoomID { 25 | pe.protectedRoomsLock.RLock() 26 | rooms := slices.Clone(pe.protectedRoomMembers[userID]) 27 | pe.protectedRoomsLock.RUnlock() 28 | return rooms 29 | } 30 | 31 | func (pe *PolicyEvaluator) ApplyPolicy(ctx context.Context, userID id.UserID, policy policylist.Match, isNew bool) { 32 | if userID == pe.Bot.UserID { 33 | return 34 | } 35 | recs := policy.Recommendations() 36 | rooms := pe.getRoomsUserIsIn(userID) 37 | if !isNew && len(rooms) == 0 { 38 | // Don't apply policies to left users when re-evaluating rules, 39 | // because it would lead to unnecessarily scanning for events to redact. 40 | // Left users do need to be scanned when a new rule is added though 41 | // in case they spammed and left right before getting banned. 42 | return 43 | } 44 | if recs.BanOrUnban != nil { 45 | if recs.BanOrUnban.Recommendation == event.PolicyRecommendationBan || recs.BanOrUnban.Recommendation == event.PolicyRecommendationUnstableTakedown { 46 | zerolog.Ctx(ctx).Info(). 47 | Stringer("user_id", userID). 48 | Any("matches", policy). 49 | Msg("Applying ban recommendation") 50 | pe.policyServer.UpdateRecommendation(userID, pe.GetProtectedRooms(), PSRecommendationSpam) 51 | for _, room := range rooms { 52 | pe.ApplyBan(ctx, userID, room, recs.BanOrUnban) 53 | } 54 | shouldRedact := recs.BanOrUnban.Recommendation == event.PolicyRecommendationUnstableTakedown 55 | if !shouldRedact && recs.BanOrUnban.Reason != "" { 56 | for _, pattern := range pe.autoRedactPatterns { 57 | if pattern.Match(recs.BanOrUnban.Reason) { 58 | shouldRedact = true 59 | break 60 | } 61 | } 62 | } 63 | if shouldRedact { 64 | go pe.RedactUser(context.WithoutCancel(ctx), userID, recs.BanOrUnban.Reason, true) 65 | } 66 | if isNew { 67 | go pe.RejectPendingInvites(context.WithoutCancel(ctx), userID, recs.BanOrUnban) 68 | } 69 | pe.maybeApplySuspend(ctx, userID, recs.BanOrUnban) 70 | } else { 71 | // TODO unban if banned in some rooms? or just require doing that manually 72 | //takenActions, err := pe.DB.TakenAction.GetAllByTargetUser(ctx, userID, database.TakenActionTypeBanOrUnban) 73 | //if err != nil { 74 | // zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to get taken actions") 75 | // pe.sendNotice(ctx, "Database error in ApplyPolicy (GetAllByTargetUser): %v", err) 76 | // return 77 | //} 78 | } 79 | } 80 | } 81 | 82 | func (pe *PolicyEvaluator) PromptRoomPolicy(ctx context.Context, roomID id.RoomID, policy policylist.Match, isNewRule bool) { 83 | recs := policy.Recommendations() 84 | if recs.BanOrUnban == nil || (recs.BanOrUnban.Recommendation != event.PolicyRecommendationBan && recs.BanOrUnban.Recommendation != event.PolicyRecommendationUnstableTakedown) { 85 | return 86 | } 87 | rec := recs.BanOrUnban 88 | roomInfo, err := pe.Bot.SynapseAdmin.RoomInfo(ctx, roomID) 89 | var msg string 90 | explanation := "banned" 91 | if !isNewRule { 92 | explanation = "discovered after being banned" 93 | } 94 | if err != nil { 95 | msg = fmt.Sprintf( 96 | `Room %s (failed to get info) was %s for %s by %s at %s`, 97 | format.SafeMarkdownCode(roomID), 98 | explanation, 99 | format.SafeMarkdownCode(rec.Reason), 100 | format.MarkdownMention(rec.Sender), 101 | time.UnixMilli(rec.Timestamp).String(), 102 | ) 103 | } else { 104 | msg = fmt.Sprintf( 105 | `Room %s (||%s|| with %d members, of which %d are local) was %s for %s by %s at %s`, 106 | format.SafeMarkdownCode(roomID), 107 | roomInfo.Name, 108 | roomInfo.JoinedMembers, 109 | roomInfo.JoinedLocalMembers, 110 | explanation, 111 | format.SafeMarkdownCode(rec.Reason), 112 | format.MarkdownMention(rec.Sender), 113 | time.UnixMilli(rec.Timestamp).String(), 114 | ) 115 | } 116 | eventID := pe.Bot.SendNoticeOpts( 117 | ctx, pe.ManagementRoom, msg, &bot.SendNoticeOpts{Extra: map[string]any{ 118 | commands.ReactionCommandsKey: map[string]any{ 119 | "/block-room": "!rooms block --confirm " + roomID.String(), 120 | "/ignore": "", // TODO actually ignore this policy so it doesn't come up again? 121 | }, 122 | }}, 123 | ) 124 | pe.sendReactions(ctx, eventID, "/block-room", "/ignore") 125 | } 126 | 127 | func filterReason(reason string) string { 128 | if reason == "" { 129 | return "" 130 | } 131 | return reason 132 | } 133 | 134 | func (pe *PolicyEvaluator) maybeApplySuspend(ctx context.Context, userID id.UserID, policy *policylist.Policy) { 135 | if userID.Homeserver() != pe.Bot.ServerName { 136 | return 137 | } 138 | plist := pe.GetWatchedListMeta(policy.RoomID) 139 | if !plist.AutoSuspend { 140 | return 141 | } 142 | err := pe.Bot.SynapseAdmin.SuspendAccount(ctx, userID, synapseadmin.ReqSuspendUser{Suspend: true}) 143 | if err != nil { 144 | zerolog.Ctx(ctx).Err(err).Stringer("user_id", userID).Msg("Failed to suspend user") 145 | pe.sendNotice(ctx, "Failed to suspend [%s](%s): %v", userID, userID.URI().MatrixToURL(), err) 146 | } else { 147 | zerolog.Ctx(ctx).Info().Stringer("user_id", userID).Msg("Suspended user") 148 | pe.sendNotice(ctx, "Suspended [%s](%s) due to received ban policy", userID, userID.URI().MatrixToURL()) 149 | } 150 | } 151 | 152 | func (pe *PolicyEvaluator) ApplyBan(ctx context.Context, userID id.UserID, roomID id.RoomID, policy *policylist.Policy) { 153 | ta := &database.TakenAction{ 154 | TargetUser: userID, 155 | InRoomID: roomID, 156 | ActionType: database.TakenActionTypeBanOrUnban, 157 | PolicyList: policy.RoomID, 158 | RuleEntity: policy.EntityOrHash(), 159 | Action: policy.Recommendation, 160 | TakenAt: time.Now(), 161 | } 162 | var err error 163 | if !pe.DryRun { 164 | _, err = pe.Bot.BanUser(ctx, roomID, &mautrix.ReqBanUser{ 165 | Reason: filterReason(policy.Reason), 166 | UserID: userID, 167 | }) 168 | } 169 | if err != nil { 170 | var respErr mautrix.HTTPError 171 | if errors.As(err, &respErr) { 172 | err = respErr 173 | } 174 | zerolog.Ctx(ctx).Err(err).Any("attempted_action", ta).Msg("Failed to ban user") 175 | pe.sendNotice(ctx, "Failed to ban ||[%s](%s)|| in [%s](%s) for %s: %v", userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL(), policy.Reason, err) 176 | return 177 | } 178 | err = pe.DB.TakenAction.Put(ctx, ta) 179 | if err != nil { 180 | zerolog.Ctx(ctx).Err(err).Any("taken_action", ta).Msg("Failed to save taken action") 181 | pe.sendNotice(ctx, "Banned ||[%s](%s)|| in [%s](%s) for %s, but failed to save to database: %v", userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL(), policy.Reason, err) 182 | } else { 183 | zerolog.Ctx(ctx).Info().Any("taken_action", ta).Msg("Took action") 184 | pe.sendNotice(ctx, "Banned ||[%s](%s)|| in [%s](%s) for %s", userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL(), policy.Reason) 185 | } 186 | } 187 | 188 | func (pe *PolicyEvaluator) UndoBan(ctx context.Context, userID id.UserID, roomID id.RoomID) bool { 189 | if !pe.DryRun && !pe.Bot.StateStore.IsMembership(ctx, roomID, userID, event.MembershipBan) { 190 | zerolog.Ctx(ctx).Trace().Msg("User is not banned in room, skipping unban") 191 | return true 192 | } 193 | 194 | var err error 195 | if !pe.DryRun { 196 | _, err = pe.Bot.UnbanUser(ctx, roomID, &mautrix.ReqUnbanUser{ 197 | UserID: userID, 198 | }) 199 | } 200 | if err != nil { 201 | var respErr mautrix.HTTPError 202 | if errors.As(err, &respErr) { 203 | err = respErr 204 | } 205 | zerolog.Ctx(ctx).Err(err).Msg("Failed to unban user") 206 | pe.sendNotice(ctx, "Failed to unban [%s](%s) in [%s](%s): %v", userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL(), err) 207 | return false 208 | } 209 | zerolog.Ctx(ctx).Debug().Msg("Unbanned user") 210 | pe.sendNotice(ctx, "Unbanned [%s](%s) in [%s](%s)", userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL()) 211 | return true 212 | } 213 | 214 | func pluralize(value int, unit string) string { 215 | if value == 1 { 216 | return "1 " + unit 217 | } 218 | return fmt.Sprintf("%d %ss", value, unit) 219 | } 220 | 221 | func (pe *PolicyEvaluator) redactUserMSC4194(ctx context.Context, userID id.UserID, reason string) { 222 | rooms := pe.GetProtectedRooms() 223 | var errorMessages []string 224 | var redactedCount, roomCount int 225 | Outer: 226 | for _, roomID := range rooms { 227 | hasMore := true 228 | roomCounted := false 229 | for hasMore { 230 | resp, err := pe.Bot.UnstableRedactUserEvents(ctx, roomID, userID, &mautrix.ReqRedactUser{Reason: reason}) 231 | if err != nil { 232 | zerolog.Ctx(ctx).Err(err).Stringer("room_id", roomID).Msg("Failed to redact messages") 233 | errorMessages = append(errorMessages, fmt.Sprintf( 234 | "* Failed to redact events from [%s](%s) in [%s](%s): %v", 235 | userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL(), err)) 236 | continue Outer 237 | } 238 | hasMore = resp.IsMoreEvents 239 | if resp.RedactedEvents.Total > 0 { 240 | redactedCount += resp.RedactedEvents.Total 241 | if !roomCounted { 242 | roomCount++ 243 | roomCounted = true 244 | } 245 | } 246 | } 247 | } 248 | pe.sendRedactResult(ctx, redactedCount, roomCount, userID, errorMessages) 249 | } 250 | 251 | func (pe *PolicyEvaluator) redactUserSynapse(ctx context.Context, userID id.UserID, reason string, allowReredact bool) { 252 | start := time.Now() 253 | events, maxTS, err := pe.SynapseDB.GetEventsToRedact(ctx, userID, pe.GetProtectedRooms()) 254 | dur := time.Since(start) 255 | if err != nil { 256 | zerolog.Ctx(ctx).Err(err). 257 | Stringer("user_id", userID). 258 | Dur("query_duration", dur). 259 | Msg("Failed to get events to redact") 260 | pe.sendNotice(ctx, 261 | "Failed to get events to redact for [%s](%s): %v", 262 | userID, userID.URI().MatrixToURL(), err) 263 | return 264 | } else if len(events) == 0 { 265 | zerolog.Ctx(ctx).Debug(). 266 | Stringer("user_id", userID). 267 | Str("reason", reason). 268 | Bool("allow_reredact", allowReredact). 269 | Dur("query_duration", dur). 270 | Msg("No events found to redact") 271 | return 272 | } 273 | reason = filterReason(reason) 274 | needsReredact := allowReredact && time.Since(maxTS) < 5*time.Minute 275 | zerolog.Ctx(ctx).Debug(). 276 | Stringer("user_id", userID). 277 | Int("event_count", len(events)). 278 | Time("max_ts", maxTS). 279 | Bool("needs_redact", needsReredact). 280 | Str("reason", reason). 281 | Dur("query_duration", dur). 282 | Msg("Got events to redact") 283 | var errorMessages []string 284 | var redactedCount int 285 | for roomID, roomEvents := range events { 286 | successCount, failedCount := pe.redactEventsInRoom(ctx, userID, roomID, roomEvents, reason) 287 | if failedCount > 0 { 288 | errorMessages = append(errorMessages, fmt.Sprintf( 289 | "* Failed to redact %d/%d events from [%s](%s) in [%s](%s)", 290 | failedCount, failedCount+successCount, userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL())) 291 | } 292 | redactedCount += successCount 293 | } 294 | pe.sendRedactResult(ctx, redactedCount, len(events), userID, errorMessages) 295 | if needsReredact { 296 | time.Sleep(15 * time.Second) 297 | zerolog.Ctx(ctx).Debug(). 298 | Stringer("user_id", userID). 299 | Msg("Re-redacting user to ensure soft-failed events get redacted") 300 | pe.RedactUser(ctx, userID, reason, false) 301 | } 302 | } 303 | 304 | func (pe *PolicyEvaluator) sendRedactResult(ctx context.Context, events, rooms int, userID id.UserID, errorMessages []string) { 305 | if events == 0 && len(errorMessages) == 0 { 306 | // Skip sending a message if no events were redacted and there were no errors 307 | return 308 | } 309 | output := fmt.Sprintf("Redacted %s across %s from [%s](%s)", 310 | pluralize(events, "event"), pluralize(rooms, "room"), 311 | userID, userID.URI().MatrixToURL()) 312 | if len(errorMessages) > 0 { 313 | output += "\n\n" + strings.Join(errorMessages, "\n") 314 | } 315 | pe.sendNotice(ctx, output) 316 | } 317 | 318 | func (pe *PolicyEvaluator) RedactUser(ctx context.Context, userID id.UserID, reason string, allowReredact bool) { 319 | if pe.SynapseDB != nil { 320 | pe.redactUserSynapse(ctx, userID, reason, allowReredact) 321 | } else if pe.Bot.Client.SpecVersions.Supports(mautrix.FeatureUserRedaction) { 322 | pe.redactUserMSC4194(ctx, userID, reason) 323 | } else { 324 | zerolog.Ctx(ctx).Warn(). 325 | Stringer("user_id", userID). 326 | Msg("Falling back to history iteration based event discovery for redaction. This is slow.") 327 | for _, roomID := range pe.GetProtectedRooms() { 328 | redactedCount, err := pe.redactRecentMessages(ctx, roomID, userID, 24*time.Hour, true, reason) 329 | if err != nil { 330 | zerolog.Ctx(ctx).Err(err). 331 | Stringer("user_id", userID). 332 | Stringer("room_id", roomID). 333 | Msg("Failed to redact recent messages") 334 | continue 335 | } 336 | if redactedCount > 0 { 337 | pe.sendNotice(ctx, "Redacted %d events from [%s](%s) in [%s](%s)", redactedCount, userID, userID.URI().MatrixToURL(), roomID, roomID.URI().MatrixToURL()) 338 | } 339 | } 340 | } 341 | } 342 | 343 | func (pe *PolicyEvaluator) redactEventsInRoom(ctx context.Context, userID id.UserID, roomID id.RoomID, events []id.EventID, reason string) (successCount, failedCount int) { 344 | for _, evtID := range events { 345 | var resp *mautrix.RespSendEvent 346 | var err error 347 | if !pe.DryRun { 348 | resp, err = pe.Bot.RedactEvent(ctx, roomID, evtID, mautrix.ReqRedact{Reason: reason}) 349 | } else { 350 | resp = &mautrix.RespSendEvent{EventID: "$fake-redaction-id"} 351 | } 352 | if err != nil { 353 | zerolog.Ctx(ctx).Err(err). 354 | Stringer("sender", userID). 355 | Stringer("room_id", roomID). 356 | Stringer("event_id", evtID). 357 | Msg("Failed to redact event") 358 | failedCount++ 359 | } else { 360 | zerolog.Ctx(ctx).Debug(). 361 | Stringer("sender", userID). 362 | Stringer("room_id", roomID). 363 | Stringer("event_id", evtID). 364 | Stringer("redaction_id", resp.EventID). 365 | Msg("Successfully redacted event") 366 | successCount++ 367 | } 368 | } 369 | return 370 | } 371 | 372 | func (pe *PolicyEvaluator) redactRecentMessages(ctx context.Context, roomID id.RoomID, sender id.UserID, maxAge time.Duration, redactState bool, reason string) (int, error) { 373 | var pls event.PowerLevelsEventContent 374 | err := pe.Bot.StateEvent(ctx, roomID, event.StatePowerLevels, "", &pls) 375 | if err != nil { 376 | return 0, fmt.Errorf("failed to get power levels: %w", err) 377 | } 378 | minTS := time.Now().Add(-maxAge).UnixMilli() 379 | var sinceToken string 380 | var redactedCount int 381 | for { 382 | events, err := pe.Bot.Messages(ctx, roomID, sinceToken, "", mautrix.DirectionBackward, nil, 50) 383 | if err != nil { 384 | zerolog.Ctx(ctx).Err(err). 385 | Stringer("room_id", roomID). 386 | Str("since_token", sinceToken). 387 | Msg("Failed to get recent messages") 388 | return redactedCount, fmt.Errorf("failed to get messages: %w", err) 389 | } 390 | for _, evt := range events.Chunk { 391 | if evt.Timestamp < minTS { 392 | return redactedCount, nil 393 | } else if (evt.StateKey != nil && !redactState) || 394 | evt.Type == event.EventRedaction || 395 | pls.GetUserLevel(evt.Sender) >= pls.Redact() || 396 | evt.Unsigned.RedactedBecause != nil { 397 | continue 398 | } 399 | if sender != "" && evt.Sender != sender { 400 | continue 401 | } 402 | resp, err := pe.Bot.RedactEvent(ctx, roomID, evt.ID, mautrix.ReqRedact{Reason: reason}) 403 | if err != nil { 404 | zerolog.Ctx(ctx).Err(err). 405 | Stringer("room_id", roomID). 406 | Stringer("event_id", evt.ID). 407 | Msg("Failed to redact event") 408 | } else { 409 | zerolog.Ctx(ctx).Debug(). 410 | Stringer("room_id", roomID). 411 | Stringer("event_id", evt.ID). 412 | Stringer("redaction_id", resp.EventID). 413 | Msg("Successfully redacted event") 414 | redactedCount++ 415 | } 416 | } 417 | sinceToken = events.End 418 | if sinceToken == "" { 419 | break 420 | } 421 | } 422 | return redactedCount, nil 423 | } 424 | -------------------------------------------------------------------------------- /policyeval/main.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/rs/zerolog" 11 | "go.mau.fi/util/exsync" 12 | "go.mau.fi/util/glob" 13 | "maunium.net/go/mautrix" 14 | "maunium.net/go/mautrix/commands" 15 | "maunium.net/go/mautrix/event" 16 | "maunium.net/go/mautrix/id" 17 | 18 | "go.mau.fi/meowlnir/bot" 19 | "go.mau.fi/meowlnir/config" 20 | "go.mau.fi/meowlnir/database" 21 | "go.mau.fi/meowlnir/policyeval/roomhash" 22 | "go.mau.fi/meowlnir/policylist" 23 | "go.mau.fi/meowlnir/synapsedb" 24 | ) 25 | 26 | type protectedRoomMeta struct { 27 | Name string 28 | ACL *event.ServerACLEventContent 29 | ApplyACL bool 30 | } 31 | 32 | type PolicyEvaluator struct { 33 | Bot *bot.Bot 34 | Store *policylist.Store 35 | SynapseDB *synapsedb.SynapseDB 36 | DB *database.Database 37 | DryRun bool 38 | 39 | ManagementRoom id.RoomID 40 | Admins *exsync.Set[id.UserID] 41 | RoomHashes *roomhash.Map 42 | 43 | commandProcessor *commands.Processor[*PolicyEvaluator] 44 | 45 | watchedListsEvent *config.WatchedListsEventContent 46 | watchedListsMap map[id.RoomID]*config.WatchedPolicyList 47 | watchedListsList []id.RoomID 48 | watchedListsForACLs []id.RoomID 49 | watchedListsLock sync.RWMutex 50 | 51 | configLock sync.Mutex 52 | aclLock sync.Mutex 53 | 54 | aclDeferChan chan struct{} 55 | 56 | claimProtected func(roomID id.RoomID, eval *PolicyEvaluator, claim bool) *PolicyEvaluator 57 | protectedRoomsEvent *config.ProtectedRoomsEventContent 58 | protectedRooms map[id.RoomID]*protectedRoomMeta 59 | wantToProtect map[id.RoomID]struct{} 60 | isJoining map[id.RoomID]struct{} 61 | protectedRoomMembers map[id.UserID][]id.RoomID 62 | memberHashes map[[32]byte]id.UserID 63 | skipACLForRooms []id.RoomID 64 | protectedRoomsLock sync.RWMutex 65 | 66 | pendingInvites map[pendingInvite]struct{} 67 | pendingInvitesLock sync.Mutex 68 | AutoRejectInvites bool 69 | FilterLocalInvites bool 70 | createPuppetClient func(userID id.UserID) *mautrix.Client 71 | autoRedactPatterns []glob.Glob 72 | policyServer *PolicyServer 73 | } 74 | 75 | func NewPolicyEvaluator( 76 | bot *bot.Bot, 77 | store *policylist.Store, 78 | managementRoom id.RoomID, 79 | db *database.Database, 80 | synapseDB *synapsedb.SynapseDB, 81 | claimProtected func(roomID id.RoomID, eval *PolicyEvaluator, claim bool) *PolicyEvaluator, 82 | createPuppetClient func(userID id.UserID) *mautrix.Client, 83 | autoRejectInvites, filterLocalInvites, dryRun bool, 84 | hackyAutoRedactPatterns []glob.Glob, 85 | policyServer *PolicyServer, 86 | roomHashes *roomhash.Map, 87 | ) *PolicyEvaluator { 88 | pe := &PolicyEvaluator{ 89 | Bot: bot, 90 | DB: db, 91 | SynapseDB: synapseDB, 92 | Store: store, 93 | ManagementRoom: managementRoom, 94 | Admins: exsync.NewSet[id.UserID](), 95 | commandProcessor: commands.NewProcessor[*PolicyEvaluator](bot.Client), 96 | protectedRoomMembers: make(map[id.UserID][]id.RoomID), 97 | memberHashes: make(map[[32]byte]id.UserID), 98 | watchedListsMap: make(map[id.RoomID]*config.WatchedPolicyList), 99 | protectedRooms: make(map[id.RoomID]*protectedRoomMeta), 100 | wantToProtect: make(map[id.RoomID]struct{}), 101 | isJoining: make(map[id.RoomID]struct{}), 102 | aclDeferChan: make(chan struct{}, 1), 103 | claimProtected: claimProtected, 104 | pendingInvites: make(map[pendingInvite]struct{}), 105 | createPuppetClient: createPuppetClient, 106 | AutoRejectInvites: autoRejectInvites, 107 | FilterLocalInvites: filterLocalInvites, 108 | DryRun: dryRun, 109 | autoRedactPatterns: hackyAutoRedactPatterns, 110 | policyServer: policyServer, 111 | RoomHashes: roomHashes, 112 | } 113 | pe.commandProcessor.LogArgs = true 114 | pe.commandProcessor.Meta = pe 115 | pe.commandProcessor.PreValidator = commands.AnyPreValidator[*PolicyEvaluator]{ 116 | commands.ValidatePrefixCommand[*PolicyEvaluator](pe.Bot.UserID.String()), 117 | commands.ValidatePrefixCommand[*PolicyEvaluator]("!meowlnir"), 118 | commands.ValidatePrefixSubstring[*PolicyEvaluator]("!"), 119 | } 120 | pe.commandProcessor.ReactionCommandPrefix = "/" 121 | pe.commandProcessor.Register( 122 | cmdJoin, 123 | cmdKnock, 124 | cmdLeave, 125 | cmdPowerLevel, 126 | cmdRedact, 127 | cmdRedactRecent, 128 | cmdKick, 129 | cmdBan, 130 | cmdRemovePolicy, 131 | cmdAddUnban, 132 | cmdMatch, 133 | cmdSearch, 134 | cmdSendAsBot, 135 | cmdSuspend, 136 | cmdDeactivate, 137 | cmdRooms, 138 | cmdProtectRoom, 139 | cmdHelp, 140 | ) 141 | go pe.aclDeferLoop() 142 | return pe 143 | } 144 | 145 | func (pe *PolicyEvaluator) sendNotice(ctx context.Context, message string, args ...any) id.EventID { 146 | return pe.Bot.SendNotice(ctx, pe.ManagementRoom, message, args...) 147 | } 148 | 149 | func (pe *PolicyEvaluator) sendReactions(ctx context.Context, eventID id.EventID, reactions ...string) { 150 | if eventID == "" { 151 | return 152 | } 153 | for _, react := range reactions { 154 | _, err := pe.Bot.SendReaction(ctx, pe.ManagementRoom, eventID, react) 155 | if err != nil { 156 | zerolog.Ctx(ctx).Err(err). 157 | Stringer("event_id", eventID). 158 | Str("reaction", react). 159 | Msg("Failed to send reaction") 160 | } 161 | } 162 | } 163 | 164 | func (pe *PolicyEvaluator) Load(ctx context.Context) { 165 | err := pe.tryLoad(ctx) 166 | if err != nil { 167 | zerolog.Ctx(ctx).Err(err).Msg("Failed to load initial state") 168 | pe.sendNotice(ctx, "Failed to load initial state: %v", err) 169 | } else { 170 | zerolog.Ctx(ctx).Info().Msg("Loaded initial state") 171 | } 172 | } 173 | 174 | func (pe *PolicyEvaluator) tryLoad(ctx context.Context) error { 175 | pe.sendNotice(ctx, "Loading initial state...") 176 | pe.configLock.Lock() 177 | defer pe.configLock.Unlock() 178 | start := time.Now() 179 | state, err := pe.Bot.State(ctx, pe.ManagementRoom) 180 | if err != nil { 181 | return fmt.Errorf("failed to get management room state: %w", err) 182 | } 183 | var errors []string 184 | if evt, ok := state[event.StatePowerLevels][""]; !ok { 185 | return fmt.Errorf("no power level event found in management room") 186 | } else if errMsg := pe.handlePowerLevels(evt); errMsg != "" { 187 | errors = append(errors, errMsg) 188 | } 189 | if evt, ok := state[config.StateWatchedLists][""]; !ok { 190 | zerolog.Ctx(ctx).Info().Msg("No watched lists event found in management room") 191 | } else { 192 | _, errorMsgs := pe.handleWatchedLists(ctx, evt, true) 193 | errors = append(errors, errorMsgs...) 194 | } 195 | if evt, ok := state[config.StateProtectedRooms][""]; !ok { 196 | zerolog.Ctx(ctx).Info().Msg("No protected rooms event found in management room") 197 | } else { 198 | _, errorMsgs := pe.handleProtectedRooms(ctx, evt, true) 199 | errors = append(errors, errorMsgs...) 200 | } 201 | initDuration := time.Since(start) 202 | start = time.Now() 203 | pe.EvaluateAll(ctx) 204 | evalDuration := time.Since(start) 205 | pe.protectedRoomsLock.Lock() 206 | userCount := len(pe.protectedRoomMembers) 207 | var joinedUserCount int 208 | for _, rooms := range pe.protectedRoomMembers { 209 | if len(rooms) > 0 { 210 | joinedUserCount++ 211 | } 212 | } 213 | protectedRoomsCount := len(pe.protectedRooms) 214 | pe.protectedRoomsLock.Unlock() 215 | if len(errors) > 0 { 216 | pe.sendNotice(ctx, 217 | "Errors occurred during initialization:\n\n%s\n\nProtecting %d rooms with %d users (%d all time) using %d lists.", 218 | strings.Join(errors, "\n"), protectedRoomsCount, joinedUserCount, userCount, len(pe.GetWatchedLists())) 219 | } else { 220 | pe.sendNotice(ctx, 221 | "Initialization completed successfully (took %s to load data and %s to evaluate rules). "+ 222 | "Protecting %d rooms with %d users (%d all time) using %d lists.", 223 | initDuration, evalDuration, protectedRoomsCount, joinedUserCount, userCount, len(pe.GetWatchedLists())) 224 | } 225 | return nil 226 | } 227 | 228 | func (pe *PolicyEvaluator) handlePowerLevels(evt *event.Event) string { 229 | content, ok := evt.Content.Parsed.(*event.PowerLevelsEventContent) 230 | if !ok { 231 | return "* Failed to parse power level event" 232 | } 233 | adminLevel := content.GetEventLevel(config.StateWatchedLists) 234 | admins := exsync.NewSet[id.UserID]() 235 | for user, level := range content.Users { 236 | if level >= adminLevel { 237 | admins.Add(user) 238 | } 239 | } 240 | pe.Admins.ReplaceAll(admins) 241 | return "" 242 | } 243 | -------------------------------------------------------------------------------- /policyeval/messagehandle.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "maunium.net/go/mautrix/event" 9 | 10 | "go.mau.fi/meowlnir/bot" 11 | ) 12 | 13 | func (pe *PolicyEvaluator) isMention(content *event.MessageEventContent) bool { 14 | if content.Mentions != nil { 15 | return content.Mentions.Has(pe.Bot.UserID) 16 | } 17 | return strings.Contains(content.FormattedBody, pe.Bot.UserID.URI().MatrixToURL()) || 18 | strings.Contains(content.FormattedBody, pe.Bot.UserID.String()) 19 | } 20 | 21 | func (pe *PolicyEvaluator) HandleMessage(ctx context.Context, evt *event.Event) { 22 | content, ok := evt.Content.Parsed.(*event.MessageEventContent) 23 | if !ok { 24 | return 25 | } 26 | if pe.isMention(content) { 27 | pe.Bot.SendNoticeOpts( 28 | ctx, pe.ManagementRoom, 29 | fmt.Sprintf( 30 | `@room [%s](%s) [pinged](%s) the bot in [%s](%s)`, 31 | evt.Sender, evt.Sender.URI().MatrixToURL(), 32 | evt.RoomID.EventURI(evt.ID).MatrixToURL(), 33 | evt.RoomID, evt.RoomID.URI().MatrixToURL(), 34 | ), 35 | &bot.SendNoticeOpts{Mentions: &event.Mentions{Room: true}, SendAsText: true}, 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /policyeval/policyserver.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | "sync" 7 | "time" 8 | 9 | "github.com/rs/zerolog" 10 | "maunium.net/go/mautrix/event" 11 | "maunium.net/go/mautrix/federation" 12 | "maunium.net/go/mautrix/id" 13 | 14 | "go.mau.fi/meowlnir/policylist" 15 | ) 16 | 17 | type psCacheEntry struct { 18 | Recommendation PSRecommendation 19 | LastAccessed time.Time 20 | PDU *event.Event 21 | Lock sync.Mutex 22 | } 23 | 24 | type PolicyServer struct { 25 | Federation *federation.Client 26 | ServerAuth *federation.ServerAuth 27 | eventCache map[id.EventID]*psCacheEntry 28 | cacheLock sync.Mutex 29 | 30 | CacheMaxSize int 31 | CacheMaxAge time.Duration 32 | lastCacheClear time.Time 33 | } 34 | 35 | func NewPolicyServer(serverName string) *PolicyServer { 36 | inMemCache := federation.NewInMemoryCache() 37 | fed := federation.NewClient(serverName, nil, inMemCache) 38 | return &PolicyServer{ 39 | eventCache: make(map[id.EventID]*psCacheEntry), 40 | Federation: fed, 41 | ServerAuth: federation.NewServerAuth(fed, inMemCache, func(auth federation.XMatrixAuth) string { 42 | return auth.Destination 43 | }), 44 | CacheMaxSize: 1000, 45 | CacheMaxAge: 5 * time.Minute, 46 | } 47 | } 48 | 49 | func (ps *PolicyServer) UpdateRecommendation(userID id.UserID, roomIDs []id.RoomID, rec PSRecommendation) { 50 | ps.cacheLock.Lock() 51 | defer ps.cacheLock.Unlock() 52 | for _, cache := range ps.eventCache { 53 | if cache.PDU.Sender == userID && slices.Contains(roomIDs, cache.PDU.RoomID) { 54 | cache.Recommendation = rec 55 | } 56 | } 57 | } 58 | 59 | func (ps *PolicyServer) getCache(evtID id.EventID, pdu *event.Event) *psCacheEntry { 60 | ps.cacheLock.Lock() 61 | defer ps.cacheLock.Unlock() 62 | entry, ok := ps.eventCache[evtID] 63 | if !ok { 64 | ps.unlockedClearCacheIfNeeded() 65 | entry = &psCacheEntry{LastAccessed: time.Now(), PDU: pdu} 66 | ps.eventCache[evtID] = entry 67 | } 68 | return entry 69 | } 70 | 71 | func (ps *PolicyServer) unlockedClearCacheIfNeeded() { 72 | if len(ps.eventCache) > ps.CacheMaxSize && time.Since(ps.lastCacheClear) > 1*time.Minute { 73 | for evtID, entry := range ps.eventCache { 74 | if time.Since(entry.LastAccessed) > ps.CacheMaxAge { 75 | delete(ps.eventCache, evtID) 76 | } 77 | } 78 | ps.lastCacheClear = time.Now() 79 | } 80 | } 81 | 82 | type PSRecommendation string 83 | 84 | const ( 85 | PSRecommendationOk PSRecommendation = "ok" 86 | PSRecommendationSpam PSRecommendation = "spam" 87 | ) 88 | 89 | type PolicyServerResponse struct { 90 | Recommendation PSRecommendation `json:"recommendation"` 91 | } 92 | 93 | func (ps *PolicyServer) getRecommendation(pdu *event.Event, evaluator *PolicyEvaluator) (PSRecommendation, policylist.Match) { 94 | watchedLists := evaluator.GetWatchedLists() 95 | match := evaluator.Store.MatchUser(watchedLists, pdu.Sender) 96 | if match != nil { 97 | return PSRecommendationSpam, match 98 | } 99 | match = evaluator.Store.MatchServer(watchedLists, pdu.Sender.Homeserver()) 100 | if match != nil { 101 | return PSRecommendationSpam, match 102 | } 103 | // TODO check protections 104 | return PSRecommendationOk, nil 105 | } 106 | 107 | func (ps *PolicyServer) HandleCheck( 108 | ctx context.Context, 109 | evtID id.EventID, 110 | pdu *event.Event, 111 | evaluator *PolicyEvaluator, 112 | redact bool, 113 | ) (res *PolicyServerResponse, err error) { 114 | r := ps.getCache(evtID, pdu) 115 | r.Lock.Lock() 116 | defer r.Lock.Unlock() 117 | if r.Recommendation == "" { 118 | log := zerolog.Ctx(ctx).With().Stringer("room_id", pdu.RoomID).Stringer("event_id", evtID).Logger() 119 | log.Trace().Any("event", pdu).Msg("Checking event received by policy server") 120 | rec, match := ps.getRecommendation(pdu, evaluator) 121 | r.Recommendation = rec 122 | if rec == PSRecommendationSpam { 123 | log.Debug().Stringer("recommendations", match.Recommendations()).Msg("Event rejected for spam") 124 | if redact { 125 | go func() { 126 | if _, err = evaluator.Bot.RedactEvent(context.WithoutCancel(ctx), pdu.RoomID, evtID); err != nil { 127 | log.Error().Err(err).Msg("Failed to redact event") 128 | } 129 | }() 130 | } 131 | } else { 132 | log.Trace().Msg("Event accepted") 133 | } 134 | } 135 | r.LastAccessed = time.Now() 136 | return &PolicyServerResponse{Recommendation: r.Recommendation}, nil 137 | } 138 | -------------------------------------------------------------------------------- /policyeval/protectedrooms.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "slices" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/rs/zerolog" 12 | "maunium.net/go/mautrix" 13 | "maunium.net/go/mautrix/event" 14 | "maunium.net/go/mautrix/id" 15 | 16 | "go.mau.fi/meowlnir/config" 17 | "go.mau.fi/meowlnir/util" 18 | ) 19 | 20 | func (pe *PolicyEvaluator) GetProtectedRooms() []id.RoomID { 21 | pe.protectedRoomsLock.RLock() 22 | rooms := slices.Collect(maps.Keys(pe.protectedRooms)) 23 | pe.protectedRoomsLock.RUnlock() 24 | return rooms 25 | } 26 | 27 | func (pe *PolicyEvaluator) IsProtectedRoom(roomID id.RoomID) bool { 28 | pe.protectedRoomsLock.RLock() 29 | _, protected := pe.protectedRooms[roomID] 30 | pe.protectedRoomsLock.RUnlock() 31 | return protected 32 | } 33 | 34 | func (pe *PolicyEvaluator) HandleProtectedRoomMeta(ctx context.Context, evt *event.Event) { 35 | switch evt.Type { 36 | case event.StatePowerLevels: 37 | pe.handleProtectedRoomPowerLevels(ctx, evt) 38 | case event.StateRoomName: 39 | pe.protectedRoomsLock.Lock() 40 | meta, ok := pe.protectedRooms[evt.RoomID] 41 | if ok { 42 | meta.Name = evt.Content.AsRoomName().Name 43 | } 44 | pe.protectedRoomsLock.Unlock() 45 | case event.StateServerACL: 46 | pe.protectedRoomsLock.Lock() 47 | meta, ok := pe.protectedRooms[evt.RoomID] 48 | if ok { 49 | meta.ACL, ok = evt.Content.Parsed.(*event.ServerACLEventContent) 50 | if !ok { 51 | zerolog.Ctx(ctx).Warn(). 52 | Stringer("room_id", evt.RoomID). 53 | Msg("Failed to parse new server ACL in room") 54 | } else { 55 | slices.Sort(meta.ACL.Deny) 56 | } 57 | // TODO notify management room about change? 58 | } 59 | pe.protectedRoomsLock.Unlock() 60 | } 61 | } 62 | 63 | func (pe *PolicyEvaluator) handleProtectedRoomPowerLevels(ctx context.Context, evt *event.Event) { 64 | powerLevels := evt.Content.AsPowerLevels() 65 | ownLevel := powerLevels.GetUserLevel(pe.Bot.UserID) 66 | minLevel := max(powerLevels.Ban(), powerLevels.Redact()) 67 | pe.protectedRoomsLock.RLock() 68 | meta, isProtecting := pe.protectedRooms[evt.RoomID] 69 | _, wantToProtect := pe.wantToProtect[evt.RoomID] 70 | pe.protectedRoomsLock.RUnlock() 71 | if meta != nil && meta.ApplyACL { 72 | minLevel = max(minLevel, powerLevels.GetEventLevel(event.StateServerACL)) 73 | } 74 | if isProtecting && ownLevel < minLevel { 75 | pe.sendNotice(ctx, "⚠️ Bot no longer has sufficient power level in [%s](%s) (have %d, minimum %d)", evt.RoomID, evt.RoomID.URI().MatrixToURL(), ownLevel, minLevel) 76 | } else if wantToProtect && ownLevel >= minLevel { 77 | _, errMsg := pe.tryProtectingRoom(ctx, nil, evt.RoomID, true) 78 | if errMsg != "" { 79 | pe.sendNotice(ctx, "Retried protecting room after power level change, but failed: %s", strings.TrimPrefix(errMsg, "* ")) 80 | } else { 81 | pe.sendNotice(ctx, "Power levels corrected, now protecting [%s](%s)", evt.RoomID, evt.RoomID.URI().MatrixToURL()) 82 | } 83 | } 84 | } 85 | 86 | func (pe *PolicyEvaluator) lockJoin(roomID id.RoomID) func() { 87 | pe.protectedRoomsLock.Lock() 88 | _, isJoining := pe.isJoining[roomID] 89 | pe.isJoining[roomID] = struct{}{} 90 | pe.protectedRoomsLock.Unlock() 91 | if isJoining { 92 | return nil 93 | } 94 | return func() { 95 | pe.protectedRoomsLock.Lock() 96 | delete(pe.isJoining, roomID) 97 | pe.protectedRoomsLock.Unlock() 98 | } 99 | } 100 | 101 | func (pe *PolicyEvaluator) tryProtectingRoom(ctx context.Context, joinedRooms *mautrix.RespJoinedRooms, roomID id.RoomID, doReeval bool) (*mautrix.RespMembers, string) { 102 | if roomID == pe.ManagementRoom { 103 | return nil, "* The management room can't be a protected room" 104 | } else if claimer := pe.claimProtected(roomID, pe, true); claimer != pe { 105 | if claimer != nil && claimer.Bot.UserID == pe.Bot.UserID { 106 | return nil, fmt.Sprintf("* Room [%s](%s) is already protected by [%s](%s)", roomID, roomID.URI().MatrixToURL(), claimer.ManagementRoom, claimer.ManagementRoom.URI().MatrixToURL()) 107 | } else { 108 | if claimer != nil { 109 | zerolog.Ctx(ctx).Debug(). 110 | Stringer("claimer_user_id", claimer.Bot.UserID). 111 | Stringer("claimer_room_id", claimer.ManagementRoom). 112 | Msg("Failed to protect room that's already claimed by another bot") 113 | } else { 114 | zerolog.Ctx(ctx).Warn().Msg("Failed to protect room, but no existing claimer found, likely a management room") 115 | } 116 | return nil, fmt.Sprintf("* Room [%s](%s) is already protected by another bot", roomID, roomID.URI().MatrixToURL()) 117 | } 118 | } 119 | var err error 120 | if joinedRooms == nil { 121 | joinedRooms, err = pe.Bot.JoinedRooms(ctx) 122 | if err != nil { 123 | return nil, fmt.Sprintf("* Failed to get joined rooms: %v", err) 124 | } 125 | } 126 | pe.markAsWantToProtect(roomID) 127 | if !slices.Contains(joinedRooms.JoinedRooms, roomID) { 128 | unlock := pe.lockJoin(roomID) 129 | if unlock == nil { 130 | return nil, "" 131 | } 132 | defer unlock() 133 | _, err = pe.Bot.JoinRoom(ctx, roomID.String(), nil) 134 | if err != nil { 135 | return nil, fmt.Sprintf("* Bot is not in protected room [%s](%s) and joining failed: %v", roomID, roomID.URI().MatrixToURL(), err) 136 | } 137 | } 138 | var powerLevels event.PowerLevelsEventContent 139 | err = pe.Bot.StateEvent(ctx, roomID, event.StatePowerLevels, "", &powerLevels) 140 | if err != nil { 141 | return nil, fmt.Sprintf("* Failed to get power levels for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err) 142 | } 143 | ownLevel := powerLevels.GetUserLevel(pe.Bot.UserID) 144 | minLevel := max(powerLevels.Ban(), powerLevels.Redact()) 145 | if ownLevel < minLevel && !pe.DryRun { 146 | return nil, fmt.Sprintf("* Bot does not have sufficient power level in [%s](%s) (have %d, minimum %d)", roomID, roomID.URI().MatrixToURL(), ownLevel, minLevel) 147 | } 148 | members, err := pe.Bot.Members(ctx, roomID) 149 | if err != nil { 150 | return nil, fmt.Sprintf("* Failed to get room members for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err) 151 | } 152 | var name event.RoomNameEventContent 153 | err = pe.Bot.StateEvent(ctx, roomID, event.StateRoomName, "", &name) 154 | if err != nil { 155 | zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Msg("Failed to get room name") 156 | } 157 | var acl event.ServerACLEventContent 158 | err = pe.Bot.StateEvent(ctx, roomID, event.StateServerACL, "", &acl) 159 | if err != nil { 160 | zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Msg("Failed to get server ACL") 161 | } 162 | slices.Sort(acl.Deny) 163 | pe.markAsProtectedRoom(roomID, name.Name, &acl, members.Chunk) 164 | if doReeval { 165 | memberIDs := make([]id.UserID, len(members.Chunk)) 166 | for i, member := range members.Chunk { 167 | memberIDs[i] = id.UserID(member.GetStateKey()) 168 | } 169 | pe.EvaluateAllMembers(ctx, memberIDs) 170 | pe.UpdateACL(ctx) 171 | } 172 | return members, "" 173 | } 174 | 175 | func (pe *PolicyEvaluator) handleProtectedRooms(ctx context.Context, evt *event.Event, isInitial bool) (output, errors []string) { 176 | content, ok := evt.Content.Parsed.(*config.ProtectedRoomsEventContent) 177 | if !ok { 178 | return nil, []string{"* Failed to parse protected rooms event"} 179 | } 180 | pe.protectedRoomsLock.Lock() 181 | pe.protectedRoomsEvent = content 182 | pe.skipACLForRooms = content.SkipACL 183 | for roomID := range pe.protectedRooms { 184 | if !slices.Contains(content.Rooms, roomID) { 185 | delete(pe.protectedRooms, roomID) 186 | pe.claimProtected(roomID, pe, false) 187 | output = append(output, fmt.Sprintf("* Stopped protecting room [%s](%s)", roomID, roomID.URI().MatrixToURL())) 188 | } 189 | } 190 | pe.protectedRoomsLock.Unlock() 191 | joinedRooms, err := pe.Bot.JoinedRooms(ctx) 192 | if err != nil { 193 | return output, []string{"* Failed to get joined rooms: ", err.Error()} 194 | } 195 | var outLock sync.Mutex 196 | reevalMembers := make(map[id.UserID]struct{}) 197 | var wg sync.WaitGroup 198 | for _, roomID := range content.Rooms { 199 | if pe.IsProtectedRoom(roomID) { 200 | continue 201 | } 202 | wg.Add(1) 203 | go func() { 204 | defer wg.Done() 205 | members, errMsg := pe.tryProtectingRoom(ctx, joinedRooms, roomID, false) 206 | outLock.Lock() 207 | defer outLock.Unlock() 208 | if errMsg != "" { 209 | errors = append(errors, errMsg) 210 | } 211 | if !isInitial && members != nil { 212 | for _, member := range members.Chunk { 213 | reevalMembers[id.UserID(member.GetStateKey())] = struct{}{} 214 | } 215 | output = append(output, fmt.Sprintf("* Started protecting room [%s](%s)", roomID, roomID.URI().MatrixToURL())) 216 | } 217 | }() 218 | } 219 | wg.Wait() 220 | if len(reevalMembers) > 0 { 221 | pe.EvaluateAllMembers(ctx, slices.Collect(maps.Keys(reevalMembers))) 222 | pe.UpdateACL(ctx) 223 | } 224 | return 225 | } 226 | 227 | func (pe *PolicyEvaluator) markAsWantToProtect(roomID id.RoomID) { 228 | pe.protectedRoomsLock.Lock() 229 | defer pe.protectedRoomsLock.Unlock() 230 | pe.wantToProtect[roomID] = struct{}{} 231 | } 232 | 233 | func (pe *PolicyEvaluator) markAsProtectedRoom(roomID id.RoomID, name string, acl *event.ServerACLEventContent, evts []*event.Event) { 234 | pe.protectedRoomsLock.Lock() 235 | defer pe.protectedRoomsLock.Unlock() 236 | pe.protectedRooms[roomID] = &protectedRoomMeta{Name: name, ACL: acl, ApplyACL: !slices.Contains(pe.skipACLForRooms, roomID)} 237 | delete(pe.wantToProtect, roomID) 238 | for _, evt := range evts { 239 | pe.unlockedUpdateUser(id.UserID(evt.GetStateKey()), evt.RoomID, evt.Content.AsMember().Membership) 240 | } 241 | } 242 | 243 | func isInRoom(membership event.Membership) bool { 244 | switch membership { 245 | case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock: 246 | return true 247 | } 248 | return false 249 | } 250 | 251 | func (pe *PolicyEvaluator) updateUser(userID id.UserID, roomID id.RoomID, membership event.Membership) bool { 252 | pe.protectedRoomsLock.Lock() 253 | defer pe.protectedRoomsLock.Unlock() 254 | _, isProtected := pe.protectedRooms[roomID] 255 | if !isProtected { 256 | return false 257 | } 258 | return pe.unlockedUpdateUser(userID, roomID, membership) 259 | } 260 | 261 | func (pe *PolicyEvaluator) unlockedUpdateUser(userID id.UserID, roomID id.RoomID, membership event.Membership) bool { 262 | add := isInRoom(membership) 263 | existingList, ok := pe.protectedRoomMembers[userID] 264 | if !ok { 265 | pe.memberHashes[util.SHA256String(userID)] = userID 266 | } 267 | if add { 268 | if !slices.Contains(existingList, roomID) { 269 | pe.protectedRoomMembers[userID] = append(existingList, roomID) 270 | return true 271 | } 272 | } else if idx := slices.Index(existingList, roomID); idx >= 0 { 273 | pe.protectedRoomMembers[userID] = slices.Delete(existingList, idx, idx+1) 274 | } else if !ok && membership != event.MembershipBan { 275 | // Even left users are added to the map to ensure events are redacted if they leave before being banned 276 | pe.protectedRoomMembers[userID] = []id.RoomID{} 277 | } 278 | return false 279 | } 280 | -------------------------------------------------------------------------------- /policyeval/report.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/rs/zerolog" 10 | "maunium.net/go/mautrix" 11 | "maunium.net/go/mautrix/event" 12 | "maunium.net/go/mautrix/id" 13 | 14 | "go.mau.fi/meowlnir/policylist" 15 | ) 16 | 17 | func (pe *PolicyEvaluator) HandleReport(ctx context.Context, senderClient *mautrix.Client, targetUserID id.UserID, roomID id.RoomID, eventID id.EventID, reason string) error { 18 | sender := senderClient.UserID 19 | var evt *event.Event 20 | var err error 21 | if eventID != "" { 22 | evt, err = senderClient.GetEvent(ctx, roomID, eventID) 23 | if err != nil { 24 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get report target event with user's token") 25 | pe.sendNotice( 26 | ctx, `[%s](%s) reported [an event](%s) for %s, but the event could not be fetched: %v`, 27 | sender, sender.URI().MatrixToURL(), roomID.EventURI(eventID).MatrixToURL(), reason, err, 28 | ) 29 | return fmt.Errorf("failed to fetch event: %w", err) 30 | } 31 | targetUserID = evt.Sender 32 | } 33 | if !pe.Admins.Has(sender) || !strings.HasPrefix(reason, "/") || targetUserID == "" { 34 | if eventID != "" { 35 | pe.sendNotice( 36 | ctx, `[%s](%s) reported [an event](%s) from ||[%s](%s)|| for %s`, 37 | sender, sender.URI().MatrixToURL(), roomID.EventURI(eventID).MatrixToURL(), 38 | evt.Sender, evt.Sender.URI().MatrixToURL(), 39 | reason, 40 | ) 41 | } else if roomID != "" { 42 | pe.sendNotice( 43 | ctx, `[%s](%s) reported ||[a room](%s)|| for %s`, 44 | sender, sender.URI().MatrixToURL(), roomID.URI().MatrixToURL(), 45 | reason, 46 | ) 47 | } else if targetUserID != "" { 48 | pe.sendNotice( 49 | ctx, `[%s](%s) reported ||[%s](%s)|| for %s`, 50 | sender, sender.URI().MatrixToURL(), targetUserID.URI().MatrixToURL(), 51 | reason, 52 | ) 53 | } 54 | return nil 55 | } 56 | fields := strings.Fields(reason) 57 | cmd := strings.TrimPrefix(fields[0], "/") 58 | args := fields[1:] 59 | switch strings.ToLower(cmd) { 60 | case "ban": 61 | if len(args) < 2 { 62 | return mautrix.MInvalidParam.WithMessage("Not enough arguments for ban") 63 | } 64 | list := pe.FindListByShortcode(args[0]) 65 | if list == nil { 66 | pe.sendNotice(ctx, `Failed to handle [%s](%s)'s report of [%s](%s): list %q not found`, 67 | sender, sender.URI().MatrixToURL(), targetUserID, targetUserID.URI().MatrixToURL(), args[0]) 68 | return mautrix.MNotFound.WithMessage(fmt.Sprintf("List with shortcode %q not found", args[0])) 69 | } 70 | match := pe.Store.MatchUser([]id.RoomID{list.RoomID}, targetUserID) 71 | if rec := match.Recommendations().BanOrUnban; rec != nil { 72 | if rec.Recommendation == event.PolicyRecommendationUnban { 73 | return mautrix.RespError{ 74 | ErrCode: "FI.MAU.MEOWLNIR.UNBAN_RECOMMENDED", 75 | Err: fmt.Sprintf("%s has an unban recommendation: %s", targetUserID, rec.Reason), 76 | StatusCode: http.StatusConflict, 77 | } 78 | } else { 79 | return mautrix.RespError{ 80 | ErrCode: "FI.MAU.MEOWLNIR.ALREADY_BANNED", 81 | Err: fmt.Sprintf("%s is already banned for: %s", targetUserID, rec.Reason), 82 | StatusCode: http.StatusConflict, 83 | } 84 | } 85 | } 86 | policy := &event.ModPolicyContent{ 87 | Entity: string(targetUserID), 88 | Reason: strings.Join(args[1:], " "), 89 | Recommendation: event.PolicyRecommendationBan, 90 | } 91 | resp, err := pe.SendPolicy(ctx, list.RoomID, policylist.EntityTypeUser, "", string(targetUserID), policy) 92 | if err != nil { 93 | pe.sendNotice(ctx, `Failed to handle [%s](%s)'s report of ||[%s](%s)|| for %s ([%s](%s)): %v`, 94 | sender, sender.URI().MatrixToURL(), targetUserID, targetUserID.URI().MatrixToURL(), 95 | list.Name, list.RoomID, list.RoomID.URI().MatrixToURL(), err) 96 | return fmt.Errorf("failed to send policy: %w", err) 97 | } 98 | zerolog.Ctx(ctx).Info(). 99 | Stringer("policy_list", list.RoomID). 100 | Any("policy", policy). 101 | Stringer("policy_event_id", resp.EventID). 102 | Msg("Sent ban policy from report") 103 | pe.sendNotice(ctx, `Processed [%s](%s)'s report of ||[%s](%s)|| and sent a ban policy to %s ([%s](%s)) for %s`, 104 | sender, sender.URI().MatrixToURL(), targetUserID, targetUserID.URI().MatrixToURL(), 105 | list.Name, list.RoomID, list.RoomID.URI().MatrixToURL(), policy.Reason) 106 | } 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /policyeval/roomhash/roomhash.go: -------------------------------------------------------------------------------- 1 | package roomhash 2 | 3 | import ( 4 | "sync" 5 | 6 | "maunium.net/go/mautrix/id" 7 | 8 | "go.mau.fi/meowlnir/util" 9 | ) 10 | 11 | type Map struct { 12 | hashToRoomID map[[32]byte]id.RoomID 13 | lock sync.RWMutex 14 | } 15 | 16 | func NewMap() *Map { 17 | return &Map{ 18 | hashToRoomID: make(map[[32]byte]id.RoomID), 19 | } 20 | } 21 | 22 | func (m *Map) Put(roomID id.RoomID) bool { 23 | hash := util.SHA256String(roomID) 24 | m.lock.Lock() 25 | _, exists := m.hashToRoomID[hash] 26 | if !exists { 27 | m.hashToRoomID[hash] = roomID 28 | } 29 | m.lock.Unlock() 30 | return !exists 31 | } 32 | 33 | func (m *Map) Get(hash [32]byte) id.RoomID { 34 | m.lock.RLock() 35 | roomID := m.hashToRoomID[hash] 36 | m.lock.RUnlock() 37 | return roomID 38 | } 39 | 40 | func (m *Map) Has(roomID id.RoomID) bool { 41 | hash := util.SHA256String(roomID) 42 | m.lock.RLock() 43 | _, exists := m.hashToRoomID[hash] 44 | m.lock.RUnlock() 45 | return exists 46 | } 47 | -------------------------------------------------------------------------------- /policyeval/serveracl.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | "strings" 7 | "sync" 8 | "sync/atomic" 9 | "time" 10 | 11 | "github.com/rs/zerolog" 12 | "go.mau.fi/util/exslices" 13 | "maunium.net/go/mautrix/event" 14 | "maunium.net/go/mautrix/id" 15 | ) 16 | 17 | func (pe *PolicyEvaluator) CompileACL() (*event.ServerACLEventContent, time.Duration) { 18 | start := time.Now() 19 | rules := pe.Store.ListServerRules(pe.GetWatchedListsForACLs()) 20 | acl := event.ServerACLEventContent{ 21 | Allow: []string{"*"}, 22 | Deny: make([]string, 0, len(rules)), 23 | 24 | AllowIPLiterals: false, 25 | } 26 | for entity, policy := range rules { 27 | if policy.Pattern.Match(pe.Bot.ServerName) { 28 | continue 29 | } 30 | if policy.Recommendation != event.PolicyRecommendationUnban { 31 | acl.Deny = append(acl.Deny, entity) 32 | } 33 | } 34 | slices.Sort(acl.Deny) 35 | return &acl, time.Since(start) 36 | } 37 | 38 | func (pe *PolicyEvaluator) DeferredUpdateACL() { 39 | select { 40 | case pe.aclDeferChan <- struct{}{}: 41 | default: 42 | } 43 | } 44 | 45 | const aclDeferTime = 15 * time.Second 46 | 47 | func (pe *PolicyEvaluator) aclDeferLoop() { 48 | ctx := pe.Bot.Log.With(). 49 | Str("action", "deferred acl update"). 50 | Stringer("management_room", pe.ManagementRoom). 51 | Logger(). 52 | WithContext(context.Background()) 53 | after := time.NewTimer(aclDeferTime) 54 | after.Stop() 55 | for { 56 | select { 57 | case <-pe.aclDeferChan: 58 | after.Reset(aclDeferTime) 59 | case <-after.C: 60 | pe.UpdateACL(ctx) 61 | } 62 | } 63 | } 64 | 65 | func (pe *PolicyEvaluator) UpdateACL(ctx context.Context) { 66 | log := zerolog.Ctx(ctx) 67 | pe.aclLock.Lock() 68 | defer pe.aclLock.Unlock() 69 | newACL, compileDur := pe.CompileACL() 70 | pe.protectedRoomsLock.RLock() 71 | changedRooms := make(map[id.RoomID][]string, len(pe.protectedRooms)) 72 | for roomID, meta := range pe.protectedRooms { 73 | if !meta.ApplyACL { 74 | continue 75 | } 76 | if meta.ACL == nil || !slices.Equal(meta.ACL.Deny, newACL.Deny) { 77 | changedRooms[roomID] = meta.ACL.Deny 78 | } 79 | } 80 | pe.protectedRoomsLock.RUnlock() 81 | if len(changedRooms) == 0 { 82 | log.Info(). 83 | Dur("compile_duration", compileDur). 84 | Msg("No server ACL changes to send") 85 | return 86 | } 87 | log.Info(). 88 | Int("room_count", len(changedRooms)). 89 | Any("new_acl", newACL). 90 | Dur("compile_duration", compileDur). 91 | Msg("Sending updated server ACL event") 92 | var wg sync.WaitGroup 93 | wg.Add(len(changedRooms)) 94 | var successCount atomic.Int32 95 | for roomID, oldACLDeny := range changedRooms { 96 | go func(roomID id.RoomID, oldACLDeny []string) { 97 | defer wg.Done() 98 | removed, added := exslices.SortedDiff(oldACLDeny, newACL.Deny, strings.Compare) 99 | if pe.DryRun { 100 | log.Debug(). 101 | Stringer("room_id", roomID). 102 | Strs("deny_added", added). 103 | Strs("deny_removed", removed). 104 | Msg("Dry run: would send server ACL to room") 105 | successCount.Add(1) 106 | return 107 | } 108 | resp, err := pe.Bot.SendStateEvent(ctx, roomID, event.StateServerACL, "", newACL) 109 | if err != nil { 110 | log.Err(err). 111 | Strs("deny_added", added). 112 | Strs("deny_removed", removed). 113 | Stringer("room_id", roomID). 114 | Msg("Failed to send server ACL to room") 115 | pe.sendNotice(ctx, "Failed to send server ACL to room %s: %v", roomID, err) 116 | } else { 117 | log.Debug(). 118 | Stringer("room_id", roomID). 119 | Stringer("event_id", resp.EventID). 120 | Strs("deny_added", added). 121 | Strs("deny_removed", removed). 122 | Msg("Sent new server ACL to room") 123 | successCount.Add(1) 124 | } 125 | }(roomID, oldACLDeny) 126 | } 127 | wg.Wait() 128 | pe.protectedRoomsLock.Lock() 129 | for roomID := range changedRooms { 130 | pe.protectedRooms[roomID].ACL = newACL 131 | } 132 | pe.protectedRoomsLock.Unlock() 133 | log.Info(). 134 | Int("room_count", len(changedRooms)). 135 | Int32("success_count", successCount.Load()). 136 | Msg("Finished sending server ACL updates") 137 | pe.sendNotice(ctx, "Successfully sent updated server ACL to %d/%d rooms", successCount.Load(), len(changedRooms)) 138 | } 139 | -------------------------------------------------------------------------------- /policyeval/watchedlists.go: -------------------------------------------------------------------------------- 1 | package policyeval 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "slices" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/rs/zerolog" 12 | "go.mau.fi/util/exslices" 13 | "maunium.net/go/mautrix/event" 14 | "maunium.net/go/mautrix/id" 15 | 16 | "go.mau.fi/meowlnir/config" 17 | ) 18 | 19 | func (pe *PolicyEvaluator) IsWatchingList(roomID id.RoomID) bool { 20 | pe.watchedListsLock.RLock() 21 | _, watched := pe.watchedListsMap[roomID] 22 | pe.watchedListsLock.RUnlock() 23 | return watched 24 | } 25 | 26 | func (pe *PolicyEvaluator) GetWatchedListMeta(roomID id.RoomID) *config.WatchedPolicyList { 27 | pe.watchedListsLock.RLock() 28 | meta := pe.watchedListsMap[roomID] 29 | pe.watchedListsLock.RUnlock() 30 | return meta 31 | } 32 | 33 | func (pe *PolicyEvaluator) FindListByShortcode(shortcode string) *config.WatchedPolicyList { 34 | shortcode = strings.ToLower(shortcode) 35 | pe.watchedListsLock.RLock() 36 | defer pe.watchedListsLock.RUnlock() 37 | for _, meta := range pe.watchedListsMap { 38 | if strings.ToLower(meta.Shortcode) == shortcode { 39 | return meta 40 | } 41 | } 42 | return nil 43 | } 44 | 45 | func (pe *PolicyEvaluator) GetWatchedLists() []id.RoomID { 46 | pe.watchedListsLock.RLock() 47 | defer pe.watchedListsLock.RUnlock() 48 | return pe.watchedListsList 49 | } 50 | 51 | func (pe *PolicyEvaluator) GetWatchedListsForACLs() []id.RoomID { 52 | pe.watchedListsLock.RLock() 53 | defer pe.watchedListsLock.RUnlock() 54 | return pe.watchedListsForACLs 55 | } 56 | 57 | func (pe *PolicyEvaluator) handleWatchedLists(ctx context.Context, evt *event.Event, isInitial bool) (output, errors []string) { 58 | content, ok := evt.Content.Parsed.(*config.WatchedListsEventContent) 59 | if !ok { 60 | return nil, []string{"* Failed to parse watched lists event"} 61 | } 62 | var wg sync.WaitGroup 63 | var outLock sync.Mutex 64 | wg.Add(len(content.Lists)) 65 | for _, listInfo := range content.Lists { 66 | go func() { 67 | defer wg.Done() 68 | if !pe.Store.Contains(listInfo.RoomID) { 69 | state, err := pe.Bot.State(ctx, listInfo.RoomID) 70 | if err != nil { 71 | zerolog.Ctx(ctx).Err(err).Stringer("room_id", listInfo.RoomID).Msg("Failed to load state of watched list") 72 | outLock.Lock() 73 | errors = append(errors, fmt.Sprintf("* Failed to get room state for [%s](%s): %v", listInfo.Name, listInfo.RoomID.URI().MatrixToURL(), err)) 74 | outLock.Unlock() 75 | return 76 | } 77 | pe.Store.Add(listInfo.RoomID, state) 78 | } 79 | }() 80 | } 81 | wg.Wait() 82 | watchedList := make([]id.RoomID, 0, len(content.Lists)) 83 | aclWatchedList := make([]id.RoomID, 0, len(content.Lists)) 84 | watchedMap := make(map[id.RoomID]*config.WatchedPolicyList, len(content.Lists)) 85 | for _, listInfo := range content.Lists { 86 | if _, alreadyWatched := watchedMap[listInfo.RoomID]; alreadyWatched { 87 | errors = append(errors, fmt.Sprintf("* Duplicate watched list [%s](%s)", listInfo.Name, listInfo.RoomID.URI().MatrixToURL())) 88 | } else { 89 | watchedMap[listInfo.RoomID] = &listInfo 90 | if !listInfo.DontApply { 91 | watchedList = append(watchedList, listInfo.RoomID) 92 | if !listInfo.DontApplyACL { 93 | aclWatchedList = append(aclWatchedList, listInfo.RoomID) 94 | } 95 | } 96 | } 97 | } 98 | pe.watchedListsLock.Lock() 99 | oldWatchedList := pe.watchedListsList 100 | oldACLWatchedList := pe.watchedListsForACLs 101 | oldFullWatchedList := slices.Collect(maps.Keys(pe.watchedListsMap)) 102 | pe.watchedListsMap = watchedMap 103 | pe.watchedListsList = watchedList 104 | pe.watchedListsForACLs = aclWatchedList 105 | pe.watchedListsEvent = content 106 | pe.watchedListsLock.Unlock() 107 | if !isInitial { 108 | unsubscribed, subscribed := exslices.Diff(oldWatchedList, watchedList) 109 | noApplyUnsubscribed, noApplySubscribed := exslices.Diff(oldFullWatchedList, slices.Collect(maps.Keys(pe.watchedListsMap))) 110 | for _, roomID := range subscribed { 111 | output = append(output, fmt.Sprintf("* Subscribed to %s [%s](%s)", pe.GetWatchedListMeta(roomID).Name, roomID, roomID.URI().MatrixToURL())) 112 | } 113 | for _, roomID := range noApplySubscribed { 114 | if !slices.Contains(subscribed, roomID) { 115 | output = append(output, fmt.Sprintf("* Subscribed to %s [%s](%s) without applying policies", pe.GetWatchedListMeta(roomID).Name, roomID, roomID.URI().MatrixToURL())) 116 | } 117 | } 118 | for _, roomID := range unsubscribed { 119 | output = append(output, fmt.Sprintf("* Unsubscribed from [%s](%s)", roomID, roomID.URI().MatrixToURL())) 120 | } 121 | for _, roomID := range noApplyUnsubscribed { 122 | if !slices.Contains(unsubscribed, roomID) { 123 | output = append(output, fmt.Sprintf("* Unsubscribed from [%s](%s) (policies weren't being applied)", roomID, roomID.URI().MatrixToURL())) 124 | } 125 | } 126 | aclUnsubscribed, aclSubscribed := exslices.Diff(oldACLWatchedList, aclWatchedList) 127 | for _, roomID := range aclSubscribed { 128 | if !slices.Contains(subscribed, roomID) { 129 | output = append(output, fmt.Sprintf("* Subscribed to server ACLs in %s [%s](%s)", pe.GetWatchedListMeta(roomID).Name, roomID, roomID.URI().MatrixToURL())) 130 | } 131 | } 132 | for _, roomID := range aclUnsubscribed { 133 | if !slices.Contains(unsubscribed, roomID) { 134 | output = append(output, fmt.Sprintf("* Unsubscribed from server ACLs in %s [%s](%s)", pe.GetWatchedListMeta(roomID).Name, roomID, roomID.URI().MatrixToURL())) 135 | } 136 | } 137 | go func(ctx context.Context) { 138 | if len(unsubscribed) > 0 { 139 | pe.ReevaluateAffectedByLists(ctx, unsubscribed) 140 | } 141 | if len(subscribed) > 0 || len(unsubscribed) > 0 { 142 | pe.EvaluateAll(ctx) 143 | } 144 | if len(aclSubscribed) > 0 || len(aclUnsubscribed) > 0 { 145 | pe.UpdateACL(ctx) 146 | } 147 | }(context.WithoutCancel(ctx)) 148 | } 149 | return 150 | } 151 | -------------------------------------------------------------------------------- /policylist/list.go: -------------------------------------------------------------------------------- 1 | package policylist 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "go.mau.fi/util/glob" 10 | "maunium.net/go/mautrix/event" 11 | "maunium.net/go/mautrix/id" 12 | 13 | "go.mau.fi/meowlnir/util" 14 | ) 15 | 16 | type dplNode struct { 17 | *Policy 18 | prev *dplNode 19 | next *dplNode 20 | } 21 | 22 | // List represents the list of rules for a single entity type. 23 | // 24 | // Policies are split into literal rules and dynamic rules. Literal rules are stored in a map for fast matching, 25 | // while dynamic rules are glob patterns and are evaluated one by one for each query. 26 | type List struct { 27 | matchDuration prometheus.Observer 28 | byStateKey map[string]*dplNode 29 | byEntity map[string]*dplNode 30 | byEntityHash map[[util.HashSize]byte]*dplNode 31 | dynamicHead *dplNode 32 | lock sync.RWMutex 33 | } 34 | 35 | func NewList(roomID id.RoomID, entityType string) *List { 36 | return &List{ 37 | matchDuration: matchDuration.WithLabelValues(roomID.String(), entityType), 38 | byStateKey: make(map[string]*dplNode), 39 | byEntity: make(map[string]*dplNode), 40 | byEntityHash: make(map[[util.HashSize]byte]*dplNode), 41 | } 42 | } 43 | 44 | func typeQuality(evtType event.Type) int { 45 | switch evtType { 46 | case event.StatePolicyUser, event.StatePolicyRoom, event.StatePolicyServer: 47 | return 5 48 | case event.StateLegacyPolicyUser, event.StateLegacyPolicyRoom, event.StateLegacyPolicyServer: 49 | return 4 50 | case event.StateUnstablePolicyUser, event.StateUnstablePolicyRoom, event.StateUnstablePolicyServer: 51 | return 3 52 | default: 53 | return 0 54 | } 55 | } 56 | 57 | func (l *List) removeFromLinkedList(node *dplNode) { 58 | if l.dynamicHead == node { 59 | l.dynamicHead = node.next 60 | } 61 | if node.prev != nil { 62 | node.prev.next = node.next 63 | } 64 | if node.next != nil { 65 | node.next.prev = node.prev 66 | } 67 | } 68 | 69 | func (l *List) Add(value *Policy) (*Policy, bool) { 70 | l.lock.Lock() 71 | defer l.lock.Unlock() 72 | existing, ok := l.byStateKey[value.StateKey] 73 | if ok { 74 | if typeQuality(existing.Type) > typeQuality(value.Type) { 75 | // There's an existing policy with the same state key, but a newer event type, ignore this one. 76 | return nil, false 77 | } else if existing.EntityOrHash() == value.EntityOrHash() { 78 | oldPolicy := existing.Policy 79 | // The entity in the policy didn't change, just update the policy. 80 | existing.Policy = value 81 | return oldPolicy, true 82 | } 83 | // There's an existing event with the same state key, but the entity changed, remove the old node. 84 | l.removeFromLinkedList(existing) 85 | if existing.Entity != "" { 86 | delete(l.byEntity, existing.Entity) 87 | } 88 | if existing.EntityHash != nil { 89 | delete(l.byEntityHash, *existing.EntityHash) 90 | } 91 | } 92 | node := &dplNode{Policy: value} 93 | l.byStateKey[value.StateKey] = node 94 | if !value.Ignored { 95 | if value.Entity != "" { 96 | l.byEntity[value.Entity] = node 97 | } 98 | if value.EntityHash != nil { 99 | l.byEntityHash[*value.EntityHash] = node 100 | } 101 | } 102 | if _, isStatic := value.Pattern.(glob.ExactGlob); value.Entity != "" && !isStatic && !value.Ignored { 103 | if l.dynamicHead != nil { 104 | node.next = l.dynamicHead 105 | l.dynamicHead.prev = node 106 | } 107 | l.dynamicHead = node 108 | } 109 | if existing != nil { 110 | return existing.Policy, true 111 | } 112 | return nil, true 113 | } 114 | 115 | func (l *List) Remove(eventType event.Type, stateKey string) *Policy { 116 | l.lock.Lock() 117 | defer l.lock.Unlock() 118 | if value, ok := l.byStateKey[stateKey]; ok && eventType == value.Type { 119 | l.removeFromLinkedList(value) 120 | if entValue, ok := l.byEntity[value.Entity]; ok && entValue == value && value.Entity != "" { 121 | delete(l.byEntity, value.Entity) 122 | } 123 | if value.EntityHash != nil { 124 | if entHashValue, ok := l.byEntityHash[*value.EntityHash]; ok && entHashValue == value { 125 | delete(l.byEntityHash, *value.EntityHash) 126 | } 127 | } 128 | delete(l.byStateKey, stateKey) 129 | return value.Policy 130 | } 131 | return nil 132 | } 133 | 134 | var matchDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 135 | Name: "meowlnir_policylist_match_duration_nanoseconds", 136 | Help: "Time taken to evaluate an entity against all policies", 137 | Buckets: []float64{ 138 | // 1µs - 100µs 139 | 1_000, 5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 140 | // 250µs - 10ms 141 | 250_000, 500_000, 750_000, 1_000_000, 5_000_000, 10_000_000, 142 | }, 143 | }, []string{"policy_list", "entity_type"}) 144 | 145 | func (l *List) Match(entity string) (output Match) { 146 | if entity == "" { 147 | return 148 | } 149 | l.lock.RLock() 150 | defer l.lock.RUnlock() 151 | start := time.Now() 152 | exactMatch, ok := l.byEntity[entity] 153 | if ok { 154 | output = Match{exactMatch.Policy} 155 | } 156 | if value, ok := l.byEntityHash[util.SHA256String(entity)]; ok { 157 | output = append(output, value.Policy) 158 | } 159 | for item := l.dynamicHead; item != nil; item = item.next { 160 | if !item.Ignored && item.Pattern.Match(entity) && item != exactMatch { 161 | output = append(output, item.Policy) 162 | } 163 | } 164 | l.matchDuration.Observe(float64(time.Since(start))) 165 | return 166 | } 167 | 168 | func (l *List) MatchExact(entity string) (output Match) { 169 | if entity == "" { 170 | return 171 | } 172 | l.lock.RLock() 173 | defer l.lock.RUnlock() 174 | if value, ok := l.byEntity[entity]; ok { 175 | output = Match{value.Policy} 176 | } 177 | if value, ok := l.byEntityHash[util.SHA256String(entity)]; ok { 178 | output = append(output, value.Policy) 179 | } 180 | return 181 | } 182 | 183 | func (l *List) MatchHash(hash [util.HashSize]byte) (output Match) { 184 | l.lock.RLock() 185 | defer l.lock.RUnlock() 186 | if value, ok := l.byEntityHash[hash]; ok { 187 | output = Match{value.Policy} 188 | } 189 | return 190 | } 191 | 192 | func (l *List) Search(patternString string, pattern glob.Glob) (output Match) { 193 | l.lock.RLock() 194 | defer l.lock.RUnlock() 195 | for _, item := range l.byStateKey { 196 | if !item.Ignored && (pattern.Match(item.EntityOrHash()) || item.Pattern.Match(patternString)) { 197 | output = append(output, item.Policy) 198 | } 199 | } 200 | return 201 | } 202 | -------------------------------------------------------------------------------- /policylist/policy.go: -------------------------------------------------------------------------------- 1 | package policylist 2 | 3 | import ( 4 | "go.mau.fi/util/glob" 5 | "maunium.net/go/mautrix/event" 6 | "maunium.net/go/mautrix/id" 7 | 8 | "go.mau.fi/meowlnir/util" 9 | ) 10 | 11 | // Policy represents a single moderation policy event with the relevant data parsed out. 12 | type Policy struct { 13 | *event.ModPolicyContent 14 | Pattern glob.Glob 15 | EntityHash *[util.HashSize]byte 16 | 17 | EntityType EntityType 18 | RoomID id.RoomID 19 | StateKey string 20 | Sender id.UserID 21 | Type event.Type 22 | Timestamp int64 23 | ID id.EventID 24 | Ignored bool 25 | } 26 | 27 | // Match represent a list of policies that matched a specific entity. 28 | type Match []*Policy 29 | 30 | type Recommendations struct { 31 | BanOrUnban *Policy 32 | } 33 | 34 | func (r Recommendations) String() string { 35 | if r.BanOrUnban != nil { 36 | return string(r.BanOrUnban.Recommendation) 37 | } 38 | return "" 39 | } 40 | 41 | // Recommendations aggregates the recommendations in the match. 42 | func (m Match) Recommendations() (output Recommendations) { 43 | for _, policy := range m { 44 | switch policy.Recommendation { 45 | case event.PolicyRecommendationBan, event.PolicyRecommendationUnban, event.PolicyRecommendationUnstableTakedown: 46 | if output.BanOrUnban == nil { 47 | output.BanOrUnban = policy 48 | } 49 | } 50 | } 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /policylist/room.go: -------------------------------------------------------------------------------- 1 | package policylist 2 | 3 | import ( 4 | "slices" 5 | "sync" 6 | 7 | "go.mau.fi/util/glob" 8 | "maunium.net/go/mautrix/event" 9 | "maunium.net/go/mautrix/id" 10 | 11 | "go.mau.fi/meowlnir/util" 12 | ) 13 | 14 | type typeStateKeyTuple struct { 15 | Type event.Type 16 | StateKey string 17 | } 18 | 19 | // Room represents a single moderation policy room and all the policies inside it. 20 | type Room struct { 21 | RoomID id.RoomID 22 | UserRules *List 23 | RoomRules *List 24 | ServerRules *List 25 | mapLock sync.RWMutex 26 | byEventID map[id.EventID]typeStateKeyTuple 27 | } 28 | 29 | // NewRoom creates a new store for a single policy room. 30 | func NewRoom(roomID id.RoomID) *Room { 31 | return &Room{ 32 | RoomID: roomID, 33 | UserRules: NewList(roomID, "user"), 34 | RoomRules: NewList(roomID, "room"), 35 | ServerRules: NewList(roomID, "server"), 36 | byEventID: make(map[id.EventID]typeStateKeyTuple), 37 | } 38 | } 39 | 40 | func (r *Room) GetUserRules() *List { 41 | return r.UserRules 42 | } 43 | 44 | func (r *Room) GetRoomRules() *List { 45 | return r.RoomRules 46 | } 47 | 48 | func (r *Room) GetServerRules() *List { 49 | return r.ServerRules 50 | } 51 | 52 | type EntityType string 53 | 54 | func (et EntityType) EventType() event.Type { 55 | switch et { 56 | case EntityTypeUser: 57 | return event.StatePolicyUser 58 | case EntityTypeRoom: 59 | return event.StatePolicyRoom 60 | case EntityTypeServer: 61 | return event.StatePolicyServer 62 | } 63 | return event.Type{} 64 | } 65 | 66 | const ( 67 | EntityTypeUser EntityType = "user" 68 | EntityTypeRoom EntityType = "room" 69 | EntityTypeServer EntityType = "server" 70 | ) 71 | 72 | // Update updates the state of this object with the given policy event. 73 | // 74 | // It returns the added and removed/replaced policies, if any. 75 | func (r *Room) Update(evt *event.Event) (added, removed *Policy) { 76 | if r == nil || evt.RoomID != r.RoomID { 77 | return 78 | } 79 | switch evt.Type { 80 | case event.StatePolicyUser, event.StateLegacyPolicyUser, event.StateUnstablePolicyUser: 81 | added, removed = r.updatePolicyList(evt, EntityTypeUser, r.UserRules) 82 | case event.StatePolicyRoom, event.StateLegacyPolicyRoom, event.StateUnstablePolicyRoom: 83 | added, removed = r.updatePolicyList(evt, EntityTypeRoom, r.RoomRules) 84 | case event.StatePolicyServer, event.StateLegacyPolicyServer, event.StateUnstablePolicyServer: 85 | added, removed = r.updatePolicyList(evt, EntityTypeServer, r.ServerRules) 86 | case event.EventRedaction: 87 | redacts := evt.Redacts 88 | if redacts == "" { 89 | redacts = evt.Content.AsRedaction().Redacts 90 | } 91 | r.mapLock.RLock() 92 | target, ok := r.byEventID[redacts] 93 | r.mapLock.RUnlock() 94 | if ok { 95 | switch target.Type { 96 | case event.StatePolicyUser, event.StateLegacyPolicyUser, event.StateUnstablePolicyUser: 97 | removed = r.UserRules.Remove(target.Type, target.StateKey) 98 | case event.StatePolicyRoom, event.StateLegacyPolicyRoom, event.StateUnstablePolicyRoom: 99 | removed = r.RoomRules.Remove(target.Type, target.StateKey) 100 | case event.StatePolicyServer, event.StateLegacyPolicyServer, event.StateUnstablePolicyServer: 101 | removed = r.ServerRules.Remove(target.Type, target.StateKey) 102 | } 103 | } 104 | } 105 | if added != removed && removed != nil { 106 | removed.Sender = evt.Sender 107 | } 108 | return 109 | } 110 | 111 | // ParseState updates the state of this object with the given state events. 112 | func (r *Room) ParseState(state map[event.Type]map[string]*event.Event) *Room { 113 | userPolicies := mergeUnstableEvents(state[event.StatePolicyUser], state[event.StateLegacyPolicyUser], state[event.StateUnstablePolicyUser]) 114 | roomPolicies := mergeUnstableEvents(state[event.StatePolicyRoom], state[event.StateLegacyPolicyRoom], state[event.StateUnstablePolicyRoom]) 115 | serverPolicies := mergeUnstableEvents(state[event.StatePolicyServer], state[event.StateLegacyPolicyServer], state[event.StateUnstablePolicyServer]) 116 | r.massUpdatePolicyList(userPolicies, EntityTypeUser, r.UserRules) 117 | r.massUpdatePolicyList(roomPolicies, EntityTypeRoom, r.RoomRules) 118 | r.massUpdatePolicyList(serverPolicies, EntityTypeServer, r.ServerRules) 119 | return r 120 | } 121 | 122 | func mergeUnstableEvents(into map[string]*event.Event, sources ...map[string]*event.Event) (output map[string]*event.Event) { 123 | output = into 124 | if output == nil { 125 | output = make(map[string]*event.Event) 126 | } 127 | for _, source := range sources { 128 | for key, evt := range source { 129 | if _, ok := output[key]; !ok { 130 | output[key] = evt 131 | } 132 | } 133 | } 134 | return output 135 | } 136 | 137 | func (r *Room) massUpdatePolicyList(input map[string]*event.Event, entityType EntityType, rules *List) { 138 | for _, evt := range input { 139 | r.updatePolicyList(evt, entityType, rules) 140 | } 141 | } 142 | 143 | var HackyRuleFilter []string 144 | var HackyRuleFilterHashes [][util.HashSize]byte 145 | 146 | type hashGlob [util.HashSize]byte 147 | 148 | func (hg *hashGlob) Match(entity string) bool { 149 | return util.SHA256String(entity) == *hg 150 | } 151 | 152 | func (r *Room) updatePolicyList(evt *event.Event, entityType EntityType, rules *List) (added, removed *Policy) { 153 | content, ok := evt.Content.Parsed.(*event.ModPolicyContent) 154 | if !ok || evt.StateKey == nil { 155 | return 156 | } 157 | r.mapLock.Lock() 158 | r.byEventID[evt.ID] = typeStateKeyTuple{Type: evt.Type, StateKey: *evt.StateKey} 159 | r.mapLock.Unlock() 160 | var entityHash *[util.HashSize]byte 161 | if content.Entity == "" && content.UnstableHashes != nil && len(content.UnstableHashes.SHA256) == util.Base64SHA256Length { 162 | entityHash, _ = util.DecodeBase64Hash(content.UnstableHashes.SHA256) 163 | } 164 | if (content.Entity == "" && entityHash == nil) || content.Recommendation == "" { 165 | removed = rules.Remove(evt.Type, *evt.StateKey) 166 | return 167 | } 168 | if content.Recommendation == event.PolicyRecommendationUnstableBan { 169 | content.Recommendation = event.PolicyRecommendationBan 170 | } 171 | added = &Policy{ 172 | ModPolicyContent: content, 173 | Pattern: glob.Compile(content.Entity), 174 | EntityHash: entityHash, 175 | EntityType: entityType, 176 | RoomID: evt.RoomID, 177 | StateKey: *evt.StateKey, 178 | Sender: evt.Sender, 179 | Type: evt.Type, 180 | Timestamp: evt.Timestamp, 181 | ID: evt.ID, 182 | } 183 | if entityHash != nil { 184 | added.Pattern = (*hashGlob)(entityHash) 185 | } 186 | if added.Recommendation == event.PolicyRecommendationBan { 187 | if added.EntityHash != nil { 188 | added.Ignored = slices.Contains(HackyRuleFilterHashes, *added.EntityHash) 189 | } else { 190 | for _, entry := range HackyRuleFilter { 191 | if added.Pattern.Match(entry) { 192 | added.Ignored = true 193 | } 194 | } 195 | } 196 | } 197 | var wasAdded bool 198 | removed, wasAdded = rules.Add(added) 199 | if !wasAdded { 200 | added = nil 201 | } 202 | return 203 | } 204 | -------------------------------------------------------------------------------- /policylist/store.go: -------------------------------------------------------------------------------- 1 | package policylist 2 | 3 | import ( 4 | "maps" 5 | "regexp" 6 | "slices" 7 | "sync" 8 | 9 | "go.mau.fi/util/glob" 10 | "maunium.net/go/mautrix/event" 11 | "maunium.net/go/mautrix/id" 12 | 13 | "go.mau.fi/meowlnir/util" 14 | ) 15 | 16 | // Store is a collection of policy rooms that allows matching users, rooms, and servers 17 | // against the policies of any subset of rooms in the store. 18 | type Store struct { 19 | rooms map[id.RoomID]*Room 20 | roomsLock sync.RWMutex 21 | } 22 | 23 | // NewStore creates a new policy list store. 24 | func NewStore() *Store { 25 | return &Store{ 26 | rooms: make(map[id.RoomID]*Room), 27 | } 28 | } 29 | 30 | // MatchUser finds all matching policies for the given user ID in the given policy rooms. 31 | func (s *Store) MatchUser(listIDs []id.RoomID, userID id.UserID) Match { 32 | return s.match(listIDs, string(userID), (*Room).GetUserRules) 33 | } 34 | 35 | // MatchRoom finds all matching policies for the given room ID in the given policy rooms. 36 | // If no matches are found, nil is returned. 37 | func (s *Store) MatchRoom(listIDs []id.RoomID, roomID id.RoomID) Match { 38 | return s.match(listIDs, string(roomID), (*Room).GetRoomRules) 39 | } 40 | 41 | var portRegex = regexp.MustCompile(`:\d+$`) 42 | var ipRegex = regexp.MustCompile(`^(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(?:\[[0-9a-fA-F:.]+\])$`) 43 | var fakeBanForIPLiterals = &Policy{ 44 | ModPolicyContent: &event.ModPolicyContent{ 45 | Recommendation: event.PolicyRecommendationBan, 46 | Entity: "IP literal", 47 | Reason: "IP literals are not allowed", 48 | }, 49 | EntityType: EntityTypeServer, 50 | } 51 | 52 | func CleanupServerNameForMatch(serverName string) string { 53 | return portRegex.ReplaceAllString(serverName, "") 54 | } 55 | 56 | func IsIPLiteral(serverName string) bool { 57 | return ipRegex.MatchString(serverName) 58 | } 59 | 60 | // MatchServer finds all matching policies for the given server name in the given policy rooms. 61 | func (s *Store) MatchServer(listIDs []id.RoomID, serverName string) Match { 62 | serverName = CleanupServerNameForMatch(serverName) 63 | if IsIPLiteral(serverName) { 64 | return Match{fakeBanForIPLiterals} 65 | } 66 | return s.match(listIDs, serverName, (*Room).GetServerRules) 67 | } 68 | 69 | func (s *Store) ListServerRules(listIDs []id.RoomID) map[string]*Policy { 70 | return s.compileList(listIDs, (*Room).GetServerRules) 71 | } 72 | 73 | // Update updates the store with the given policy event. 74 | // 75 | // The provided event will be ignored if it belongs to a room that is not tracked by this store, 76 | // is not a moderation policy event, or is not a state event. 77 | // 78 | // If the event doesn't have the `entity` and `recommendation` fields set, 79 | // it will be treated as removing the current policy. 80 | // 81 | // The added and removed/replaced policies (if any) are returned 82 | func (s *Store) Update(evt *event.Event) (added, removed *Policy) { 83 | switch evt.Type { 84 | case event.StatePolicyUser, event.StateLegacyPolicyUser, event.StateUnstablePolicyUser, 85 | event.StatePolicyRoom, event.StateLegacyPolicyRoom, event.StateUnstablePolicyRoom, 86 | event.StatePolicyServer, event.StateLegacyPolicyServer, event.StateUnstablePolicyServer, 87 | event.EventRedaction: 88 | default: 89 | return 90 | } 91 | s.roomsLock.RLock() 92 | list, ok := s.rooms[evt.RoomID] 93 | s.roomsLock.RUnlock() 94 | if !ok { 95 | return 96 | } 97 | return list.Update(evt) 98 | } 99 | 100 | // Add adds a room to the store with the given state. 101 | // 102 | // This will always replace the existing state for the given room, even if it already exists. 103 | // 104 | // To ensure the store doesn't contain partial state, the store is locked for the duration of the parsing. 105 | func (s *Store) Add(roomID id.RoomID, state map[event.Type]map[string]*event.Event) { 106 | s.roomsLock.Lock() 107 | s.rooms[roomID] = NewRoom(roomID).ParseState(state) 108 | s.roomsLock.Unlock() 109 | } 110 | 111 | func (s *Store) Contains(roomID id.RoomID) bool { 112 | s.roomsLock.RLock() 113 | _, ok := s.rooms[roomID] 114 | s.roomsLock.RUnlock() 115 | return ok 116 | } 117 | 118 | func (s *Store) match(listIDs []id.RoomID, entity string, listGetter func(*Room) *List) (output Match) { 119 | if listIDs == nil { 120 | s.roomsLock.Lock() 121 | listIDs = slices.Collect(maps.Keys(s.rooms)) 122 | s.roomsLock.Unlock() 123 | } 124 | for _, roomID := range listIDs { 125 | s.roomsLock.RLock() 126 | list, ok := s.rooms[roomID] 127 | s.roomsLock.RUnlock() 128 | if !ok { 129 | continue 130 | } 131 | rules := listGetter(list) 132 | output = append(output, rules.Match(entity)...) 133 | } 134 | return 135 | } 136 | 137 | func (s *Store) matchExactFunc(listIDs []id.RoomID, entityType EntityType, fn func(*List) Match) (output Match) { 138 | if listIDs == nil { 139 | s.roomsLock.Lock() 140 | listIDs = slices.Collect(maps.Keys(s.rooms)) 141 | s.roomsLock.Unlock() 142 | } 143 | for _, roomID := range listIDs { 144 | s.roomsLock.RLock() 145 | list, ok := s.rooms[roomID] 146 | s.roomsLock.RUnlock() 147 | if !ok { 148 | continue 149 | } 150 | var rules *List 151 | switch entityType { 152 | case EntityTypeUser: 153 | rules = list.GetUserRules() 154 | case EntityTypeRoom: 155 | rules = list.GetRoomRules() 156 | case EntityTypeServer: 157 | rules = list.GetServerRules() 158 | } 159 | output = append(output, fn(rules)...) 160 | } 161 | return 162 | } 163 | 164 | func (s *Store) MatchExact(listIDs []id.RoomID, entityType EntityType, entity string) (output Match) { 165 | return s.matchExactFunc(listIDs, entityType, func(list *List) Match { 166 | return list.MatchExact(entity) 167 | }) 168 | } 169 | 170 | func (s *Store) MatchHash(listIDs []id.RoomID, entityType EntityType, entity [util.HashSize]byte) (output Match) { 171 | return s.matchExactFunc(listIDs, entityType, func(list *List) Match { 172 | return list.MatchHash(entity) 173 | }) 174 | } 175 | 176 | func (s *Store) Search(listIDs []id.RoomID, entity string) (output Match) { 177 | if listIDs == nil { 178 | s.roomsLock.Lock() 179 | listIDs = slices.Collect(maps.Keys(s.rooms)) 180 | s.roomsLock.Unlock() 181 | } 182 | entityGlob := glob.Compile(entity) 183 | for _, roomID := range listIDs { 184 | s.roomsLock.RLock() 185 | list, ok := s.rooms[roomID] 186 | s.roomsLock.RUnlock() 187 | if !ok { 188 | continue 189 | } 190 | output = append(output, list.GetUserRules().Search(entity, entityGlob)...) 191 | output = append(output, list.GetRoomRules().Search(entity, entityGlob)...) 192 | output = append(output, list.GetServerRules().Search(entity, entityGlob)...) 193 | } 194 | return 195 | } 196 | 197 | func (s *Store) compileList(listIDs []id.RoomID, listGetter func(*Room) *List) (output map[string]*Policy) { 198 | output = make(map[string]*Policy) 199 | // Iterate the list backwards so that entries in higher priority lists overwrite lower priority ones 200 | for _, roomID := range slices.Backward(listIDs) { 201 | s.roomsLock.RLock() 202 | list, ok := s.rooms[roomID] 203 | s.roomsLock.RUnlock() 204 | if !ok { 205 | continue 206 | } 207 | rules := listGetter(list) 208 | rules.lock.RLock() 209 | for _, policy := range rules.byEntity { 210 | output[policy.Entity] = policy.Policy 211 | } 212 | rules.lock.RUnlock() 213 | } 214 | return 215 | } 216 | -------------------------------------------------------------------------------- /synapsedb/db.go: -------------------------------------------------------------------------------- 1 | package synapsedb 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/lib/pq" 8 | "github.com/rs/zerolog" 9 | "go.mau.fi/util/dbutil" 10 | "go.mau.fi/util/exslices" 11 | "maunium.net/go/mautrix/event" 12 | "maunium.net/go/mautrix/id" 13 | ) 14 | 15 | type SynapseDB struct { 16 | DB *dbutil.Database 17 | } 18 | 19 | const MaxPreferredVersion = 92 20 | const MinPreferredVersion = 88 21 | 22 | func (s *SynapseDB) CheckVersion(ctx context.Context) error { 23 | var current, compat int 24 | err := s.DB.QueryRow(ctx, "SELECT version FROM schema_version").Scan(¤t) 25 | if err != nil { 26 | return err 27 | } 28 | err = s.DB.QueryRow(ctx, "SELECT compat_version FROM schema_compat_version").Scan(&compat) 29 | if err != nil { 30 | return err 31 | } 32 | if current < MinPreferredVersion { 33 | zerolog.Ctx(ctx).Warn(). 34 | Int("min_preferred_version", MinPreferredVersion). 35 | Int("current_version", current). 36 | Int("current_compat_version", compat). 37 | Msg("Synapse database schema is older than expected") 38 | } else if compat > MaxPreferredVersion { 39 | zerolog.Ctx(ctx).Warn(). 40 | Int("min_preferred_version", MaxPreferredVersion). 41 | Int("current_version", current). 42 | Int("current_compat_version", compat). 43 | Msg("Synapse database schema is newer than expected") 44 | } 45 | return nil 46 | } 47 | 48 | const getUnredactedEventsBySenderInRoomQuery = ` 49 | SELECT events.room_id, events.event_id, events.origin_server_ts 50 | FROM events 51 | LEFT JOIN redactions ON events.event_id=redactions.redacts 52 | WHERE events.sender = $1 AND events.room_id = ANY($2) AND redactions.redacts IS NULL 53 | ` 54 | 55 | const getEventQuery = ` 56 | SELECT events.room_id, sender, type, state_key, origin_server_ts, json 57 | FROM events 58 | LEFT JOIN event_json ON events.event_id=event_json.event_id 59 | WHERE events.event_id = $1 60 | ` 61 | 62 | const getAllRoomIDsQuery = `SELECT room_id FROM rooms` 63 | 64 | type roomEventTuple struct { 65 | RoomID id.RoomID 66 | EventID id.EventID 67 | Timestamp int64 68 | } 69 | 70 | var scanRoomEventTuple = dbutil.ConvertRowFn[roomEventTuple](func(row dbutil.Scannable) (t roomEventTuple, err error) { 71 | err = row.Scan(&t.RoomID, &t.EventID, &t.Timestamp) 72 | return 73 | }) 74 | 75 | func (s *SynapseDB) GetEventsToRedact(ctx context.Context, sender id.UserID, inRooms []id.RoomID) (map[id.RoomID][]id.EventID, time.Time, error) { 76 | output := make(map[id.RoomID][]id.EventID) 77 | var maxTSRaw int64 78 | err := scanRoomEventTuple.NewRowIter( 79 | s.DB.Query(ctx, getUnredactedEventsBySenderInRoomQuery, sender, pq.Array(exslices.CastToString[string](inRooms))), 80 | ).Iter(func(tuple roomEventTuple) (bool, error) { 81 | output[tuple.RoomID] = append(output[tuple.RoomID], tuple.EventID) 82 | maxTSRaw = max(maxTSRaw, tuple.Timestamp) 83 | return true, nil 84 | }) 85 | return output, time.UnixMilli(maxTSRaw), err 86 | } 87 | 88 | func (s *SynapseDB) GetEvent(ctx context.Context, eventID id.EventID) (*event.Event, error) { 89 | var evt event.Event 90 | evt.ID = eventID 91 | // TODO get redaction event? 92 | err := s.DB.QueryRow(ctx, getEventQuery, eventID). 93 | Scan(&evt.RoomID, &evt.Sender, &evt.Type.Type, &evt.StateKey, &evt.Timestamp, dbutil.JSON{Data: &evt}) 94 | if err != nil { 95 | return nil, err 96 | } 97 | evt.Type.Class = event.MessageEventType 98 | if evt.StateKey != nil { 99 | evt.Type.Class = event.StateEventType 100 | } 101 | return &evt, nil 102 | } 103 | 104 | var roomIDScanner = dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) 105 | 106 | func (s *SynapseDB) GetAllRooms(ctx context.Context) dbutil.RowIter[id.RoomID] { 107 | return roomIDScanner.NewRowIter(s.DB.Query(ctx, getAllRoomIDsQuery)) 108 | } 109 | 110 | func (s *SynapseDB) Close() error { 111 | return s.DB.Close() 112 | } 113 | -------------------------------------------------------------------------------- /util/hash.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | 7 | "go.mau.fi/util/exstrings" 8 | ) 9 | 10 | const HashSize = sha256.Size 11 | 12 | var Base64SHA256Length = base64.StdEncoding.EncodedLen(HashSize) 13 | 14 | func SHA256String[T ~string](entity T) [HashSize]byte { 15 | return sha256.Sum256(exstrings.UnsafeBytes(string(entity))) 16 | } 17 | 18 | func DecodeBase64Hash(hash string) (*[HashSize]byte, bool) { 19 | if len(hash) != Base64SHA256Length { 20 | return nil, false 21 | } 22 | decoded, err := base64.StdEncoding.DecodeString(hash) 23 | if err != nil { 24 | return nil, false 25 | } 26 | return (*[HashSize]byte)(decoded), true 27 | } 28 | --------------------------------------------------------------------------------