client,event,bridgev2: add support for Beeper's custom ephemeral events and AI stream events (#457)
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

This commit is contained in:
batuhan içöz 2026-03-04 01:38:50 +01:00 committed by GitHub
commit fef4326fbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 418 additions and 8 deletions

View file

@ -222,6 +222,17 @@ func (intent *IntentAPI) SendMessageEvent(ctx context.Context, roomID id.RoomID,
return intent.Client.SendMessageEvent(ctx, roomID, eventType, contentJSON, extra...)
}
func (intent *IntentAPI) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) {
if err := intent.EnsureJoined(ctx, roomID); err != nil {
return nil, err
}
if !intent.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) {
return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support")
}
contentJSON = intent.AddDoublePuppetValue(contentJSON)
return intent.Client.BeeperSendEphemeralEvent(ctx, roomID, eventType, contentJSON, extra...)
}
// Deprecated: use SendMessageEvent with mautrix.ReqSendEvent.Timestamp instead
func (intent *IntentAPI) SendMassagedMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, ts int64) (*mautrix.RespSendEvent, error) {
return intent.SendMessageEvent(ctx, roomID, eventType, contentJSON, mautrix.ReqSendEvent{Timestamp: ts})

View file

@ -75,6 +75,7 @@ var (
ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true)
ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrBeeperAIStreamNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support Beeper AI stream events")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported)
ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld)
ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld)

View file

@ -144,6 +144,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) {
br.EventProcessor.On(event.EventReaction, br.handleRoomEvent)
br.EventProcessor.On(event.EventRedaction, br.handleRoomEvent)
br.EventProcessor.On(event.EventEncrypted, br.handleEncryptedEvent)
br.EventProcessor.On(event.EphemeralEventEncrypted, br.handleEncryptedEvent)
br.EventProcessor.On(event.StateMember, br.handleRoomEvent)
br.EventProcessor.On(event.StatePowerLevels, br.handleRoomEvent)
br.EventProcessor.On(event.StateRoomName, br.handleRoomEvent)
@ -156,6 +157,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) {
br.EventProcessor.On(event.BeeperAcceptMessageRequest, br.handleRoomEvent)
br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent)
br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent)
br.EventProcessor.On(event.BeeperEphemeralEventAIStream, br.handleEphemeralEvent)
br.Bot = br.AS.BotIntent()
br.Crypto = NewCryptoHelper(br)
br.Bridge.Commands.(*commands.Processor).AddHandlers(

View file

@ -43,6 +43,7 @@ type ASIntent struct {
var _ bridgev2.MatrixAPI = (*ASIntent)(nil)
var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil)
var _ bridgev2.EphemeralSendingMatrixAPI = (*ASIntent)(nil)
func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) {
if extra == nil {
@ -84,6 +85,21 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType
return as.Matrix.SendMessageEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{Timestamp: extra.Timestamp.UnixMilli()})
}
func (as *ASIntent) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error) {
if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureEphemeralEvents) {
return nil, mautrix.MUnrecognized.WithMessage("Homeserver does not advertise com.beeper.ephemeral support")
}
if encrypted, err := as.Matrix.StateStore.IsEncrypted(ctx, roomID); err != nil {
return nil, fmt.Errorf("failed to check if room is encrypted: %w", err)
} else if encrypted && as.Connector.Crypto != nil {
if err = as.Connector.Crypto.Encrypt(ctx, roomID, eventType, content); err != nil {
return nil, err
}
eventType = event.EventEncrypted
}
return as.Matrix.BeeperSendEphemeralEvent(ctx, roomID, eventType, content, mautrix.ReqSendEvent{TransactionID: txnID})
}
func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) {
targetContent, ok := content.Parsed.(*event.MemberEventContent)
if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" {

View file

@ -68,6 +68,10 @@ func (br *Connector) handleEphemeralEvent(ctx context.Context, evt *event.Event)
case event.EphemeralEventTyping:
typingContent := evt.Content.AsTyping()
typingContent.UserIDs = slices.DeleteFunc(typingContent.UserIDs, br.shouldIgnoreEventFromUser)
case event.BeeperEphemeralEventAIStream:
if br.shouldIgnoreEvent(evt) {
return
}
}
br.Bridge.QueueMatrixEvent(ctx, evt)
}
@ -231,7 +235,6 @@ func (br *Connector) postDecrypt(ctx context.Context, original, decrypted *event
go br.sendSuccessCheckpoint(ctx, decrypted, status.MsgStepDecrypted, retryCount)
decrypted.Mautrix.CheckpointSent = true
decrypted.Mautrix.DecryptionDuration = duration
decrypted.Mautrix.EventSource |= event.SourceDecrypted
br.EventProcessor.Dispatch(ctx, decrypted)
if errorEventID != nil && *errorEventID != "" {
_, _ = br.Bot.RedactEvent(ctx, decrypted.RoomID, *errorEventID)

View file

@ -217,3 +217,8 @@ type MarkAsDMMatrixAPI interface {
MatrixAPI
MarkAsDM(ctx context.Context, roomID id.RoomID, otherUser id.UserID) error
}
type EphemeralSendingMatrixAPI interface {
MatrixAPI
BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, txnID string) (*mautrix.RespSendEvent, error)
}

View file

@ -726,6 +726,11 @@ type MessageRequestAcceptingNetworkAPI interface {
HandleMatrixAcceptMessageRequest(ctx context.Context, msg *MatrixAcceptMessageRequest) error
}
type BeeperAIStreamHandlingNetworkAPI interface {
NetworkAPI
HandleMatrixBeeperAIStream(ctx context.Context, msg *MatrixBeeperAIStream) error
}
type ResolveIdentifierResponse struct {
// Ghost is the ghost of the user that the identifier resolves to.
// This field should be set whenever possible. However, it is not required,
@ -1439,6 +1444,7 @@ type MatrixViewingChat struct {
type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent]
type MatrixAcceptMessageRequest = MatrixEventBase[*event.BeeperAcceptMessageRequestEventContent]
type MatrixBeeperAIStream = MatrixEventBase[*event.BeeperAIStreamEventContent]
type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent]
type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent]
type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent]

