all: add support for creator power

This commit is contained in:
Tulir Asokan 2025-07-11 12:55:44 +03:00
commit 0b62253d3b
11 changed files with 178 additions and 78 deletions

View file

@ -375,6 +375,24 @@ func (intent *IntentAPI) Member(ctx context.Context, roomID id.RoomID, userID id
return member
}
func (intent *IntentAPI) FillPowerLevelCreateEvent(ctx context.Context, roomID id.RoomID, pl *event.PowerLevelsEventContent) error {
if pl.CreateEvent != nil {
return nil
}
var err error
pl.CreateEvent, err = intent.StateStore.GetCreate(ctx, roomID)
if err != nil {
return fmt.Errorf("failed to get create event from cache: %w", err)
} else if pl.CreateEvent != nil {
return nil
}
pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "")
if err != nil {
return fmt.Errorf("failed to get create event from server: %w", err)
}
return nil
}
func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl *event.PowerLevelsEventContent, err error) {
pl, err = intent.as.StateStore.GetPowerLevels(ctx, roomID)
if err != nil {
@ -384,6 +402,12 @@ func (intent *IntentAPI) PowerLevels(ctx context.Context, roomID id.RoomID) (pl
if pl == nil {
pl = &event.PowerLevelsEventContent{}
err = intent.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl)
if err != nil {
return
}
}
if pl.CreateEvent == nil {
pl.CreateEvent, err = intent.FullStateEvent(ctx, roomID, event.StateCreate, "")
}
return
}
@ -398,8 +422,7 @@ func (intent *IntentAPI) SetPowerLevel(ctx context.Context, roomID id.RoomID, us
return nil, err
}
if pl.GetUserLevel(userID) != level {
pl.SetUserLevel(userID, level)
if pl.EnsureUserLevelAs(intent.UserID, userID, level) {
return intent.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", &pl)
}
return nil, nil

View file

@ -534,6 +534,14 @@ func (br *Connector) GetPowerLevels(ctx context.Context, roomID id.RoomID) (*eve
return br.Bot.PowerLevels(ctx, roomID)
}
func (br *Connector) GetCreateEvent(ctx context.Context, roomID id.RoomID) (*event.Event, error) {
createEvt, err := br.Bot.StateStore.GetCreate(ctx, roomID)
if err != nil || createEvt != nil {
return createEvt, err
}
return br.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "")
}
func (br *Connector) GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error) {
fetched, err := br.Bot.StateStore.HasFetchedMembers(ctx, roomID)
if err != nil {

View file

@ -47,6 +47,7 @@ type MatrixConnector interface {
GenerateContentURI(ctx context.Context, mediaID networkid.MediaID) (id.ContentURIString, error)
GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error)
GetCreateEvent(ctx context.Context, roomID id.RoomID) (*event.Event, error)
GetMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error)
GetMemberInfo(ctx context.Context, roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, error)

View file

@ -1654,6 +1654,13 @@ func (portal *Portal) handleMatrixPowerLevels(
log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type")
return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed))
}
if content.CreateEvent == nil {
var err error
content.CreateEvent, err = portal.Bridge.Matrix.GetCreateEvent(ctx, portal.MXID)
if err != nil {
return EventHandlingResultFailed.WithMSSError(fmt.Errorf("failed to get create event for power levels: %w", err))
}
}
api, ok := sender.Client.(PowerLevelHandlingNetworkAPI)
if !ok {
return EventHandlingResultIgnored.WithMSSError(ErrPowerLevelsNotSupported)
@ -1662,6 +1669,7 @@ func (portal *Portal) handleMatrixPowerLevels(
if evt.Unsigned.PrevContent != nil {
_ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
prevContent, _ = evt.Unsigned.PrevContent.Parsed.(*event.PowerLevelsEventContent)
prevContent.CreateEvent = content.CreateEvent
}
plChange := &MatrixPowerLevelChange{

View file

@ -1548,12 +1548,15 @@ func (cli *Client) FullStateEvent(ctx context.Context, roomID id.RoomID, eventTy
"format": "event",
})
_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &evt)
if err == nil && cli.StateStore != nil {
UpdateStateStore(ctx, cli.StateStore, evt)
}
if evt != nil {
evt.Type.Class = event.StateEventType
_ = evt.Content.ParseRaw(evt.Type)
if evt.RoomID == "" {
evt.RoomID = roomID
}
}
if err == nil && cli.StateStore != nil {
UpdateStateStore(ctx, cli.StateStore, evt)
}
return
}
@ -1606,12 +1609,21 @@ func (cli *Client) State(ctx context.Context, roomID id.RoomID) (stateMap RoomSt
ResponseJSON: &stateMap,
Handler: parseRoomStateArray,
})
if stateMap != nil {
pls, ok := stateMap[event.StatePowerLevels][""]
if ok {
pls.Content.AsPowerLevels().CreateEvent = stateMap[event.StateCreate][""]
}
}
if err == nil && cli.StateStore != nil {
for evtType, evts := range stateMap {
if evtType == event.StateMember {
continue
}
for _, evt := range evts {
if evt.RoomID == "" {
evt.RoomID = roomID
}
UpdateStateStore(ctx, cli.StateStore, evt)
}
}

View file

@ -7,6 +7,8 @@
package event
import (
"math"
"slices"
"sync"
"go.mau.fi/util/ptr"
@ -34,6 +36,10 @@ type PowerLevelsEventContent struct {
KickPtr *int `json:"kick,omitempty"`
BanPtr *int `json:"ban,omitempty"`
RedactPtr *int `json:"redact,omitempty"`
// This is not a part of power levels, it's added by mautrix-go internally in certain places
// in order to detect creator power accurately.
CreateEvent *Event `json:"-,omitempty"`
}
func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent {
@ -53,6 +59,8 @@ func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent {
KickPtr: ptr.Clone(pl.KickPtr),
BanPtr: ptr.Clone(pl.BanPtr),
RedactPtr: ptr.Clone(pl.RedactPtr),
CreateEvent: pl.CreateEvent,
}
}
@ -112,6 +120,9 @@ func (pl *PowerLevelsEventContent) StateDefault() int {
}
func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int {
if pl.isCreator(userID) {
return math.MaxInt
}
pl.usersLock.RLock()
defer pl.usersLock.RUnlock()
level, ok := pl.Users[userID]
@ -138,9 +149,24 @@ func (pl *PowerLevelsEventContent) EnsureUserLevel(target id.UserID, level int)
return pl.EnsureUserLevelAs("", target, level)
}
func (pl *PowerLevelsEventContent) createContent() *CreateEventContent {
if pl.CreateEvent == nil {
return &CreateEventContent{}
}
return pl.CreateEvent.Content.AsCreate()
}
func (pl *PowerLevelsEventContent) isCreator(userID id.UserID) bool {
cc := pl.createContent()
return cc.SupportsCreatorPower() && (userID == pl.CreateEvent.Sender || slices.Contains(cc.AdditionalCreators, userID))
}
func (pl *PowerLevelsEventContent) EnsureUserLevelAs(actor, target id.UserID, level int) bool {
if pl.isCreator(target) {
return false
}
existingLevel := pl.GetUserLevel(target)
if actor != "" {
if actor != "" && !pl.isCreator(actor) {
actorLevel := pl.GetUserLevel(actor)
if actorLevel <= existingLevel || actorLevel < level {
return false
@ -185,7 +211,7 @@ func (pl *PowerLevelsEventContent) EnsureEventLevel(eventType Type, level int) b
func (pl *PowerLevelsEventContent) EnsureEventLevelAs(actor id.UserID, eventType Type, level int) bool {
existingLevel := pl.GetEventLevel(eventType)
if actor != "" {
if actor != "" && !pl.isCreator(actor) {
actorLevel := pl.GetUserLevel(actor)
if existingLevel > actorLevel || level > actorLevel {
return false

View file

@ -88,6 +88,7 @@ const (
RoomV9 RoomVersion = "9"
RoomV10 RoomVersion = "10"
RoomV11 RoomVersion = "11"
RoomV12 RoomVersion = "12"
)
// CreateEventContent represents the content of a m.room.create state event.
@ -98,10 +99,23 @@ type CreateEventContent struct {
RoomVersion RoomVersion `json:"room_version,omitempty"`
Predecessor *Predecessor `json:"predecessor,omitempty"`
// Room v12+ only
AdditionalCreators []id.UserID `json:"additional_creators,omitempty"`
// Deprecated: use the event sender instead
Creator id.UserID `json:"creator,omitempty"`
}
func (cec *CreateEventContent) SupportsCreatorPower() bool {
switch cec.RoomVersion {
case "", RoomV1, RoomV2, RoomV3, RoomV4, RoomV5, RoomV6, RoomV7, RoomV8, RoomV9, RoomV10, RoomV11:
return false
default:
// Assume anything except known old versions supports creator power.
return true
}
}
// JoinRule specifies how open a room is to new members.
// https://spec.matrix.org/v1.2/client-server-api/#mroomjoin_rules
type JoinRule string

View file

@ -379,89 +379,67 @@ func (store *SQLStateStore) SetPowerLevels(ctx context.Context, roomID id.RoomID
}
func (store *SQLStateStore) GetPowerLevels(ctx context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) {
levels = &event.PowerLevelsEventContent{}
err = store.
QueryRow(ctx, "SELECT power_levels FROM mx_room_state WHERE room_id=$1", roomID).
Scan(&dbutil.JSON{Data: &levels})
QueryRow(ctx, "SELECT power_levels, create_event FROM mx_room_state WHERE room_id=$1", roomID).
Scan(&dbutil.JSON{Data: &levels}, &dbutil.JSON{Data: &levels.CreateEvent})
if errors.Is(err, sql.ErrNoRows) {
err = nil
return nil, nil
} else if err != nil {
return nil, err
}
if levels.CreateEvent != nil {
err = levels.CreateEvent.Content.ParseRaw(event.StateCreate)
}
return
}
func (store *SQLStateStore) GetPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID) (int, error) {
if store.Dialect == dbutil.Postgres {
var powerLevel int
err := store.
QueryRow(ctx, `
SELECT COALESCE((power_levels->'users'->$2)::int, (power_levels->'users_default')::int, 0)
FROM mx_room_state WHERE room_id=$1
`, roomID, userID).
Scan(&powerLevel)
return powerLevel, err
} else {
levels, err := store.GetPowerLevels(ctx, roomID)
if err != nil {
return 0, err
}
return levels.GetUserLevel(userID), nil
levels, err := store.GetPowerLevels(ctx, roomID)
if err != nil {
return 0, err
}
return levels.GetUserLevel(userID), nil
}
func (store *SQLStateStore) GetPowerLevelRequirement(ctx context.Context, roomID id.RoomID, eventType event.Type) (int, error) {
if store.Dialect == dbutil.Postgres {
defaultType := "events_default"
defaultValue := 0
if eventType.IsState() {
defaultType = "state_default"
defaultValue = 50
}
var powerLevel int
err := store.
QueryRow(ctx, `
SELECT COALESCE((power_levels->'events'->$2)::int, (power_levels->'$3')::int, $4)
FROM mx_room_state WHERE room_id=$1
`, roomID, eventType.Type, defaultType, defaultValue).
Scan(&powerLevel)
if errors.Is(err, sql.ErrNoRows) {
err = nil
powerLevel = defaultValue
}
return powerLevel, err
} else {
levels, err := store.GetPowerLevels(ctx, roomID)
if err != nil {
return 0, err
}
return levels.GetEventLevel(eventType), nil
levels, err := store.GetPowerLevels(ctx, roomID)
if err != nil {
return 0, err
}
return levels.GetEventLevel(eventType), nil
}
func (store *SQLStateStore) HasPowerLevel(ctx context.Context, roomID id.RoomID, userID id.UserID, eventType event.Type) (bool, error) {
if store.Dialect == dbutil.Postgres {
defaultType := "events_default"
defaultValue := 0
if eventType.IsState() {
defaultType = "state_default"
defaultValue = 50
}
var hasPower bool
err := store.
QueryRow(ctx, `SELECT
COALESCE((power_levels->'users'->$2)::int, (power_levels->'users_default')::int, 0)
>=
COALESCE((power_levels->'events'->$3)::int, (power_levels->'$4')::int, $5)
FROM mx_room_state WHERE room_id=$1`, roomID, userID, eventType.Type, defaultType, defaultValue).
Scan(&hasPower)
if errors.Is(err, sql.ErrNoRows) {
err = nil
hasPower = defaultValue == 0
}
return hasPower, err
} else {
levels, err := store.GetPowerLevels(ctx, roomID)
if err != nil {
return false, err
}
return levels.GetUserLevel(userID) >= levels.GetEventLevel(eventType), nil
levels, err := store.GetPowerLevels(ctx, roomID)
if err != nil {
return false, err
}
return levels.GetUserLevel(userID) >= levels.GetEventLevel(eventType), nil
}
func (store *SQLStateStore) SetCreate(ctx context.Context, evt *event.Event) error {
if evt.Type != event.StateCreate {
return fmt.Errorf("invalid event type for create event: %s", evt.Type)
}
_, err := store.Exec(ctx, `
INSERT INTO mx_room_state (room_id, create_event) VALUES ($1, $2)
ON CONFLICT (room_id) DO UPDATE SET create_event=excluded.create_event
`, evt.RoomID, dbutil.JSON{Data: evt})
return err
}
func (store *SQLStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (evt *event.Event, err error) {
err = store.
QueryRow(ctx, "SELECT create_event FROM mx_room_state WHERE room_id=$1", roomID).
Scan(&dbutil.JSON{Data: &evt})
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, err
}
if evt != nil {
err = evt.Content.ParseRaw(event.StateCreate)
}
return
}

View file

@ -1,4 +1,4 @@
-- v0 -> v7 (compatible with v3+): Latest revision
-- v0 -> v8 (compatible with v3+): Latest revision
CREATE TABLE mx_registrations (
user_id TEXT PRIMARY KEY
@ -26,5 +26,6 @@ CREATE TABLE mx_room_state (
room_id TEXT PRIMARY KEY,
power_levels jsonb,
encryption jsonb,
create_event jsonb,
members_fetched BOOLEAN NOT NULL DEFAULT false
);

View file

@ -0,0 +1,2 @@
-- v8 (compatible with v3+): Add create event to room state table
ALTER TABLE mx_room_state ADD COLUMN create_event jsonb;

View file

@ -34,6 +34,9 @@ type StateStore interface {
SetPowerLevels(ctx context.Context, roomID id.RoomID, levels *event.PowerLevelsEventContent) error
GetPowerLevels(ctx context.Context, roomID id.RoomID) (*event.PowerLevelsEventContent, error)
SetCreate(ctx context.Context, evt *event.Event) error
GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error)
HasFetchedMembers(ctx context.Context, roomID id.RoomID) (bool, error)
MarkMembersFetched(ctx context.Context, roomID id.RoomID) error
GetAllMembers(ctx context.Context, roomID id.RoomID) (map[id.UserID]*event.MemberEventContent, error)
@ -68,9 +71,11 @@ func UpdateStateStore(ctx context.Context, store StateStore, evt *event.Event) {
err = store.SetPowerLevels(ctx, evt.RoomID, content)
case *event.EncryptionEventContent:
err = store.SetEncryptionEvent(ctx, evt.RoomID, content)
case *event.CreateEventContent:
err = store.SetCreate(ctx, evt)
default:
switch evt.Type {
case event.StateMember, event.StatePowerLevels, event.StateEncryption:
case event.StateMember, event.StatePowerLevels, event.StateEncryption, event.StateCreate:
zerolog.Ctx(ctx).Warn().
Stringer("event_id", evt.ID).
Str("event_type", evt.Type.Type).
@ -101,6 +106,7 @@ type MemoryStateStore struct {
MembersFetched map[id.RoomID]bool `json:"members_fetched"`
PowerLevels map[id.RoomID]*event.PowerLevelsEventContent `json:"power_levels"`
Encryption map[id.RoomID]*event.EncryptionEventContent `json:"encryption"`
Create map[id.RoomID]*event.Event `json:"create"`
registrationsLock sync.RWMutex
membersLock sync.RWMutex
@ -115,6 +121,7 @@ func NewMemoryStateStore() StateStore {
MembersFetched: make(map[id.RoomID]bool),
PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent),
Encryption: make(map[id.RoomID]*event.EncryptionEventContent),
Create: make(map[id.RoomID]*event.Event),
}
}
@ -298,6 +305,9 @@ func (store *MemoryStateStore) SetPowerLevels(_ context.Context, roomID id.RoomI
func (store *MemoryStateStore) GetPowerLevels(_ context.Context, roomID id.RoomID) (levels *event.PowerLevelsEventContent, err error) {
store.powerLevelsLock.RLock()
levels = store.PowerLevels[roomID]
if levels != nil && levels.CreateEvent == nil {
levels.CreateEvent = store.Create[roomID]
}
store.powerLevelsLock.RUnlock()
return
}
@ -314,6 +324,23 @@ func (store *MemoryStateStore) HasPowerLevel(ctx context.Context, roomID id.Room
return exerrors.Must(store.GetPowerLevel(ctx, roomID, userID)) >= exerrors.Must(store.GetPowerLevelRequirement(ctx, roomID, eventType)), nil
}
func (store *MemoryStateStore) SetCreate(ctx context.Context, evt *event.Event) error {
store.powerLevelsLock.Lock()
store.Create[evt.RoomID] = evt
if pls, ok := store.PowerLevels[evt.RoomID]; ok && pls.CreateEvent == nil {
pls.CreateEvent = evt
}
store.powerLevelsLock.Unlock()
return nil
}
func (store *MemoryStateStore) GetCreate(ctx context.Context, roomID id.RoomID) (*event.Event, error) {
store.powerLevelsLock.RLock()
evt := store.Create[roomID]
store.powerLevelsLock.RUnlock()
return evt, nil
}
func (store *MemoryStateStore) SetEncryptionEvent(_ context.Context, roomID id.RoomID, content *event.EncryptionEventContent) error {
store.encryptionLock.Lock()
store.Encryption[roomID] = content