diff options
Diffstat (limited to 'teleirc/matterbridge/bridge/matrix')
| -rw-r--r-- | teleirc/matterbridge/bridge/matrix/helpers.go | 215 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/matrix/matrix.go | 718 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/matrix/matrix_test.go | 28 |
3 files changed, 961 insertions, 0 deletions
diff --git a/teleirc/matterbridge/bridge/matrix/helpers.go b/teleirc/matterbridge/bridge/matrix/helpers.go new file mode 100644 index 0000000..5a91f74 --- /dev/null +++ b/teleirc/matterbridge/bridge/matrix/helpers.go @@ -0,0 +1,215 @@ +package bmatrix + +import ( + "encoding/json" + "errors" + "fmt" + "html" + "strings" + "time" + + matrix "github.com/matterbridge/gomatrix" +) + +func newMatrixUsername(username string) *matrixUsername { + mUsername := new(matrixUsername) + + // check if we have a </tag>. if we have, we don't escape HTML. #696 + if htmlTag.MatchString(username) { + mUsername.formatted = username + // remove the HTML formatting for beautiful push messages #1188 + mUsername.plain = htmlReplacementTag.ReplaceAllString(username, "") + } else { + mUsername.formatted = html.EscapeString(username) + mUsername.plain = username + } + + return mUsername +} + +// getRoomID retrieves a matching room ID from the channel name. +func (b *Bmatrix) getRoomID(channel string) string { + b.RLock() + defer b.RUnlock() + for ID, name := range b.RoomMap { + if name == channel { + return ID + } + } + + return "" +} + +// interface2Struct marshals and immediately unmarshals an interface. +// Useful for converting map[string]interface{} to a struct. +func interface2Struct(in interface{}, out interface{}) error { + jsonObj, err := json.Marshal(in) + if err != nil { + return err //nolint:wrapcheck + } + + return json.Unmarshal(jsonObj, out) +} + +// getDisplayName retrieves the displayName for mxid, querying the homeserver if the mxid is not in the cache. +func (b *Bmatrix) getDisplayName(mxid string) string { + if b.GetBool("UseUserName") { + return mxid[1:] + } + + b.RLock() + if val, present := b.NicknameMap[mxid]; present { + b.RUnlock() + + return val.displayName + } + b.RUnlock() + + displayName, err := b.mc.GetDisplayName(mxid) + var httpError *matrix.HTTPError + if errors.As(err, &httpError) { + b.Log.Warnf("Couldn't retrieve the display name for %s", mxid) + } + + if err != nil { + return b.cacheDisplayName(mxid, mxid[1:]) + } + + return b.cacheDisplayName(mxid, displayName.DisplayName) +} + +// cacheDisplayName stores the mapping between a mxid and a display name, to be reused later without performing a query to the homserver. +// Note that old entries are cleaned when this function is called. +func (b *Bmatrix) cacheDisplayName(mxid string, displayName string) string { + now := time.Now() + + // scan to delete old entries, to stop memory usage from becoming too high with old entries. + // In addition, we also detect if another user have the same username, and if so, we append their mxids to their usernames to differentiate them. + toDelete := []string{} + conflict := false + + b.Lock() + for mxid, v := range b.NicknameMap { + // to prevent username reuse across matrix servers - or even on the same server, append + // the mxid to the username when there is a conflict + if v.displayName == displayName { + conflict = true + // TODO: it would be nice to be able to rename previous messages from this user. + // The current behavior is that only users with clashing usernames and *that have spoken since the bridge last started* will get their mxids shown, and I don't know if that's the expected behavior. + v.displayName = fmt.Sprintf("%s (%s)", displayName, mxid) + b.NicknameMap[mxid] = v + } + + if now.Sub(v.lastUpdated) > 10*time.Minute { + toDelete = append(toDelete, mxid) + } + } + + if conflict { + displayName = fmt.Sprintf("%s (%s)", displayName, mxid) + } + + for _, v := range toDelete { + delete(b.NicknameMap, v) + } + + b.NicknameMap[mxid] = NicknameCacheEntry{ + displayName: displayName, + lastUpdated: now, + } + b.Unlock() + + return displayName +} + +// handleError converts errors into httpError. +//nolint:exhaustivestruct +func handleError(err error) *httpError { + var mErr matrix.HTTPError + if !errors.As(err, &mErr) { + return &httpError{ + Err: "not a HTTPError", + } + } + + var httpErr httpError + + if err := json.Unmarshal(mErr.Contents, &httpErr); err != nil { + return &httpError{ + Err: "unmarshal failed", + } + } + + return &httpErr +} + +func (b *Bmatrix) containsAttachment(content map[string]interface{}) bool { + // Skip empty messages + if content["msgtype"] == nil { + return false + } + + // Only allow image,video or file msgtypes + if !(content["msgtype"].(string) == "m.image" || + content["msgtype"].(string) == "m.video" || + content["msgtype"].(string) == "m.file") { + return false + } + + return true +} + +// getAvatarURL returns the avatar URL of the specified sender. +func (b *Bmatrix) getAvatarURL(sender string) string { + urlPath := b.mc.BuildURL("profile", sender, "avatar_url") + + s := struct { + AvatarURL string `json:"avatar_url"` + }{} + + err := b.mc.MakeRequest("GET", urlPath, nil, &s) + if err != nil { + b.Log.Errorf("getAvatarURL failed: %s", err) + + return "" + } + + url := strings.ReplaceAll(s.AvatarURL, "mxc://", b.GetString("Server")+"/_matrix/media/r0/thumbnail/") + if url != "" { + url += "?width=37&height=37&method=crop" + } + + return url +} + +// handleRatelimit handles the ratelimit errors and return if we're ratelimited and the amount of time to sleep +func (b *Bmatrix) handleRatelimit(err error) (time.Duration, bool) { + httpErr := handleError(err) + if httpErr.Errcode != "M_LIMIT_EXCEEDED" { + return 0, false + } + + b.Log.Debugf("ratelimited: %s", httpErr.Err) + b.Log.Infof("getting ratelimited by matrix, sleeping approx %d seconds before retrying", httpErr.RetryAfterMs/1000) + + return time.Duration(httpErr.RetryAfterMs) * time.Millisecond, true +} + +// retry function will check if we're ratelimited and retries again when backoff time expired +// returns original error if not 429 ratelimit +func (b *Bmatrix) retry(f func() error) error { + b.rateMutex.Lock() + defer b.rateMutex.Unlock() + + for { + if err := f(); err != nil { + if backoff, ok := b.handleRatelimit(err); ok { + time.Sleep(backoff) + } else { + return err + } + } else { + return nil + } + } +} diff --git a/teleirc/matterbridge/bridge/matrix/matrix.go b/teleirc/matterbridge/bridge/matrix/matrix.go new file mode 100644 index 0000000..49fc33b --- /dev/null +++ b/teleirc/matterbridge/bridge/matrix/matrix.go @@ -0,0 +1,718 @@ +package bmatrix + +import ( + "bytes" + "fmt" + "mime" + "regexp" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + matrix "github.com/matterbridge/gomatrix" +) + +var ( + htmlTag = regexp.MustCompile("</.*?>") + htmlReplacementTag = regexp.MustCompile("<[^>]*>") +) + +type NicknameCacheEntry struct { + displayName string + lastUpdated time.Time +} + +type Bmatrix struct { + mc *matrix.Client + UserID string + NicknameMap map[string]NicknameCacheEntry + RoomMap map[string]string + rateMutex sync.RWMutex + sync.RWMutex + *bridge.Config +} + +type httpError struct { + Errcode string `json:"errcode"` + Err string `json:"error"` + RetryAfterMs int `json:"retry_after_ms"` +} + +type matrixUsername struct { + plain string + formatted string +} + +// SubTextMessage represents the new content of the message in edit messages. +type SubTextMessage struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` + FormattedBody string `json:"formatted_body,omitempty"` + Format string `json:"format,omitempty"` +} + +// MessageRelation explains how the current message relates to a previous message. +// Notably used for message edits. +type MessageRelation struct { + EventID string `json:"event_id"` + Type string `json:"rel_type"` +} + +type EditedMessage struct { + NewContent SubTextMessage `json:"m.new_content"` + RelatedTo MessageRelation `json:"m.relates_to"` + matrix.TextMessage +} + +type InReplyToRelationContent struct { + EventID string `json:"event_id"` +} + +type InReplyToRelation struct { + InReplyTo InReplyToRelationContent `json:"m.in_reply_to"` +} + +type ReplyMessage struct { + RelatedTo InReplyToRelation `json:"m.relates_to"` + matrix.TextMessage +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bmatrix{Config: cfg} + b.RoomMap = make(map[string]string) + b.NicknameMap = make(map[string]NicknameCacheEntry) + return b +} + +func (b *Bmatrix) Connect() error { + var err error + b.Log.Infof("Connecting %s", b.GetString("Server")) + if b.GetString("MxID") != "" && b.GetString("Token") != "" { + b.mc, err = matrix.NewClient( + b.GetString("Server"), b.GetString("MxID"), b.GetString("Token"), + ) + if err != nil { + return err + } + b.UserID = b.GetString("MxID") + b.Log.Info("Using existing Matrix credentials") + } else { + b.mc, err = matrix.NewClient(b.GetString("Server"), "", "") + if err != nil { + return err + } + resp, err := b.mc.Login(&matrix.ReqLogin{ + Type: "m.login.password", + User: b.GetString("Login"), + Password: b.GetString("Password"), + Identifier: matrix.NewUserIdentifier(b.GetString("Login")), + }) + if err != nil { + return err + } + b.mc.SetCredentials(resp.UserID, resp.AccessToken) + b.UserID = resp.UserID + b.Log.Info("Connection succeeded") + } + go b.handlematrix() + return nil +} + +func (b *Bmatrix) Disconnect() error { + return nil +} + +func (b *Bmatrix) JoinChannel(channel config.ChannelInfo) error { + return b.retry(func() error { + resp, err := b.mc.JoinRoom(channel.Name, "", nil) + if err != nil { + return err + } + + b.Lock() + b.RoomMap[resp.RoomID] = channel.Name + b.Unlock() + + return nil + }) +} + +func (b *Bmatrix) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + channel := b.getRoomID(msg.Channel) + b.Log.Debugf("Channel %s maps to channel id %s", msg.Channel, channel) + + username := newMatrixUsername(msg.Username) + + body := username.plain + msg.Text + formattedBody := username.formatted + helper.ParseMarkdown(msg.Text) + + if b.GetBool("SpoofUsername") { + // https://spec.matrix.org/v1.3/client-server-api/#mroommember + type stateMember struct { + AvatarURL string `json:"avatar_url,omitempty"` + DisplayName string `json:"displayname"` + Membership string `json:"membership"` + } + + // TODO: reset username afterwards with DisplayName: null ? + m := stateMember{ + AvatarURL: "", + DisplayName: username.plain, + Membership: "join", + } + + _, err := b.mc.SendStateEvent(channel, "m.room.member", b.UserID, m) + if err == nil { + body = msg.Text + formattedBody = helper.ParseMarkdown(msg.Text) + } + } + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + m := matrix.TextMessage{ + MsgType: "m.emote", + Body: body, + FormattedBody: formattedBody, + Format: "org.matrix.custom.html", + } + + if b.GetBool("HTMLDisable") { + m.Format = "" + m.FormattedBody = "" + } + + msgID := "" + + err := b.retry(func() error { + resp, err := b.mc.SendMessageEvent(channel, "m.room.message", m) + if err != nil { + return err + } + + msgID = resp.EventID + + return err + }) + + return msgID, err + } + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + + msgID := "" + + err := b.retry(func() error { + resp, err := b.mc.RedactEvent(channel, msg.ID, &matrix.ReqRedact{}) + if err != nil { + return err + } + + msgID = resp.EventID + + return err + }) + + return msgID, err + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + rmsg := rmsg + + err := b.retry(func() error { + _, err := b.mc.SendText(channel, rmsg.Username+rmsg.Text) + + return err + }) + if err != nil { + b.Log.Errorf("sendText failed: %s", err) + } + } + // check if we have files to upload (from slack, telegram or mattermost) + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFiles(&msg, channel) + } + } + + // Edit message if we have an ID + if msg.ID != "" { + rmsg := EditedMessage{ + TextMessage: matrix.TextMessage{ + Body: body, + MsgType: "m.text", + Format: "org.matrix.custom.html", + FormattedBody: formattedBody, + }, + } + + rmsg.NewContent = SubTextMessage{ + Body: rmsg.TextMessage.Body, + FormattedBody: rmsg.TextMessage.FormattedBody, + Format: rmsg.TextMessage.Format, + MsgType: "m.text", + } + + if b.GetBool("HTMLDisable") { + rmsg.TextMessage.Format = "" + rmsg.TextMessage.FormattedBody = "" + rmsg.NewContent.Format = "" + rmsg.NewContent.FormattedBody = "" + } + + rmsg.RelatedTo = MessageRelation{ + EventID: msg.ID, + Type: "m.replace", + } + + err := b.retry(func() error { + _, err := b.mc.SendMessageEvent(channel, "m.room.message", rmsg) + + return err + }) + if err != nil { + return "", err + } + + return msg.ID, nil + } + + // Use notices to send join/leave events + if msg.Event == config.EventJoinLeave { + m := matrix.TextMessage{ + MsgType: "m.notice", + Body: body, + FormattedBody: formattedBody, + Format: "org.matrix.custom.html", + } + + if b.GetBool("HTMLDisable") { + m.Format = "" + m.FormattedBody = "" + } + + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err + } + + if msg.ParentValid() { + m := ReplyMessage{ + TextMessage: matrix.TextMessage{ + MsgType: "m.text", + Body: body, + FormattedBody: formattedBody, + Format: "org.matrix.custom.html", + }, + } + + if b.GetBool("HTMLDisable") { + m.TextMessage.Format = "" + m.TextMessage.FormattedBody = "" + } + + m.RelatedTo = InReplyToRelation{ + InReplyTo: InReplyToRelationContent{ + EventID: msg.ParentID, + }, + } + + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err + } + + if b.GetBool("HTMLDisable") { + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendText(channel, body) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err + } + + // Post normal message with HTML support (eg riot.im) + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendFormattedText(channel, body, formattedBody) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err +} + +func (b *Bmatrix) handlematrix() { + syncer := b.mc.Syncer.(*matrix.DefaultSyncer) + syncer.OnEventType("m.room.redaction", b.handleEvent) + syncer.OnEventType("m.room.message", b.handleEvent) + syncer.OnEventType("m.room.member", b.handleMemberChange) + go func() { + for { + if b == nil { + return + } + if err := b.mc.Sync(); err != nil { + b.Log.Println("Sync() returned ", err) + } + } + }() +} + +func (b *Bmatrix) handleEdit(ev *matrix.Event, rmsg config.Message) bool { + relationInterface, present := ev.Content["m.relates_to"] + newContentInterface, present2 := ev.Content["m.new_content"] + if !(present && present2) { + return false + } + + var relation MessageRelation + if err := interface2Struct(relationInterface, &relation); err != nil { + b.Log.Warnf("Couldn't parse 'm.relates_to' object with value %#v", relationInterface) + return false + } + + var newContent SubTextMessage + if err := interface2Struct(newContentInterface, &newContent); err != nil { + b.Log.Warnf("Couldn't parse 'm.new_content' object with value %#v", newContentInterface) + return false + } + + if relation.Type != "m.replace" { + return false + } + + rmsg.ID = relation.EventID + rmsg.Text = newContent.Body + b.Remote <- rmsg + + return true +} + +func (b *Bmatrix) handleReply(ev *matrix.Event, rmsg config.Message) bool { + relationInterface, present := ev.Content["m.relates_to"] + if !present { + return false + } + + var relation InReplyToRelation + if err := interface2Struct(relationInterface, &relation); err != nil { + // probably fine + return false + } + + body := rmsg.Text + + if !b.GetBool("keepquotedreply") { + for strings.HasPrefix(body, "> ") { + lineIdx := strings.IndexRune(body, '\n') + if lineIdx == -1 { + body = "" + } else { + body = body[(lineIdx + 1):] + } + } + } + + rmsg.Text = body + rmsg.ParentID = relation.InReplyTo.EventID + b.Remote <- rmsg + + return true +} + +func (b *Bmatrix) handleMemberChange(ev *matrix.Event) { + // Update the displayname on join messages, according to https://matrix.org/docs/spec/client_server/r0.6.1#events-on-change-of-profile-information + if ev.Content["membership"] == "join" { + if dn, ok := ev.Content["displayname"].(string); ok { + b.cacheDisplayName(ev.Sender, dn) + } + } +} + +func (b *Bmatrix) handleEvent(ev *matrix.Event) { + b.Log.Debugf("== Receiving event: %#v", ev) + if ev.Sender != b.UserID { + b.RLock() + channel, ok := b.RoomMap[ev.RoomID] + b.RUnlock() + if !ok { + b.Log.Debugf("Unknown room %s", ev.RoomID) + return + } + + // Create our message + rmsg := config.Message{ + Username: b.getDisplayName(ev.Sender), + Channel: channel, + Account: b.Account, + UserID: ev.Sender, + ID: ev.ID, + Avatar: b.getAvatarURL(ev.Sender), + } + + // Remove homeserver suffix if configured + if b.GetBool("NoHomeServerSuffix") { + re := regexp.MustCompile("(.*?):.*") + rmsg.Username = re.ReplaceAllString(rmsg.Username, `$1`) + } + + // Delete event + if ev.Type == "m.room.redaction" { + rmsg.Event = config.EventMsgDelete + rmsg.ID = ev.Redacts + rmsg.Text = config.EventMsgDelete + b.Remote <- rmsg + return + } + + // Text must be a string + if rmsg.Text, ok = ev.Content["body"].(string); !ok { + b.Log.Errorf("Content[body] is not a string: %T\n%#v", + ev.Content["body"], ev.Content) + return + } + + // Do we have a /me action + if ev.Content["msgtype"].(string) == "m.emote" { + rmsg.Event = config.EventUserAction + } + + // Is it an edit? + if b.handleEdit(ev, rmsg) { + return + } + + // Is it a reply? + if b.handleReply(ev, rmsg) { + return + } + + // Do we have attachments + if b.containsAttachment(ev.Content) { + err := b.handleDownloadFile(&rmsg, ev.Content) + if err != nil { + b.Log.Errorf("download failed: %#v", err) + } + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Sender, b.Account) + b.Remote <- rmsg + + // not crucial, so no ratelimit check here + if err := b.mc.MarkRead(ev.RoomID, ev.ID); err != nil { + b.Log.Errorf("couldn't mark message as read %s", err.Error()) + } + } +} + +// handleDownloadFile handles file download +func (b *Bmatrix) handleDownloadFile(rmsg *config.Message, content map[string]interface{}) error { + var ( + ok bool + url, name, msgtype, mtype string + info map[string]interface{} + size float64 + ) + + rmsg.Extra = make(map[string][]interface{}) + if url, ok = content["url"].(string); !ok { + return fmt.Errorf("url isn't a %T", url) + } + url = strings.Replace(url, "mxc://", b.GetString("Server")+"/_matrix/media/v1/download/", -1) + + if info, ok = content["info"].(map[string]interface{}); !ok { + return fmt.Errorf("info isn't a %T", info) + } + if size, ok = info["size"].(float64); !ok { + return fmt.Errorf("size isn't a %T", size) + } + if name, ok = content["body"].(string); !ok { + return fmt.Errorf("name isn't a %T", name) + } + if msgtype, ok = content["msgtype"].(string); !ok { + return fmt.Errorf("msgtype isn't a %T", msgtype) + } + if mtype, ok = info["mimetype"].(string); !ok { + return fmt.Errorf("mtype isn't a %T", mtype) + } + + // check if we have an image uploaded without extension + if !strings.Contains(name, ".") { + if msgtype == "m.image" { + mext, _ := mime.ExtensionsByType(mtype) + if len(mext) > 0 { + name += mext[0] + } + } else { + // just a default .png extension if we don't have mime info + name += ".png" + } + } + + // check if the size is ok + err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General) + if err != nil { + return err + } + // actually download the file + data, err := helper.DownloadFile(url) + if err != nil { + return fmt.Errorf("download %s failed %#v", url, err) + } + // add the downloaded data to the message + helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General) + return nil +} + +// handleUploadFiles handles native upload of files. +func (b *Bmatrix) handleUploadFiles(msg *config.Message, channel string) (string, error) { + for _, f := range msg.Extra["file"] { + if fi, ok := f.(config.FileInfo); ok { + b.handleUploadFile(msg, channel, &fi) + } + } + return "", nil +} + +// handleUploadFile handles native upload of a file. +func (b *Bmatrix) handleUploadFile(msg *config.Message, channel string, fi *config.FileInfo) { + username := newMatrixUsername(msg.Username) + content := bytes.NewReader(*fi.Data) + sp := strings.Split(fi.Name, ".") + mtype := mime.TypeByExtension("." + sp[len(sp)-1]) + // image and video uploads send no username, we have to do this ourself here #715 + err := b.retry(func() error { + _, err := b.mc.SendFormattedText(channel, username.plain+fi.Comment, username.formatted+fi.Comment) + + return err + }) + if err != nil { + b.Log.Errorf("file comment failed: %#v", err) + } + + b.Log.Debugf("uploading file: %s %s", fi.Name, mtype) + + var res *matrix.RespMediaUpload + + err = b.retry(func() error { + res, err = b.mc.UploadToContentRepo(content, mtype, int64(len(*fi.Data))) + + return err + }) + + if err != nil { + b.Log.Errorf("file upload failed: %#v", err) + return + } + + switch { + case strings.Contains(mtype, "video"): + b.Log.Debugf("sendVideo %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendVideo(channel, fi.Name, res.ContentURI) + + return err + }) + if err != nil { + b.Log.Errorf("sendVideo failed: %#v", err) + } + case strings.Contains(mtype, "image"): + b.Log.Debugf("sendImage %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendImage(channel, fi.Name, res.ContentURI) + + return err + }) + if err != nil { + b.Log.Errorf("sendImage failed: %#v", err) + } + case strings.Contains(mtype, "audio"): + b.Log.Debugf("sendAudio %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.AudioMessage{ + MsgType: "m.audio", + Body: fi.Name, + URL: res.ContentURI, + Info: matrix.AudioInfo{ + Mimetype: mtype, + Size: uint(len(*fi.Data)), + }, + }) + + return err + }) + if err != nil { + b.Log.Errorf("sendAudio failed: %#v", err) + } + default: + b.Log.Debugf("sendFile %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.FileMessage{ + MsgType: "m.file", + Body: fi.Name, + URL: res.ContentURI, + Info: matrix.FileInfo{ + Mimetype: mtype, + Size: uint(len(*fi.Data)), + }, + }) + + return err + }) + if err != nil { + b.Log.Errorf("sendFile failed: %#v", err) + } + } + b.Log.Debugf("result: %#v", res) +} diff --git a/teleirc/matterbridge/bridge/matrix/matrix_test.go b/teleirc/matterbridge/bridge/matrix/matrix_test.go new file mode 100644 index 0000000..846d9a4 --- /dev/null +++ b/teleirc/matterbridge/bridge/matrix/matrix_test.go @@ -0,0 +1,28 @@ +package bmatrix + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlainUsername(t *testing.T) { + uut := newMatrixUsername("MyUser") + + assert.Equal(t, "MyUser", uut.formatted) + assert.Equal(t, "MyUser", uut.plain) +} + +func TestHTMLUsername(t *testing.T) { + uut := newMatrixUsername("<b>MyUser</b>") + + assert.Equal(t, "<b>MyUser</b>", uut.formatted) + assert.Equal(t, "MyUser", uut.plain) +} + +func TestFancyUsername(t *testing.T) { + uut := newMatrixUsername("<MyUser>") + + assert.Equal(t, "<MyUser>", uut.formatted) + assert.Equal(t, "<MyUser>", uut.plain) +} |
