// Copyright (c) 2024 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 import ( "crypto/sha256" "encoding/base64" "encoding/binary" "fmt" "io" "mime" "slices" "strings" "go.mau.fi/util/exerrors" "go.mau.fi/util/jsontime" "golang.org/x/exp/constraints" "golang.org/x/exp/maps" ) type RoomFeatures struct { ID string `json:"id,omitempty"` // N.B. New fields need to be added to the Hash function to be included in the deduplication hash. Formatting FormattingFeatureMap `json:"formatting,omitempty"` File FileFeatureMap `json:"file,omitempty"` MaxTextLength int `json:"max_text_length,omitempty"` LocationMessage CapabilitySupportLevel `json:"location_message,omitempty"` Poll CapabilitySupportLevel `json:"poll,omitempty"` Thread CapabilitySupportLevel `json:"thread,omitempty"` Reply CapabilitySupportLevel `json:"reply,omitempty"` Edit CapabilitySupportLevel `json:"edit,omitempty"` EditMaxCount int `json:"edit_max_count,omitempty"` EditMaxAge *jsontime.Seconds `json:"edit_max_age,omitempty"` Delete CapabilitySupportLevel `json:"delete,omitempty"` DeleteForMe bool `json:"delete_for_me,omitempty"` DeleteMaxAge *jsontime.Seconds `json:"delete_max_age,omitempty"` DisappearingTimer *DisappearingTimerCapability `json:"disappearing_timer,omitempty"` Reaction CapabilitySupportLevel `json:"reaction,omitempty"` ReactionCount int `json:"reaction_count,omitempty"` AllowedReactions []string `json:"allowed_reactions,omitempty"` CustomEmojiReactions bool `json:"custom_emoji_reactions,omitempty"` ReadReceipts bool `json:"read_receipts,omitempty"` TypingNotifications bool `json:"typing_notifications,omitempty"` Archive bool `json:"archive,omitempty"` MarkAsUnread bool `json:"mark_as_unread,omitempty"` DeleteChat bool `json:"delete_chat,omitempty"` } func (rf *RoomFeatures) GetID() string { if rf.ID != "" { return rf.ID } return base64.RawURLEncoding.EncodeToString(rf.Hash()) } type FormattingFeatureMap map[FormattingFeature]CapabilitySupportLevel type FileFeatureMap map[CapabilityMsgType]*FileFeatures type DisappearingTimerCapability struct { Types []DisappearingType `json:"types"` Timers []jsontime.Milliseconds `json:"timers,omitempty"` OmitEmptyTimer bool `json:"omit_empty_timer,omitempty"` } func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { if dtc == nil || content == nil || content.Type == DisappearingTypeNone { return true } return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer)) } type CapabilityMsgType = MessageType // Message types which are used for event capability signaling, but aren't real values for the msgtype field. const ( CapMsgVoice CapabilityMsgType = "org.matrix.msc3245.voice" CapMsgGIF CapabilityMsgType = "fi.mau.gif" CapMsgSticker CapabilityMsgType = "m.sticker" ) type CapabilitySupportLevel int func (csl CapabilitySupportLevel) Partial() bool { return csl >= CapLevelPartialSupport } func (csl CapabilitySupportLevel) Full() bool { return csl >= CapLevelFullySupported } func (csl CapabilitySupportLevel) Reject() bool { return csl <= CapLevelRejected } const ( CapLevelRejected CapabilitySupportLevel = -2 // The feature is unsupported and messages using it will be rejected. CapLevelDropped CapabilitySupportLevel = -1 // The feature is unsupported and has no fallback. The message will go through, but data may be lost. CapLevelUnsupported CapabilitySupportLevel = 0 // The feature is unsupported, but may have a fallback. CapLevelPartialSupport CapabilitySupportLevel = 1 // The feature is partially supported (e.g. it may be converted to a different format). CapLevelFullySupported CapabilitySupportLevel = 2 // The feature is fully supported and can be safely used. ) type FormattingFeature string const ( FmtBold FormattingFeature = "bold" // strong, b FmtItalic FormattingFeature = "italic" // em, i FmtUnderline FormattingFeature = "underline" // u FmtStrikethrough FormattingFeature = "strikethrough" // del, s FmtInlineCode FormattingFeature = "inline_code" // code FmtCodeBlock FormattingFeature = "code_block" // pre + code FmtSyntaxHighlighting FormattingFeature = "code_block.syntax_highlighting" //
FmtBlockquote FormattingFeature = "blockquote" // blockquote
FmtInlineLink FormattingFeature = "inline_link" // a
FmtUserLink FormattingFeature = "user_link" //
FmtRoomLink FormattingFeature = "room_link" //
FmtEventLink FormattingFeature = "event_link" //
FmtAtRoomMention FormattingFeature = "at_room_mention" // @room (no html tag)
FmtUnorderedList FormattingFeature = "unordered_list" // ul + li
FmtOrderedList FormattingFeature = "ordered_list" // ol + li
FmtListStart FormattingFeature = "ordered_list.start" //
FmtListJumpValue FormattingFeature = "ordered_list.jump_value" // -
FmtCustomEmoji FormattingFeature = "custom_emoji" //
FmtSpoiler FormattingFeature = "spoiler" //
FmtSpoilerReason FormattingFeature = "spoiler.reason" //
FmtTextForegroundColor FormattingFeature = "color.foreground" //
FmtTextBackgroundColor FormattingFeature = "color.background" //
FmtHorizontalLine FormattingFeature = "horizontal_line" // hr
FmtHeaders FormattingFeature = "headers" // h1, h2, h3, h4, h5, h6
FmtSuperscript FormattingFeature = "superscript" // sup
FmtSubscript FormattingFeature = "subscript" // sub
FmtMath FormattingFeature = "math" //
FmtDetailsSummary FormattingFeature = "details_summary" // ...
...
FmtTable FormattingFeature = "table" // table, thead, tbody, tr, th, td
)
type FileFeatures struct {
// N.B. New fields need to be added to the Hash function to be included in the deduplication hash.
MimeTypes map[string]CapabilitySupportLevel `json:"mime_types"`
Caption CapabilitySupportLevel `json:"caption,omitempty"`
MaxCaptionLength int `json:"max_caption_length,omitempty"`
MaxSize int64 `json:"max_size,omitempty"`
MaxWidth int `json:"max_width,omitempty"`
MaxHeight int `json:"max_height,omitempty"`
MaxDuration *jsontime.Seconds `json:"max_duration,omitempty"`
ViewOnce bool `json:"view_once,omitempty"`
}
func (ff *FileFeatures) GetMimeSupport(inputType string) CapabilitySupportLevel {
match, ok := ff.MimeTypes[inputType]
if ok {
return match
}
if strings.IndexByte(inputType, ';') != -1 {
plainMime, _, _ := mime.ParseMediaType(inputType)
if plainMime != "" {
if match, ok = ff.MimeTypes[plainMime]; ok {
return match
}
}
}
if slash := strings.IndexByte(inputType, '/'); slash > 0 {
generalType := fmt.Sprintf("%s/*", inputType[:slash])
if match, ok = ff.MimeTypes[generalType]; ok {
return match
}
}
match, ok = ff.MimeTypes["*/*"]
if ok {
return match
}
return CapLevelRejected
}
type hashable interface {
Hash() []byte
}
func hashMap[Key ~string, Value hashable](w io.Writer, name string, data map[Key]Value) {
keys := maps.Keys(data)
slices.Sort(keys)
exerrors.Must(w.Write([]byte(name)))
for _, key := range keys {
exerrors.Must(w.Write([]byte(key)))
exerrors.Must(w.Write(data[key].Hash()))
exerrors.Must(w.Write([]byte{0}))
}
}
func hashValue(w io.Writer, name string, data hashable) {
exerrors.Must(w.Write([]byte(name)))
exerrors.Must(w.Write(data.Hash()))
}
func hashInt[T constraints.Integer](w io.Writer, name string, data T) {
exerrors.Must(w.Write(binary.BigEndian.AppendUint64([]byte(name), uint64(data))))
}
func hashBool[T ~bool](w io.Writer, name string, data T) {
exerrors.Must(w.Write([]byte(name)))
if data {
exerrors.Must(w.Write([]byte{1}))
} else {
exerrors.Must(w.Write([]byte{0}))
}
}
func (csl CapabilitySupportLevel) Hash() []byte {
return []byte{byte(csl + 128)}
}
func (rf *RoomFeatures) Hash() []byte {
hasher := sha256.New()
hashMap(hasher, "formatting", rf.Formatting)
hashMap(hasher, "file", rf.File)
hashInt(hasher, "max_text_length", rf.MaxTextLength)
hashValue(hasher, "location_message", rf.LocationMessage)
hashValue(hasher, "poll", rf.Poll)
hashValue(hasher, "thread", rf.Thread)
hashValue(hasher, "reply", rf.Reply)
hashValue(hasher, "edit", rf.Edit)
hashInt(hasher, "edit_max_count", rf.EditMaxCount)
hashInt(hasher, "edit_max_age", rf.EditMaxAge.Get())
hashValue(hasher, "delete", rf.Delete)
hashBool(hasher, "delete_for_me", rf.DeleteForMe)
hashInt(hasher, "delete_max_age", rf.DeleteMaxAge.Get())
hashValue(hasher, "disappearing_timer", rf.DisappearingTimer)
hashValue(hasher, "reaction", rf.Reaction)
hashInt(hasher, "reaction_count", rf.ReactionCount)
hasher.Write([]byte("allowed_reactions"))
for _, reaction := range rf.AllowedReactions {
hasher.Write([]byte(reaction))
}
hashBool(hasher, "custom_emoji_reactions", rf.CustomEmojiReactions)
hashBool(hasher, "read_receipts", rf.ReadReceipts)
hashBool(hasher, "typing_notifications", rf.TypingNotifications)
hashBool(hasher, "archive", rf.Archive)
hashBool(hasher, "mark_as_unread", rf.MarkAsUnread)
hashBool(hasher, "delete_chat", rf.DeleteChat)
return hasher.Sum(nil)
}
func (dtc *DisappearingTimerCapability) Hash() []byte {
if dtc == nil {
return nil
}
hasher := sha256.New()
hasher.Write([]byte("types"))
for _, t := range dtc.Types {
hasher.Write([]byte(t))
}
hasher.Write([]byte("timers"))
for _, timer := range dtc.Timers {
hashInt(hasher, "", timer.Milliseconds())
}
return hasher.Sum(nil)
}
func (ff *FileFeatures) Hash() []byte {
hasher := sha256.New()
hashMap(hasher, "mime_types", ff.MimeTypes)
hashValue(hasher, "caption", ff.Caption)
hashInt(hasher, "max_caption_length", ff.MaxCaptionLength)
hashInt(hasher, "max_size", ff.MaxSize)
hashInt(hasher, "max_width", ff.MaxWidth)
hashInt(hasher, "max_height", ff.MaxHeight)
hashInt(hasher, "max_duration", ff.MaxDuration.Get())
hashBool(hasher, "view_once", ff.ViewOnce)
return hasher.Sum(nil)
}