mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-15 14:55:51 +01:00
Compare commits
32 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1953538cb6 | ||
|
|
8e564c38df | ||
|
|
ef6de851a2 | ||
|
|
b42ac0e83d | ||
|
|
92cfc0095d |
||
|
|
8fb92239dc | ||
|
|
c243dad24a | ||
|
|
c107c25d07 |
||
|
|
df24fb96e2 | ||
|
|
531822f6dc | ||
|
|
7a53f3928a | ||
|
|
7836f35a1a | ||
|
|
0f6a779dd2 | ||
|
|
ed6dbcaaee | ||
|
|
ed9820356e | ||
|
|
fef4326fbc |
||
|
|
77f0658365 | ||
|
|
e1529f9616 | ||
|
|
26a62a7eec | ||
|
|
f8234ecf85 | ||
|
|
36c353abc7 | ||
|
|
dd51c562ab | ||
|
|
98c830181b | ||
|
|
7f24c78002 |
||
|
|
3efa3ef73a | ||
|
|
28b7bf7e56 |
||
|
|
5779871f1b | ||
|
|
bc79822eab | ||
|
|
67d30e054c | ||
|
|
974f7dc544 | ||
|
|
ae58161412 | ||
|
|
de0d12e26a |
62 changed files with 1043 additions and 175 deletions
10
README.md
10
README.md
|
|
@ -1,8 +1,9 @@
|
|||
# mautrix-go
|
||||
[](https://pkg.go.dev/maunium.net/go/mautrix)
|
||||
|
||||
A Golang Matrix framework. Used by [gomuks](https://matrix.org/docs/projects/client/gomuks),
|
||||
[go-neb](https://github.com/matrix-org/go-neb), [mautrix-whatsapp](https://github.com/mautrix/whatsapp)
|
||||
A Golang Matrix framework. Used by [gomuks](https://gomuks.app),
|
||||
[go-neb](https://github.com/matrix-org/go-neb),
|
||||
[mautrix-whatsapp](https://github.com/mautrix/whatsapp)
|
||||
and others.
|
||||
|
||||
Matrix room: [`#go:maunium.net`](https://matrix.to/#/#go:maunium.net)
|
||||
|
|
@ -13,9 +14,10 @@ The original project is licensed under [Apache 2.0](https://github.com/matrix-or
|
|||
In addition to the basic client API features the original project has, this framework also has:
|
||||
|
||||
* Appservice support (Intent API like mautrix-python, room state storage, etc)
|
||||
* End-to-end encryption support (incl. interactive SAS verification)
|
||||
* End-to-end encryption support (incl. key backup, cross-signing, interactive verification, etc)
|
||||
* High-level module for building puppeting bridges
|
||||
* High-level module for building chat clients
|
||||
* Partial federation module (making requests, PDU processing and event authorization)
|
||||
* A media proxy server which can be used to expose anything as a Matrix media repo
|
||||
* Wrapper functions for the Synapse admin API
|
||||
* Structs for parsing event content
|
||||
* Helpers for parsing and generating Matrix HTML
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func (as *AppService) NewIntentAPI(localpart string) *IntentAPI {
|
|||
}
|
||||
|
||||
func (intent *IntentAPI) Register(ctx context.Context) error {
|
||||
_, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister{
|
||||
_, err := intent.Client.MakeRequest(ctx, http.MethodPost, intent.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{
|
||||
Username: intent.Localpart,
|
||||
Type: mautrix.AuthTypeAppservice,
|
||||
InhibitLogin: true,
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -62,38 +62,40 @@ type CleanupOnLogouts struct {
|
|||
}
|
||||
|
||||
type BridgeConfig struct {
|
||||
CommandPrefix string `yaml:"command_prefix"`
|
||||
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
|
||||
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
|
||||
AsyncEvents bool `yaml:"async_events"`
|
||||
SplitPortals bool `yaml:"split_portals"`
|
||||
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
|
||||
NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"`
|
||||
BridgeStatusNotices string `yaml:"bridge_status_notices"`
|
||||
UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"`
|
||||
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
|
||||
BridgeNotices bool `yaml:"bridge_notices"`
|
||||
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
|
||||
OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"`
|
||||
MuteOnlyOnCreate bool `yaml:"mute_only_on_create"`
|
||||
DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"`
|
||||
CrossRoomReplies bool `yaml:"cross_room_replies"`
|
||||
OutgoingMessageReID bool `yaml:"outgoing_message_re_id"`
|
||||
RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"`
|
||||
KickMatrixUsers bool `yaml:"kick_matrix_users"`
|
||||
CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"`
|
||||
Relay RelayConfig `yaml:"relay"`
|
||||
Permissions PermissionConfig `yaml:"permissions"`
|
||||
Backfill BackfillConfig `yaml:"backfill"`
|
||||
CommandPrefix string `yaml:"command_prefix"`
|
||||
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
|
||||
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
|
||||
AsyncEvents bool `yaml:"async_events"`
|
||||
SplitPortals bool `yaml:"split_portals"`
|
||||
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
|
||||
NoBridgeInfoStateKey bool `yaml:"no_bridge_info_state_key"`
|
||||
BridgeStatusNotices string `yaml:"bridge_status_notices"`
|
||||
UnknownErrorAutoReconnect time.Duration `yaml:"unknown_error_auto_reconnect"`
|
||||
UnknownErrorMaxAutoReconnects int `yaml:"unknown_error_max_auto_reconnects"`
|
||||
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
|
||||
BridgeNotices bool `yaml:"bridge_notices"`
|
||||
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
|
||||
OnlyBridgeTags []event.RoomTag `yaml:"only_bridge_tags"`
|
||||
MuteOnlyOnCreate bool `yaml:"mute_only_on_create"`
|
||||
DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"`
|
||||
CrossRoomReplies bool `yaml:"cross_room_replies"`
|
||||
OutgoingMessageReID bool `yaml:"outgoing_message_re_id"`
|
||||
RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"`
|
||||
KickMatrixUsers bool `yaml:"kick_matrix_users"`
|
||||
CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"`
|
||||
Relay RelayConfig `yaml:"relay"`
|
||||
Permissions PermissionConfig `yaml:"permissions"`
|
||||
Backfill BackfillConfig `yaml:"backfill"`
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
MessageStatusEvents bool `yaml:"message_status_events"`
|
||||
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
||||
MessageErrorNotices bool `yaml:"message_error_notices"`
|
||||
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
|
||||
FederateRooms bool `yaml:"federate_rooms"`
|
||||
UploadFileThreshold int64 `yaml:"upload_file_threshold"`
|
||||
MessageStatusEvents bool `yaml:"message_status_events"`
|
||||
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
||||
MessageErrorNotices bool `yaml:"message_error_notices"`
|
||||
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
|
||||
FederateRooms bool `yaml:"federate_rooms"`
|
||||
UploadFileThreshold int64 `yaml:"upload_file_threshold"`
|
||||
GhostExtraProfileInfo bool `yaml:"ghost_extra_profile_info"`
|
||||
}
|
||||
|
||||
type AnalyticsConfig struct {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ func doUpgrade(helper up.Helper) {
|
|||
helper.Copy(up.Bool, "bridge", "no_bridge_info_state_key")
|
||||
helper.Copy(up.Str|up.Null, "bridge", "bridge_status_notices")
|
||||
helper.Copy(up.Str|up.Int|up.Null, "bridge", "unknown_error_auto_reconnect")
|
||||
helper.Copy(up.Int, "bridge", "unknown_error_max_auto_reconnects")
|
||||
helper.Copy(up.Bool, "bridge", "bridge_matrix_leave")
|
||||
helper.Copy(up.Bool, "bridge", "bridge_notices")
|
||||
helper.Copy(up.Bool, "bridge", "tag_only_on_create")
|
||||
|
|
@ -100,6 +101,7 @@ func doUpgrade(helper up.Helper) {
|
|||
helper.Copy(up.Bool, "matrix", "sync_direct_chat_list")
|
||||
helper.Copy(up.Bool, "matrix", "federate_rooms")
|
||||
helper.Copy(up.Int, "matrix", "upload_file_threshold")
|
||||
helper.Copy(up.Bool, "matrix", "ghost_extra_profile_info")
|
||||
|
||||
helper.Copy(up.Str|up.Null, "analytics", "token")
|
||||
helper.Copy(up.Str|up.Null, "analytics", "url")
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ type BridgeStateQueue struct {
|
|||
|
||||
stopChan chan struct{}
|
||||
stopReconnect atomic.Pointer[context.CancelFunc]
|
||||
|
||||
unknownErrorReconnects int
|
||||
}
|
||||
|
||||
func (br *Bridge) SendGlobalBridgeState(state status.BridgeState) {
|
||||
|
|
@ -192,8 +194,14 @@ func (bsq *BridgeStateQueue) unknownErrorReconnect(triggeredBy status.BridgeStat
|
|||
} else if prevUnsent.StateEvent != status.StateUnknownError || prev.StateEvent != status.StateUnknownError {
|
||||
log.Debug().Msg("Not reconnecting as the previous state was not an unknown error")
|
||||
return
|
||||
} else if bsq.unknownErrorReconnects > bsq.bridge.Config.UnknownErrorMaxAutoReconnects {
|
||||
log.Warn().Msg("Not reconnecting as the maximum number of unknown error reconnects has been reached")
|
||||
return
|
||||
}
|
||||
log.Info().Msg("Disconnecting and reconnecting login due to unknown error")
|
||||
bsq.unknownErrorReconnects++
|
||||
log.Info().
|
||||
Int("reconnect_num", bsq.unknownErrorReconnects).
|
||||
Msg("Disconnecting and reconnecting login due to unknown error")
|
||||
bsq.login.Disconnect()
|
||||
log.Debug().Msg("Disconnection finished, recreating client and reconnecting")
|
||||
err := bsq.login.recreateClient(ctx)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ func fnLogin(ce *Event) {
|
|||
ce.Reply("Failed to start login: %v", err)
|
||||
return
|
||||
}
|
||||
ce.Log.Debug().Any("first_step", nextStep).Msg("Created login process")
|
||||
|
||||
nextStep = checkLoginCommandDirectParams(ce, login, nextStep)
|
||||
if nextStep != nil {
|
||||
|
|
@ -251,14 +252,19 @@ func sendQR(ce *Event, qr string, prevEventID *id.EventID) error {
|
|||
return fmt.Errorf("failed to upload image: %w", err)
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgImage,
|
||||
FileName: "qr.png",
|
||||
URL: qrMXC,
|
||||
File: qrFile,
|
||||
|
||||
MsgType: event.MsgImage,
|
||||
FileName: "qr.png",
|
||||
URL: qrMXC,
|
||||
File: qrFile,
|
||||
Body: qr,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: fmt.Sprintf("<pre><code>%s</code></pre>", html.EscapeString(qr)),
|
||||
Info: &event.FileInfo{
|
||||
MimeType: "image/png",
|
||||
Width: qrSizePx,
|
||||
Height: qrSizePx,
|
||||
Size: len(qrData),
|
||||
},
|
||||
}
|
||||
if *prevEventID != "" {
|
||||
content.SetEdit(*prevEventID)
|
||||
|
|
@ -273,6 +279,36 @@ func sendQR(ce *Event, qr string, prevEventID *id.EventID) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func sendUserInputAttachments(ce *Event, atts []*bridgev2.LoginUserInputAttachment) error {
|
||||
for _, att := range atts {
|
||||
if att.FileName == "" {
|
||||
return fmt.Errorf("missing attachment filename")
|
||||
}
|
||||
mxc, file, err := ce.Bot.UploadMedia(ce.Ctx, ce.RoomID, att.Content, att.FileName, att.Info.MimeType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload attachment %q: %w", att.FileName, err)
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: att.Type,
|
||||
FileName: att.FileName,
|
||||
URL: mxc,
|
||||
File: file,
|
||||
Info: &event.FileInfo{
|
||||
MimeType: att.Info.MimeType,
|
||||
Width: att.Info.Width,
|
||||
Height: att.Info.Height,
|
||||
Size: att.Info.Size,
|
||||
},
|
||||
Body: att.FileName,
|
||||
}
|
||||
_, err = ce.Bot.SendMessage(ce.Ctx, ce.RoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
|
|
@ -464,6 +500,7 @@ func maybeURLDecodeCookie(val string, field *bridgev2.LoginCookieField) string {
|
|||
}
|
||||
|
||||
func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginStep, override *bridgev2.UserLogin) {
|
||||
ce.Log.Debug().Any("next_step", step).Msg("Got next login step")
|
||||
if step.Instructions != "" {
|
||||
ce.Reply(step.Instructions)
|
||||
}
|
||||
|
|
@ -478,6 +515,10 @@ func doLoginStep(ce *Event, login bridgev2.LoginProcess, step *bridgev2.LoginSte
|
|||
Override: override,
|
||||
}).prompt(ce)
|
||||
case bridgev2.LoginStepTypeUserInput:
|
||||
err := sendUserInputAttachments(ce, step.UserInputParams.Attachments)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to send attachments: %v", err)
|
||||
}
|
||||
(&userInputLoginCommandState{
|
||||
Login: login.(bridgev2.LoginProcessUserInput),
|
||||
RemainingFields: step.UserInputParams.Fields,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/exerrors"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/crypto/canonicaljson"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
|
|
@ -22,6 +27,55 @@ type GhostQuery struct {
|
|||
*dbutil.QueryHelper[*Ghost]
|
||||
}
|
||||
|
||||
type ExtraProfile map[string]json.RawMessage
|
||||
|
||||
func (ep *ExtraProfile) Set(key string, value any) error {
|
||||
if key == "displayname" || key == "avatar_url" {
|
||||
return fmt.Errorf("cannot set reserved profile key %q", key)
|
||||
}
|
||||
marshaled, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *ep == nil {
|
||||
*ep = make(ExtraProfile)
|
||||
}
|
||||
(*ep)[key] = canonicaljson.CanonicalJSONAssumeValid(marshaled)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ep *ExtraProfile) With(key string, value any) *ExtraProfile {
|
||||
exerrors.PanicIfNotNil(ep.Set(key, value))
|
||||
return ep
|
||||
}
|
||||
|
||||
func canonicalizeIfObject(data json.RawMessage) json.RawMessage {
|
||||
if len(data) > 0 && (data[0] == '{' || data[0] == '[') {
|
||||
return canonicaljson.CanonicalJSONAssumeValid(data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (ep *ExtraProfile) CopyTo(dest *ExtraProfile) (changed bool) {
|
||||
if len(*ep) == 0 {
|
||||
return
|
||||
}
|
||||
if *dest == nil {
|
||||
*dest = make(ExtraProfile)
|
||||
}
|
||||
for key, val := range *ep {
|
||||
if key == "displayname" || key == "avatar_url" {
|
||||
continue
|
||||
}
|
||||
existing, exists := (*dest)[key]
|
||||
if !exists || !bytes.Equal(canonicalizeIfObject(existing), val) {
|
||||
(*dest)[key] = val
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type Ghost struct {
|
||||
BridgeID networkid.BridgeID
|
||||
ID networkid.UserID
|
||||
|
|
@ -35,13 +89,14 @@ type Ghost struct {
|
|||
ContactInfoSet bool
|
||||
IsBot bool
|
||||
Identifiers []string
|
||||
ExtraProfile ExtraProfile
|
||||
Metadata any
|
||||
}
|
||||
|
||||
const (
|
||||
getGhostBaseQuery = `
|
||||
SELECT bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
|
||||
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
|
||||
name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata
|
||||
FROM ghost
|
||||
`
|
||||
getGhostByIDQuery = getGhostBaseQuery + `WHERE bridge_id=$1 AND id=$2`
|
||||
|
|
@ -49,13 +104,14 @@ const (
|
|||
insertGhostQuery = `
|
||||
INSERT INTO ghost (
|
||||
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
|
||||
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
|
||||
name_set, avatar_set, contact_info_set, is_bot, identifiers, extra_profile, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
`
|
||||
updateGhostQuery = `
|
||||
UPDATE ghost SET name=$3, avatar_id=$4, avatar_hash=$5, avatar_mxc=$6,
|
||||
name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10, identifiers=$11, metadata=$12
|
||||
name_set=$7, avatar_set=$8, contact_info_set=$9, is_bot=$10,
|
||||
identifiers=$11, extra_profile=$12, metadata=$13
|
||||
WHERE bridge_id=$1 AND id=$2
|
||||
`
|
||||
)
|
||||
|
|
@ -86,7 +142,7 @@ func (g *Ghost) Scan(row dbutil.Scannable) (*Ghost, error) {
|
|||
&g.BridgeID, &g.ID,
|
||||
&g.Name, &g.AvatarID, &avatarHash, &g.AvatarMXC,
|
||||
&g.NameSet, &g.AvatarSet, &g.ContactInfoSet, &g.IsBot,
|
||||
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.Metadata},
|
||||
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: &g.ExtraProfile}, dbutil.JSON{Data: g.Metadata},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -116,6 +172,6 @@ func (g *Ghost) sqlVariables() []any {
|
|||
g.BridgeID, g.ID,
|
||||
g.Name, g.AvatarID, avatarHash, g.AvatarMXC,
|
||||
g.NameSet, g.AvatarSet, g.ContactInfoSet, g.IsBot,
|
||||
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.Metadata},
|
||||
dbutil.JSON{Data: &g.Identifiers}, dbutil.JSON{Data: g.ExtraProfile}, dbutil.JSON{Data: g.Metadata},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ const (
|
|||
getFirstMessagePartByIDQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id=$3 ORDER BY part_id ASC LIMIT 1`
|
||||
getMessagesBetweenTimeQuery = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND timestamp>$4 AND timestamp<=$5`
|
||||
getOldestMessageInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp ASC, part_id ASC LIMIT 1`
|
||||
getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp ASC, part_id ASC LIMIT 1`
|
||||
getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY timestamp DESC, part_id DESC LIMIT 1`
|
||||
getFirstMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS FIRST, timestamp ASC, part_id ASC LIMIT 1`
|
||||
getLastMessageInThread = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 AND (id=$4 OR thread_root_id=$4) ORDER BY thread_root_id NULLS LAST, timestamp DESC, part_id DESC LIMIT 1`
|
||||
getLastNInPortal = getMessageBaseQuery + `WHERE bridge_id=$1 AND room_id=$2 AND room_receiver=$3 ORDER BY timestamp DESC, part_id DESC LIMIT $4`
|
||||
|
||||
getLastMessagePartAtOrBeforeTimeQuery = getMessageBaseQuery + `WHERE bridge_id = $1 AND room_id=$2 AND room_receiver=$3 AND timestamp<=$4 ORDER BY timestamp DESC, part_id DESC LIMIT 1`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
-- v0 -> v26 (compatible with v9+): Latest revision
|
||||
-- v0 -> v27 (compatible with v9+): Latest revision
|
||||
CREATE TABLE "user" (
|
||||
bridge_id TEXT NOT NULL,
|
||||
mxid TEXT NOT NULL,
|
||||
|
|
@ -80,6 +80,7 @@ CREATE TABLE ghost (
|
|||
contact_info_set BOOLEAN NOT NULL,
|
||||
is_bot BOOLEAN NOT NULL,
|
||||
identifiers jsonb NOT NULL,
|
||||
extra_profile jsonb,
|
||||
metadata jsonb NOT NULL,
|
||||
|
||||
PRIMARY KEY (bridge_id, id)
|
||||
|
|
|
|||
2
bridgev2/database/upgrades/27-ghost-extra-profile.sql
Normal file
2
bridgev2/database/upgrades/27-ghost-extra-profile.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- v27 (compatible with v9+): Add column for extra ghost profile metadata
|
||||
ALTER TABLE ghost ADD COLUMN extra_profile jsonb;
|
||||
|
|
@ -116,7 +116,7 @@ func (u *UserLogin) ensureHasMetadata(metaType MetaTypeCreator) *UserLogin {
|
|||
|
||||
func (u *UserLogin) sqlVariables() []any {
|
||||
var remoteProfile dbutil.JSON
|
||||
if !u.RemoteProfile.IsEmpty() {
|
||||
if !u.RemoteProfile.IsZero() {
|
||||
remoteProfile.Data = &u.RemoteProfile
|
||||
}
|
||||
return []any{u.BridgeID, u.UserMXID, u.ID, u.RemoteName, remoteProfile, dbutil.StrPtr(u.SpaceRoom), dbutil.JSON{Data: u.Metadata}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ package bridgev2
|
|||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/exerrors"
|
||||
"go.mau.fi/util/exmime"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
|
@ -134,10 +137,11 @@ func (a *Avatar) Reupload(ctx context.Context, intent MatrixAPI, currentHash [32
|
|||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Identifiers []string
|
||||
Name *string
|
||||
Avatar *Avatar
|
||||
IsBot *bool
|
||||
Identifiers []string
|
||||
Name *string
|
||||
Avatar *Avatar
|
||||
IsBot *bool
|
||||
ExtraProfile database.ExtraProfile
|
||||
|
||||
ExtraUpdates ExtraUpdater[*Ghost]
|
||||
}
|
||||
|
|
@ -185,9 +189,9 @@ func (ghost *Ghost) UpdateAvatar(ctx context.Context, avatar *Avatar) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func (ghost *Ghost) getExtraProfileMeta() *event.BeeperProfileExtra {
|
||||
func (ghost *Ghost) getExtraProfileMeta() any {
|
||||
bridgeName := ghost.Bridge.Network.GetName()
|
||||
return &event.BeeperProfileExtra{
|
||||
baseExtra := &event.BeeperProfileExtra{
|
||||
RemoteID: string(ghost.ID),
|
||||
Identifiers: ghost.Identifiers,
|
||||
Service: bridgeName.BeeperBridgeType,
|
||||
|
|
@ -195,23 +199,35 @@ func (ghost *Ghost) getExtraProfileMeta() *event.BeeperProfileExtra {
|
|||
IsBridgeBot: false,
|
||||
IsNetworkBot: ghost.IsBot,
|
||||
}
|
||||
if len(ghost.ExtraProfile) == 0 {
|
||||
return baseExtra
|
||||
}
|
||||
mergedExtra := maps.Clone(ghost.ExtraProfile)
|
||||
baseExtraMarshaled := exerrors.Must(json.Marshal(baseExtra))
|
||||
exerrors.PanicIfNotNil(json.Unmarshal(baseExtraMarshaled, &mergedExtra))
|
||||
return mergedExtra
|
||||
}
|
||||
|
||||
func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool) bool {
|
||||
if identifiers != nil {
|
||||
slices.Sort(identifiers)
|
||||
}
|
||||
if ghost.ContactInfoSet &&
|
||||
(identifiers == nil || slices.Equal(identifiers, ghost.Identifiers)) &&
|
||||
(isBot == nil || *isBot == ghost.IsBot) {
|
||||
func (ghost *Ghost) UpdateContactInfo(ctx context.Context, identifiers []string, isBot *bool, extraProfile database.ExtraProfile) bool {
|
||||
if !ghost.Bridge.Matrix.GetCapabilities().ExtraProfileMeta {
|
||||
ghost.ContactInfoSet = false
|
||||
return false
|
||||
}
|
||||
if identifiers != nil {
|
||||
slices.Sort(identifiers)
|
||||
}
|
||||
changed := extraProfile.CopyTo(&ghost.ExtraProfile)
|
||||
if identifiers != nil {
|
||||
changed = changed || !slices.Equal(identifiers, ghost.Identifiers)
|
||||
ghost.Identifiers = identifiers
|
||||
}
|
||||
if isBot != nil {
|
||||
changed = changed || *isBot != ghost.IsBot
|
||||
ghost.IsBot = *isBot
|
||||
}
|
||||
if ghost.ContactInfoSet && !changed {
|
||||
return false
|
||||
}
|
||||
err := ghost.Intent.SetExtraProfileMeta(ctx, ghost.getExtraProfileMeta())
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to set extra profile metadata")
|
||||
|
|
@ -287,8 +303,8 @@ func (ghost *Ghost) UpdateInfo(ctx context.Context, info *UserInfo) {
|
|||
ghost.AvatarSet = true
|
||||
update = true
|
||||
}
|
||||
if info.Identifiers != nil || info.IsBot != nil {
|
||||
update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot) || update
|
||||
if info.Identifiers != nil || info.IsBot != nil || info.ExtraProfile != nil {
|
||||
update = ghost.UpdateContactInfo(ctx, info.Identifiers, info.IsBot, info.ExtraProfile) || update
|
||||
}
|
||||
if info.ExtraUpdates != nil {
|
||||
update = info.ExtraUpdates(ctx, ghost) || update
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/event"
|
||||
)
|
||||
|
||||
// LoginProcess represents a single occurrence of a user logging into the remote network.
|
||||
|
|
@ -179,6 +180,7 @@ const (
|
|||
LoginInputFieldTypeURL LoginInputFieldType = "url"
|
||||
LoginInputFieldTypeDomain LoginInputFieldType = "domain"
|
||||
LoginInputFieldTypeSelect LoginInputFieldType = "select"
|
||||
LoginInputFieldTypeCaptchaCode LoginInputFieldType = "captcha_code"
|
||||
)
|
||||
|
||||
type LoginInputDataField struct {
|
||||
|
|
@ -271,6 +273,23 @@ func (f *LoginInputDataField) FillDefaultValidate() {
|
|||
type LoginUserInputParams struct {
|
||||
// The fields that the user needs to fill in.
|
||||
Fields []LoginInputDataField `json:"fields"`
|
||||
|
||||
// Attachments to display alongside the input fields.
|
||||
Attachments []*LoginUserInputAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type LoginUserInputAttachment struct {
|
||||
Type event.MessageType `json:"type,omitempty"`
|
||||
FileName string `json:"filename,omitempty"`
|
||||
Content []byte `json:"content,omitempty"`
|
||||
Info LoginUserInputAttachmentInfo `json:"info,omitempty"`
|
||||
}
|
||||
|
||||
type LoginUserInputAttachmentInfo struct {
|
||||
MimeType string `json:"mimetype,omitempty"`
|
||||
Width int `json:"w,omitempty"`
|
||||
Height int `json:"h,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
type LoginCompleteParams struct {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -367,6 +369,8 @@ func (br *Connector) ensureConnection(ctx context.Context) {
|
|||
br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites)
|
||||
br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending)
|
||||
br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange)
|
||||
br.Capabilities.ExtraProfileMeta = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) ||
|
||||
(br.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && br.Config.Matrix.GhostExtraProfileInfo)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package matrix
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -27,6 +28,7 @@ import (
|
|||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||
"maunium.net/go/mautrix/crypto/attachment"
|
||||
"maunium.net/go/mautrix/crypto/canonicaljson"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/pushrules"
|
||||
|
|
@ -43,6 +45,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 +87,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 != "" {
|
||||
|
|
@ -468,11 +486,62 @@ func (as *ASIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIStr
|
|||
return as.Matrix.SetAvatarURL(ctx, parsedAvatarURL)
|
||||
}
|
||||
|
||||
func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error {
|
||||
if !as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
|
||||
return nil
|
||||
func dataToFields(data any) (map[string]json.RawMessage, error) {
|
||||
fields, ok := data.(map[string]json.RawMessage)
|
||||
if ok {
|
||||
return fields, nil
|
||||
}
|
||||
return as.Matrix.BeeperUpdateProfile(ctx, data)
|
||||
d, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d = canonicaljson.CanonicalJSONAssumeValid(d)
|
||||
err = json.Unmarshal(d, &fields)
|
||||
return fields, err
|
||||
}
|
||||
|
||||
func marshalField(val any) json.RawMessage {
|
||||
data, _ := json.Marshal(val)
|
||||
if len(data) > 0 && (data[0] == '{' || data[0] == '[') {
|
||||
return canonicaljson.CanonicalJSONAssumeValid(data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
var nullJSON = json.RawMessage("null")
|
||||
|
||||
func (as *ASIntent) SetExtraProfileMeta(ctx context.Context, data any) error {
|
||||
if as.Connector.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
|
||||
return as.Matrix.BeeperUpdateProfile(ctx, data)
|
||||
} else if as.Connector.SpecVersions.Supports(mautrix.FeatureArbitraryProfileFields) && as.Connector.Config.Matrix.GhostExtraProfileInfo {
|
||||
fields, err := dataToFields(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal fields: %w", err)
|
||||
}
|
||||
currentProfile, err := as.Matrix.GetProfile(ctx, as.Matrix.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current profile: %w", err)
|
||||
}
|
||||
for key, val := range fields {
|
||||
existing, ok := currentProfile.Extra[key]
|
||||
if !ok {
|
||||
if bytes.Equal(val, nullJSON) {
|
||||
continue
|
||||
}
|
||||
err = as.Matrix.SetProfileField(ctx, key, val)
|
||||
} else if !bytes.Equal(marshalField(existing), val) {
|
||||
if bytes.Equal(val, nullJSON) {
|
||||
err = as.Matrix.DeleteProfileField(ctx, key)
|
||||
} else {
|
||||
err = as.Matrix.SetProfileField(ctx, key, val)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set profile field %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (as *ASIntent) GetMXID() id.UserID {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ bridge:
|
|||
# How long after an unknown error should the bridge attempt a full reconnect?
|
||||
# Must be at least 1 minute. The bridge will add an extra ±20% jitter to this value.
|
||||
unknown_error_auto_reconnect: null
|
||||
# Maximum number of times to do the auto-reconnect above.
|
||||
# The counter is per login, but is never reset except on logout and restart.
|
||||
unknown_error_max_auto_reconnects: 10
|
||||
|
||||
# Should leaving Matrix rooms be bridged as leaving groups on the remote network?
|
||||
bridge_matrix_leave: false
|
||||
|
|
@ -241,6 +244,9 @@ matrix:
|
|||
# The threshold as bytes after which the bridge should roundtrip uploads via the disk
|
||||
# rather than keeping the whole file in memory.
|
||||
upload_file_threshold: 5242880
|
||||
# Should the bridge set additional custom profile info for ghosts?
|
||||
# This can make a lot of requests, as there's no batch profile update endpoint.
|
||||
ghost_extra_profile_info: false
|
||||
|
||||
# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors.
|
||||
analytics:
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ func (prov *ProvisioningAPI) GetWhoami(w http.ResponseWriter, r *http.Request) {
|
|||
prevState.UserID = ""
|
||||
prevState.RemoteID = ""
|
||||
prevState.RemoteName = ""
|
||||
prevState.RemoteProfile = nil
|
||||
prevState.RemoteProfile = status.RemoteProfile{}
|
||||
resp.Logins[i] = RespWhoamiLogin{
|
||||
StateEvent: prevState.StateEvent,
|
||||
StateTS: prevState.Timestamp,
|
||||
|
|
@ -403,10 +403,17 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque
|
|||
Override: overrideLogin,
|
||||
}
|
||||
prov.loginsLock.Unlock()
|
||||
zerolog.Ctx(r.Context()).Info().
|
||||
Any("first_step", firstStep).
|
||||
Msg("Created login process")
|
||||
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep})
|
||||
}
|
||||
|
||||
func (prov *ProvisioningAPI) handleCompleteStep(ctx context.Context, login *ProvLogin, step *bridgev2.LoginStep) {
|
||||
zerolog.Ctx(ctx).Info().
|
||||
Str("step_id", step.StepID).
|
||||
Str("user_login_id", string(step.CompleteParams.UserLoginID)).
|
||||
Msg("Login completed successfully")
|
||||
prov.deleteLogin(login, false)
|
||||
if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID {
|
||||
return
|
||||
|
|
@ -506,6 +513,8 @@ func (prov *ProvisioningAPI) PostLoginSubmitInput(w http.ResponseWriter, r *http
|
|||
login.NextStep = nextStep
|
||||
if nextStep.Type == bridgev2.LoginStepTypeComplete {
|
||||
prov.handleCompleteStep(r.Context(), login, nextStep)
|
||||
} else {
|
||||
zerolog.Ctx(r.Context()).Debug().Any("next_step", nextStep).Msg("Returning next login step")
|
||||
}
|
||||
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep})
|
||||
}
|
||||
|
|
@ -525,6 +534,8 @@ func (prov *ProvisioningAPI) PostLoginWait(w http.ResponseWriter, r *http.Reques
|
|||
login.NextStep = nextStep
|
||||
if nextStep.Type == bridgev2.LoginStepTypeComplete {
|
||||
prov.handleCompleteStep(r.Context(), login, nextStep)
|
||||
} else {
|
||||
zerolog.Ctx(r.Context()).Debug().Any("next_step", nextStep).Msg("Returning next login step")
|
||||
}
|
||||
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: login.ID, LoginStep: nextStep})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -740,6 +740,41 @@ components:
|
|||
description: For fields of type select, the valid options.
|
||||
items:
|
||||
type: string
|
||||
attachments:
|
||||
type: array
|
||||
description: A list of media attachments to show the user alongside the form fields.
|
||||
items:
|
||||
type: object
|
||||
description: A media attachment to show the user.
|
||||
required: [ type, filename, content ]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The type of media attachment, using the same media type identifiers as Matrix attachments. Only some are supported.
|
||||
enum: [ m.image, m.audio ]
|
||||
filename:
|
||||
type: string
|
||||
description: The filename for the media attachment.
|
||||
content:
|
||||
type: string
|
||||
description: The raw file content for the attachment encoded in base64.
|
||||
info:
|
||||
type: object
|
||||
description: Optional but recommended metadata for the attachment. Can generally be derived from the raw content if omitted.
|
||||
properties:
|
||||
mimetype:
|
||||
type: string
|
||||
description: The MIME type for the media content.
|
||||
examples: [ image/png, audio/mpeg ]
|
||||
w:
|
||||
type: number
|
||||
description: The width of the media in pixels. Only applicable for images and videos.
|
||||
h:
|
||||
type: number
|
||||
description: The height of the media in pixels. Only applicable for images and videos.
|
||||
size:
|
||||
type: number
|
||||
description: The size of the media content in number of bytes. Strongly recommended to include.
|
||||
- description: Cookie login step
|
||||
required: [ type, cookies ]
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type MatrixCapabilities struct {
|
|||
AutoJoinInvites bool
|
||||
BatchSending bool
|
||||
ArbitraryMemberChange bool
|
||||
ExtraProfileMeta bool
|
||||
}
|
||||
|
||||
type MatrixConnector interface {
|
||||
|
|
@ -217,3 +218,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -1110,6 +1115,11 @@ type RemoteEvent interface {
|
|||
GetSender() EventSender
|
||||
}
|
||||
|
||||
type RemoteEventWithContextMutation interface {
|
||||
RemoteEvent
|
||||
MutateContext(ctx context.Context) context.Context
|
||||
}
|
||||
|
||||
type RemoteEventWithUncertainPortalReceiver interface {
|
||||
RemoteEvent
|
||||
PortalReceiverIsUncertain() bool
|
||||
|
|
@ -1439,6 +1449,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]
|
||||
|
|
|
|||
|
|
@ -169,7 +169,9 @@ func (br *Bridge) loadPortal(ctx context.Context, dbPortal *database.Portal, que
|
|||
}
|
||||
|
||||
func (portal *Portal) updateLogger() {
|
||||
logWith := portal.Bridge.Log.With().Str("portal_id", string(portal.ID))
|
||||
logWith := portal.Bridge.Log.With().
|
||||
Str("portal_id", string(portal.ID)).
|
||||
Str("portal_receiver", string(portal.Receiver))
|
||||
if portal.MXID != "" {
|
||||
logWith = logWith.Stringer("portal_mxid", portal.MXID)
|
||||
}
|
||||
|
|
@ -446,6 +448,23 @@ func (portal *Portal) handleSingleEventWithDelayLogging(idx int, rawEvt any) (ou
|
|||
return
|
||||
}
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextKeyRemoteEvent contextKey = iota
|
||||
contextKeyMatrixEvent
|
||||
)
|
||||
|
||||
func GetMatrixEventFromContext(ctx context.Context) (evt *event.Event) {
|
||||
evt, _ = ctx.Value(contextKeyMatrixEvent).(*event.Event)
|
||||
return
|
||||
}
|
||||
|
||||
func GetRemoteEventFromContext(ctx context.Context) (evt RemoteEvent) {
|
||||
evt, _ = ctx.Value(contextKeyRemoteEvent).(RemoteEvent)
|
||||
return
|
||||
}
|
||||
|
||||
func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context {
|
||||
var logWith zerolog.Context
|
||||
switch evt := rawEvt.(type) {
|
||||
|
|
@ -459,6 +478,10 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context {
|
|||
Stringer("event_id", evt.evt.ID).
|
||||
Stringer("sender", evt.sender.MXID)
|
||||
}
|
||||
ctx := portal.Bridge.BackgroundCtx
|
||||
ctx = context.WithValue(ctx, contextKeyMatrixEvent, evt.evt)
|
||||
ctx = logWith.Logger().WithContext(ctx)
|
||||
return ctx
|
||||
case *portalRemoteEvent:
|
||||
evt.evtType = evt.evt.GetType()
|
||||
logWith = portal.Log.With().Int("event_loop_index", idx).
|
||||
|
|
@ -484,10 +507,23 @@ func (portal *Portal) getEventCtxWithLog(rawEvt any, idx int) context.Context {
|
|||
logWith = logWith.Int64("remote_stream_order", remoteStreamOrder)
|
||||
}
|
||||
}
|
||||
if remoteMsg, ok := evt.evt.(RemoteEventWithTimestamp); ok {
|
||||
if remoteTimestamp := remoteMsg.GetTimestamp(); !remoteTimestamp.IsZero() {
|
||||
logWith = logWith.Time("remote_timestamp", remoteTimestamp)
|
||||
}
|
||||
}
|
||||
ctx := portal.Bridge.BackgroundCtx
|
||||
ctx = context.WithValue(ctx, contextKeyRemoteEvent, evt.evt)
|
||||
ctx = logWith.Logger().WithContext(ctx)
|
||||
if ctxMut, ok := evt.evt.(RemoteEventWithContextMutation); ok {
|
||||
ctx = ctxMut.MutateContext(ctx)
|
||||
}
|
||||
return ctx
|
||||
case *portalCreateEvent:
|
||||
return evt.ctx
|
||||
default:
|
||||
panic(fmt.Errorf("invalid type %T in getEventCtxWithLog", evt))
|
||||
}
|
||||
return logWith.Logger().WithContext(portal.Bridge.BackgroundCtx)
|
||||
}
|
||||
|
||||
func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCallback func(res EventHandlingResult)) {
|
||||
|
|
@ -692,6 +728,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
|
||||
}
|
||||
|
|
@ -936,6 +974,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]
|
||||
|
|
@ -1536,6 +1618,12 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi
|
|||
if portal.Bridge.Config.OutgoingMessageReID {
|
||||
deterministicID = portal.Bridge.Matrix.GenerateReactionEventID(portal.MXID, reactionTarget, preResp.SenderID, preResp.EmojiID)
|
||||
}
|
||||
defer func() {
|
||||
// Do this in a defer so that it happens after any potential defer calls to removeOutdatedReaction
|
||||
if handleRes.Success {
|
||||
portal.sendSuccessStatus(ctx, evt, 0, deterministicID)
|
||||
}
|
||||
}()
|
||||
removeOutdatedReaction := func(oldReact *database.Reaction, deleteDB bool) {
|
||||
if !handleRes.Success {
|
||||
return
|
||||
|
|
@ -1581,6 +1669,10 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi
|
|||
// Keep n-1 previous reactions and remove the rest
|
||||
react.ExistingReactionsToKeep = allReactions[:preResp.MaxReactions-1]
|
||||
for _, oldReaction := range allReactions[preResp.MaxReactions-1:] {
|
||||
if existing != nil && oldReaction.EmojiID == existing.EmojiID {
|
||||
// Don't double-delete on networks that only allow one emoji
|
||||
continue
|
||||
}
|
||||
// Intentionally defer in a loop, there won't be that many items,
|
||||
// and we want all of them to be done after this function completes successfully
|
||||
//goland:noinspection GoDeferInLoop
|
||||
|
|
@ -1629,7 +1721,6 @@ func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserLogi
|
|||
if err != nil {
|
||||
log.Err(err).Msg("Failed to save reaction to database")
|
||||
}
|
||||
portal.sendSuccessStatus(ctx, evt, 0, deterministicID)
|
||||
return EventHandlingResultSuccess.WithEventID(deterministicID)
|
||||
}
|
||||
|
||||
|
|
@ -2701,7 +2792,7 @@ func (portal *Portal) getRelationMeta(
|
|||
log.Err(err).Msg("Failed to get last thread message from database")
|
||||
}
|
||||
if prevThreadEvent == nil {
|
||||
prevThreadEvent = threadRoot
|
||||
prevThreadEvent = ptr.Clone(threadRoot)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
|
@ -5312,6 +5403,9 @@ func (portal *Portal) removeInPortalCache(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (portal *Portal) unlockedDelete(ctx context.Context) error {
|
||||
if portal.deleted.IsSet() {
|
||||
return nil
|
||||
}
|
||||
err := portal.safeDBDelete(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -38,17 +38,20 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
|
|||
Stringer("target_portal_key", target).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
if !br.cacheLock.TryLock() {
|
||||
log.Debug().Msg("Waiting for cache lock")
|
||||
br.cacheLock.Lock()
|
||||
log.Debug().Msg("Acquired cache lock after waiting")
|
||||
}
|
||||
defer func() {
|
||||
br.cacheLock.Unlock()
|
||||
log.Debug().Msg("Finished handling portal re-ID")
|
||||
}()
|
||||
acquireCacheLock := func() {
|
||||
if !br.cacheLock.TryLock() {
|
||||
log.Debug().Msg("Waiting for global cache lock")
|
||||
br.cacheLock.Lock()
|
||||
log.Debug().Msg("Acquired global cache lock after waiting")
|
||||
} else {
|
||||
log.Trace().Msg("Acquired global cache lock without waiting")
|
||||
}
|
||||
}
|
||||
log.Debug().Msg("Re-ID'ing portal")
|
||||
sourcePortal, err := br.UnlockedGetPortalByKey(ctx, source, true)
|
||||
sourcePortal, err := br.GetExistingPortalByKey(ctx, source)
|
||||
if err != nil {
|
||||
return ReIDResultError, nil, fmt.Errorf("failed to get source portal: %w", err)
|
||||
} else if sourcePortal == nil {
|
||||
|
|
@ -75,18 +78,24 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
|
|||
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
||||
return c.Stringer("source_portal_mxid", sourcePortal.MXID)
|
||||
})
|
||||
|
||||
acquireCacheLock()
|
||||
targetPortal, err := br.UnlockedGetPortalByKey(ctx, target, true)
|
||||
if err != nil {
|
||||
br.cacheLock.Unlock()
|
||||
return ReIDResultError, nil, fmt.Errorf("failed to get target portal: %w", err)
|
||||
}
|
||||
if targetPortal == nil {
|
||||
log.Info().Msg("Target portal doesn't exist, re-ID'ing source portal")
|
||||
err = sourcePortal.unlockedReID(ctx, target)
|
||||
br.cacheLock.Unlock()
|
||||
if err != nil {
|
||||
return ReIDResultError, nil, fmt.Errorf("failed to re-ID source portal: %w", err)
|
||||
}
|
||||
return ReIDResultSourceReIDd, sourcePortal, nil
|
||||
}
|
||||
br.cacheLock.Unlock()
|
||||
|
||||
if !targetPortal.roomCreateLock.TryLock() {
|
||||
if cancelCreate := targetPortal.cancelRoomCreate.Swap(nil); cancelCreate != nil {
|
||||
(*cancelCreate)()
|
||||
|
|
@ -98,6 +107,8 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
|
|||
defer targetPortal.roomCreateLock.Unlock()
|
||||
if targetPortal.MXID == "" {
|
||||
log.Info().Msg("Target portal row exists, but doesn't have a Matrix room. Deleting target portal row and re-ID'ing source portal")
|
||||
acquireCacheLock()
|
||||
defer br.cacheLock.Unlock()
|
||||
err = targetPortal.unlockedDelete(ctx)
|
||||
if err != nil {
|
||||
return ReIDResultError, nil, fmt.Errorf("failed to delete target portal: %w", err)
|
||||
|
|
@ -112,6 +123,9 @@ func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.Porta
|
|||
return c.Stringer("target_portal_mxid", targetPortal.MXID)
|
||||
})
|
||||
log.Info().Msg("Both target and source portals have Matrix rooms, tombstoning source portal")
|
||||
sourcePortal.removeInPortalCache(ctx)
|
||||
acquireCacheLock()
|
||||
defer br.cacheLock.Unlock()
|
||||
err = sourcePortal.unlockedDelete(ctx)
|
||||
if err != nil {
|
||||
return ReIDResultError, nil, fmt.Errorf("failed to delete source portal row: %w", err)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev
|
|||
if !ok {
|
||||
return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups"))
|
||||
}
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Any("create_params", params).
|
||||
Msg("Creating group chat on remote network")
|
||||
caps := login.Bridge.Network.GetCapabilities()
|
||||
typeSpec, validType := caps.Provisioning.GroupCreation[params.Type]
|
||||
if !validType {
|
||||
|
|
@ -98,6 +101,9 @@ func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev
|
|||
if resp.PortalKey.IsEmpty() {
|
||||
return nil, ErrNoPortalKey
|
||||
}
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Object("portal_key", resp.PortalKey).
|
||||
Msg("Successfully created group on remote network")
|
||||
if resp.Portal == nil {
|
||||
resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ type EventMeta struct {
|
|||
Timestamp time.Time
|
||||
StreamOrder int64
|
||||
|
||||
PreHandleFunc func(context.Context, *bridgev2.Portal)
|
||||
PostHandleFunc func(context.Context, *bridgev2.Portal)
|
||||
PreHandleFunc func(context.Context, *bridgev2.Portal)
|
||||
PostHandleFunc func(context.Context, *bridgev2.Portal)
|
||||
MutateContextFunc func(context.Context) context.Context
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -39,6 +40,7 @@ var (
|
|||
_ bridgev2.RemoteEventWithStreamOrder = (*EventMeta)(nil)
|
||||
_ bridgev2.RemotePreHandler = (*EventMeta)(nil)
|
||||
_ bridgev2.RemotePostHandler = (*EventMeta)(nil)
|
||||
_ bridgev2.RemoteEventWithContextMutation = (*EventMeta)(nil)
|
||||
)
|
||||
|
||||
func (evt *EventMeta) AddLogContext(c zerolog.Context) zerolog.Context {
|
||||
|
|
@ -91,6 +93,13 @@ func (evt *EventMeta) PostHandle(ctx context.Context, portal *bridgev2.Portal) {
|
|||
}
|
||||
}
|
||||
|
||||
func (evt *EventMeta) MutateContext(ctx context.Context) context.Context {
|
||||
if evt.MutateContextFunc == nil {
|
||||
return ctx
|
||||
}
|
||||
return evt.MutateContextFunc(ctx)
|
||||
}
|
||||
|
||||
func (evt EventMeta) WithType(t bridgev2.RemoteEventType) EventMeta {
|
||||
evt.Type = t
|
||||
return evt
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import (
|
|||
|
||||
"github.com/tidwall/sjson"
|
||||
"go.mau.fi/util/jsontime"
|
||||
"go.mau.fi/util/ptr"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
|
@ -112,7 +111,7 @@ func (rp *RemoteProfile) Merge(other RemoteProfile) RemoteProfile {
|
|||
return other
|
||||
}
|
||||
|
||||
func (rp *RemoteProfile) IsEmpty() bool {
|
||||
func (rp *RemoteProfile) IsZero() bool {
|
||||
return rp == nil || (rp.Phone == "" && rp.Email == "" && rp.Username == "" && rp.Name == "" && rp.Avatar == "" && rp.AvatarFile == nil)
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +129,7 @@ type BridgeState struct {
|
|||
UserID id.UserID `json:"user_id,omitempty"`
|
||||
RemoteID networkid.UserLoginID `json:"remote_id,omitempty"`
|
||||
RemoteName string `json:"remote_name,omitempty"`
|
||||
RemoteProfile *RemoteProfile `json:"remote_profile,omitempty"`
|
||||
RemoteProfile RemoteProfile `json:"remote_profile,omitzero"`
|
||||
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Info map[string]interface{} `json:"info,omitempty"`
|
||||
|
|
@ -210,7 +209,7 @@ func (pong *BridgeState) ShouldDeduplicate(newPong *BridgeState) bool {
|
|||
pong.StateEvent == newPong.StateEvent &&
|
||||
pong.RemoteName == newPong.RemoteName &&
|
||||
pong.UserAction == newPong.UserAction &&
|
||||
ptr.Val(pong.RemoteProfile) == ptr.Val(newPong.RemoteProfile) &&
|
||||
pong.RemoteProfile == newPong.RemoteProfile &&
|
||||
pong.Error == newPong.Error &&
|
||||
maps.EqualFunc(pong.Info, newPong.Info, reflect.DeepEqual) &&
|
||||
pong.Timestamp.Add(time.Duration(pong.TTL)*time.Second).After(time.Now())
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ func (ul *UserLogin) FillBridgeState(state status.BridgeState) status.BridgeStat
|
|||
state.UserID = ul.UserMXID
|
||||
state.RemoteID = ul.ID
|
||||
state.RemoteName = ul.RemoteName
|
||||
state.RemoteProfile = &ul.RemoteProfile
|
||||
state.RemoteProfile = ul.RemoteProfile
|
||||
filler, ok := ul.Client.(status.BridgeStateFiller)
|
||||
if ok {
|
||||
return filler.FillBridgeState(state)
|
||||
|
|
|
|||
85
client.go
85
client.go
|
|
@ -386,7 +386,14 @@ func (cli *Client) LogRequestDone(req *http.Request, resp *http.Response, err er
|
|||
}
|
||||
}
|
||||
if body := req.Context().Value(LogBodyContextKey); body != nil {
|
||||
evt.Interface("req_body", body)
|
||||
switch typedLogBody := body.(type) {
|
||||
case json.RawMessage:
|
||||
evt.RawJSON("req_body", typedLogBody)
|
||||
case string:
|
||||
evt.Str("req_body", typedLogBody)
|
||||
default:
|
||||
panic(fmt.Errorf("invalid type for LogBodyContextKey: %T", body))
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
evt.Msg("Request canceled")
|
||||
|
|
@ -450,8 +457,10 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e
|
|||
}
|
||||
if params.SensitiveContent && !logSensitiveContent {
|
||||
logBody = "<sensitive content omitted>"
|
||||
} else if len(jsonStr) > 32768 {
|
||||
logBody = fmt.Sprintf("<large content omitted (%d bytes)>", len(jsonStr))
|
||||
} else {
|
||||
logBody = params.RequestJSON
|
||||
logBody = json.RawMessage(jsonStr)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonStr)
|
||||
reqLen = int64(len(jsonStr))
|
||||
|
|
@ -476,7 +485,7 @@ func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, e
|
|||
}
|
||||
} else if params.Method != http.MethodGet && params.Method != http.MethodHead {
|
||||
params.RequestJSON = struct{}{}
|
||||
logBody = params.RequestJSON
|
||||
logBody = json.RawMessage("{}")
|
||||
reqBody = bytes.NewReader([]byte("{}"))
|
||||
reqLen = 2
|
||||
}
|
||||
|
|
@ -909,7 +918,7 @@ func (cli *Client) RegisterAvailable(ctx context.Context, username string) (resp
|
|||
return
|
||||
}
|
||||
|
||||
func (cli *Client) register(ctx context.Context, url string, req *ReqRegister) (resp *RespRegister, uiaResp *RespUserInteractive, err error) {
|
||||
func (cli *Client) register(ctx context.Context, url string, req *ReqRegister[any]) (resp *RespRegister, uiaResp *RespUserInteractive, err error) {
|
||||
var bodyBytes []byte
|
||||
bodyBytes, err = cli.MakeFullRequest(ctx, FullRequest{
|
||||
Method: http.MethodPost,
|
||||
|
|
@ -933,7 +942,7 @@ func (cli *Client) register(ctx context.Context, url string, req *ReqRegister) (
|
|||
// Register makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register
|
||||
//
|
||||
// Registers with kind=user. For kind=guest, see RegisterGuest.
|
||||
func (cli *Client) Register(ctx context.Context, req *ReqRegister) (*RespRegister, *RespUserInteractive, error) {
|
||||
func (cli *Client) Register(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) {
|
||||
u := cli.BuildClientURL("v3", "register")
|
||||
return cli.register(ctx, u, req)
|
||||
}
|
||||
|
|
@ -942,7 +951,7 @@ func (cli *Client) Register(ctx context.Context, req *ReqRegister) (*RespRegiste
|
|||
// with kind=guest.
|
||||
//
|
||||
// For kind=user, see Register.
|
||||
func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister) (*RespRegister, *RespUserInteractive, error) {
|
||||
func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister[any]) (*RespRegister, *RespUserInteractive, error) {
|
||||
query := map[string]string{
|
||||
"kind": "guest",
|
||||
}
|
||||
|
|
@ -965,7 +974,7 @@ func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister) (*RespRe
|
|||
// panic(err)
|
||||
// }
|
||||
// token := res.AccessToken
|
||||
func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister) (*RespRegister, error) {
|
||||
func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister[any]) (*RespRegister, error) {
|
||||
_, uia, err := cli.Register(ctx, req)
|
||||
if err != nil && uia == nil {
|
||||
return nil, err
|
||||
|
|
@ -1149,7 +1158,9 @@ func (cli *Client) SearchUserDirectory(ctx context.Context, query string, limit
|
|||
}
|
||||
|
||||
func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, extras ...ReqMutualRooms) (resp *RespMutualRooms, err error) {
|
||||
if cli.SpecVersions != nil && !cli.SpecVersions.Supports(FeatureMutualRooms) {
|
||||
supportsStable := cli.SpecVersions.Supports(FeatureStableMutualRooms)
|
||||
supportsUnstable := cli.SpecVersions.Supports(FeatureUnstableMutualRooms)
|
||||
if cli.SpecVersions != nil && !supportsUnstable && !supportsStable {
|
||||
err = fmt.Errorf("server does not support fetching mutual rooms")
|
||||
return
|
||||
}
|
||||
|
|
@ -1159,7 +1170,10 @@ func (cli *Client) GetMutualRooms(ctx context.Context, otherUserID id.UserID, ex
|
|||
if len(extras) > 0 {
|
||||
query["from"] = extras[0].From
|
||||
}
|
||||
urlPath := cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query)
|
||||
urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "mutual_rooms"}, query)
|
||||
if !supportsStable && supportsUnstable {
|
||||
urlPath = cli.BuildURLWithQuery(ClientURLPath{"unstable", "uk.half-shot.msc2666", "user", "mutual_rooms"}, query)
|
||||
}
|
||||
_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
|
||||
return
|
||||
}
|
||||
|
|
@ -1350,6 +1364,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) {
|
||||
|
|
@ -1990,7 +2046,10 @@ type ReqUploadMedia struct {
|
|||
}
|
||||
|
||||
func (cli *Client) tryUploadMediaToURL(ctx context.Context, url, contentType string, content io.Reader, contentLength int64) (*http.Response, error) {
|
||||
cli.Log.Debug().Str("url", url).Msg("Uploading media to external URL")
|
||||
cli.Log.Debug().
|
||||
Str("url", url).
|
||||
Int64("content_length", contentLength).
|
||||
Msg("Uploading media to external URL")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -2628,13 +2687,13 @@ func (cli *Client) SetDeviceInfo(ctx context.Context, deviceID id.DeviceID, req
|
|||
return err
|
||||
}
|
||||
|
||||
func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice) error {
|
||||
func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice[any]) error {
|
||||
urlPath := cli.BuildClientURL("v3", "devices", deviceID)
|
||||
_, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices) error {
|
||||
func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices[any]) error {
|
||||
urlPath := cli.BuildClientURL("v3", "delete_devices")
|
||||
_, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, req, nil)
|
||||
return err
|
||||
|
|
@ -2645,7 +2704,7 @@ type UIACallback = func(*RespUserInteractive) interface{}
|
|||
// UploadCrossSigningKeys uploads the given cross-signing keys to the server.
|
||||
// Because the endpoint requires user-interactive authentication a callback must be provided that,
|
||||
// given the UI auth parameters, produces the required result (or nil to end the flow).
|
||||
func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq, uiaCallback UIACallback) error {
|
||||
func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq[any], uiaCallback UIACallback) error {
|
||||
content, err := cli.MakeFullRequest(ctx, FullRequest{
|
||||
Method: http.MethodPost,
|
||||
URL: cli.BuildClientURL("v3", "keys", "device_signing", "upload"),
|
||||
|
|
|
|||
158
client_ephemeral_test.go
Normal file
158
client_ephemeral_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ func (mach *OlmMachine) PublishCrossSigningKeys(ctx context.Context, keys *Cross
|
|||
}
|
||||
userKey.Signatures = signatures.NewSingleSignature(userID, id.KeyAlgorithmEd25519, keys.MasterKey.PublicKey().String(), userSig)
|
||||
|
||||
err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq{
|
||||
err = mach.Client.UploadCrossSigningKeys(ctx, &mautrix.UploadCrossSigningKeysReq[any]{
|
||||
Master: masterKey,
|
||||
SelfSigning: selfKey,
|
||||
UserSigning: userKey,
|
||||
|
|
|
|||
|
|
@ -124,7 +124,13 @@ func (mach *OlmMachine) DecryptMegolmEvent(ctx context.Context, evt *event.Event
|
|||
Msg("Couldn't resolve trust level of session: sent by unknown device")
|
||||
trustLevel = id.TrustStateUnknownDevice
|
||||
} else if device.SigningKey != sess.SigningKey || device.IdentityKey != sess.SenderKey {
|
||||
return nil, ErrDeviceKeyMismatch
|
||||
log.Debug().
|
||||
Stringer("session_sender_key", sess.SenderKey).
|
||||
Stringer("device_sender_key", device.IdentityKey).
|
||||
Stringer("session_signing_key", sess.SigningKey).
|
||||
Stringer("device_signing_key", device.SigningKey).
|
||||
Msg("Device keys don't match keys in session, marking as untrusted")
|
||||
trustLevel = id.TrustStateDeviceKeyMismatch
|
||||
} else {
|
||||
trustLevel, err = mach.ResolveTrustContext(ctx, device)
|
||||
if err != nil {
|
||||
|
|
@ -207,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
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ func (mach *OlmMachine) decryptAndParseOlmCiphertext(ctx context.Context, evt *e
|
|||
}
|
||||
|
||||
func olmMessageHash(ciphertext string) ([32]byte, error) {
|
||||
if ciphertext == "" {
|
||||
return [32]byte{}, fmt.Errorf("empty ciphertext")
|
||||
}
|
||||
ciphertextBytes, err := base64.RawStdEncoding.DecodeString(ciphertext)
|
||||
return sha256.Sum256(ciphertextBytes), err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,26 +370,19 @@ func (mach *OlmMachine) encryptAndSendGroupSession(ctx context.Context, session
|
|||
log.Trace().Msg("Encrypting group session for all found devices")
|
||||
deviceCount := 0
|
||||
toDevice := &mautrix.ReqSendToDevice{Messages: make(map[id.UserID]map[id.DeviceID]*event.Content)}
|
||||
logUsers := zerolog.Dict()
|
||||
for userID, sessions := range olmSessions {
|
||||
if len(sessions) == 0 {
|
||||
continue
|
||||
}
|
||||
logDevices := zerolog.Dict()
|
||||
output := make(map[id.DeviceID]*event.Content)
|
||||
toDevice.Messages[userID] = output
|
||||
for deviceID, device := range sessions {
|
||||
log.Trace().
|
||||
Stringer("target_user_id", userID).
|
||||
Stringer("target_device_id", deviceID).
|
||||
Stringer("target_identity_key", device.identity.IdentityKey).
|
||||
Msg("Encrypting group session for device")
|
||||
content := mach.encryptOlmEvent(ctx, device.session, device.identity, event.ToDeviceRoomKey, session.ShareContent())
|
||||
output[deviceID] = &event.Content{Parsed: content}
|
||||
logDevices.Str(string(deviceID), string(device.identity.IdentityKey))
|
||||
deviceCount++
|
||||
log.Debug().
|
||||
Stringer("target_user_id", userID).
|
||||
Stringer("target_device_id", deviceID).
|
||||
Stringer("target_identity_key", device.identity.IdentityKey).
|
||||
Msg("Encrypted group session for device")
|
||||
if !mach.DisableSharedGroupSessionTracking {
|
||||
err := mach.CryptoStore.MarkOutboundGroupSessionShared(ctx, userID, device.identity.IdentityKey, session.id)
|
||||
if err != nil {
|
||||
|
|
@ -403,11 +396,13 @@ func (mach *OlmMachine) encryptAndSendGroupSession(ctx context.Context, session
|
|||
}
|
||||
}
|
||||
}
|
||||
logUsers.Dict(string(userID), logDevices)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("device_count", deviceCount).
|
||||
Int("user_count", len(toDevice.Messages)).
|
||||
Dict("destination_map", logUsers).
|
||||
Msg("Sending to-device messages to share group session")
|
||||
_, err := mach.Client.SendToDevice(ctx, event.ToDeviceEncrypted, toDevice)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -96,15 +96,19 @@ func (mach *OlmMachine) encryptOlmEvent(ctx context.Context, session *OlmSession
|
|||
panic(err)
|
||||
}
|
||||
log := mach.machOrContextLog(ctx)
|
||||
log.Debug().
|
||||
Str("recipient_identity_key", recipient.IdentityKey.String()).
|
||||
Str("olm_session_id", session.ID().String()).
|
||||
Str("olm_session_description", session.Describe()).
|
||||
Msg("Encrypting olm message")
|
||||
msgType, ciphertext, err := session.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ciphertextStr := string(ciphertext)
|
||||
ciphertextHash, _ := olmMessageHash(ciphertextStr)
|
||||
log.Debug().
|
||||
Stringer("event_type", evtType).
|
||||
Str("recipient_identity_key", recipient.IdentityKey.String()).
|
||||
Str("olm_session_id", session.ID().String()).
|
||||
Str("olm_session_description", session.Describe()).
|
||||
Hex("ciphertext_hash", ciphertextHash[:]).
|
||||
Msg("Encrypted olm message")
|
||||
err = mach.CryptoStore.UpdateSession(ctx, recipient.IdentityKey, session)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update olm session in crypto store after encrypting")
|
||||
|
|
@ -115,7 +119,7 @@ func (mach *OlmMachine) encryptOlmEvent(ctx context.Context, session *OlmSession
|
|||
OlmCiphertext: event.OlmCiphertexts{
|
||||
recipient.IdentityKey: {
|
||||
Type: msgType,
|
||||
Body: string(ciphertext),
|
||||
Body: ciphertextStr,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ func (c Curve25519KeyPair) B64Encoded() id.Curve25519 {
|
|||
|
||||
// SharedSecret returns the shared secret between the key pair and the given public key.
|
||||
func (c Curve25519KeyPair) SharedSecret(pubKey Curve25519PublicKey) ([]byte, error) {
|
||||
// Note: the standard library checks that the output is non-zero
|
||||
return c.PrivateKey.SharedSecret(pubKey)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ func TestCurve25519(t *testing.T) {
|
|||
fromPrivate, err := crypto.Curve25519GenerateFromPrivate(firstKeypair.PrivateKey)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, fromPrivate, firstKeypair)
|
||||
_, err = secondKeypair.SharedSecret(make([]byte, crypto.Curve25519PublicKeyLength))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCurve25519Case1(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -200,13 +200,14 @@ func (mach *OlmMachine) ImportRoomKeyFromBackupWithoutSaving(
|
|||
SigningKey: keyBackupData.SenderClaimedKeys.Ed25519,
|
||||
SenderKey: keyBackupData.SenderKey,
|
||||
RoomID: roomID,
|
||||
ForwardingChains: append(keyBackupData.ForwardingKeyChain, keyBackupData.SenderKey.String()),
|
||||
ForwardingChains: keyBackupData.ForwardingKeyChain,
|
||||
id: sessionID,
|
||||
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
MaxAge: maxAge.Milliseconds(),
|
||||
MaxMessages: maxMessages,
|
||||
KeyBackupVersion: version,
|
||||
KeySource: id.KeySourceBackup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,14 +108,13 @@ func (mach *OlmMachine) importExportedRoomKey(ctx context.Context, session Expor
|
|||
return false, ErrMismatchingExportedSessionID
|
||||
}
|
||||
igs := &InboundGroupSession{
|
||||
Internal: igsInternal,
|
||||
SigningKey: session.SenderClaimedKeys.Ed25519,
|
||||
SenderKey: session.SenderKey,
|
||||
RoomID: session.RoomID,
|
||||
// TODO should we add something here to mark the signing key as unverified like key requests do?
|
||||
Internal: igsInternal,
|
||||
SigningKey: session.SenderClaimedKeys.Ed25519,
|
||||
SenderKey: session.SenderKey,
|
||||
RoomID: session.RoomID,
|
||||
ForwardingChains: session.ForwardingChains,
|
||||
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
KeySource: id.KeySourceImport,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}
|
||||
existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID())
|
||||
firstKnownIndex := igs.Internal.FirstKnownIndex()
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ func (mach *OlmMachine) importForwardedRoomKey(ctx context.Context, evt *Decrypt
|
|||
MaxAge: maxAge.Milliseconds(),
|
||||
MaxMessages: maxMessages,
|
||||
IsScheduled: content.IsScheduled,
|
||||
KeySource: id.KeySourceForward,
|
||||
}
|
||||
existingIGS, _ := mach.CryptoStore.GetGroupSession(ctx, igs.RoomID, igs.ID())
|
||||
if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= igs.Internal.FirstKnownIndex() {
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ type InboundGroupSession struct {
|
|||
MaxMessages int
|
||||
IsScheduled bool
|
||||
KeyBackupVersion id.KeyBackupVersion
|
||||
KeySource id.KeySource
|
||||
|
||||
id id.SessionID
|
||||
}
|
||||
|
|
@ -136,6 +137,7 @@ func NewInboundGroupSession(senderKey id.SenderKey, signingKey id.Ed25519, roomI
|
|||
MaxAge: maxAge.Milliseconds(),
|
||||
MaxMessages: maxMessages,
|
||||
IsScheduled: isScheduled,
|
||||
KeySource: id.KeySourceDirect,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,22 +346,23 @@ func (store *SQLCryptoStore) PutGroupSession(ctx context.Context, session *Inbou
|
|||
Int("max_messages", session.MaxMessages).
|
||||
Bool("is_scheduled", session.IsScheduled).
|
||||
Stringer("key_backup_version", session.KeyBackupVersion).
|
||||
Stringer("key_source", session.KeySource).
|
||||
Msg("Upserting megolm inbound group session")
|
||||
_, err = store.DB.Exec(ctx, `
|
||||
INSERT INTO crypto_megolm_inbound_session (
|
||||
session_id, sender_key, signing_key, room_id, session, forwarding_chains,
|
||||
ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, account_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source, account_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (session_id, account_id) DO UPDATE
|
||||
SET withheld_code=NULL, withheld_reason=NULL, sender_key=excluded.sender_key, signing_key=excluded.signing_key,
|
||||
room_id=excluded.room_id, session=excluded.session, forwarding_chains=excluded.forwarding_chains,
|
||||
ratchet_safety=excluded.ratchet_safety, received_at=excluded.received_at,
|
||||
max_age=excluded.max_age, max_messages=excluded.max_messages, is_scheduled=excluded.is_scheduled,
|
||||
key_backup_version=excluded.key_backup_version
|
||||
key_backup_version=excluded.key_backup_version, key_source=excluded.key_source
|
||||
`,
|
||||
session.ID(), session.SenderKey, session.SigningKey, session.RoomID, sessionBytes, forwardingChains,
|
||||
ratchetSafety, datePtr(session.ReceivedAt), dbutil.NumPtr(session.MaxAge), dbutil.NumPtr(session.MaxMessages),
|
||||
session.IsScheduled, session.KeyBackupVersion, store.AccountID,
|
||||
session.IsScheduled, session.KeyBackupVersion, session.KeySource, store.AccountID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
|
@ -374,12 +375,13 @@ func (store *SQLCryptoStore) GetGroupSession(ctx context.Context, roomID id.Room
|
|||
var maxAge, maxMessages sql.NullInt64
|
||||
var isScheduled bool
|
||||
var version id.KeyBackupVersion
|
||||
var keySource id.KeySource
|
||||
err := store.DB.QueryRow(ctx, `
|
||||
SELECT sender_key, signing_key, session, forwarding_chains, withheld_code, withheld_reason, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version
|
||||
SELECT sender_key, signing_key, session, forwarding_chains, withheld_code, withheld_reason, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source
|
||||
FROM crypto_megolm_inbound_session
|
||||
WHERE room_id=$1 AND session_id=$2 AND account_id=$3`,
|
||||
roomID, sessionID, store.AccountID,
|
||||
).Scan(&senderKey, &signingKey, &sessionBytes, &forwardingChains, &withheldCode, &withheldReason, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version)
|
||||
).Scan(&senderKey, &signingKey, &sessionBytes, &forwardingChains, &withheldCode, &withheldReason, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
|
|
@ -410,6 +412,7 @@ func (store *SQLCryptoStore) GetGroupSession(ctx context.Context, roomID id.Room
|
|||
MaxMessages: int(maxMessages.Int64),
|
||||
IsScheduled: isScheduled,
|
||||
KeyBackupVersion: version,
|
||||
KeySource: keySource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -534,7 +537,8 @@ func (store *SQLCryptoStore) scanInboundGroupSession(rows dbutil.Scannable) (*In
|
|||
var maxAge, maxMessages sql.NullInt64
|
||||
var isScheduled bool
|
||||
var version id.KeyBackupVersion
|
||||
err := rows.Scan(&roomID, &senderKey, &signingKey, &sessionBytes, &forwardingChains, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version)
|
||||
var keySource id.KeySource
|
||||
err := rows.Scan(&roomID, &senderKey, &signingKey, &sessionBytes, &forwardingChains, &ratchetSafetyBytes, &receivedAt, &maxAge, &maxMessages, &isScheduled, &version, &keySource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -554,12 +558,13 @@ func (store *SQLCryptoStore) scanInboundGroupSession(rows dbutil.Scannable) (*In
|
|||
MaxMessages: int(maxMessages.Int64),
|
||||
IsScheduled: isScheduled,
|
||||
KeyBackupVersion: version,
|
||||
KeySource: keySource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (store *SQLCryptoStore) GetGroupSessionsForRoom(ctx context.Context, roomID id.RoomID) dbutil.RowIter[*InboundGroupSession] {
|
||||
rows, err := store.DB.Query(ctx, `
|
||||
SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version
|
||||
SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source
|
||||
FROM crypto_megolm_inbound_session WHERE room_id=$1 AND account_id=$2 AND session IS NOT NULL`,
|
||||
roomID, store.AccountID,
|
||||
)
|
||||
|
|
@ -568,7 +573,7 @@ func (store *SQLCryptoStore) GetGroupSessionsForRoom(ctx context.Context, roomID
|
|||
|
||||
func (store *SQLCryptoStore) GetAllGroupSessions(ctx context.Context) dbutil.RowIter[*InboundGroupSession] {
|
||||
rows, err := store.DB.Query(ctx, `
|
||||
SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version
|
||||
SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source
|
||||
FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL`,
|
||||
store.AccountID,
|
||||
)
|
||||
|
|
@ -577,7 +582,7 @@ func (store *SQLCryptoStore) GetAllGroupSessions(ctx context.Context) dbutil.Row
|
|||
|
||||
func (store *SQLCryptoStore) GetGroupSessionsWithoutKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) dbutil.RowIter[*InboundGroupSession] {
|
||||
rows, err := store.DB.Query(ctx, `
|
||||
SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version
|
||||
SELECT room_id, sender_key, signing_key, session, forwarding_chains, ratchet_safety, received_at, max_age, max_messages, is_scheduled, key_backup_version, key_source
|
||||
FROM crypto_megolm_inbound_session WHERE account_id=$1 AND session IS NOT NULL AND key_backup_version != $2`,
|
||||
store.AccountID, version,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
-- v0 -> v18 (compatible with v15+): Latest revision
|
||||
-- v0 -> v19 (compatible with v15+): Latest revision
|
||||
CREATE TABLE IF NOT EXISTS crypto_account (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
|
|
@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS crypto_megolm_inbound_session (
|
|||
max_messages INTEGER,
|
||||
is_scheduled BOOLEAN NOT NULL DEFAULT false,
|
||||
key_backup_version TEXT NOT NULL DEFAULT '',
|
||||
key_source TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (account_id, session_id)
|
||||
);
|
||||
-- Useful index to find keys that need backing up
|
||||
|
|
|
|||
2
crypto/sql_store_upgrade/19-megolm-session-source.sql
Normal file
2
crypto/sql_store_upgrade/19-megolm-session-source.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- v19 (compatible with v15+): Store megolm session source
|
||||
ALTER TABLE crypto_megolm_inbound_session ADD COLUMN key_source TEXT NOT NULL DEFAULT '';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ var TypeMap = map[Type]reflect.Type{
|
|||
StateSpaceParent: reflect.TypeOf(SpaceParentEventContent{}),
|
||||
StateSpaceChild: reflect.TypeOf(SpaceChildEventContent{}),
|
||||
|
||||
StateRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}),
|
||||
StateUnstableRoomPolicy: reflect.TypeOf(RoomPolicyEventContent{}),
|
||||
|
||||
StateLegacyPolicyRoom: reflect.TypeOf(ModPolicyContent{}),
|
||||
StateLegacyPolicyServer: reflect.TypeOf(ModPolicyContent{}),
|
||||
StateLegacyPolicyUser: reflect.TypeOf(ModPolicyContent{}),
|
||||
|
|
@ -73,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{}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
67
event/powerlevels_ephemeral_test.go
Normal file
67
event/powerlevels_ephemeral_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -343,3 +343,15 @@ func (efmc *ElementFunctionalMembersContent) Add(mxid id.UserID) bool {
|
|||
efmc.ServiceMembers = append(efmc.ServiceMembers, mxid)
|
||||
return true
|
||||
}
|
||||
|
||||
type PolicyServerPublicKeys struct {
|
||||
Ed25519 id.Ed25519 `json:"ed25519,omitempty"`
|
||||
}
|
||||
|
||||
type RoomPolicyEventContent struct {
|
||||
Via string `json:"via,omitempty"`
|
||||
PublicKeys *PolicyServerPublicKeys `json:"public_keys,omitempty"`
|
||||
|
||||
// Deprecated, only for legacy use
|
||||
PublicKey id.Ed25519 `json:"public_key,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ func (et *Type) GuessClass() TypeClass {
|
|||
StatePinnedEvents.Type, StateTombstone.Type, StateEncryption.Type, StateBridge.Type, StateHalfShotBridge.Type,
|
||||
StateSpaceParent.Type, StateSpaceChild.Type, StatePolicyRoom.Type, StatePolicyServer.Type, StatePolicyUser.Type,
|
||||
StateElementFunctionalMembers.Type, StateBeeperRoomFeatures.Type, StateBeeperDisappearingTimer.Type,
|
||||
StateMSC4391BotCommand.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,
|
||||
|
|
@ -195,6 +195,9 @@ var (
|
|||
StateSpaceChild = Type{"m.space.child", StateEventType}
|
||||
StateSpaceParent = Type{"m.space.parent", StateEventType}
|
||||
|
||||
StateRoomPolicy = Type{"m.room.policy", StateEventType}
|
||||
StateUnstableRoomPolicy = Type{"org.matrix.msc4284.policy", StateEventType}
|
||||
|
||||
StateLegacyPolicyRoom = Type{"m.room.rule.room", StateEventType}
|
||||
StateLegacyPolicyServer = Type{"m.room.rule.server", StateEventType}
|
||||
StateLegacyPolicyUser = Type{"m.room.rule.user", StateEventType}
|
||||
|
|
@ -247,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
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ func authorizeMember(roomVersion id.RoomVersion, evt, createEvt *pdu.PDU, authEv
|
|||
// 5.5.5. Otherwise, reject.
|
||||
return ErrInsufficientPermissionForKick
|
||||
case event.MembershipBan:
|
||||
if senderMembership != event.MembershipLeave {
|
||||
if senderMembership != event.MembershipJoin {
|
||||
// 5.6.1. If the sender’s current membership state is not join, reject.
|
||||
return ErrCantBanWithoutBeingInRoom
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,19 @@ func (pdu *PDU) ToClientEvent(roomVersion id.RoomVersion) (*event.Event, error)
|
|||
return evt, nil
|
||||
}
|
||||
|
||||
func (pdu *PDU) AddSignature(serverName string, keyID id.KeyID, signature string) {
|
||||
if signature == "" {
|
||||
return
|
||||
}
|
||||
if pdu.Signatures == nil {
|
||||
pdu.Signatures = make(map[string]map[id.KeyID]string)
|
||||
}
|
||||
if _, ok := pdu.Signatures[serverName]; !ok {
|
||||
pdu.Signatures[serverName] = make(map[id.KeyID]string)
|
||||
}
|
||||
pdu.Signatures[serverName][keyID] = signature
|
||||
}
|
||||
|
||||
func marshalCanonical(data any) (jsontext.Value, error) {
|
||||
marshaledBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -28,13 +28,7 @@ func (pdu *PDU) Sign(roomVersion id.RoomVersion, serverName string, keyID id.Key
|
|||
return fmt.Errorf("failed to marshal redacted PDU to sign: %w", err)
|
||||
}
|
||||
signature := ed25519.Sign(privateKey, rawJSON)
|
||||
if pdu.Signatures == nil {
|
||||
pdu.Signatures = make(map[string]map[id.KeyID]string)
|
||||
}
|
||||
if _, ok := pdu.Signatures[serverName]; !ok {
|
||||
pdu.Signatures[serverName] = make(map[id.KeyID]string)
|
||||
}
|
||||
pdu.Signatures[serverName][keyID] = base64.RawStdEncoding.EncodeToString(signature)
|
||||
pdu.AddSignature(serverName, keyID, base64.RawStdEncoding.EncodeToString(signature))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,30 @@ func DefaultPillConverter(displayname, mxid, eventID string, ctx Context) string
|
|||
}
|
||||
}
|
||||
|
||||
func onlyBacktickCount(line string) (count int) {
|
||||
for i := 0; i < len(line); i++ {
|
||||
if line[i] != '`' {
|
||||
return -1
|
||||
}
|
||||
count++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func DefaultMonospaceBlockConverter(code, language string, ctx Context) string {
|
||||
if len(code) == 0 || code[len(code)-1] != '\n' {
|
||||
code += "\n"
|
||||
}
|
||||
fence := "```"
|
||||
for line := range strings.SplitSeq(code, "\n") {
|
||||
count := onlyBacktickCount(strings.TrimSpace(line))
|
||||
if count >= len(fence) {
|
||||
fence = strings.Repeat("`", count+1)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s%s\n%s%s", fence, language, code, fence)
|
||||
}
|
||||
|
||||
// HTMLParser is a somewhat customizable Matrix HTML parser.
|
||||
type HTMLParser struct {
|
||||
PillConverter PillConverter
|
||||
|
|
@ -348,10 +372,7 @@ func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) string {
|
|||
if parser.MonospaceBlockConverter != nil {
|
||||
return parser.MonospaceBlockConverter(preStr, language, ctx)
|
||||
}
|
||||
if len(preStr) == 0 || preStr[len(preStr)-1] != '\n' {
|
||||
preStr += "\n"
|
||||
}
|
||||
return fmt.Sprintf("```%s\n%s```", language, preStr)
|
||||
return DefaultMonospaceBlockConverter(preStr, language, ctx)
|
||||
default:
|
||||
return parser.nodeToTagAwareString(node.FirstChild, ctx)
|
||||
}
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -5,7 +5,7 @@ go 1.25.0
|
|||
toolchain go1.26.0
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0
|
||||
filippo.io/edwards25519 v1.2.0
|
||||
github.com/chzyer/readline v1.5.1
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/lib/pq v1.11.2
|
||||
|
|
@ -20,7 +20,7 @@ require (
|
|||
go.mau.fi/util v0.9.6
|
||||
go.mau.fi/zeroconfig v0.2.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sync v0.19.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -1,5 +1,5 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
|
|
@ -58,8 +58,8 @@ go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
|
|||
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
|
|
|
|||
28
id/crypto.go
28
id/crypto.go
|
|
@ -53,6 +53,34 @@ const (
|
|||
KeyBackupAlgorithmMegolmBackupV1 KeyBackupAlgorithm = "m.megolm_backup.v1.curve25519-aes-sha2"
|
||||
)
|
||||
|
||||
type KeySource string
|
||||
|
||||
func (source KeySource) String() string {
|
||||
return string(source)
|
||||
}
|
||||
|
||||
func (source KeySource) Int() int {
|
||||
switch source {
|
||||
case KeySourceDirect:
|
||||
return 100
|
||||
case KeySourceBackup:
|
||||
return 90
|
||||
case KeySourceImport:
|
||||
return 80
|
||||
case KeySourceForward:
|
||||
return 50
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
KeySourceDirect KeySource = "direct"
|
||||
KeySourceBackup KeySource = "backup"
|
||||
KeySourceImport KeySource = "import"
|
||||
KeySourceForward KeySource = "forward"
|
||||
)
|
||||
|
||||
// BackupVersion is an arbitrary string that identifies a server side key backup.
|
||||
type KeyBackupVersion string
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type TrustState int
|
|||
|
||||
const (
|
||||
TrustStateBlacklisted TrustState = -100
|
||||
TrustStateDeviceKeyMismatch TrustState = -5
|
||||
TrustStateUnset TrustState = 0
|
||||
TrustStateUnknownDevice TrustState = 10
|
||||
TrustStateForwarded TrustState = 20
|
||||
|
|
@ -23,7 +24,7 @@ const (
|
|||
TrustStateCrossSignedTOFU TrustState = 100
|
||||
TrustStateCrossSignedVerified TrustState = 200
|
||||
TrustStateVerified TrustState = 300
|
||||
TrustStateInvalid TrustState = (1 << 31) - 1
|
||||
TrustStateInvalid TrustState = -2147483647
|
||||
)
|
||||
|
||||
func (ts *TrustState) UnmarshalText(data []byte) error {
|
||||
|
|
@ -44,6 +45,8 @@ func ParseTrustState(val string) TrustState {
|
|||
switch strings.ToLower(val) {
|
||||
case "blacklisted":
|
||||
return TrustStateBlacklisted
|
||||
case "device-key-mismatch":
|
||||
return TrustStateDeviceKeyMismatch
|
||||
case "unverified":
|
||||
return TrustStateUnset
|
||||
case "cross-signed-untrusted":
|
||||
|
|
@ -67,6 +70,8 @@ func (ts TrustState) String() string {
|
|||
switch ts {
|
||||
case TrustStateBlacklisted:
|
||||
return "blacklisted"
|
||||
case TrustStateDeviceKeyMismatch:
|
||||
return "device-key-mismatch"
|
||||
case TrustStateUnset:
|
||||
return "unverified"
|
||||
case TrustStateCrossSignedUntrusted:
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ func (ms *MockServer) postKeysUpload(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (ms *MockServer) postDeviceSigningUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var req mautrix.UploadCrossSigningKeysReq
|
||||
var req mautrix.UploadCrossSigningKeysReq[any]
|
||||
mustDecode(r, &req)
|
||||
|
||||
userID := ms.getUserID(r).UserID
|
||||
|
|
|
|||
16
requests.go
16
requests.go
|
|
@ -66,14 +66,14 @@ const (
|
|||
)
|
||||
|
||||
// ReqRegister is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register
|
||||
type ReqRegister struct {
|
||||
type ReqRegister[UIAType any] struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
DeviceID id.DeviceID `json:"device_id,omitempty"`
|
||||
InitialDeviceDisplayName string `json:"initial_device_display_name,omitempty"`
|
||||
InhibitLogin bool `json:"inhibit_login,omitempty"`
|
||||
RefreshToken bool `json:"refresh_token,omitempty"`
|
||||
Auth interface{} `json:"auth,omitempty"`
|
||||
Auth UIAType `json:"auth,omitempty"`
|
||||
|
||||
// Type for registration, only used for appservice user registrations
|
||||
// https://spec.matrix.org/v1.2/application-service-api/#server-admin-style-permissions
|
||||
|
|
@ -320,11 +320,11 @@ func (csk *CrossSigningKeys) FirstKey() id.Ed25519 {
|
|||
return ""
|
||||
}
|
||||
|
||||
type UploadCrossSigningKeysReq struct {
|
||||
type UploadCrossSigningKeysReq[UIAType any] struct {
|
||||
Master CrossSigningKeys `json:"master_key"`
|
||||
SelfSigning CrossSigningKeys `json:"self_signing_key"`
|
||||
UserSigning CrossSigningKeys `json:"user_signing_key"`
|
||||
Auth interface{} `json:"auth,omitempty"`
|
||||
Auth UIAType `json:"auth,omitempty"`
|
||||
}
|
||||
|
||||
type KeyMap map[id.DeviceKeyID]string
|
||||
|
|
@ -392,14 +392,14 @@ type ReqDeviceInfo struct {
|
|||
}
|
||||
|
||||
// ReqDeleteDevice is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#delete_matrixclientv3devicesdeviceid
|
||||
type ReqDeleteDevice struct {
|
||||
Auth interface{} `json:"auth,omitempty"`
|
||||
type ReqDeleteDevice[UIAType any] struct {
|
||||
Auth UIAType `json:"auth,omitempty"`
|
||||
}
|
||||
|
||||
// ReqDeleteDevices is the JSON request for https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3delete_devices
|
||||
type ReqDeleteDevices struct {
|
||||
type ReqDeleteDevices[UIAType any] struct {
|
||||
Devices []id.DeviceID `json:"devices"`
|
||||
Auth interface{} `json:"auth,omitempty"`
|
||||
Auth UIAType `json:"auth,omitempty"`
|
||||
}
|
||||
|
||||
type ReqPutPushRule struct {
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ func (r *UserDirectoryEntry) MarshalJSON() ([]byte, error) {
|
|||
type RespMutualRooms struct {
|
||||
Joined []id.RoomID `json:"joined"`
|
||||
NextBatch string `json:"next_batch,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type RespRoomSummary struct {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ var (
|
|||
FeatureAsyncUploads = UnstableFeature{UnstableFlag: "fi.mau.msc2246.stable", SpecVersion: SpecV17}
|
||||
FeatureAppservicePing = UnstableFeature{UnstableFlag: "fi.mau.msc2659.stable", SpecVersion: SpecV17}
|
||||
FeatureAuthenticatedMedia = UnstableFeature{UnstableFlag: "org.matrix.msc3916.stable", SpecVersion: SpecV111}
|
||||
FeatureMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"}
|
||||
FeatureUnstableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms"}
|
||||
FeatureStableMutualRooms = UnstableFeature{UnstableFlag: "uk.half-shot.msc2666.query_mutual_rooms.stable" /*, SpecVersion: SpecV118*/}
|
||||
FeatureUserRedaction = UnstableFeature{UnstableFlag: "org.matrix.msc4194"}
|
||||
FeatureViewRedactedContent = UnstableFeature{UnstableFlag: "fi.mau.msc2815"}
|
||||
FeatureUnstableAccountModeration = UnstableFeature{UnstableFlag: "uk.timedout.msc4323"}
|
||||
|
|
@ -80,6 +81,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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue