// Copyright (c) 2023 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 (
"encoding/json"
"slices"
"strconv"
"strings"
"golang.org/x/net/html"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/id"
)
// MessageType is the sub-type of a m.room.message event.
// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes
type MessageType string
func (mt MessageType) IsText() bool {
switch mt {
case MsgText, MsgNotice, MsgEmote:
return true
default:
return false
}
}
func (mt MessageType) IsMedia() bool {
switch mt {
case MsgImage, MsgVideo, MsgAudio, MsgFile, MessageType(EventSticker.Type):
return true
default:
return false
}
}
// Msgtypes
const (
MsgText MessageType = "m.text"
MsgEmote MessageType = "m.emote"
MsgNotice MessageType = "m.notice"
MsgImage MessageType = "m.image"
MsgLocation MessageType = "m.location"
MsgVideo MessageType = "m.video"
MsgAudio MessageType = "m.audio"
MsgFile MessageType = "m.file"
MsgVerificationRequest MessageType = "m.key.verification.request"
MsgBeeperGallery MessageType = "com.beeper.gallery"
)
// Format specifies the format of the formatted_body in m.room.message events.
// https://spec.matrix.org/v1.2/client-server-api/#mroommessage-msgtypes
type Format string
// Message formats
const (
FormatHTML Format = "org.matrix.custom.html"
)
// RedactionEventContent represents the content of a m.room.redaction message event.
//
// https://spec.matrix.org/v1.8/client-server-api/#mroomredaction
type RedactionEventContent struct {
Reason string `json:"reason,omitempty"`
// The event ID is here as of room v11. In old servers it may only be at the top level.
Redacts id.EventID `json:"redacts,omitempty"`
}
// ReactionEventContent represents the content of a m.reaction message event.
// This is not yet in a spec release, see https://github.com/matrix-org/matrix-doc/pull/1849
type ReactionEventContent struct {
RelatesTo RelatesTo `json:"m.relates_to"`
}
func (content *ReactionEventContent) GetRelatesTo() *RelatesTo {
return &content.RelatesTo
}
func (content *ReactionEventContent) OptionalGetRelatesTo() *RelatesTo {
return &content.RelatesTo
}
func (content *ReactionEventContent) SetRelatesTo(rel *RelatesTo) {
content.RelatesTo = *rel
}
// MessageEventContent represents the content of a m.room.message event.
//
// It is also used to represent m.sticker events, as they are equivalent to m.room.message
// with the exception of the msgtype field.
//
// https://spec.matrix.org/v1.2/client-server-api/#mroommessage
type MessageEventContent struct {
// Base m.room.message fields
MsgType MessageType `json:"msgtype,omitempty"`
Body string `json:"body"`
// Extra fields for text types
Format Format `json:"format,omitempty"`
FormattedBody string `json:"formatted_body,omitempty"`
// Extra field for m.location
GeoURI string `json:"geo_uri,omitempty"`
// Extra fields for media types
URL id.ContentURIString `json:"url,omitempty"`
Info *FileInfo `json:"info,omitempty"`
File *EncryptedFileInfo `json:"file,omitempty"`
FileName string `json:"filename,omitempty"`
Mentions *Mentions `json:"m.mentions,omitempty"`
// Edits and relations
NewContent *MessageEventContent `json:"m.new_content,omitempty"`
RelatesTo *RelatesTo `json:"m.relates_to,omitempty"`
// In-room verification
To id.UserID `json:"to,omitempty"`
FromDevice id.DeviceID `json:"from_device,omitempty"`
Methods []VerificationMethod `json:"methods,omitempty"`
replyFallbackRemoved bool
MessageSendRetry *BeeperRetryMetadata `json:"com.beeper.message_send_retry,omitempty"`
BeeperGalleryImages []*MessageEventContent `json:"com.beeper.gallery.images,omitempty"`
BeeperGalleryCaption string `json:"com.beeper.gallery.caption,omitempty"`
BeeperGalleryCaptionHTML string `json:"com.beeper.gallery.caption_html,omitempty"`
BeeperPerMessageProfile *BeeperPerMessageProfile `json:"com.beeper.per_message_profile,omitempty"`
BeeperLinkPreviews []*BeeperLinkPreview `json:"com.beeper.linkpreviews,omitempty"`
MSC1767Audio *MSC1767Audio `json:"org.matrix.msc1767.audio,omitempty"`
MSC3245Voice *MSC3245Voice `json:"org.matrix.msc3245.voice,omitempty"`
}
func (content *MessageEventContent) GetFileName() string {
if content.FileName != "" {
return content.FileName
}
return content.Body
}
func (content *MessageEventContent) GetCaption() string {
if content.FileName != "" && content.Body != "" && content.Body != content.FileName {
return content.Body
}
return ""
}
func (content *MessageEventContent) GetFormattedCaption() string {
if content.Format == FormatHTML && content.FormattedBody != "" {
return content.FormattedBody
}
return ""
}
func (content *MessageEventContent) GetRelatesTo() *RelatesTo {
if content.RelatesTo == nil {
content.RelatesTo = &RelatesTo{}
}
return content.RelatesTo
}
func (content *MessageEventContent) OptionalGetRelatesTo() *RelatesTo {
return content.RelatesTo
}
func (content *MessageEventContent) SetRelatesTo(rel *RelatesTo) {
content.RelatesTo = rel
}
func (content *MessageEventContent) SetEdit(original id.EventID) {
newContent := *content
content.NewContent = &newContent
content.RelatesTo = (&RelatesTo{}).SetReplace(original)
if content.MsgType == MsgText || content.MsgType == MsgNotice {
content.Body = "* " + content.Body
if content.Format == FormatHTML && len(content.FormattedBody) > 0 {
content.FormattedBody = "* " + content.FormattedBody
}
// If the message is long, remove most of the useless edit fallback to avoid event size issues.
if len(content.Body) > 10000 {
content.FormattedBody = ""
content.Format = ""
content.Body = content.Body[:50] + "[edit fallback cut…]"
}
}
}
// TextToHTML converts the given text to a HTML-safe representation by escaping HTML characters
// and replacing newlines with
tags.
func TextToHTML(text string) string {
return strings.ReplaceAll(html.EscapeString(text), "\n", "
")
}
// ReverseTextToHTML reverses the modifications made by TextToHTML, i.e. replaces
tags with newlines
// and unescapes HTML escape codes. For actually parsing HTML, use the format package instead.
func ReverseTextToHTML(input string) string {
return html.UnescapeString(strings.ReplaceAll(input, "
", "\n"))
}
func (content *MessageEventContent) EnsureHasHTML() {
if len(content.FormattedBody) == 0 || content.Format != FormatHTML {
content.FormattedBody = TextToHTML(content.Body)
content.Format = FormatHTML
}
}
func (content *MessageEventContent) GetFile() *EncryptedFileInfo {
if content.File == nil {
content.File = &EncryptedFileInfo{}
}
return content.File
}
func (content *MessageEventContent) GetInfo() *FileInfo {
if content.Info == nil {
content.Info = &FileInfo{}
}
return content.Info
}
type Mentions struct {
UserIDs []id.UserID `json:"user_ids,omitempty"`
Room bool `json:"room,omitempty"`
}
func (m *Mentions) Add(userID id.UserID) {
if userID != "" && !slices.Contains(m.UserIDs, userID) {
m.UserIDs = append(m.UserIDs, userID)
}
}
func (m *Mentions) Has(userID id.UserID) bool {
return m != nil && slices.Contains(m.UserIDs, userID)
}
type EncryptedFileInfo struct {
attachment.EncryptedFile
URL id.ContentURIString `json:"url"`
}
type FileInfo struct {
MimeType string `json:"mimetype,omitempty"`
ThumbnailInfo *FileInfo `json:"thumbnail_info,omitempty"`
ThumbnailURL id.ContentURIString `json:"thumbnail_url,omitempty"`
ThumbnailFile *EncryptedFileInfo `json:"thumbnail_file,omitempty"`
Blurhash string `json:"blurhash,omitempty"`
AnoaBlurhash string `json:"xyz.amorgan.blurhash,omitempty"`
Width int `json:"-"`
Height int `json:"-"`
Duration int `json:"-"`
Size int `json:"-"`
}
type serializableFileInfo struct {
MimeType string `json:"mimetype,omitempty"`
ThumbnailInfo *serializableFileInfo `json:"thumbnail_info,omitempty"`
ThumbnailURL id.ContentURIString `json:"thumbnail_url,omitempty"`
ThumbnailFile *EncryptedFileInfo `json:"thumbnail_file,omitempty"`
Blurhash string `json:"blurhash,omitempty"`
AnoaBlurhash string `json:"xyz.amorgan.blurhash,omitempty"`
Width json.Number `json:"w,omitempty"`
Height json.Number `json:"h,omitempty"`
Duration json.Number `json:"duration,omitempty"`
Size json.Number `json:"size,omitempty"`
}
func (sfi *serializableFileInfo) CopyFrom(fileInfo *FileInfo) *serializableFileInfo {
if fileInfo == nil {
return nil
}
*sfi = serializableFileInfo{
MimeType: fileInfo.MimeType,
ThumbnailURL: fileInfo.ThumbnailURL,
ThumbnailInfo: (&serializableFileInfo{}).CopyFrom(fileInfo.ThumbnailInfo),
ThumbnailFile: fileInfo.ThumbnailFile,
Blurhash: fileInfo.Blurhash,
AnoaBlurhash: fileInfo.AnoaBlurhash,
}
if fileInfo.Width > 0 {
sfi.Width = json.Number(strconv.Itoa(fileInfo.Width))
}
if fileInfo.Height > 0 {
sfi.Height = json.Number(strconv.Itoa(fileInfo.Height))
}
if fileInfo.Size > 0 {
sfi.Size = json.Number(strconv.Itoa(fileInfo.Size))
}
if fileInfo.Duration > 0 {
sfi.Duration = json.Number(strconv.Itoa(int(fileInfo.Duration)))
}
return sfi
}
func (sfi *serializableFileInfo) CopyTo(fileInfo *FileInfo) {
*fileInfo = FileInfo{
Width: numberToInt(sfi.Width),
Height: numberToInt(sfi.Height),
Size: numberToInt(sfi.Size),
Duration: numberToInt(sfi.Duration),
MimeType: sfi.MimeType,
ThumbnailURL: sfi.ThumbnailURL,
ThumbnailFile: sfi.ThumbnailFile,
Blurhash: sfi.Blurhash,
AnoaBlurhash: sfi.AnoaBlurhash,
}
if sfi.ThumbnailInfo != nil {
fileInfo.ThumbnailInfo = &FileInfo{}
sfi.ThumbnailInfo.CopyTo(fileInfo.ThumbnailInfo)
}
}
func (fileInfo *FileInfo) UnmarshalJSON(data []byte) error {
sfi := &serializableFileInfo{}
if err := json.Unmarshal(data, sfi); err != nil {
return err
}
sfi.CopyTo(fileInfo)
return nil
}
func (fileInfo *FileInfo) MarshalJSON() ([]byte, error) {
return json.Marshal((&serializableFileInfo{}).CopyFrom(fileInfo))
}
func numberToInt(val json.Number) int {
f64, _ := val.Float64()
if f64 > 0 {
return int(f64)
}
return 0
}
func (fileInfo *FileInfo) GetThumbnailInfo() *FileInfo {
if fileInfo.ThumbnailInfo == nil {
fileInfo.ThumbnailInfo = &FileInfo{}
}
return fileInfo.ThumbnailInfo
}