View file

@ -697,6 +697,8 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt *
return portal.handleMatrixReceipts(ctx, evt)
case event.EphemeralEventTyping:
return portal.handleMatrixTyping(ctx, evt)
case event.BeeperEphemeralEventAIStream:
return portal.handleMatrixAIStream(ctx, sender, evt)
default:
return EventHandlingResultIgnored
}
@ -941,6 +943,50 @@ func (portal *Portal) handleMatrixTyping(ctx context.Context, evt *event.Event)
return EventHandlingResultSuccess
}
func (portal *Portal) handleMatrixAIStream(ctx context.Context, sender *User, evt *event.Event) EventHandlingResult {
log := zerolog.Ctx(ctx)
if sender == nil {
log.Error().Msg("Missing sender for Matrix AI stream event")
return EventHandlingResultIgnored
}
login, _, err := portal.FindPreferredLogin(ctx, sender, true)
if err != nil {
log.Err(err).Msg("Failed to get user login to handle Matrix AI stream event")
return EventHandlingResultFailed.WithMSSError(err)
}
var origSender *OrigSender
if login == nil {
if portal.Relay == nil {
return EventHandlingResultIgnored
}
login = portal.Relay
origSender = &OrigSender{
User: sender,
UserID: sender.MXID,
}
}
content, ok := evt.Content.Parsed.(*event.BeeperAIStreamEventContent)
if !ok {
log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type")
return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed))
}
api, ok := login.Client.(BeeperAIStreamHandlingNetworkAPI)
if !ok {
return EventHandlingResultIgnored.WithMSSError(ErrBeeperAIStreamNotSupported)
}
err = api.HandleMatrixBeeperAIStream(ctx, &MatrixBeeperAIStream{
Event: evt,
Content: content,
Portal: portal,
OrigSender: origSender,
})
if err != nil {
log.Err(err).Msg("Failed to handle Matrix AI stream event")
return EventHandlingResultFailed.WithMSSError(err)
}
return EventHandlingResultSuccess.WithMSS()
}
func (portal *Portal) sendTypings(ctx context.Context, userIDs []id.UserID, typing bool) {
for _, userID := range userIDs {
login, ok := portal.currentlyTypingLogins[userID]

View file

@ -1359,6 +1359,48 @@ func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, event
return
}
// BeeperSendEphemeralEvent sends an ephemeral event into a room using Beeper's unstable endpoint.
// contentJSON should be a value that can be encoded as JSON using json.Marshal.
func (cli *Client) BeeperSendEphemeralEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) {
var req ReqSendEvent
if len(extra) > 0 {
req = extra[0]
}
var txnID string
if len(req.TransactionID) > 0 {
txnID = req.TransactionID
} else {
txnID = cli.TxnID()
}
queryParams := map[string]string{}
if req.Timestamp > 0 {
queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10)
}
if !req.DontEncrypt && cli != nil && cli.Crypto != nil && eventType != event.EventEncrypted {
var isEncrypted bool
isEncrypted, err = cli.StateStore.IsEncrypted(ctx, roomID)
if err != nil {
err = fmt.Errorf("failed to check if room is encrypted: %w", err)
return
}
if isEncrypted {
if contentJSON, err = cli.Crypto.Encrypt(ctx, roomID, eventType, contentJSON); err != nil {
err = fmt.Errorf("failed to encrypt event: %w", err)
return
}
eventType = event.EventEncrypted
}
}
urlData := ClientURLPath{"unstable", "com.beeper.ephemeral", "rooms", roomID, "ephemeral", eventType.String(), txnID}
urlPath := cli.BuildURLWithQuery(urlData, queryParams)
_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp)
return
}
// SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.16/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal.
func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON any, extra ...ReqSendEvent) (resp *RespSendEvent, err error) {

158
client_ephemeral_test.go Normal file
View file

@ -0,0 +1,158 @@
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package mautrix_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
func TestClient_SendEphemeralEvent_UsesUnstablePathTxnAndTS(t *testing.T) {
roomID := id.RoomID("!room:example.com")
evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType}
txnID := "txn-123"
var gotPath string
var gotQueryTS string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQueryTS = r.URL.Query().Get("ts")
assert.Equal(t, http.MethodPut, r.Method)
_, _ = w.Write([]byte(`{"event_id":"$evt"}`))
}))
defer ts.Close()
cli, err := mautrix.NewClient(ts.URL, "", "")
require.NoError(t, err)
_, err = cli.BeeperSendEphemeralEvent(
context.Background(),
roomID,
evtType,
map[string]any{"foo": "bar"},
mautrix.ReqSendEvent{TransactionID: txnID, Timestamp: 1234},
)
require.NoError(t, err)
assert.True(t, strings.Contains(gotPath, "/_matrix/client/unstable/com.beeper.ephemeral/rooms/"))
assert.True(t, strings.HasSuffix(gotPath, "/ephemeral/com.example.ephemeral/"+txnID))
assert.Equal(t, "1234", gotQueryTS)
}
func TestClient_SendEphemeralEvent_UnsupportedReturnsMUnrecognized(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"errcode":"M_UNRECOGNIZED","error":"Unrecognized endpoint"}`))
}))
defer ts.Close()
cli, err := mautrix.NewClient(ts.URL, "", "")
require.NoError(t, err)
_, err = cli.BeeperSendEphemeralEvent(
context.Background(),
id.RoomID("!room:example.com"),
event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType},
map[string]any{"foo": "bar"},
)
require.Error(t, err)
assert.True(t, errors.Is(err, mautrix.MUnrecognized))
}
func TestClient_SendEphemeralEvent_EncryptsInEncryptedRooms(t *testing.T) {
roomID := id.RoomID("!room:example.com")
evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType}
txnID := "txn-encrypted"
stateStore := mautrix.NewMemoryStateStore()
err := stateStore.SetEncryptionEvent(context.Background(), roomID, &event.EncryptionEventContent{
Algorithm: id.AlgorithmMegolmV1,
})
require.NoError(t, err)
fakeCrypto := &fakeCryptoHelper{
encryptedContent: &event.EncryptedEventContent{
Algorithm: id.AlgorithmMegolmV1,
MegolmCiphertext: []byte("ciphertext"),
},
}
var gotPath string
var gotBody map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
assert.Equal(t, http.MethodPut, r.Method)
err := json.NewDecoder(r.Body).Decode(&gotBody)
require.NoError(t, err)
_, _ = w.Write([]byte(`{"event_id":"$evt"}`))
}))
defer ts.Close()
cli, err := mautrix.NewClient(ts.URL, "", "")
require.NoError(t, err)
cli.StateStore = stateStore
cli.Crypto = fakeCrypto
_, err = cli.BeeperSendEphemeralEvent(
context.Background(),
roomID,
evtType,
map[string]any{"foo": "bar"},
mautrix.ReqSendEvent{TransactionID: txnID},
)
require.NoError(t, err)
assert.True(t, strings.HasSuffix(gotPath, "/ephemeral/m.room.encrypted/"+txnID))
assert.Equal(t, string(id.AlgorithmMegolmV1), gotBody["algorithm"])
assert.Equal(t, 1, fakeCrypto.encryptCalls)
assert.Equal(t, roomID, fakeCrypto.lastRoomID)
assert.Equal(t, evtType, fakeCrypto.lastEventType)
}
type fakeCryptoHelper struct {
encryptCalls int
lastRoomID id.RoomID
lastEventType event.Type
lastEncryptInput any
encryptedContent *event.EncryptedEventContent
}
func (f *fakeCryptoHelper) Encrypt(_ context.Context, roomID id.RoomID, eventType event.Type, content any) (*event.EncryptedEventContent, error) {
f.encryptCalls++
f.lastRoomID = roomID
f.lastEventType = eventType
f.lastEncryptInput = content
return f.encryptedContent, nil
}
func (f *fakeCryptoHelper) Decrypt(context.Context, *event.Event) (*event.Event, error) {
return nil, nil
}
func (f *fakeCryptoHelper) WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool {
return false
}
func (f *fakeCryptoHelper) RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID) {
}
func (f *fakeCryptoHelper) Init(context.Context) error {
return nil
}

View file

@ -213,6 +213,7 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event
TrustSource: device,
ForwardedKeys: forwardedKeys,
WasEncrypted: true,
EventSource: evt.Mautrix.EventSource | event.SourceDecrypted,
ReceivedAt: evt.Mautrix.ReceivedAt,
},
}, nil

View file

@ -214,6 +214,15 @@ func (content *MessageEventContent) RemovePerMessageProfileFallback() {
}
}
type BeeperAIStreamEventContent struct {
TurnID string `json:"turn_id"`
Seq int `json:"seq"`
Part map[string]any `json:"part"`
TargetEvent id.EventID `json:"target_event,omitempty"`
AgentID string `json:"agent_id,omitempty"`
RelatesTo *RelatesTo `json:"m.relates_to,omitempty"`
}
type BeeperEncodedOrder struct {
order int64
suborder int16

View file

@ -76,9 +76,11 @@ var TypeMap = map[Type]reflect.Type{
AccountDataMarkedUnread: reflect.TypeOf(MarkedUnreadEventContent{}),
AccountDataBeeperMute: reflect.TypeOf(BeeperMuteEventContent{}),
EphemeralEventTyping: reflect.TypeOf(TypingEventContent{}),
EphemeralEventReceipt: reflect.TypeOf(ReceiptEventContent{}),
EphemeralEventPresence: reflect.TypeOf(PresenceEventContent{}),
EphemeralEventTyping: reflect.TypeOf(TypingEventContent{}),
EphemeralEventReceipt: reflect.TypeOf(ReceiptEventContent{}),
EphemeralEventPresence: reflect.TypeOf(PresenceEventContent{}),
EphemeralEventEncrypted: reflect.TypeOf(EncryptedEventContent{}),
BeeperEphemeralEventAIStream: reflect.TypeOf(BeeperAIStreamEventContent{}),
InRoomVerificationReady: reflect.TypeOf(VerificationReadyEventContent{}),
InRoomVerificationStart: reflect.TypeOf(VerificationStartEventContent{}),

View file

@ -28,6 +28,9 @@ type PowerLevelsEventContent struct {
Events map[string]int `json:"events,omitempty"`
EventsDefault int `json:"events_default,omitempty"`
beeperEphemeralLock sync.RWMutex
BeeperEphemeral map[string]int `json:"com.beeper.ephemeral,omitempty"`
Notifications *NotificationPowerLevels `json:"notifications,omitempty"`
StateDefaultPtr *int `json:"state_default,omitempty"`
@ -37,6 +40,8 @@ type PowerLevelsEventContent struct {
BanPtr *int `json:"ban,omitempty"`
RedactPtr *int `json:"redact,omitempty"`
BeeperEphemeralDefaultPtr *int `json:"com.beeper.ephemeral_default,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:"-"`
@ -51,6 +56,7 @@ func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent {
UsersDefault: pl.UsersDefault,
Events: maps.Clone(pl.Events),
EventsDefault: pl.EventsDefault,
BeeperEphemeral: maps.Clone(pl.BeeperEphemeral),
StateDefaultPtr: ptr.Clone(pl.StateDefaultPtr),
Notifications: pl.Notifications.Clone(),
@ -60,6 +66,8 @@ func (pl *PowerLevelsEventContent) Clone() *PowerLevelsEventContent {
BanPtr: ptr.Clone(pl.BanPtr),
RedactPtr: ptr.Clone(pl.RedactPtr),
BeeperEphemeralDefaultPtr: ptr.Clone(pl.BeeperEphemeralDefaultPtr),
CreateEvent: pl.CreateEvent,
}
}
@ -119,6 +127,13 @@ func (pl *PowerLevelsEventContent) StateDefault() int {
return 50
}
func (pl *PowerLevelsEventContent) BeeperEphemeralDefault() int {
if pl.BeeperEphemeralDefaultPtr != nil {
return *pl.BeeperEphemeralDefaultPtr
}
return pl.EventsDefault
}
func (pl *PowerLevelsEventContent) GetUserLevel(userID id.UserID) int {
if pl.isCreator(userID) {
return math.MaxInt
@ -202,6 +217,29 @@ func (pl *PowerLevelsEventContent) GetEventLevel(eventType Type) int {
return level
}
func (pl *PowerLevelsEventContent) GetBeeperEphemeralLevel(eventType Type) int {
pl.beeperEphemeralLock.RLock()
defer pl.beeperEphemeralLock.RUnlock()
level, ok := pl.BeeperEphemeral[eventType.String()]
if !ok {
return pl.BeeperEphemeralDefault()
}
return level
}
func (pl *PowerLevelsEventContent) SetBeeperEphemeralLevel(eventType Type, level int) {
pl.beeperEphemeralLock.Lock()
defer pl.beeperEphemeralLock.Unlock()
if level == pl.BeeperEphemeralDefault() {
delete(pl.BeeperEphemeral, eventType.String())
} else {
if pl.BeeperEphemeral == nil {
pl.BeeperEphemeral = make(map[string]int)
}
pl.BeeperEphemeral[eventType.String()] = level
}
}
func (pl *PowerLevelsEventContent) SetEventLevel(eventType Type, level int) {
pl.eventsLock.Lock()
defer pl.eventsLock.Unlock()

View file

@ -0,0 +1,67 @@
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package event_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maunium.net/go/mautrix/event"
)
func TestPowerLevelsEventContent_BeeperEphemeralDefaultFallsBackToEventsDefault(t *testing.T) {
pl := &event.PowerLevelsEventContent{
EventsDefault: 45,
}
assert.Equal(t, 45, pl.BeeperEphemeralDefault())
override := 60
pl.BeeperEphemeralDefaultPtr = &override
assert.Equal(t, 60, pl.BeeperEphemeralDefault())
}
func TestPowerLevelsEventContent_GetSetBeeperEphemeralLevel(t *testing.T) {
pl := &event.PowerLevelsEventContent{
EventsDefault: 25,
}
evtType := event.Type{Type: "com.example.ephemeral", Class: event.EphemeralEventType}
assert.Equal(t, 25, pl.GetBeeperEphemeralLevel(evtType))
pl.SetBeeperEphemeralLevel(evtType, 50)
assert.Equal(t, 50, pl.GetBeeperEphemeralLevel(evtType))
require.NotNil(t, pl.BeeperEphemeral)
assert.Equal(t, 50, pl.BeeperEphemeral[evtType.String()])
pl.SetBeeperEphemeralLevel(evtType, 25)
_, exists := pl.BeeperEphemeral[evtType.String()]
assert.False(t, exists)
}
func TestPowerLevelsEventContent_CloneCopiesBeeperEphemeralFields(t *testing.T) {
override := 70
pl := &event.PowerLevelsEventContent{
EventsDefault: 35,
BeeperEphemeral: map[string]int{"com.example.ephemeral": 90},
BeeperEphemeralDefaultPtr: &override,
}
cloned := pl.Clone()
require.NotNil(t, cloned)
require.NotNil(t, cloned.BeeperEphemeralDefaultPtr)
assert.Equal(t, 70, *cloned.BeeperEphemeralDefaultPtr)
assert.Equal(t, 90, cloned.BeeperEphemeral["com.example.ephemeral"])
cloned.BeeperEphemeral["com.example.ephemeral"] = 99
*cloned.BeeperEphemeralDefaultPtr = 71
assert.Equal(t, 90, pl.BeeperEphemeral["com.example.ephemeral"])
assert.Equal(t, 70, *pl.BeeperEphemeralDefaultPtr)
}

View file

@ -115,7 +115,7 @@ func (et *Type) GuessClass() TypeClass {
StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type,
StateMSC4391BotCommand.Type, StateRoomPolicy.Type, StateUnstableRoomPolicy.Type:
return StateEventType
case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type:
case EphemeralEventReceipt.Type, EphemeralEventTyping.Type, EphemeralEventPresence.Type, BeeperEphemeralEventAIStream.Type:
return EphemeralEventType
case AccountDataDirectChats.Type, AccountDataPushRules.Type, AccountDataRoomTags.Type,
AccountDataFullyRead.Type, AccountDataIgnoredUserList.Type, AccountDataMarkedUnread.Type,
@ -250,9 +250,11 @@ var (
// Ephemeral events
var (
EphemeralEventReceipt = Type{"m.receipt", EphemeralEventType}
EphemeralEventTyping = Type{"m.typing", EphemeralEventType}
EphemeralEventPresence = Type{"m.presence", EphemeralEventType}
EphemeralEventReceipt = Type{"m.receipt", EphemeralEventType}
EphemeralEventTyping = Type{"m.typing", EphemeralEventType}
EphemeralEventPresence = Type{"m.presence", EphemeralEventType}
EphemeralEventEncrypted = Type{"m.room.encrypted", EphemeralEventType}
BeeperEphemeralEventAIStream = Type{"com.beeper.ai.stream_event", EphemeralEventType}
)
// Account data events

View file

@ -80,6 +80,7 @@ var (
BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"}
BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"}
BeeperFeatureArbitraryMemberChange = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_member_change"}
BeeperFeatureEphemeralEvents = UnstableFeature{UnstableFlag: "com.beeper.ephemeral"}
)
func (versions *RespVersions) Supports(feature UnstableFeature) bool {