event: implement disappearing timer types (#399)
Some checks are pending
Go / Lint (latest) (push) Waiting to run
Go / Build (old, libolm) (push) Waiting to run
Go / Build (latest, libolm) (push) Waiting to run
Go / Build (old, goolm) (push) Waiting to run
Go / Build (latest, goolm) (push) Waiting to run

Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
Kishan Bagaria 2025-08-22 14:46:56 +05:30 committed by GitHub
commit 1d484e01d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 127 additions and 14 deletions

View file

@ -12,28 +12,41 @@ import (
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// DisappearingType represents the type of a disappearing message timer.
type DisappearingType string
// Deprecated: use [event.DisappearingType]
type DisappearingType = event.DisappearingType
// Deprecated: use constants in event package
const (
DisappearingTypeNone DisappearingType = ""
DisappearingTypeAfterRead DisappearingType = "after_read"
DisappearingTypeAfterSend DisappearingType = "after_send"
DisappearingTypeNone = event.DisappearingTypeNone
DisappearingTypeAfterRead = event.DisappearingTypeAfterRead
DisappearingTypeAfterSend = event.DisappearingTypeAfterSend
)
// DisappearingSetting represents a disappearing message timer setting
// by combining a type with a timer and an optional start timestamp.
type DisappearingSetting struct {
Type DisappearingType
Type event.DisappearingType
Timer time.Duration
DisappearAt time.Time
}
func (ds DisappearingSetting) ToEventContent() *event.BeeperDisappearingTimer {
if ds.Type == event.DisappearingTypeNone || ds.Timer == 0 {
return nil
}
return &event.BeeperDisappearingTimer{
Type: ds.Type,
Timer: jsontime.MS(ds.Timer),
}
}
type DisappearingMessageQuery struct {
BridgeID networkid.BridgeID
*dbutil.QueryHelper[*DisappearingMessage]

View file

@ -16,6 +16,7 @@ import (
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@ -34,9 +35,20 @@ type PortalQuery struct {
*dbutil.QueryHelper[*Portal]
}
type CapStateFlags uint32
func (csf CapStateFlags) Has(flag CapStateFlags) bool {
return csf&flag != 0
}
const (
CapStateFlagDisappearingTimerSet CapStateFlags = 1 << iota
)
type CapabilityState struct {
Source networkid.UserLoginID `json:"source"`
ID string `json:"id"`
Flags CapStateFlags `json:"flags"`
}
type Portal struct {
@ -208,7 +220,7 @@ func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
}
if disappearType.Valid {
p.Disappear = DisappearingSetting{
Type: DisappearingType(disappearType.String),
Type: event.DisappearingType(disappearType.String),
Timer: time.Duration(disappearTimer.Int64),
}
}

View file

@ -1101,7 +1101,7 @@ func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserLogin
}
portal.sendSuccessStatus(ctx, evt, resp.StreamOrder, message.MXID)
}
if portal.Disappear.Type != database.DisappearingTypeNone {
if portal.Disappear.Type != event.DisappearingTypeNone {
go portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{
RoomID: portal.MXID,
EventID: message.MXID,
@ -2281,6 +2281,7 @@ func (portal *Portal) sendConvertedMessage(
allSuccess := true
for i, part := range converted.Parts {
portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent)
part.Content.BeeperDisappearingTimer = converted.Disappear.ToEventContent()
dbMessage := &database.Message{
ID: id,
PartID: part.ID,
@ -2325,8 +2326,8 @@ func (portal *Portal) sendConvertedMessage(
logContext(log.Err(err)).Str("part_id", string(part.ID)).Msg("Failed to save message part to database")
allSuccess = false
}
if converted.Disappear.Type != database.DisappearingTypeNone && !dbMessage.HasFakeMXID() {
if converted.Disappear.Type == database.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() {
if converted.Disappear.Type != event.DisappearingTypeNone && !dbMessage.HasFakeMXID() {
if converted.Disappear.Type == event.DisappearingTypeAfterSend && converted.Disappear.DisappearAt.IsZero() {
converted.Disappear.DisappearAt = dbMessage.Timestamp.Add(converted.Disappear.Timer)
}
portal.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{
@ -3648,6 +3649,15 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin,
portal.CapState = database.CapabilityState{
Source: source.ID,
ID: capID,
Flags: portal.CapState.Flags,
}
if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) {
zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event")
success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent())
if !success {
return false
}
portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet
}
portal.lastCapUpdate = time.Now()
if implicit {
@ -4030,7 +4040,7 @@ func DisappearingMessageNotice(expiration time.Duration, implicit bool) *event.M
func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting database.DisappearingSetting, sender MatrixAPI, ts time.Time, implicit, save bool) bool {
if setting.Timer == 0 {
setting.Type = ""
setting.Type = event.DisappearingTypeNone
}
if portal.Disappear.Timer == setting.Timer && portal.Disappear.Type == setting.Type {
return false
@ -4046,6 +4056,9 @@ func (portal *Portal) UpdateDisappearingSetting(ctx context.Context, setting dat
if portal.MXID == "" {
return true
}
portal.sendRoomMeta(ctx, sender, ts, event.StateBeeperDisappearingTimer, "", setting.ToEventContent())
content := DisappearingMessageNotice(setting.Timer, implicit)
if sender == nil {
sender = portal.Bridge.Bot
@ -4333,6 +4346,13 @@ func (portal *Portal) createMatrixRoomInLoop(ctx context.Context, source *UserLo
Type: event.StateBeeperRoomFeatures,
Content: event.Content{Parsed: roomFeatures},
})
if roomFeatures.DisappearingTimer != nil {
req.InitialState = append(req.InitialState, &event.Event{
Type: event.StateBeeperDisappearingTimer,
Content: event.Content{Parsed: portal.Disappear.ToEventContent()},
})
portal.CapState.Flags |= database.CapStateFlagDisappearingTimerSet
}
if req.Topic == "" {
// Add explicit topic event if topic is empty to ensure the event is set.
// This ensures that there won't be an extra event later if PUT /state/... is called.

View file

@ -339,6 +339,7 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin
for i, part := range msg.Parts {
partIDs = append(partIDs, part.ID)
portal.applyRelationMeta(ctx, part.Content, replyTo, threadRoot, prevThreadEvent)
part.Content.BeeperDisappearingTimer = msg.Disappear.ToEventContent()
evtID := portal.Bridge.Matrix.GenerateDeterministicEventID(portal.MXID, portal.PortalKey, msg.ID, part.ID)
dbMessage := &database.Message{
ID: msg.ID,
@ -379,8 +380,8 @@ func (portal *Portal) compileBatchMessage(ctx context.Context, source *UserLogin
prevThreadEvent.MXID = evtID
out.PrevThreadEvents[*msg.ThreadRoot] = evtID
}
if msg.Disappear.Type != database.DisappearingTypeNone {
if msg.Disappear.Type == database.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() {
if msg.Disappear.Type != event.DisappearingTypeNone {
if msg.Disappear.Type == event.DisappearingTypeAfterSend && msg.Disappear.DisappearAt.IsZero() {
msg.Disappear.DisappearAt = msg.Timestamp.Add(msg.Disappear.Timer)
}
out.Disappear = append(out.Disappear, &database.DisappearingMessage{

View file

@ -41,6 +41,8 @@ export interface RoomFeatures {
delete_max_age?: seconds
/** Whether deleting messages just for yourself is supported. No message age limit. */
delete_for_me?: boolean
/** Allowed configuration options for disappearing timers. */
disappearing_timer?: DisappearingTimerCapability
/** Whether reactions are supported. */
reaction?: CapabilitySupportLevel
@ -57,6 +59,7 @@ export interface RoomFeatures {
declare type integer = number
declare type seconds = integer
declare type milliseconds = integer
declare type MIMEClass = "image" | "audio" | "video" | "text" | "font" | "model" | "application"
declare type MIMETypeOrPattern =
"*/*"
@ -106,6 +109,24 @@ export interface FileFeatures {
view_once?: boolean
}
export enum DisappearingType {
None = "",
AfterRead = "after_read",
AfterSend = "after_send",
}
export interface DisappearingTimerCapability {
types: DisappearingType[]
timers: milliseconds[]
/**
* Whether clients should omit the empty disappearing_timer object in messages that they don't want to disappear
*
* Generally, bridged rooms will want the object to be always present, while native Matrix rooms don't,
* so the hardcoded features for Matrix rooms should set this to true, while bridges will not.
*/
omit_empty_timer?: true
}
/**
* The support level for a feature. These are integers rather than booleans
* to accurately represent what the bridge is doing and hopefully make the

View file

@ -44,6 +44,8 @@ type RoomFeatures struct {
DeleteForMe bool `json:"delete_for_me,omitempty"`
DeleteMaxAge *jsontime.Seconds `json:"delete_max_age,omitempty"`
DisappearingTimer *DisappearingTimerCapability `json:"disappearing_timer,omitempty"`
Reaction CapabilitySupportLevel `json:"reaction,omitempty"`
ReactionCount int `json:"reaction_count,omitempty"`
AllowedReactions []string `json:"allowed_reactions,omitempty"`
@ -67,6 +69,13 @@ type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel
type FileFeatureMap map[CapabilityMsgType]*FileFeatures
type DisappearingTimerCapability struct {
Types []DisappearingType `json:"types"`
Timers []jsontime.Milliseconds `json:"timers"`
OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"`
}
type CapabilityMsgType = MessageType
// Message types which are used for event capability signaling, but aren't real values for the msgtype field.
@ -231,6 +240,7 @@ func (rf *RoomFeatures) Hash() []byte {
hashValue(hasher, "delete", rf.Delete)
hashBool(hasher, "delete_for_me", rf.DeleteForMe)
hashInt(hasher, "delete_max_age", rf.DeleteMaxAge.Get())
hashValue(hasher, "disappearing_timer", rf.DisappearingTimer)
hashValue(hasher, "reaction", rf.Reaction)
hashInt(hasher, "reaction_count", rf.ReactionCount)
@ -249,6 +259,22 @@ func (rf *RoomFeatures) Hash() []byte {
return hasher.Sum(nil)
}
func (dtc *DisappearingTimerCapability) Hash() []byte {
if dtc == nil {
return nil
}
hasher := sha256.New()
hasher.Write([]byte("types"))
for _, t := range dtc.Types {
hasher.Write([]byte(t))
}
hasher.Write([]byte("timers"))
for _, timer := range dtc.Timers {
hashInt(hasher, "", timer.Milliseconds())
}
return hasher.Sum(nil)
}
func (ff *FileFeatures) Hash() []byte {
hasher := sha256.New()
hashMap(hasher, "mime_types", ff.MimeTypes)

View file

@ -48,6 +48,7 @@ var TypeMap = map[Type]reflect.Type{
StateElementFunctionalMembers: reflect.TypeOf(ElementFunctionalMembersContent{}),
StateBeeperRoomFeatures: reflect.TypeOf(RoomFeatures{}),
StateBeeperDisappearingTimer: reflect.TypeOf(BeeperDisappearingTimer{}),
EventMessage: reflect.TypeOf(MessageEventContent{}),
EventSticker: reflect.TypeOf(MessageEventContent{}),

View file

@ -138,6 +138,8 @@ type MessageEventContent struct {
BeeperLinkPreviews []*BeeperLinkPreview `json:"com.beeper.linkpreviews,omitempty"`
BeeperDisappearingTimer *BeeperDisappearingTimer `json:"com.beeper.disappearing_timer,omitempty"`
MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"`
MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"`
}

View file

@ -10,6 +10,8 @@ import (
"encoding/base64"
"slices"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/id"
)
@ -207,6 +209,20 @@ type BridgeEventContent struct {
BeeperRoomTypeV2 string `json:"com.beeper.room_type.v2,omitempty"`
}
// DisappearingType represents the type of a disappearing message timer.
type DisappearingType string
const (
DisappearingTypeNone DisappearingType = ""
DisappearingTypeAfterRead DisappearingType = "after_read"
DisappearingTypeAfterSend DisappearingType = "after_send"
)
type BeeperDisappearingTimer struct {
Type DisappearingType `json:"type"`
Timer jsontime.Milliseconds `json:"timer"`
}
type SpaceChildEventContent struct {
Via []string `json:"via,omitempty"`
Order string `json:"order,omitempty"`

View file

@ -112,7 +112,7 @@ func (et *Type) GuessClass() TypeClass {
StatePowerLevels.Type, StateRoomName.Type, StateRoomAvatar.Type, StateServerACL.Type, StateTopic.Type,
StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type,
StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type,
StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type:
StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type:
return StateEventType
case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type:
return EphemeralEventType
@ -202,6 +202,7 @@ var (
StateElementFunctionalMembers = Type{"io.element.functional_members", StateEventType}
StateBeeperRoomFeatures = Type{"com.beeper.room_features", StateEventType}
StateBeeperDisappearingTimer = Type{"com.beeper.disappearing_timer", StateEventType}
)
// Message events