diff --git a/.claude/prompts/01-notification-counts.md b/.claude/prompts/01-notification-counts.md new file mode 100644 index 0000000..db38b04 --- /dev/null +++ b/.claude/prompts/01-notification-counts.md @@ -0,0 +1,47 @@ +Add notification counting for events + +- spec states "The updated notification count from a new event MUST appear in the same /sync response as the event itself." +- store as rooms.users.notificationVersions (userid, roomid, version) -> types.Notifications{notifs, highlights, ...} +- store types.Notifications as msgpack with single letter keys +- for every event send we must (SendLocalEvents, SendFederatedEvents): + - get local users in the room + - get all their push rules (stub these for now), before any write txn + - inside the txn for each event, eval each users rules, map eventsToUserNotifications[id.EventID]types.Notifications{} + - pass to txnStoreEvents, we apply notificationVersions (userid, roomid, eventVersion) -> types.Notifications{} +- version is the event version (so can get back to the eventid) +- on sync, just + - range notificationVersions (userid, roomid) => sum counts +- on receipt just + - clearrange up to (userid, roomid, eventVersionFromReceipt) + +Explore the codebase and come up with a plan to implement the above changes. + +... implemented, second prompt: + +Now we need to implement an EventNotificationIterator to compact notificationVersions: + +- compact notificationVersions by aggregating old -> new (userid, roomid, version) +- just iter events constantly compact + - so should just be merging 2 -> 1 constantly, unless falls behind +- note: in future this worker will also actually turn each (unaggregated) notification into an actual notification for each of the users configured push targets + +Explore the codebase and come up with a plan to implement the above changes. + +... implemented, third prompt + +Let's extend notification counts to handle threads. We need to: + +- add ThreadID to types.Notifications (already done) +- in eventsend.go, we: + - move the notification generation into a new read txn, just before each write txn + - include threadID in generated notifications, this is: + - "" if event has no relation + - $event_id of thread root (found by walking thread relations of m.thread type until no more) +- we need two ways to sum notifications: + - the current one is fine for non-threading clients + - new sum by threadID version +- update sync + - add SyncOption to enable threaded notification counts + - when set, sum by threadID and update sync response accordingly + +Explore the codebase and come up with a plan to implement the above changes. diff --git a/.claude/prompts/02-push-rules.md b/.claude/prompts/02-push-rules.md new file mode 100644 index 0000000..831b6bd --- /dev/null +++ b/.claude/prompts/02-push-rules.md @@ -0,0 +1,55 @@ +Add push rules to the accounts database + +- store Matrix push rules in a new directory in the accounts database, keys: + - userPushRules (userID, groupName, kind, ruleID) -> partial mautrix.PushRule + - kind is one of: override, underride, sender, room, content + - userPushVersions (userID) -> versionstamp of last written rule +- new methods: + - AccountsDatabase.GetRulesForUser + - AccountsDatabase.GetRuleForUser + - AccountsDatabase.PutRuleForUser + - AccountsDatabase.DeleteRuleForUser +- sync must return all the users push rules if the userPushVersion > the sync token as m.push_rules account data event + +Explore the codebase and come up with a plan to implement the above changes. + +... implemented, second prompt: + +Now we need to evaluate the push rules during event sending. + +- add databases.SendLocalEvents which calls rooms.SendLocalEvents + - modify rooms.SendLocalEvents to take userid -> pushrules map + - databases.SendLocalEvents fetches local users in room -> makes the map + - rooms.SendLocalEvents then uses push rules for evaluation +- same for databases.SendFederatedEvents -> rooms.SendFederatedEvents + +Explore the codebase and come up with a plan to implement. + +... implemented, second prompt: + +Now we need to implement Matrix pushers APIs: + +- store Matrix pushers (mautrix pushgateway.Pusher) for users in UsersDirectory + - userPushers subspace (userID, pushKey) -> pushgateway.Pusher + - methods: + - AccountsDatabase.GetPushersForUser + - AccountsDatabase.SetPusherForUser + +Explore the codebase with a few agents (databases, routes) and come up with a plan to implement. + +... implemented, second prompt: + +Finally, now that we've implemented the various push components, let's actually send some push notifications! + +- we're going to base this on the CompactNotificationIterator, which is currently disabled +- to prevent the notifications keyspace growing indefinitely (UsersDirectory.notificationVersions), we add a configurable limit to the number of notifications per user/room to keep, this worker will handle deleting the oldest N to maintain the limit (this means read receipt accuracy over the most recent X events per room) +- let's call it PushNotificationIterator, it now has two responsibilities: + - as events come in, send pushes as required + - remove old notification count keys +- to implement this, for every event that comes in: + - fetch all the local users in the room + - for each user, fetch push notification keys up to (including) the event version + - if notification was generated for this event (ie userid/roomid/eventversion notification exists), fetch users pushers and send it to each in parallel + - delete oldest push notifications > the configurable limit + +Explore the codebase with a few agents (databases, routes) and come up with a plan to implement. diff --git a/.gitignore b/.gitignore index a826a8b..236426a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ ed25519-* *.pem babbleserv external/ -.claude/ +.claude/settings.local.json +*.frpc diff --git a/docker/complement-tests.list b/docker/complement-tests.list index d874163..33bc3dd 100644 --- a/docker/complement-tests.list +++ b/docker/complement-tests.list @@ -13,6 +13,8 @@ TestKeysQueryWithDeviceIDAsObjectFails TestNotPresentUserCannotBanOthers TestPresence TestPresenceSyncDifferentRooms +TestPushRuleCacheHealth +TestPushSync TestRoomAlias TestRoomCreate TestRoomReceipts @@ -21,6 +23,7 @@ TestServerCapabilities TestSync TestSyncFilter TestSyncOmitsStateChangeOnFilteredEvents +TestThreadedReceipts TestToDeviceMessages TestUploadKey TestUploadKeyIdempotency diff --git a/docs/data-flows.md b/docs/data-flows.md index 22cc1d8..0911d4f 100644 --- a/docs/data-flows.md +++ b/docs/data-flows.md @@ -5,11 +5,17 @@ Some high level flow charts describing how routes, databases and workers interac ## Room Events ``` + ┌─────────────────────────────┐ ┌──────────────────────────┐ + │ │ │ │ + │ CompactNotificationIterator │ │ PushNotificationIterator │ + │ │ │ │ + └─────────────────────────────┘ └──────────────────────────┘ + ┌───────────────────┐ ┌──────────────────────────┐ │ │ │ │ ┌─────────────►│ FederationRoutes ├────►│ RoomsDatabase │◄────────────────┐ │ │ │ │ - SendLocalEvents │ │ - │ └───────────────────┘ │ - SendFederatedEvents │ send events + │ └───────────────────┘ │ - SendFederatedEvents │ send events │ │ │ │ │ └───────────┬──────────────┘ │ │ │ │ @@ -23,7 +29,7 @@ Federation Transaction PDUs │ │ │ │ └────────────────────┘ Federation outgoing events ◄────────────────────┤ FederationSender │ │ (per server) │ - └────────────────────────┘ + └────────────────────────┘ ``` ## Key & device management (user xs keys, device list updates) @@ -37,20 +43,20 @@ Federation outgoing events ◄──────────────── │ │ user send events ┌────────┼──────────┐ ┌───────────────────┐ │ │ │ │ │ │ │ │ - ┌─────────────►│ FederationRoutes ├─►│ AccountsDatabase │◄───┼───────────────┐ │ - │ │ │ │ │ │ │ │ - │ └───────────────────┘ └──────┬────────────┘ │ user upload keys │ - │ │ member events │ │ - │ │ │ │ │ - │ │device change │ │ │ - │ │ │ │ │ -Federation Transaction EDUs │ │ │ │ - │ │ │ │ - ┌──────────────▼─────────┐ ┌─────▼──────────┐ ┌──┴─────────────┴───┐ - │ │ │ │ │ │ - │ DeviceChangeIterator │ │ EventsIterator │ │ ClientRoutes │ - │ │ │ │ │ │ -Federation outgoing └─────────────┬──────────┘ └┬───────────────┘ └────────────────────┘ + ┌─────────────►│ FederationRoutes ├─►│ AccountsDatabase │◄───┼──────────────────┐ │ + │ │ │ │ │ │ │ │ + │ └───────────────────┘ └──────┬────────────┘ │ user upload keys │ + │ │ member events │ │ + │ │ │ │ │ + │ │device change │ │ │ + │ │ │ │ │ +Federation Transaction EDUs │ │ │ │ + │ │ │ │ + ┌───────────────────▼────┐ ┌──────────▼─────────────┐ ┌──┴──────────┴──────┐ + │ │ │ │ │ │ + │ DeviceChangeIterator │ │DeviceJoinEventIterator │ │ ClientRoutes │ + │ │ │ │ │ │ +Federation outgoing └──────────────────┬─────┘ └─────┬──────────────────┘ └────────────────────┘ - m.device_list_update │ │ ▲ - m.signing_key_update │ to-device │ │ ▲ │ │ │ diff --git a/docs/matrix-spec-compatibility.md b/docs/matrix-spec-compatibility.md index 9649892..42d2ad9 100644 --- a/docs/matrix-spec-compatibility.md +++ b/docs/matrix-spec-compatibility.md @@ -6,7 +6,7 @@ This document explores Babbleserv's compatability (or not) with the Matrix speci These seem incredibly expensive to calculate for little benefit - clients must still implement all of their own aggregation logic because servers cannot guarantee their own aggregations are correct [citation needed]. So what's the point. -Note: backfilling still presents an issue here, but the `/reations` and threads APIs are supported and are more suitable for gathering this information. +Note: backfilling still presents an issue here, but the `/reations` and threads APIs _will be_ supported and are more suitable for gathering this information. - see: [MSC2675 limitations](https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/2675-aggregations-server.md#limitations), also see [MSC2677 (reactions) explicitly states server should NOT aggregate](https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/2677-reactions.md#server-side-aggregation-of-mannotation-relationships), despite MSC2575 recommending this exact thing - note that this also means edits are not applied by the server, clients should (and do) handle these appropriately - from the server perspective events are immutable unless redacted @@ -40,20 +40,20 @@ Cheap alternative: ## Linearized Matrix -See [MSC3995](https://github.com/matrix-org/matrix-spec-proposals/pull/3995) - Babbleserv's data model means that within the local database state is always resolved before storage. There may be multiple dangling events in a room but the current state is always a resolved state in those cases. As such in many ways Babbleserv is similar to linearized Matrix hub servers. Events will be synced in version order, always. The `prev_events` are only relevant when ingesting events over Federation. +See [MSC3995](https://github.com/matrix-org/matrix-spec-proposals/pull/3995) - Babbleserv's data model means that within the local database state is always resolved before storage. There may be multiple dangling events in a room but the current state is always a resolved state in those cases. As such in many ways Babbleserv is similar to linearized Matrix hub servers. Events will be synced in version order, always. ## No Reactions in Relations API The `/relations` API will not return `m.annotation` evens unless the `rel_type` is explicitly specified (and only `m.annotation` events are returned). -## Push Rules +## Profile Updates and Device List Changes are Asynchronous -Not implemented. +Request to update/change will return before the changes are applied. Does this even deviate from the spec? -## Profile Updates are NOT Considered Room State +## Push Rules/Notifications/Notification Counts -Deviates from the spec. Synthetic events used. +Not implemented yet. -## Profile Updates and Device List Changes are Asynchronous +## Device last_seen and last_seen_ip aren't populated -Request to update/change will return before the changes are applied. Does this even deviate from the spec? +Not implemented yet. diff --git a/docs/project-structure.md b/docs/project-structure.md index 93519d5..757ef24 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -21,15 +21,22 @@ Ingesting federated events is a good example of this - all the network fetching ## Module Layout +Babbleserv is roughly divided in three: + +- databases talk to FoundationDB, no access to network/federation, implements Matrix spec on top of the databases (event auth, state res) using FDB transactions +- routes implement the client/federation Matrix APIs, pre-fetch anything before passing to relevant database call +- workers handle asynchronous tasks after database changes (federation outgoing, profile updates, presence, push notifications) + ### `internal/databases/*/` - each represents a FDB cluster containing a logical group of sub-databases - top level database transactions called by routes - call through to the domain specific directories nested modules +- each database lives under a key prefix #### `internal/databases/*/*/` -- individual database "directories" (FDB thing) +- individual database "directories" (key prefix) - group together common key prefix operations (ie events, users) - not exported/available outside of database diff --git a/internal/config/config.go b/internal/config/config.go index 955ec6e..cc94e8b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,14 +38,24 @@ type BabbleConfig struct { SigningKeyRefreshInterval time.Duration `yaml:"signingKeyRefreshInterval"` Rooms struct { - Enabled bool `yaml:"enabled"` Database databaseConfig `yaml:"database"` Notifier NotifierConfig `yaml:"notifier"` DefaultVersion string `yaml:"defaultVersion"` + + // Max notifications per user/room to keep count of, is not accurately applied, ie counts + // may go over this before being trimmed back down after the timeout below or sufficient + // traffic in the room. + // Default: 100 + MaxNotificationsPerUserRoom int `yaml:"maxNotificationsPerUserRoom"` + + // Timeout after which we compact a rooms notifications even if less than max notifications + // have been sent. This accounts for process restarts - the notification compactor stores + // events sent per room in memory only. + // Default: 3h + CompactRoomNotificationsTimeout time.Duration `yaml:"compactRoomNotificationsTimeout"` } `yaml:"rooms"` Accounts struct { - Enabled bool `yaml:"enabled"` Database databaseConfig `yaml:"database"` Notifier NotifierConfig `yaml:"notifier"` @@ -59,7 +69,6 @@ type BabbleConfig struct { } `yaml:"accounts"` Transient struct { - Enabled bool `yaml:"enabled"` Database databaseConfig `yaml:"database"` Notifier NotifierConfig `yaml:"notifier"` @@ -85,9 +94,6 @@ type BabbleConfig struct { Servers []serverConfig `yaml:"servers"` } `yaml:"routes"` - Workers struct { - } `yaml:"workers"` - Federation struct { MaxFetchMissingEvents int `yaml:"maxFetchMissingEvents"` FetchProfileForMemberEvents bool `yaml:"fetchProfileForMemberEvents"` @@ -159,6 +165,13 @@ func NewBabbleConfig(filename string, commitHash string) BabbleConfig { cfg.Transient.PresenceTimeoutCheckInterval = time.Minute } + if cfg.Rooms.MaxNotificationsPerUserRoom == 0 { + cfg.Rooms.MaxNotificationsPerUserRoom = 100 + } + if cfg.Rooms.CompactRoomNotificationsTimeout == 0 { + cfg.Rooms.CompactRoomNotificationsTimeout = 3 * time.Hour + } + return cfg } diff --git a/internal/databases/accounts/accounts.go b/internal/databases/accounts/accounts.go index 012f0a7..8477314 100644 --- a/internal/databases/accounts/accounts.go +++ b/internal/databases/accounts/accounts.go @@ -12,6 +12,7 @@ import ( "github.com/beeper/babbleserv/internal/config" "github.com/beeper/babbleserv/internal/databases/accounts/accountdata" "github.com/beeper/babbleserv/internal/databases/accounts/devices" + "github.com/beeper/babbleserv/internal/databases/accounts/pushrules" "github.com/beeper/babbleserv/internal/databases/accounts/tokens" "github.com/beeper/babbleserv/internal/databases/accounts/users" "github.com/beeper/babbleserv/internal/notifier" @@ -30,6 +31,7 @@ type AccountsDatabase struct { tokens *tokens.TokensDirectory devices *devices.DevicesDirectory accountdata *accountdata.AccountDataDirectory + pushrules *pushrules.PushRulesDirectory } func NewAccountsDatabase( @@ -65,10 +67,11 @@ func NewAccountsDatabase( config: cfg, notifier: notifier, - users: users.NewUsersDirectory(cfg, log, db, accountsDir), + users: users.NewUsersDirectory(log, db, accountsDir, cfg.ServerName), tokens: tokens.NewTokensDirectory(log, db, accountsDir), devices: devices.NewDevicesDirectory(log, db, accountsDir), accountdata: accountdata.NewAccountDataDirectory(log, db, accountsDir), + pushrules: pushrules.NewPushRulesDirectory(log, db, accountsDir), } } diff --git a/internal/databases/accounts/devices/devices.go b/internal/databases/accounts/devices/devices.go index 67fd5e9..3b6bdca 100644 --- a/internal/databases/accounts/devices/devices.go +++ b/internal/databases/accounts/devices/devices.go @@ -114,7 +114,7 @@ func (d *DevicesDirectory) TxnGetDevice(txn fdb.ReadTransaction, userID id.UserI } func (d *DevicesDirectory) TxnStoreDevice(txn fdb.Transaction, userID id.UserID, device *types.Device) { - txn.Set(d.keyForDevice(userID, device.ID), device.ToMsgpack()) + txn.Set(d.keyForDevice(userID, device.ID), device.ToBytes()) } func (d *DevicesDirectory) TxnGetOrCreateDevice(txn fdb.Transaction, userID id.UserID, deviceID id.DeviceID, initialDisplayName string) (*types.Device, error) { diff --git a/internal/databases/accounts/pushers.go b/internal/databases/accounts/pushers.go new file mode 100644 index 0000000..d508005 --- /dev/null +++ b/internal/databases/accounts/pushers.go @@ -0,0 +1,44 @@ +package accounts + +import ( + "context" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules/pushgateway" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +func (a *AccountsDatabase) GetPushersForUser( + ctx context.Context, + userID id.UserID, +) ([]pushgateway.Pusher, error) { + return util.DoReadTransaction(ctx, a.db, func(txn fdb.ReadTransaction) ([]pushgateway.Pusher, error) { + return a.users.TxnGetPushersForUser(txn, userID) + }) +} + +func (a *AccountsDatabase) SetPusherForUser( + ctx context.Context, + userID id.UserID, + pusher *pushgateway.Pusher, +) error { + _, err := util.DoWriteTransaction(ctx, a.db, func(txn fdb.Transaction) (types.Nil, error) { + return nil, a.users.TxnSetPusherForUser(txn, userID, pusher) + }) + return err +} + +func (a *AccountsDatabase) DeletePusherForUser( + ctx context.Context, + userID id.UserID, + pushKey string, +) error { + _, err := util.DoWriteTransaction(ctx, a.db, func(txn fdb.Transaction) (types.Nil, error) { + a.users.TxnDeletePusherForUser(txn, userID, pushKey) + return nil, nil + }) + return err +} diff --git a/internal/databases/accounts/pushrules.go b/internal/databases/accounts/pushrules.go new file mode 100644 index 0000000..e25a88b --- /dev/null +++ b/internal/databases/accounts/pushrules.go @@ -0,0 +1,64 @@ +package accounts + +import ( + "context" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" + + "github.com/beeper/babbleserv/internal/notifier" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +func (a *AccountsDatabase) GetPushRulesForUser(ctx context.Context, userID id.UserID) (*pushrules.PushRuleset, error) { + return util.DoReadTransaction(ctx, a.db, func(txn fdb.ReadTransaction) (*pushrules.PushRuleset, error) { + return a.pushrules.TxnGetRulesForUser(txn, userID) + }) +} + +func (a *AccountsDatabase) GetPushRulesForUserByKind(ctx context.Context, userID id.UserID, kind pushrules.PushRuleType) ([]*pushrules.PushRule, error) { + return util.DoReadTransaction(ctx, a.db, func(txn fdb.ReadTransaction) ([]*pushrules.PushRule, error) { + return a.pushrules.TxnGetRulesForUserByKind(txn, userID, kind) + }) +} + +func (a *AccountsDatabase) GetPushRuleForUser(ctx context.Context, userID id.UserID, kind pushrules.PushRuleType, ruleID string) (*pushrules.PushRule, error) { + return util.DoReadTransaction(ctx, a.db, func(txn fdb.ReadTransaction) (*pushrules.PushRule, error) { + return a.pushrules.TxnGetRuleForUser(txn, userID, kind, ruleID) + }) +} + +func (a *AccountsDatabase) PutPushRuleForUser(ctx context.Context, userID id.UserID, kind pushrules.PushRuleType, ruleID string, rule *types.StoredPushRule) error { + _, err := util.DoWriteTransactionWithVersion(ctx, a.db, func(txn fdb.Transaction) (types.Nil, error) { + a.pushrules.TxnPutRuleForUser(txn, userID, kind, ruleID, rule) + return nil, nil + }) + if err == nil { + a.notifier.SendChange(notifier.Change{ + UserIDs: []id.UserID{userID}, + }) + } + return err +} + +func (a *AccountsDatabase) DeletePushRuleForUser(ctx context.Context, userID id.UserID, kind pushrules.PushRuleType, ruleID string) error { + _, err := util.DoWriteTransactionWithVersion(ctx, a.db, func(txn fdb.Transaction) (types.Nil, error) { + a.pushrules.TxnDeleteRuleForUser(txn, userID, kind, ruleID) + return nil, nil + }) + if err == nil { + a.notifier.SendChange(notifier.Change{ + UserIDs: []id.UserID{userID}, + }) + } + return err +} + +func (a *AccountsDatabase) GetUserPushRulesVersion(ctx context.Context, userID id.UserID) (tuple.Versionstamp, error) { + return util.DoReadTransaction(ctx, a.db, func(txn fdb.ReadTransaction) (tuple.Versionstamp, error) { + return a.pushrules.TxnGetUserPushVersion(txn, userID), nil + }) +} diff --git a/internal/databases/accounts/pushrules/defaults.go b/internal/databases/accounts/pushrules/defaults.go new file mode 100644 index 0000000..c35ffec --- /dev/null +++ b/internal/databases/accounts/pushrules/defaults.go @@ -0,0 +1,209 @@ +package pushrules + +import ( + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +// DefaultPushRuleset returns the predefined push rules as specified in the Matrix spec. +// https://spec.matrix.org/v1.13/client-server-api/#predefined-rules +func DefaultPushRuleset(userID id.UserID) *pushrules.PushRuleset { + // Common action arrays + notifyDefault := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "default"}, + } + notifyHighlight := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, + } + notifyHighlightDefault := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "default"}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakHighlight}, + } + notifyRing := pushrules.PushActionArray{ + {Action: pushrules.ActionNotify}, + {Action: pushrules.ActionSetTweak, Tweak: pushrules.TweakSound, Value: "ring"}, + } + dontNotify := pushrules.PushActionArray{} + + return &pushrules.PushRuleset{ + Override: pushrules.PushRuleArray{ + // .m.rule.master - disables all notifications when enabled (disabled by default) + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.master", + Default: true, + Enabled: false, + Actions: dontNotify, + }, + // .m.rule.suppress_notices - suppress notifications for m.notice messages + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.suppress_notices", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "content.msgtype", Pattern: "m.notice"}, + }, + Actions: dontNotify, + }, + // .m.rule.invite_for_me - notify with sound when receiving room invitation + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.invite_for_me", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.member"}, + {Kind: pushrules.KindEventMatch, Key: "content.membership", Pattern: "invite"}, + {Kind: pushrules.KindEventMatch, Key: "state_key", Pattern: string(userID)}, + }, + Actions: notifyDefault, + }, + // .m.rule.member_event - suppress notifications for all membership events + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.member_event", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.member"}, + }, + Actions: dontNotify, + }, + // .m.rule.is_user_mention - notify with sound and highlight when mentioned + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.is_user_mention", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventPropertyContains, Key: "content.m\\.mentions.user_ids", Value: string(userID)}, + }, + Actions: notifyHighlightDefault, + }, + // .m.rule.is_room_mention - notify with highlight for room mentions + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.is_room_mention", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventPropertyIs, Key: "content.m\\.mentions.room", Value: true}, + {Kind: pushrules.KindSenderNotificationPermission, Key: "room"}, + }, + Actions: notifyHighlight, + }, + // .m.rule.tombstone - notify with highlight when room is upgraded + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.tombstone", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.tombstone"}, + {Kind: pushrules.KindEventMatch, Key: "state_key", Pattern: ""}, + }, + Actions: notifyHighlight, + }, + // .m.rule.reaction - suppress notifications for reactions + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.reaction", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.reaction"}, + }, + Actions: dontNotify, + }, + // .m.rule.room.server_acl - suppress notifications for server ACL events + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.room.server_acl", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.server_acl"}, + {Kind: pushrules.KindEventMatch, Key: "state_key", Pattern: ""}, + }, + Actions: dontNotify, + }, + // .m.rule.suppress_edits - suppress notifications for message edits + { + Type: pushrules.OverrideRule, + RuleID: ".m.rule.suppress_edits", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventPropertyIs, Key: "content.m\\.relates_to.rel_type", Value: "m.replace"}, + }, + Actions: dontNotify, + }, + }, + Content: pushrules.PushRuleArray{}, + Room: pushrules.PushRuleMap{Map: make(map[string]*pushrules.PushRule), Type: pushrules.RoomRule}, + Sender: pushrules.PushRuleMap{Map: make(map[string]*pushrules.PushRule), Type: pushrules.SenderRule}, + Underride: pushrules.PushRuleArray{ + // .m.rule.call - notify with ring sound for incoming calls + { + Type: pushrules.UnderrideRule, + RuleID: ".m.rule.call", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.call.invite"}, + }, + Actions: notifyRing, + }, + // .m.rule.encrypted_room_one_to_one - notify for encrypted messages in DMs + { + Type: pushrules.UnderrideRule, + RuleID: ".m.rule.encrypted_room_one_to_one", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindRoomMemberCount, MemberCountCondition: "2"}, + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.encrypted"}, + }, + Actions: notifyDefault, + }, + // .m.rule.room_one_to_one - notify for messages in DMs + { + Type: pushrules.UnderrideRule, + RuleID: ".m.rule.room_one_to_one", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindRoomMemberCount, MemberCountCondition: "2"}, + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.message"}, + }, + Actions: notifyDefault, + }, + // .m.rule.message - notify for all messages + { + Type: pushrules.UnderrideRule, + RuleID: ".m.rule.message", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.message"}, + }, + Actions: pushrules.PushActionArray{{Action: pushrules.ActionNotify}}, + }, + // .m.rule.encrypted - notify for all encrypted messages in group rooms + { + Type: pushrules.UnderrideRule, + RuleID: ".m.rule.encrypted", + Default: true, + Enabled: true, + Conditions: []*pushrules.PushCondition{ + {Kind: pushrules.KindEventMatch, Key: "type", Pattern: "m.room.encrypted"}, + }, + Actions: pushrules.PushActionArray{{Action: pushrules.ActionNotify}}, + }, + }, + } +} diff --git a/internal/databases/accounts/pushrules/pushrules.go b/internal/databases/accounts/pushrules/pushrules.go new file mode 100644 index 0000000..ee9e8ea --- /dev/null +++ b/internal/databases/accounts/pushrules/pushrules.go @@ -0,0 +1,238 @@ +package pushrules + +import ( + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/directory" + "github.com/apple/foundationdb/bindings/go/src/fdb/subspace" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" + + "github.com/beeper/babbleserv/internal/types" +) + +type PushRulesDirectory struct { + log zerolog.Logger + db fdb.Database + + // Push rules storage + // key: (id.UserID, Scope, Kind, RuleID) + // value: types.StoredPushRule + // Scope is "global" in Matrix spec (reserved for future scopes) + // Kind is one of: override, content, room, sender, underride + userPushRules subspace.Subspace + + // Version tracking for sync + // key: (id.UserID) + // value: tuple.Versionstamp + userPushVersions subspace.Subspace +} + +func NewPushRulesDirectory(logger zerolog.Logger, db fdb.Database, parentDir directory.Directory) *PushRulesDirectory { + pushRulesDir, err := parentDir.CreateOrOpen(db, []string{"pushrules"}, nil) + if err != nil { + panic(err) + } + + log := logger.With().Str("directory", "pushrules").Logger() + log.Debug(). + Bytes("prefix", pushRulesDir.Bytes()). + Msg("Init accounts/pushrules directory") + + return &PushRulesDirectory{ + log: log, + db: db, + + userPushRules: pushRulesDir.Sub("upr"), + userPushVersions: pushRulesDir.Sub("upv"), + } +} + +func (p *PushRulesDirectory) keyForRule(userID id.UserID, scope string, kind pushrules.PushRuleType, ruleID string) fdb.Key { + return p.userPushRules.Pack(tuple.Tuple{userID.String(), scope, string(kind), ruleID}) +} + +func (p *PushRulesDirectory) keyForUserPushVersion(userID id.UserID) fdb.Key { + return p.userPushVersions.Pack(tuple.Tuple{userID.String()}) +} + +func (p *PushRulesDirectory) rangeForUserRules(userID id.UserID, scope string) fdb.ExactRange { + return p.userPushRules.Sub(userID.String(), scope) +} + +func (p *PushRulesDirectory) rangeForUserKindRules(userID id.UserID, scope string, kind pushrules.PushRuleType) fdb.ExactRange { + return p.userPushRules.Sub(userID.String(), scope, string(kind)) +} + +func (p *PushRulesDirectory) TxnGetRulesForUser(txn fdb.ReadTransaction, userID id.UserID) (*pushrules.PushRuleset, error) { + rng := p.rangeForUserRules(userID, "global") + iter := txn.GetRange(rng, fdb.RangeOptions{Mode: fdb.StreamingModeWantAll}).Iterator() + + // Start with default rules + ruleset := DefaultPushRuleset(userID) + + for iter.Advance() { + kv, err := iter.Get() + if err != nil { + return nil, err + } + + // Unpack the key to get kind and ruleID + tup, err := p.userPushRules.Unpack(kv.Key) + if err != nil { + return nil, err + } + // tup is (userID, scope, kind, ruleID) + kind := pushrules.PushRuleType(tup[2].(string)) + ruleID := tup[3].(string) + + storedRule := types.MustNewStoredPushRuleFromBytes(kv.Value) + rule := storedRule.ToPushRule(kind, ruleID) + + switch kind { + case pushrules.OverrideRule: + ruleset.Override = mergeRule(ruleset.Override, rule) + case pushrules.ContentRule: + ruleset.Content = mergeRule(ruleset.Content, rule) + case pushrules.RoomRule: + ruleset.Room.Map[ruleID] = rule + case pushrules.SenderRule: + ruleset.Sender.Map[ruleID] = rule + case pushrules.UnderrideRule: + ruleset.Underride = mergeRule(ruleset.Underride, rule) + } + } + + return ruleset, nil +} + +// mergeRule updates an existing rule if it exists, otherwise appends the rule +func mergeRule(rules pushrules.PushRuleArray, rule *pushrules.PushRule) pushrules.PushRuleArray { + for i, existing := range rules { + if existing.RuleID == rule.RuleID { + rules[i] = rule + return rules + } + } + return append(rules, rule) +} + +func (p *PushRulesDirectory) TxnGetRulesForUserByKind(txn fdb.ReadTransaction, userID id.UserID, kind pushrules.PushRuleType) ([]*pushrules.PushRule, error) { + rng := p.rangeForUserKindRules(userID, "global", kind) + iter := txn.GetRange(rng, fdb.RangeOptions{Mode: fdb.StreamingModeWantAll}).Iterator() + + // Start with default rules for this kind + defaultRuleset := DefaultPushRuleset(userID) + var rules pushrules.PushRuleArray + switch kind { + case pushrules.OverrideRule: + rules = defaultRuleset.Override + case pushrules.ContentRule: + rules = defaultRuleset.Content + case pushrules.RoomRule: + rules = defaultRuleset.Room.Unmap() + case pushrules.SenderRule: + rules = defaultRuleset.Sender.Unmap() + case pushrules.UnderrideRule: + rules = defaultRuleset.Underride + default: + rules = make(pushrules.PushRuleArray, 0) + } + + for iter.Advance() { + kv, err := iter.Get() + if err != nil { + return nil, err + } + + // Unpack the key to get ruleID + tup, err := p.userPushRules.Unpack(kv.Key) + if err != nil { + return nil, err + } + // tup is (userID, scope, kind, ruleID) + ruleID := tup[3].(string) + + storedRule := types.MustNewStoredPushRuleFromBytes(kv.Value) + rules = mergeRule(rules, storedRule.ToPushRule(kind, ruleID)) + } + + return rules, nil +} + +func (p *PushRulesDirectory) TxnGetRuleForUser(txn fdb.ReadTransaction, userID id.UserID, kind pushrules.PushRuleType, ruleID string) (*pushrules.PushRule, error) { + key := p.keyForRule(userID, "global", kind, ruleID) + value := txn.Get(key).MustGet() + if value == nil { + // No user-defined rule, check for default rule + return getDefaultRule(userID, kind, ruleID), nil + } + + storedRule := types.MustNewStoredPushRuleFromBytes(value) + return storedRule.ToPushRule(kind, ruleID), nil +} + +func getDefaultRule(userID id.UserID, kind pushrules.PushRuleType, ruleID string) *pushrules.PushRule { + defaultRuleset := DefaultPushRuleset(userID) + var rules pushrules.PushRuleArray + switch kind { + case pushrules.OverrideRule: + rules = defaultRuleset.Override + case pushrules.ContentRule: + rules = defaultRuleset.Content + case pushrules.RoomRule: + if rule, ok := defaultRuleset.Room.Map[ruleID]; ok { + return rule + } + return nil + case pushrules.SenderRule: + if rule, ok := defaultRuleset.Sender.Map[ruleID]; ok { + return rule + } + return nil + case pushrules.UnderrideRule: + rules = defaultRuleset.Underride + default: + return nil + } + + for _, rule := range rules { + if rule.RuleID == ruleID { + return rule + } + } + return nil +} + +func (p *PushRulesDirectory) TxnPutRuleForUser(txn fdb.Transaction, userID id.UserID, kind pushrules.PushRuleType, ruleID string, rule *types.StoredPushRule) { + key := p.keyForRule(userID, "global", kind, ruleID) + txn.Set(key, rule.ToBytes()) + + // Update the user's push rules version + p.txnUpdateUserPushVersion(txn, userID) +} + +func (p *PushRulesDirectory) TxnDeleteRuleForUser(txn fdb.Transaction, userID id.UserID, kind pushrules.PushRuleType, ruleID string) { + key := p.keyForRule(userID, "global", kind, ruleID) + txn.Clear(key) + + // Update the user's push rules version + p.txnUpdateUserPushVersion(txn, userID) +} + +func (p *PushRulesDirectory) txnUpdateUserPushVersion(txn fdb.Transaction, userID id.UserID) { + key := p.keyForUserPushVersion(userID) + version := tuple.IncompleteVersionstamp(0) + versionBytes := types.MustVersionstampToBytes(version) + txn.SetVersionstampedValue(key, versionBytes) +} + +func (p *PushRulesDirectory) TxnGetUserPushVersion(txn fdb.ReadTransaction, userID id.UserID) tuple.Versionstamp { + key := p.keyForUserPushVersion(userID) + value := txn.Get(key).MustGet() + if value == nil { + return types.ZeroVersionstamp + } + return types.MustBytesToVersionstamp(value) +} diff --git a/internal/databases/accounts/sync.go b/internal/databases/accounts/sync.go index a267a23..f34a8f8 100644 --- a/internal/databases/accounts/sync.go +++ b/internal/databases/accounts/sync.go @@ -7,6 +7,7 @@ import ( "github.com/apple/foundationdb/bindings/go/src/fdb" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" @@ -17,9 +18,10 @@ func (a *AccountsDatabase) SyncAccountsForuser( userID id.UserID, fromVersion tuple.Versionstamp, options types.SyncOptions, -) (tuple.Versionstamp, map[types.AccountDataTup]map[string]any, error) { +) (tuple.Versionstamp, map[types.AccountDataTup]map[string]any, *pushrules.PushRuleset, error) { var ads map[types.AccountDataTup]map[string]any var latestVersion tuple.Versionstamp + var ruleset *pushrules.PushRuleset _, err := util.DoReadTransaction(ctx, a.db, func(txn fdb.ReadTransaction) (types.Nil, error) { latestVersion = util.TxnGetLatestWriteVersion(txn) @@ -33,10 +35,7 @@ func (a *AccountsDatabase) SyncAccountsForuser( ads = make(map[types.AccountDataTup]map[string]any, 10) for iter.Advance() { - kv, err := iter.Get() - if err != nil { - return nil, err - } + kv := iter.MustGet() ad, err := types.BytesToAccountData(kv.Value) if err != nil { return nil, err @@ -48,8 +47,25 @@ func (a *AccountsDatabase) SyncAccountsForuser( ads[ad.AccountDataTup] = data } + var fetchRules bool + if fromVersion == types.ZeroVersionstamp { + fetchRules = true + } else { + pushVersion := a.pushrules.TxnGetUserPushVersion(txn, userID) + if types.VersionIsAfter(pushVersion, fromVersion) && types.VersionIsAtOrBefore(pushVersion, latestVersion) { + fetchRules = true + } + } + if fetchRules { + rules, err := a.pushrules.TxnGetRulesForUser(txn, userID) + if err != nil { + return nil, err + } + ruleset = rules + } + return nil, nil }) - return latestVersion, ads, err + return latestVersion, ads, ruleset, err } diff --git a/internal/databases/accounts/users/filters.go b/internal/databases/accounts/users/filters.go index df5646e..27e6244 100644 --- a/internal/databases/accounts/users/filters.go +++ b/internal/databases/accounts/users/filters.go @@ -25,7 +25,7 @@ func (u *UsersDirectory) keyForUserFilter(username string, version tuple.Version } func (u *UsersDirectory) TxnGetUserFilter(txn fdb.ReadTransaction, userID id.UserID, version tuple.Versionstamp) (*mautrix.Filter, error) { - if userID.Homeserver() != u.config.ServerName { + if userID.Homeserver() != u.serverName { return nil, fmt.Errorf("userid is not local: %s", userID) } @@ -46,7 +46,7 @@ func (u *UsersDirectory) TxnGetUserFilter(txn fdb.ReadTransaction, userID id.Use } func (u *UsersDirectory) TxnStoreUserFilter(txn fdb.Transaction, userID id.UserID, filter mautrix.Filter, version tuple.Versionstamp) error { - if userID.Homeserver() != u.config.ServerName { + if userID.Homeserver() != u.serverName { return fmt.Errorf("userid is not local: %s", userID) } diff --git a/internal/databases/accounts/users/pushers.go b/internal/databases/accounts/users/pushers.go new file mode 100644 index 0000000..bcca298 --- /dev/null +++ b/internal/databases/accounts/users/pushers.go @@ -0,0 +1,51 @@ +package users + +import ( + "encoding/json" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules/pushgateway" +) + +func (u *UsersDirectory) keyForUserPusher(userID id.UserID, pushKey string) fdb.Key { + return u.userPushers.Pack(tuple.Tuple{userID.String(), pushKey}) +} + +func (u *UsersDirectory) RangeForUserPushers(userID id.UserID) fdb.ExactRange { + return u.userPushers.Sub(userID.String()) +} + +func (u *UsersDirectory) TxnGetPushersForUser(txn fdb.ReadTransaction, userID id.UserID) ([]pushgateway.Pusher, error) { + iter := txn.GetRange( + u.RangeForUserPushers(userID), + fdb.RangeOptions{Mode: fdb.StreamingModeWantAll}, + ).Iterator() + + pushers := make([]pushgateway.Pusher, 0) + for iter.Advance() { + kv := iter.MustGet() + var pusher pushgateway.Pusher + if err := json.Unmarshal(kv.Value, &pusher); err != nil { + return nil, err + } + pushers = append(pushers, pusher) + } + return pushers, nil +} + +func (u *UsersDirectory) TxnSetPusherForUser(txn fdb.Transaction, userID id.UserID, pusher *pushgateway.Pusher) error { + key := u.keyForUserPusher(userID, pusher.PushKey) + value, err := json.Marshal(pusher) + if err != nil { + return err + } + txn.Set(key, value) + return nil +} + +func (u *UsersDirectory) TxnDeletePusherForUser(txn fdb.Transaction, userID id.UserID, pushKey string) { + key := u.keyForUserPusher(userID, pushKey) + txn.Clear(key) +} diff --git a/internal/databases/accounts/users/users.go b/internal/databases/accounts/users/users.go index 7147697..436f445 100644 --- a/internal/databases/accounts/users/users.go +++ b/internal/databases/accounts/users/users.go @@ -10,14 +10,13 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix/id" - "github.com/beeper/babbleserv/internal/config" "github.com/beeper/babbleserv/internal/types" ) type UsersDirectory struct { - log zerolog.Logger - db fdb.Database - config config.BabbleConfig + log zerolog.Logger + db fdb.Database + serverName string /// version -> user index // @@ -81,13 +80,19 @@ type UsersDirectory struct { // key: (Username, Versionstamp) // value: matruix.Filter userFilters subspace.Subspace + + // User push notification endpoints (pushers) + // + // key: (id.UserID, pushKey) + // value: pushgateway.Pusher (JSON) + userPushers subspace.Subspace } func NewUsersDirectory( - cfg config.BabbleConfig, logger zerolog.Logger, db fdb.Database, parentDir directory.Directory, + serverName string, ) *UsersDirectory { usersDir, err := parentDir.CreateOrOpen(db, []string{"users"}, nil) if err != nil { @@ -100,9 +105,9 @@ func NewUsersDirectory( Msg("Init accounts/users directory") return &UsersDirectory{ - log: log, - db: db, - config: cfg, + log: log, + db: db, + serverName: serverName, byVersion: usersDir.Sub("uvr"), localUsers: usersDir.Sub("unm"), @@ -113,6 +118,7 @@ func NewUsersDirectory( userCrossSigningKeys: usersDir.Sub("uxs"), userKeySignatures: usersDir.Sub("uks"), userFilters: usersDir.Sub("ufl"), + userPushers: usersDir.Sub("upk"), } } @@ -122,7 +128,7 @@ func (u *UsersDirectory) TxnGetLocalUserPasswordHash(txn fdb.ReadTransaction, us } func (u *UsersDirectory) keyForUser(userID id.UserID) fdb.Key { - if userID.Homeserver() == u.config.ServerName { + if userID.Homeserver() == u.serverName { return u.localUsers.Pack(tuple.Tuple{userID.String()}) } return u.remoteUsers.Pack(tuple.Tuple{userID.String()}) @@ -137,14 +143,14 @@ func (u *UsersDirectory) keyForUserVersion(version tuple.Versionstamp) fdb.Key { } func (u *UsersDirectory) TxnGetLocalUser(txn fdb.ReadTransaction, userID id.UserID) (*types.User, error) { - if userID.Homeserver() != u.config.ServerName { + if userID.Homeserver() != u.serverName { return nil, fmt.Errorf("userid is not local: %s", userID) } return u.txnGetUser(txn, userID) } func (u *UsersDirectory) TxnGetRemoteUser(txn fdb.ReadTransaction, userID id.UserID) (*types.User, error) { - if userID.Homeserver() == u.config.ServerName { + if userID.Homeserver() == u.serverName { return nil, fmt.Errorf("userid is not remote: %s", userID) } return u.txnGetUser(txn, userID) @@ -163,7 +169,7 @@ func (u *UsersDirectory) txnGetUser(txn fdb.ReadTransaction, userID id.UserID) ( func (u *UsersDirectory) TxnCreateLocalUser(txn fdb.Transaction, user *types.User, hashedPassword []byte) error { userID := user.UserID() - if userID.Homeserver() != u.config.ServerName { + if userID.Homeserver() != u.serverName { return fmt.Errorf("userid is not local: %s", userID) } @@ -202,7 +208,7 @@ func (u *UsersDirectory) TxnIncrementUserDeviceListVersion(txn fdb.Transaction, if err != nil { return nil } else if user == nil { - if userID.Homeserver() == u.config.ServerName { + if userID.Homeserver() == u.serverName { return fmt.Errorf("user not found for local userid: %s", userID) } diff --git a/internal/databases/databases.go b/internal/databases/databases.go index 6afb289..a5b7698 100644 --- a/internal/databases/databases.go +++ b/internal/databases/databases.go @@ -22,7 +22,8 @@ import ( ) type Databases struct { - log zerolog.Logger + log zerolog.Logger + config config.BabbleConfig Rooms *rooms.RoomsDatabase Accounts *accounts.AccountsDatabase @@ -41,20 +42,16 @@ func NewDatabases( Logger() dbs := Databases{ - log: log, + log: log, + config: cfg, System: system.NewSystemDatabase(cfg, log), } - if cfg.Rooms.Enabled { - dbs.Rooms = rooms.NewRoomsDatabase(cfg, log, notifiers.Rooms) - } - if cfg.Accounts.Enabled { - dbs.Accounts = accounts.NewAccountsDatabase(cfg, log, notifiers.Accounts) - } - if cfg.Transient.Enabled { - dbs.Transient = transient.NewTransientDatabase(cfg, log, notifiers.Transient) - } + dbs.Rooms = rooms.NewRoomsDatabase(cfg, log, notifiers.Rooms) + dbs.Accounts = accounts.NewAccountsDatabase(cfg, log, notifiers.Accounts) + dbs.Transient = transient.NewTransientDatabase(cfg, log, notifiers.Transient) + if cfg.Media.Enabled { dbs.Media = media.NewMediaDatabase(cfg, log) } diff --git a/internal/databases/eventsend.go b/internal/databases/eventsend.go new file mode 100644 index 0000000..5330631 --- /dev/null +++ b/internal/databases/eventsend.go @@ -0,0 +1,89 @@ +package databases + +import ( + "context" + + "maunium.net/go/mautrix/id" + + "github.com/rs/zerolog" + + "github.com/beeper/babbleserv/internal/databases/rooms" + "github.com/beeper/babbleserv/internal/types" +) + +// Wrapper around rooms.SendLocalEvents that pre-fetches local user push rules and room context +func (d *Databases) SendLocalEvents( + ctx context.Context, + roomID id.RoomID, + partialEvs []*types.PartialEvent, + options rooms.SendLocalEventsOptions, +) (*rooms.SendEventsResult, error) { + return d.sendEventsFunc(ctx, roomID, func(userPushRules types.UserPushRulesMap, userRoomContext types.UserRoomContextMap) (*rooms.SendEventsResult, error) { + return d.Rooms.SendLocalEvents(ctx, roomID, partialEvs, userPushRules, userRoomContext, options) + }) +} + +// Wrapper around rooms.SendFederatedEvents that pre-fetches local user push rules and room context +func (d *Databases) SendFederatedEvents( + ctx context.Context, + roomID id.RoomID, + evs []*types.Event, + options rooms.SendFederatedEventsOptions, +) (*rooms.SendEventsResult, error) { + return d.sendEventsFunc(ctx, roomID, func(userPushRules types.UserPushRulesMap, userRoomContext types.UserRoomContextMap) (*rooms.SendEventsResult, error) { + return d.Rooms.SendFederatedEvents(ctx, roomID, evs, userPushRules, userRoomContext, options) + }) +} + +func (d *Databases) sendEventsFunc( + ctx context.Context, + roomID id.RoomID, + fn func(types.UserPushRulesMap, types.UserRoomContextMap) (*rooms.SendEventsResult, error), +) (*rooms.SendEventsResult, error) { + // Get room for member count - this means the member count for push rules does *not* consider + // any member events in the batch being persisted. + room, err := d.Rooms.GetRoom(ctx, roomID) + if err != nil { + return nil, err + } + + var memberCount int + if room != nil { + memberCount = room.MemberCount + } + + // Get local users in room + memberships, err := d.Rooms.GetCurrentRoomLocalJoinedMemberships(ctx, roomID) + if err != nil { + return nil, err + } + + // Build push rules map for each local user + userPushRules := make(types.UserPushRulesMap, len(memberships)) + userRoomContext := make(types.UserRoomContextMap, len(memberships)) + + for userID := range memberships { + // TODO: GetRulesForUsers in parallel + ruleset, err := d.Accounts.GetPushRulesForUser(ctx, userID) + if err != nil { + return nil, err + } + + context := &types.PushRuleRoom{ + MemberCount: memberCount, + OwnDisplayname: userID.String(), + } + + // TODO: GetProfilesForUsers in parallel + if profile, err := d.Accounts.GetUserProfile(ctx, userID); err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get user profile, falling back to userID") + } else if profile != nil && profile.DisplayName != "" { + context.OwnDisplayname = profile.DisplayName + } + + userPushRules[userID] = ruleset + userRoomContext[userID] = context + } + + return fn(userPushRules, userRoomContext) +} diff --git a/internal/databases/rooms/events/events.go b/internal/databases/rooms/events/events.go index 778a441..52a3758 100644 --- a/internal/databases/rooms/events/events.go +++ b/internal/databases/rooms/events/events.go @@ -440,11 +440,5 @@ func (e *EventsDirectory) KeyForRoomReaction(roomID id.RoomID, relEvID id.EventI } func (e *EventsDirectory) KeyForRoomThread(roomID id.RoomID, version tuple.Versionstamp) fdb.Key { - if key, err := e.roomThreadVersionToID.PackWithVersionstamp(tuple.Tuple{ - roomID.String(), version, - }); err != nil { - panic(err) - } else { - return key - } + return e.roomThreadVersionToID.Pack(tuple.Tuple{roomID.String(), version}) } diff --git a/internal/databases/rooms/eventsend.go b/internal/databases/rooms/eventsend.go index c914a6c..78566e0 100644 --- a/internal/databases/rooms/eventsend.go +++ b/internal/databases/rooms/eventsend.go @@ -35,6 +35,8 @@ func (r *RoomsDatabase) SendLocalEvents( ctx context.Context, roomID id.RoomID, partialEvs []*types.PartialEvent, + userPushRules types.UserPushRulesMap, + userRoomContext types.UserRoomContextMap, options SendLocalEventsOptions, ) (*SendEventsResult, error) { lock, _ := r.roomLocks.GetOrSet(roomID, &sync.Mutex{}) @@ -54,15 +56,22 @@ func (r *RoomsDatabase) SendLocalEvents( return nil, err } - allowedEvs, rejectedEvs, err := r.txnPrepareLocalEvents(ctx, txn, room, partialEvs, options) + eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) + + allowedEvs, rejectedEvs, err := r.txnPrepareLocalEvents(ctx, txn, room, partialEvs, eventsProvider, options) if err != nil { return nil, err } + // Get local users in the room and evaluate notifications for each event + eventNotifications := txnEvaluateNotificationsForEvents( + txn, eventsProvider, allowedEvs, userPushRules, userRoomContext, + ) + changedUsers := make(map[id.UserID]struct{}, 1) changedServers := make(map[string]struct{}, 1) - if !r.txnStoreEvents(ctx, txn, room, allowedEvs, changedUsers, changedServers) { + if !r.txnStoreEvents(ctx, txn, room, allowedEvs, changedUsers, changedServers, eventNotifications) { log.Warn().Msg("No events stored in send transaction") } @@ -100,12 +109,14 @@ func (r *RoomsDatabase) PrepareLocalEvents(ctx context.Context, roomID id.RoomID var rejected []RejectedEvent _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { + eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) + room, err := r.txnGetOrCreateRoomForEvents(txn, roomID, partialEvs) if err != nil { return nil, err } allowed, rejected, err = r.txnPrepareLocalEvents( - ctx, txn, room, partialEvs, SendLocalEventsOptions{}, + ctx, txn, room, partialEvs, eventsProvider, SendLocalEventsOptions{}, ) return nil, err }) @@ -118,10 +129,9 @@ func (r *RoomsDatabase) txnPrepareLocalEvents( txn fdb.ReadTransaction, room *types.Room, partialEvs []*types.PartialEvent, + eventsProvider *events.TxnEventsProvider, options SendLocalEventsOptions, ) ([]*types.Event, []RejectedEvent, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - // Get the current room state which we'll use to authenticate the events currentStateMap := r.events.TxnLookupCurrentRoomAuthAndSpecificMemberStateMap( ctx, @@ -305,6 +315,8 @@ func (r *RoomsDatabase) SendFederatedEvents( ctx context.Context, roomID id.RoomID, evs []*types.Event, + userPushRules types.UserPushRulesMap, + userRoomContext types.UserRoomContextMap, options SendFederatedEventsOptions, ) (*SendEventsResult, error) { lock, _ := r.roomLocks.GetOrSet(roomID, &sync.Mutex{}) @@ -643,10 +655,15 @@ func (r *RoomsDatabase) SendFederatedEvents( evLog.Debug().Msg("Event authorized for storage") } + // Note: federated events use fallback notification evaluation (no push rules) + eventNotifications := txnEvaluateNotificationsForEvents( + txn, eventsProvider, evs, userPushRules, userRoomContext, + ) + changedUsers := make(map[id.UserID]struct{}, 1) changedServers := make(map[string]struct{}, 1) - if !r.txnStoreEvents(ctx, txn, room, evs, changedUsers, changedServers) { + if !r.txnStoreEvents(ctx, txn, room, evs, changedUsers, changedServers, eventNotifications) { log.Warn().Msg("No events stored in send transaction") } else { if options.RemoteJoinEventID != "" && !thisServerInRoom { @@ -850,6 +867,7 @@ func (r *RoomsDatabase) txnStoreEvents( evs []*types.Event, changedUsers map[id.UserID]struct{}, changedServers map[string]struct{}, + eventNotifications map[id.EventID]map[id.UserID]types.Notifications, ) bool { if len(evs) > types.MaxVersionstampUserVersion { panic("not safe to write this many events in one transaction") @@ -857,7 +875,14 @@ func (r *RoomsDatabase) txnStoreEvents( return false } - zerolog.Ctx(ctx).Debug().Int("events", len(evs)).Msg("Storing batch of events") + notifs := 0 + for _, u := range eventNotifications { + notifs += len(u) + } + zerolog.Ctx(ctx).Debug(). + Int("events", len(evs)). + Int("event_notifications", notifs). + Msg("Storing batch of events") var version tuple.Versionstamp depthKey := r.KeyForRoomDepth(room.ID) @@ -960,6 +985,13 @@ func (r *RoomsDatabase) txnStoreEvents( } } + // Store notification counts for local users + if userNotifs, ok := eventNotifications[ev.ID]; ok { + for userID, notif := range userNotifs { + r.users.TxnStoreNotification(txn, userID, room.ID, version, notif) + } + } + // Update room extremeties // This is where we handle the partial DAG ordering via prev_events // For each new event: diff --git a/internal/databases/rooms/eventsendutil.go b/internal/databases/rooms/eventsendutil.go index 3ed4464..9e1ac7e 100644 --- a/internal/databases/rooms/eventsendutil.go +++ b/internal/databases/rooms/eventsendutil.go @@ -255,6 +255,9 @@ func (r *RoomsDatabase) txnPreProcessEventUnsigned( ev.SetUnsigned("prev_content", currentEv.Content) // Note: this is not referenced anywhere in the spec but synapse does it and complement tests it ev.SetUnsigned("prev_sender", currentEv.Sender) + + // Internal cache of the prev state event object, used when updating room below + ev.PrevStateEvent = currentEv } func (r *RoomsDatabase) updateRoomForStateEvent(room *types.Room, ev *types.Event) bool { @@ -269,6 +272,18 @@ func (r *RoomsDatabase) updateRoomForStateEvent(room *types.Room, ev *types.Even case event.StateRoomAvatar: room.AvatarURL = gjson.GetBytes(ev.Content, "url").String() changed = true + case event.StateMember: + if ev.Membership() == event.MembershipJoin { + // We're joining new if no prev or prev wasn't join + if ev.PrevStateEvent == nil || ev.PrevStateEvent.Membership() != event.MembershipJoin { + room.MemberCount++ + } + } else { + // We're leaving if prev was join + if ev.PrevStateEvent != nil && ev.PrevStateEvent.Membership() == event.MembershipJoin { + room.MemberCount-- + } + } } return changed diff --git a/internal/databases/rooms/notifications.go b/internal/databases/rooms/notifications.go new file mode 100644 index 0000000..00674d2 --- /dev/null +++ b/internal/databases/rooms/notifications.go @@ -0,0 +1,176 @@ +package rooms + +import ( + "context" + "encoding/json" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +// evaluateNotificationsForEvent evaluates push rules for an event against a target user. +// Returns notification deltas for this event. +// +// If a push ruleset is provided, it uses the ruleset to determine actions. +// Falls back to default behavior if ruleset is nil: +// - Count +1 notification for every message event from someone other than the target user +// - Count +1 highlight for @mentions of the target user in the message body +func evaluateNotificationsForEvent( + ev *types.Event, + targetUserID id.UserID, + threadID string, + ruleset *pushrules.PushRuleset, + roomCtx *types.PushRuleRoom, +) types.Notifications { + // Always ignore our own messages + if ev.Sender == targetUserID { + return types.Notifications{} + } + + // If we have a ruleset, use push rules evaluation + if ruleset != nil { + // Convert to mautrix event for push rules + mautrixEvt := &event.Event{ + Type: ev.Type, + StateKey: ev.StateKey, + Sender: ev.Sender, + RoomID: ev.RoomID, + ID: ev.ID, + Timestamp: ev.Timestamp, + Content: event.Content{VeryRaw: ev.Content}, + } + + // Ensure .Raw is populated as is needed for push rule eval + json.Unmarshal(ev.Content, &mautrixEvt.Content.Raw) + + actions := ruleset.GetActions(roomCtx, mautrixEvt) + should := actions.Should() + if !should.Notify { + return types.Notifications{} + } + + notif := types.Notifications{Count: 1, ThreadID: threadID} + if should.Highlight { + notif.Highlight = 1 + } + return notif + } + + // Fallback to default behavior if no ruleset + switch ev.Type { + case event.EventMessage, event.EventEncrypted: + // Only count as notification for m.room.message & m.room.encrypted + default: + return types.Notifications{} + } + + notif := types.Notifications{Count: 1, ThreadID: threadID} + + mentions := ev.Mentions() + if mentions.Room || slices.Contains(mentions.UserIDs, targetUserID) { + // Highlight if intentional mention match + notif.Highlight = 1 + } + + return notif +} + +// txnEvaluateNotificationsForEvents evaluates notifications for a batch of events. +// Returns a map of event ID -> user ID -> notifications. +// Takes a read transaction and events directory to look up thread roots. +func txnEvaluateNotificationsForEvents( + txn fdb.ReadTransaction, + eventsProvider *events.TxnEventsProvider, + evs []*types.Event, + userPushRules map[id.UserID]*pushrules.PushRuleset, + userRoomContext map[id.UserID]*types.PushRuleRoom, +) map[id.EventID]map[id.UserID]types.Notifications { + result := make(map[id.EventID]map[id.UserID]types.Notifications, len(evs)) + + for _, ev := range evs { + // Skip duplicates, rejected, soft-failed, and outlier events + if ev.IsDuplicate || ev.Rejected || ev.SoftFailed || ev.Outlier { + continue + } + + // Determine the thread root ID for this event + threadID := getThreadRootID(eventsProvider, ev) + + userNotifs := make(map[id.UserID]types.Notifications, len(userPushRules)) + for userID, ruleset := range userPushRules { + notif := evaluateNotificationsForEvent(ev, userID, threadID, ruleset, userRoomContext[userID]) + if !notif.IsEmpty() { + userNotifs[userID] = notif + } + } + + if len(userNotifs) > 0 { + result[ev.ID] = userNotifs + } + } + + return result +} + +// getThreadRootID walks m.thread relations to find the thread root event ID. +// Returns empty string if the event is not part of a thread. +// Per Matrix spec, m.thread always points directly to the root (flat threads), +// so walking typically terminates immediately. The walking handles edge cases +// from buggy clients that might chain thread relations. +func getThreadRootID(eventsProvider *events.TxnEventsProvider, ev *types.Event) string { + relEventID, relType := ev.RelatesTo() + if relType != event.RelThread || relEventID == "" { + return "" + } + + // Walk until we find an event with no m.thread relation (the root) + currentID := relEventID + for { + parentEv := eventsProvider.MustGet(currentID) + if parentEv == nil { + // Parent not found in our database, use current as root + return currentID.String() + } + parentRelID, parentRelType := parentEv.RelatesTo() + if parentRelType != event.RelThread || parentRelID == "" { + // No further thread relation, this is the root + return currentID.String() + } + currentID = parentRelID + } +} + +// CompactNotifications compacts notification entries for a user in a room. +// If there are 2+ entries, they are merged into a single entry with summed counts. +func (r *RoomsDatabase) CompactNotifications(ctx context.Context, userID id.UserID, roomID id.RoomID) error { + _, err := util.DoWriteTransaction(ctx, r.db, func(txn fdb.Transaction) (*struct{}, error) { + r.users.TxnCompactNotifications(txn, userID, roomID, r.config.Rooms.MaxNotificationsPerUserRoom) + return nil, nil + }) + return err +} + +// GetNotificationAtVersion gets a notification entry at an exact version. +// Returns nil if no notification exists at that version. +func (r *RoomsDatabase) GetNotificationAtVersion(ctx context.Context, userID id.UserID, roomID id.RoomID, version tuple.Versionstamp) (*types.Notifications, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*types.Notifications, error) { + return r.users.TxnGetNotificationAtVersion(txn, userID, roomID, version), nil + }) +} + +// SumNotifications sums all notification deltas for a user in a room. +func (r *RoomsDatabase) SumNotifications(ctx context.Context, userID id.UserID, roomID id.RoomID, upToVersion tuple.Versionstamp) (notifCount int, highlightCount int, err error) { + _, err = util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (struct{}, error) { + notifCount, highlightCount = r.users.TxnSumNotifications(txn, userID, roomID, upToVersion) + return struct{}{}, nil + }) + return +} diff --git a/internal/databases/rooms/receiptsend.go b/internal/databases/rooms/receiptsend.go index 8e85866..acc272e 100644 --- a/internal/databases/rooms/receiptsend.go +++ b/internal/databases/rooms/receiptsend.go @@ -3,7 +3,6 @@ package rooms import ( "context" "fmt" - "math" "sync" "maunium.net/go/mautrix/event" @@ -39,7 +38,7 @@ func (r *RoomsDatabase) SendReceipts( lock.Lock() defer lock.Unlock() - if len(rcs) >= math.MaxUint16 { + if len(rcs) >= types.MaxVersionstampUserVersion { // Very unlikely! But safety first panic("too many rcs") } @@ -138,6 +137,18 @@ func (r *RoomsDatabase) SendReceipts( continue } + // Clear notification counts up to the receipt's event version + // Both read and private read receipts mark messages as read + if rc.EventVersion != types.ZeroVersionstamp && rc.UserID.Homeserver() == r.config.ServerName { + if rc.ThreadID != "" { + // Thread-specific receipt: only clear notifications for this thread + r.users.TxnClearThreadNotificationsUpTo(txn, rc.UserID, rc.RoomID, rc.ThreadID, rc.EventVersion) + } else { + // Main timeline receipt: clear all notifications + r.users.TxnClearNotificationsUpTo(txn, rc.UserID, rc.RoomID, rc.EventVersion) + } + } + // Finally update the last version txn.SetVersionstampedValue(versionKey, types.MustVersionstampToBytes(version)) @@ -145,7 +156,7 @@ func (r *RoomsDatabase) SendReceipts( } // Bump the room version ahead of any receipts sent (max userID part of versionstamp) - version := tuple.IncompleteVersionstamp(uint16(math.MaxUint16)) + version := tuple.IncompleteVersionstamp(types.MaxVersionstampUserVersion) txn.SetVersionstampedValue(r.KeyForRoomVersion(roomID), types.MustVersionstampToBytes(version)) return &SendReceiptsResults{ diff --git a/internal/databases/rooms/roomstate.go b/internal/databases/rooms/roomstate.go index 05968f5..b74cbad 100644 --- a/internal/databases/rooms/roomstate.go +++ b/internal/databases/rooms/roomstate.go @@ -3,6 +3,7 @@ package rooms import ( "context" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/apple/foundationdb/bindings/go/src/fdb" @@ -31,6 +32,19 @@ func (r *RoomsDatabase) GetCurrentRoomMemberships(ctx context.Context, roomID id }) } +func (r *RoomsDatabase) GetCurrentRoomLocalJoinedMemberships(ctx context.Context, roomID id.RoomID) (types.RoomMemberships, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.RoomMemberships, error) { + memberships := r.events.TxnLookupCurrentRoomMemberships(txn, roomID, nil) + localMemberships := make(types.RoomMemberships, len(memberships)) + for userID, mTup := range memberships { + if userID.Homeserver() == r.config.ServerName && mTup.Membership == event.MembershipJoin { + localMemberships[userID] = mTup + } + } + return localMemberships, nil + }) +} + func (r *RoomsDatabase) GetRoomStateMapAtEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (types.StateMap, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.StateMap, error) { return r.events.TxnLookupRoomStateAndMemberMapAtEvent(txn, roomID, eventID, nil), nil diff --git a/internal/databases/rooms/sync.go b/internal/databases/rooms/sync.go index c6877eb..6d56ef1 100644 --- a/internal/databases/rooms/sync.go +++ b/internal/databases/rooms/sync.go @@ -154,6 +154,8 @@ func (r *RoomsDatabase) syncRoomEvents( if types.VersionIsAtOrBefore(roomVersion, fromVersion) { zerolog.Ctx(ctx).Trace().Any("membership_tup", membershipTup).Msg("Skip room with no changes") continue + } else { + zerolog.Ctx(ctx).Trace().Any("membership_tup", membershipTup).Any("room_version", roomVersion).Any("from_version", fromVersion).Any("latest_version", latestVersion).Msg("Including room with changes") } } @@ -314,7 +316,7 @@ func (r *RoomsDatabase) syncRoomEvents( state[i].SetUnsigned("hs.order", types.MustVersionstampToString(idToVersion[tup.EventID])) } - rooms[membershipTup] = &types.SyncRoom{ + syncRoom := &types.SyncRoom{ TimelineEvents: types.Timeline{ EventList: types.EventList{Events: timeline}, Limited: result.limited, @@ -322,6 +324,33 @@ func (r *RoomsDatabase) syncRoomEvents( StateEvents: types.EventList{Events: state}, Receipts: result.receipts, } + + // Add notification counts for joined rooms, pinned to latestVersion to avoid + // over-counting if parallel events come in during sync + if membershipTup.Membership == event.MembershipJoin && !options.IsServerToServer { + if options.UseRoomThreadedNotifications() { + // Thread-aware: separate main room and per-thread counts + mainNotif, mainHighlight, threadCounts := r.users.TxnSumNotificationsByThread( + txn, options.UserID, membershipTup.RoomID, latestVersion, + ) + syncRoom.UnreadNotifications = &types.UnreadNotificationCounts{ + NotificationCount: mainNotif, + HighlightCount: mainHighlight, + } + if len(threadCounts) > 0 { + syncRoom.UnreadThreadNotifications = threadCounts + } + } else { + // Legacy: sum all notifications together regardless of thread + notifCount, highlightCount := r.users.TxnSumNotifications(txn, options.UserID, membershipTup.RoomID, latestVersion) + syncRoom.UnreadNotifications = &types.UnreadNotificationCounts{ + NotificationCount: notifCount, + HighlightCount: highlightCount, + } + } + } + + rooms[membershipTup] = syncRoom } return nil, nil diff --git a/internal/databases/rooms/users/notifications.go b/internal/databases/rooms/users/notifications.go new file mode 100644 index 0000000..2905aed --- /dev/null +++ b/internal/databases/rooms/users/notifications.go @@ -0,0 +1,219 @@ +package users + +import ( + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +// Notification versions (id.UserID, id.RoomID, tuple.Versionstamp) -> types.Notifications +// + +func (u *UsersDirectory) keyForNotificationVersion( + userID id.UserID, + roomID id.RoomID, + version tuple.Versionstamp, +) fdb.Key { + tup := tuple.Tuple{userID.String(), roomID.String(), version} + if types.IsIncompleteVersionstamp(version) { + key, err := u.notificationVersions.PackWithVersionstamp(tup) + if err != nil { + panic(err) + } + return key + } + return u.notificationVersions.Pack(tup) +} + +func (u *UsersDirectory) rangeForNotifications( + userID id.UserID, + roomID id.RoomID, + upToVersion tuple.Versionstamp, +) fdb.ExactRange { + return types.GetVersionRange( + u.notificationVersions, + types.ZeroVersionstamp, + upToVersion, + userID.String(), + roomID.String(), + ) +} + +// TxnStoreNotification stores a notification delta for an event. +func (u *UsersDirectory) TxnStoreNotification( + txn fdb.Transaction, + userID id.UserID, + roomID id.RoomID, + version tuple.Versionstamp, + notif types.Notifications, +) { + if notif.IsEmpty() { + return + } + txn.SetVersionstampedKey( + u.keyForNotificationVersion(userID, roomID, version), + types.NotificationsToBytes(notif), + ) +} + +// TxnClearNotificationsUpTo clears all notification entries up to and including the given version. +// Used when a read receipt is received to mark messages as read. +// This clears ALL notifications regardless of ThreadID. +func (u *UsersDirectory) TxnClearNotificationsUpTo( + txn fdb.Transaction, + userID id.UserID, + roomID id.RoomID, + upToVersion tuple.Versionstamp, +) { + txn.ClearRange(u.rangeForNotifications(userID, roomID, upToVersion)) +} + +// TxnClearThreadNotificationsUpTo clears notification entries for a specific thread +// up to and including the given version. Used when a thread-specific read receipt is received. +func (u *UsersDirectory) TxnClearThreadNotificationsUpTo( + txn fdb.Transaction, + userID id.UserID, + roomID id.RoomID, + threadID string, + upToVersion tuple.Versionstamp, +) { + // We need to iterate and selectively clear only notifications matching the threadID + iter := txn.GetRange( + u.rangeForNotifications(userID, roomID, upToVersion), + fdb.RangeOptions{ + Mode: fdb.StreamingModeWantAll, + }, + ).Iterator() + + for iter.Advance() { + kv := iter.MustGet() + notif := types.BytesToNotifications(kv.Value) + if notif.ThreadID == threadID { + txn.Clear(kv.Key) + } else if threadID == "main" && notif.ThreadID == "" { + // Receipts with "main" threadID also clear unthreaded receipts + txn.Clear(kv.Key) + } + } +} + +// TxnSumNotifications sums all notification deltas for a user in a room up to +// and including upToVersion. Returns the total notification count and highlight count. +// This sums ALL notifications regardless of ThreadID (for non-threading clients). +func (u *UsersDirectory) TxnSumNotifications( + txn fdb.ReadTransaction, + userID id.UserID, + roomID id.RoomID, + upToVersion tuple.Versionstamp, +) (notifCount int, highlightCount int) { + iter := txn.GetRange( + u.rangeForNotifications(userID, roomID, upToVersion), + fdb.RangeOptions{ + Mode: fdb.StreamingModeWantAll, + }, + ).Iterator() + + for iter.Advance() { + kv := iter.MustGet() + notif := types.BytesToNotifications(kv.Value) + notifCount += notif.Count + highlightCount += notif.Highlight + } + + return notifCount, highlightCount +} + +// TxnSumNotificationsByThread sums notification deltas grouped by ThreadID. +// Returns: +// - mainNotifCount, mainHighlightCount: notifications with empty ThreadID (main timeline) +// - threadCounts: map of threadID -> notification counts for each thread +func (u *UsersDirectory) TxnSumNotificationsByThread( + txn fdb.ReadTransaction, + userID id.UserID, + roomID id.RoomID, + upToVersion tuple.Versionstamp, +) (mainNotifCount, mainHighlightCount int, threadCounts map[string]*types.UnreadNotificationCounts) { + threadCounts = make(map[string]*types.UnreadNotificationCounts) + + iter := txn.GetRange( + u.rangeForNotifications(userID, roomID, upToVersion), + fdb.RangeOptions{ + Mode: fdb.StreamingModeWantAll, + }, + ).Iterator() + + for iter.Advance() { + kv := iter.MustGet() + notif := types.BytesToNotifications(kv.Value) + + if notif.ThreadID == "" { + mainNotifCount += notif.Count + mainHighlightCount += notif.Highlight + } else { + tc := threadCounts[notif.ThreadID] + if tc == nil { + tc = &types.UnreadNotificationCounts{} + threadCounts[notif.ThreadID] = tc + } + tc.NotificationCount += notif.Count + tc.HighlightCount += notif.Highlight + } + } + + return mainNotifCount, mainHighlightCount, threadCounts +} + +// TxnGetNotificationAtVersion gets a notification entry at an exact version. +// Returns nil if no notification exists at that version. +func (u *UsersDirectory) TxnGetNotificationAtVersion( + txn fdb.ReadTransaction, + userID id.UserID, + roomID id.RoomID, + version tuple.Versionstamp, +) *types.Notifications { + key := u.notificationVersions.Pack(tuple.Tuple{userID.String(), roomID.String(), version}) + b := txn.Get(key).MustGet() + if b == nil { + return nil + } + notif := types.BytesToNotifications(b) + return ¬if +} + +// Clear the oldest notifications per thread to a given limit +func (u *UsersDirectory) TxnCompactNotifications(txn fdb.Transaction, userID id.UserID, roomID id.RoomID, limitPerThread int) int { + rng := u.rangeForNotifications(userID, roomID, types.ZeroVersionstamp) + iter := txn.GetRange(rng, fdb.RangeOptions{ + Mode: fdb.StreamingModeWantAll, + }).Iterator() + + // Generate thread ID -> ordered list of notifications + byThread := make(map[string][]fdb.KeyValue) + + for iter.Advance() { + kv := iter.MustGet() + notif := types.BytesToNotifications(kv.Value) + + if _, ok := byThread[notif.ThreadID]; !ok { + byThread[notif.ThreadID] = make([]fdb.KeyValue, 0, 1) + } + byThread[notif.ThreadID] = append(byThread[notif.ThreadID], kv) + } + + var deleted int + for _, keys := range byThread { + // We need to clear the first N to keep the total as configured + toDelete := len(keys) - limitPerThread + if toDelete < 1 { + continue + } + for _, kv := range keys[:toDelete] { + txn.Clear(kv.Key) + } + deleted += toDelete + } + + return deleted +} diff --git a/internal/databases/rooms/users/users.go b/internal/databases/rooms/users/users.go index 036ba2d..14281ba 100644 --- a/internal/databases/rooms/users/users.go +++ b/internal/databases/rooms/users/users.go @@ -22,6 +22,13 @@ type UsersDirectory struct { // key: (id.UserID, tuple.Versionstamp) // value: types.MembershipTupWithVersion membershipChanges subspace.Subspace + + // Notification counts per event version + // Stored as deltas, summed for total counts, cleared on receipt + // + // key: (id.UserID, id.RoomID, tuple.Versionstamp) + // value: types.Notifications (msgpack) + notificationVersions subspace.Subspace } func NewUsersDirectory(logger zerolog.Logger, db fdb.Database, parentDir directory.Directory) *UsersDirectory { @@ -42,7 +49,8 @@ func NewUsersDirectory(logger zerolog.Logger, db fdb.Database, parentDir directo // Init data model subspaces, subspace prefixes are intentionally short // "When using the tuple layer to encode keys (as is recommended), select short strings or small integers for tuple elements." // https://apple.github.io/foundationdb/data-modeling.html#key-and-value-sizes - memberships: usersDir.Sub("mem"), - membershipChanges: usersDir.Sub("mch"), + memberships: usersDir.Sub("mem"), + membershipChanges: usersDir.Sub("mch"), + notificationVersions: usersDir.Sub("nv"), } } diff --git a/internal/databases/sync.go b/internal/databases/sync.go index a292f12..02092a1 100644 --- a/internal/databases/sync.go +++ b/internal/databases/sync.go @@ -23,7 +23,7 @@ func (d *Databases) SyncForUser( versions[types.RoomsVersionKey] = nextRoomsVersion } - nextAccountsVersion, accounts, err := d.Accounts.SyncAccountsForuser(ctx, userID, versions[types.AccountsVersionKey], options) + nextAccountsVersion, accounts, pushRules, err := d.Accounts.SyncAccountsForuser(ctx, userID, versions[types.AccountsVersionKey], options) if err != nil { return nil, err } else { @@ -37,7 +37,7 @@ func (d *Databases) SyncForUser( versions[types.TransientVersionKey] = nextTransientVersion } - sync := types.NewSync(rooms, accounts, toDevice) + sync := types.NewSync(rooms, accounts, toDevice, pushRules) sync.NextBatch = util.VersionMapToString(versions) return sync, nil diff --git a/internal/databases/todevice.go b/internal/databases/todevice.go new file mode 100644 index 0000000..23f6de8 --- /dev/null +++ b/internal/databases/todevice.go @@ -0,0 +1,36 @@ +package databases + +import ( + "context" + + "github.com/beeper/babbleserv/internal/databases/transient" + "github.com/beeper/babbleserv/internal/types" +) + +// Essentially a wrapper around Transient.SendRawToDeviceEvents that expands any events for local +// users with device ID "*" via Accounts.GetUserDevices. +func (d *Databases) SendToDeviceEvents( + ctx context.Context, + tds []*types.ToDevice, + options transient.SendToDeviceOptions, +) (*transient.SendToDeviceResults, error) { + expandedTDs := make([]*types.ToDevice, 0, len(tds)*2) + + for _, td := range tds { + if td.DeviceID == "*" && td.UserID.Homeserver() == d.config.ServerName { + devices, err := d.Accounts.GetUserDevices(ctx, td.UserID) + if err != nil { + return nil, err + } + for _, d := range devices { + tdCopy := *td + tdCopy.DeviceID = d.ID + expandedTDs = append(expandedTDs, &tdCopy) + } + } else { + expandedTDs = append(expandedTDs, td) + } + } + + return d.Transient.SendRawToDeviceEvents(ctx, expandedTDs, options) +} diff --git a/internal/databases/transient/todevicesend.go b/internal/databases/transient/todevicesend.go index 09f2b6d..530ce9d 100644 --- a/internal/databases/transient/todevicesend.go +++ b/internal/databases/transient/todevicesend.go @@ -37,7 +37,8 @@ type SendToDeviceOptions struct { LockTxnRefresh lock.LockTxnRefreshFunc } -func (t *TransientDatabase) SendToDeviceEvents( +// Sends to-device events, raw meaning the DeviceIDs must be specific and not "*" +func (t *TransientDatabase) SendRawToDeviceEvents( ctx context.Context, tds []*types.ToDevice, options SendToDeviceOptions, diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index 40bc434..72811bf 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -30,7 +30,7 @@ type Subscription struct { type subscription struct { Subscription // The callback channel to send results to - we key subscriptions by this - channel chan any + channel chan Change } // A change represents one or more changes to entities @@ -44,6 +44,13 @@ type Change struct { Servers []string `msgpack:"s,omitempty"` } +func (c Change) IsEmpty() bool { + return len(c.EventIDs) == 0 && + len(c.RoomIDs) == 0 && + len(c.UserIDs) == 0 && + len(c.Servers) == 0 +} + func (c Change) MarshalZerologObject(ev *zerolog.Event) { for _, eventID := range c.EventIDs { ev.Str("event_id", eventID.String()) @@ -75,21 +82,21 @@ type Notifier struct { // Subscribe/unsubscribe channels subscribeCh chan subscription - unsubscribeCh chan chan any + unsubscribeCh chan chan Change // Send change channels - userChangeCh chan id.UserID - roomChangeCh chan id.RoomID - eventsChangeCh chan id.EventID - serverChangeCh chan string + userChangeCh chan Change + roomChangeCh chan Change + eventsChangeCh chan Change + serverChangeCh chan Change // Map channels to subscriptions - chanToSubscription map[chan any]subscription + chanToSubscription map[chan Change]subscription // Map user/room/event IDs to channels - userIDToChan map[id.UserID]map[chan any]struct{} - roomIDToChan map[id.RoomID]map[chan any]struct{} + userIDToChan map[id.UserID]map[chan Change]struct{} + roomIDToChan map[id.RoomID]map[chan Change]struct{} // Map channels for all event/server subscribers - eventChs map[chan any]struct{} - serverChs map[chan any]struct{} - userChs map[chan any]struct{} + eventChs map[chan Change]struct{} + serverChs map[chan Change]struct{} + userChs map[chan Change]struct{} } func NewNotifier(name string, cfg config.NotifierConfig, logger zerolog.Logger) *Notifier { @@ -115,18 +122,18 @@ func NewNotifier(name string, cfg config.NotifierConfig, logger zerolog.Logger) instanceID: instanceID, subscribeCh: make(chan subscription), - unsubscribeCh: make(chan chan any), - userChangeCh: make(chan id.UserID), - roomChangeCh: make(chan id.RoomID), - eventsChangeCh: make(chan id.EventID), - serverChangeCh: make(chan string), - - chanToSubscription: make(map[chan any]subscription), - userIDToChan: make(map[id.UserID]map[chan any]struct{}), - roomIDToChan: make(map[id.RoomID]map[chan any]struct{}), - eventChs: make(map[chan any]struct{}), - serverChs: make(map[chan any]struct{}), - userChs: make(map[chan any]struct{}), + unsubscribeCh: make(chan chan Change), + userChangeCh: make(chan Change), + roomChangeCh: make(chan Change), + eventsChangeCh: make(chan Change), + serverChangeCh: make(chan Change), + + chanToSubscription: make(map[chan Change]subscription), + userIDToChan: make(map[id.UserID]map[chan Change]struct{}), + roomIDToChan: make(map[id.RoomID]map[chan Change]struct{}), + eventChs: make(map[chan Change]struct{}), + serverChs: make(map[chan Change]struct{}), + userChs: make(map[chan Change]struct{}), } } @@ -163,16 +170,19 @@ func (n *Notifier) Stop() { // Subscribe for notifier changes, which will be sent to the channel provided, // delivery is not guaranteed if the channel is blocked as the notifier cannot // wait for any downstream work. -func (n *Notifier) subscribe(ch chan any, req Subscription) { +func (n *Notifier) subscribe(ch chan Change, req Subscription) { n.log.Trace().Any("subscription", req).Msg("Subscribe") n.subscribeCh <- subscription{req, ch} } -func (n *Notifier) unsubscribe(ch chan any) { +func (n *Notifier) unsubscribe(ch chan Change) { n.unsubscribeCh <- ch } func (n *Notifier) SendChange(change Change) { + if change.IsEmpty() { + return + } n.log.Trace().Any("change", change).Msg("Sending change") n.sendInternalChange(change) // Fire of the Redis change asynchronously, as pubsub is best-effort + unordered @@ -182,17 +192,17 @@ func (n *Notifier) SendChange(change Change) { } func (n *Notifier) sendInternalChange(change Change) { - for _, evID := range change.EventIDs { - n.eventsChangeCh <- evID + if len(change.EventIDs) > 0 { + n.eventsChangeCh <- change } - for _, roomID := range change.RoomIDs { - n.roomChangeCh <- roomID + if len(change.RoomIDs) > 0 { + n.roomChangeCh <- change } - for _, userID := range change.UserIDs { - n.userChangeCh <- userID + if len(change.UserIDs) > 0 { + n.userChangeCh <- change } - for _, server := range change.Servers { - n.serverChangeCh <- server + if len(change.Servers) > 0 { + n.serverChangeCh <- change } } @@ -236,29 +246,33 @@ func (n *Notifier) internalLoop(ctx context.Context) { case ch := <-n.unsubscribeCh: n.unlockedUnusbscribe(ch) // Handle subscriptions - case eventID := <-n.eventsChangeCh: + case change := <-n.eventsChangeCh: // All event subscribers - n.unlockedSendChanges(n.eventChs, eventID) - case server := <-n.serverChangeCh: + n.unlockedSendChanges(n.eventChs, change) + case change := <-n.serverChangeCh: // All server subscribers - n.unlockedSendChanges(n.serverChs, server) - case userID := <-n.userChangeCh: + n.unlockedSendChanges(n.serverChs, change) + case change := <-n.userChangeCh: // All user subscribers - n.unlockedSendChanges(n.userChs, userID) + n.unlockedSendChanges(n.userChs, change) // Per-user subscribers - if chs, found := n.userIDToChan[userID]; found { - n.unlockedSendChanges(chs, userID) + for _, userID := range change.UserIDs { + if chs, found := n.userIDToChan[userID]; found { + n.unlockedSendChanges(chs, change) + } } - case roomID := <-n.roomChangeCh: + case change := <-n.roomChangeCh: // Per-room subscribers - if chs, found := n.roomIDToChan[roomID]; found { - n.unlockedSendChanges(chs, roomID) + for _, roomID := range change.RoomIDs { + if chs, found := n.roomIDToChan[roomID]; found { + n.unlockedSendChanges(chs, change) + } } } } } -func (n *Notifier) unlockedSendChanges(chs map[chan any]struct{}, item any) { +func (n *Notifier) unlockedSendChanges(chs map[chan Change]struct{}, item Change) { for ch := range chs { select { case ch <- item: @@ -286,19 +300,19 @@ func (n *Notifier) unlockedSubscribe(sub subscription) { // Add specific subscription channels for _, userID := range sub.UserIDs { if _, found := n.userIDToChan[userID]; !found { - n.userIDToChan[userID] = make(map[chan any]struct{}) + n.userIDToChan[userID] = make(map[chan Change]struct{}) } n.userIDToChan[userID][sub.channel] = struct{}{} } for _, roomID := range sub.RoomIDs { if _, found := n.roomIDToChan[roomID]; !found { - n.roomIDToChan[roomID] = make(map[chan any]struct{}) + n.roomIDToChan[roomID] = make(map[chan Change]struct{}) } n.roomIDToChan[roomID][sub.channel] = struct{}{} } } -func (n *Notifier) unlockedUnusbscribe(ch chan any) { +func (n *Notifier) unlockedUnusbscribe(ch chan Change) { sub, found := n.chanToSubscription[ch] if !found { n.log.Warn().Msg("Unsubscribe using non-existent channel") diff --git a/internal/notifier/notifiers.go b/internal/notifier/notifiers.go index a895d06..dc9a349 100644 --- a/internal/notifier/notifiers.go +++ b/internal/notifier/notifiers.go @@ -17,73 +17,45 @@ func NewNotifiers(cfg config.BabbleConfig, logger zerolog.Logger) *Notifiers { Str("component", "notifier"). Logger() - var notifiers Notifiers - - if cfg.Rooms.Enabled { - notifiers.Rooms = NewNotifier("rooms", cfg.Rooms.Notifier, log) - } - if cfg.Accounts.Enabled { - notifiers.Accounts = NewNotifier("accounts", cfg.Accounts.Notifier, log) - } - if cfg.Transient.Enabled { - notifiers.Transient = NewNotifier("accounts", cfg.Transient.Notifier, log) + return &Notifiers{ + Rooms: NewNotifier("rooms", cfg.Rooms.Notifier, log), + Accounts: NewNotifier("accounts", cfg.Accounts.Notifier, log), + Transient: NewNotifier("accounts", cfg.Transient.Notifier, log), } - - return ¬ifiers } -func (n *Notifiers) Subscribe(req Subscription) chan any { - return n.SubscribeWithChannel(make(chan any, 1), req) +// Subscribe to changes and get a channel of those. Subscriptions are lossy - if the channel isn't +// being read from we only keep the first change. This means notifier subscriptions can be used to +// wake up systems in response to changes but not to accurately track all changes. +func (n *Notifiers) Subscribe(req Subscription) chan Change { + // Buffer 1 change to immediately wakeup subscribers if they're currently processing + return n.SubscribeWithChannel(make(chan Change, 1), req) } -func (n *Notifiers) SubscribeWithChannel(ch chan any, req Subscription) chan any { - if n.Rooms != nil { - n.Rooms.subscribe(ch, req) - } - if n.Accounts != nil { - n.Accounts.subscribe(ch, req) - } - if n.Transient != nil { - n.Transient.subscribe(ch, req) - } - +// Similar to subscribe but takes a custom channel, which can have a greater buffer than default 1, +// but note the delivery is still lossy - if the channel is full the chnage will be dropped. +func (n *Notifiers) SubscribeWithChannel(ch chan Change, req Subscription) chan Change { + n.Rooms.subscribe(ch, req) + n.Accounts.subscribe(ch, req) + n.Transient.subscribe(ch, req) return ch } -func (n *Notifiers) Unsubscribe(ch chan any) { - if n.Rooms != nil { - n.Rooms.unsubscribe(ch) - } - if n.Accounts != nil { - n.Accounts.unsubscribe(ch) - } - if n.Transient != nil { - n.Transient.unsubscribe(ch) - } - +func (n *Notifiers) Unsubscribe(ch chan Change) { + n.Rooms.unsubscribe(ch) + n.Accounts.unsubscribe(ch) + n.Transient.unsubscribe(ch) close(ch) } func (n *Notifiers) Start() { - if n.Rooms != nil { - n.Rooms.Start() - } - if n.Accounts != nil { - n.Accounts.Start() - } - if n.Transient != nil { - n.Transient.Start() - } + n.Rooms.Start() + n.Accounts.Start() + n.Transient.Start() } func (n *Notifiers) Stop() { - if n.Rooms != nil { - n.Rooms.Stop() - } - if n.Accounts != nil { - n.Accounts.Stop() - } - if n.Transient != nil { - n.Transient.Stop() - } + n.Rooms.Stop() + n.Accounts.Stop() + n.Transient.Stop() } diff --git a/internal/routes/client/client.go b/internal/routes/client/client.go index d0da64c..1a9dd65 100644 --- a/internal/routes/client/client.go +++ b/internal/routes/client/client.go @@ -66,100 +66,102 @@ func (c *ClientRoutes) AddClientRoutes(rtr chi.Router) { rtr.MethodFunc(http.MethodGet, "/versions", c.GetVersions) rtr.MethodFunc(http.MethodGet, "/v3/capabilities", middleware.RequireUserAuth(c.GetCapabilities)) - if c.config.Rooms.Enabled && c.config.Accounts.Enabled && c.config.Transient.Enabled { - // Legacy (v2/3) sync witb init and increment variants and basic filters, all rooms - rtr.MethodFunc(http.MethodGet, "/v3/sync", middleware.RequireUserAuth(c.SyncLegacy)) - // Simplified sliding "native" sync MSC4186, same as v2 with room filters, roughly - rtr.MethodFunc(http.MethodGet, "/unstable/org.matrix.simplified_msc3575/sync", middleware.RequireUserAuth(c.SyncSliding)) - // Beeper's streaming sync, no gaps, firehose style - rtr.MethodFunc(http.MethodGet, "/unstable/com.beeper.streaming/sync", middleware.RequireUserAuth(c.SyncStreaming)) - } - - if c.config.Rooms.Enabled { - rtr.MethodFunc(http.MethodPost, "/v3/createRoom", middleware.RequireUserAuth(c.CreateRoom)) - // Send events - rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/state/{eventType}", middleware.RequireUserAuth(c.SendRoomStateEvent)) - rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/state/{eventType}/", middleware.RequireUserAuth(c.SendRoomStateEvent)) - rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/state/{eventType}/{stateKey}", middleware.RequireUserAuth(c.SendRoomStateEvent)) - rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/send/{eventType}/{txnID}", middleware.RequireUserAuth(c.SendRoomEvent)) - // Send membership events - rtr.MethodFunc(http.MethodGet, "/v3/joined_rooms", middleware.RequireUserAuth(c.GetJoinedRooms)) - rtr.MethodFunc(http.MethodPost, "/v3/join/{roomID}", middleware.RequireUserAuth(c.SendRoomJoinAlias)) - rtr.MethodFunc(http.MethodPost, "/v3/knock/{roomID}", middleware.RequireUserAuth(c.SendRoomKnockAlias)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/invite", middleware.RequireUserAuth(c.SendRoomInvite)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/join", middleware.RequireUserAuth(c.SendRoomJoin)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/forget", middleware.RequireUserAuth(c.ForgetRoom)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/leave", middleware.RequireUserAuth(c.SendRoomLeave)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/kick", middleware.RequireUserAuth(c.SendRoomKick)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/ban", middleware.RequireUserAuth(c.SendRoomBan)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/unban", middleware.RequireUserAuth(c.SendRoomUnban)) - // Get events/state - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/event/{eventID}", middleware.RequireUserAuth(c.GetRoomEvent)) - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state/{eventType}", middleware.RequireUserAuth(c.GetRoomStateEvent)) - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state/{eventType}/", middleware.RequireUserAuth(c.GetRoomStateEvent)) - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state/{eventType}/{stateKey}", middleware.RequireUserAuth(c.GetRoomStateEvent)) - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state", middleware.RequireUserAuth(c.GetRoomState)) - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/members", middleware.RequireUserAuth(c.GetRoomMembers)) - - // Room aliases - rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/aliases", middleware.RequireUserAuth(c.GetAliasesForRoom)) - rtr.MethodFunc(http.MethodGet, "/v3/directory/room/{roomAlias}", c.GetAlias) - rtr.MethodFunc(http.MethodPut, "/v3/directory/room/{roomAlias}", middleware.RequireUserAuth(c.CreateAlias)) - rtr.MethodFunc(http.MethodDelete, "/v3/directory/room/{roomAlias}", middleware.RequireUserAuth(c.DeleteAlias)) - - // Receipts routes - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/receipt/{receiptType}/{eventID}", middleware.RequireUserAuth(c.SendRoomReadReceipt)) - rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/read_markers", middleware.RequireUserAuth(c.SendRoomReadMarkers)) - } - - if c.config.Transient.Enabled { - // Presence routes - rtr.MethodFunc(http.MethodGet, "/v3/presence/{userID}/status", middleware.RequireUserAuth(c.GetPresence)) - rtr.MethodFunc(http.MethodPut, "/v3/presence/{userID}/status", middleware.RequireUserAuth(c.PutPresence)) - } - - if c.config.Accounts.Enabled { - rtr.MethodFunc(http.MethodPost, "/v3/register", c.Register) - rtr.MethodFunc(http.MethodGet, "/v3/login", c.GetLogin) - rtr.MethodFunc(http.MethodPost, "/v3/login", c.Login) - - rtr.MethodFunc(http.MethodGet, "/v3/whoami", middleware.RequireUserAuth(c.GetWhoami)) - - // Profile routes - note the spec has the GET endpoints un-authenticated but Babbleserv disagrees - rtr.MethodFunc(http.MethodGet, "/v3/profile/{userID}", middleware.RequireUserAuth(c.GetProfile)) - rtr.MethodFunc(http.MethodGet, "/v3/profile/{userID}/{key}", middleware.RequireUserAuth(c.GetProfile)) - rtr.MethodFunc(http.MethodPut, "/v3/profile/{userID}/{key}", middleware.RequireUserAuth(c.PutProfile)) - - rtr.MethodFunc(http.MethodGet, "/v3/devices", middleware.RequireUserAuth(c.GetDevices)) - rtr.MethodFunc(http.MethodGet, "/v3/devices/{deviceID}", middleware.RequireUserAuth(c.GetDevice)) - rtr.MethodFunc(http.MethodPut, "/v3/devices/{deviceID}", middleware.RequireUserAuth(c.PutDevice)) - rtr.MethodFunc(http.MethodDelete, "/v3/devices/{deviceID}", middleware.RequireUserAuth(c.DeleteDevice)) - rtr.MethodFunc(http.MethodDelete, "/v3/delete_devices", middleware.RequireUserAuth(c.DeleteDevices)) - - rtr.MethodFunc(http.MethodGet, "/v3/keys/changes", middleware.RequireUserAuth(c.GetKeyChanges)) - rtr.MethodFunc(http.MethodPost, "/v3/keys/query", middleware.RequireUserAuth(c.QueryKeys)) - rtr.MethodFunc(http.MethodPost, "/v3/keys/upload", middleware.RequireUserAuth(c.UploadKeys)) - rtr.MethodFunc(http.MethodPost, "/v3/keys/claim", middleware.RequireUserAuth(c.ClaimKeys)) - rtr.MethodFunc(http.MethodPost, "/v3/keys/signatures/upload", middleware.RequireUserAuth(c.UploadSignatures)) - rtr.MethodFunc(http.MethodPost, "/v3/keys/device_signing/upload", middleware.RequireUserAuth(c.UploadCrossSigningKeys)) - - rtr.MethodFunc(http.MethodPost, "/v3/user/{userID}/filter", middleware.RequireUserAuth(c.CreateFilter)) - rtr.MethodFunc(http.MethodGet, "/v3/user/{userID}/filter/{filterID}", middleware.RequireUserAuth(c.GetFilter)) - - rtr.MethodFunc(http.MethodGet, "/v3/pushrules", middleware.RequireUserAuth(c.GetPushRules)) - rtr.MethodFunc(http.MethodGet, "/v3/pushrules/", middleware.RequireUserAuth(c.GetPushRules)) - - // Global account data - rtr.MethodFunc(http.MethodPut, "/v3/user/{userID}/account_data/{type}", middleware.RequireUserAuth(c.SetAccountData)) - rtr.MethodFunc(http.MethodGet, "/v3/user/{userID}/account_data/{type}", middleware.RequireUserAuth(c.GetAccountData)) - // Room account data - rtr.MethodFunc(http.MethodPut, "/v3/user/{userID}/rooms/{roomID}/account_data/{type}", middleware.RequireUserAuth(c.SetAccountData)) - rtr.MethodFunc(http.MethodGet, "/v3/user/{userID}/rooms/{roomID}/account_data/{type}", middleware.RequireUserAuth(c.GetAccountData)) - } - - if c.config.Transient.Enabled { - rtr.MethodFunc(http.MethodPut, "/v3/sendToDevice/{eventType}/{txnID}", middleware.RequireUserAuth(c.SendToDevice)) - } + // Legacy (v2/3) sync witb init and increment variants and basic filters, all rooms + rtr.MethodFunc(http.MethodGet, "/v3/sync", middleware.RequireUserAuth(c.SyncLegacy)) + // Simplified sliding "native" sync MSC4186, same as v2 with room filters, roughly + rtr.MethodFunc(http.MethodGet, "/unstable/org.matrix.simplified_msc3575/sync", middleware.RequireUserAuth(c.SyncSliding)) + // Beeper's streaming sync, no gaps, firehose style + rtr.MethodFunc(http.MethodGet, "/unstable/com.beeper.streaming/sync", middleware.RequireUserAuth(c.SyncStreaming)) + + rtr.MethodFunc(http.MethodPost, "/v3/createRoom", middleware.RequireUserAuth(c.CreateRoom)) + // Send events + rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/state/{eventType}", middleware.RequireUserAuth(c.SendRoomStateEvent)) + rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/state/{eventType}/", middleware.RequireUserAuth(c.SendRoomStateEvent)) + rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/state/{eventType}/{stateKey}", middleware.RequireUserAuth(c.SendRoomStateEvent)) + rtr.MethodFunc(http.MethodPut, "/v3/rooms/{roomID}/send/{eventType}/{txnID}", middleware.RequireUserAuth(c.SendRoomEvent)) + // Send membership events + rtr.MethodFunc(http.MethodGet, "/v3/joined_rooms", middleware.RequireUserAuth(c.GetJoinedRooms)) + rtr.MethodFunc(http.MethodPost, "/v3/join/{roomID}", middleware.RequireUserAuth(c.SendRoomJoinAlias)) + rtr.MethodFunc(http.MethodPost, "/v3/knock/{roomID}", middleware.RequireUserAuth(c.SendRoomKnockAlias)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/invite", middleware.RequireUserAuth(c.SendRoomInvite)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/join", middleware.RequireUserAuth(c.SendRoomJoin)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/forget", middleware.RequireUserAuth(c.ForgetRoom)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/leave", middleware.RequireUserAuth(c.SendRoomLeave)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/kick", middleware.RequireUserAuth(c.SendRoomKick)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/ban", middleware.RequireUserAuth(c.SendRoomBan)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/unban", middleware.RequireUserAuth(c.SendRoomUnban)) + // Get events/state + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/event/{eventID}", middleware.RequireUserAuth(c.GetRoomEvent)) + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state/{eventType}", middleware.RequireUserAuth(c.GetRoomStateEvent)) + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state/{eventType}/", middleware.RequireUserAuth(c.GetRoomStateEvent)) + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state/{eventType}/{stateKey}", middleware.RequireUserAuth(c.GetRoomStateEvent)) + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/state", middleware.RequireUserAuth(c.GetRoomState)) + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/members", middleware.RequireUserAuth(c.GetRoomMembers)) + + // Room aliases + rtr.MethodFunc(http.MethodGet, "/v3/rooms/{roomID}/aliases", middleware.RequireUserAuth(c.GetAliasesForRoom)) + rtr.MethodFunc(http.MethodGet, "/v3/directory/room/{roomAlias}", c.GetAlias) + rtr.MethodFunc(http.MethodPut, "/v3/directory/room/{roomAlias}", middleware.RequireUserAuth(c.CreateAlias)) + rtr.MethodFunc(http.MethodDelete, "/v3/directory/room/{roomAlias}", middleware.RequireUserAuth(c.DeleteAlias)) + + // Receipts routes + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/receipt/{receiptType}/{eventID}", middleware.RequireUserAuth(c.SendRoomReadReceipt)) + rtr.MethodFunc(http.MethodPost, "/v3/rooms/{roomID}/read_markers", middleware.RequireUserAuth(c.SendRoomReadMarkers)) + + // Presence routes + rtr.MethodFunc(http.MethodGet, "/v3/presence/{userID}/status", middleware.RequireUserAuth(c.GetPresence)) + rtr.MethodFunc(http.MethodPut, "/v3/presence/{userID}/status", middleware.RequireUserAuth(c.PutPresence)) + + rtr.MethodFunc(http.MethodPost, "/v3/register", c.Register) + rtr.MethodFunc(http.MethodGet, "/v3/login", c.GetLogin) + rtr.MethodFunc(http.MethodPost, "/v3/login", c.Login) + + rtr.MethodFunc(http.MethodGet, "/v3/whoami", middleware.RequireUserAuth(c.GetWhoami)) + + // Profile routes - note the spec has the GET endpoints un-authenticated but Babbleserv disagrees + rtr.MethodFunc(http.MethodGet, "/v3/profile/{userID}", middleware.RequireUserAuth(c.GetProfile)) + rtr.MethodFunc(http.MethodGet, "/v3/profile/{userID}/{key}", middleware.RequireUserAuth(c.GetProfile)) + rtr.MethodFunc(http.MethodPut, "/v3/profile/{userID}/{key}", middleware.RequireUserAuth(c.PutProfile)) + + rtr.MethodFunc(http.MethodGet, "/v3/devices", middleware.RequireUserAuth(c.GetDevices)) + rtr.MethodFunc(http.MethodGet, "/v3/devices/{deviceID}", middleware.RequireUserAuth(c.GetDevice)) + rtr.MethodFunc(http.MethodPut, "/v3/devices/{deviceID}", middleware.RequireUserAuth(c.PutDevice)) + rtr.MethodFunc(http.MethodDelete, "/v3/devices/{deviceID}", middleware.RequireUserAuth(c.DeleteDevice)) + rtr.MethodFunc(http.MethodDelete, "/v3/delete_devices", middleware.RequireUserAuth(c.DeleteDevices)) + + rtr.MethodFunc(http.MethodGet, "/v3/keys/changes", middleware.RequireUserAuth(c.GetKeyChanges)) + rtr.MethodFunc(http.MethodPost, "/v3/keys/query", middleware.RequireUserAuth(c.QueryKeys)) + rtr.MethodFunc(http.MethodPost, "/v3/keys/upload", middleware.RequireUserAuth(c.UploadKeys)) + rtr.MethodFunc(http.MethodPost, "/v3/keys/claim", middleware.RequireUserAuth(c.ClaimKeys)) + rtr.MethodFunc(http.MethodPost, "/v3/keys/signatures/upload", middleware.RequireUserAuth(c.UploadSignatures)) + rtr.MethodFunc(http.MethodPost, "/v3/keys/device_signing/upload", middleware.RequireUserAuth(c.UploadCrossSigningKeys)) + + rtr.MethodFunc(http.MethodPost, "/v3/user/{userID}/filter", middleware.RequireUserAuth(c.CreateFilter)) + rtr.MethodFunc(http.MethodGet, "/v3/user/{userID}/filter/{filterID}", middleware.RequireUserAuth(c.GetFilter)) + + rtr.MethodFunc(http.MethodGet, "/v3/pushrules", middleware.RequireUserAuth(c.GetPushRules)) + rtr.MethodFunc(http.MethodGet, "/v3/pushrules/", middleware.RequireUserAuth(c.GetPushRules)) + rtr.MethodFunc(http.MethodGet, "/v3/pushrules/{scope}/", middleware.RequireUserAuth(c.GetPushRules)) + rtr.MethodFunc(http.MethodGet, "/v3/pushrules/{scope}/{kind}/", middleware.RequireUserAuth(c.GetPushRulesByKind)) + rtr.MethodFunc(http.MethodGet, "/v3/pushrules/{scope}/{kind}/{ruleId}", middleware.RequireUserAuth(c.GetPushRule)) + rtr.MethodFunc(http.MethodPut, "/v3/pushrules/{scope}/{kind}/{ruleId}", middleware.RequireUserAuth(c.PutPushRule)) + rtr.MethodFunc(http.MethodDelete, "/v3/pushrules/{scope}/{kind}/{ruleId}", middleware.RequireUserAuth(c.DeletePushRule)) + rtr.MethodFunc(http.MethodGet, "/v3/pushrules/{scope}/{kind}/{ruleId}/enabled", middleware.RequireUserAuth(c.GetPushRuleEnabled)) + rtr.MethodFunc(http.MethodPut, "/v3/pushrules/{scope}/{kind}/{ruleId}/enabled", middleware.RequireUserAuth(c.SetPushRuleEnabled)) + rtr.MethodFunc(http.MethodGet, "/v3/pushrules/{scope}/{kind}/{ruleId}/actions", middleware.RequireUserAuth(c.GetPushRuleActions)) + rtr.MethodFunc(http.MethodPut, "/v3/pushrules/{scope}/{kind}/{ruleId}/actions", middleware.RequireUserAuth(c.SetPushRuleActions)) + + rtr.MethodFunc(http.MethodGet, "/v3/pushers", middleware.RequireUserAuth(c.GetPushers)) + rtr.MethodFunc(http.MethodPost, "/v3/pushers/set", middleware.RequireUserAuth(c.SetPusher)) + + // Global account data + rtr.MethodFunc(http.MethodPut, "/v3/user/{userID}/account_data/{type}", middleware.RequireUserAuth(c.SetAccountData)) + rtr.MethodFunc(http.MethodGet, "/v3/user/{userID}/account_data/{type}", middleware.RequireUserAuth(c.GetAccountData)) + // Room account data + rtr.MethodFunc(http.MethodPut, "/v3/user/{userID}/rooms/{roomID}/account_data/{type}", middleware.RequireUserAuth(c.SetAccountData)) + rtr.MethodFunc(http.MethodGet, "/v3/user/{userID}/rooms/{roomID}/account_data/{type}", middleware.RequireUserAuth(c.GetAccountData)) + + rtr.MethodFunc(http.MethodPut, "/v3/sendToDevice/{eventType}/{txnID}", middleware.RequireUserAuth(c.SendToDevice)) if c.config.Media.Enabled { rtr.MethodFunc(http.MethodGet, "/v1/media/config", middleware.RequireUserAuth(c.GetMediaConfig)) diff --git a/internal/routes/client/pushers.go b/internal/routes/client/pushers.go new file mode 100644 index 0000000..85c74d3 --- /dev/null +++ b/internal/routes/client/pushers.go @@ -0,0 +1,59 @@ +package client + +import ( + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/pushrules/pushgateway" + + "github.com/beeper/babbleserv/internal/middleware" + "github.com/beeper/babbleserv/internal/util" +) + +// https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3pushers +func (c *ClientRoutes) GetPushers(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + + pushers, err := c.db.Accounts.GetPushersForUser(r.Context(), userID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + util.ResponseJSON(w, r, http.StatusOK, pushgateway.RespPushers{Pushers: pushers}) +} + +// https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3pushersset +func (c *ClientRoutes) SetPusher(w http.ResponseWriter, r *http.Request) { + req, respErr := util.ParseRequestJSON[pushgateway.Pusher](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) + return + } + + if req.PushKey == "" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing pushkey") + return + } + + userID := middleware.GetRequestUserID(r) + + // Per spec, if kind is null/empty, delete the pusher + if req.Kind == nil { + if err := c.db.Accounts.DeletePusherForUser(r.Context(), userID, req.PushKey); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + } else { + if req.AppDisplayName == "" || req.AppID == "" || req.Data == nil { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing app_display_name, app_id or data") + return + } + if err := c.db.Accounts.SetPusherForUser(r.Context(), userID, &req); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + } + + util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) +} diff --git a/internal/routes/client/pushrules.go b/internal/routes/client/pushrules.go index 5225e9b..51e07dc 100644 --- a/internal/routes/client/pushrules.go +++ b/internal/routes/client/pushrules.go @@ -3,12 +3,354 @@ package client import ( "net/http" + "github.com/go-chi/chi/v5" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/pushrules" + + "github.com/beeper/babbleserv/internal/middleware" + "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) +// parseRuleKind converts a string to a PushRuleType and validates it. +func parseRuleKind(kind string) (pushrules.PushRuleType, bool) { + switch kind { + case "override": + return pushrules.OverrideRule, true + case "content": + return pushrules.ContentRule, true + case "room": + return pushrules.RoomRule, true + case "sender": + return pushrules.SenderRule, true + case "underride": + return pushrules.UnderrideRule, true + case "postcontent": + // TODO: this *only* exists to appease complement MSC4306 + return pushrules.PushRuleType("postcontent"), true + default: + return "", false + } +} + +// https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3pushrules func (c *ClientRoutes) GetPushRules(w http.ResponseWriter, r *http.Request) { - // Just a stub implementation - util.ResponseJSON(w, r, http.StatusOK, struct { - Global map[string]any `json:"global"` - }{map[string]any{}}) + userID := middleware.GetRequestUserID(r) + + ruleset, err := c.db.Accounts.GetPushRulesForUser(r.Context(), userID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + // Return the full push rules structure + util.ResponseJSON(w, r, http.StatusOK, map[string]any{ + "global": ruleset, + }) +} + +// https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3pushrulesscopekind +func (c *ClientRoutes) GetPushRulesByKind(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + rules, err := c.db.Accounts.GetPushRulesForUserByKind(r.Context(), userID, kind) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + util.ResponseJSON(w, r, http.StatusOK, rules) +} + +// https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3pushrulesscopekindruleid +func (c *ClientRoutes) GetPushRule(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + rule, err := c.db.Accounts.GetPushRuleForUser(r.Context(), userID, kind, ruleID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + if rule == nil { + util.ResponseErrorJSON(w, r, mautrix.MNotFound) + return + } + + util.ResponseJSON(w, r, http.StatusOK, rule) +} + +// https://spec.matrix.org/v1.11/client-server-api/#put_matrixclientv3pushrulesscopekindruleid +func (c *ClientRoutes) PutPushRule(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + type reqType struct { + Actions pushrules.PushActionArray `json:"actions"` + Conditions []*pushrules.PushCondition `json:"conditions,omitempty"` + Pattern string `json:"pattern,omitempty"` + } + req, respErr := util.ParseRequestJSON[reqType](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) + return + } + + // Create the stored rule + storedRule := &types.StoredPushRule{ + Actions: req.Actions, + Default: false, // User-created rules are not default + Enabled: true, // New rules are enabled by default + Conditions: req.Conditions, + Pattern: req.Pattern, + } + + if err := c.db.Accounts.PutPushRuleForUser(r.Context(), userID, kind, ruleID, storedRule); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) +} + +// https://spec.matrix.org/v1.11/client-server-api/#delete_matrixclientv3pushrulesscopekindruleid +func (c *ClientRoutes) DeletePushRule(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + // Check if the rule exists first + rule, err := c.db.Accounts.GetPushRuleForUser(r.Context(), userID, kind, ruleID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + if rule == nil { + util.ResponseErrorJSON(w, r, mautrix.MNotFound) + return + } + + if err := c.db.Accounts.DeletePushRuleForUser(r.Context(), userID, kind, ruleID); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) +} + +// https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3pushrulesscopekindruleidenabled +func (c *ClientRoutes) GetPushRuleEnabled(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + rule, err := c.db.Accounts.GetPushRuleForUser(r.Context(), userID, kind, ruleID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + if rule == nil { + util.ResponseErrorJSON(w, r, mautrix.MNotFound) + return + } + + util.ResponseJSON(w, r, http.StatusOK, map[string]bool{ + "enabled": rule.Enabled, + }) +} + +// https://spec.matrix.org/v1.11/client-server-api/#put_matrixclientv3pushrulesscopekindruleidenabled +func (c *ClientRoutes) SetPushRuleEnabled(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + type reqType struct { + Enabled bool `json:"enabled"` + } + req, respErr := util.ParseRequestJSON[reqType](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) + return + } + + // Get the existing rule + rule, err := c.db.Accounts.GetPushRuleForUser(r.Context(), userID, kind, ruleID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + if rule == nil { + util.ResponseErrorJSON(w, r, mautrix.MNotFound) + return + } + + // Update the enabled status + storedRule := types.NewStoredPushRuleFromPushRule(rule) + storedRule.Enabled = req.Enabled + + if err := c.db.Accounts.PutPushRuleForUser(r.Context(), userID, kind, ruleID, storedRule); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) +} + +// https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3pushrulesscopekindruleidactions +func (c *ClientRoutes) GetPushRuleActions(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + rule, err := c.db.Accounts.GetPushRuleForUser(r.Context(), userID, kind, ruleID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + if rule == nil { + util.ResponseErrorJSON(w, r, mautrix.MNotFound) + return + } + + util.ResponseJSON(w, r, http.StatusOK, map[string]any{ + "actions": rule.Actions, + }) +} + +// https://spec.matrix.org/v1.11/client-server-api/#put_matrixclientv3pushrulesscopekindruleidactions +func (c *ClientRoutes) SetPushRuleActions(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetRequestUserID(r) + scope := chi.URLParam(r, "scope") + kindStr := chi.URLParam(r, "kind") + ruleID := chi.URLParam(r, "ruleId") + + if scope != "global" { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Only global scope is supported") + return + } + + kind, valid := parseRuleKind(kindStr) + if !valid { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid rule kind") + return + } + + type reqType struct { + Actions pushrules.PushActionArray `json:"actions"` + } + req, respErr := util.ParseRequestJSON[reqType](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) + return + } + + // Get the existing rule + rule, err := c.db.Accounts.GetPushRuleForUser(r.Context(), userID, kind, ruleID) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + if rule == nil { + util.ResponseErrorJSON(w, r, mautrix.MNotFound) + return + } + + // Update the actions + storedRule := types.NewStoredPushRuleFromPushRule(rule) + storedRule.Actions = req.Actions + + if err := c.db.Accounts.PutPushRuleForUser(r.Context(), userID, kind, ruleID, storedRule); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + + util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) } diff --git a/internal/routes/client/receipts.go b/internal/routes/client/receipts.go index c2f3188..bf7d822 100644 --- a/internal/routes/client/receipts.go +++ b/internal/routes/client/receipts.go @@ -19,11 +19,18 @@ func (c *ClientRoutes) SendRoomReadReceipt(w http.ResponseWriter, r *http.Reques eventID := util.EventIDFromRequestURLParam(r, "eventID") receiptType := chi.URLParam(r, "receiptType") + req, respErr := util.ParseRequestJSON[mautrix.ReqSendReceipt](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) + return + } + rc := types.Receipt{ ReceiptTup: types.ReceiptTup{ - UserID: middleware.GetRequestUserID(r), - RoomID: roomID, - Type: event.ReceiptType(receiptType), + UserID: middleware.GetRequestUserID(r), + RoomID: roomID, + Type: event.ReceiptType(receiptType), + ThreadID: req.ThreadID, }, EventID: eventID, Timestamp: time.Now().UTC().UnixMilli(), diff --git a/internal/routes/client/roomcreate.go b/internal/routes/client/roomcreate.go index ed248a6..7cbc69b 100644 --- a/internal/routes/client/roomcreate.go +++ b/internal/routes/client/roomcreate.go @@ -163,7 +163,7 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { } } - _, err := c.db.Rooms.SendLocalEvents(r.Context(), roomID, evs, rooms.SendLocalEventsOptions{}) + _, err := c.db.SendLocalEvents(r.Context(), roomID, evs, rooms.SendLocalEventsOptions{}) if err != nil { util.ResponseErrorUnknownJSON(w, r, fmt.Errorf("error sending local events: %w", err)) return diff --git a/internal/routes/client/roommember.go b/internal/routes/client/roommember.go index d8f049e..a439c94 100644 --- a/internal/routes/client/roommember.go +++ b/internal/routes/client/roommember.go @@ -250,7 +250,7 @@ func (c *ClientRoutes) sendRoomJoin(w http.ResponseWriter, r *http.Request) { util.SortEventList(allEvs) - if _, err = c.db.Rooms.SendFederatedEvents( + if _, err = c.db.SendFederatedEvents( backgroundCtx, roomID, allEvs, rooms.SendFederatedEventsOptions{ // We're joining *now* and won't have all prev event history, ultimately we have @@ -438,7 +438,7 @@ func (c *ClientRoutes) sendRoomLeaveOrKick(w http.ResponseWriter, r *http.Reques Content: exerrors.Must(json.Marshal(ev)), } - _, err := c.db.Transient.SendToDeviceEvents(r.Context(), []*types.ToDevice{td}, transient.SendToDeviceOptions{}) + _, err := c.db.SendToDeviceEvents(r.Context(), []*types.ToDevice{td}, transient.SendToDeviceOptions{}) if err != nil { hlog.FromRequest(r).Err(err). Msg("Failed to send federated leave event over to-device to nonjoined server") diff --git a/internal/routes/client/roomutil.go b/internal/routes/client/roomutil.go index 38cb9fc..b1afefe 100644 --- a/internal/routes/client/roomutil.go +++ b/internal/routes/client/roomutil.go @@ -28,7 +28,7 @@ func (c *ClientRoutes) sendLocalEventHandleResults( partialEv *types.PartialEvent, responseGen func(ev *types.Event) any, ) { - res, err := c.db.Rooms.SendLocalEvents(r.Context(), roomID, []*types.PartialEvent{partialEv}, rooms.SendLocalEventsOptions{}) + res, err := c.db.SendLocalEvents(r.Context(), roomID, []*types.PartialEvent{partialEv}, rooms.SendLocalEventsOptions{}) if errors.Is(err, types.ErrRoomNotFound) { util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, err.Error()) } else if err != nil { @@ -113,7 +113,7 @@ func (c *ClientRoutes) prepareAndSendInviteForRemoteUser( // Now that we've prepared, other HS signed and we verified the event we can send it. We send // it as if it's a federated event which triggers all the authorization checks, accounting for // any state changes in the room during the signing process above. - results, err := c.db.Rooms.SendFederatedEvents(backgroundCtx, roomID, []*types.Event{ev}, rooms.SendFederatedEventsOptions{}) + results, err := c.db.SendFederatedEvents(backgroundCtx, roomID, []*types.Event{ev}, rooms.SendFederatedEventsOptions{}) if err != nil { return nil, nil, err } diff --git a/internal/routes/client/todevice.go b/internal/routes/client/todevice.go index adf0b77..9bc5d3b 100644 --- a/internal/routes/client/todevice.go +++ b/internal/routes/client/todevice.go @@ -7,7 +7,6 @@ import ( "github.com/rs/zerolog/hlog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/databases/transient" "github.com/beeper/babbleserv/internal/middleware" @@ -42,32 +41,13 @@ func (c *ClientRoutes) SendToDevice(w http.ResponseWriter, r *http.Request) { } } for targetDeviceID, content := range devices { - deviceIDs := make([]id.DeviceID, 0, 1) - - if targetDeviceID == "*" && targetUserHS == c.config.ServerName { - // If sending to a local user with "*" as the device ID, fetch all the users - // devices and create a to device for each. - userDevices, err := c.db.Accounts.GetUserDevices(r.Context(), targetUserID) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - for _, d := range userDevices { - deviceIDs = append(deviceIDs, d.ID) - } - } else { - deviceIDs = append(deviceIDs, targetDeviceID) - } - - for _, did := range deviceIDs { - tds = append(tds, &types.ToDevice{ - UserID: targetUserID, - DeviceID: did, - Sender: userDevice.UserID, - Type: eventType, - Content: content.VeryRaw, - }) - } + tds = append(tds, &types.ToDevice{ + UserID: targetUserID, + DeviceID: targetDeviceID, + Sender: userDevice.UserID, + Type: eventType, + Content: content.VeryRaw, + }) } } @@ -77,7 +57,7 @@ func (c *ClientRoutes) SendToDevice(w http.ResponseWriter, r *http.Request) { return } - _, err := c.db.Transient.SendToDeviceEvents(r.Context(), tds, transient.SendToDeviceOptions{ + _, err := c.db.SendToDeviceEvents(r.Context(), tds, transient.SendToDeviceOptions{ TransactionID: txnID, DeviceID: userDevice.DeviceID, }) diff --git a/internal/routes/federation/federation.go b/internal/routes/federation/federation.go index d932e47..25eac3a 100644 --- a/internal/routes/federation/federation.go +++ b/internal/routes/federation/federation.go @@ -64,39 +64,31 @@ func (f *FederationRoutes) AddFederationRoutes(rtr chi.Router) { requireServerAuth := middleware.NewServerAuthMiddleware(f.config.ServerName, f.keyStore) - if f.config.Rooms.Enabled { - rtr.MethodFunc(http.MethodPut, "/v1/send/{txnID}", requireServerAuth(f.SendTransaction)) + rtr.MethodFunc(http.MethodPut, "/v1/send/{txnID}", requireServerAuth(f.SendTransaction)) - rtr.MethodFunc(http.MethodGet, "/v1/event/{eventID}", requireServerAuth(f.GetEvent)) - rtr.MethodFunc(http.MethodGet, "/v1/event_auth/{roomID}/{eventID}", requireServerAuth(f.GetEventAuth)) - rtr.MethodFunc(http.MethodPost, "/v1/get_missing_events/{roomID}", requireServerAuth(f.GetMissingEvents)) + rtr.MethodFunc(http.MethodGet, "/v1/event/{eventID}", requireServerAuth(f.GetEvent)) + rtr.MethodFunc(http.MethodGet, "/v1/event_auth/{roomID}/{eventID}", requireServerAuth(f.GetEventAuth)) + rtr.MethodFunc(http.MethodPost, "/v1/get_missing_events/{roomID}", requireServerAuth(f.GetMissingEvents)) - rtr.MethodFunc(http.MethodGet, "/v1/state/{roomID}", requireServerAuth(f.GetState)) - rtr.MethodFunc(http.MethodGet, "/v1/state_ids/{roomID}", requireServerAuth(f.GetStateIDs)) + rtr.MethodFunc(http.MethodGet, "/v1/state/{roomID}", requireServerAuth(f.GetState)) + rtr.MethodFunc(http.MethodGet, "/v1/state_ids/{roomID}", requireServerAuth(f.GetStateIDs)) - rtr.MethodFunc(http.MethodPut, "/v2/invite/{roomID}/{eventID}", requireServerAuth(f.SignInvite)) + rtr.MethodFunc(http.MethodPut, "/v2/invite/{roomID}/{eventID}", requireServerAuth(f.SignInvite)) - rtr.MethodFunc(http.MethodGet, "/v1/make_join/{roomID}/{userID}", requireServerAuth(f.MakeJoin)) - rtr.MethodFunc(http.MethodGet, "/v1/make_leave/{roomID}/{userID}", requireServerAuth(f.MakeLeave)) - rtr.MethodFunc(http.MethodGet, "/v1/make_knock/{roomID}/{userID}", requireServerAuth(f.MakeKnock)) + rtr.MethodFunc(http.MethodGet, "/v1/make_join/{roomID}/{userID}", requireServerAuth(f.MakeJoin)) + rtr.MethodFunc(http.MethodGet, "/v1/make_leave/{roomID}/{userID}", requireServerAuth(f.MakeLeave)) + rtr.MethodFunc(http.MethodGet, "/v1/make_knock/{roomID}/{userID}", requireServerAuth(f.MakeKnock)) - rtr.MethodFunc(http.MethodPut, "/v2/send_join/{roomID}/{eventID}", requireServerAuth(f.SendJoin)) - rtr.MethodFunc(http.MethodPut, "/v2/send_leave/{roomID}/{eventID}", requireServerAuth(f.SendLeave)) - rtr.MethodFunc(http.MethodPut, "/v1/send_knock/{roomID}/{eventID}", requireServerAuth(f.SendKnock)) - } - - if f.config.Accounts.Enabled { - rtr.MethodFunc(http.MethodGet, "/v1/user/devices/{userID}", requireServerAuth(f.GetUserDevices)) + rtr.MethodFunc(http.MethodPut, "/v2/send_join/{roomID}/{eventID}", requireServerAuth(f.SendJoin)) + rtr.MethodFunc(http.MethodPut, "/v2/send_leave/{roomID}/{eventID}", requireServerAuth(f.SendLeave)) + rtr.MethodFunc(http.MethodPut, "/v1/send_knock/{roomID}/{eventID}", requireServerAuth(f.SendKnock)) - rtr.MethodFunc(http.MethodGet, "/v1/query/profile", requireServerAuth(f.QueryProfile)) + rtr.MethodFunc(http.MethodGet, "/v1/user/devices/{userID}", requireServerAuth(f.GetUserDevices)) - rtr.MethodFunc(http.MethodPost, "/v1/user/keys/query", requireServerAuth(f.QueryUserKeys)) - rtr.MethodFunc(http.MethodPost, "/v1/user/keys/claim", requireServerAuth(f.ClaimUserKeys)) - } + rtr.MethodFunc(http.MethodGet, "/v1/query/profile", requireServerAuth(f.QueryProfile)) - if f.config.Transient.Enabled { - - } + rtr.MethodFunc(http.MethodPost, "/v1/user/keys/query", requireServerAuth(f.QueryUserKeys)) + rtr.MethodFunc(http.MethodPost, "/v1/user/keys/claim", requireServerAuth(f.ClaimUserKeys)) if f.config.Media.Enabled { rtr.MethodFunc(http.MethodGet, "/v1/media/download/{mediaID}", requireServerAuth(f.DownloadMedia)) diff --git a/internal/routes/federation/roommember.go b/internal/routes/federation/roommember.go index 1723a9c..7a5552c 100644 --- a/internal/routes/federation/roommember.go +++ b/internal/routes/federation/roommember.go @@ -66,7 +66,7 @@ func (f *FederationRoutes) SignInvite(w http.ResponseWriter, r *http.Request) { if serverInRoom { // We're in the room - so we can just send it directly as a federated event, we'll receive // it a second time over federation txn (will be ignored as dupe). - res, err := f.db.Rooms.SendFederatedEvents(r.Context(), roomID, []*types.Event{req.Event}, rooms.SendFederatedEventsOptions{}) + res, err := f.db.SendFederatedEvents(r.Context(), roomID, []*types.Event{req.Event}, rooms.SendFederatedEventsOptions{}) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return diff --git a/internal/routes/federation/roommemberutil.go b/internal/routes/federation/roommemberutil.go index 8a900e7..f04209e 100644 --- a/internal/routes/federation/roommemberutil.go +++ b/internal/routes/federation/roommemberutil.go @@ -216,7 +216,7 @@ func (f *FederationRoutes) sendMembershipEventFromOtherServer( return } } else { - if res, err := f.db.Rooms.SendFederatedEvents(r.Context(), roomID, []*types.Event{&ev}, rooms.SendFederatedEventsOptions{}); err != nil { + if res, err := f.db.SendFederatedEvents(r.Context(), roomID, []*types.Event{&ev}, rooms.SendFederatedEventsOptions{}); err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } else if len(res.Rejected) > 0 { diff --git a/internal/routes/federation/transaction.go b/internal/routes/federation/transaction.go index 158794f..edb94f3 100644 --- a/internal/routes/federation/transaction.go +++ b/internal/routes/federation/transaction.go @@ -237,7 +237,7 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types } } - _, err := f.db.Transient.SendToDeviceEvents(r.Context(), tds, transient.SendToDeviceOptions{}) + _, err := f.db.SendToDeviceEvents(r.Context(), tds, transient.SendToDeviceOptions{}) if err != nil { panic(err) } @@ -377,7 +377,7 @@ func (f *FederationRoutes) processTransactionPDUs(r *http.Request, origin string return } options := rooms.SendFederatedEventsOptions{} - results, err := f.db.Rooms.SendFederatedEvents(r.Context(), roomID, evs, options) + results, err := f.db.SendFederatedEvents(r.Context(), roomID, evs, options) if err != nil { // This is *BAD*, an unexpected error handling results for a room, we can't bail the // request here as we'll poison other parallel room sends. So we just log and none diff --git a/internal/types/device.go b/internal/types/device.go index 10054a1..d086dac 100644 --- a/internal/types/device.go +++ b/internal/types/device.go @@ -44,7 +44,7 @@ func MustNewDeviceFromBytes(b []byte) *Device { } } -func (d *Device) ToMsgpack() []byte { +func (d *Device) ToBytes() []byte { if b, err := msgpack.Marshal(d); err != nil { panic(err) } else { diff --git a/internal/types/event.go b/internal/types/event.go index 1df5b8b..cdd6a70 100644 --- a/internal/types/event.go +++ b/internal/types/event.go @@ -73,6 +73,7 @@ type Event struct { IsForClientAPI bool `msgpack:"-" json:"-"` IsDuplicate bool `msgpack:"-" json:"-"` IncompleteVersion tuple.Versionstamp `msgpack:"-" json:"-"` + PrevStateEvent *Event `msgpack:"-" json:"-"` } func NewEventFromBytes(b []byte, id id.EventID) (*Event, error) { @@ -284,6 +285,11 @@ func (ev *Event) Membership() event.Membership { return event.Membership(gjson.GetBytes(ev.Content, "membership").String()) } +func (ev *Event) Mentions() (m event.Mentions) { + json.Unmarshal([]byte(gjson.GetBytes(ev.Content, "m\\.mentions").Raw), &m) + return m +} + func (ev *Event) RelatesTo() (id.EventID, event.RelationType) { relatesTo := gjson.GetBytes(ev.Content, "m\\.relates_to") if !relatesTo.Exists() { diff --git a/internal/types/notifications.go b/internal/types/notifications.go new file mode 100644 index 0000000..e767919 --- /dev/null +++ b/internal/types/notifications.go @@ -0,0 +1,33 @@ +package types + +import ( + "github.com/vmihailenco/msgpack/v5" +) + +// Notifications represents notification and highlight counts for a single event. +// Stored as deltas per event version, summed for total counts. +type Notifications struct { + Count int `msgpack:"n"` // notification count delta (+1 for notifying event) + Highlight int `msgpack:"h"` // highlight count delta (+1 if highlighted) + ThreadID string `msgpack:"t"` +} + +func (n Notifications) IsEmpty() bool { + return n.Count == 0 && n.Highlight == 0 +} + +func NotificationsToBytes(n Notifications) []byte { + b, err := msgpack.Marshal(n) + if err != nil { + panic(err) + } + return b +} + +func BytesToNotifications(b []byte) Notifications { + var n Notifications + if err := msgpack.Unmarshal(b, &n); err != nil { + panic(err) + } + return n +} diff --git a/internal/types/pushrules.go b/internal/types/pushrules.go new file mode 100644 index 0000000..df3ad43 --- /dev/null +++ b/internal/types/pushrules.go @@ -0,0 +1,72 @@ +package types + +import ( + "github.com/vmihailenco/msgpack/v5" + "go.mau.fi/util/exerrors" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" +) + +// StoredPushRule is the stored version of a push rule that excludes Type and RuleID +// since those are stored in the key. +type StoredPushRule struct { + Actions pushrules.PushActionArray `msgpack:"ac" json:"actions"` + Default bool `msgpack:"de" json:"default"` + Enabled bool `msgpack:"en" json:"enabled"` + Conditions []*pushrules.PushCondition `msgpack:"co" json:"conditions,omitempty"` + Pattern string `msgpack:"pa" json:"pattern,omitempty"` +} + +func NewStoredPushRuleFromBytes(b []byte) (*StoredPushRule, error) { + var s StoredPushRule + if err := msgpack.Unmarshal(b, &s); err != nil { + return nil, err + } + return &s, nil +} + +func MustNewStoredPushRuleFromBytes(b []byte) *StoredPushRule { + return exerrors.Must(NewStoredPushRuleFromBytes(b)) +} + +func (s *StoredPushRule) ToBytes() []byte { + if b, err := msgpack.Marshal(s); err != nil { + panic(err) + } else { + return b + } +} + +func NewStoredPushRuleFromPushRule(rule *pushrules.PushRule) *StoredPushRule { + return &StoredPushRule{ + Actions: rule.Actions, + Default: rule.Default, + Enabled: rule.Enabled, + Conditions: rule.Conditions, + Pattern: rule.Pattern, + } +} + +func (s *StoredPushRule) ToPushRule(kind pushrules.PushRuleType, ruleID string) *pushrules.PushRule { + return &pushrules.PushRule{ + Type: kind, + RuleID: ruleID, + Actions: s.Actions, + Default: s.Default, + Enabled: s.Enabled, + Conditions: s.Conditions, + Pattern: s.Pattern, + } +} + +// PushRuleRoom implements the pushrules.Room interface for push rule evaluation +type PushRuleRoom struct { + MemberCount int + OwnDisplayname string +} + +func (r *PushRuleRoom) GetOwnDisplayname() string { return r.OwnDisplayname } +func (r *PushRuleRoom) GetMemberCount() int { return r.MemberCount } + +type UserPushRulesMap map[id.UserID]*pushrules.PushRuleset +type UserRoomContextMap map[id.UserID]*PushRuleRoom diff --git a/internal/types/sync.go b/internal/types/sync.go index 3735525..181bbda 100644 --- a/internal/types/sync.go +++ b/internal/types/sync.go @@ -9,6 +9,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" ) // Sync request @@ -56,6 +57,13 @@ func (o *SyncOptions) GetReceiptsLimit() int { return DefaultReceiptsLimit } +func (o *SyncOptions) UseRoomThreadedNotifications() bool { + if o == nil || o.Filter == nil || o.Filter.Room == nil || o.Filter.Room.Timeline == nil { + return false + } + return o.Filter.Room.Timeline.UnreadThreadNotifications +} + func (o *SyncOptions) GetRoomFilter() *mautrix.RoomFilter { if o == nil || o.Filter == nil { return nil @@ -105,9 +113,17 @@ func NewSync( rooms map[MembershipTup]*SyncRoom, accounts map[AccountDataTup]map[string]any, toDevice []*ToDeviceWithVersion, + pushRules *pushrules.PushRuleset, ) *Sync { sync := &Sync{} + // If push rules changed, add them as m.push_rules global account data + if pushRules != nil { + accounts[AccountDataTup{Type: event.AccountDataPushRules}] = map[string]any{ + "global": pushRules, + } + } + if len(toDevice) > 0 { // Convert internal device list to-device events into presence and device lists toDeviceEvents := make([]*PartialEvent, 0, len(toDevice)) @@ -278,12 +294,22 @@ type syncRoomKnock struct { KnockState EventList `json:"knock_state"` } +// UnreadNotificationCounts represents the unread notification counts for a room. +type UnreadNotificationCounts struct { + NotificationCount int `json:"notification_count"` + HighlightCount int `json:"highlight_count"` +} + type SyncRoom struct { // Rooms database - TimelineEvents Timeline `json:"timeline"` - StateEvents EventList `json:"state"` - Ephemeral EventList `json:"ephemeral"` - AccountData EventList `json:"account_data"` + TimelineEvents Timeline `json:"timeline"` + StateEvents EventList `json:"state"` + Ephemeral EventList `json:"ephemeral"` + AccountData EventList `json:"account_data"` + UnreadNotifications *UnreadNotificationCounts `json:"unread_notifications"` + + // Per-thread notification counts (MSC3773), keyed by thread root event ID + UnreadThreadNotifications map[string]*UnreadNotificationCounts `json:"unread_thread_notifications,omitzero"` Receipts []*ReceiptWithVersion `json:"-"` DeviceListChanges []id.UserID `json:"-"` diff --git a/internal/workers/compactnotificationiterator.go b/internal/workers/compactnotificationiterator.go new file mode 100644 index 0000000..392d42c --- /dev/null +++ b/internal/workers/compactnotificationiterator.go @@ -0,0 +1,119 @@ +package workers + +import ( + "context" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/config" + "github.com/beeper/babbleserv/internal/databases" + "github.com/beeper/babbleserv/internal/notifier" + "github.com/beeper/babbleserv/internal/util/lock" +) + +const ( + compactNotificationIteratorLockName = "CompactNotificationIteratorLock" + compactNotificationIteratorLockRetry = time.Second * 5 + compactNotificationIteratorLockTimeout = time.Second * 10 +) + +// The CompactNotificationIterator is a singleton background worker that iterates over events +// and compacts notification versions for affected users. This reduces the number of +// notification entries that need to be summed during sync. +type CompactNotificationIterator struct { + iteratorWorker + + roomIDToCount map[id.RoomID]int + roomIDToCancel map[id.RoomID]context.CancelFunc +} + +func NewCompactNotificationIterator( + log zerolog.Logger, + cfg config.BabbleConfig, + db *databases.Databases, + notifiers *notifier.Notifiers, +) *CompactNotificationIterator { + n := &CompactNotificationIterator{ + iteratorWorker: NewWorker( + "CompactNotificationIterator", log, cfg, db, notifiers, + compactNotificationIteratorLockName, + compactNotificationIteratorLockRetry, + compactNotificationIteratorLockTimeout, + ), + roomIDToCount: make(map[id.RoomID]int, 100), + roomIDToCancel: make(map[id.RoomID]context.CancelFunc, 100), + } + n.handler = n.handleNotificationsLoop + return n +} + +func (n *CompactNotificationIterator) handleNotificationsLoop(lock lock.Lock) { + newEventsCh := n.notifiers.Subscribe(notifier.Subscription{AllEvents: true}) + defer n.notifiers.Unsubscribe(newEventsCh) + + for { + select { + case <-n.ctx.Done(): + lock.Release() + return + case change := <-newEventsCh: + n.unlockedHandleChange(lock, change) + case <-time.After(compactNotificationIteratorLockRetry): + lock.Refresh() + } + } +} + +func (n *CompactNotificationIterator) unlockedHandleChange(lock lock.Lock, change notifier.Change) { + for _, roomID := range change.RoomIDs { + // First cancel any in-flight timeout for the room + if cancel, ok := n.roomIDToCancel[roomID]; ok { + cancel() + } + + // Bump counter, if > the notification limit, compact room and exit + n.roomIDToCount[roomID]++ + if n.roomIDToCount[roomID] > n.config.Rooms.MaxNotificationsPerUserRoom { + n.log.Debug(). + Stringer("room_id", roomID). + Int("max_notifications", n.config.Rooms.MaxNotificationsPerUserRoom). + Msg("Compacting room notifications after sufficient traffic") + n.compactNotificationsForRoom(roomID) + return + } + + // Less than configured changes in this room, set background timeout to + ctx, cancel := context.WithCancel(context.Background()) + n.roomIDToCancel[roomID] = cancel + go func() { + select { + case <-ctx.Done(): + n.log.Trace().Msg("Room compact timeout canceled") + return + case <-time.After(n.config.Rooms.CompactRoomNotificationsTimeout): + n.log.Debug(). + Stringer("room_id", roomID). + Dur("timeout", n.config.Rooms.CompactRoomNotificationsTimeout). + Msg("Compacting room notifications after timeout") + n.compactNotificationsForRoom(roomID) + } + }() + } +} + +func (n *CompactNotificationIterator) compactNotificationsForRoom(roomID id.RoomID) error { + memberships, err := n.db.Rooms.GetCurrentRoomLocalJoinedMemberships(n.ctx, roomID) + if err != nil { + return err + } + + for userID := range memberships { + if err := n.db.Rooms.CompactNotifications(n.ctx, userID, roomID); err != nil { + return err + } + } + + return nil +} diff --git a/internal/workers/devicechangeiterator.go b/internal/workers/devicechangeiterator.go index 088b7b8..7557cd6 100644 --- a/internal/workers/devicechangeiterator.go +++ b/internal/workers/devicechangeiterator.go @@ -181,21 +181,15 @@ func (d *DeviceChangeIterator) processRemoteDeviceChange(lock lock.Lock, change tds := make([]*types.ToDevice, 0, len(localUserIDs)) for userID := range localUserIDs { - userDevices, err := d.db.Accounts.GetUserDevices(d.ctx, userID) - if err != nil { - return err - } - for _, d := range userDevices { - tds = append(tds, &types.ToDevice{ - UserID: userID, - DeviceID: d.ID, - Sender: change.UserID, - Type: types.BabbleservLocalDeviceChange, - }) - } + tds = append(tds, &types.ToDevice{ + UserID: userID, + DeviceID: id.DeviceID("*"), + Sender: change.UserID, + Type: types.BabbleservLocalDeviceChange, + }) } - _, err = d.db.Transient.SendToDeviceEvents(d.ctx, tds, transient.SendToDeviceOptions{ + _, err = d.db.SendToDeviceEvents(d.ctx, tds, transient.SendToDeviceOptions{ // Ensure we hold the lock when comitting the events LockTxnRefresh: lock.TxnRefresh, }) @@ -249,18 +243,12 @@ func (d *DeviceChangeIterator) processLocalDeviceChange(lock lock.Lock, change t // If user is local - we just need to populate device_lists in sync, so send dummy to-device // events to be expanded later, no content needed as clients will query it. if userID.Homeserver() == d.config.ServerName { - userDevices, err := d.db.Accounts.GetUserDevices(d.ctx, userID) - if err != nil { - return err - } - for _, d := range userDevices { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceChange, - UserID: userID, - DeviceID: d.ID, - Sender: change.UserID, - }) - } + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceChange, + UserID: userID, + DeviceID: id.DeviceID("*"), + Sender: change.UserID, + }) continue } } @@ -332,7 +320,7 @@ func (d *DeviceChangeIterator) processLocalDeviceChange(lock lock.Lock, change t } } - _, err = d.db.Transient.SendToDeviceEvents(d.ctx, tds, transient.SendToDeviceOptions{ + _, err = d.db.SendToDeviceEvents(d.ctx, tds, transient.SendToDeviceOptions{ // Ensure we hold the lock when comitting the events LockTxnRefresh: lock.TxnRefresh, }) diff --git a/internal/workers/devicejoineventiterator.go b/internal/workers/devicejoineventiterator.go index 8be8df0..4ef50e3 100644 --- a/internal/workers/devicejoineventiterator.go +++ b/internal/workers/devicejoineventiterator.go @@ -165,7 +165,7 @@ func (e *DeviceJoinEventIterator) sendLocalDeviceChanges(lock lock.Lock, tups [] } if len(allTds) > 0 { - _, err := e.db.Transient.SendToDeviceEvents(e.ctx, allTds, transient.SendToDeviceOptions{ + _, err := e.db.SendToDeviceEvents(e.ctx, allTds, transient.SendToDeviceOptions{ // Ensure we hold the lock when comitting the events LockTxnRefresh: lock.TxnRefresh, }) @@ -206,38 +206,26 @@ func (e *DeviceJoinEventIterator) localDeviceChangesForJoinEvent(ev *types.Event if memberID.Homeserver() != e.config.ServerName { continue } - devices, err := e.db.Accounts.GetUserDevices(e.ctx, memberID) - if err != nil { - return nil, err - } - for _, d := range devices { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceChange, - UserID: memberID, - DeviceID: d.ID, - Sender: ev.Sender, - }) - } + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceChange, + UserID: memberID, + DeviceID: id.DeviceID("*"), + Sender: ev.Sender, + }) } // If the joining user is local, also notify them about changes to all other members if ev.Sender.Homeserver() == e.config.ServerName { - devices, err := e.db.Accounts.GetUserDevices(e.ctx, ev.Sender) - if err != nil { - return nil, err - } for memberID := range roomMembers { if memberID == ev.Sender { continue } - for _, d := range devices { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceChange, - UserID: ev.Sender, - DeviceID: d.ID, - Sender: memberID, - }) - } + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceChange, + UserID: ev.Sender, + DeviceID: id.DeviceID("*"), + Sender: memberID, + }) } } @@ -344,14 +332,6 @@ func (e *DeviceJoinEventIterator) localDeviceChangesForLeaveEvent(ev *types.Even return mtup.Membership == event.MembershipJoin }) - var leaverDevices []*types.Device - if ev.Sender.Homeserver() == e.config.ServerName { - leaverDevices, err = e.db.Accounts.GetUserDevices(e.ctx, ev.Sender) - if err != nil { - return nil, err - } - } - roomMembers, err := e.db.Rooms.GetCurrentRoomMemberships(e.ctx, ev.RoomID) if err != nil { return nil, err @@ -385,28 +365,20 @@ func (e *DeviceJoinEventIterator) localDeviceChangesForLeaveEvent(ev *types.Even // them both. if !match { if ev.Sender.Homeserver() == e.config.ServerName { - for _, d := range leaverDevices { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceLeft, - Sender: memberID, - UserID: ev.Sender, - DeviceID: d.ID, - }) - } + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceLeft, + Sender: memberID, + UserID: ev.Sender, + DeviceID: id.DeviceID("*"), + }) } if memberID.Homeserver() == e.config.ServerName { - devices, err := e.db.Accounts.GetUserDevices(e.ctx, memberID) - if err != nil { - return nil, err - } - for _, d := range devices { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceLeft, - Sender: ev.Sender, - UserID: memberID, - DeviceID: d.ID, - }) - } + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceLeft, + Sender: ev.Sender, + UserID: memberID, + DeviceID: id.DeviceID("*"), + }) } } } diff --git a/internal/workers/eventsiterator.go b/internal/workers/eventsiterator.go index 6e9fc5c..418b0a0 100644 --- a/internal/workers/eventsiterator.go +++ b/internal/workers/eventsiterator.go @@ -24,7 +24,6 @@ const ( // The EventsIterator is a singleton background worker that iterates over all events ever stored // by Babbleserv and triggers other things: // - starts federation senders for servers in rooms with new events -// - sends device change notifications for join/leave events in encrypted rooms type EventsIterator struct { iteratorWorker } diff --git a/internal/workers/federationsender.go b/internal/workers/federationsender.go index 374644a..e556def 100644 --- a/internal/workers/federationsender.go +++ b/internal/workers/federationsender.go @@ -88,57 +88,59 @@ func (fs *FederationSender) handleServersLoop(ctx context.Context, initialServer fs.wg.Add(1) defer fs.wg.Done() - newServersCh := make(chan any, 1000) + newServersCh := make(chan notifier.Change, 1000) fs.notifiers.SubscribeWithChannel(newServersCh, notifier.Subscription{AllServers: true}) defer fs.notifiers.Unsubscribe(newServersCh) - // Kick off a goroutine to push our initial servers into the queue - go func() { - for _, name := range initialServerNames { - newServersCh <- name - } - }() + // Push our initial servers into the queue + newServersCh <- notifier.Change{ + Servers: initialServerNames, + } for { select { case <-ctx.Done(): return - case server := <-newServersCh: - serverName := server.(string) + case change := <-newServersCh: + for _, serverName := range change.Servers { + fs.handleServerChange(ctx, serverName) + } + } + } +} - log := fs.log.With(). - Str("server", serverName). - Logger() - srvCtx := log.WithContext(ctx) +func (fs *FederationSender) handleServerChange(ctx context.Context, serverName string) { + log := fs.log.With(). + Str("server", serverName). + Logger() + srvCtx := log.WithContext(ctx) - if serverName == fs.config.ServerName { - log.Warn().Str("server", serverName).Msg("Ignoring change from ourselves") - continue - } + if serverName == fs.config.ServerName { + log.Warn().Str("server", serverName).Msg("Ignoring change from ourselves") + return + } - // First check our in memory map of active senders, avoid the FDB lock - // entirely if we're already running this sender. - fs.lock.RLock() - ch, found := fs.serverSenders[serverName] - select { - // Wakeup the sender if needed - case ch <- struct{}{}: - default: - } - fs.lock.RUnlock() + // First check our in memory map of active senders, avoid the FDB lock + // entirely if we're already running this sender. + fs.lock.RLock() + ch, found := fs.serverSenders[serverName] + select { + // Wakeup the sender if needed + case ch <- struct{}{}: + default: + } + fs.lock.RUnlock() - if found { - log.Debug(). - Str("server", serverName). - Msg("We are already running this server sender") - } else { - fs.wg.Add(1) - go func() { - fs.maybeRunServerSender(srvCtx, serverName) - fs.wg.Done() - }() - } - } + if found { + log.Debug(). + Str("server", serverName). + Msg("We are already running this server sender") + } else { + fs.wg.Add(1) + go func() { + fs.maybeRunServerSender(srvCtx, serverName) + fs.wg.Done() + }() } } diff --git a/internal/workers/presencechangeiterator.go b/internal/workers/presencechangeiterator.go index 90b14bf..66865b7 100644 --- a/internal/workers/presencechangeiterator.go +++ b/internal/workers/presencechangeiterator.go @@ -178,23 +178,17 @@ func (p *PresenceChangeIterator) processRemotePresenceChange(lock lock.Lock, cha tds := make([]*types.ToDevice, 0, len(localUserIDs)) for userID := range localUserIDs { - userDevices, err := p.db.Accounts.GetUserDevices(p.ctx, userID) - if err != nil { - return err - } - for _, d := range userDevices { - tds = append(tds, &types.ToDevice{ - UserID: userID, - DeviceID: d.ID, - Type: types.BabbleservLocalPresenceChange, - Sender: change.UserID, - Content: makePresenceLocalContent(change), - }) - } + tds = append(tds, &types.ToDevice{ + UserID: userID, + DeviceID: id.DeviceID("*"), + Type: types.BabbleservLocalPresenceChange, + Sender: change.UserID, + Content: makePresenceLocalContent(change), + }) } if len(tds) > 0 { - _, err = p.db.Transient.SendToDeviceEvents(p.ctx, tds, transient.SendToDeviceOptions{ + _, err = p.db.SendToDeviceEvents(p.ctx, tds, transient.SendToDeviceOptions{ // Ensure we hold the lock when comitting the events LockTxnRefresh: lock.TxnRefresh, }) @@ -250,20 +244,13 @@ func (p *PresenceChangeIterator) processLocalPresenceChange(lock lock.Lock, chan // If user is local - we just need to populate presence in sync, so send to-device // events to be expanded later. if userID.Homeserver() == p.config.ServerName { - userDevices, err := p.db.Accounts.GetUserDevices(p.ctx, userID) - if err != nil { - return err - } - - for _, d := range userDevices { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalPresenceChange, - UserID: userID, - DeviceID: d.ID, - Sender: change.UserID, - Content: makePresenceLocalContent(change), - }) - } + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalPresenceChange, + UserID: userID, + DeviceID: id.DeviceID("*"), + Sender: change.UserID, + Content: makePresenceLocalContent(change), + }) } } @@ -280,7 +267,7 @@ func (p *PresenceChangeIterator) processLocalPresenceChange(lock lock.Lock, chan } if len(tds) > 0 { - _, err = p.db.Transient.SendToDeviceEvents(p.ctx, tds, transient.SendToDeviceOptions{ + _, err = p.db.SendToDeviceEvents(p.ctx, tds, transient.SendToDeviceOptions{ // Ensure we hold the lock when comitting the events LockTxnRefresh: lock.TxnRefresh, }) diff --git a/internal/workers/profilechangeiterator.go b/internal/workers/profilechangeiterator.go index 6310ed3..94192ca 100644 --- a/internal/workers/profilechangeiterator.go +++ b/internal/workers/profilechangeiterator.go @@ -168,7 +168,7 @@ func (p *ProfileChangeIterator) processProfileChange(lock lock.Lock, change type ) // Send event to room - results, err := p.db.Rooms.SendLocalEvents( + results, err := p.db.SendLocalEvents( p.ctx, roomID, []*types.PartialEvent{partialEv}, diff --git a/internal/workers/pushnotificationiterator.go b/internal/workers/pushnotificationiterator.go new file mode 100644 index 0000000..46a5759 --- /dev/null +++ b/internal/workers/pushnotificationiterator.go @@ -0,0 +1,280 @@ +package workers + +import ( + "errors" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/tidwall/gjson" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules/pushgateway" + + "github.com/beeper/babbleserv/internal/config" + "github.com/beeper/babbleserv/internal/databases" + "github.com/beeper/babbleserv/internal/notifier" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util/lock" +) + +const ( + pushNotificationIteratorPositionsKey = "PushNotificationIteratorPositions" + pushNotificationIteratorLockName = "PushNotificationIteratorLock" + pushNotificationIteratorLockRetry = time.Second * 5 + pushNotificationIteratorLockTimeout = time.Second * 10 + pushNotificationIteratorBatchSize = 10 +) + +// The PushNotificationIterator sends push notifications to local user devices for new events. +// It also cleans up old notification entries to prevent indefinite growth. +type PushNotificationIterator struct { + iteratorWorker +} + +func NewPushNotificationIterator( + log zerolog.Logger, + cfg config.BabbleConfig, + db *databases.Databases, + notifiers *notifier.Notifiers, +) *PushNotificationIterator { + n := &PushNotificationIterator{NewWorker( + "PushNotificationIterator", log, cfg, db, notifiers, + pushNotificationIteratorLockName, + pushNotificationIteratorLockRetry, + pushNotificationIteratorLockTimeout, + )} + n.handler = n.handleNotificationsLoop + return n +} + +func (n *PushNotificationIterator) handleNotificationsLoop(lock lock.Lock) { + newEventsCh := n.notifiers.Subscribe(notifier.Subscription{AllEvents: true}) + defer n.notifiers.Unsubscribe(newEventsCh) + + // Cold start case: handle anything waiting right away + n.handleNotifications(lock) + + for { + select { + case <-n.ctx.Done(): + lock.Release() + return + case <-newEventsCh: + n.handleNotifications(lock) + case <-time.After(pushNotificationIteratorLockRetry): + lock.Refresh() + } + } +} + +func (n *PushNotificationIterator) handleNotifications(lock lock.Lock) { + startVersion, err := n.db.System.GetIteratorPositions(n.ctx, pushNotificationIteratorPositionsKey) + if err != nil { + n.log.Err(err).Msg("Failed to get current position") + return + } + + currentVersion := startVersion + for { + // Refresh the lock before we process each batch + lock.Refresh() + + newEventTups, err := n.db.Rooms.PaginateAllEventTups(n.ctx, types.PaginationOptions{ + From: currentVersion, + Limit: pushNotificationIteratorBatchSize, + }) + if err != nil { + n.log.Err(err).Msg("Failed to paginate events") + return + } else if len(newEventTups) == 0 { + n.log.Trace().Any("fromVersion", currentVersion).Msg("No events found") + break + } + + n.log.Debug(). + Int("events", len(newEventTups)). + Any("fromVersion", currentVersion). + Msg("Handling push notification batch") + + if err := n.sendPushNotificationsForEvents(newEventTups); err != nil { + n.log.Err(err).Msg("Failed to send push notifications") + } + + currentVersion = newEventTups[len(newEventTups)-1].Version + + if len(newEventTups) < pushNotificationIteratorBatchSize { + break + } + } + + if currentVersion == startVersion { + return + } + + // Update the position - refreshing the lock as part of the transaction to + // ensure the write is safe. + err = n.db.System.UpdateIteratorPositions(n.ctx, pushNotificationIteratorPositionsKey, currentVersion, lock.TxnRefresh) + if err != nil { + n.log.Err(err).Msg("Failed to update current position") + return + } +} + +func (n *PushNotificationIterator) sendPushNotificationsForEvents(tups []types.EventTupWithVersion) error { + // Group events by room + eventsByRoom := make(map[id.RoomID][]types.EventTupWithVersion) + for _, tup := range tups { + eventsByRoom[tup.RoomID] = append(eventsByRoom[tup.RoomID], tup) + } + + var wg sync.WaitGroup + + for roomID, eventTups := range eventsByRoom { + // Get local joined users in room + memberships, err := n.db.Rooms.GetCurrentRoomLocalJoinedMemberships(n.ctx, roomID) + if err != nil { + return err + } + + for userID := range memberships { + for _, eventTup := range eventTups { + // Check if notification exists at this event's version + notif, err := n.db.Rooms.GetNotificationAtVersion(n.ctx, userID, roomID, eventTup.Version) + if err != nil { + return err + } + if notif != nil { + wg.Add(1) + go func(userID id.UserID, eventTup types.EventTupWithVersion, notif types.Notifications) { + defer wg.Done() + n.sendPushForUser(userID, eventTup, notif) + }(userID, eventTup, *notif) + } + } + } + } + + // Wait for all push notifications to be sent before returning + wg.Wait() + + return nil +} + +func (n *PushNotificationIterator) sendPushForUser(userID id.UserID, eventTup types.EventTupWithVersion, notif types.Notifications) { + log := n.log.With(). + Stringer("user_id", userID). + Stringer("event_id", eventTup.EventID). + Stringer("room_id", eventTup.RoomID). + Logger() + + // Get user's pushers from accounts db + pushers, err := n.db.Accounts.GetPushersForUser(n.ctx, userID) + if err != nil { + log.Err(err).Msg("Failed to get pushers for user") + return + } + if len(pushers) == 0 { + return + } + + // Get full event for push content + ev, err := n.db.Rooms.GetEvent(n.ctx, eventTup.EventID) + if err != nil { + log.Err(err).Msg("Failed to get event") + return + } + if ev == nil { + log.Warn().Msg("Event not found") + return + } + + // Get current notification counts for this user/room (up to this event) + notifCount, highlightCount, err := n.db.Rooms.SumNotifications(n.ctx, userID, eventTup.RoomID, eventTup.Version) + if err != nil { + log.Err(err).Msg("Failed to sum notifications") + return + } + + // Determine priority based on highlight + priority := pushgateway.PushPriorityLow + if notif.Highlight > 0 { + priority = pushgateway.PushPriorityHigh + } + + // Send to each pusher + for _, pusher := range pushers { + if pusher.Kind == nil || *pusher.Kind != pushgateway.PusherKindHTTP { + continue + } + + url := pusher.Data.URL() + if url == "" { + log.Warn().Msg("HTTP pusher has no URL") + continue + } + + // Build PushNotification + pushNotif := &pushgateway.PushNotification{ + EventID: ev.ID, + RoomID: ev.RoomID, + Sender: ev.Sender, + Type: ev.Type.String(), + Priority: priority, + Counts: &pushgateway.NotificationCounts{ + Unread: notifCount + highlightCount, + }, + Devices: []pushgateway.Device{{ + BaseDevice: pushgateway.BaseDevice{ + AppID: pusher.AppID, + PushKey: pusher.PushKey, + Data: pusher.Data.ConvertToNotificationData(), + }, + }}, + } + + // Include content unless event_id_only format + if pusher.Data.Format() != pushgateway.PushFormatEventIDOnly { + pushNotif.Content = ev.Content + pushNotif.SenderDisplayName = n.getSenderDisplayName(ev) + pushNotif.RoomName = n.getRoomName(eventTup.RoomID) + } + + if err := pushNotif.Push(n.ctx, url); err != nil { + // If rejected, delete the pusher + if errors.Is(err, pushgateway.ErrPushRejected) { + log.Info().Str("pushkey", pusher.PushKey).Msg("Push rejected, deleting pusher") + if err := n.db.Accounts.DeletePusherForUser(n.ctx, userID, pusher.PushKey); err != nil { + log.Err(err).Str("pushkey", pusher.PushKey).Msg("Failed to delete rejected pusher") + } + } else { + log.Err(err).Str("pushkey", pusher.PushKey).Msg("Failed to send push notification") + } + } else { + log.Debug().Str("pushkey", pusher.PushKey).Msg("Sent push notification") + } + } + + log.Debug().Msg("Sent user push notifications") +} + +func (n *PushNotificationIterator) getSenderDisplayName(ev *types.Event) string { + // Try to get display name from profile + profile, err := n.db.Accounts.GetUserProfile(n.ctx, ev.Sender) + if err == nil && profile != nil && profile.DisplayName != "" { + return profile.DisplayName + } + return ev.Sender.Localpart() +} + +func (n *PushNotificationIterator) getRoomName(roomID id.RoomID) string { + // Try to get room name from state + roomNameEvent, err := n.db.Rooms.GetCurrentRoomStateEvent(n.ctx, roomID, types.StateTup{ + Type: event.StateRoomName, + StateKey: "", + }) + if err == nil && roomNameEvent != nil { + return gjson.GetBytes(roomNameEvent.Content, "name").String() + } + return "" +} diff --git a/internal/workers/workers.go b/internal/workers/workers.go index 7f06f68..14abfcc 100644 --- a/internal/workers/workers.go +++ b/internal/workers/workers.go @@ -30,39 +30,29 @@ func NewWorkers( Str("component", "workers"). Logger() - workers := []Worker{} + workers := []Worker{ + // Wakes up relevant federation senders for new events + NewEventsIterator(log, cfg, db, notifiers), + // Federation sender per remote homeserver + NewFederationSender(log, cfg, db, notifiers, fclient), + // Compacts notification versions for users + NewCompactNotificationIterator(log, cfg, db, notifiers), - if cfg.Rooms.Enabled { - workers = append(workers, - // Wakes up relevant federation senders for new events - NewEventsIterator(log, cfg, db, notifiers), - // Federation sender per remote homeserver - NewFederationSender(log, cfg, db, notifiers, fclient), - ) - } - - if cfg.Accounts.Enabled && cfg.Rooms.Enabled { // Profile changes from accounts -> member events in rooms - workers = append(workers, NewProfileChangeIterator(log, cfg, db, notifiers)) - } + NewProfileChangeIterator(log, cfg, db, notifiers), - if cfg.Accounts.Enabled && cfg.Transient.Enabled { - // Device changes from accounts -> internal to-device change notifications - workers = append(workers, NewDeviceChangeIterator(log, cfg, db, notifiers)) - } + // Uses push rules from accounts -> push notifications for new events + NewPushNotificationIterator(log, cfg, db, notifiers), - if cfg.Accounts.Enabled && cfg.Rooms.Enabled && cfg.Transient.Enabled { + // Device changes from accounts -> internal to-device change notifications + NewDeviceChangeIterator(log, cfg, db, notifiers), // Join events from rooms -> internal to-device change notifications (w/devices from accounts) - workers = append(workers, NewDeviceJoinEventIterator(log, cfg, db, notifiers)) - } + NewDeviceJoinEventIterator(log, cfg, db, notifiers), - if cfg.Transient.Enabled { - workers = append(workers, - // Presence change -> internal to-device presence notifications - NewPresenceChangeIterator(log, cfg, db, notifiers), - // Presence timeouts -> presence changes - NewPresenceTimeoutIterator(log, cfg, db, notifiers), - ) + // Presence change -> internal to-device presence notifications + NewPresenceChangeIterator(log, cfg, db, notifiers), + // Presence timeouts -> presence changes + NewPresenceTimeoutIterator(log, cfg, db, notifiers), } return &Workers{ diff --git a/scripts/dev.sh b/scripts/dev.sh index 27e074f..fd4170e 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -5,7 +5,7 @@ if [ $(uname -s) = "Darwin" ]; then export DYLD_FALLBACK_LIBRARY_PATH=/usr/local/lib fi -CMD="gow" +CMD="gow -e go,sql,yaml" if [ -n "${ONESHOT}" ]; then CMD="go" fi