From 91aa9ea470ea72fb9a5d3ba4885e2c8bcf155551 Mon Sep 17 00:00:00 2001 From: Alexander Griesser <46035328+anx-ag@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:38:27 +0100 Subject: [PATCH 01/42] Do not double prefix the username in messages --- bridge/mattermost/helpers.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bridge/mattermost/helpers.go b/bridge/mattermost/helpers.go index 63e143dda9..8b7361c2f8 100644 --- a/bridge/mattermost/helpers.go +++ b/bridge/mattermost/helpers.go @@ -117,7 +117,11 @@ func (b *Bmattermost) sendWebhook(msg config.Message) (string, error) { return "", nil } - if b.GetBool("PrefixMessagesWithNick") { + // When the webhook sets override_username (msg.Username is non-empty), + // do NOT prefix the message with the nick — it would show up twice + // (once as the sender name, once in the message body). + // Only prefix when there's no username to use as override. + if b.GetBool("PrefixMessagesWithNick") && msg.Username == "" { msg.Text = msg.Username + msg.Text } From 1b548247a9d0849ee32769a178c527b6cc03be79 Mon Sep 17 00:00:00 2001 From: Alexander Griesser <46035328+anx-ag@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:41:07 +0100 Subject: [PATCH 02/42] Support for hybrid (hook + token) mode Webhooks in Mattermost allow for overriding the username and icon in root messages, bot tokens support threading. We need to use both in hybrid mode for the best user experience. --- bridge/mattermost/mattermost.go | 405 +++++++++++++++++++------------- 1 file changed, 244 insertions(+), 161 deletions(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 2a9cbf7670..f53301c6a8 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -1,194 +1,277 @@ package bmattermost import ( - "context" - "errors" - "fmt" - "strings" - "sync" - - "github.com/matterbridge-org/matterbridge/bridge" - "github.com/matterbridge-org/matterbridge/bridge/config" - "github.com/matterbridge-org/matterbridge/bridge/helper" - "github.com/matterbridge-org/matterbridge/matterhook" - "github.com/matterbridge/matterclient" - "github.com/rs/xid" + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/matterbridge-org/matterbridge/bridge" + "github.com/matterbridge-org/matterbridge/bridge/config" + "github.com/matterbridge-org/matterbridge/bridge/helper" + "github.com/matterbridge-org/matterbridge/matterhook" + "github.com/matterbridge/matterclient" + "github.com/rs/xid" ) type Bmattermost struct { - mh *matterhook.Client - mc *matterclient.Client - v6 bool - uuid string - TeamID string - *bridge.Config - avatarMap map[string]string - channelsMutex sync.RWMutex - channelInfoMap map[string]*config.ChannelInfo + mh *matterhook.Client + mc *matterclient.Client + v6 bool + uuid string + TeamID string + *bridge.Config + avatarMap map[string]string + channelsMutex sync.RWMutex + channelInfoMap map[string]*config.ChannelInfo } const mattermostPlugin = "mattermost.plugin" func New(cfg *bridge.Config) bridge.Bridger { - b := &Bmattermost{ - Config: cfg, - avatarMap: make(map[string]string), - channelInfoMap: make(map[string]*config.ChannelInfo), - } + b := &Bmattermost{ + Config: cfg, + avatarMap: make(map[string]string), + channelInfoMap: make(map[string]*config.ChannelInfo), + } - b.v6 = b.GetBool("v6") - b.uuid = xid.New().String() + b.v6 = b.GetBool("v6") + b.uuid = xid.New().String() - return b + return b } func (b *Bmattermost) Command(cmd string) string { - return "" + return "" } func (b *Bmattermost) Connect() error { - if b.Account == mattermostPlugin { - return nil - } - - if strings.HasPrefix(b.getVersion(), "6.") || strings.HasPrefix(b.getVersion(), "7.") { - if !b.v6 { - b.v6 = true - } - } - - if b.GetString("WebhookBindAddress") != "" { - if err := b.doConnectWebhookBind(); err != nil { - return err - } - go b.handleMatter() - return nil - } - switch { - case b.GetString("WebhookURL") != "": - if err := b.doConnectWebhookURL(); err != nil { - return err - } - go b.handleMatter() - return nil - case b.GetString("Token") != "": - b.Log.Info("Connecting using token (sending and receiving)") - err := b.apiLogin() - if err != nil { - return err - } - go b.handleMatter() - case b.GetString("Login") != "": - b.Log.Info("Connecting using login/password (sending and receiving)") - b.Log.Infof("Using mattermost v6 methods: %t", b.v6) - err := b.apiLogin() - if err != nil { - return err - } - go b.handleMatter() - } - if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" && - b.GetString("Login") == "" && b.GetString("Token") == "" { - return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token/Login/Password/Server/Team configured") - } - return nil + if b.Account == mattermostPlugin { + return nil + } + + if strings.HasPrefix(b.getVersion(), "6.") || strings.HasPrefix(b.getVersion(), "7.") { + if !b.v6 { + b.v6 = true + } + } + + if b.GetString("WebhookBindAddress") != "" { + if err := b.doConnectWebhookBind(); err != nil { + return err + } + go b.handleMatter() + return nil + } + + switch { + case b.GetString("WebhookURL") != "": + if err := b.doConnectWebhookURL(); err != nil { + return err + } + // doConnectWebhookURL() already calls apiLogin() if Token or Login + // is configured, so b.mc is available for hybrid mode. + if b.mc != nil { + b.Log.Info("Hybrid mode: webhook for new messages, API for thread replies/edits/deletes") + } + go b.handleMatter() + return nil + case b.GetString("Token") != "": + b.Log.Info("Connecting using token (sending and receiving)") + err := b.apiLogin() + if err != nil { + return err + } + go b.handleMatter() + case b.GetString("Login") != "": + b.Log.Info("Connecting using login/password (sending and receiving)") + b.Log.Infof("Using mattermost v6 methods: %t", b.v6) + err := b.apiLogin() + if err != nil { + return err + } + go b.handleMatter() + } + + if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" && + b.GetString("Login") == "" && b.GetString("Token") == "" { + return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token/Login/Password/Server/Team configured") + } + + return nil } func (b *Bmattermost) Disconnect() error { - return nil + return nil } func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error { - if b.Account == mattermostPlugin { - return nil - } + if b.Account == mattermostPlugin { + return nil + } + + b.channelsMutex.Lock() + b.channelInfoMap[channel.ID] = &channel + b.channelsMutex.Unlock() - b.channelsMutex.Lock() - b.channelInfoMap[channel.ID] = &channel - b.channelsMutex.Unlock() + // we can only join channels using the API + if b.GetString("WebhookURL") == "" && b.GetString("WebhookBindAddress") == "" { + id := b.getChannelID(channel.Name) + if id == "" { + return fmt.Errorf("Could not find channel ID for channel %s", channel.Name) + } + return b.mc.JoinChannel(id) + } - // we can only join channels using the API - if b.GetString("WebhookURL") == "" && b.GetString("WebhookBindAddress") == "" { - id := b.getChannelID(channel.Name) - if id == "" { - return fmt.Errorf("Could not find channel ID for channel %s", channel.Name) - } + return nil +} + +// lookupWebhookPostID searches recent channel posts to find the ID of a message +// just sent via webhook. Webhooks don't return post IDs, but the gateway needs +// them to map replies across bridges. We look for a recent post from the bot +// user that has our matterbridge uuid prop set and matches the expected text. +func (b *Bmattermost) lookupWebhookPostID(channelName, text string) string { + if b.mc == nil { + return "" + } + + channelID := b.getChannelID(channelName) + if channelID == "" { + return "" + } + + postList, _, err := b.mc.Client.GetPostsForChannel(context.TODO(), channelID, 0, 10, "", false, false) + if err != nil { + b.Log.Debugf("lookupWebhookPostID: GetPostsForChannel failed: %s", err) + return "" + } - return b.mc.JoinChannel(id) - } + now := time.Now().UnixMilli() + propKey := "matterbridge_" + b.uuid - return nil + for _, id := range postList.Order { + post := postList.Posts[id] + if now-post.CreateAt > 5000 { + continue + } + if _, ok := post.Props[propKey]; !ok { + continue + } + if post.RootId != "" { + continue + } + if strings.Contains(post.Message, text) || post.Message == text { + b.Log.Debugf("lookupWebhookPostID: found post %s for webhook message", post.Id) + return post.Id + } + } + + b.Log.Debugf("lookupWebhookPostID: no matching post found") + return "" } func (b *Bmattermost) Send(msg config.Message) (string, error) { - if b.Account == mattermostPlugin { - return "", nil - } - b.Log.Debugf("=> Receiving %#v", msg) - - // Make a action /me of the message - if msg.Event == config.EventUserAction { - msg.Text = "*" + msg.Text + "*" - } - - // map the file SHA to our user (caches the avatar) - if msg.Event == config.EventAvatarDownload { - return b.cacheAvatar(&msg) - } - - // Use webhook to send the message - if b.GetString("WebhookURL") != "" { - return b.sendWebhook(msg) - } - - // Delete message - if msg.Event == config.EventMsgDelete { - if msg.ID == "" { - return "", nil - } - - return msg.ID, b.mc.DeleteMessage(msg.ID) - } - - // Handle prefix hint for unthreaded messages. - if msg.ParentNotFound() { - msg.ParentID = "" - msg.Text = fmt.Sprintf("[thread]: %s", msg.Text) - } - - // we only can reply to the root of the thread, not to a specific ID (like discord for example does) - if msg.ParentID != "" { - post, _, err := b.mc.Client.GetPost(context.TODO(), msg.ParentID, "") - if err != nil { - b.Log.Errorf("getting post %s failed: %s", msg.ParentID, err) - } - if post != nil && post.RootId != "" { - msg.ParentID = post.RootId - } - } - - // Upload a file if it exists - if msg.Extra != nil { - for _, rmsg := range helper.HandleExtra(&msg, b.General) { - if _, err := b.mc.PostMessage(b.getChannelID(rmsg.Channel), rmsg.Username+rmsg.Text, msg.ParentID); err != nil { - b.Log.Errorf("PostMessage failed: %s", err) - } - } - if len(msg.Extra["file"]) > 0 { - return b.handleUploadFile(&msg) - } - } - - // Prepend nick if configured - if b.GetBool("PrefixMessagesWithNick") { - msg.Text = msg.Username + msg.Text - } - - // Edit message if we have an ID - if msg.ID != "" { - return b.mc.EditMessage(msg.ID, msg.Text) - } - - // Post normal message - return b.mc.PostMessage(b.getChannelID(msg.Channel), msg.Text, msg.ParentID) + if b.Account == mattermostPlugin { + return "", nil + } + + b.Log.Debugf("=> Receiving %#v", msg) + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + msg.Text = "*" + msg.Text + "*" + } + + // map the file SHA to our user (caches the avatar) + if msg.Event == config.EventAvatarDownload { + return b.cacheAvatar(&msg) + } + + // --- Hybrid mode: webhook for top-level, API for replies/edits/deletes --- + if b.GetString("WebhookURL") != "" { + isReply := msg.ParentValid() + isEdit := msg.ID != "" && msg.Event == "msg_update" + isDelete := msg.Event == config.EventMsgDelete + + if !isReply && !isEdit && !isDelete { + // Top-level new message → use webhook (username/avatar override). + // Remember the original text for post-ID lookup. + originalText := msg.Text + + _, err := b.sendWebhook(msg) + if err != nil { + return "", err + } + + // Look up the post ID so the gateway can cache it for reply mapping. + postID := b.lookupWebhookPostID(msg.Channel, originalText) + if postID != "" { + return postID, nil + } + return "", nil + } + + // Reply, edit, or delete → need API client. + if b.mc == nil { + b.Log.Warnf("Cannot send thread reply/edit/delete via API: no API connection.") + if isReply { + return b.sendWebhook(msg) + } + return "", fmt.Errorf("API client not available for edit/delete") + } + // Fall through to the API path below. + } + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + return msg.ID, b.mc.DeleteMessage(msg.ID) + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + msg.Text = fmt.Sprintf("[thread]: %s", msg.Text) + } + + // we only can reply to the root of the thread, not to a specific ID + if msg.ParentID != "" { + post, _, err := b.mc.Client.GetPost(context.TODO(), msg.ParentID, "") + if err != nil { + b.Log.Errorf("getting post %s failed: %s", msg.ParentID, err) + } + if post != nil && post.RootId != "" { + msg.ParentID = post.RootId + } + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + if _, err := b.mc.PostMessage(b.getChannelID(rmsg.Channel), rmsg.Username+rmsg.Text, msg.ParentID); err != nil { + b.Log.Errorf("PostMessage failed: %s", err) + } + } + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(&msg) + } + } + + // Prepend nick if configured. Bold the username and put the message + // on the next line so it visually matches webhook post styling. + if b.GetBool("PrefixMessagesWithNick") { + msg.Text = "**" + strings.TrimSpace(msg.Username) + "**\n" + msg.Text + } + + // Edit message if we have an ID + if msg.ID != "" { + return b.mc.EditMessage(msg.ID, msg.Text) + } + + // Post normal message + return b.mc.PostMessage(b.getChannelID(msg.Channel), msg.Text, msg.ParentID) } From 139284c27950b6a06703832ca93f670396d68f59 Mon Sep 17 00:00:00 2001 From: Alexander Griesser <46035328+anx-ag@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:47:58 +0100 Subject: [PATCH 03/42] Major MSTeams Overhaul - initial release This patch fixes multiple issues and adds new features. * Support for message editing (bi-directional) * Support for message deletion (bi-directional) * Initial support for image/file attachments (not working ATM) * Since we support threading, the "[thread]" prefix is not posted anymore * Message formatting is fixed so that the nick is posted in a separate bold line * Initial support for some markdown (code, quote, etc.) - some of it works bidirectional at the moment, some only in one direction (markdown2teams converter and vice versa) * ignores messages before matterbridge starttime * keeps track of sent messages during runtime --- bridge/msteams/msteams.go | 973 ++++++++++++++++++++++++++++++-------- 1 file changed, 786 insertions(+), 187 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index f79e718884..0a5aca9541 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -1,229 +1,828 @@ package bmsteams import ( - "context" - "fmt" - "os" - "regexp" - "strings" - "time" - - "github.com/davecgh/go-spew/spew" - "github.com/matterbridge-org/matterbridge/bridge" - "github.com/matterbridge-org/matterbridge/bridge/config" - - "github.com/mattn/godown" - msgraph "github.com/yaegashi/msgraph.go/beta" - "github.com/yaegashi/msgraph.go/msauth" - - "golang.org/x/oauth2" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/matterbridge-org/matterbridge/bridge" + "github.com/matterbridge-org/matterbridge/bridge/config" + "github.com/davecgh/go-spew/spew" + "github.com/mattn/godown" + msgraph "github.com/yaegashi/msgraph.go/beta" + "github.com/yaegashi/msgraph.go/msauth" + "golang.org/x/oauth2" ) var ( - defaultScopes = []string{"openid", "profile", "offline_access", "Group.Read.All", "Group.ReadWrite.All"} - attachRE = regexp.MustCompile(``) + defaultScopes = []string{"openid", "profile", "offline_access", "Group.Read.All", "Group.ReadWrite.All"} + attachRE = regexp.MustCompile(``) ) type Bmsteams struct { - gc *msgraph.GraphServiceRequestBuilder - ctx context.Context - botID string - *bridge.Config + gc *msgraph.GraphServiceRequestBuilder + ctx context.Context + botID string + ts oauth2.TokenSource // token source for fresh access tokens + sentIDs map[string]struct{} // IDs of messages/replies we posted (echo prevention) + updatedIDs map[string]time.Time // IDs of messages we PATCHed, with expiry time + *bridge.Config } func New(cfg *bridge.Config) bridge.Bridger { - return &Bmsteams{Config: cfg} + return &Bmsteams{ + Config: cfg, + sentIDs: make(map[string]struct{}), + updatedIDs: make(map[string]time.Time), + } } func (b *Bmsteams) Connect() error { - tokenCachePath := b.GetString("sessionFile") - if tokenCachePath == "" { - tokenCachePath = "msteams_session.json" - } - ctx := context.Background() - m := msauth.NewManager() - m.LoadFile(tokenCachePath) //nolint:errcheck - ts, err := m.DeviceAuthorizationGrant(ctx, b.GetString("TenantID"), b.GetString("ClientID"), defaultScopes, nil) - if err != nil { - return err - } - err = m.SaveFile(tokenCachePath) - if err != nil { - b.Log.Errorf("Couldn't save sessionfile in %s: %s", tokenCachePath, err) - } - // make file readable only for matterbridge user - err = os.Chmod(tokenCachePath, 0o600) - if err != nil { - b.Log.Errorf("Couldn't change permissions for %s: %s", tokenCachePath, err) - } - httpClient := oauth2.NewClient(ctx, ts) - graphClient := msgraph.NewClient(httpClient) - b.gc = graphClient - b.ctx = ctx - - err = b.setBotID() - if err != nil { - return err - } - b.Log.Info("Connection succeeded") - return nil + tokenCachePath := b.GetString("sessionFile") + if tokenCachePath == "" { + tokenCachePath = "msteams_session.json" + } + + ctx := context.Background() + m := msauth.NewManager() + m.LoadFile(tokenCachePath) //nolint:errcheck + + ts, err := m.DeviceAuthorizationGrant(ctx, b.GetString("TenantID"), b.GetString("ClientID"), defaultScopes, nil) + if err != nil { + return err + } + + err = m.SaveFile(tokenCachePath) + if err != nil { + b.Log.Errorf("Couldn't save sessionfile in %s: %s", tokenCachePath, err) + } + + err = os.Chmod(tokenCachePath, 0o600) + if err != nil { + b.Log.Errorf("Couldn't change permissions for %s: %s", tokenCachePath, err) + } + + httpClient := oauth2.NewClient(ctx, ts) + graphClient := msgraph.NewClient(httpClient) + b.gc = graphClient + b.ctx = ctx + + // Store the token source so we can get fresh tokens for direct HTTP calls. + b.ts = ts + + err = b.setBotID() + if err != nil { + return err + } + + b.Log.Info("Connection succeeded") + return nil } func (b *Bmsteams) Disconnect() error { - return nil + return nil } func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { - go func(name string) { - for { - err := b.poll(name) - if err != nil { - b.Log.Errorf("polling failed for %s: %s. retrying in 5 seconds", name, err) - } - time.Sleep(time.Second * 5) - } - }(channel.Name) - return nil + go func(name string) { + for { + err := b.poll(name) + if err != nil { + b.Log.Errorf("polling failed for %s: %s. retrying in 5 seconds", name, err) + } + time.Sleep(time.Second * 2) + } + }(channel.Name) + return nil } func (b *Bmsteams) Send(msg config.Message) (string, error) { - b.Log.Debugf("=> Receiving %#v", msg) - if msg.ParentValid() { - return b.sendReply(msg) - } - - // Handle prefix hint for unthreaded messages. - if msg.ParentNotFound() { - msg.ParentID = "" - msg.Text = fmt.Sprintf("[thread]: %s", msg.Text) - } - - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().Request() - text := msg.Username + msg.Text - content := &msgraph.ItemBody{Content: &text} - rmsg := &msgraph.ChatMessage{Body: content} - res, err := ct.Add(b.ctx, rmsg) - if err != nil { - return "", err - } - return *res.ID, nil + b.Log.Debugf("=> Receiving %#v", msg) + + // Handle deletes from Mattermost → Teams. + if msg.Event == config.EventMsgDelete && msg.ID != "" { + b.Log.Debugf("delete: soft-deleting Teams message ID %s", msg.ID) + return b.deleteMessage(msg) + } + + // Handle edits from Mattermost → Teams. + // The gateway sets msg.ID="" on first send, but on edits it maps the Mattermost + // post-ID to the Teams message-ID (returned by our Send()) and passes it here. + // So msg.ID != "" (and not a delete) means this is an edit. + if msg.ID != "" { + b.Log.Debugf("edit: updating Teams message ID %s", msg.ID) + return b.updateMessage(msg) + } + + // Handle file/image attachments. + if msg.Extra != nil { + for _, files := range msg.Extra["file"] { + fi, ok := files.(config.FileInfo) + if !ok { + continue + } + if err := b.sendFileAsMessage(msg, fi); err != nil { + b.Log.Errorf("sending file %s failed: %s", fi.Name, err) + } + } + if msg.Text == "" && len(msg.Extra["file"]) > 0 { + return "", nil + } + } + + if msg.ParentValid() { + return b.sendReply(msg) + } + + if msg.ParentNotFound() { + msg.ParentID = "" + // Don't add a [thread] prefix — the message is posted to the correct + // context already and the prefix just clutters the content. + } + + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() + text := formatMessageText(msg.Username, msg.Text) + htmlText, isHTML := mdToTeamsHTML(text) + content := &msgraph.ItemBody{Content: &text} + if isHTML { + htmlType := msgraph.BodyTypeVHTML + content = &msgraph.ItemBody{Content: &htmlText, ContentType: &htmlType} + } + rmsg := &msgraph.ChatMessage{Body: content} + + res, err := ct.Add(b.ctx, rmsg) + if err != nil { + return "", err + } + b.sentIDs[*res.ID] = struct{}{} + return *res.ID, nil +} + +// formatMessageText combines the username prefix with the message text. +// If the text starts with a block element (quote, code fence, list), +// the username is placed on its own line to avoid formatting corruption. +func formatMessageText(username, text string) string { + if username == "" { + return text + } + trimmed := strings.TrimSpace(text) + if strings.HasPrefix(trimmed, ">") || + strings.HasPrefix(trimmed, "```") || + strings.HasPrefix(trimmed, " ") { + return username + "\n" + trimmed + } + return username + text +} + +// mdToTeamsHTML converts a message that may contain markdown code fences +// into Teams-compatible HTML so that code blocks render correctly. +// If the text has no code fences, it returns the text with newlines +// converted to
and sets needsHTML=true only when conversion happened. +func mdToTeamsHTML(text string) (string, bool) { + if !strings.Contains(text, "```") { + return text, false + } + + codeFenceRE := regexp.MustCompile("(?s)```(\\w*)\\n(.*?)\\n?```") + + // Convert code fences to Teams-native HTML format for syntax highlighting. + // Teams uses ... internally. + result := codeFenceRE.ReplaceAllStringFunc(text, func(match string) string { + parts := codeFenceRE.FindStringSubmatch(match) + if len(parts) < 3 { + return match + } + lang := parts[1] + code := parts[2] + // Escape HTML entities in code content. + code = strings.ReplaceAll(code, "&", "&") + code = strings.ReplaceAll(code, "<", "<") + code = strings.ReplaceAll(code, ">", ">") + // Convert newlines to
inside code for Teams rendering. + code = strings.ReplaceAll(code, "\n", "
") + if lang != "" { + return `` + code + "" + } + return "" + code + "" + }) + + // Convert remaining newlines to
for non-code text. + // Split by codeblock/pre blocks to avoid adding
inside code. + codeSplitRE := regexp.MustCompile(`(?s)(]*>.*?)`) + textParts := codeSplitRE.Split(result, -1) + codeParts := codeSplitRE.FindAllString(result, -1) + + var final strings.Builder + for i, part := range textParts { + // Escape HTML in non-code parts and convert newlines. + part = strings.ReplaceAll(part, "&", "&") + part = strings.ReplaceAll(part, "<", "<") + part = strings.ReplaceAll(part, ">", ">") + part = strings.ReplaceAll(part, "\n", "
") + final.WriteString(part) + if i < len(codeParts) { + final.WriteString(codeParts[i]) + } + } + + return final.String(), true +} + +// getAccessToken returns a fresh access token from the token source. +func (b *Bmsteams) getAccessToken() (string, error) { + t, err := b.ts.Token() + if err != nil { + return "", fmt.Errorf("failed to get access token: %w", err) + } + return t.AccessToken, nil +} + +// updateMessage patches an existing Teams message with new content. +// The Teams Graph API only allows the original sender to update via delegated perms, +// so this may fail if matterbridge is not authenticated as the message author. +func (b *Bmsteams) updateMessage(msg config.Message) (string, error) { + text := formatMessageText(msg.Username, msg.Text) + + type patchBody struct { + Body struct { + ContentType string `json:"contentType"` + Content string `json:"content"` + } `json:"body"` + } + + var patch patchBody + patch.Body.ContentType = "text" + patch.Body.Content = text + + jsonData, err := json.Marshal(patch) + if err != nil { + return "", err + } + + teamID := b.GetString("TeamID") + channelID := msg.Channel + messageID := msg.ID + + url := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s", + teamID, channelID, messageID) + + token, err := b.getAccessToken() + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(b.ctx, http.MethodPatch, url, bytes.NewReader(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("update message failed: %d %s", resp.StatusCode, string(body)) + } + + // Suppress echo: ignore this message in the poll loop for the next 30 seconds. + // Teams may update LastModifiedDateTime multiple times after a PATCH. + b.updatedIDs[msg.ID] = time.Now().Add(30 * time.Second) + return msg.ID, nil +} + +// deleteMessage soft-deletes a Teams channel message or reply via the Graph API. +// For replies, msg.ParentID must be set to the top-level message ID. +func (b *Bmsteams) deleteMessage(msg config.Message) (string, error) { + teamID := b.GetString("TeamID") + channelID := msg.Channel + messageID := msg.ID + + var url string + if msg.ParentID != "" { + // This is a reply — use the reply softDelete endpoint. + url = fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/replies/%s/softDelete", + teamID, channelID, msg.ParentID, messageID) + } else { + url = fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/softDelete", + teamID, channelID, messageID) + } + + req, err := http.NewRequestWithContext(b.ctx, http.MethodPost, url, nil) + if err != nil { + return "", err + } + + token, err := b.getAccessToken() + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("delete message failed: %d %s", resp.StatusCode, string(body)) + } + + // Suppress echo for the deletion event. + b.updatedIDs[messageID] = time.Now().Add(30 * time.Second) + return messageID, nil +} + +// uploadToMediaServer uploads file bytes to the configured MediaServerUpload endpoint. +func (b *Bmsteams) uploadToMediaServer(fi config.FileInfo) (string, error) { + serverURL := b.GetString("MediaServerUpload") + if serverURL == "" { + return "", fmt.Errorf("no MediaServerUpload configured") + } + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, err := writer.CreateFormFile("file", fi.Name) + if err != nil { + return "", err + } + if _, err = io.Copy(part, bytes.NewReader(*fi.Data)); err != nil { + return "", err + } + writer.Close() + + resp, err := http.Post(serverURL+"/"+fi.Name, writer.FormDataContentType(), &buf) //nolint:gosec + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("media server returned %d", resp.StatusCode) + } + + urlBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return strings.TrimSpace(string(urlBytes)), nil +} + +// mimeTypeForFile returns a MIME type for image files, or empty string otherwise. +func mimeTypeForFile(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".svg": + return "image/svg+xml" + case ".bmp": + return "image/bmp" + default: + return "" + } +} + +func isImageFile(name string) bool { + return mimeTypeForFile(name) != "" +} + +func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) error { + contentType := msgraph.BodyTypeVHTML + var bodyText string + isImage := isImageFile(fi.Name) + + fileURL := fi.URL + if fileURL == "" && fi.Data != nil { + uploadedURL, err := b.uploadToMediaServer(fi) + if err != nil { + b.Log.Debugf("media server upload failed for %s: %s", fi.Name, err) + } else { + fileURL = uploadedURL + } + } + + switch { + case fileURL != "" && isImage: + bodyText = fmt.Sprintf( + `%s
%s`, + msg.Username, fileURL, fi.Name, + ) + case fileURL != "": + bodyText = fmt.Sprintf( + `%s📎 %s`, + msg.Username, fileURL, fi.Name, + ) + default: + b.Log.Debugf("cannot embed file %s in Teams: configure MediaServerUpload to enable image transfer", fi.Name) + notice := fmt.Sprintf("%s[Datei: %s — konfiguriere MediaServerUpload für Dateiübertragung]", msg.Username, fi.Name) + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() + htmlType := msgraph.BodyTypeVHTML + content := &msgraph.ItemBody{Content: ¬ice, ContentType: &htmlType} + _, err := ct.Add(b.ctx, &msgraph.ChatMessage{Body: content}) + return err + } + + content := &msgraph.ItemBody{ + Content: &bodyText, + ContentType: &contentType, + } + chatMsg := &msgraph.ChatMessage{Body: content} + + if msg.ParentValid() { + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().ID(msg.ParentID).Replies().Request() + _, err := ct.Add(b.ctx, chatMsg) + return err + } + + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() + _, err := ct.Add(b.ctx, chatMsg) + return err } func (b *Bmsteams) sendReply(msg config.Message) (string, error) { - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().ID(msg.ParentID).Replies().Request() - // Handle prefix hint for unthreaded messages. + channelID := decodeChannelID(msg.Channel) + b.Log.Debugf("sendReply: ParentID=%s Channel=%s", msg.ParentID, channelID) + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(channelID).Messages().ID(msg.ParentID).Replies().Request() - text := msg.Username + msg.Text - content := &msgraph.ItemBody{Content: &text} - rmsg := &msgraph.ChatMessage{Body: content} - res, err := ct.Add(b.ctx, rmsg) - if err != nil { - return "", err - } - return *res.ID, nil + text := formatMessageText(msg.Username, msg.Text) + htmlText, isHTML := mdToTeamsHTML(text) + content := &msgraph.ItemBody{Content: &text} + if isHTML { + htmlType := msgraph.BodyTypeVHTML + content = &msgraph.ItemBody{Content: &htmlText, ContentType: &htmlType} + } + rmsg := &msgraph.ChatMessage{Body: content} + + res, err := ct.Add(b.ctx, rmsg) + if err != nil { + b.Log.Errorf("sendReply failed: ParentID=%s err=%s", msg.ParentID, err) + return "", err + } + b.sentIDs[*res.ID] = struct{}{} + return *res.ID, nil +} + +// decodeChannelID URL-decodes a channel ID if needed. +// The gateway stores channel IDs URL-encoded (e.g. 19%3A...%40thread.tacv2) +// but the Teams Graph API requires the decoded form (19:...@thread.tacv2). +func decodeChannelID(id string) string { + decoded, err := url.PathUnescape(id) + if err != nil { + return id + } + return decoded } func (b *Bmsteams) getMessages(channel string) ([]msgraph.ChatMessage, error) { - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(channel).Messages().Request() - rct, err := ct.Get(b.ctx) - if err != nil { - return nil, err - } - b.Log.Debugf("got %#v messages", len(rct)) - return rct, nil + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(channel)).Messages().Request() + + rct, err := ct.Get(b.ctx) + if err != nil { + return nil, err + } + + b.Log.Debugf("got %#v messages", len(rct)) + return rct, nil +} + +func (b *Bmsteams) getReplies(channel, messageID string) ([]msgraph.ChatMessage, error) { + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(channel)).Messages().ID(messageID).Replies().Request() + return ct.Get(b.ctx) } //nolint:gocognit func (b *Bmsteams) poll(channelName string) error { - msgmap := make(map[string]time.Time) - b.Log.Debug("getting initial messages") - res, err := b.getMessages(channelName) - if err != nil { - return err - } - for _, msg := range res { - msgmap[*msg.ID] = *msg.CreatedDateTime - if msg.LastModifiedDateTime != nil { - msgmap[*msg.ID] = *msg.LastModifiedDateTime - } - } - time.Sleep(time.Second * 5) - b.Log.Debug("polling for messages") - for { - res, err := b.getMessages(channelName) - if err != nil { - return err - } - for i := len(res) - 1; i >= 0; i-- { - msg := res[i] - if mtime, ok := msgmap[*msg.ID]; ok { - if mtime == *msg.CreatedDateTime && msg.LastModifiedDateTime == nil { - continue - } - if msg.LastModifiedDateTime != nil && mtime == *msg.LastModifiedDateTime { - continue - } - } - - if b.GetBool("debug") { - b.Log.Debug("Msg dump: ", spew.Sdump(msg)) - } - - // skip non-user message for now. - if msg.From == nil || msg.From.User == nil { - continue - } - - if *msg.From.User.ID == b.botID { - b.Log.Debug("skipping own message") - msgmap[*msg.ID] = *msg.CreatedDateTime - continue - } - - msgmap[*msg.ID] = *msg.CreatedDateTime - if msg.LastModifiedDateTime != nil { - msgmap[*msg.ID] = *msg.LastModifiedDateTime - } - b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account) - text := b.convertToMD(*msg.Body.Content) - rmsg := config.Message{ - Username: *msg.From.User.DisplayName, - Text: text, - Channel: channelName, - Account: b.Account, - Avatar: "", - UserID: *msg.From.User.ID, - ID: *msg.ID, - Extra: make(map[string][]interface{}), - } - - b.handleAttachments(&rmsg, msg) - b.Log.Debugf("<= Message is %#v", rmsg) - b.Remote <- rmsg - } - time.Sleep(time.Second * 5) - } + msgmap := make(map[string]time.Time) + + // Record start time — we will ignore any message created before this moment + // that wasn't already captured in our initial seed. + startTime := time.Now() + + b.Log.Debug("getting initial messages") + res, err := b.getMessages(channelName) + if err != nil { + return err + } + + // Seed with existing messages — use newest timestamp to avoid re-delivery. + for _, msg := range res { + if msg.LastModifiedDateTime != nil { + msgmap[*msg.ID] = *msg.LastModifiedDateTime + } else { + msgmap[*msg.ID] = *msg.CreatedDateTime + } + } + + // repliesFetchedAt tracks when we last fetched replies per message. + // We only poll replies for messages younger than 24h. + repliesFetchedAt := make(map[string]time.Time) + + time.Sleep(time.Second * 2) + b.Log.Debug("polling for messages") + + for { + res, err := b.getMessages(channelName) + if err != nil { + return err + } + + now := time.Now() + + for i := len(res) - 1; i >= 0; i-- { + msg := res[i] + + // --- Top-level message --- + isNewOrChanged := true + if mtime, ok := msgmap[*msg.ID]; ok { + if mtime == *msg.CreatedDateTime && msg.LastModifiedDateTime == nil { + isNewOrChanged = false + } else if msg.LastModifiedDateTime != nil && mtime == *msg.LastModifiedDateTime { + isNewOrChanged = false + } + } else if msg.CreatedDateTime.Before(startTime) { + // Message existed before we started but wasn't in our seed + // (older than the ~20 messages getMessages returns). + // Seed it silently to prevent future re-delivery. + if msg.LastModifiedDateTime != nil { + msgmap[*msg.ID] = *msg.LastModifiedDateTime + } else { + msgmap[*msg.ID] = *msg.CreatedDateTime + } + isNewOrChanged = false + } + + if isNewOrChanged { + if b.GetBool("debug") { + b.Log.Debug("Msg dump: ", spew.Sdump(msg)) + } + + if msg.From == nil || msg.From.User == nil { + msgmap[*msg.ID] = *msg.CreatedDateTime + } else if expiry, wasUpdatedByUs := b.updatedIDs[*msg.ID]; wasUpdatedByUs && time.Now().Before(expiry) { + // We PATCHed this message — suppress echo, update msgmap silently. + b.Log.Debugf("skipping echo of our own edit for %s", *msg.ID) + if msg.LastModifiedDateTime != nil { + msgmap[*msg.ID] = *msg.LastModifiedDateTime + } else { + msgmap[*msg.ID] = *msg.CreatedDateTime + } + } else if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { + // We posted this message — suppress echo. + b.Log.Debug("skipping own message") + msgmap[*msg.ID] = *msg.CreatedDateTime + delete(b.sentIDs, *msg.ID) + } else { + // Check if this is a deletion. + isDelete := msg.DeletedDateTime != nil + isEdit := false + if !isDelete { + if _, alreadySeen := msgmap[*msg.ID]; alreadySeen { + isEdit = true + } + } + + msgmap[*msg.ID] = *msg.CreatedDateTime + if msg.LastModifiedDateTime != nil { + msgmap[*msg.ID] = *msg.LastModifiedDateTime + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account) + + text := b.convertToMD(*msg.Body.Content) + // Prepend subject if present (Teams thread subjects) + if msg.Subject != nil && *msg.Subject != "" { + text = "**" + *msg.Subject + "**\n" + text + } + event := "" + if isDelete { + event = config.EventMsgDelete + text = config.EventMsgDelete // gateway ignores empty text, use event as placeholder + } else if isEdit { + event = "msg_update" + } + rmsg := config.Message{ + Username: *msg.From.User.DisplayName, + Text: text, + Channel: channelName, + Account: b.Account, + UserID: *msg.From.User.ID, + ID: *msg.ID, + Event: event, + Avatar: b.GetString("IconURL"), + Extra: make(map[string][]interface{}), + } + b.handleAttachments(&rmsg, msg) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg + } + } + + // --- Replies: only for messages younger than 24h --- + msgAge := now.Sub(*msg.CreatedDateTime) + if msgAge >= 24*time.Hour { + continue + } + + lastFetch, fetched := repliesFetchedAt[*msg.ID] + if fetched && now.Sub(lastFetch) < 5*time.Second { + continue + } + _ = lastFetch + + replies, err := b.getReplies(channelName, *msg.ID) + if err != nil { + b.Log.Errorf("getting replies for %s failed: %s", *msg.ID, err) + continue + } + repliesFetchedAt[*msg.ID] = now + + for j := len(replies) - 1; j >= 0; j-- { + reply := replies[j] + key := *msg.ID + "/" + *reply.ID + + isReplyNewOrChanged := true + if mtime, ok := msgmap[key]; ok { + if mtime == *reply.CreatedDateTime && reply.LastModifiedDateTime == nil { + isReplyNewOrChanged = false + } else if reply.LastModifiedDateTime != nil && mtime == *reply.LastModifiedDateTime { + isReplyNewOrChanged = false + } + } else if reply.CreatedDateTime.Before(startTime) { + // Reply existed before startup — seed silently. + if reply.LastModifiedDateTime != nil { + msgmap[key] = *reply.LastModifiedDateTime + } else { + msgmap[key] = *reply.CreatedDateTime + } + isReplyNewOrChanged = false + } + + if !isReplyNewOrChanged { + continue + } + + if b.GetBool("debug") { + b.Log.Debug("Reply dump: ", spew.Sdump(reply)) + } + + if reply.From == nil || reply.From.User == nil { + msgmap[key] = *reply.CreatedDateTime + continue + } + + // Check if we PATCHed this reply (echo prevention with expiry). + if expiry, wasUpdatedByUs := b.updatedIDs[*reply.ID]; wasUpdatedByUs && time.Now().Before(expiry) { + b.Log.Debugf("skipping echo of our own reply edit for %s", *reply.ID) + if reply.LastModifiedDateTime != nil { + msgmap[key] = *reply.LastModifiedDateTime + } else { + msgmap[key] = *reply.CreatedDateTime + } + continue + } + + if _, wasSentByUs := b.sentIDs[*reply.ID]; wasSentByUs { + b.Log.Debug("skipping own reply") + msgmap[key] = *reply.CreatedDateTime + delete(b.sentIDs, *reply.ID) + continue + } + + isReplyDelete := reply.DeletedDateTime != nil + isReplyEdit := false + if !isReplyDelete { + if _, alreadySeen := msgmap[key]; alreadySeen { + isReplyEdit = true + } + } + + msgmap[key] = *reply.CreatedDateTime + if reply.LastModifiedDateTime != nil { + msgmap[key] = *reply.LastModifiedDateTime + } + + b.Log.Debugf("<= Sending reply from %s on %s to gateway", *reply.From.User.DisplayName, b.Account) + + text := b.convertToMD(*reply.Body.Content) + event := "" + if isReplyDelete { + event = config.EventMsgDelete + text = config.EventMsgDelete // gateway ignores empty text, use event as placeholder + } else if isReplyEdit { + event = "msg_update" + } + rrmsg := config.Message{ + Username: *reply.From.User.DisplayName, + Text: text, + Channel: channelName, + Account: b.Account, + UserID: *reply.From.User.ID, + ID: key, + ParentID: *msg.ID, + Event: event, + Avatar: b.GetString("IconURL"), + Extra: make(map[string][]interface{}), + } + b.handleAttachments(&rrmsg, reply) + b.Log.Debugf("<= Reply message is %#v", rrmsg) + b.Remote <- rrmsg + } + } + + time.Sleep(time.Second * 2) + } } func (b *Bmsteams) setBotID() error { - req := b.gc.Me().Request() - r, err := req.Get(b.ctx) - if err != nil { - return err - } - b.botID = *r.ID - return nil + req := b.gc.Me().Request() + r, err := req.Get(b.ctx) + if err != nil { + return err + } + b.botID = *r.ID + return nil } func (b *Bmsteams) convertToMD(text string) string { - if !strings.Contains(text, "
") { - return text - } - var sb strings.Builder - err := godown.Convert(&sb, strings.NewReader(text), nil) - if err != nil { - b.Log.Errorf("Couldn't convert message to markdown %s", text) - return text - } - return sb.String() + // Pre-process Teams-specific tags that godown doesn't understand. + + // Convert to just the alt text (the actual emoji character). + emojiRE := regexp.MustCompile(`]*\salt="([^"]*)"[^>]*>.*?`) + text = emojiRE.ReplaceAllString(text, "$1") + + // Convert ... to markdown fenced code blocks. + codeblockRE := regexp.MustCompile(`(?is)]*class="([^"]*)"[^>]*>]*>(.*?)`) + if codeblockRE.MatchString(text) { + parts := codeblockRE.FindStringSubmatch(text) + lang := strings.ToLower(parts[1]) + code := parts[2] + + // Replace
with newlines first (before stripping other tags). + code = regexp.MustCompile(`(?i)`).ReplaceAllString(code, "\n") + + // Replace block-level closing/opening tags with newlines. + code = regexp.MustCompile(`(?i)]*)?>`).ReplaceAllString(code, "\n") + + // Strip remaining HTML tags (syntax highlighting spans etc.) + code = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(code, "") + + // Decode HTML entities. + code = strings.ReplaceAll(code, "<", "<") + code = strings.ReplaceAll(code, ">", ">") + code = strings.ReplaceAll(code, "&", "&") + code = strings.ReplaceAll(code, " ", " ") + code = strings.ReplaceAll(code, " ", " ") + + // Replace non-breaking space (U+00A0) used by Teams as line separator. + code = strings.ReplaceAll(code, "\u00a0", "\n") + + // Collapse excessive newlines. + code = regexp.MustCompile(`\n{3,}`).ReplaceAllString(code, "\n\n") + code = strings.TrimSpace(code) + + replacement := "\n```" + lang + "\n" + code + "\n```\n" + text = codeblockRE.ReplaceAllLiteralString(text, replacement) + } + + // Strip empty paragraphs that Teams inserts around code blocks. + emptyParaRE := regexp.MustCompile(`(?i)]*>\s*( |\s)*

`) + text = emptyParaRE.ReplaceAllString(text, "") + + // If no HTML tags remain, return as-is (preserves codeblock newlines). + if !strings.ContainsAny(text, "<>") { + return strings.TrimSpace(text) + } + + // Convert remaining HTML to Markdown using godown. + var sb strings.Builder + err := godown.Convert(&sb, strings.NewReader(text), nil) + if err != nil { + b.Log.Errorf("Couldn't convert message to markdown: %s", err) + return text + } + + return strings.TrimSpace(sb.String()) } From 67a99e427ab34150f6ca458983fa669b5d7297a6 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 06:36:45 +0100 Subject: [PATCH 04/42] Add @matterbridge test command for end-to-end relay testing Posts a sequence of test messages (root, thread replies, code block, quote, emojis, formatting) with edit and delete steps when a user types "@matterbridge test" in either Mattermost or Teams. The trigger message is intercepted and not relayed; the test messages bypass echo prevention so they flow through the normal relay pipeline. Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/handlers.go | 7 ++ bridge/mattermost/helpers.go | 7 +- bridge/mattermost/test.go | 103 +++++++++++++++++ bridge/msteams/msteams.go | 9 ++ bridge/msteams/test.go | 200 ++++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 bridge/mattermost/test.go create mode 100644 bridge/msteams/test.go diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index 88e44757c0..b49dd5ca0b 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -144,6 +144,13 @@ func (b *Bmattermost) handleMatterClient(messages chan *config.Message) { } } + // Intercept test command before relaying. + if b.isTestCommand(rmsg.Text) { + b.Log.Info("Test command received, starting test sequence") + go b.runTestSequence(channelName) + continue + } + // Use nickname instead of username if defined if !b.GetBool("useusername") { if nick := b.mc.GetNickName(rmsg.UserID); nick != "" { diff --git a/bridge/mattermost/helpers.go b/bridge/mattermost/helpers.go index 8b7361c2f8..3dfa56907a 100644 --- a/bridge/mattermost/helpers.go +++ b/bridge/mattermost/helpers.go @@ -231,8 +231,11 @@ func (b *Bmattermost) skipMessage(message *matterclient.Message) bool { } } - // Ignore messages sent from a user logged in as the bot - if b.mc.User.Username == message.Username { + // Allow test messages from ourselves to be relayed (bypass echo prevention). + isTestMessage := message.Post.Props != nil && message.Post.Props["matterbridge_test"] != nil + + // Ignore messages sent from a user logged in as the bot (unless it's a test message). + if !isTestMessage && b.mc.User.Username == message.Username { b.Log.Debug("message from same user as bot, ignoring") return true } diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go new file mode 100644 index 0000000000..3d88df1a89 --- /dev/null +++ b/bridge/mattermost/test.go @@ -0,0 +1,103 @@ +package bmattermost + +import ( + "context" + "strings" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +// isTestCommand returns true if the message text is exactly "@matterbridge test". +func (b *Bmattermost) isTestCommand(text string) bool { + return strings.TrimSpace(strings.ToLower(text)) == "@matterbridge test" +} + +// runTestSequence posts a series of test messages to the given channel. +// The messages are posted via the API with a special "matterbridge_test" prop +// so that skipMessage() allows them through for relay to the other bridge side. +func (b *Bmattermost) runTestSequence(channelName string) { + channelID := b.getChannelID(channelName) + if channelID == "" { + b.Log.Errorf("test: could not resolve channel ID for %s", channelName) + return + } + + b.Log.Infof("test: starting test sequence in channel %s", channelName) + + testProps := model.StringInterface{"matterbridge_test": true} + + // Helper to post a message and return the post ID. + post := func(message, rootID string) string { + p := &model.Post{ + ChannelId: channelID, + Message: message, + RootId: rootID, + Props: testProps, + } + created, _, err := b.mc.Client.CreatePost(context.TODO(), p) + if err != nil { + b.Log.Errorf("test: CreatePost failed: %s", err) + return "" + } + return created.Id + } + + // Step 1: Root message + rootID := post("🧪 **Matterbridge Test Sequence**\nThis is a root message to test the bridge relay.", "") + if rootID == "" { + return + } + time.Sleep(time.Second) + + // Step 2: Thread reply + post("This is a thread reply to test threading support.", rootID) + time.Sleep(time.Second) + + // Step 3: Typo message (will be edited later) + typoID := post("this message contains a tipo", rootID) + time.Sleep(time.Second) + + // Step 4: Code block + post("```python\ndef hello():\n for i in range(3):\n print(f\"Hello from Matterbridge! ({i+1})\")\n\nhello()\n```", rootID) + time.Sleep(time.Second) + + // Step 5: Message to be deleted + deleteID := post("this message will be deleted", rootID) + time.Sleep(time.Second) + + // Step 6: Quote block + post("> This is a quoted line.\n> Matterbridge supports quote blocks.\n> Third line of the quote.", rootID) + time.Sleep(time.Second) + + // Step 7: Emojis + post(":thumbsup: :tada: :rocket: :heart: :eyes: :flag_at:", rootID) + time.Sleep(time.Second) + + // Step 8: Edit the typo message + if typoID != "" { + newText := "this message contained a typo" + _, _, err := b.mc.Client.PatchPost(context.TODO(), typoID, &model.PostPatch{Message: &newText}) + if err != nil { + b.Log.Errorf("test: PatchPost failed: %s", err) + } + } + time.Sleep(time.Second) + + // Step 9: Text formatting demo + post("**This text is bold**\n*This text is italic*\n~~This text is strikethrough~~\n### This is a heading\n[This is a link](https://github.com/matterbridge-org/matterbridge)", rootID) + time.Sleep(time.Second) + + // Step 10: Delete the marked message + if deleteID != "" { + _, err := b.mc.Client.DeletePost(context.TODO(), deleteID) + if err != nil { + b.Log.Errorf("test: DeletePost failed: %s", err) + } + } + + // Step 11: Test finished + post("✅ Test finished", rootID) + + b.Log.Info("test: test sequence completed") +} diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 0a5aca9541..cab3ed583e 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -612,6 +612,15 @@ func (b *Bmsteams) poll(channelName string) error { b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account) text := b.convertToMD(*msg.Body.Content) + + // Intercept test command (only for new messages, not edits/deletes). + if !isDelete && !isEdit && b.isTestCommand(text) { + b.Log.Info("Test command received, starting test sequence") + go b.runTestSequence(channelName) + // Don't relay the trigger message, but continue processing other messages. + continue + } + // Prepend subject if present (Teams thread subjects) if msg.Subject != nil && *msg.Subject != "" { text = "**" + *msg.Subject + "**\n" + text diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go new file mode 100644 index 0000000000..7a96e4f710 --- /dev/null +++ b/bridge/msteams/test.go @@ -0,0 +1,200 @@ +package bmsteams + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + msgraph "github.com/yaegashi/msgraph.go/beta" +) + +// isTestCommand returns true if the message text is exactly "@matterbridge test". +func (b *Bmsteams) isTestCommand(text string) bool { + return strings.TrimSpace(strings.ToLower(text)) == "@matterbridge test" +} + +// runTestSequence posts a series of test messages to the given channel. +// Messages are posted via the Graph API but NOT added to sentIDs/updatedIDs, +// so the poll loop picks them up and relays them to the other bridge side. +func (b *Bmsteams) runTestSequence(channelName string) { + teamID := b.GetString("TeamID") + channelID := decodeChannelID(channelName) + + b.Log.Infof("test: starting test sequence in channel %s", channelName) + + // Helper to post a top-level message and return its ID. + postRoot := func(text string, contentType *msgraph.BodyType) string { + ct := b.gc.Teams().ID(teamID).Channels().ID(channelID).Messages().Request() + content := &msgraph.ItemBody{Content: &text} + if contentType != nil { + content.ContentType = contentType + } + res, err := ct.Add(b.ctx, &msgraph.ChatMessage{Body: content}) + if err != nil { + b.Log.Errorf("test: post root failed: %s", err) + return "" + } + // Do NOT add to sentIDs — let poll() pick it up for relay. + return *res.ID + } + + // Helper to post a reply and return its ID. + postReply := func(rootID, text string, contentType *msgraph.BodyType) string { + ct := b.gc.Teams().ID(teamID).Channels().ID(channelID).Messages().ID(rootID).Replies().Request() + content := &msgraph.ItemBody{Content: &text} + if contentType != nil { + content.ContentType = contentType + } + res, err := ct.Add(b.ctx, &msgraph.ChatMessage{Body: content}) + if err != nil { + b.Log.Errorf("test: post reply failed: %s", err) + return "" + } + // Do NOT add to sentIDs — let poll() pick it up for relay. + return *res.ID + } + + // Helper to edit a reply without adding to updatedIDs. + editReply := func(rootID, replyID, newText string) { + type patchBody struct { + Body struct { + ContentType string `json:"contentType"` + Content string `json:"content"` + } `json:"body"` + } + var patch patchBody + patch.Body.ContentType = "text" + patch.Body.Content = newText + + jsonData, err := json.Marshal(patch) + if err != nil { + b.Log.Errorf("test: marshal failed: %s", err) + return + } + + url := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/replies/%s", + teamID, channelID, rootID, replyID) + + token, err := b.getAccessToken() + if err != nil { + b.Log.Errorf("test: getAccessToken failed: %s", err) + return + } + + req, err := http.NewRequestWithContext(b.ctx, http.MethodPatch, url, bytes.NewReader(jsonData)) + if err != nil { + b.Log.Errorf("test: NewRequest failed: %s", err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + b.Log.Errorf("test: PATCH failed: %s", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + b.Log.Errorf("test: edit reply failed: %d %s", resp.StatusCode, string(body)) + } + // Do NOT add to updatedIDs — let poll() pick up the edit for relay. + } + + // Helper to soft-delete a reply without adding to updatedIDs. + deleteReply := func(rootID, replyID string) { + url := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/replies/%s/softDelete", + teamID, channelID, rootID, replyID) + + token, err := b.getAccessToken() + if err != nil { + b.Log.Errorf("test: getAccessToken failed: %s", err) + return + } + + req, err := http.NewRequestWithContext(b.ctx, http.MethodPost, url, nil) + if err != nil { + b.Log.Errorf("test: NewRequest failed: %s", err) + return + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + b.Log.Errorf("test: softDelete failed: %s", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + b.Log.Errorf("test: delete reply failed: %d %s", resp.StatusCode, string(body)) + } + // Do NOT add to updatedIDs — let poll() pick up the delete for relay. + } + + htmlType := msgraph.BodyTypeVHTML + + // Step 1: Root message + rootID := postRoot("🧪 Matterbridge Test Sequence
This is a root message to test the bridge relay.", &htmlType) + if rootID == "" { + return + } + time.Sleep(time.Second) + + // Step 2: Thread reply + postReply(rootID, "This is a thread reply to test threading support.", nil) + time.Sleep(time.Second) + + // Step 3: Typo message (will be edited later) + typoID := postReply(rootID, "this message contains a tipo", nil) + time.Sleep(time.Second) + + // Step 4: Code block + codeHTML := `def hello():
for i in range(3):
print(f"Hello from Matterbridge! ({i+1})")

hello()
` + postReply(rootID, codeHTML, &htmlType) + time.Sleep(time.Second) + + // Step 5: Message to be deleted + deleteID := postReply(rootID, "this message will be deleted", nil) + time.Sleep(time.Second) + + // Step 6: Quote block + postReply(rootID, "
This is a quoted line.
Matterbridge supports quote blocks.
Third line of the quote.
", &htmlType) + time.Sleep(time.Second) + + // Step 7: Emojis + postReply(rootID, "👍 🎉 🚀 ❤️ 👀 🇦🇹", nil) + time.Sleep(time.Second) + + // Step 8: Edit the typo message + if typoID != "" { + editReply(rootID, typoID, "this message contained a typo") + } + time.Sleep(time.Second) + + // Step 9: Text formatting demo + formattingHTML := `This text is bold
` + + `This text is italic
` + + `This text is strikethrough
` + + `

This is a heading

` + + `This is a link` + postReply(rootID, formattingHTML, &htmlType) + time.Sleep(time.Second) + + // Step 10: Delete the marked message + if deleteID != "" { + deleteReply(rootID, deleteID) + } + + // Step 11: Test finished + postReply(rootID, "✅ Test finished", nil) + + b.Log.Info("test: test sequence completed") +} From a9553b0ca34a89589844249a15ecd3c2d7a26f4c Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 07:28:43 +0100 Subject: [PATCH 05/42] =?UTF-8?q?Fix=20MM=E2=86=92Teams=20formatting,=20em?= =?UTF-8?q?oji=20mapping,=20and=20strikethrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite mdToTeamsHTML() to use gomarkdown for full markdown→HTML conversion (bold, italic, strikethrough, headings, links, quotes, code fences, line breaks). Previously only code fences were converted. - Add HTML-aware RemoteNickFormat expansion: {NICK} renders as bold, \n as
. Gateway now passes original nick via msg.Extra["nick"]. - Add extensible emoji mapping with regex support (bridge/msteams/emoji.go). Converts Mattermost :flag-xx: to standard :flag_xx: format. - Fix strikethrough Teams→MM: pre-process // tags to ~~text~~ in convertToMD() before godown processing. - All messages to Teams now use HTML content type consistently. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/emoji.go | 34 ++++++++ bridge/msteams/msteams.go | 174 ++++++++++++++++++++------------------ gateway/gateway.go | 8 ++ 3 files changed, 135 insertions(+), 81 deletions(-) create mode 100644 bridge/msteams/emoji.go diff --git a/bridge/msteams/emoji.go b/bridge/msteams/emoji.go new file mode 100644 index 0000000000..230c539221 --- /dev/null +++ b/bridge/msteams/emoji.go @@ -0,0 +1,34 @@ +package bmsteams + +import ( + "regexp" + + "github.com/kyokomi/emoji/v2" +) + +// emojiMapping defines a regex-based emoji name replacement rule. +// This allows mapping platform-specific emoji shortcodes to a canonical form +// that the emoji library can resolve to unicode. +type emojiMapping struct { + pattern *regexp.Regexp + replace string +} + +// emojiMappings contains all emoji name conversions. +// Add new entries here to handle additional platform differences. +var emojiMappings = []emojiMapping{ + // Mattermost flag emojis use hyphens (:flag-at:), standard format uses underscores (:flag_at:). + {regexp.MustCompile(`:flag-([a-z]{2}):`), ":flag_$1:"}, +} + +// mapEmojis applies all emoji name mappings and then converts any resulting +// shortcodes to unicode. This catches platform-specific shortcodes that +// the gateway's initial emoji.Sprint() pass could not resolve. +func mapEmojis(text string) string { + for _, m := range emojiMappings { + text = m.pattern.ReplaceAllString(text, m.replace) + } + // Re-run emoji sprint for any newly mapped shortcodes. + emoji.ReplacePadding = "" + return emoji.Sprint(text) +} diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index cab3ed583e..1f0025da4b 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -18,6 +18,9 @@ import ( "github.com/matterbridge-org/matterbridge/bridge" "github.com/matterbridge-org/matterbridge/bridge/config" "github.com/davecgh/go-spew/spew" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" "github.com/mattn/godown" msgraph "github.com/yaegashi/msgraph.go/beta" "github.com/yaegashi/msgraph.go/msauth" @@ -151,13 +154,14 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { } ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() - text := formatMessageText(msg.Username, msg.Text) - htmlText, isHTML := mdToTeamsHTML(text) - content := &msgraph.ItemBody{Content: &text} - if isHTML { - htmlType := msgraph.BodyTypeVHTML - content = &msgraph.ItemBody{Content: &htmlText, ContentType: &htmlType} - } + + // Apply emoji mapping for any platform-specific shortcodes. + msg.Text = mapEmojis(msg.Text) + + // Convert markdown to Teams HTML and prepend formatted username. + htmlText := b.formatMessageHTML(msg, mdToTeamsHTML(msg.Text)) + htmlType := msgraph.BodyTypeVHTML + content := &msgraph.ItemBody{Content: &htmlText, ContentType: &htmlType} rmsg := &msgraph.ChatMessage{Body: content} res, err := ct.Add(b.ctx, rmsg) @@ -168,74 +172,74 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { return *res.ID, nil } -// formatMessageText combines the username prefix with the message text. -// If the text starts with a block element (quote, code fence, list), -// the username is placed on its own line to avoid formatting corruption. -func formatMessageText(username, text string) string { - if username == "" { - return text - } - trimmed := strings.TrimSpace(text) - if strings.HasPrefix(trimmed, ">") || - strings.HasPrefix(trimmed, "```") || - strings.HasPrefix(trimmed, " ") { - return username + "\n" + trimmed - } - return username + text +// mdToTeamsHTML converts markdown text to Teams-compatible HTML. +// Handles bold, italic, strikethrough, headings, links, blockquotes, +// code fences, and line breaks using the gomarkdown library. +// Code fences are post-processed to use Teams-native tags. +func mdToTeamsHTML(text string) string { + extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode | parser.Strikethrough + p := parser.NewWithExtensions(extensions) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: 0}) + result := string(markdown.ToHTML([]byte(text), p, renderer)) + + // Post-process: convert gomarkdown's
 to Teams .
+        preCodeLangRE := regexp.MustCompile(`
`)
+        result = preCodeLangRE.ReplaceAllString(result, ``)
+        result = strings.ReplaceAll(result, "
", "
") + result = strings.ReplaceAll(result, "
", "")
+
+        return strings.TrimSpace(result)
+}
+
+// htmlEscape escapes HTML special characters in a string.
+func htmlEscape(s string) string {
+        s = strings.ReplaceAll(s, "&", "&")
+        s = strings.ReplaceAll(s, "<", "<")
+        s = strings.ReplaceAll(s, ">", ">")
+        s = strings.ReplaceAll(s, "\"", """)
+        return s
 }
 
-// mdToTeamsHTML converts a message that may contain markdown code fences
-// into Teams-compatible HTML so that code blocks render correctly.
-// If the text has no code fences, it returns the text with newlines
-// converted to 
and sets needsHTML=true only when conversion happened. -func mdToTeamsHTML(text string) (string, bool) { - if !strings.Contains(text, "```") { - return text, false +// extractBridgeName returns the bridge name part from an account string like "mattermost.mybot". +func extractBridgeName(account string) string { + parts := strings.SplitN(account, ".", 2) + if len(parts) > 1 { + return parts[1] } + return account +} - codeFenceRE := regexp.MustCompile("(?s)```(\\w*)\\n(.*?)\\n?```") +// formatMessageHTML builds an HTML username prefix from the RemoteNickFormat template. +// It replaces {NICK} with nick, \n with
, and expands other placeholders. +func (b *Bmsteams) formatMessageHTML(msg config.Message, bodyHTML string) string { + template := b.GetString("RemoteNickFormat") + if template == "" { + return bodyHTML + } - // Convert code fences to Teams-native HTML format for syntax highlighting. - // Teams uses ... internally. - result := codeFenceRE.ReplaceAllStringFunc(text, func(match string) string { - parts := codeFenceRE.FindStringSubmatch(match) - if len(parts) < 3 { - return match - } - lang := parts[1] - code := parts[2] - // Escape HTML entities in code content. - code = strings.ReplaceAll(code, "&", "&") - code = strings.ReplaceAll(code, "<", "<") - code = strings.ReplaceAll(code, ">", ">") - // Convert newlines to
inside code for Teams rendering. - code = strings.ReplaceAll(code, "\n", "
") - if lang != "" { - return `` + code + "" - } - return "" + code + "" - }) - - // Convert remaining newlines to
for non-code text. - // Split by codeblock/pre blocks to avoid adding
inside code. - codeSplitRE := regexp.MustCompile(`(?s)(]*>.*?
)`) - textParts := codeSplitRE.Split(result, -1) - codeParts := codeSplitRE.FindAllString(result, -1) - - var final strings.Builder - for i, part := range textParts { - // Escape HTML in non-code parts and convert newlines. - part = strings.ReplaceAll(part, "&", "&") - part = strings.ReplaceAll(part, "<", "<") - part = strings.ReplaceAll(part, ">", ">") - part = strings.ReplaceAll(part, "\n", "
") - final.WriteString(part) - if i < len(codeParts) { - final.WriteString(codeParts[i]) + // Extract original nick from Extra (set by gateway). + originalNick := "" + if nicks, ok := msg.Extra["nick"]; ok && len(nicks) > 0 { + if n, ok := nicks[0].(string); ok { + originalNick = n } } + if originalNick == "" { + originalNick = strings.TrimSpace(msg.Username) + } + + // HTML-aware expansion. + result := template + result = strings.ReplaceAll(result, "{NICK}", ""+htmlEscape(originalNick)+"") + result = strings.ReplaceAll(result, "{NOPINGNICK}", ""+htmlEscape(originalNick)+"") + result = strings.ReplaceAll(result, "{PROTOCOL}", htmlEscape(msg.Protocol)) + result = strings.ReplaceAll(result, "{BRIDGE}", htmlEscape(extractBridgeName(msg.Account))) + result = strings.ReplaceAll(result, "{GATEWAY}", htmlEscape(msg.Gateway)) + result = strings.ReplaceAll(result, "{USERID}", htmlEscape(msg.UserID)) + result = strings.ReplaceAll(result, "{CHANNEL}", htmlEscape(msg.Channel)) + result = strings.ReplaceAll(result, "\n", "
") - return final.String(), true + return result + bodyHTML } // getAccessToken returns a fresh access token from the token source. @@ -251,7 +255,9 @@ func (b *Bmsteams) getAccessToken() (string, error) { // The Teams Graph API only allows the original sender to update via delegated perms, // so this may fail if matterbridge is not authenticated as the message author. func (b *Bmsteams) updateMessage(msg config.Message) (string, error) { - text := formatMessageText(msg.Username, msg.Text) + // Apply emoji mapping and convert markdown to Teams HTML. + msg.Text = mapEmojis(msg.Text) + htmlText := b.formatMessageHTML(msg, mdToTeamsHTML(msg.Text)) type patchBody struct { Body struct { @@ -261,8 +267,8 @@ func (b *Bmsteams) updateMessage(msg config.Message) (string, error) { } var patch patchBody - patch.Body.ContentType = "text" - patch.Body.Content = text + patch.Body.ContentType = "html" + patch.Body.Content = htmlText jsonData, err := json.Marshal(patch) if err != nil { @@ -424,20 +430,22 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) err } } + usernameHTML := b.formatMessageHTML(msg, "") + switch { case fileURL != "" && isImage: bodyText = fmt.Sprintf( - `%s
%s`, - msg.Username, fileURL, fi.Name, + `%s%s`, + usernameHTML, fileURL, fi.Name, ) case fileURL != "": bodyText = fmt.Sprintf( `%s📎 %s`, - msg.Username, fileURL, fi.Name, + usernameHTML, fileURL, fi.Name, ) default: b.Log.Debugf("cannot embed file %s in Teams: configure MediaServerUpload to enable image transfer", fi.Name) - notice := fmt.Sprintf("%s[Datei: %s — konfiguriere MediaServerUpload für Dateiübertragung]", msg.Username, fi.Name) + notice := fmt.Sprintf("%s[Datei: %s — konfiguriere MediaServerUpload für Dateiübertragung]", usernameHTML, fi.Name) ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() htmlType := msgraph.BodyTypeVHTML content := &msgraph.ItemBody{Content: ¬ice, ContentType: &htmlType} @@ -467,13 +475,13 @@ func (b *Bmsteams) sendReply(msg config.Message) (string, error) { b.Log.Debugf("sendReply: ParentID=%s Channel=%s", msg.ParentID, channelID) ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(channelID).Messages().ID(msg.ParentID).Replies().Request() - text := formatMessageText(msg.Username, msg.Text) - htmlText, isHTML := mdToTeamsHTML(text) - content := &msgraph.ItemBody{Content: &text} - if isHTML { - htmlType := msgraph.BodyTypeVHTML - content = &msgraph.ItemBody{Content: &htmlText, ContentType: &htmlType} - } + // Apply emoji mapping for any platform-specific shortcodes. + msg.Text = mapEmojis(msg.Text) + + // Convert markdown to Teams HTML and prepend formatted username. + htmlText := b.formatMessageHTML(msg, mdToTeamsHTML(msg.Text)) + htmlType := msgraph.BodyTypeVHTML + content := &msgraph.ItemBody{Content: &htmlText, ContentType: &htmlType} rmsg := &msgraph.ChatMessage{Body: content} res, err := ct.Add(b.ctx, rmsg) @@ -816,6 +824,10 @@ func (b *Bmsteams) convertToMD(text string) string { text = codeblockRE.ReplaceAllLiteralString(text, replacement) } + // Convert strikethrough HTML tags to markdown before godown (godown may not handle these). + strikeRE := regexp.MustCompile(`(?is)<(s|del|strike)>(.*?)`) + text = strikeRE.ReplaceAllString(text, "~~$2~~") + // Strip empty paragraphs that Teams inserts around code blocks. emptyParaRE := regexp.MustCompile(`(?i)]*>\s*( |\s)*

`) text = emptyParaRE.ReplaceAllString(text, "") diff --git a/gateway/gateway.go b/gateway/gateway.go index 7ca84e4f49..617d351cbd 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -476,6 +476,14 @@ func (gw *Gateway) SendMessage( msg.Channel = channel.Name msg.Avatar = gw.modifyAvatar(rmsg, dest) + + // Store original nick before RemoteNickFormat expansion, so bridges + // that need HTML-aware formatting can access it. + if msg.Extra == nil { + msg.Extra = make(map[string][]interface{}) + } + msg.Extra["nick"] = []interface{}{rmsg.Username} + msg.Username = gw.modifyUsername(rmsg, dest) // exclude file delete event as the msg ID here is the native file ID that needs to be deleted From 312897efb6c68cfef8eb7a0eeacd121d55bdd6a6 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 07:43:40 +0100 Subject: [PATCH 06/42] Fix emoji format, strikethrough, and add nick debug logging - Use :flag-at: (hyphen) in Mattermost test instead of :flag_at: (underscore) - Convert gomarkdown's to in mdToTeamsHTML for Teams strikethrough - Add debug logging for nick resolution in Send() to troubleshoot RemoteNickFormat Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/test.go | 2 +- bridge/msteams/msteams.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 3d88df1a89..00edbffc27 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -71,7 +71,7 @@ func (b *Bmattermost) runTestSequence(channelName string) { time.Sleep(time.Second) // Step 7: Emojis - post(":thumbsup: :tada: :rocket: :heart: :eyes: :flag_at:", rootID) + post(":thumbsup: :tada: :rocket: :heart: :eyes: :flag-at:", rootID) time.Sleep(time.Second) // Step 8: Edit the typo message diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 1f0025da4b..cce79094db 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -112,6 +112,13 @@ func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { func (b *Bmsteams) Send(msg config.Message) (string, error) { b.Log.Debugf("=> Receiving %#v", msg) + // Debug: log nick resolution for troubleshooting RemoteNickFormat. + if nicks, ok := msg.Extra["nick"]; ok && len(nicks) > 0 { + b.Log.Debugf("nick from Extra: %v, msg.Username: %s", nicks[0], msg.Username) + } else { + b.Log.Debugf("no nick in Extra, msg.Username: %s", msg.Username) + } + // Handle deletes from Mattermost → Teams. if msg.Event == config.EventMsgDelete && msg.ID != "" { b.Log.Debugf("delete: soft-deleting Teams message ID %s", msg.ID) @@ -188,6 +195,11 @@ func mdToTeamsHTML(text string) string { result = strings.ReplaceAll(result, "
", "") result = strings.ReplaceAll(result, "
", "")
 
+        // Post-process: convert gomarkdown's  to  for Teams strikethrough support.
+        // Teams renders  but not .
+        result = strings.ReplaceAll(result, "", "")
+        result = strings.ReplaceAll(result, "", "")
+
         return strings.TrimSpace(result)
 }
 

From 5f1fa5a515bba7aee10a09f4e639026513e7f5a1 Mon Sep 17 00:00:00 2001
From: Alexander Griesser 
Date: Thu, 12 Mar 2026 08:24:50 +0100
Subject: [PATCH 07/42] =?UTF-8?q?Add=20list=20test=20steps=20and=20hostedC?=
 =?UTF-8?q?ontents=20image=20transfer=20for=20MM=E2=86=92Teams?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add ordered and unordered list test messages to both Mattermost and
  MSTeams test sequences (@matterbridge test)
- Implement sendImageHostedContent() using Graph API hostedContents to
  embed images directly as base64 in Teams messages (no external media
  server required)
- Update sendFileAsMessage() to prefer hostedContents for images with
  binary data, falling back to URL-based embedding

Co-Authored-By: Claude Opus 4.6 
---
 bridge/mattermost/test.go |  12 ++++-
 bridge/msteams/msteams.go | 103 ++++++++++++++++++++++++++++++++++++--
 bridge/msteams/test.go    |  12 ++++-
 3 files changed, 120 insertions(+), 7 deletions(-)

diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go
index 00edbffc27..840d1f66f7 100644
--- a/bridge/mattermost/test.go
+++ b/bridge/mattermost/test.go
@@ -88,7 +88,15 @@ func (b *Bmattermost) runTestSequence(channelName string) {
 	post("**This text is bold**\n*This text is italic*\n~~This text is strikethrough~~\n### This is a heading\n[This is a link](https://github.com/matterbridge-org/matterbridge)", rootID)
 	time.Sleep(time.Second)
 
-	// Step 10: Delete the marked message
+	// Step 10: Unordered list
+	post("- Item eins\n- Item zwei\n- Item drei", rootID)
+	time.Sleep(time.Second)
+
+	// Step 11: Ordered list
+	post("1. Erster Punkt\n2. Zweiter Punkt\n3. Dritter Punkt", rootID)
+	time.Sleep(time.Second)
+
+	// Step 12: Delete the marked message
 	if deleteID != "" {
 		_, err := b.mc.Client.DeletePost(context.TODO(), deleteID)
 		if err != nil {
@@ -96,7 +104,7 @@ func (b *Bmattermost) runTestSequence(channelName string) {
 		}
 	}
 
-	// Step 11: Test finished
+	// Step 13: Test finished
 	post("✅ Test finished", rootID)
 
 	b.Log.Info("test: test sequence completed")
diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go
index cce79094db..2a57eb174f 100644
--- a/bridge/msteams/msteams.go
+++ b/bridge/msteams/msteams.go
@@ -3,6 +3,7 @@ package bmsteams
 import (
         "bytes"
         "context"
+        "encoding/base64"
         "encoding/json"
         "fmt"
         "io"
@@ -427,10 +428,106 @@ func isImageFile(name string) bool {
         return mimeTypeForFile(name) != ""
 }
 
+// sendImageHostedContent sends an image as a Teams message using the hostedContents API.
+// The image data is base64-encoded and embedded directly in the message, so no external
+// server or public URL is required. Only works for image files.
+func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo) error {
+        mimeType := mimeTypeForFile(fi.Name)
+        if mimeType == "" || fi.Data == nil {
+                return fmt.Errorf("sendImageHostedContent requires image file with data")
+        }
+
+        usernameHTML := b.formatMessageHTML(msg, "")
+        bodyHTML := fmt.Sprintf(
+                `%s%s`,
+                usernameHTML, fi.Name,
+        )
+
+        type hostedContent struct {
+                TempID       string `json:"@microsoft.graph.temporaryId"`
+                ContentBytes string `json:"contentBytes"`
+                ContentType  string `json:"contentType"`
+        }
+        type msgBody struct {
+                ContentType string `json:"contentType"`
+                Content     string `json:"content"`
+        }
+        type graphMessage struct {
+                Body           msgBody          `json:"body"`
+                HostedContents []hostedContent  `json:"hostedContents"`
+        }
+
+        payload := graphMessage{
+                Body: msgBody{
+                        ContentType: "html",
+                        Content:     bodyHTML,
+                },
+                HostedContents: []hostedContent{
+                        {
+                                TempID:       "1",
+                                ContentBytes: base64.StdEncoding.EncodeToString(*fi.Data),
+                                ContentType:  mimeType,
+                        },
+                },
+        }
+
+        jsonData, err := json.Marshal(payload)
+        if err != nil {
+                return err
+        }
+
+        teamID := b.GetString("TeamID")
+        channelID := decodeChannelID(msg.Channel)
+
+        var apiURL string
+        if msg.ParentValid() {
+                apiURL = fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/replies",
+                        teamID, channelID, msg.ParentID)
+        } else {
+                apiURL = fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages",
+                        teamID, channelID)
+        }
+
+        token, err := b.getAccessToken()
+        if err != nil {
+                return err
+        }
+
+        req, err := http.NewRequestWithContext(b.ctx, http.MethodPost, apiURL, bytes.NewReader(jsonData))
+        if err != nil {
+                return err
+        }
+        req.Header.Set("Content-Type", "application/json")
+        req.Header.Set("Authorization", "Bearer "+token)
+
+        resp, err := http.DefaultClient.Do(req)
+        if err != nil {
+                return err
+        }
+        defer resp.Body.Close()
+
+        if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+                body, _ := io.ReadAll(resp.Body)
+                return fmt.Errorf("sendImageHostedContent failed: %d %s", resp.StatusCode, string(body))
+        }
+
+        return nil
+}
+
 func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) error {
+        isImage := isImageFile(fi.Name)
+
+        // Prefer hostedContents for images with binary data (no external server needed).
+        if isImage && fi.Data != nil {
+                if err := b.sendImageHostedContent(msg, fi); err != nil {
+                        b.Log.Debugf("hostedContents failed for %s, falling back: %s", fi.Name, err)
+                } else {
+                        return nil
+                }
+        }
+
         contentType := msgraph.BodyTypeVHTML
         var bodyText string
-        isImage := isImageFile(fi.Name)
 
         fileURL := fi.URL
         if fileURL == "" && fi.Data != nil {
@@ -456,8 +553,8 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) err
                         usernameHTML, fileURL, fi.Name,
                 )
         default:
-                b.Log.Debugf("cannot embed file %s in Teams: configure MediaServerUpload to enable image transfer", fi.Name)
-                notice := fmt.Sprintf("%s[Datei: %s — konfiguriere MediaServerUpload für Dateiübertragung]", usernameHTML, fi.Name)
+                b.Log.Debugf("cannot send file %s to Teams: no URL and hostedContents failed", fi.Name)
+                notice := fmt.Sprintf("%s[Datei: %s — Dateiübertragung nicht möglich]", usernameHTML, fi.Name)
                 ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request()
                 htmlType := msgraph.BodyTypeVHTML
                 content := &msgraph.ItemBody{Content: ¬ice, ContentType: &htmlType}
diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go
index 7a96e4f710..d3af3a0b98 100644
--- a/bridge/msteams/test.go
+++ b/bridge/msteams/test.go
@@ -188,12 +188,20 @@ func (b *Bmsteams) runTestSequence(channelName string) {
 	postReply(rootID, formattingHTML, &htmlType)
 	time.Sleep(time.Second)
 
-	// Step 10: Delete the marked message
+	// Step 10: Unordered list
+	postReply(rootID, "
  • Item eins
  • Item zwei
  • Item drei
", &htmlType) + time.Sleep(time.Second) + + // Step 11: Ordered list + postReply(rootID, "
  1. Erster Punkt
  2. Zweiter Punkt
  3. Dritter Punkt
", &htmlType) + time.Sleep(time.Second) + + // Step 12: Delete the marked message if deleteID != "" { deleteReply(rootID, deleteID) } - // Step 11: Test finished + // Step 13: Test finished postReply(rootID, "✅ Test finished", nil) b.Log.Info("test: test sequence completed") From db9870d301153a90b7f0a49b3fe40fb91b666359 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 08:47:33 +0100 Subject: [PATCH 08/42] =?UTF-8?q?Fix=20Teams=E2=86=92MM=20image=20transfer?= =?UTF-8?q?:=20strip=20hostedContents,=20webhook=20props,=20hybrid=20uploa?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip tags referencing hostedContents URLs in convertToMD() to prevent broken markdown images (these are Teams-internal auth-required URLs) - In hybrid mode, upload files via API before sending text via webhook, fixing the issue where file uploads were skipped for top-level messages - Use CreatePost with webhook-like props (override_username, override_icon_url) in handleUploadFile so file messages show the bridged user's identity - Add bold formatting for PrefixMessagesWithNick in handleUploadFile - TrimSpace on username to remove trailing newlines from RemoteNickFormat Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/handlers.go | 38 ++++++++++++++++++++++++++------- bridge/mattermost/mattermost.go | 15 +++++++++++++ bridge/msteams/msteams.go | 7 ++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index b49dd5ca0b..c08f848046 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -2,6 +2,7 @@ package bmattermost import ( "context" + "strings" "github.com/matterbridge-org/matterbridge/bridge/config" "github.com/matterbridge-org/matterbridge/bridge/helper" @@ -178,19 +179,40 @@ func (b *Bmattermost) handleMatterHook(messages chan *config.Message) { func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) { var err error - var res, id string + var res string channelID := b.getChannelID(msg.Channel) for _, f := range msg.Extra["file"] { fi := f.(config.FileInfo) - id, err = b.mc.UploadFile(*fi.Data, channelID, fi.Name) - if err != nil { - return "", err + fileID, uploadErr := b.mc.UploadFile(*fi.Data, channelID, fi.Name) + if uploadErr != nil { + return "", uploadErr } - msg.Text = fi.Comment + text := fi.Comment if b.GetBool("PrefixMessagesWithNick") { - msg.Text = msg.Username + msg.Text - } - res, err = b.mc.PostMessageWithFiles(channelID, msg.Text, msg.ParentID, []string{id}) + text = "**" + strings.TrimSpace(msg.Username) + "**\n" + text + } + + // Build a post with webhook-like props so the message appears with the + // bridged user's name and avatar instead of the bot's identity. + post := &model.Post{ + ChannelId: channelID, + Message: text, + RootId: msg.ParentID, + FileIds: []string{fileID}, + Props: model.StringInterface{ + "from_webhook": "true", + "override_username": strings.TrimSpace(msg.Username), + "matterbridge_" + b.uuid: true, + }, + } + if msg.Avatar != "" { + post.Props["override_icon_url"] = msg.Avatar + } + created, _, createErr := b.mc.Client.CreatePost(context.TODO(), post) + if createErr != nil { + return "", createErr + } + res = created.Id } return res, err } diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index f53301c6a8..964302b070 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -196,6 +196,21 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { isDelete := msg.Event == config.EventMsgDelete if !isReply && !isEdit && !isDelete { + // Top-level new message with files → upload files via API first, + // then send any remaining text via webhook. Webhooks can't upload + // binary files, so we need the API path for actual file uploads. + if msg.Extra != nil && len(msg.Extra["file"]) > 0 && b.mc != nil { + if _, err := b.handleUploadFile(&msg); err != nil { + b.Log.Errorf("handleUploadFile failed: %s", err) + } + // If there's no remaining text, we're done. + if strings.TrimSpace(msg.Text) == "" { + return "", nil + } + // Clear the files so sendWebhook doesn't append URLs again. + delete(msg.Extra, "file") + } + // Top-level new message → use webhook (username/avatar override). // Remember the original text for post-ID lookup. originalText := msg.Text diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 2a57eb174f..86ef44ce0f 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -933,6 +933,13 @@ func (b *Bmsteams) convertToMD(text string) string { text = codeblockRE.ReplaceAllLiteralString(text, replacement) } + // Strip inline tags that reference hostedContents URLs — these are + // Teams-internal image URLs that require authentication and would produce + // broken markdown like ![image](https://graph.microsoft.com/.../hostedContents/.../$value). + // The actual image data is handled separately via handleAttachments(). + hostedImgRE := regexp.MustCompile(`(?i)]*src="[^"]*hostedContents[^"]*"[^>]*/?>`) + text = hostedImgRE.ReplaceAllString(text, "") + // Convert strikethrough HTML tags to markdown before godown (godown may not handle these). strikeRE := regexp.MustCompile(`(?is)<(s|del|strike)>(.*?)`) text = strikeRE.ReplaceAllString(text, "~~$2~~") From af7162b3848126b583ac5bdc58ac2b225b010850 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 09:02:39 +0100 Subject: [PATCH 09/42] =?UTF-8?q?Fix=20echo,=20mixed=20content,=20and=20GI?= =?UTF-8?q?F=20handling=20for=20MM=E2=86=92Teams=20file=20transfer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track message IDs from sendImageHostedContent and sendFileAsMessage in sentIDs to prevent echo (file messages being relayed back to Mattermost) - Combine text + image into a single Teams message via captionHTML parameter instead of posting them as two separate messages - Only use hostedContents for JPG/PNG (the only types Microsoft supports); skip unsupported types like GIF/WEBP/BMP silently with a log warning instead of posting error messages to Teams - Return (string, error) from sendFileAsMessage and sendImageHostedContent for proper ID tracking Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 130 ++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 42 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 86ef44ce0f..a2b693101f 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -136,19 +136,30 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { } // Handle file/image attachments. - if msg.Extra != nil { - for _, files := range msg.Extra["file"] { + if msg.Extra != nil && len(msg.Extra["file"]) > 0 { + // For the first file, include msg.Text as a caption so that + // mixed content (text + image) arrives as a single Teams message. + captionHTML := "" + if msg.Text != "" { + captionText := mapEmojis(msg.Text) + captionHTML = mdToTeamsHTML(captionText) + } + + for i, files := range msg.Extra["file"] { fi, ok := files.(config.FileInfo) if !ok { continue } - if err := b.sendFileAsMessage(msg, fi); err != nil { - b.Log.Errorf("sending file %s failed: %s", fi.Name, err) + caption := "" + if i == 0 { + caption = captionHTML + } + if _, err := b.sendFileAsMessage(msg, fi, caption); err != nil { + b.Log.Warnf("sending file %s: %s", fi.Name, err) } } - if msg.Text == "" && len(msg.Extra["file"]) > 0 { - return "", nil - } + // Text was included in the first file message, so don't send it again. + return "", nil } if msg.ParentValid() { @@ -428,19 +439,31 @@ func isImageFile(name string) bool { return mimeTypeForFile(name) != "" } +// isSupportedHostedContentType returns true if the file type can be embedded +// via the Graph API hostedContents endpoint. Only JPG and PNG are supported. +func isSupportedHostedContentType(name string) bool { + mime := mimeTypeForFile(name) + return mime == "image/jpeg" || mime == "image/png" +} + // sendImageHostedContent sends an image as a Teams message using the hostedContents API. // The image data is base64-encoded and embedded directly in the message, so no external -// server or public URL is required. Only works for image files. -func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo) error { +// server or public URL is required. Only works for JPG/PNG files. +// The captionHTML parameter allows including additional text (e.g. msg.Text) in the same message. +func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo, captionHTML string) (string, error) { mimeType := mimeTypeForFile(fi.Name) if mimeType == "" || fi.Data == nil { - return fmt.Errorf("sendImageHostedContent requires image file with data") + return "", fmt.Errorf("sendImageHostedContent requires image file with data") } usernameHTML := b.formatMessageHTML(msg, "") - bodyHTML := fmt.Sprintf( - `%s%s`, - usernameHTML, fi.Name, + bodyHTML := usernameHTML + if captionHTML != "" { + bodyHTML += captionHTML + "
" + } + bodyHTML += fmt.Sprintf( + `%s`, + fi.Name, ) type hostedContent struct { @@ -473,7 +496,7 @@ func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo jsonData, err := json.Marshal(payload) if err != nil { - return err + return "", err } teamID := b.GetString("TeamID") @@ -490,39 +513,52 @@ func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo token, err := b.getAccessToken() if err != nil { - return err + return "", err } req, err := http.NewRequestWithContext(b.ctx, http.MethodPost, apiURL, bytes.NewReader(jsonData)) if err != nil { - return err + return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { - return err + return "", err } defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("sendImageHostedContent failed: %d %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("sendImageHostedContent failed: %d %s", resp.StatusCode, string(respBody)) } - return nil + // Parse the response to extract the message ID for echo prevention. + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal(respBody, &result); err == nil && result.ID != "" { + b.sentIDs[result.ID] = struct{}{} + return result.ID, nil + } + return "", nil } -func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) error { +// sendFileAsMessage sends a file as a Teams message. The captionHTML parameter +// allows including additional text (converted from msg.Text) in the same message +// so that text+image posts arrive as a single message instead of two. +func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, captionHTML string) (string, error) { isImage := isImageFile(fi.Name) - // Prefer hostedContents for images with binary data (no external server needed). - if isImage && fi.Data != nil { - if err := b.sendImageHostedContent(msg, fi); err != nil { + // Prefer hostedContents for supported image types with binary data. + if isImage && fi.Data != nil && isSupportedHostedContentType(fi.Name) { + id, err := b.sendImageHostedContent(msg, fi, captionHTML) + if err != nil { b.Log.Debugf("hostedContents failed for %s, falling back: %s", fi.Name, err) } else { - return nil + return id, nil } } @@ -540,26 +576,28 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) err } usernameHTML := b.formatMessageHTML(msg, "") + captionPart := "" + if captionHTML != "" { + captionPart = captionHTML + "
" + } switch { case fileURL != "" && isImage: bodyText = fmt.Sprintf( - `%s%s`, - usernameHTML, fileURL, fi.Name, + `%s%s%s`, + usernameHTML, captionPart, fileURL, fi.Name, ) case fileURL != "": bodyText = fmt.Sprintf( - `%s📎 %s`, - usernameHTML, fileURL, fi.Name, + `%s%s📎 %s`, + usernameHTML, captionPart, fileURL, fi.Name, ) default: - b.Log.Debugf("cannot send file %s to Teams: no URL and hostedContents failed", fi.Name) - notice := fmt.Sprintf("%s[Datei: %s — Dateiübertragung nicht möglich]", usernameHTML, fi.Name) - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() - htmlType := msgraph.BodyTypeVHTML - content := &msgraph.ItemBody{Content: ¬ice, ContentType: &htmlType} - _, err := ct.Add(b.ctx, &msgraph.ChatMessage{Body: content}) - return err + // Don't post error messages to Teams for unsupported file types. + // Just log and return an error so the caller can handle it. + b.Log.Warnf("cannot send file %s (%s) to Teams: type not supported by hostedContents and no MediaServerUpload configured", + fi.Name, mimeTypeForFile(fi.Name)) + return "", fmt.Errorf("file type not supported: %s", fi.Name) } content := &msgraph.ItemBody{ @@ -568,15 +606,23 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo) err } chatMsg := &msgraph.ChatMessage{Body: content} + var res *msgraph.ChatMessage + var err error if msg.ParentValid() { ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().ID(msg.ParentID).Replies().Request() - _, err := ct.Add(b.ctx, chatMsg) - return err + res, err = ct.Add(b.ctx, chatMsg) + } else { + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() + res, err = ct.Add(b.ctx, chatMsg) } - - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() - _, err := ct.Add(b.ctx, chatMsg) - return err + if err != nil { + return "", err + } + if res != nil && res.ID != nil { + b.sentIDs[*res.ID] = struct{}{} + return *res.ID, nil + } + return "", nil } func (b *Bmsteams) sendReply(msg config.Message) (string, error) { From fcf5afa54ef460b301f67fd5ca7ae8964b2b59a5 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 09:26:36 +0100 Subject: [PATCH 10/42] Fix duplicate delivery, thread ID mapping, echo, and unsupported file notifications - Skip handleAttachments on msg_update/delete events to prevent Teams auto-modifications from causing duplicate file downloads (Bug A) - Return first file message ID from Send() so gateway can cache it for thread-reply ParentID mapping (Bug B) - Add updatedIDs (30s window) alongside sentIDs on all self-posted messages to suppress Teams auto-modification echoes as msg_update (Bug C) - Post a visible notification in Teams channel when a file type is not supported by hostedContents (instead of silent drop), with sentIDs/ updatedIDs protection to prevent relay (Bug D) Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 50 ++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index a2b693101f..dcd9cf90d3 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -145,6 +145,7 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { captionHTML = mdToTeamsHTML(captionText) } + var firstID string for i, files := range msg.Extra["file"] { fi, ok := files.(config.FileInfo) if !ok { @@ -154,12 +155,17 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { if i == 0 { caption = captionHTML } - if _, err := b.sendFileAsMessage(msg, fi, caption); err != nil { + id, err := b.sendFileAsMessage(msg, fi, caption) + if err != nil { b.Log.Warnf("sending file %s: %s", fi.Name, err) } + if firstID == "" && id != "" { + firstID = id + } } - // Text was included in the first file message, so don't send it again. - return "", nil + // Return the first file message ID so the gateway can cache it + // for thread-reply mapping. + return firstID, nil } if msg.ParentValid() { @@ -188,6 +194,7 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { return "", err } b.sentIDs[*res.ID] = struct{}{} + b.updatedIDs[*res.ID] = time.Now().Add(30 * time.Second) return *res.ID, nil } @@ -541,6 +548,7 @@ func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo } if err := json.Unmarshal(respBody, &result); err == nil && result.ID != "" { b.sentIDs[result.ID] = struct{}{} + b.updatedIDs[result.ID] = time.Now().Add(30 * time.Second) return result.ID, nil } return "", nil @@ -593,11 +601,31 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap usernameHTML, captionPart, fileURL, fi.Name, ) default: - // Don't post error messages to Teams for unsupported file types. - // Just log and return an error so the caller can handle it. + // Post a notification to the Teams channel so users know the file didn't arrive. b.Log.Warnf("cannot send file %s (%s) to Teams: type not supported by hostedContents and no MediaServerUpload configured", fi.Name, mimeTypeForFile(fi.Name)) - return "", fmt.Errorf("file type not supported: %s", fi.Name) + noticeText := fmt.Sprintf(`⚠ Datei %s (%s) konnte nicht übertragen werden — `+ + `Format wird von Teams nicht unterstützt.`, fi.Name, mimeTypeForFile(fi.Name)) + noticeHTML := usernameHTML + noticeText + noticeContent := &msgraph.ItemBody{ + Content: ¬iceHTML, + ContentType: &contentType, + } + noticeMsg := &msgraph.ChatMessage{Body: noticeContent} + + var noticeRes *msgraph.ChatMessage + if msg.ParentValid() { + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().ID(msg.ParentID).Replies().Request() + noticeRes, _ = ct.Add(b.ctx, noticeMsg) + } else { + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() + noticeRes, _ = ct.Add(b.ctx, noticeMsg) + } + if noticeRes != nil && noticeRes.ID != nil { + b.sentIDs[*noticeRes.ID] = struct{}{} + b.updatedIDs[*noticeRes.ID] = time.Now().Add(30 * time.Second) + } + return "", nil } content := &msgraph.ItemBody{ @@ -620,6 +648,7 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap } if res != nil && res.ID != nil { b.sentIDs[*res.ID] = struct{}{} + b.updatedIDs[*res.ID] = time.Now().Add(30 * time.Second) return *res.ID, nil } return "", nil @@ -645,6 +674,7 @@ func (b *Bmsteams) sendReply(msg config.Message) (string, error) { return "", err } b.sentIDs[*res.ID] = struct{}{} + b.updatedIDs[*res.ID] = time.Now().Add(30 * time.Second) return *res.ID, nil } @@ -806,7 +836,9 @@ func (b *Bmsteams) poll(channelName string) error { Avatar: b.GetString("IconURL"), Extra: make(map[string][]interface{}), } - b.handleAttachments(&rmsg, msg) + if !isEdit && !isDelete { + b.handleAttachments(&rmsg, msg) + } b.Log.Debugf("<= Message is %#v", rmsg) b.Remote <- rmsg } @@ -918,7 +950,9 @@ func (b *Bmsteams) poll(channelName string) error { Avatar: b.GetString("IconURL"), Extra: make(map[string][]interface{}), } - b.handleAttachments(&rrmsg, reply) + if !isReplyEdit && !isReplyDelete { + b.handleAttachments(&rrmsg, reply) + } b.Log.Debugf("<= Reply message is %#v", rrmsg) b.Remote <- rrmsg } From d9ed49ad8e79f6386b6bc1a94b22488276b789c1 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 09:56:04 +0100 Subject: [PATCH 11/42] Fix multi-image bundling, MM thread ID mapping, and source-side notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MM bridge: return postID from handleUploadFile so gateway can cache it for thread-reply mapping (fixes msg-parent-not-found for file-only thread openers from Teams) - MM bridge: bundle all files into a single post instead of one post per file (fixes 3 images → 3 messages from Teams→MM) - Teams bridge: refactor sendImageHostedContent to accept []FileInfo, sending all supported images in one message with multiple hostedContents entries (fixes 3 images → 3 messages from MM→Teams) - Teams bridge: classify files in Send() — supported images go through hostedContents batch, others through sendFileAsMessage individually - Teams bridge: send unsupported file notifications via b.Remote to route back to source side (MM) instead of posting to Teams channel Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/handlers.go | 82 +++++++++-------- bridge/mattermost/mattermost.go | 8 +- bridge/msteams/msteams.go | 152 +++++++++++++++++--------------- 3 files changed, 135 insertions(+), 107 deletions(-) diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index c08f848046..b23017874f 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -2,6 +2,7 @@ package bmattermost import ( "context" + "fmt" "strings" "github.com/matterbridge-org/matterbridge/bridge/config" @@ -178,43 +179,54 @@ func (b *Bmattermost) handleMatterHook(messages chan *config.Message) { } func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) { - var err error - var res string channelID := b.getChannelID(msg.Channel) - for _, f := range msg.Extra["file"] { + + // Upload all files first, then create a single post with all file IDs. + var fileIDs []string + var firstComment string + for i, f := range msg.Extra["file"] { fi := f.(config.FileInfo) - fileID, uploadErr := b.mc.UploadFile(*fi.Data, channelID, fi.Name) - if uploadErr != nil { - return "", uploadErr - } - text := fi.Comment - if b.GetBool("PrefixMessagesWithNick") { - text = "**" + strings.TrimSpace(msg.Username) + "**\n" + text - } - - // Build a post with webhook-like props so the message appears with the - // bridged user's name and avatar instead of the bot's identity. - post := &model.Post{ - ChannelId: channelID, - Message: text, - RootId: msg.ParentID, - FileIds: []string{fileID}, - Props: model.StringInterface{ - "from_webhook": "true", - "override_username": strings.TrimSpace(msg.Username), - "matterbridge_" + b.uuid: true, - }, - } - if msg.Avatar != "" { - post.Props["override_icon_url"] = msg.Avatar - } - created, _, createErr := b.mc.Client.CreatePost(context.TODO(), post) - if createErr != nil { - return "", createErr - } - res = created.Id - } - return res, err + fileID, err := b.mc.UploadFile(*fi.Data, channelID, fi.Name) + if err != nil { + b.Log.Errorf("upload file %s failed: %s", fi.Name, err) + continue + } + fileIDs = append(fileIDs, fileID) + if i == 0 { + firstComment = fi.Comment + } + } + + if len(fileIDs) == 0 { + return "", fmt.Errorf("no files uploaded successfully") + } + + text := firstComment + if b.GetBool("PrefixMessagesWithNick") { + text = "**" + strings.TrimSpace(msg.Username) + "**\n" + text + } + + // Build a single post with all files so they appear as one message + // with the bridged user's name and avatar. + post := &model.Post{ + ChannelId: channelID, + Message: text, + RootId: msg.ParentID, + FileIds: fileIDs, + Props: model.StringInterface{ + "from_webhook": "true", + "override_username": strings.TrimSpace(msg.Username), + "matterbridge_" + b.uuid: true, + }, + } + if msg.Avatar != "" { + post.Props["override_icon_url"] = msg.Avatar + } + created, _, err := b.mc.Client.CreatePost(context.TODO(), post) + if err != nil { + return "", err + } + return created.Id, nil } //nolint:forcetypeassert diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 964302b070..e7fde159c4 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -200,12 +200,14 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { // then send any remaining text via webhook. Webhooks can't upload // binary files, so we need the API path for actual file uploads. if msg.Extra != nil && len(msg.Extra["file"]) > 0 && b.mc != nil { - if _, err := b.handleUploadFile(&msg); err != nil { + postID, err := b.handleUploadFile(&msg) + if err != nil { b.Log.Errorf("handleUploadFile failed: %s", err) } - // If there's no remaining text, we're done. + // If there's no remaining text, return the upload post ID + // so the gateway can cache it for thread-reply mapping. if strings.TrimSpace(msg.Text) == "" { - return "", nil + return postID, nil } // Clear the files so sendWebhook doesn't append URLs again. delete(msg.Extra, "file") diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index dcd9cf90d3..26a873b3af 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -137,34 +137,54 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { // Handle file/image attachments. if msg.Extra != nil && len(msg.Extra["file"]) > 0 { - // For the first file, include msg.Text as a caption so that - // mixed content (text + image) arrives as a single Teams message. + // Build caption from msg.Text for the first message. captionHTML := "" if msg.Text != "" { captionText := mapEmojis(msg.Text) captionHTML = mdToTeamsHTML(captionText) } - var firstID string - for i, files := range msg.Extra["file"] { + // Classify files: supported images (hostedContents) vs others. + var supportedImages []config.FileInfo + var otherFiles []config.FileInfo + for _, files := range msg.Extra["file"] { fi, ok := files.(config.FileInfo) if !ok { continue } - caption := "" - if i == 0 { - caption = captionHTML + if isImageFile(fi.Name) && fi.Data != nil && isSupportedHostedContentType(fi.Name) { + supportedImages = append(supportedImages, fi) + } else { + otherFiles = append(otherFiles, fi) + } + } + + var firstID string + + // Send all supported images in a single Teams message. + if len(supportedImages) > 0 { + id, err := b.sendImageHostedContent(msg, supportedImages, captionHTML) + if err != nil { + b.Log.Warnf("sendImageHostedContent failed: %s", err) + } else { + firstID = id + captionHTML = "" // caption was included, don't duplicate } - id, err := b.sendFileAsMessage(msg, fi, caption) + } + + // Handle remaining files individually (URL-based or notification). + for _, fi := range otherFiles { + id, err := b.sendFileAsMessage(msg, fi, captionHTML) if err != nil { b.Log.Warnf("sending file %s: %s", fi.Name, err) } if firstID == "" && id != "" { firstID = id } + captionHTML = "" // only include caption once } - // Return the first file message ID so the gateway can cache it - // for thread-reply mapping. + + // Return the first message ID for gateway thread-reply mapping. return firstID, nil } @@ -453,25 +473,14 @@ func isSupportedHostedContentType(name string) bool { return mime == "image/jpeg" || mime == "image/png" } -// sendImageHostedContent sends an image as a Teams message using the hostedContents API. -// The image data is base64-encoded and embedded directly in the message, so no external -// server or public URL is required. Only works for JPG/PNG files. -// The captionHTML parameter allows including additional text (e.g. msg.Text) in the same message. -func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo, captionHTML string) (string, error) { - mimeType := mimeTypeForFile(fi.Name) - if mimeType == "" || fi.Data == nil { - return "", fmt.Errorf("sendImageHostedContent requires image file with data") - } - - usernameHTML := b.formatMessageHTML(msg, "") - bodyHTML := usernameHTML - if captionHTML != "" { - bodyHTML += captionHTML + "
" +// sendImageHostedContent sends one or more images as a single Teams message using +// the hostedContents API. Image data is base64-encoded and embedded directly in the +// message, so no external server or public URL is required. Only works for JPG/PNG. +// The captionHTML parameter allows including additional text in the same message. +func (b *Bmsteams) sendImageHostedContent(msg config.Message, files []config.FileInfo, captionHTML string) (string, error) { + if len(files) == 0 { + return "", fmt.Errorf("sendImageHostedContent requires at least one file") } - bodyHTML += fmt.Sprintf( - `%s`, - fi.Name, - ) type hostedContent struct { TempID string `json:"@microsoft.graph.temporaryId"` @@ -487,18 +496,42 @@ func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo HostedContents []hostedContent `json:"hostedContents"` } + usernameHTML := b.formatMessageHTML(msg, "") + bodyHTML := usernameHTML + if captionHTML != "" { + bodyHTML += captionHTML + "
" + } + + var hosted []hostedContent + for i, fi := range files { + if fi.Data == nil { + continue + } + id := fmt.Sprintf("%d", i+1) + bodyHTML += fmt.Sprintf( + `%s`, + id, fi.Name, + ) + if i < len(files)-1 { + bodyHTML += "
" + } + hosted = append(hosted, hostedContent{ + TempID: id, + ContentBytes: base64.StdEncoding.EncodeToString(*fi.Data), + ContentType: mimeTypeForFile(fi.Name), + }) + } + + if len(hosted) == 0 { + return "", fmt.Errorf("no valid image data to send") + } + payload := graphMessage{ Body: msgBody{ ContentType: "html", Content: bodyHTML, }, - HostedContents: []hostedContent{ - { - TempID: "1", - ContentBytes: base64.StdEncoding.EncodeToString(*fi.Data), - ContentType: mimeType, - }, - }, + HostedContents: hosted, } jsonData, err := json.Marshal(payload) @@ -554,22 +587,13 @@ func (b *Bmsteams) sendImageHostedContent(msg config.Message, fi config.FileInfo return "", nil } -// sendFileAsMessage sends a file as a Teams message. The captionHTML parameter -// allows including additional text (converted from msg.Text) in the same message -// so that text+image posts arrive as a single message instead of two. +// sendFileAsMessage sends a file as a Teams message using a URL (either from the +// source bridge or uploaded to a MediaServer). For hostedContents-supported images, +// use sendImageHostedContent instead (called from Send()). +// The captionHTML parameter allows including additional text in the same message. func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, captionHTML string) (string, error) { isImage := isImageFile(fi.Name) - // Prefer hostedContents for supported image types with binary data. - if isImage && fi.Data != nil && isSupportedHostedContentType(fi.Name) { - id, err := b.sendImageHostedContent(msg, fi, captionHTML) - if err != nil { - b.Log.Debugf("hostedContents failed for %s, falling back: %s", fi.Name, err) - } else { - return id, nil - } - } - contentType := msgraph.BodyTypeVHTML var bodyText string @@ -601,29 +625,19 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap usernameHTML, captionPart, fileURL, fi.Name, ) default: - // Post a notification to the Teams channel so users know the file didn't arrive. + // File can't be sent: no hostedContents support and no MediaServer URL. + // Send a notification back to the source side via b.Remote so users + // know the file didn't arrive (instead of posting to Teams). b.Log.Warnf("cannot send file %s (%s) to Teams: type not supported by hostedContents and no MediaServerUpload configured", fi.Name, mimeTypeForFile(fi.Name)) - noticeText := fmt.Sprintf(`⚠ Datei %s (%s) konnte nicht übertragen werden — `+ - `Format wird von Teams nicht unterstützt.`, fi.Name, mimeTypeForFile(fi.Name)) - noticeHTML := usernameHTML + noticeText - noticeContent := &msgraph.ItemBody{ - Content: ¬iceHTML, - ContentType: &contentType, - } - noticeMsg := &msgraph.ChatMessage{Body: noticeContent} - - var noticeRes *msgraph.ChatMessage - if msg.ParentValid() { - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().ID(msg.ParentID).Replies().Request() - noticeRes, _ = ct.Add(b.ctx, noticeMsg) - } else { - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(msg.Channel)).Messages().Request() - noticeRes, _ = ct.Add(b.ctx, noticeMsg) - } - if noticeRes != nil && noticeRes.ID != nil { - b.sentIDs[*noticeRes.ID] = struct{}{} - b.updatedIDs[*noticeRes.ID] = time.Now().Add(30 * time.Second) + b.Remote <- config.Message{ + Text: fmt.Sprintf("⚠️ Datei **%s** (%s) konnte nicht zu Teams übertragen werden"+ + " — Format wird nicht unterstützt, kein MediaServer konfiguriert.", + fi.Name, mimeTypeForFile(fi.Name)), + Channel: msg.Channel, + Account: b.Account, + Username: "system", + Extra: make(map[string][]interface{}), } return "", nil } From 375c8d40d0e06fd28866e5190615030cda02bdc6 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 10:22:57 +0100 Subject: [PATCH 12/42] Fix deadlock: wrap b.Remote send in goroutine for unsupported file notification The synchronous b.Remote <- msg in sendFileAsMessage blocked forever because b.Remote is an unbuffered channel read by handleReceive(), which is the same goroutine that called Send() -> sendFileAsMessage(). Wrapping in a goroutine lets Send() return immediately so handleReceive() can read the notification on the next loop iteration. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 26a873b3af..d767f5b05a 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -630,15 +630,17 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap // know the file didn't arrive (instead of posting to Teams). b.Log.Warnf("cannot send file %s (%s) to Teams: type not supported by hostedContents and no MediaServerUpload configured", fi.Name, mimeTypeForFile(fi.Name)) - b.Remote <- config.Message{ - Text: fmt.Sprintf("⚠️ Datei **%s** (%s) konnte nicht zu Teams übertragen werden"+ - " — Format wird nicht unterstützt, kein MediaServer konfiguriert.", - fi.Name, mimeTypeForFile(fi.Name)), - Channel: msg.Channel, - Account: b.Account, - Username: "system", - Extra: make(map[string][]interface{}), - } + go func() { + b.Remote <- config.Message{ + Text: fmt.Sprintf("⚠️ Datei **%s** (%s) konnte nicht zu Teams übertragen werden"+ + " — Format wird nicht unterstützt, kein MediaServer konfiguriert.", + fi.Name, mimeTypeForFile(fi.Name)), + Channel: msg.Channel, + Account: b.Account, + Username: "system", + Extra: make(map[string][]interface{}), + } + }() return "", nil } From 6c19a37a094f0d6fcff74288098d2d45fb15ae55 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 11:54:27 +0100 Subject: [PATCH 13/42] Post unsupported-file notification as thread reply with username matterbridge Return a fake ID from sendFileAsMessage so the gateway caches a BrMsgID entry for the original source message. The notification references this fake ID as ParentID, which the gateway resolves back to the original Mattermost post ID via FindCanonicalMsgID downstream search + getDestMsgID protocol-strip fallback. This makes the notification appear as a threaded reply to the user's message instead of a new root message. Also changes the notification username from "system" to "matterbridge". Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index d767f5b05a..9e6647c12a 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -630,6 +630,12 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap // know the file didn't arrive (instead of posting to Teams). b.Log.Warnf("cannot send file %s (%s) to Teams: type not supported by hostedContents and no MediaServerUpload configured", fi.Name, mimeTypeForFile(fi.Name)) + // Return a fake ID so the gateway caches it as a BrMsgID for this + // message. The notification references it as ParentID — the gateway + // then resolves it back to the original source post ID via the + // downstream search in FindCanonicalMsgID + the protocol-strip fallback + // in getDestMsgID. + fakeID := fmt.Sprintf("unsupported-%d", time.Now().UnixNano()) go func() { b.Remote <- config.Message{ Text: fmt.Sprintf("⚠️ Datei **%s** (%s) konnte nicht zu Teams übertragen werden"+ @@ -637,11 +643,12 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap fi.Name, mimeTypeForFile(fi.Name)), Channel: msg.Channel, Account: b.Account, - Username: "system", + Username: "matterbridge", + ParentID: fakeID, Extra: make(map[string][]interface{}), } }() - return "", nil + return fakeID, nil } content := &msgraph.ItemBody{ From b646be066ac37a373cd14e44fcc37c9325b54887 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 13:38:50 +0100 Subject: [PATCH 14/42] Add image tests, GIF support, priority forwarding, and persistent message cache - Translate remaining German text to English in test sequences and notifications - Add image test steps (PNG, GIF, multi-image) to both MM and Teams test sequences - Fix isSupportedHostedContentType to include image/gif (supported by MS Graph API) - Forward MM message priority (important/urgent) to Teams with emoji prefix - Add persistent JSON-backed message ID cache (MessageCacheFile config option) with LRU fallback, write-through, and background flush - Embed source message IDs in relayed messages (hidden HTML span for Teams, matterbridge_srcid prop for MM) for historical cache reconstruction - Scan recent messages on startup to populate persistent cache from markers - Add demo.png and demo.gif test assets Co-Authored-By: Claude Opus 4.6 --- bridge/config/config.go | 2 + bridge/mattermost/handlers.go | 16 ++++ bridge/mattermost/mattermost.go | 67 +++++++++++++++- bridge/mattermost/test.go | 51 +++++++++++- bridge/msteams/msteams.go | 47 +++++++++-- bridge/msteams/test.go | 107 ++++++++++++++++++++++++- gateway/gateway.go | 91 ++++++++++++++++++++-- gateway/msgcache.go | 133 ++++++++++++++++++++++++++++++++ gateway/router.go | 122 ++++++++++++++++++++++++++++- testdata/demo.gif | Bin 0 -> 57541 bytes testdata/demo.png | Bin 0 -> 6765 bytes testdata/embed.go | 11 +++ 12 files changed, 621 insertions(+), 26 deletions(-) create mode 100644 gateway/msgcache.go create mode 100644 testdata/demo.gif create mode 100644 testdata/demo.png create mode 100644 testdata/embed.go diff --git a/bridge/config/config.go b/bridge/config/config.go index 994b83488c..0fe1c444f9 100644 --- a/bridge/config/config.go +++ b/bridge/config/config.go @@ -30,6 +30,7 @@ const ( EventUserTyping = "user_typing" EventGetChannelMembers = "get_channel_members" EventNoticeIRC = "notice_irc" + EventHistoricalMapping = "historical_mapping" ) const ParentIDNotFound = "msg-parent-not-found" @@ -155,6 +156,7 @@ type Protocol struct { MediaServerDownload string MediaConvertTgs string // telegram MediaConvertWebPToPNG bool // telegram + MessageCacheFile string // general, msteams, mattermost: persistent message ID cache file MessageDelay int // IRC, time in millisecond to wait between messages MessageFormat string // telegram MessageLength int // IRC, max length of a message allowed diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index b23017874f..44c3ddbe62 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -130,6 +130,15 @@ func (b *Bmattermost) handleMatterClient(messages chan *config.Message) { // handle mattermost post properties (override username and attachments) b.handleProps(rmsg, message) + // Extract message priority from Post.Metadata if present. + if message.Post.Metadata != nil && message.Post.Metadata.Priority != nil && + message.Post.Metadata.Priority.Priority != nil { + prio := *message.Post.Metadata.Priority.Priority + if prio != "" && prio != "standard" { + rmsg.Extra["priority"] = []interface{}{prio} + } + } + // create a text for bridges that don't support native editing if message.Raw.EventType() == model.WebsocketEventPostEdited && !b.GetBool("EditDisable") { rmsg.Text = message.Text + b.GetString("EditSuffix") @@ -222,6 +231,13 @@ func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) { if msg.Avatar != "" { post.Props["override_icon_url"] = msg.Avatar } + if msg.Extra != nil { + if srcIDs, ok := msg.Extra["source_msgid"]; ok && len(srcIDs) > 0 { + if srcID, ok := srcIDs[0].(string); ok { + post.Props["matterbridge_srcid"] = srcID + } + } + } created, _, err := b.mc.Client.CreatePost(context.TODO(), post) if err != nil { return "", err diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index e7fde159c4..e0e529bd87 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -13,6 +13,7 @@ import ( "github.com/matterbridge-org/matterbridge/bridge/helper" "github.com/matterbridge-org/matterbridge/matterhook" "github.com/matterbridge/matterclient" + "github.com/mattermost/mattermost/server/public/model" "github.com/rs/xid" ) @@ -122,12 +123,56 @@ func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error { if id == "" { return fmt.Errorf("Could not find channel ID for channel %s", channel.Name) } - return b.mc.JoinChannel(id) + if err := b.mc.JoinChannel(id); err != nil { + return err + } } + // Scan recent messages for historical source-ID markers in background. + go b.scanHistoricalMappings(channel) + return nil } +// scanHistoricalMappings scans recent channel messages for matterbridge_srcid +// props and sends EventHistoricalMapping events to the gateway for persistent +// cache population. +func (b *Bmattermost) scanHistoricalMappings(channel config.ChannelInfo) { + if b.mc == nil { + return + } + channelID := b.getChannelID(channel.Name) + if channelID == "" { + return + } + + postList, _, err := b.mc.Client.GetPostsForChannel(context.TODO(), channelID, 0, 200, "", false, false) + if err != nil { + b.Log.Debugf("scanHistoricalMappings: GetPostsForChannel %s: %s", channel.Name, err) + return + } + + count := 0 + for _, id := range postList.Order { + post := postList.Posts[id] + srcID, ok := post.Props["matterbridge_srcid"].(string) + if !ok || srcID == "" { + continue + } + b.Remote <- config.Message{ + Event: config.EventHistoricalMapping, + Account: b.Account, + Channel: channel.Name, + ID: post.Id, + Extra: map[string][]interface{}{"source_msgid": {srcID}}, + } + count++ + } + if count > 0 { + b.Log.Infof("scanHistoricalMappings: found %d mappings in %s", count, channel.Name) + } +} + // lookupWebhookPostID searches recent channel posts to find the ID of a message // just sent via webhook. Webhooks don't return post IDs, but the gateway needs // them to map replies across bridges. We look for a recent post from the bot @@ -289,6 +334,22 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { return b.mc.EditMessage(msg.ID, msg.Text) } - // Post normal message - return b.mc.PostMessage(b.getChannelID(msg.Channel), msg.Text, msg.ParentID) + // Post normal message, embedding source ID for cross-bridge cache reconstruction. + post := &model.Post{ + ChannelId: b.getChannelID(msg.Channel), + Message: msg.Text, + RootId: msg.ParentID, + } + if msg.Extra != nil { + if srcIDs, ok := msg.Extra["source_msgid"]; ok && len(srcIDs) > 0 { + if srcID, ok := srcIDs[0].(string); ok { + post.SetProp("matterbridge_srcid", srcID) + } + } + } + created, _, err := b.mc.Client.CreatePost(context.TODO(), post) + if err != nil { + return "", err + } + return created.Id, nil } diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 840d1f66f7..5f094e272f 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/matterbridge-org/matterbridge/testdata" "github.com/mattermost/mattermost/server/public/model" ) @@ -89,14 +90,56 @@ func (b *Bmattermost) runTestSequence(channelName string) { time.Sleep(time.Second) // Step 10: Unordered list - post("- Item eins\n- Item zwei\n- Item drei", rootID) + post("- Item one\n- Item two\n- Item three", rootID) time.Sleep(time.Second) // Step 11: Ordered list - post("1. Erster Punkt\n2. Zweiter Punkt\n3. Dritter Punkt", rootID) + post("1. First point\n2. Second point\n3. Third point", rootID) time.Sleep(time.Second) - // Step 12: Delete the marked message + // Step 12: Single PNG image + if pngID, err := b.mc.UploadFile(testdata.DemoPNG, channelID, "demo.png"); err != nil { + b.Log.Errorf("test: upload demo.png failed: %s", err) + } else { + p := &model.Post{ChannelId: channelID, Message: "Image test: PNG", RootId: rootID, FileIds: model.StringArray{pngID}, Props: testProps} + if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with PNG failed: %s", err) + } + } + time.Sleep(time.Second) + + // Step 13: Single GIF image + if gifID, err := b.mc.UploadFile(testdata.DemoGIF, channelID, "demo.gif"); err != nil { + b.Log.Errorf("test: upload demo.gif failed: %s", err) + } else { + p := &model.Post{ChannelId: channelID, Message: "Image test: GIF", RootId: rootID, FileIds: model.StringArray{gifID}, Props: testProps} + if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with GIF failed: %s", err) + } + } + time.Sleep(time.Second) + + // Step 14: Multi-image (2x PNG in one message) + { + var fileIDs model.StringArray + for _, name := range []string{"demo1.png", "demo2.png"} { + id, err := b.mc.UploadFile(testdata.DemoPNG, channelID, name) + if err != nil { + b.Log.Errorf("test: upload %s failed: %s", name, err) + continue + } + fileIDs = append(fileIDs, id) + } + if len(fileIDs) > 0 { + p := &model.Post{ChannelId: channelID, Message: "Image test: multi-image (2x PNG)", RootId: rootID, FileIds: fileIDs, Props: testProps} + if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with multi-image failed: %s", err) + } + } + } + time.Sleep(time.Second) + + // Step 15: Delete the marked message if deleteID != "" { _, err := b.mc.Client.DeletePost(context.TODO(), deleteID) if err != nil { @@ -104,7 +147,7 @@ func (b *Bmattermost) runTestSequence(channelName string) { } } - // Step 13: Test finished + // Step 16: Test finished post("✅ Test finished", rootID) b.Log.Info("test: test sequence completed") diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 9e6647c12a..d6e726c647 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -135,6 +135,20 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { return b.updateMessage(msg) } + // Prepend priority indicator emoji for Mattermost important/urgent messages. + if msg.Extra != nil { + if priorities, ok := msg.Extra["priority"]; ok && len(priorities) > 0 { + if prio, ok := priorities[0].(string); ok { + switch prio { + case "important": + msg.Text = "❗ " + msg.Text + case "urgent": + msg.Text = "🚨 " + msg.Text + } + } + } + } + // Handle file/image attachments. if msg.Extra != nil && len(msg.Extra["file"]) > 0 { // Build caption from msg.Text for the first message. @@ -290,7 +304,16 @@ func (b *Bmsteams) formatMessageHTML(msg config.Message, bodyHTML string) string result = strings.ReplaceAll(result, "{CHANNEL}", htmlEscape(msg.Channel)) result = strings.ReplaceAll(result, "\n", "
") - return result + bodyHTML + html := result + bodyHTML + + // Embed source message ID as hidden span for historical cache population. + if srcIDs, ok := msg.Extra["source_msgid"]; ok && len(srcIDs) > 0 { + if srcID, ok := srcIDs[0].(string); ok { + html += `` + } + } + + return html } // getAccessToken returns a fresh access token from the token source. @@ -467,10 +490,10 @@ func isImageFile(name string) bool { } // isSupportedHostedContentType returns true if the file type can be embedded -// via the Graph API hostedContents endpoint. Only JPG and PNG are supported. +// via the Graph API hostedContents endpoint. JPG, PNG, and GIF are supported. func isSupportedHostedContentType(name string) bool { mime := mimeTypeForFile(name) - return mime == "image/jpeg" || mime == "image/png" + return mime == "image/jpeg" || mime == "image/png" || mime == "image/gif" } // sendImageHostedContent sends one or more images as a single Teams message using @@ -638,8 +661,8 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap fakeID := fmt.Sprintf("unsupported-%d", time.Now().UnixNano()) go func() { b.Remote <- config.Message{ - Text: fmt.Sprintf("⚠️ Datei **%s** (%s) konnte nicht zu Teams übertragen werden"+ - " — Format wird nicht unterstützt, kein MediaServer konfiguriert.", + Text: fmt.Sprintf("⚠️ File **%s** (%s) could not be transferred to Teams"+ + " — format not supported, no MediaServer configured.", fi.Name, mimeTypeForFile(fi.Name)), Channel: msg.Channel, Account: b.Account, @@ -744,12 +767,26 @@ func (b *Bmsteams) poll(channelName string) error { } // Seed with existing messages — use newest timestamp to avoid re-delivery. + // Also scan for historical source-ID markers for persistent cache population. + mbSrcRE := regexp.MustCompile(`data-mb-src="([^"]+)"`) for _, msg := range res { if msg.LastModifiedDateTime != nil { msgmap[*msg.ID] = *msg.LastModifiedDateTime } else { msgmap[*msg.ID] = *msg.CreatedDateTime } + // Extract source ID marker from message body. + if msg.Body != nil && msg.Body.Content != nil { + if matches := mbSrcRE.FindStringSubmatch(*msg.Body.Content); len(matches) == 2 { + b.Remote <- config.Message{ + Event: config.EventHistoricalMapping, + Account: b.Account, + Channel: channelName, + ID: *msg.ID, + Extra: map[string][]interface{}{"source_msgid": {matches[1]}}, + } + } + } } // repliesFetchedAt tracks when we last fetched replies per message. diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go index d3af3a0b98..0958a4cef8 100644 --- a/bridge/msteams/test.go +++ b/bridge/msteams/test.go @@ -2,6 +2,7 @@ package bmsteams import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "io" @@ -9,6 +10,7 @@ import ( "strings" "time" + "github.com/matterbridge-org/matterbridge/testdata" msgraph "github.com/yaegashi/msgraph.go/beta" ) @@ -189,19 +191,116 @@ func (b *Bmsteams) runTestSequence(channelName string) { time.Sleep(time.Second) // Step 10: Unordered list - postReply(rootID, "
  • Item eins
  • Item zwei
  • Item drei
", &htmlType) + postReply(rootID, "
  • Item one
  • Item two
  • Item three
", &htmlType) time.Sleep(time.Second) // Step 11: Ordered list - postReply(rootID, "
  1. Erster Punkt
  2. Zweiter Punkt
  3. Dritter Punkt
", &htmlType) + postReply(rootID, "
  1. First point
  2. Second point
  3. Third point
", &htmlType) time.Sleep(time.Second) - // Step 12: Delete the marked message + type testImage struct { + name string + contentType string + data []byte + } + + // Helper to post a reply with hostedContents images. + postReplyWithImages := func(rootID, caption string, images []testImage) { + type hostedContent struct { + TempID string `json:"@microsoft.graph.temporaryId"` + ContentBytes string `json:"contentBytes"` + ContentType string `json:"contentType"` + } + type msgBody struct { + ContentType string `json:"contentType"` + Content string `json:"content"` + } + type graphMessage struct { + Body msgBody `json:"body"` + HostedContents []hostedContent `json:"hostedContents"` + } + + bodyHTML := caption + if bodyHTML != "" { + bodyHTML += "
" + } + var hosted []hostedContent + for i, img := range images { + id := fmt.Sprintf("%d", i+1) + bodyHTML += fmt.Sprintf(`%s`, id, img.name) + if i < len(images)-1 { + bodyHTML += "
" + } + hosted = append(hosted, hostedContent{ + TempID: id, + ContentBytes: base64.StdEncoding.EncodeToString(img.data), + ContentType: img.contentType, + }) + } + + payload := graphMessage{ + Body: msgBody{ContentType: "html", Content: bodyHTML}, + HostedContents: hosted, + } + jsonData, err := json.Marshal(payload) + if err != nil { + b.Log.Errorf("test: marshal image payload failed: %s", err) + return + } + + apiURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/replies", + teamID, channelID, rootID) + token, err := b.getAccessToken() + if err != nil { + b.Log.Errorf("test: getAccessToken failed: %s", err) + return + } + req, err := http.NewRequestWithContext(b.ctx, http.MethodPost, apiURL, bytes.NewReader(jsonData)) + if err != nil { + b.Log.Errorf("test: NewRequest failed: %s", err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + b.Log.Errorf("test: image post failed: %s", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + b.Log.Errorf("test: image reply failed: %d %s", resp.StatusCode, string(body)) + } + // Do NOT add to sentIDs — let poll() pick it up for relay. + } + + // Step 12: Single PNG image + postReplyWithImages(rootID, "Image test: PNG", []testImage{ + {name: "demo.png", contentType: "image/png", data: testdata.DemoPNG}, + }) + time.Sleep(time.Second) + + // Step 13: Single GIF image + postReplyWithImages(rootID, "Image test: GIF", []testImage{ + {name: "demo.gif", contentType: "image/gif", data: testdata.DemoGIF}, + }) + time.Sleep(time.Second) + + // Step 14: Multi-image (2x PNG in one message) + postReplyWithImages(rootID, "Image test: multi-image (2x PNG)", []testImage{ + {name: "demo1.png", contentType: "image/png", data: testdata.DemoPNG}, + {name: "demo2.png", contentType: "image/png", data: testdata.DemoPNG}, + }) + time.Sleep(time.Second) + + // Step 15: Delete the marked message if deleteID != "" { deleteReply(rootID, deleteID) } - // Step 13: Test finished + // Step 16: Test finished postReply(rootID, "✅ Test finished", nil) b.Log.Info("test: test sequence completed") diff --git a/gateway/gateway.go b/gateway/gateway.go index 617d351cbd..5d3ab10f7c 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -20,14 +20,15 @@ import ( type Gateway struct { config.Config - Router *Router - MyConfig *config.Gateway - Bridges map[string]*bridge.Bridge - Channels map[string]*config.ChannelInfo - ChannelOptions map[string]config.ChannelOptions - Message chan config.Message - Name string - Messages *lru.Cache + Router *Router + MyConfig *config.Gateway + Bridges map[string]*bridge.Bridge + Channels map[string]*config.ChannelInfo + ChannelOptions map[string]config.ChannelOptions + Message chan config.Message + Name string + Messages *lru.Cache + PersistentCache *PersistentMsgCache logger *logrus.Entry } @@ -58,6 +59,12 @@ func New(rootLogger *logrus.Logger, cfg *config.Gateway, r *Router) *Gateway { if err := gw.AddConfig(cfg); err != nil { logger.Errorf("Failed to add configuration to gateway: %#v", err) } + + // Initialize persistent message ID cache if configured. + if cachePath := gw.BridgeValues().General.MessageCacheFile; cachePath != "" { + gw.PersistentCache = NewPersistentMsgCache(cachePath, logger) + } + return gw } @@ -78,9 +85,55 @@ func (gw *Gateway) FindCanonicalMsgID(protocol string, mID string) string { } } } + + // Fallback to persistent cache if LRU missed. + if gw.PersistentCache != nil { + // Check if ID is a direct key in persistent cache. + if entries, ok := gw.PersistentCache.Get(ID); ok { + gw.restoreToCacheFindCanonical(ID, entries) + return ID + } + // Check if ID is a downstream value. + if canonical := gw.PersistentCache.FindDownstream(ID); canonical != "" { + if entries, ok := gw.PersistentCache.Get(canonical); ok { + gw.restoreToCacheFindCanonical(canonical, entries) + } + return canonical + } + } + return "" } +// restoreToCacheFindCanonical restores persistent cache entries into the LRU cache. +func (gw *Gateway) restoreToCacheFindCanonical(key string, entries []PersistentMsgEntry) { + var brMsgIDs []*BrMsgID + for _, entry := range entries { + br := gw.findBridge(entry.Protocol, entry.BridgeName) + if br == nil { + continue + } + brMsgIDs = append(brMsgIDs, &BrMsgID{ + br: br, + ID: entry.ID, + ChannelID: entry.ChannelID, + }) + } + if len(brMsgIDs) > 0 { + gw.Messages.Add(key, brMsgIDs) + } +} + +// findBridge looks up a bridge by protocol and name across the gateway. +func (gw *Gateway) findBridge(protocol, name string) *bridge.Bridge { + for _, br := range gw.Bridges { + if br.Protocol == protocol && br.Name == name { + return br + } + } + return nil +} + // AddBridge sets up a new bridge on startup. // // It's added in the gateway object with the specified configuration, and is @@ -283,6 +336,23 @@ func (gw *Gateway) getDestMsgID(msgID string, dest *bridge.Bridge, channel *conf } } } + + // Fallback to persistent cache if LRU missed. + if gw.PersistentCache != nil { + if entries, ok := gw.PersistentCache.Get(msgID); ok { + // Restore to LRU and retry. + gw.restoreToCacheFindCanonical(msgID, entries) + if res, ok := gw.Messages.Get(msgID); ok { + IDs := res.([]*BrMsgID) + for _, id := range IDs { + if dest.Protocol == id.br.Protocol && dest.Name == id.br.Name && channel.ID == id.ChannelID { + return strings.Replace(id.ID, dest.Protocol+" ", "", 1) + } + } + } + } + } + return "" } @@ -484,6 +554,11 @@ func (gw *Gateway) SendMessage( } msg.Extra["nick"] = []interface{}{rmsg.Username} + // Pass source message ID so bridges can embed it for historical cache population. + if rmsg.ID != "" { + msg.Extra["source_msgid"] = []interface{}{rmsg.Protocol + ":" + rmsg.ID} + } + msg.Username = gw.modifyUsername(rmsg, dest) // exclude file delete event as the msg ID here is the native file ID that needs to be deleted diff --git a/gateway/msgcache.go b/gateway/msgcache.go new file mode 100644 index 0000000000..34ab9ed2c1 --- /dev/null +++ b/gateway/msgcache.go @@ -0,0 +1,133 @@ +package gateway + +import ( + "encoding/json" + "os" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// PersistentMsgEntry represents a single downstream message ID mapping. +type PersistentMsgEntry struct { + Protocol string `json:"protocol"` + BridgeName string `json:"bridge_name"` + ID string `json:"id"` + ChannelID string `json:"channel_id"` +} + +// PersistentMsgCache is a file-backed message ID cache that persists +// cross-bridge message ID mappings across restarts. +type PersistentMsgCache struct { + mu sync.Mutex + path string + data map[string][]PersistentMsgEntry + dirty bool + ticker *time.Ticker + stopCh chan struct{} + logger *logrus.Entry +} + +// NewPersistentMsgCache creates a new persistent cache backed by the given file path. +// Returns nil if path is empty. Loads existing data on creation and starts a +// background flush loop that writes changes to disk every 30 seconds. +func NewPersistentMsgCache(path string, logger *logrus.Entry) *PersistentMsgCache { + if path == "" { + return nil + } + c := &PersistentMsgCache{ + path: path, + data: make(map[string][]PersistentMsgEntry), + stopCh: make(chan struct{}), + logger: logger, + } + c.load() + c.ticker = time.NewTicker(30 * time.Second) + go c.flushLoop() + return c +} + +func (c *PersistentMsgCache) load() { + f, err := os.ReadFile(c.path) + if err != nil { + if !os.IsNotExist(err) { + c.logger.Warnf("failed to read message cache %s: %s", c.path, err) + } + return + } + if err := json.Unmarshal(f, &c.data); err != nil { + c.logger.Warnf("failed to parse message cache %s: %s", c.path, err) + } else { + c.logger.Infof("loaded %d entries from message cache %s", len(c.data), c.path) + } +} + +func (c *PersistentMsgCache) flushLoop() { + for { + select { + case <-c.ticker.C: + c.Flush() + case <-c.stopCh: + c.ticker.Stop() + c.Flush() + return + } + } +} + +// Add stores a message ID mapping. +func (c *PersistentMsgCache) Add(key string, entries []PersistentMsgEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.data[key] = entries + c.dirty = true +} + +// Get returns downstream IDs for a key, or nil if not found. +func (c *PersistentMsgCache) Get(key string) ([]PersistentMsgEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + v, ok := c.data[key] + return v, ok +} + +// FindDownstream searches all entries for a downstream match (by ID field) +// and returns the canonical (upstream) key. This mirrors the linear scan +// in Gateway.FindCanonicalMsgID but over the persistent store. +func (c *PersistentMsgCache) FindDownstream(id string) string { + c.mu.Lock() + defer c.mu.Unlock() + for key, entries := range c.data { + for _, entry := range entries { + if entry.ID == id { + return key + } + } + } + return "" +} + +// Flush writes the cache to disk if it has been modified since the last flush. +func (c *PersistentMsgCache) Flush() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.dirty { + return + } + data, err := json.MarshalIndent(c.data, "", " ") + if err != nil { + c.logger.Errorf("failed to marshal message cache: %s", err) + return + } + if err := os.WriteFile(c.path, data, 0600); err != nil { + c.logger.Errorf("failed to write message cache %s: %s", c.path, err) + return + } + c.dirty = false +} + +// Stop stops the background flush loop and performs a final flush. +func (c *PersistentMsgCache) Stop() { + close(c.stopCh) +} diff --git a/gateway/router.go b/gateway/router.go index a8aad1aaef..7966c58d46 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -2,6 +2,7 @@ package gateway import ( "fmt" + "strings" "sync" "time" @@ -143,6 +144,12 @@ func (r *Router) handleReceive() { // Set message protocol based on the account it came from msg.Protocol = r.getBridge(msg.Account).Protocol + // Handle historical cache population events — don't relay, just cache. + if msg.Event == config.EventHistoricalMapping { + r.handleHistoricalMapping(&msg) + continue + } + filesHandled := false for _, gw := range r.Gateways { // record all the message ID's of the different bridges @@ -161,7 +168,8 @@ func (r *Router) handleReceive() { } if msg.ID != "" { - _, exists := gw.Messages.Get(msg.Protocol + " " + msg.ID) + cacheKey := msg.Protocol + " " + msg.ID + _, exists := gw.Messages.Get(cacheKey) // Only add the message ID if it doesn't already exist // @@ -169,13 +177,123 @@ func (r *Router) handleReceive() { // This is necessary as msgIDs will change if a bridge returns // a different ID in response to edits. if !exists { - gw.Messages.Add(msg.Protocol+" "+msg.ID, msgIDs) + gw.Messages.Add(cacheKey, msgIDs) + } + + // Write-through to persistent cache. + if gw.PersistentCache != nil && len(msgIDs) > 0 { + var entries []PersistentMsgEntry + for _, mid := range msgIDs { + if mid.br != nil && mid.ID != "" { + entries = append(entries, PersistentMsgEntry{ + Protocol: mid.br.Protocol, + BridgeName: mid.br.Name, + ID: mid.ID, + ChannelID: mid.ChannelID, + }) + } + } + if len(entries) > 0 { + gw.PersistentCache.Add(cacheKey, entries) + } } } } } } +// handleHistoricalMapping processes historical ID mapping events from bridges. +// It extracts the source-ID marker and stores a bidirectional mapping in the +// persistent cache of every gateway that has both the reporting bridge and +// the source bridge configured. +func (r *Router) handleHistoricalMapping(msg *config.Message) { + if msg.ID == "" || msg.Extra == nil { + return + } + srcIDs, ok := msg.Extra["source_msgid"] + if !ok || len(srcIDs) == 0 { + return + } + sourceIDStr, ok := srcIDs[0].(string) + if !ok || sourceIDStr == "" { + return + } + + // Parse "protocol:messageID" from the source marker. + parts := strings.SplitN(sourceIDStr, ":", 2) + if len(parts) != 2 { + return + } + sourceProtocol := parts[0] + sourceMessageID := parts[1] + + localKey := msg.Protocol + " " + msg.ID + sourceKey := sourceProtocol + " " + sourceMessageID + + for _, gw := range r.Gateways { + if gw.PersistentCache == nil { + continue + } + + // Find the local bridge (the one that reported this mapping). + localBridge := gw.findBridge(msg.Protocol, extractBridgeName(msg.Account)) + if localBridge == nil { + continue + } + + // Find a bridge matching the source protocol in this gateway. + var sourceBridge *bridge.Bridge + for _, br := range gw.Bridges { + if br.Protocol == sourceProtocol { + sourceBridge = br + break + } + } + if sourceBridge == nil { + continue + } + + // Find channel IDs for both sides. + localChannelID := msg.Channel + msg.Account + var sourceChannelID string + for chID, ch := range gw.Channels { + if ch.Account == sourceBridge.Account { + sourceChannelID = chID + break + } + } + + // Store: sourceKey → points to local bridge (e.g., "mattermost POST123" → msteams entry) + if _, exists := gw.PersistentCache.Get(sourceKey); !exists { + gw.PersistentCache.Add(sourceKey, []PersistentMsgEntry{{ + Protocol: localBridge.Protocol, + BridgeName: localBridge.Name, + ID: localKey, + ChannelID: localChannelID, + }}) + } + + // Store: localKey → points to source bridge (e.g., "msteams TEAMS456" → mattermost entry) + if _, exists := gw.PersistentCache.Get(localKey); !exists && sourceChannelID != "" { + gw.PersistentCache.Add(localKey, []PersistentMsgEntry{{ + Protocol: sourceBridge.Protocol, + BridgeName: sourceBridge.Name, + ID: sourceKey, + ChannelID: sourceChannelID, + }}) + } + } +} + +// extractBridgeName returns the part after the dot in an account string like "msteams.windoof". +func extractBridgeName(account string) string { + parts := strings.SplitN(account, ".", 2) + if len(parts) == 2 { + return parts[1] + } + return account +} + // updateChannelMembers sends every minute an GetChannelMembers event to all bridges. func (r *Router) updateChannelMembers() { // TODO sleep a minute because slack can take a while diff --git a/testdata/demo.gif b/testdata/demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..9dd5d0c52425e9f9b2840562edd735c0615a4b79 GIT binary patch literal 57541 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 Date: Thu, 12 Mar 2026 13:40:37 +0100 Subject: [PATCH 15/42] Fix SetProp: use Props map directly for older Mattermost model Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/mattermost.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index e0e529bd87..51ba94a1d1 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -343,7 +343,10 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { if msg.Extra != nil { if srcIDs, ok := msg.Extra["source_msgid"]; ok && len(srcIDs) > 0 { if srcID, ok := srcIDs[0].(string); ok { - post.SetProp("matterbridge_srcid", srcID) + if post.Props == nil { + post.Props = model.StringInterface{} + } + post.Props["matterbridge_srcid"] = srcID } } } From cf308ac847959391c35f6bc93c4d8a741b657d9e Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 14:45:46 +0100 Subject: [PATCH 16/42] Add hostedContents image relay, revert GIF support, add priority tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert image/gif from isSupportedHostedContentType (Teams API rejects it) - Add handleHostedContents() to download inline images from Teams messages via Graph API hostedContents/$value endpoint for Teams→MM relay - Replace GIF test step with manual check instruction (Teams uses SharePoint) - Add priority test steps (important + urgent) to MM test sequence - Fix MessageCacheFile to use per-bridge config with [general] fallback Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/test.go | 36 ++++++++++++++++++++-- bridge/msteams/handler.go | 63 +++++++++++++++++++++++++++++++++++++++ bridge/msteams/msteams.go | 6 ++-- bridge/msteams/test.go | 6 ++-- gateway/gateway.go | 10 ++++++- 5 files changed, 112 insertions(+), 9 deletions(-) diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 5f094e272f..0018f98cef 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -139,7 +139,39 @@ func (b *Bmattermost) runTestSequence(channelName string) { } time.Sleep(time.Second) - // Step 15: Delete the marked message + // Step 15: Important priority message + { + prio := "important" + p := &model.Post{ + ChannelId: channelID, + Message: "Priority test: important message", + RootId: rootID, + Props: testProps, + Metadata: &model.PostMetadata{Priority: &model.PostPriority{Priority: &prio}}, + } + if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with important priority failed: %s", err) + } + } + time.Sleep(time.Second) + + // Step 16: Urgent priority message + { + prio := "urgent" + p := &model.Post{ + ChannelId: channelID, + Message: "Priority test: urgent message", + RootId: rootID, + Props: testProps, + Metadata: &model.PostMetadata{Priority: &model.PostPriority{Priority: &prio}}, + } + if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with urgent priority failed: %s", err) + } + } + time.Sleep(time.Second) + + // Step 17: Delete the marked message if deleteID != "" { _, err := b.mc.Client.DeletePost(context.TODO(), deleteID) if err != nil { @@ -147,7 +179,7 @@ func (b *Bmattermost) runTestSequence(channelName string) { } } - // Step 16: Test finished + // Step 18: Test finished post("✅ Test finished", rootID) b.Log.Info("test: test sequence completed") diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index 992e28a9e1..4fcdb0e2b8 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "regexp" "strings" "github.com/matterbridge-org/matterbridge/bridge/config" @@ -12,6 +13,9 @@ import ( msgraph "github.com/yaegashi/msgraph.go/beta" ) +var hostedContentImgRE = regexp.MustCompile(`(?i)]*src="[^"]*hostedContents/([^/]+)/\$value"[^>]*(?:alt="([^"]*)")?[^>]*/?>`) + + func (b *Bmsteams) findFile(weburl string) (string, error) { itemRB, err := b.gc.GetDriveItemByURL(b.ctx, weburl) if err != nil { @@ -100,3 +104,62 @@ func (b *Bmsteams) handleCodeSnippet(rmsg *config.Message, attach msgraph.ChatMe } rmsg.Text = rmsg.Text + "\n```" + content.Language + "\n" + string(res) + "\n```\n" } + +// handleHostedContents downloads inline images embedded via hostedContents +// in the Teams message HTML body and adds them to rmsg.Extra["file"]. +// parentMsgID should be empty for top-level messages, or the parent message ID for replies. +func (b *Bmsteams) handleHostedContents(rmsg *config.Message, msg msgraph.ChatMessage, parentMsgID string) { + if msg.Body == nil || msg.Body.Content == nil { + return + } + + matches := hostedContentImgRE.FindAllStringSubmatch(*msg.Body.Content, -1) + if len(matches) == 0 { + return + } + + teamID := b.GetString("TeamID") + channelID := decodeChannelID(rmsg.Channel) + msgID := *msg.ID + + for _, m := range matches { + hcID := m[1] + filename := m[2] // from alt attribute + if filename == "" { + filename = fmt.Sprintf("image_%s.png", hcID) + } + + // Build the Graph API URL for the hostedContent binary. + var apiURL string + if parentMsgID == "" { + apiURL = fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/hostedContents/%s/$value", + teamID, channelID, msgID, hcID) + } else { + apiURL = fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s/replies/%s/hostedContents/%s/$value", + teamID, channelID, parentMsgID, msgID, hcID) + } + + resp, err := b.gc.Teams().Request().Client().Get(apiURL) + if err != nil { + b.Log.Errorf("handleHostedContents: GET %s failed: %s", apiURL, err) + continue + } + + data, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + b.Log.Errorf("handleHostedContents: reading body for %s failed: %s", filename, err) + continue + } + + if resp.StatusCode >= 400 { + b.Log.Errorf("handleHostedContents: GET %s returned %d", apiURL, resp.StatusCode) + continue + } + + b.Log.Debugf("handleHostedContents: downloaded %s (%d bytes)", filename, len(data)) + comment := rmsg.Text + rmsg.Text = "" + helper.HandleDownloadData(b.Log, rmsg, filename, comment, "", &data, b.General) + } +} diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index d6e726c647..980397884f 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -490,10 +490,10 @@ func isImageFile(name string) bool { } // isSupportedHostedContentType returns true if the file type can be embedded -// via the Graph API hostedContents endpoint. JPG, PNG, and GIF are supported. +// via the Graph API hostedContents endpoint. Only JPG and PNG are supported. func isSupportedHostedContentType(name string) bool { mime := mimeTypeForFile(name) - return mime == "image/jpeg" || mime == "image/png" || mime == "image/gif" + return mime == "image/jpeg" || mime == "image/png" } // sendImageHostedContent sends one or more images as a single Teams message using @@ -898,6 +898,7 @@ func (b *Bmsteams) poll(channelName string) error { } if !isEdit && !isDelete { b.handleAttachments(&rmsg, msg) + b.handleHostedContents(&rmsg, msg, "") } b.Log.Debugf("<= Message is %#v", rmsg) b.Remote <- rmsg @@ -1012,6 +1013,7 @@ func (b *Bmsteams) poll(channelName string) error { } if !isReplyEdit && !isReplyDelete { b.handleAttachments(&rrmsg, reply) + b.handleHostedContents(&rrmsg, reply, *msg.ID) } b.Log.Debugf("<= Reply message is %#v", rrmsg) b.Remote <- rrmsg diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go index 0958a4cef8..c9b180881f 100644 --- a/bridge/msteams/test.go +++ b/bridge/msteams/test.go @@ -282,10 +282,8 @@ func (b *Bmsteams) runTestSequence(channelName string) { }) time.Sleep(time.Second) - // Step 13: Single GIF image - postReplyWithImages(rootID, "Image test: GIF", []testImage{ - {name: "demo.gif", contentType: "image/gif", data: testdata.DemoGIF}, - }) + // Step 13: GIF — hostedContents only supports JPG/PNG; Teams client uses SharePoint for GIFs. + postReply(rootID, "⚠️ Please manually check GIF file transmission from Teams to Mattermost — this test cannot upload files to your SharePoint.", nil) time.Sleep(time.Second) // Step 14: Multi-image (2x PNG in one message) diff --git a/gateway/gateway.go b/gateway/gateway.go index 5d3ab10f7c..4418a31b7e 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -61,7 +61,15 @@ func New(rootLogger *logrus.Logger, cfg *config.Gateway, r *Router) *Gateway { } // Initialize persistent message ID cache if configured. - if cachePath := gw.BridgeValues().General.MessageCacheFile; cachePath != "" { + // Check per-bridge settings first (br.GetString falls back to [general]). + var cachePath string + for _, br := range gw.Bridges { + if p := br.GetString("MessageCacheFile"); p != "" { + cachePath = p + break + } + } + if cachePath != "" { gw.PersistentCache = NewPersistentMsgCache(cachePath, logger) } From 50df69c18a66419afca66e0f35b354147e3ef479 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 16:22:15 +0100 Subject: [PATCH 17/42] Per-bridge MessageCacheFile, override_username for API posts, fix priority tests - Rework MessageCacheFile from one-per-gateway to per-bridge caches with dedup for shared paths and helper methods - Add from_webhook/override_username/override_icon_url to all API-path CreatePost calls so thread replies show bridged user identity - Remove redundant bold username prefix from handleUploadFile and text CreatePost path - Fix priority test steps: create post first, then SetPostPriority Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/handlers.go | 5 +- bridge/mattermost/mattermost.go | 35 +++++++++---- bridge/mattermost/test.go | 24 ++++++--- gateway/gateway.go | 92 +++++++++++++++++++++++++++------ gateway/router.go | 14 ++--- 5 files changed, 123 insertions(+), 47 deletions(-) diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index 44c3ddbe62..cb87851a75 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -211,12 +211,9 @@ func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) { } text := firstComment - if b.GetBool("PrefixMessagesWithNick") { - text = "**" + strings.TrimSpace(msg.Username) + "**\n" + text - } // Build a single post with all files so they appear as one message - // with the bridged user's name and avatar. + // with the bridged user's name and avatar via override_username. post := &model.Post{ ChannelId: channelID, Message: text, diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 51ba94a1d1..08f80a44a7 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -314,7 +314,20 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { // Upload a file if it exists if msg.Extra != nil { for _, rmsg := range helper.HandleExtra(&msg, b.General) { - if _, err := b.mc.PostMessage(b.getChannelID(rmsg.Channel), rmsg.Username+rmsg.Text, msg.ParentID); err != nil { + extraPost := &model.Post{ + ChannelId: b.getChannelID(rmsg.Channel), + Message: rmsg.Text, + RootId: msg.ParentID, + Props: model.StringInterface{ + "from_webhook": "true", + "override_username": strings.TrimSpace(rmsg.Username), + "matterbridge_" + b.uuid: true, + }, + } + if rmsg.Avatar != "" { + extraPost.Props["override_icon_url"] = rmsg.Avatar + } + if _, _, err := b.mc.Client.CreatePost(context.TODO(), extraPost); err != nil { b.Log.Errorf("PostMessage failed: %s", err) } } @@ -323,29 +336,29 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { } } - // Prepend nick if configured. Bold the username and put the message - // on the next line so it visually matches webhook post styling. - if b.GetBool("PrefixMessagesWithNick") { - msg.Text = "**" + strings.TrimSpace(msg.Username) + "**\n" + msg.Text - } - // Edit message if we have an ID if msg.ID != "" { return b.mc.EditMessage(msg.ID, msg.Text) } - // Post normal message, embedding source ID for cross-bridge cache reconstruction. + // Post normal message with override_username/icon so it appears as the + // bridged user (same as handleUploadFile does for file posts). post := &model.Post{ ChannelId: b.getChannelID(msg.Channel), Message: msg.Text, RootId: msg.ParentID, + Props: model.StringInterface{ + "from_webhook": "true", + "override_username": strings.TrimSpace(msg.Username), + "matterbridge_" + b.uuid: true, + }, + } + if msg.Avatar != "" { + post.Props["override_icon_url"] = msg.Avatar } if msg.Extra != nil { if srcIDs, ok := msg.Extra["source_msgid"]; ok && len(srcIDs) > 0 { if srcID, ok := srcIDs[0].(string); ok { - if post.Props == nil { - post.Props = model.StringInterface{} - } post.Props["matterbridge_srcid"] = srcID } } diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 0018f98cef..0f2bb5ccac 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -140,33 +140,41 @@ func (b *Bmattermost) runTestSequence(channelName string) { time.Sleep(time.Second) // Step 15: Important priority message + // Create post first, then set priority via separate API call. + // (Metadata in CreatePost is ignored by the server.) { - prio := "important" p := &model.Post{ ChannelId: channelID, Message: "Priority test: important message", RootId: rootID, Props: testProps, - Metadata: &model.PostMetadata{Priority: &model.PostPriority{Priority: &prio}}, } - if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { - b.Log.Errorf("test: CreatePost with important priority failed: %s", err) + created, _, err := b.mc.Client.CreatePost(context.TODO(), p) + if err != nil { + b.Log.Errorf("test: CreatePost important priority failed: %s", err) + } else { + b.Log.Debugf("test: created important priority post %s, setting priority...", created.Id) + prio := "important" + b.mc.Client.SetPostPriority(context.TODO(), created.Id, &model.PostPriority{Priority: &prio}) } } time.Sleep(time.Second) // Step 16: Urgent priority message { - prio := "urgent" p := &model.Post{ ChannelId: channelID, Message: "Priority test: urgent message", RootId: rootID, Props: testProps, - Metadata: &model.PostMetadata{Priority: &model.PostPriority{Priority: &prio}}, } - if _, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { - b.Log.Errorf("test: CreatePost with urgent priority failed: %s", err) + created, _, err := b.mc.Client.CreatePost(context.TODO(), p) + if err != nil { + b.Log.Errorf("test: CreatePost urgent priority failed: %s", err) + } else { + b.Log.Debugf("test: created urgent priority post %s, setting priority...", created.Id) + prio := "urgent" + b.mc.Client.SetPostPriority(context.TODO(), created.Id, &model.PostPriority{Priority: &prio}) } } time.Sleep(time.Second) diff --git a/gateway/gateway.go b/gateway/gateway.go index 4418a31b7e..2518515bdd 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -27,8 +27,8 @@ type Gateway struct { ChannelOptions map[string]config.ChannelOptions Message chan config.Message Name string - Messages *lru.Cache - PersistentCache *PersistentMsgCache + Messages *lru.Cache + BridgeCaches map[string]*PersistentMsgCache // per-bridge persistent caches, keyed by Account logger *logrus.Entry } @@ -60,17 +60,24 @@ func New(rootLogger *logrus.Logger, cfg *config.Gateway, r *Router) *Gateway { logger.Errorf("Failed to add configuration to gateway: %#v", err) } - // Initialize persistent message ID cache if configured. - // Check per-bridge settings first (br.GetString falls back to [general]). - var cachePath string + // Initialize per-bridge persistent message ID caches. + // Each bridge with a MessageCacheFile setting gets its own cache. + // br.GetString() checks per-bridge config first, then falls back to [general]. + // Bridges resolving to the same file path share one cache instance. + gw.BridgeCaches = make(map[string]*PersistentMsgCache) + pathToCache := make(map[string]*PersistentMsgCache) for _, br := range gw.Bridges { - if p := br.GetString("MessageCacheFile"); p != "" { - cachePath = p - break + p := br.GetString("MessageCacheFile") + if p == "" { + continue + } + if existing, ok := pathToCache[p]; ok { + gw.BridgeCaches[br.Account] = existing + } else { + cache := NewPersistentMsgCache(p, logger) + pathToCache[p] = cache + gw.BridgeCaches[br.Account] = cache } - } - if cachePath != "" { - gw.PersistentCache = NewPersistentMsgCache(cachePath, logger) } return gw @@ -95,15 +102,15 @@ func (gw *Gateway) FindCanonicalMsgID(protocol string, mID string) string { } // Fallback to persistent cache if LRU missed. - if gw.PersistentCache != nil { + if gw.hasPersistentCache() { // Check if ID is a direct key in persistent cache. - if entries, ok := gw.PersistentCache.Get(ID); ok { + if entries, ok := gw.persistentCacheGet(ID); ok { gw.restoreToCacheFindCanonical(ID, entries) return ID } // Check if ID is a downstream value. - if canonical := gw.PersistentCache.FindDownstream(ID); canonical != "" { - if entries, ok := gw.PersistentCache.Get(canonical); ok { + if canonical := gw.persistentCacheFindDownstream(ID); canonical != "" { + if entries, ok := gw.persistentCacheGet(canonical); ok { gw.restoreToCacheFindCanonical(canonical, entries) } return canonical @@ -142,6 +149,57 @@ func (gw *Gateway) findBridge(protocol, name string) *bridge.Bridge { return nil } +// hasPersistentCache returns true if any bridge has a persistent cache configured. +func (gw *Gateway) hasPersistentCache() bool { + return len(gw.BridgeCaches) > 0 +} + +// persistentCacheAdd writes an entry to all unique persistent caches. +func (gw *Gateway) persistentCacheAdd(key string, entries []PersistentMsgEntry) { + seen := make(map[*PersistentMsgCache]bool) + for _, cache := range gw.BridgeCaches { + if cache != nil && !seen[cache] { + cache.Add(key, entries) + seen[cache] = true + } + } +} + +// persistentCacheGet looks up an entry across all persistent caches. +func (gw *Gateway) persistentCacheGet(key string) ([]PersistentMsgEntry, bool) { + for _, cache := range gw.BridgeCaches { + if cache != nil { + if entries, ok := cache.Get(key); ok { + return entries, true + } + } + } + return nil, false +} + +// persistentCacheFindDownstream searches for a downstream ID across all persistent caches. +func (gw *Gateway) persistentCacheFindDownstream(id string) string { + for _, cache := range gw.BridgeCaches { + if cache != nil { + if key := cache.FindDownstream(id); key != "" { + return key + } + } + } + return "" +} + +// stopPersistentCaches stops all unique persistent cache instances. +func (gw *Gateway) stopPersistentCaches() { + seen := make(map[*PersistentMsgCache]bool) + for _, cache := range gw.BridgeCaches { + if cache != nil && !seen[cache] { + cache.Stop() + seen[cache] = true + } + } +} + // AddBridge sets up a new bridge on startup. // // It's added in the gateway object with the specified configuration, and is @@ -346,8 +404,8 @@ func (gw *Gateway) getDestMsgID(msgID string, dest *bridge.Bridge, channel *conf } // Fallback to persistent cache if LRU missed. - if gw.PersistentCache != nil { - if entries, ok := gw.PersistentCache.Get(msgID); ok { + if gw.hasPersistentCache() { + if entries, ok := gw.persistentCacheGet(msgID); ok { // Restore to LRU and retry. gw.restoreToCacheFindCanonical(msgID, entries) if res, ok := gw.Messages.Get(msgID); ok { diff --git a/gateway/router.go b/gateway/router.go index 7966c58d46..e4de7e2c98 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -181,7 +181,7 @@ func (r *Router) handleReceive() { } // Write-through to persistent cache. - if gw.PersistentCache != nil && len(msgIDs) > 0 { + if gw.hasPersistentCache() && len(msgIDs) > 0 { var entries []PersistentMsgEntry for _, mid := range msgIDs { if mid.br != nil && mid.ID != "" { @@ -194,7 +194,7 @@ func (r *Router) handleReceive() { } } if len(entries) > 0 { - gw.PersistentCache.Add(cacheKey, entries) + gw.persistentCacheAdd(cacheKey, entries) } } } @@ -231,7 +231,7 @@ func (r *Router) handleHistoricalMapping(msg *config.Message) { sourceKey := sourceProtocol + " " + sourceMessageID for _, gw := range r.Gateways { - if gw.PersistentCache == nil { + if !gw.hasPersistentCache() { continue } @@ -264,8 +264,8 @@ func (r *Router) handleHistoricalMapping(msg *config.Message) { } // Store: sourceKey → points to local bridge (e.g., "mattermost POST123" → msteams entry) - if _, exists := gw.PersistentCache.Get(sourceKey); !exists { - gw.PersistentCache.Add(sourceKey, []PersistentMsgEntry{{ + if _, exists := gw.persistentCacheGet(sourceKey); !exists { + gw.persistentCacheAdd(sourceKey, []PersistentMsgEntry{{ Protocol: localBridge.Protocol, BridgeName: localBridge.Name, ID: localKey, @@ -274,8 +274,8 @@ func (r *Router) handleHistoricalMapping(msg *config.Message) { } // Store: localKey → points to source bridge (e.g., "msteams TEAMS456" → mattermost entry) - if _, exists := gw.PersistentCache.Get(localKey); !exists && sourceChannelID != "" { - gw.PersistentCache.Add(localKey, []PersistentMsgEntry{{ + if _, exists := gw.persistentCacheGet(localKey); !exists && sourceChannelID != "" { + gw.persistentCacheAdd(localKey, []PersistentMsgEntry{{ Protocol: sourceBridge.Protocol, BridgeName: sourceBridge.Name, ID: sourceKey, From 12902444cfdb9923f5dcfb9bcf9e17006fc6c721 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 16:23:27 +0100 Subject: [PATCH 18/42] Fix priority test: use simple posts (SetPostPriority not in Client4) Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/test.go | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 0f2bb5ccac..caea38a611 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -140,43 +140,14 @@ func (b *Bmattermost) runTestSequence(channelName string) { time.Sleep(time.Second) // Step 15: Important priority message - // Create post first, then set priority via separate API call. - // (Metadata in CreatePost is ignored by the server.) - { - p := &model.Post{ - ChannelId: channelID, - Message: "Priority test: important message", - RootId: rootID, - Props: testProps, - } - created, _, err := b.mc.Client.CreatePost(context.TODO(), p) - if err != nil { - b.Log.Errorf("test: CreatePost important priority failed: %s", err) - } else { - b.Log.Debugf("test: created important priority post %s, setting priority...", created.Id) - prio := "important" - b.mc.Client.SetPostPriority(context.TODO(), created.Id, &model.PostPriority{Priority: &prio}) - } - } + // NOTE: Client4.SetPostPriority is not available in this model version. + // Posts are created as regular messages; priority must be set manually in MM + // to test the priority extraction + relay pipeline. + post("❗ Priority test: important message", rootID) time.Sleep(time.Second) // Step 16: Urgent priority message - { - p := &model.Post{ - ChannelId: channelID, - Message: "Priority test: urgent message", - RootId: rootID, - Props: testProps, - } - created, _, err := b.mc.Client.CreatePost(context.TODO(), p) - if err != nil { - b.Log.Errorf("test: CreatePost urgent priority failed: %s", err) - } else { - b.Log.Debugf("test: created urgent priority post %s, setting priority...", created.Id) - prio := "urgent" - b.mc.Client.SetPostPriority(context.TODO(), created.Id, &model.PostPriority{Priority: &prio}) - } - } + post("🚨 Priority test: urgent message", rootID) time.Sleep(time.Second) // Step 17: Delete the marked message From 62e2f9a09f58852610c036ee6fbca5bacb03e6b6 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Thu, 12 Mar 2026 17:04:45 +0100 Subject: [PATCH 19/42] rework Mattermost Message Handling, fix MessageCacheFile Handling, fix priority tests, add manual teams file transfer test --- bridge/mattermost/test.go | 47 +++++++++++++++++++++++++++++++++------ bridge/msteams/test.go | 10 ++++++--- gateway/gateway.go | 13 +++++------ gateway/router.go | 6 ++--- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index caea38a611..e0d14dd21e 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -139,15 +139,48 @@ func (b *Bmattermost) runTestSequence(channelName string) { } time.Sleep(time.Second) - // Step 15: Important priority message - // NOTE: Client4.SetPostPriority is not available in this model version. - // Posts are created as regular messages; priority must be set manually in MM - // to test the priority extraction + relay pipeline. - post("❗ Priority test: important message", rootID) + // Step 15: Important priority message (set via API metadata) + { + prio := "important" + p := &model.Post{ + ChannelId: channelID, + Message: "Priority test: important message", + RootId: rootID, + Props: testProps, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: &prio, + }, + }, + } + if created, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with important priority failed: %s", err) + } else if created != nil { + b.Log.Infof("test: posted important priority message %s", created.Id) + } + } time.Sleep(time.Second) - // Step 16: Urgent priority message - post("🚨 Priority test: urgent message", rootID) + // Step 16: Urgent priority message (set via API metadata) + { + prio := "urgent" + p := &model.Post{ + ChannelId: channelID, + Message: "Priority test: urgent message", + RootId: rootID, + Props: testProps, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: &prio, + }, + }, + } + if created, _, err := b.mc.Client.CreatePost(context.TODO(), p); err != nil { + b.Log.Errorf("test: CreatePost with urgent priority failed: %s", err) + } else if created != nil { + b.Log.Infof("test: posted urgent priority message %s", created.Id) + } + } time.Sleep(time.Second) // Step 17: Delete the marked message diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go index c9b180881f..e544d9121b 100644 --- a/bridge/msteams/test.go +++ b/bridge/msteams/test.go @@ -286,19 +286,23 @@ func (b *Bmsteams) runTestSequence(channelName string) { postReply(rootID, "⚠️ Please manually check GIF file transmission from Teams to Mattermost — this test cannot upload files to your SharePoint.", nil) time.Sleep(time.Second) - // Step 14: Multi-image (2x PNG in one message) + // Step 14: File upload — SharePoint needed, cannot be automated. + postReply(rootID, "⚠️ Please manually check file (PDF, etc.) transmission from Teams to Mattermost — this test cannot upload files to your SharePoint.", nil) + time.Sleep(time.Second) + + // Step 15: Multi-image (2x PNG in one message) postReplyWithImages(rootID, "Image test: multi-image (2x PNG)", []testImage{ {name: "demo1.png", contentType: "image/png", data: testdata.DemoPNG}, {name: "demo2.png", contentType: "image/png", data: testdata.DemoPNG}, }) time.Sleep(time.Second) - // Step 15: Delete the marked message + // Step 16: Delete the marked message if deleteID != "" { deleteReply(rootID, deleteID) } - // Step 16: Test finished + // Step 17: Test finished postReply(rootID, "✅ Test finished", nil) b.Log.Info("test: test sequence completed") diff --git a/gateway/gateway.go b/gateway/gateway.go index 2518515bdd..d46df59dc2 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -154,14 +154,11 @@ func (gw *Gateway) hasPersistentCache() bool { return len(gw.BridgeCaches) > 0 } -// persistentCacheAdd writes an entry to all unique persistent caches. -func (gw *Gateway) persistentCacheAdd(key string, entries []PersistentMsgEntry) { - seen := make(map[*PersistentMsgCache]bool) - for _, cache := range gw.BridgeCaches { - if cache != nil && !seen[cache] { - cache.Add(key, entries) - seen[cache] = true - } +// persistentCacheAdd writes an entry to the persistent cache of the given +// source bridge account. Lookups (Get/FindDownstream) still search all caches. +func (gw *Gateway) persistentCacheAdd(key string, entries []PersistentMsgEntry, sourceAccount string) { + if cache, ok := gw.BridgeCaches[sourceAccount]; ok && cache != nil { + cache.Add(key, entries) } } diff --git a/gateway/router.go b/gateway/router.go index e4de7e2c98..5f9505387a 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -194,7 +194,7 @@ func (r *Router) handleReceive() { } } if len(entries) > 0 { - gw.persistentCacheAdd(cacheKey, entries) + gw.persistentCacheAdd(cacheKey, entries, msg.Account) } } } @@ -270,7 +270,7 @@ func (r *Router) handleHistoricalMapping(msg *config.Message) { BridgeName: localBridge.Name, ID: localKey, ChannelID: localChannelID, - }}) + }}, sourceBridge.Account) } // Store: localKey → points to source bridge (e.g., "msteams TEAMS456" → mattermost entry) @@ -280,7 +280,7 @@ func (r *Router) handleHistoricalMapping(msg *config.Message) { BridgeName: sourceBridge.Name, ID: sourceKey, ChannelID: sourceChannelID, - }}) + }}, msg.Account) } } } From d7223dc8857b65efe8aa732477629d9e14db6b12 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 10:16:20 +0100 Subject: [PATCH 20/42] Add MediaDownloadSize enforcement for Teams, message replay on restart, autolink fix, priority test fix - Teams bridge: enforce MediaDownloadSize via HTTP HEAD pre-check + LimitReader fallback, notify sender when file exceeds limit (handleDownloadFile, handleHostedContents) - New DownloadFileWithSizeCheck helper with ErrFileTooLarge error type - Message replay: fetch and relay missed messages on bridge restart using configurable ReplayWindow (per-bridge with [general] fallback), LastSeen tracking in PersistentMsgCache, dedup via persistent cache, thread preservation - Priority test: send priority posts as root posts (Mattermost requires this), reorder steps so delete runs before priority, "Test finished" stays last - Autolink: add parser.Autolink to mdToTeamsHTML so plain URLs from Mattermost become clickable tags in Teams Co-Authored-By: Claude Opus 4.6 --- bridge/bridge.go | 8 ++ bridge/config/config.go | 2 + bridge/helper/helper.go | 64 ++++++++++++++ bridge/mattermost/mattermost.go | 145 +++++++++++++++++++++++++++++++- bridge/mattermost/test.go | 25 +++--- bridge/msteams/handler.go | 41 +++++++-- bridge/msteams/msteams.go | 140 +++++++++++++++++++++++++++++- gateway/gateway.go | 20 +++++ gateway/msgcache.go | 28 ++++++ gateway/router.go | 32 +++++++ 10 files changed, 484 insertions(+), 21 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 44642e7339..63d9df7da1 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -45,6 +45,14 @@ type Config struct { *Bridge Remote chan config.Message + + // IsMessageBridged checks whether a message has already been bridged + // (exists in the persistent cache). Used by replay to avoid duplicates. + IsMessageBridged func(protocol, msgID string) bool + + // GetLastSeen returns the timestamp of the last processed message for a channel. + // Used by replay to determine the cutoff point. + GetLastSeen func(channelKey string) (time.Time, bool) } // Factory is the factory function to create a bridge diff --git a/bridge/config/config.go b/bridge/config/config.go index 0fe1c444f9..7be61872ed 100644 --- a/bridge/config/config.go +++ b/bridge/config/config.go @@ -31,6 +31,7 @@ const ( EventGetChannelMembers = "get_channel_members" EventNoticeIRC = "notice_irc" EventHistoricalMapping = "historical_mapping" + EventReplayMessage = "replay_message" ) const ParentIDNotFound = "msg-parent-not-found" @@ -157,6 +158,7 @@ type Protocol struct { MediaConvertTgs string // telegram MediaConvertWebPToPNG bool // telegram MessageCacheFile string // general, msteams, mattermost: persistent message ID cache file + ReplayWindow string // general, msteams, mattermost: duration for replay window on startup (e.g. "24h") MessageDelay int // IRC, time in millisecond to wait between messages MessageFormat string // telegram MessageLength int // IRC, max length of a message allowed diff --git a/bridge/helper/helper.go b/bridge/helper/helper.go index dd9a6d4fa6..116da8a441 100644 --- a/bridge/helper/helper.go +++ b/bridge/helper/helper.go @@ -23,10 +23,74 @@ import ( var errHttpGetNotOk = errors.New("HTTP server responded non-OK code") +// ErrFileTooLarge is returned when a file exceeds the configured MediaDownloadSize. +type ErrFileTooLarge struct { + Size int64 + MaxSize int +} + +func (e *ErrFileTooLarge) Error() string { + return fmt.Sprintf("file too large (%d bytes, limit %d bytes)", e.Size, e.MaxSize) +} + func HttpGetNotOkError(url string, code int) error { return fmt.Errorf("%w: %s returned code %d", errHttpGetNotOk, url, code) } +// DownloadFileWithSizeCheck downloads a file, aborting if it exceeds maxSize bytes. +// First tries an HTTP HEAD request to check Content-Length before downloading. +// If HEAD is not supported or returns no Content-Length, falls back to a size-limited +// download that reads at most maxSize+1 bytes to detect oversized files without +// buffering the entire content. +func DownloadFileWithSizeCheck(url string, maxSize int) (*[]byte, error) { + client := &http.Client{Timeout: time.Second * 30} + + // Try HEAD first to check Content-Length without downloading. + headReq, err := http.NewRequest("HEAD", url, nil) + if err == nil { + if headResp, headErr := client.Do(headReq); headErr == nil { + headResp.Body.Close() + if cl := headResp.ContentLength; cl > 0 && cl > int64(maxSize) { + return nil, &ErrFileTooLarge{Size: cl, MaxSize: maxSize} + } + } + } + + // Download with size limit. + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, HttpGetNotOkError(url, resp.StatusCode) + } + + // Check Content-Length from GET response too. + if cl := resp.ContentLength; cl > 0 && cl > int64(maxSize) { + return nil, &ErrFileTooLarge{Size: cl, MaxSize: maxSize} + } + + // Read up to maxSize+1 bytes. If we get more, the file is too large. + limited := io.LimitReader(resp.Body, int64(maxSize)+1) + var buf bytes.Buffer + n, err := io.Copy(&buf, limited) + if err != nil { + return nil, err + } + if n > int64(maxSize) { + return nil, &ErrFileTooLarge{Size: n, MaxSize: maxSize} + } + + data := buf.Bytes() + return &data, nil +} + // DownloadFile downloads the given non-authenticated URL. func DownloadFile(url string) (*[]byte, error) { return DownloadFileAuth(url, "") diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 08f80a44a7..f3613d9efd 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -128,8 +128,11 @@ func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error { } } - // Scan recent messages for historical source-ID markers in background. - go b.scanHistoricalMappings(channel) + // Scan recent messages for historical source-ID markers, then replay missed messages. + go func() { + b.scanHistoricalMappings(channel) + b.replayMissedMessages(channel) + }() return nil } @@ -173,6 +176,144 @@ func (b *Bmattermost) scanHistoricalMappings(channel config.ChannelInfo) { } } +// replayMissedMessages fetches recent messages from the channel and replays any +// that were not yet bridged. This catches up on messages missed during downtime. +func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { + if b.mc == nil || b.IsMessageBridged == nil || b.GetLastSeen == nil { + return + } + + replayWindowStr := b.GetString("ReplayWindow") + if replayWindowStr == "" { + return + } + replayWindow, err := time.ParseDuration(replayWindowStr) + if err != nil { + b.Log.Errorf("replayMissedMessages: invalid ReplayWindow %q: %s", replayWindowStr, err) + return + } + + channelID := b.getChannelID(channel.Name) + if channelID == "" { + return + } + + channelKey := channel.Name + b.Account + cutoff := time.Now().Add(-replayWindow) + + // Use last-seen timestamp if available and more recent than window cutoff. + if lastSeen, ok := b.GetLastSeen(channelKey); ok && lastSeen.After(cutoff) { + cutoff = lastSeen + } + + sinceMillis := cutoff.UnixMilli() + postList, _, err := b.mc.Client.GetPostsSince(context.TODO(), channelID, sinceMillis, "") + if err != nil { + b.Log.Errorf("replayMissedMessages: GetPostsSince failed: %s", err) + return + } + + // Collect and sort posts by CreateAt ascending (oldest first). + type postEntry struct { + id string + post *model.Post + } + var posts []postEntry + for _, id := range postList.Order { + post := postList.Posts[id] + if post.CreateAt < sinceMillis { + continue + } + posts = append(posts, postEntry{id, post}) + } + // Sort oldest first. + for i := 0; i < len(posts); i++ { + for j := i + 1; j < len(posts); j++ { + if posts[j].post.CreateAt < posts[i].post.CreateAt { + posts[i], posts[j] = posts[j], posts[i] + } + } + } + + count := 0 + propKey := "matterbridge_" + b.uuid + for _, pe := range posts { + post := pe.post + + // Skip messages sent by matterbridge itself. + if post.Props != nil { + if _, ok := post.Props[propKey].(bool); ok { + continue + } + // Also skip test messages. + if _, ok := post.Props["matterbridge_test"]; ok { + continue + } + } + + // Skip system messages. + if post.Type != "" && strings.HasPrefix(post.Type, "system_") { + continue + } + + // Skip if already bridged. + if b.IsMessageBridged("mattermost", post.Id) { + continue + } + + // Resolve username for the post author. + username := "" + if post.Props != nil { + if override, ok := post.Props["override_username"].(string); ok && override != "" { + username = override + } + } + if username == "" { + user, _, userErr := b.mc.Client.GetUser(context.TODO(), post.UserId, "") + if userErr == nil && user != nil { + if !b.GetBool("useusername") && user.Nickname != "" { + username = user.Nickname + } else { + username = user.Username + } + } else { + username = "unknown" + } + } + + // Format replay prefix with original timestamp. + createTime := time.UnixMilli(post.CreateAt) + replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04")) + + rmsg := config.Message{ + Event: config.EventReplayMessage, + Account: b.Account, + Channel: channel.Name, + Username: username, + UserID: post.UserId, + Text: replayPrefix + post.Message, + ID: post.Id, + ParentID: post.RootId, + Extra: make(map[string][]interface{}), + } + + // Handle file attachments. + for _, fileID := range post.FileIds { + if dlErr := b.handleDownloadFile(&rmsg, fileID); dlErr != nil { + b.Log.Errorf("replay: download failed for %s: %s", fileID, dlErr) + } + } + + b.Remote <- rmsg + count++ + time.Sleep(500 * time.Millisecond) + } + + if count > 0 { + b.Log.Infof("replayMissedMessages: replayed %d messages from %s", count, channel.Name) + } +} + // lookupWebhookPostID searches recent channel posts to find the ID of a message // just sent via webhook. Webhooks don't return post IDs, but the gateway needs // them to map replies across bridges. We look for a recent post from the bot diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index e0d14dd21e..9340e9ac2f 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -139,13 +139,22 @@ func (b *Bmattermost) runTestSequence(channelName string) { } time.Sleep(time.Second) - // Step 15: Important priority message (set via API metadata) + // Step 15: Delete the marked message + if deleteID != "" { + _, err := b.mc.Client.DeletePost(context.TODO(), deleteID) + if err != nil { + b.Log.Errorf("test: DeletePost failed: %s", err) + } + } + time.Sleep(time.Second) + + // Step 16: Important priority message as ROOT POST (priority only allowed on root posts) { prio := "important" p := &model.Post{ ChannelId: channelID, Message: "Priority test: important message", - RootId: rootID, + RootId: "", Props: testProps, Metadata: &model.PostMetadata{ Priority: &model.PostPriority{ @@ -161,13 +170,13 @@ func (b *Bmattermost) runTestSequence(channelName string) { } time.Sleep(time.Second) - // Step 16: Urgent priority message (set via API metadata) + // Step 17: Urgent priority message as ROOT POST (priority only allowed on root posts) { prio := "urgent" p := &model.Post{ ChannelId: channelID, Message: "Priority test: urgent message", - RootId: rootID, + RootId: "", Props: testProps, Metadata: &model.PostMetadata{ Priority: &model.PostPriority{ @@ -183,14 +192,6 @@ func (b *Bmattermost) runTestSequence(channelName string) { } time.Sleep(time.Second) - // Step 17: Delete the marked message - if deleteID != "" { - _, err := b.mc.Client.DeletePost(context.TODO(), deleteID) - if err != nil { - b.Log.Errorf("test: DeletePost failed: %s", err) - } - } - // Step 18: Test finished post("✅ Test finished", rootID) diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index 4fcdb0e2b8..b0d21f1724 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -2,6 +2,7 @@ package bmsteams import ( "encoding/json" + "errors" "fmt" "io" "regexp" @@ -33,15 +34,21 @@ func (b *Bmsteams) findFile(weburl string) (string, error) { return "", nil } -// handleDownloadFile handles file download +// handleDownloadFile handles file download with size validation. func (b *Bmsteams) handleDownloadFile(rmsg *config.Message, filename, weburl string) error { realURL, err := b.findFile(weburl) if err != nil { return err } - // Actually download the file. - data, err := helper.DownloadFile(realURL) + // Download the file with size limit enforcement. + data, err := helper.DownloadFileWithSizeCheck(realURL, b.General.MediaDownloadSize) if err != nil { + var tooLarge *helper.ErrFileTooLarge + if errors.As(err, &tooLarge) { + b.Log.Warnf("file %s too large (%d bytes, limit %d bytes)", filename, tooLarge.Size, tooLarge.MaxSize) + b.notifyFileTooLarge(rmsg, filename, tooLarge.Size, tooLarge.MaxSize) + return err + } return fmt.Errorf("download %s failed %#v", weburl, err) } @@ -54,6 +61,20 @@ func (b *Bmsteams) handleDownloadFile(rmsg *config.Message, filename, weburl str return nil } +// notifyFileTooLarge sends a warning reply back to the source channel +// so the sender knows their file was not transferred. +func (b *Bmsteams) notifyFileTooLarge(rmsg *config.Message, filename string, actualSize int64, maxSize int) { + b.Remote <- config.Message{ + Text: fmt.Sprintf("⚠️ File **%s** could not be transferred — file too large (%d MB, limit: %d MB).", + filename, actualSize/(1024*1024), maxSize/(1024*1024)), + Channel: rmsg.Channel, + Account: b.Account, + Username: "matterbridge", + ParentID: rmsg.ID, + Extra: make(map[string][]interface{}), + } +} + func (b *Bmsteams) handleAttachments(rmsg *config.Message, msg msgraph.ChatMessage) { for _, a := range msg.Attachments { //remove the attachment tags from the text @@ -145,15 +166,23 @@ func (b *Bmsteams) handleHostedContents(rmsg *config.Message, msg msgraph.ChatMe continue } - data, err := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + resp.Body.Close() + b.Log.Errorf("handleHostedContents: GET %s returned %d", apiURL, resp.StatusCode) + continue + } + + maxSize := b.General.MediaDownloadSize + data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxSize)+1)) resp.Body.Close() if err != nil { b.Log.Errorf("handleHostedContents: reading body for %s failed: %s", filename, err) continue } - if resp.StatusCode >= 400 { - b.Log.Errorf("handleHostedContents: GET %s returned %d", apiURL, resp.StatusCode) + if len(data) > maxSize { + b.Log.Warnf("handleHostedContents: %s too large (>%d bytes, limit %d bytes)", filename, maxSize, maxSize) + b.notifyFileTooLarge(rmsg, filename, int64(len(data)), maxSize) continue } diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 980397884f..04663d34e2 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -98,6 +98,9 @@ func (b *Bmsteams) Disconnect() error { } func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { + // Replay missed messages before starting the poll loop. + b.replayMissedMessages(channel.Name) + go func(name string) { for { err := b.poll(name) @@ -110,6 +113,141 @@ func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { return nil } +// replayMissedMessages fetches recent messages from the Teams channel and replays +// any that were not yet bridged. This catches up on messages missed during downtime. +func (b *Bmsteams) replayMissedMessages(channelName string) { + if b.IsMessageBridged == nil || b.GetLastSeen == nil { + return + } + + replayWindowStr := b.GetString("ReplayWindow") + if replayWindowStr == "" { + return + } + replayWindow, err := time.ParseDuration(replayWindowStr) + if err != nil { + b.Log.Errorf("replayMissedMessages: invalid ReplayWindow %q: %s", replayWindowStr, err) + return + } + + channelKey := channelName + b.Account + cutoff := time.Now().Add(-replayWindow) + + if lastSeen, ok := b.GetLastSeen(channelKey); ok && lastSeen.After(cutoff) { + cutoff = lastSeen + } + + // Fetch recent messages via Graph API with $top=50 for broader coverage. + teamID := b.GetString("TeamID") + channelID := decodeChannelID(channelName) + apiURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50&$orderby=lastModifiedDateTime+desc", + teamID, channelID) + + token, tokenErr := b.getAccessToken() + if tokenErr != nil { + b.Log.Errorf("replayMissedMessages: getAccessToken failed: %s", tokenErr) + return + } + + req, reqErr := http.NewRequestWithContext(b.ctx, http.MethodGet, apiURL, nil) + if reqErr != nil { + b.Log.Errorf("replayMissedMessages: NewRequest failed: %s", reqErr) + return + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, doErr := http.DefaultClient.Do(req) + if doErr != nil { + b.Log.Errorf("replayMissedMessages: HTTP request failed: %s", doErr) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + b.Log.Errorf("replayMissedMessages: API returned %d: %s", resp.StatusCode, string(body)) + return + } + + var result struct { + Value []msgraph.ChatMessage `json:"value"` + } + body, _ := io.ReadAll(resp.Body) + if jsonErr := json.Unmarshal(body, &result); jsonErr != nil { + b.Log.Errorf("replayMissedMessages: JSON parse failed: %s", jsonErr) + return + } + + // Filter and sort messages: oldest first, only those after cutoff. + var messages []msgraph.ChatMessage + for _, msg := range result.Value { + if msg.CreatedDateTime == nil || msg.CreatedDateTime.Before(cutoff) { + continue + } + if msg.ID == nil || msg.From == nil || msg.From.User == nil || msg.Body == nil { + continue + } + if msg.DeletedDateTime != nil { + continue + } + messages = append(messages, msg) + } + // Sort oldest first (bubble sort for simplicity). + for i := 0; i < len(messages); i++ { + for j := i + 1; j < len(messages); j++ { + if messages[j].CreatedDateTime.Before(*messages[i].CreatedDateTime) { + messages[i], messages[j] = messages[j], messages[i] + } + } + } + + count := 0 + for _, msg := range messages { + // Skip messages we sent. + if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { + continue + } + + // Skip if already bridged. + if b.IsMessageBridged("msteams", *msg.ID) { + continue + } + + text := b.convertToMD(*msg.Body.Content) + + // Prepend subject if present. + if msg.Subject != nil && *msg.Subject != "" { + text = "**" + *msg.Subject + "**\n" + text + } + + // Format replay prefix with original timestamp. + createTime := *msg.CreatedDateTime + replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04")) + + rmsg := config.Message{ + Event: config.EventReplayMessage, + Username: *msg.From.User.DisplayName, + Text: replayPrefix + text, + Channel: channelName, + Account: b.Account, + UserID: *msg.From.User.ID, + ID: *msg.ID, + Extra: make(map[string][]interface{}), + } + + b.handleAttachments(&rmsg, msg) + b.handleHostedContents(&rmsg, msg, "") + + b.Remote <- rmsg + count++ + time.Sleep(500 * time.Millisecond) + } + + if count > 0 { + b.Log.Infof("replayMissedMessages: replayed %d messages from %s", count, channelName) + } +} + func (b *Bmsteams) Send(msg config.Message) (string, error) { b.Log.Debugf("=> Receiving %#v", msg) @@ -237,7 +375,7 @@ func (b *Bmsteams) Send(msg config.Message) (string, error) { // code fences, and line breaks using the gomarkdown library. // Code fences are post-processed to use Teams-native tags. func mdToTeamsHTML(text string) string { - extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode | parser.Strikethrough + extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode | parser.Strikethrough | parser.Autolink p := parser.NewWithExtensions(extensions) renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: 0}) result := string(markdown.ToHTML([]byte(text), p, renderer)) diff --git a/gateway/gateway.go b/gateway/gateway.go index d46df59dc2..a5a054cc5a 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -221,6 +221,26 @@ func (gw *Gateway) AddBridge(cfg *config.Bridge) error { brconfig := &bridge.Config{ Remote: gw.Message, Bridge: br, + IsMessageBridged: func(protocol, msgID string) bool { + key := protocol + " " + msgID + if _, exists := gw.persistentCacheGet(key); exists { + return true + } + if downstream := gw.persistentCacheFindDownstream(key); downstream != "" { + return true + } + return false + }, + GetLastSeen: func(channelKey string) (time.Time, bool) { + for _, cache := range gw.BridgeCaches { + if cache != nil { + if t, ok := cache.GetLastSeen(channelKey); ok { + return t, true + } + } + } + return time.Time{}, false + }, } // add the actual bridger for this protocol to this bridge using the bridgeMap if _, ok := gw.Router.BridgeMap[br.Protocol]; !ok { diff --git a/gateway/msgcache.go b/gateway/msgcache.go index 34ab9ed2c1..9ada703599 100644 --- a/gateway/msgcache.go +++ b/gateway/msgcache.go @@ -127,6 +127,34 @@ func (c *PersistentMsgCache) Flush() { c.dirty = false } +// SetLastSeen stores the timestamp of the last processed message for a channel. +// The channelKey should uniquely identify a channel+account combination. +func (c *PersistentMsgCache) SetLastSeen(channelKey string, t time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.data[lastSeenPrefix+channelKey] = []PersistentMsgEntry{{ + ID: t.Format(time.RFC3339Nano), + }} + c.dirty = true +} + +// GetLastSeen returns the timestamp of the last processed message for a channel. +func (c *PersistentMsgCache) GetLastSeen(channelKey string) (time.Time, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entries, ok := c.data[lastSeenPrefix+channelKey] + if !ok || len(entries) == 0 { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339Nano, entries[0].ID) + if err != nil { + return time.Time{}, false + } + return t, true +} + +const lastSeenPrefix = "__last_seen__:" + // Stop stops the background flush loop and performs a final flush. func (c *PersistentMsgCache) Stop() { close(c.stopCh) diff --git a/gateway/router.go b/gateway/router.go index 5f9505387a..c8f2a324cd 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -150,6 +150,33 @@ func (r *Router) handleReceive() { continue } + // Handle replay messages — check persistent cache for dedup, then treat as normal. + isReplay := msg.Event == config.EventReplayMessage + if isReplay { + if msg.ID != "" { + cacheKey := msg.Protocol + " " + msg.ID + alreadyBridged := false + for _, gw := range r.Gateways { + if !gw.hasPersistentCache() { + continue + } + if _, exists := gw.persistentCacheGet(cacheKey); exists { + alreadyBridged = true + break + } + if downstream := gw.persistentCacheFindDownstream(cacheKey); downstream != "" { + alreadyBridged = true + break + } + } + if alreadyBridged { + r.logger.Debugf("replay: skipping already-bridged message %s", cacheKey) + continue + } + } + msg.Event = "" // clear so downstream pipeline treats it as a normal message + } + filesHandled := false for _, gw := range r.Gateways { // record all the message ID's of the different bridges @@ -196,6 +223,11 @@ func (r *Router) handleReceive() { if len(entries) > 0 { gw.persistentCacheAdd(cacheKey, entries, msg.Account) } + // Update last-seen timestamp for the source channel. + channelKey := msg.Channel + msg.Account + if cache, ok := gw.BridgeCaches[msg.Account]; ok && cache != nil { + cache.SetLastSeen(channelKey, msg.Timestamp) + } } } } From f8cc7840246f096e6f94300bc7350da3f1df464a Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 10:33:06 +0100 Subject: [PATCH 21/42] Fix GetPostsSince: last param is bool (collapsedThreads), not string Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/mattermost.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index f3613d9efd..1e1efff514 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -207,7 +207,7 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { } sinceMillis := cutoff.UnixMilli() - postList, _, err := b.mc.Client.GetPostsSince(context.TODO(), channelID, sinceMillis, "") + postList, _, err := b.mc.Client.GetPostsSince(context.TODO(), channelID, sinceMillis, false) if err != nil { b.Log.Errorf("replayMissedMessages: GetPostsSince failed: %s", err) return From 16ec7fd286cd499ad2e693142ae6d9302002b604 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 10:54:39 +0100 Subject: [PATCH 22/42] Fix notifyFileTooLarge to post warning in Teams (source), remove $orderby from replay API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notifyFileTooLarge: use Graph API reply directly instead of b.Remote, so the warning appears in the Teams channel where the file was uploaded (b.Remote would route it to Mattermost instead) - Teams replay: remove $orderby=lastModifiedDateTime+desc from Graph API URL — not supported by the messages endpoint, client-side sort suffices Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/handler.go | 39 +++++++++++++++++++++++++++++---------- bridge/msteams/msteams.go | 2 +- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index b0d21f1724..af9602287b 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -61,17 +61,36 @@ func (b *Bmsteams) handleDownloadFile(rmsg *config.Message, filename, weburl str return nil } -// notifyFileTooLarge sends a warning reply back to the source channel -// so the sender knows their file was not transferred. +// notifyFileTooLarge posts a warning reply directly into the Teams channel +// (via Graph API) so the sender sees that their file was not transferred. +// This must NOT use b.Remote because the handler runs on the source side — +// b.Remote would route the warning to the destination bridge instead. func (b *Bmsteams) notifyFileTooLarge(rmsg *config.Message, filename string, actualSize int64, maxSize int) { - b.Remote <- config.Message{ - Text: fmt.Sprintf("⚠️ File **%s** could not be transferred — file too large (%d MB, limit: %d MB).", - filename, actualSize/(1024*1024), maxSize/(1024*1024)), - Channel: rmsg.Channel, - Account: b.Account, - Username: "matterbridge", - ParentID: rmsg.ID, - Extra: make(map[string][]interface{}), + teamID := b.GetString("TeamID") + channelID := decodeChannelID(rmsg.Channel) + parentID := rmsg.ID + + text := fmt.Sprintf("⚠️ File %s could not be transferred — file too large (%d MB, limit: %d MB).", + filename, actualSize/(1024*1024), maxSize/(1024*1024)) + htmlType := msgraph.BodyTypeVHTML + content := &msgraph.ItemBody{Content: &text, ContentType: &htmlType} + chatMsg := &msgraph.ChatMessage{Body: content} + + var res *msgraph.ChatMessage + var err error + if parentID != "" { + ct := b.gc.Teams().ID(teamID).Channels().ID(channelID).Messages().ID(parentID).Replies().Request() + res, err = ct.Add(b.ctx, chatMsg) + } else { + ct := b.gc.Teams().ID(teamID).Channels().ID(channelID).Messages().Request() + res, err = ct.Add(b.ctx, chatMsg) + } + if err != nil { + b.Log.Errorf("notifyFileTooLarge: failed to post warning: %s", err) + return + } + if res != nil && res.ID != nil { + b.sentIDs[*res.ID] = struct{}{} } } diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 04663d34e2..cce93c5dbf 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -140,7 +140,7 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { // Fetch recent messages via Graph API with $top=50 for broader coverage. teamID := b.GetString("TeamID") channelID := decodeChannelID(channelName) - apiURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50&$orderby=lastModifiedDateTime+desc", + apiURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50", teamID, channelID) token, tokenErr := b.getAccessToken() From b8df4ff8a9e17cd0f1576b3a2412e61c89e3bdc7 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 11:04:12 +0100 Subject: [PATCH 23/42] fix: move replayMissedMessages into goroutine to prevent JoinChannel blocking replayMissedMessages was called synchronously before the poll goroutine, causing the poll loop to never start if the Graph API call hung or b.Remote send blocked. Now runs inside the goroutine like Mattermost does. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index cce93c5dbf..9e7503bf30 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -98,10 +98,10 @@ func (b *Bmsteams) Disconnect() error { } func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { - // Replay missed messages before starting the poll loop. - b.replayMissedMessages(channel.Name) - go func(name string) { + // Replay missed messages before starting the poll loop. + // Runs inside the goroutine so it cannot block JoinChannel. + b.replayMissedMessages(name) for { err := b.poll(name) if err != nil { From 7ab79eceeed82b4d78ce46b6d901bfff7062880e Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 11:34:05 +0100 Subject: [PATCH 24/42] fix: replay thread replies, add pagination, skip empty replays, consistent log levels, test icons - replayMissedMessages now fetches thread replies via getReplies() with correct ParentID and composite key (msgID/replyID) matching poll loop - Add @odata.nextLink pagination (max 5 pages) to guarantee ReplayWindow coverage beyond 50 messages - Skip empty replay messages (no text + no files after attachment processing) - Change handleAttachments log level from ERROR to WARN for download failures (consistent with handleDownloadFile and GIF unsupported warnings) - Add from_webhook + override_icon_url to Mattermost test messages so they use the configured IconURL instead of the default robot icon Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/test.go | 8 +- bridge/msteams/handler.go | 2 +- bridge/msteams/msteams.go | 151 +++++++++++++++++++++++++++++++------- 3 files changed, 134 insertions(+), 27 deletions(-) diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 9340e9ac2f..30b452f5a1 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -26,7 +26,13 @@ func (b *Bmattermost) runTestSequence(channelName string) { b.Log.Infof("test: starting test sequence in channel %s", channelName) - testProps := model.StringInterface{"matterbridge_test": true} + testProps := model.StringInterface{ + "matterbridge_test": true, + "from_webhook": "true", + } + if iconURL := b.GetString("IconURL"); iconURL != "" { + testProps["override_icon_url"] = iconURL + } // Helper to post a message and return the post ID. post := func(message, rootID string) string { diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index af9602287b..c8e754f676 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -108,7 +108,7 @@ func (b *Bmsteams) handleAttachments(rmsg *config.Message, msg msgraph.ChatMessa //handle the download err := b.handleDownloadFile(rmsg, *a.Name, *a.ContentURL) if err != nil { - b.Log.Errorf("download of %s failed: %s", *a.Name, err) + b.Log.Warnf("download of %s failed: %s", *a.Name, err) } } } diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 9e7503bf30..ec9edbc4c5 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -137,10 +137,10 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { cutoff = lastSeen } - // Fetch recent messages via Graph API with $top=50 for broader coverage. + // Fetch recent messages via Graph API with pagination to cover the full ReplayWindow. teamID := b.GetString("TeamID") channelID := decodeChannelID(channelName) - apiURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50", + firstURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50", teamID, channelID) token, tokenErr := b.getAccessToken() @@ -149,38 +149,64 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { return } - req, reqErr := http.NewRequestWithContext(b.ctx, http.MethodGet, apiURL, nil) - if reqErr != nil { - b.Log.Errorf("replayMissedMessages: NewRequest failed: %s", reqErr) - return + type graphMessagesResponse struct { + Value []msgraph.ChatMessage `json:"value"` + NextLink string `json:"@odata.nextLink"` } - req.Header.Set("Authorization", "Bearer "+token) - resp, doErr := http.DefaultClient.Do(req) - if doErr != nil { - b.Log.Errorf("replayMissedMessages: HTTP request failed: %s", doErr) - return - } - defer resp.Body.Close() + var allRawMessages []msgraph.ChatMessage + currentURL := firstURL + const maxPages = 5 + + for page := 0; page < maxPages && currentURL != ""; page++ { + req, reqErr := http.NewRequestWithContext(b.ctx, http.MethodGet, currentURL, nil) + if reqErr != nil { + b.Log.Errorf("replayMissedMessages: NewRequest failed: %s", reqErr) + return + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, doErr := http.DefaultClient.Do(req) + if doErr != nil { + b.Log.Errorf("replayMissedMessages: HTTP request failed: %s", doErr) + return + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + b.Log.Errorf("replayMissedMessages: API returned %d: %s", resp.StatusCode, string(body)) + return + } - if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - b.Log.Errorf("replayMissedMessages: API returned %d: %s", resp.StatusCode, string(body)) - return - } + resp.Body.Close() - var result struct { - Value []msgraph.ChatMessage `json:"value"` - } - body, _ := io.ReadAll(resp.Body) - if jsonErr := json.Unmarshal(body, &result); jsonErr != nil { - b.Log.Errorf("replayMissedMessages: JSON parse failed: %s", jsonErr) - return + var result graphMessagesResponse + if jsonErr := json.Unmarshal(body, &result); jsonErr != nil { + b.Log.Errorf("replayMissedMessages: JSON parse failed: %s", jsonErr) + return + } + + allRawMessages = append(allRawMessages, result.Value...) + + // Graph API returns newest-first. If the oldest message in this page + // is before the cutoff, we have all messages we need. + oldestInPage := time.Now() + for _, m := range result.Value { + if m.CreatedDateTime != nil && m.CreatedDateTime.Before(oldestInPage) { + oldestInPage = *m.CreatedDateTime + } + } + if oldestInPage.Before(cutoff) || result.NextLink == "" { + break + } + currentURL = result.NextLink } // Filter and sort messages: oldest first, only those after cutoff. var messages []msgraph.ChatMessage - for _, msg := range result.Value { + for _, msg := range allRawMessages { if msg.CreatedDateTime == nil || msg.CreatedDateTime.Before(cutoff) { continue } @@ -202,6 +228,8 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { } count := 0 + + // --- Replay top-level messages --- for _, msg := range messages { // Skip messages we sent. if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { @@ -232,17 +260,90 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { Account: b.Account, UserID: *msg.From.User.ID, ID: *msg.ID, + Avatar: b.GetString("IconURL"), Extra: make(map[string][]interface{}), } b.handleAttachments(&rmsg, msg) b.handleHostedContents(&rmsg, msg, "") + // Skip empty messages (e.g. failed file download with no text content). + hasFiles := len(rmsg.Extra["file"]) > 0 + textAfterPrefix := strings.TrimSpace(strings.TrimPrefix(rmsg.Text, replayPrefix)) + if textAfterPrefix == "" && !hasFiles { + continue + } + b.Remote <- rmsg count++ time.Sleep(500 * time.Millisecond) } + // --- Replay thread replies --- + for _, msg := range messages { + if msg.ID == nil { + continue + } + replies, err := b.getReplies(channelName, *msg.ID) + if err != nil { + b.Log.Warnf("replayMissedMessages: getReplies for %s failed: %s", *msg.ID, err) + continue + } + + for _, reply := range replies { + if reply.ID == nil || reply.From == nil || reply.From.User == nil || reply.Body == nil { + continue + } + if reply.DeletedDateTime != nil { + continue + } + if reply.CreatedDateTime == nil || reply.CreatedDateTime.Before(cutoff) { + continue + } + + // Composite key matching the poll loop (poll uses msg.ID + "/" + reply.ID). + key := *msg.ID + "/" + *reply.ID + + if _, wasSentByUs := b.sentIDs[*reply.ID]; wasSentByUs { + continue + } + if b.IsMessageBridged("msteams", key) { + continue + } + + text := b.convertToMD(*reply.Body.Content) + createTime := *reply.CreatedDateTime + replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04")) + + rrmsg := config.Message{ + Event: config.EventReplayMessage, + Username: *reply.From.User.DisplayName, + Text: replayPrefix + text, + Channel: channelName, + Account: b.Account, + UserID: *reply.From.User.ID, + ID: key, + ParentID: *msg.ID, + Avatar: b.GetString("IconURL"), + Extra: make(map[string][]interface{}), + } + + b.handleAttachments(&rrmsg, reply) + b.handleHostedContents(&rrmsg, reply, *msg.ID) + + // Skip empty messages (e.g. failed file download with no text content). + hasFiles := len(rrmsg.Extra["file"]) > 0 + textAfterPrefix := strings.TrimSpace(strings.TrimPrefix(rrmsg.Text, replayPrefix)) + if textAfterPrefix == "" && !hasFiles { + continue + } + + b.Remote <- rrmsg + count++ + time.Sleep(500 * time.Millisecond) + } + } + if count > 0 { b.Log.Infof("replayMissedMessages: replayed %d messages from %s", count, channelName) } From ff1060c5bf295da22e59eee4e42d19e42d0a063a Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 12:51:49 +0100 Subject: [PATCH 25/42] fix: prevent replay of bridge-generated errors, add timezone to replay timestamps - Add MarkMessageBridged callback so bridges can persist message IDs directly in the cache without routing through the gateway - notifyFileTooLarge now marks both the original message and its warning reply in the persistent cache, preventing re-download and re-relay of already-handled messages after restart - Add timezone (MST format) to replay timestamps for clarity, e.g. [Replay 2026-03-13 10:08 UTC] instead of [Replay 2026-03-13 10:08] Co-Authored-By: Claude Opus 4.6 --- bridge/bridge.go | 6 ++++++ bridge/mattermost/mattermost.go | 2 +- bridge/msteams/handler.go | 13 +++++++++++++ bridge/msteams/msteams.go | 4 ++-- gateway/gateway.go | 8 ++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 63d9df7da1..77d9846c71 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -53,6 +53,12 @@ type Config struct { // GetLastSeen returns the timestamp of the last processed message for a channel. // Used by replay to determine the cutoff point. GetLastSeen func(channelKey string) (time.Time, bool) + + // MarkMessageBridged marks a message as handled in the persistent cache. + // Used by bridges when they handle a message locally (e.g. posting an error + // notification) without routing it through the gateway, so replay won't + // re-process it on the next restart. + MarkMessageBridged func(protocol, msgID string) } // Factory is the factory function to create a bridge diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 1e1efff514..01acb5b0fc 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -283,7 +283,7 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { // Format replay prefix with original timestamp. createTime := time.UnixMilli(post.CreateAt) - replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04")) + replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04 MST")) rmsg := config.Message{ Event: config.EventReplayMessage, diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index c8e754f676..c85e29d989 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -91,6 +91,19 @@ func (b *Bmsteams) notifyFileTooLarge(rmsg *config.Message, filename string, act } if res != nil && res.ID != nil { b.sentIDs[*res.ID] = struct{}{} + + // Persist the warning reply and the original message in the cache so + // replay won't re-process them after a restart (sentIDs is in-memory only). + if b.MarkMessageBridged != nil { + // Mark the original message as handled. + if parentID != "" { + b.MarkMessageBridged("msteams", parentID) + // Mark the reply (warning) itself using the composite key. + b.MarkMessageBridged("msteams", parentID+"/"+*res.ID) + } else { + b.MarkMessageBridged("msteams", *res.ID) + } + } } } diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index ec9edbc4c5..35c0e47318 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -250,7 +250,7 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { // Format replay prefix with original timestamp. createTime := *msg.CreatedDateTime - replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04")) + replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04 MST")) rmsg := config.Message{ Event: config.EventReplayMessage, @@ -313,7 +313,7 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { text := b.convertToMD(*reply.Body.Content) createTime := *reply.CreatedDateTime - replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04")) + replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04 MST")) rrmsg := config.Message{ Event: config.EventReplayMessage, diff --git a/gateway/gateway.go b/gateway/gateway.go index a5a054cc5a..434298a3fc 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -241,6 +241,14 @@ func (gw *Gateway) AddBridge(cfg *config.Bridge) error { } return time.Time{}, false }, + MarkMessageBridged: func(protocol, msgID string) { + key := protocol + " " + msgID + for _, cache := range gw.BridgeCaches { + if cache != nil { + cache.Add(key, []PersistentMsgEntry{}) + } + } + }, } // add the actual bridger for this protocol to this bridge using the bridgeMap if _, ok := gw.Router.BridgeMap[br.Protocol]; !ok { From 9de2046f1befaa44005a1c2876bf27b374fa50f6 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 13:01:37 +0100 Subject: [PATCH 26/42] fix: nil pointer crash in handleAttachments for messageReference attachments Teams "reply with quote" creates attachments with ContentType=messageReference that have no Name or ContentURL fields. Added nil checks before dereferencing these pointer fields to prevent SIGSEGV panic. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/handler.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index c85e29d989..b7d3ca154e 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -112,12 +112,22 @@ func (b *Bmsteams) handleAttachments(rmsg *config.Message, msg msgraph.ChatMessa //remove the attachment tags from the text rmsg.Text = attachRE.ReplaceAllString(rmsg.Text, "") + // Skip attachments without required fields (e.g. messageReference from + // "reply with quote" has no Name/ContentURL). + if a.ContentType == nil { + continue + } + //handle a code snippet (code block) if *a.ContentType == "application/vnd.microsoft.card.codesnippet" { b.handleCodeSnippet(rmsg, a) continue } + if a.Name == nil || a.ContentURL == nil { + continue + } + //handle the download err := b.handleDownloadFile(rmsg, *a.Name, *a.ContentURL) if err != nil { From 2e4fe3a979b770da8944321f2fa2452cad2daf10 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 13:11:42 +0100 Subject: [PATCH 27/42] refactor: replace ReplayWindow with lastSeen-based replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the ReplayWindow config option entirely. Replay now only happens when the persistent cache has a lastSeen timestamp for the channel (i.e. the bridge has run at least once before). On first start, no replay occurs — the cache initializes through normal message bridging. This prevents the undesired behavior where enabling MessageCacheFile caused massive replay of historical messages on first start. Co-Authored-By: Claude Opus 4.6 --- bridge/config/config.go | 1 - bridge/mattermost/mattermost.go | 21 ++++++--------------- bridge/msteams/msteams.go | 22 +++++++--------------- 3 files changed, 13 insertions(+), 31 deletions(-) diff --git a/bridge/config/config.go b/bridge/config/config.go index 7be61872ed..15a172b079 100644 --- a/bridge/config/config.go +++ b/bridge/config/config.go @@ -158,7 +158,6 @@ type Protocol struct { MediaConvertTgs string // telegram MediaConvertWebPToPNG bool // telegram MessageCacheFile string // general, msteams, mattermost: persistent message ID cache file - ReplayWindow string // general, msteams, mattermost: duration for replay window on startup (e.g. "24h") MessageDelay int // IRC, time in millisecond to wait between messages MessageFormat string // telegram MessageLength int // IRC, max length of a message allowed diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 01acb5b0fc..5454d39cdb 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -183,28 +183,19 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { return } - replayWindowStr := b.GetString("ReplayWindow") - if replayWindowStr == "" { - return - } - replayWindow, err := time.ParseDuration(replayWindowStr) - if err != nil { - b.Log.Errorf("replayMissedMessages: invalid ReplayWindow %q: %s", replayWindowStr, err) - return - } - channelID := b.getChannelID(channel.Name) if channelID == "" { return } channelKey := channel.Name + b.Account - cutoff := time.Now().Add(-replayWindow) - - // Use last-seen timestamp if available and more recent than window cutoff. - if lastSeen, ok := b.GetLastSeen(channelKey); ok && lastSeen.After(cutoff) { - cutoff = lastSeen + lastSeen, ok := b.GetLastSeen(channelKey) + if !ok { + // First start: no replay, let the cache initialize through normal operation. + b.Log.Debugf("replayMissedMessages: no lastSeen for %s, skipping (first start)", channelKey) + return } + cutoff := lastSeen sinceMillis := cutoff.UnixMilli() postList, _, err := b.mc.Client.GetPostsSince(context.TODO(), channelID, sinceMillis, false) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 35c0e47318..0d0e0d1864 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -120,24 +120,16 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { return } - replayWindowStr := b.GetString("ReplayWindow") - if replayWindowStr == "" { - return - } - replayWindow, err := time.ParseDuration(replayWindowStr) - if err != nil { - b.Log.Errorf("replayMissedMessages: invalid ReplayWindow %q: %s", replayWindowStr, err) - return - } - channelKey := channelName + b.Account - cutoff := time.Now().Add(-replayWindow) - - if lastSeen, ok := b.GetLastSeen(channelKey); ok && lastSeen.After(cutoff) { - cutoff = lastSeen + lastSeen, ok := b.GetLastSeen(channelKey) + if !ok { + // First start: no replay, let the cache initialize through normal polling. + b.Log.Debugf("replayMissedMessages: no lastSeen for %s, skipping (first start)", channelKey) + return } + cutoff := lastSeen - // Fetch recent messages via Graph API with pagination to cover the full ReplayWindow. + // Fetch recent messages via Graph API with pagination. teamID := b.GetString("TeamID") channelID := decodeChannelID(channelName) firstURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50", From 272f700135905d19ecff64576c03310a81e0f005 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 14:05:35 +0100 Subject: [PATCH 28/42] refactor: replace Teams poll loop with Graph API delta queries Replace the O(n) getMessages+getReplies polling approach with the Graph API /messages/delta endpoint. This delivers messages AND replies in a single API call, eliminating the 12+ second poll delays caused by sequential getReplies calls per thread. Delta queries also solve the missed-reply-in-old-threads problem: the endpoint returns ALL changes since the last sync, including replies in threads whose root message predates the last poll. Key changes: - New fetchDelta() helper with pagination and double-unmarshal for replyToId extraction (msgraph.ChatMessage lacks this field) - Unified poll() handles both replay (stored deltaToken) and normal polling via the same delta mechanism - $deltatoken=latest on first start avoids enumerating all historical messages (important for channels with 10k+ messages) - Remove getMessages(), getReplies(), replayMissedMessages() (no callers) - Add SetDeltaToken/GetDeltaToken to PersistentMsgCache + bridge callbacks - Mattermost: add event-type allowlist before debug logging to filter status_change/typing/hello noise (respects ShowUserTyping config) Co-Authored-By: Claude Opus 4.6 --- bridge/bridge.go | 6 + bridge/mattermost/handlers.go | 15 + bridge/msteams/msteams.go | 689 ++++++++++++++-------------------- gateway/gateway.go | 17 + gateway/msgcache.go | 23 ++ 5 files changed, 353 insertions(+), 397 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 77d9846c71..72c475c82a 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -59,6 +59,12 @@ type Config struct { // notification) without routing it through the gateway, so replay won't // re-process it on the next restart. MarkMessageBridged func(protocol, msgID string) + + // SetDeltaToken stores a Graph API delta token for a channel. + SetDeltaToken func(channelKey, token string) + + // GetDeltaToken returns the stored Graph API delta token for a channel. + GetDeltaToken func(channelKey string) (string, bool) } // Factory is the factory function to create a bridge diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index cb87851a75..673fd5e3bf 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -98,6 +98,21 @@ func (b *Bmattermost) handleMatter() { //nolint:cyclop func (b *Bmattermost) handleMatterClient(messages chan *config.Message) { for message := range b.mc.MessageChan { + // Allowlist: only process events relevant to bridging. + // This avoids logging noise from status_change, hello, preferences_changed, etc. + et := message.Raw.EventType() + switch et { + case "posted", model.WebsocketEventPostEdited, model.WebsocketEventPostDeleted: + // Post events: always process (join/leave come as "posted" with system message type). + case "typing": + // Typing events: only process if ShowUserTyping is enabled. + if !b.GetBool("ShowUserTyping") { + continue + } + default: + continue + } + b.Log.Debugf("%#v %#v", message.Raw.GetData(), message.Raw.EventType()) if b.skipMessage(message) { diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 0d0e0d1864..40e1247223 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -99,148 +99,180 @@ func (b *Bmsteams) Disconnect() error { func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { go func(name string) { - // Replay missed messages before starting the poll loop. - // Runs inside the goroutine so it cannot block JoinChannel. - b.replayMissedMessages(name) for { err := b.poll(name) if err != nil { b.Log.Errorf("polling failed for %s: %s. retrying in 5 seconds", name, err) } - time.Sleep(time.Second * 2) + time.Sleep(5 * time.Second) } }(channel.Name) return nil } -// replayMissedMessages fetches recent messages from the Teams channel and replays -// any that were not yet bridged. This catches up on messages missed during downtime. -func (b *Bmsteams) replayMissedMessages(channelName string) { - if b.IsMessageBridged == nil || b.GetLastSeen == nil { - return - } +// errDeltaTokenExpired is returned by fetchDelta when the server responds with +// HTTP 410 Gone, indicating the delta token has expired. +var errDeltaTokenExpired = fmt.Errorf("delta token expired") - channelKey := channelName + b.Account - lastSeen, ok := b.GetLastSeen(channelKey) - if !ok { - // First start: no replay, let the cache initialize through normal polling. - b.Log.Debugf("replayMissedMessages: no lastSeen for %s, skipping (first start)", channelKey) - return - } - cutoff := lastSeen +// deltaResponse is the JSON structure returned by the Graph API delta endpoint. +type deltaResponse struct { + Value []json.RawMessage `json:"value"` + NextLink string `json:"@odata.nextLink"` + DeltaLink string `json:"@odata.deltaLink"` +} - // Fetch recent messages via Graph API with pagination. - teamID := b.GetString("TeamID") - channelID := decodeChannelID(channelName) - firstURL := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages?$top=50", - teamID, channelID) +// deltaMessageMeta extracts the replyToId field that msgraph.ChatMessage lacks. +type deltaMessageMeta struct { + ReplyToID *string `json:"replyToId"` +} - token, tokenErr := b.getAccessToken() - if tokenErr != nil { - b.Log.Errorf("replayMissedMessages: getAccessToken failed: %s", tokenErr) - return - } +// fetchDelta calls the Graph API delta endpoint and paginates through all pages. +// Returns the list of messages, a map of messageID→parentID for replies, and the +// new deltaLink URL for the next incremental sync. +func (b *Bmsteams) fetchDelta(deltaURL string) ( + messages []msgraph.ChatMessage, + replyToIDs map[string]string, + nextDeltaLink string, + err error, +) { + replyToIDs = make(map[string]string) - type graphMessagesResponse struct { - Value []msgraph.ChatMessage `json:"value"` - NextLink string `json:"@odata.nextLink"` + token, err := b.getAccessToken() + if err != nil { + return nil, nil, "", fmt.Errorf("getAccessToken: %w", err) } - var allRawMessages []msgraph.ChatMessage - currentURL := firstURL - const maxPages = 5 + currentURL := deltaURL + const maxPages = 10 for page := 0; page < maxPages && currentURL != ""; page++ { req, reqErr := http.NewRequestWithContext(b.ctx, http.MethodGet, currentURL, nil) if reqErr != nil { - b.Log.Errorf("replayMissedMessages: NewRequest failed: %s", reqErr) - return + return nil, nil, "", fmt.Errorf("NewRequest: %w", reqErr) } req.Header.Set("Authorization", "Bearer "+token) resp, doErr := http.DefaultClient.Do(req) if doErr != nil { - b.Log.Errorf("replayMissedMessages: HTTP request failed: %s", doErr) - return - } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - b.Log.Errorf("replayMissedMessages: API returned %d: %s", resp.StatusCode, string(body)) - return + return nil, nil, "", fmt.Errorf("HTTP request: %w", doErr) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - var result graphMessagesResponse + if resp.StatusCode == http.StatusGone { + return nil, nil, "", errDeltaTokenExpired + } + if resp.StatusCode != http.StatusOK { + return nil, nil, "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body)) + } + + var result deltaResponse if jsonErr := json.Unmarshal(body, &result); jsonErr != nil { - b.Log.Errorf("replayMissedMessages: JSON parse failed: %s", jsonErr) - return + return nil, nil, "", fmt.Errorf("JSON parse: %w", jsonErr) } - allRawMessages = append(allRawMessages, result.Value...) + for _, raw := range result.Value { + var msg msgraph.ChatMessage + if err := json.Unmarshal(raw, &msg); err != nil { + b.Log.Debugf("fetchDelta: skipping unparseable message: %s", err) + continue + } + if msg.ID == nil { + continue + } - // Graph API returns newest-first. If the oldest message in this page - // is before the cutoff, we have all messages we need. - oldestInPage := time.Now() - for _, m := range result.Value { - if m.CreatedDateTime != nil && m.CreatedDateTime.Before(oldestInPage) { - oldestInPage = *m.CreatedDateTime + var meta deltaMessageMeta + _ = json.Unmarshal(raw, &meta) + + if meta.ReplyToID != nil && *meta.ReplyToID != "" { + replyToIDs[*msg.ID] = *meta.ReplyToID } + + messages = append(messages, msg) } - if oldestInPage.Before(cutoff) || result.NextLink == "" { - break + + if result.DeltaLink != "" { + nextDeltaLink = result.DeltaLink } currentURL = result.NextLink } - // Filter and sort messages: oldest first, only those after cutoff. - var messages []msgraph.ChatMessage - for _, msg := range allRawMessages { - if msg.CreatedDateTime == nil || msg.CreatedDateTime.Before(cutoff) { - continue - } - if msg.ID == nil || msg.From == nil || msg.From.User == nil || msg.Body == nil { + // If we exhausted maxPages without getting a deltaLink, use the last nextLink + // as a workaround (will continue pagination on next call). + if nextDeltaLink == "" && currentURL != "" { + nextDeltaLink = currentURL + } + + return messages, replyToIDs, nextDeltaLink, nil +} + +// deltaMessageKey returns the cache key and parentID for a delta message. +func deltaMessageKey(msg msgraph.ChatMessage, replyToIDs map[string]string) (key, parentID string) { + if parent, isReply := replyToIDs[*msg.ID]; isReply { + return parent + "/" + *msg.ID, parent + } + return *msg.ID, "" +} + +// seedMsgmap populates the msgmap with timestamps from messages without relaying them. +func (b *Bmsteams) seedMsgmap(messages []msgraph.ChatMessage, replyToIDs map[string]string, msgmap map[string]time.Time, mbSrcRE *regexp.Regexp, channelName string) { + for _, msg := range messages { + if msg.ID == nil || msg.CreatedDateTime == nil { continue } - if msg.DeletedDateTime != nil { - continue + key, _ := deltaMessageKey(msg, replyToIDs) + if msg.LastModifiedDateTime != nil { + msgmap[key] = *msg.LastModifiedDateTime + } else { + msgmap[key] = *msg.CreatedDateTime } - messages = append(messages, msg) - } - // Sort oldest first (bubble sort for simplicity). - for i := 0; i < len(messages); i++ { - for j := i + 1; j < len(messages); j++ { - if messages[j].CreatedDateTime.Before(*messages[i].CreatedDateTime) { - messages[i], messages[j] = messages[j], messages[i] + + // Extract source ID marker from message body for persistent cache population. + if msg.Body != nil && msg.Body.Content != nil { + if matches := mbSrcRE.FindStringSubmatch(*msg.Body.Content); len(matches) == 2 { + b.Remote <- config.Message{ + Event: config.EventHistoricalMapping, + Account: b.Account, + Channel: channelName, + ID: *msg.ID, + Extra: map[string][]interface{}{"source_msgid": {matches[1]}}, + } } } } +} +// processReplay relays missed messages (from a delta sync after restart) to the gateway. +func (b *Bmsteams) processReplay(messages []msgraph.ChatMessage, replyToIDs map[string]string, channelName string) int { count := 0 - - // --- Replay top-level messages --- for _, msg := range messages { + if msg.ID == nil || msg.CreatedDateTime == nil { + continue + } + if msg.From == nil || msg.From.User == nil || msg.Body == nil { + continue + } + if msg.DeletedDateTime != nil { + continue + } + + key, parentID := deltaMessageKey(msg, replyToIDs) + // Skip messages we sent. if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { continue } - // Skip if already bridged. - if b.IsMessageBridged("msteams", *msg.ID) { + if b.IsMessageBridged != nil && b.IsMessageBridged("msteams", key) { continue } text := b.convertToMD(*msg.Body.Content) - - // Prepend subject if present. if msg.Subject != nil && *msg.Subject != "" { text = "**" + *msg.Subject + "**\n" + text } - // Format replay prefix with original timestamp. createTime := *msg.CreatedDateTime replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04 MST")) @@ -251,13 +283,14 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { Channel: channelName, Account: b.Account, UserID: *msg.From.User.ID, - ID: *msg.ID, + ID: key, + ParentID: parentID, Avatar: b.GetString("IconURL"), Extra: make(map[string][]interface{}), } b.handleAttachments(&rmsg, msg) - b.handleHostedContents(&rmsg, msg, "") + b.handleHostedContents(&rmsg, msg, parentID) // Skip empty messages (e.g. failed file download with no text content). hasFiles := len(rmsg.Extra["file"]) > 0 @@ -270,74 +303,143 @@ func (b *Bmsteams) replayMissedMessages(channelName string) { count++ time.Sleep(500 * time.Millisecond) } + return count +} - // --- Replay thread replies --- +// processDelta handles messages from a normal delta poll cycle (not replay). +func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[string]string, channelName string, msgmap map[string]time.Time, mbSrcRE *regexp.Regexp) { for _, msg := range messages { - if msg.ID == nil { + if msg.ID == nil || msg.CreatedDateTime == nil { continue } - replies, err := b.getReplies(channelName, *msg.ID) - if err != nil { - b.Log.Warnf("replayMissedMessages: getReplies for %s failed: %s", *msg.ID, err) + + key, parentID := deltaMessageKey(msg, replyToIDs) + + // Check if this message is new or changed. + isNewOrChanged := true + if mtime, ok := msgmap[key]; ok { + if mtime == *msg.CreatedDateTime && msg.LastModifiedDateTime == nil { + isNewOrChanged = false + } else if msg.LastModifiedDateTime != nil && mtime == *msg.LastModifiedDateTime { + isNewOrChanged = false + } + } + + if !isNewOrChanged { continue } - for _, reply := range replies { - if reply.ID == nil || reply.From == nil || reply.From.User == nil || reply.Body == nil { - continue - } - if reply.DeletedDateTime != nil { - continue - } - if reply.CreatedDateTime == nil || reply.CreatedDateTime.Before(cutoff) { - continue + if b.GetBool("debug") { + b.Log.Debug("Msg dump: ", spew.Sdump(msg)) + } + + if msg.From == nil || msg.From.User == nil { + // System message or bot — update msgmap silently. + if msg.LastModifiedDateTime != nil { + msgmap[key] = *msg.LastModifiedDateTime + } else { + msgmap[key] = *msg.CreatedDateTime } + continue + } - // Composite key matching the poll loop (poll uses msg.ID + "/" + reply.ID). - key := *msg.ID + "/" + *reply.ID + // Echo prevention: check if we PATCHed this message. + if expiry, wasUpdatedByUs := b.updatedIDs[*msg.ID]; wasUpdatedByUs && time.Now().Before(expiry) { + b.Log.Debugf("skipping echo of our own edit for %s", key) + if msg.LastModifiedDateTime != nil { + msgmap[key] = *msg.LastModifiedDateTime + } else { + msgmap[key] = *msg.CreatedDateTime + } + continue + } - if _, wasSentByUs := b.sentIDs[*reply.ID]; wasSentByUs { - continue + // Echo prevention: check if we posted this message. + if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { + b.Log.Debug("skipping own message") + if msg.LastModifiedDateTime != nil { + msgmap[key] = *msg.LastModifiedDateTime + } else { + msgmap[key] = *msg.CreatedDateTime } - if b.IsMessageBridged("msteams", key) { - continue + delete(b.sentIDs, *msg.ID) + continue + } + + // Determine event type: delete, edit, or new. + isDelete := msg.DeletedDateTime != nil + isEdit := false + if !isDelete { + if _, alreadySeen := msgmap[key]; alreadySeen { + isEdit = true } + } - text := b.convertToMD(*reply.Body.Content) - createTime := *reply.CreatedDateTime - replayPrefix := fmt.Sprintf("[Replay %s]\n", createTime.Format("2006-01-02 15:04 MST")) + // Update msgmap. + if msg.LastModifiedDateTime != nil { + msgmap[key] = *msg.LastModifiedDateTime + } else { + msgmap[key] = *msg.CreatedDateTime + } - rrmsg := config.Message{ - Event: config.EventReplayMessage, - Username: *reply.From.User.DisplayName, - Text: replayPrefix + text, - Channel: channelName, - Account: b.Account, - UserID: *reply.From.User.ID, - ID: key, - ParentID: *msg.ID, - Avatar: b.GetString("IconURL"), - Extra: make(map[string][]interface{}), + // Extract source ID marker. + if msg.Body != nil && msg.Body.Content != nil { + if matches := mbSrcRE.FindStringSubmatch(*msg.Body.Content); len(matches) == 2 { + b.Remote <- config.Message{ + Event: config.EventHistoricalMapping, + Account: b.Account, + Channel: channelName, + ID: *msg.ID, + Extra: map[string][]interface{}{"source_msgid": {matches[1]}}, + } } + } - b.handleAttachments(&rrmsg, reply) - b.handleHostedContents(&rrmsg, reply, *msg.ID) + text := "" + if msg.Body != nil && msg.Body.Content != nil { + text = b.convertToMD(*msg.Body.Content) + } - // Skip empty messages (e.g. failed file download with no text content). - hasFiles := len(rrmsg.Extra["file"]) > 0 - textAfterPrefix := strings.TrimSpace(strings.TrimPrefix(rrmsg.Text, replayPrefix)) - if textAfterPrefix == "" && !hasFiles { - continue - } + // Intercept test command (only for new root messages). + if !isDelete && !isEdit && parentID == "" && b.isTestCommand(text) { + b.Log.Info("Test command received, starting test sequence") + go b.runTestSequence(channelName) + continue + } - b.Remote <- rrmsg - count++ - time.Sleep(500 * time.Millisecond) + // Prepend subject if present. + if msg.Subject != nil && *msg.Subject != "" { + text = "**" + *msg.Subject + "**\n" + text } - } - if count > 0 { - b.Log.Infof("replayMissedMessages: replayed %d messages from %s", count, channelName) + event := "" + if isDelete { + event = config.EventMsgDelete + text = config.EventMsgDelete + } else if isEdit { + event = "msg_update" + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account) + + rmsg := config.Message{ + Username: *msg.From.User.DisplayName, + Text: text, + Channel: channelName, + Account: b.Account, + UserID: *msg.From.User.ID, + ID: key, + ParentID: parentID, + Event: event, + Avatar: b.GetString("IconURL"), + Extra: make(map[string][]interface{}), + } + if !isEdit && !isDelete { + b.handleAttachments(&rmsg, msg) + b.handleHostedContents(&rmsg, msg, parentID) + } + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg } } @@ -966,292 +1068,85 @@ func decodeChannelID(id string) string { return decoded } -func (b *Bmsteams) getMessages(channel string) ([]msgraph.ChatMessage, error) { - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(channel)).Messages().Request() - - rct, err := ct.Get(b.ctx) - if err != nil { - return nil, err - } - - b.Log.Debugf("got %#v messages", len(rct)) - return rct, nil -} - -func (b *Bmsteams) getReplies(channel, messageID string) ([]msgraph.ChatMessage, error) { - ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(channel)).Messages().ID(messageID).Replies().Request() - return ct.Get(b.ctx) -} - +// poll uses Graph API delta queries to detect new/changed/deleted messages and replies +// in a single API call, replacing the previous getMessages+getReplies approach. +// On first start (no stored delta token), it initializes with $deltatoken=latest. +// On restart (stored delta token), it replays missed messages before entering the poll loop. +// //nolint:gocognit func (b *Bmsteams) poll(channelName string) error { - msgmap := make(map[string]time.Time) + channelKey := channelName + b.Account + teamID := b.GetString("TeamID") + channelID := decodeChannelID(channelName) + mbSrcRE := regexp.MustCompile(`data-mb-src="([^"]+)"`) - // Record start time — we will ignore any message created before this moment - // that wasn't already captured in our initial seed. - startTime := time.Now() + // 1. Determine initial delta URL: stored token (replay) or $deltatoken=latest (first start). + isReplay := false + var deltaURL string + if b.GetDeltaToken != nil { + if token, ok := b.GetDeltaToken(channelKey); ok && token != "" { + deltaURL = token + isReplay = true + } + } + if deltaURL == "" { + deltaURL = fmt.Sprintf( + "https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/delta?$deltatoken=latest", + teamID, channelID) + b.Log.Debugf("poll: first start for %s, using $deltatoken=latest", channelName) + } - b.Log.Debug("getting initial messages") - res, err := b.getMessages(channelName) + // 2. Initial fetch. + messages, replyToIDs, deltaLink, err := b.fetchDelta(deltaURL) + if err == errDeltaTokenExpired { + b.Log.Warn("poll: delta token expired, re-initializing") + deltaURL = fmt.Sprintf( + "https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/delta?$deltatoken=latest", + teamID, channelID) + messages, replyToIDs, deltaLink, err = b.fetchDelta(deltaURL) + isReplay = false + } if err != nil { - return err + return fmt.Errorf("initial fetchDelta: %w", err) } - // Seed with existing messages — use newest timestamp to avoid re-delivery. - // Also scan for historical source-ID markers for persistent cache population. - mbSrcRE := regexp.MustCompile(`data-mb-src="([^"]+)"`) - for _, msg := range res { - if msg.LastModifiedDateTime != nil { - msgmap[*msg.ID] = *msg.LastModifiedDateTime - } else { - msgmap[*msg.ID] = *msg.CreatedDateTime - } - // Extract source ID marker from message body. - if msg.Body != nil && msg.Body.Content != nil { - if matches := mbSrcRE.FindStringSubmatch(*msg.Body.Content); len(matches) == 2 { - b.Remote <- config.Message{ - Event: config.EventHistoricalMapping, - Account: b.Account, - Channel: channelName, - ID: *msg.ID, - Extra: map[string][]interface{}{"source_msgid": {matches[1]}}, - } - } + msgmap := make(map[string]time.Time) + + if isReplay { + count := b.processReplay(messages, replyToIDs, channelName) + if count > 0 { + b.Log.Infof("poll: replayed %d missed messages from %s", count, channelName) } } + // Seed msgmap with all messages from the initial fetch (including replayed ones). + b.seedMsgmap(messages, replyToIDs, msgmap, mbSrcRE, channelName) - // repliesFetchedAt tracks when we last fetched replies per message. - // We only poll replies for messages younger than 24h. - repliesFetchedAt := make(map[string]time.Time) + if b.SetDeltaToken != nil && deltaLink != "" { + b.SetDeltaToken(channelKey, deltaLink) + } - time.Sleep(time.Second * 2) - b.Log.Debug("polling for messages") + b.Log.Debugf("poll: entering delta poll loop for %s", channelName) + // 3. Poll loop. for { - res, err := b.getMessages(channelName) + time.Sleep(2 * time.Second) + + messages, replyToIDs, newDeltaLink, err := b.fetchDelta(deltaLink) + if err == errDeltaTokenExpired { + return fmt.Errorf("delta token expired mid-poll: %w", err) + } if err != nil { - return err + return fmt.Errorf("fetchDelta: %w", err) } - now := time.Now() - - for i := len(res) - 1; i >= 0; i-- { - msg := res[i] + b.processDelta(messages, replyToIDs, channelName, msgmap, mbSrcRE) - // --- Top-level message --- - isNewOrChanged := true - if mtime, ok := msgmap[*msg.ID]; ok { - if mtime == *msg.CreatedDateTime && msg.LastModifiedDateTime == nil { - isNewOrChanged = false - } else if msg.LastModifiedDateTime != nil && mtime == *msg.LastModifiedDateTime { - isNewOrChanged = false - } - } else if msg.CreatedDateTime.Before(startTime) { - // Message existed before we started but wasn't in our seed - // (older than the ~20 messages getMessages returns). - // Seed it silently to prevent future re-delivery. - if msg.LastModifiedDateTime != nil { - msgmap[*msg.ID] = *msg.LastModifiedDateTime - } else { - msgmap[*msg.ID] = *msg.CreatedDateTime - } - isNewOrChanged = false - } - - if isNewOrChanged { - if b.GetBool("debug") { - b.Log.Debug("Msg dump: ", spew.Sdump(msg)) - } - - if msg.From == nil || msg.From.User == nil { - msgmap[*msg.ID] = *msg.CreatedDateTime - } else if expiry, wasUpdatedByUs := b.updatedIDs[*msg.ID]; wasUpdatedByUs && time.Now().Before(expiry) { - // We PATCHed this message — suppress echo, update msgmap silently. - b.Log.Debugf("skipping echo of our own edit for %s", *msg.ID) - if msg.LastModifiedDateTime != nil { - msgmap[*msg.ID] = *msg.LastModifiedDateTime - } else { - msgmap[*msg.ID] = *msg.CreatedDateTime - } - } else if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { - // We posted this message — suppress echo. - b.Log.Debug("skipping own message") - msgmap[*msg.ID] = *msg.CreatedDateTime - delete(b.sentIDs, *msg.ID) - } else { - // Check if this is a deletion. - isDelete := msg.DeletedDateTime != nil - isEdit := false - if !isDelete { - if _, alreadySeen := msgmap[*msg.ID]; alreadySeen { - isEdit = true - } - } - - msgmap[*msg.ID] = *msg.CreatedDateTime - if msg.LastModifiedDateTime != nil { - msgmap[*msg.ID] = *msg.LastModifiedDateTime - } - - b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account) - - text := b.convertToMD(*msg.Body.Content) - - // Intercept test command (only for new messages, not edits/deletes). - if !isDelete && !isEdit && b.isTestCommand(text) { - b.Log.Info("Test command received, starting test sequence") - go b.runTestSequence(channelName) - // Don't relay the trigger message, but continue processing other messages. - continue - } - - // Prepend subject if present (Teams thread subjects) - if msg.Subject != nil && *msg.Subject != "" { - text = "**" + *msg.Subject + "**\n" + text - } - event := "" - if isDelete { - event = config.EventMsgDelete - text = config.EventMsgDelete // gateway ignores empty text, use event as placeholder - } else if isEdit { - event = "msg_update" - } - rmsg := config.Message{ - Username: *msg.From.User.DisplayName, - Text: text, - Channel: channelName, - Account: b.Account, - UserID: *msg.From.User.ID, - ID: *msg.ID, - Event: event, - Avatar: b.GetString("IconURL"), - Extra: make(map[string][]interface{}), - } - if !isEdit && !isDelete { - b.handleAttachments(&rmsg, msg) - b.handleHostedContents(&rmsg, msg, "") - } - b.Log.Debugf("<= Message is %#v", rmsg) - b.Remote <- rmsg - } - } - - // --- Replies: only for messages younger than 24h --- - msgAge := now.Sub(*msg.CreatedDateTime) - if msgAge >= 24*time.Hour { - continue - } - - lastFetch, fetched := repliesFetchedAt[*msg.ID] - if fetched && now.Sub(lastFetch) < 5*time.Second { - continue - } - _ = lastFetch - - replies, err := b.getReplies(channelName, *msg.ID) - if err != nil { - b.Log.Errorf("getting replies for %s failed: %s", *msg.ID, err) - continue - } - repliesFetchedAt[*msg.ID] = now - - for j := len(replies) - 1; j >= 0; j-- { - reply := replies[j] - key := *msg.ID + "/" + *reply.ID - - isReplyNewOrChanged := true - if mtime, ok := msgmap[key]; ok { - if mtime == *reply.CreatedDateTime && reply.LastModifiedDateTime == nil { - isReplyNewOrChanged = false - } else if reply.LastModifiedDateTime != nil && mtime == *reply.LastModifiedDateTime { - isReplyNewOrChanged = false - } - } else if reply.CreatedDateTime.Before(startTime) { - // Reply existed before startup — seed silently. - if reply.LastModifiedDateTime != nil { - msgmap[key] = *reply.LastModifiedDateTime - } else { - msgmap[key] = *reply.CreatedDateTime - } - isReplyNewOrChanged = false - } - - if !isReplyNewOrChanged { - continue - } - - if b.GetBool("debug") { - b.Log.Debug("Reply dump: ", spew.Sdump(reply)) - } - - if reply.From == nil || reply.From.User == nil { - msgmap[key] = *reply.CreatedDateTime - continue - } - - // Check if we PATCHed this reply (echo prevention with expiry). - if expiry, wasUpdatedByUs := b.updatedIDs[*reply.ID]; wasUpdatedByUs && time.Now().Before(expiry) { - b.Log.Debugf("skipping echo of our own reply edit for %s", *reply.ID) - if reply.LastModifiedDateTime != nil { - msgmap[key] = *reply.LastModifiedDateTime - } else { - msgmap[key] = *reply.CreatedDateTime - } - continue - } - - if _, wasSentByUs := b.sentIDs[*reply.ID]; wasSentByUs { - b.Log.Debug("skipping own reply") - msgmap[key] = *reply.CreatedDateTime - delete(b.sentIDs, *reply.ID) - continue - } - - isReplyDelete := reply.DeletedDateTime != nil - isReplyEdit := false - if !isReplyDelete { - if _, alreadySeen := msgmap[key]; alreadySeen { - isReplyEdit = true - } - } - - msgmap[key] = *reply.CreatedDateTime - if reply.LastModifiedDateTime != nil { - msgmap[key] = *reply.LastModifiedDateTime - } - - b.Log.Debugf("<= Sending reply from %s on %s to gateway", *reply.From.User.DisplayName, b.Account) - - text := b.convertToMD(*reply.Body.Content) - event := "" - if isReplyDelete { - event = config.EventMsgDelete - text = config.EventMsgDelete // gateway ignores empty text, use event as placeholder - } else if isReplyEdit { - event = "msg_update" - } - rrmsg := config.Message{ - Username: *reply.From.User.DisplayName, - Text: text, - Channel: channelName, - Account: b.Account, - UserID: *reply.From.User.ID, - ID: key, - ParentID: *msg.ID, - Event: event, - Avatar: b.GetString("IconURL"), - Extra: make(map[string][]interface{}), - } - if !isReplyEdit && !isReplyDelete { - b.handleAttachments(&rrmsg, reply) - b.handleHostedContents(&rrmsg, reply, *msg.ID) - } - b.Log.Debugf("<= Reply message is %#v", rrmsg) - b.Remote <- rrmsg + if newDeltaLink != "" { + deltaLink = newDeltaLink + if b.SetDeltaToken != nil { + b.SetDeltaToken(channelKey, deltaLink) } } - - time.Sleep(time.Second * 2) } } diff --git a/gateway/gateway.go b/gateway/gateway.go index 434298a3fc..3ed202c36f 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -249,6 +249,23 @@ func (gw *Gateway) AddBridge(cfg *config.Bridge) error { } } }, + SetDeltaToken: func(channelKey, token string) { + for _, cache := range gw.BridgeCaches { + if cache != nil { + cache.SetDeltaToken(channelKey, token) + } + } + }, + GetDeltaToken: func(channelKey string) (string, bool) { + for _, cache := range gw.BridgeCaches { + if cache != nil { + if token, ok := cache.GetDeltaToken(channelKey); ok { + return token, true + } + } + } + return "", false + }, } // add the actual bridger for this protocol to this bridge using the bridgeMap if _, ok := gw.Router.BridgeMap[br.Protocol]; !ok { diff --git a/gateway/msgcache.go b/gateway/msgcache.go index 9ada703599..b2472c72f5 100644 --- a/gateway/msgcache.go +++ b/gateway/msgcache.go @@ -154,6 +154,29 @@ func (c *PersistentMsgCache) GetLastSeen(channelKey string) (time.Time, bool) { } const lastSeenPrefix = "__last_seen__:" +const deltaTokenPrefix = "__delta_token__:" + +// SetDeltaToken stores a Graph API delta token for a channel. +// The channelKey should uniquely identify a channel+account combination. +func (c *PersistentMsgCache) SetDeltaToken(channelKey, token string) { + c.mu.Lock() + defer c.mu.Unlock() + c.data[deltaTokenPrefix+channelKey] = []PersistentMsgEntry{{ + ID: token, + }} + c.dirty = true +} + +// GetDeltaToken returns the stored Graph API delta token for a channel. +func (c *PersistentMsgCache) GetDeltaToken(channelKey string) (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entries, ok := c.data[deltaTokenPrefix+channelKey] + if !ok || len(entries) == 0 { + return "", false + } + return entries[0].ID, true +} // Stop stops the background flush loop and performs a final flush. func (c *PersistentMsgCache) Stop() { From 3f9cb48acd096f5d7fdc5464007ca0da802111b7 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 14:18:12 +0100 Subject: [PATCH 29/42] fix: prevent first-start message flooding with delta queries Add startTime guard to processDelta so messages created before poll start are silently seeded instead of relayed. On first start with $deltatoken=latest, the initial deltaLink returns old messages that should not be forwarded to downstream bridges. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 40e1247223..3e43b71384 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -307,7 +307,7 @@ func (b *Bmsteams) processReplay(messages []msgraph.ChatMessage, replyToIDs map[ } // processDelta handles messages from a normal delta poll cycle (not replay). -func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[string]string, channelName string, msgmap map[string]time.Time, mbSrcRE *regexp.Regexp) { +func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[string]string, channelName string, msgmap map[string]time.Time, mbSrcRE *regexp.Regexp, startTime time.Time) { for _, msg := range messages { if msg.ID == nil || msg.CreatedDateTime == nil { continue @@ -329,6 +329,18 @@ func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[s continue } + // Guard against first-start flooding: messages created before poll + // started that aren't in our seed (e.g. delta returning old messages + // on first start with $deltatoken=latest) are silently seeded. + if _, inMap := msgmap[key]; !inMap && msg.CreatedDateTime.Before(startTime) { + if msg.LastModifiedDateTime != nil { + msgmap[key] = *msg.LastModifiedDateTime + } else { + msgmap[key] = *msg.CreatedDateTime + } + continue + } + if b.GetBool("debug") { b.Log.Debug("Msg dump: ", spew.Sdump(msg)) } @@ -1079,6 +1091,7 @@ func (b *Bmsteams) poll(channelName string) error { teamID := b.GetString("TeamID") channelID := decodeChannelID(channelName) mbSrcRE := regexp.MustCompile(`data-mb-src="([^"]+)"`) + startTime := time.Now() // 1. Determine initial delta URL: stored token (replay) or $deltatoken=latest (first start). isReplay := false @@ -1139,7 +1152,7 @@ func (b *Bmsteams) poll(channelName string) error { return fmt.Errorf("fetchDelta: %w", err) } - b.processDelta(messages, replyToIDs, channelName, msgmap, mbSrcRE) + b.processDelta(messages, replyToIDs, channelName, msgmap, mbSrcRE, startTime) if newDeltaLink != "" { deltaLink = newDeltaLink From e8edab2fa9dac3fa5a7c2feca5c600e0833edbd0 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 14:42:05 +0100 Subject: [PATCH 30/42] debug: add logging to verify if delta endpoint returns replies Log each message from fetchDelta with its type (root vs reply-to:parentID) and per-page summary. Also log processDelta key/parentID to trace message flow through the pipeline. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 3e43b71384..aec6b4559d 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -187,11 +187,17 @@ func (b *Bmsteams) fetchDelta(deltaURL string) ( if meta.ReplyToID != nil && *meta.ReplyToID != "" { replyToIDs[*msg.ID] = *meta.ReplyToID + b.Log.Debugf("fetchDelta: msg id=%s type=reply-to:%s created=%v", *msg.ID, *meta.ReplyToID, msg.CreatedDateTime) + } else { + b.Log.Debugf("fetchDelta: msg id=%s type=root created=%v", *msg.ID, msg.CreatedDateTime) } messages = append(messages, msg) } + b.Log.Debugf("fetchDelta page %d: %d items, %d replies, nextLink=%v, deltaLink=%v", + page, len(result.Value), len(replyToIDs), result.NextLink != "", result.DeltaLink != "") + if result.DeltaLink != "" { nextDeltaLink = result.DeltaLink } @@ -315,6 +321,8 @@ func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[s key, parentID := deltaMessageKey(msg, replyToIDs) + b.Log.Debugf("processDelta: key=%s parentID=%q", key, parentID) + // Check if this message is new or changed. isNewOrChanged := true if mtime, ok := msgmap[key]; ok { From f8ecb5d9a4cf5ddd2352b10d3ed23a63f1691401 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 14:53:37 +0100 Subject: [PATCH 31/42] feat: delta-guided reply polling for Teams thread replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /messages/delta endpoint only returns root messages, not thread replies. However, when a reply is posted, the parent root message appears in delta with an updated lastModifiedDateTime. Use this signal to selectively call getReplies() only for threads that had activity, instead of polling all threads (O(n) → O(1-2)). Also seed replies for known root messages on startup to avoid false-positive relaying on the first poll cycle. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 82 +++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index aec6b4559d..ade85d1d8f 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -187,16 +187,15 @@ func (b *Bmsteams) fetchDelta(deltaURL string) ( if meta.ReplyToID != nil && *meta.ReplyToID != "" { replyToIDs[*msg.ID] = *meta.ReplyToID - b.Log.Debugf("fetchDelta: msg id=%s type=reply-to:%s created=%v", *msg.ID, *meta.ReplyToID, msg.CreatedDateTime) - } else { - b.Log.Debugf("fetchDelta: msg id=%s type=root created=%v", *msg.ID, msg.CreatedDateTime) } messages = append(messages, msg) } - b.Log.Debugf("fetchDelta page %d: %d items, %d replies, nextLink=%v, deltaLink=%v", - page, len(result.Value), len(replyToIDs), result.NextLink != "", result.DeltaLink != "") + if len(result.Value) > 0 { + b.Log.Debugf("fetchDelta page %d: %d items, nextLink=%v, deltaLink=%v", + page, len(result.Value), result.NextLink != "", result.DeltaLink != "") + } if result.DeltaLink != "" { nextDeltaLink = result.DeltaLink @@ -222,7 +221,8 @@ func deltaMessageKey(msg msgraph.ChatMessage, replyToIDs map[string]string) (key } // seedMsgmap populates the msgmap with timestamps from messages without relaying them. -func (b *Bmsteams) seedMsgmap(messages []msgraph.ChatMessage, replyToIDs map[string]string, msgmap map[string]time.Time, mbSrcRE *regexp.Regexp, channelName string) { +// If rootMsgCreated is non-nil, root message IDs are also tracked for reply polling. +func (b *Bmsteams) seedMsgmap(messages []msgraph.ChatMessage, replyToIDs map[string]string, msgmap map[string]time.Time, mbSrcRE *regexp.Regexp, channelName string, rootMsgCreated map[string]time.Time) { for _, msg := range messages { if msg.ID == nil || msg.CreatedDateTime == nil { continue @@ -233,6 +233,12 @@ func (b *Bmsteams) seedMsgmap(messages []msgraph.ChatMessage, replyToIDs map[str } else { msgmap[key] = *msg.CreatedDateTime } + // Track root messages for reply polling. + if rootMsgCreated != nil { + if _, isReply := replyToIDs[*msg.ID]; !isReply { + rootMsgCreated[*msg.ID] = *msg.CreatedDateTime + } + } // Extract source ID marker from message body for persistent cache population. if msg.Body != nil && msg.Body.Content != nil { @@ -321,8 +327,6 @@ func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[s key, parentID := deltaMessageKey(msg, replyToIDs) - b.Log.Debugf("processDelta: key=%s parentID=%q", key, parentID) - // Check if this message is new or changed. isNewOrChanged := true if mtime, ok := msgmap[key]; ok { @@ -1088,8 +1092,13 @@ func decodeChannelID(id string) string { return decoded } -// poll uses Graph API delta queries to detect new/changed/deleted messages and replies -// in a single API call, replacing the previous getMessages+getReplies approach. +func (b *Bmsteams) getReplies(channel, messageID string) ([]msgraph.ChatMessage, error) { + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(decodeChannelID(channel)).Messages().ID(messageID).Replies().Request() + return ct.Get(b.ctx) +} + +// poll uses Graph API delta queries to detect new/changed/deleted root messages +// and getReplies() to poll thread replies for recent threads. // On first start (no stored delta token), it initializes with $deltatoken=latest. // On restart (stored delta token), it replays missed messages before entering the poll loop. // @@ -1132,6 +1141,7 @@ func (b *Bmsteams) poll(channelName string) error { } msgmap := make(map[string]time.Time) + rootMsgCreated := make(map[string]time.Time) // rootID → createdDateTime (for reply polling) if isReplay { count := b.processReplay(messages, replyToIDs, channelName) @@ -1140,13 +1150,36 @@ func (b *Bmsteams) poll(channelName string) error { } } // Seed msgmap with all messages from the initial fetch (including replayed ones). - b.seedMsgmap(messages, replyToIDs, msgmap, mbSrcRE, channelName) + b.seedMsgmap(messages, replyToIDs, msgmap, mbSrcRE, channelName, rootMsgCreated) + + // Seed replies for known root messages to avoid false-positive relaying + // on the first poll cycle. + for rootID := range rootMsgCreated { + replies, err := b.getReplies(channelName, rootID) + if err != nil { + b.Log.Errorf("seeding replies for %s: %s", rootID, err) + continue + } + rids := make(map[string]string) + for _, r := range replies { + if r.ID != nil { + rids[*r.ID] = rootID + } + } + if isReplay { + count := b.processReplay(replies, rids, channelName) + if count > 0 { + b.Log.Infof("poll: replayed %d missed replies for thread %s", count, rootID) + } + } + b.seedMsgmap(replies, rids, msgmap, mbSrcRE, channelName, nil) + } if b.SetDeltaToken != nil && deltaLink != "" { b.SetDeltaToken(channelKey, deltaLink) } - b.Log.Debugf("poll: entering delta poll loop for %s", channelName) + b.Log.Debugf("poll: entering delta poll loop for %s (%d root messages tracked)", channelName, len(rootMsgCreated)) // 3. Poll loop. for { @@ -1162,6 +1195,31 @@ func (b *Bmsteams) poll(channelName string) error { b.processDelta(messages, replyToIDs, channelName, msgmap, mbSrcRE, startTime) + // Delta-guided reply polling: when a root message appears in delta + // (e.g. lastModifiedDateTime updated because a reply was posted), + // fetch and process its replies. + for _, msg := range messages { + if msg.ID == nil { + continue + } + // Skip if this is somehow a reply (shouldn't happen with delta, but be safe). + if _, isReply := replyToIDs[*msg.ID]; isReply { + continue + } + replies, err := b.getReplies(channelName, *msg.ID) + if err != nil { + b.Log.Errorf("getReplies for %s: %s", *msg.ID, err) + continue + } + rids := make(map[string]string) + for _, r := range replies { + if r.ID != nil { + rids[*r.ID] = *msg.ID + } + } + b.processDelta(replies, rids, channelName, msgmap, mbSrcRE, startTime) + } + if newDeltaLink != "" { deltaLink = newDeltaLink if b.SetDeltaToken != nil { From 1958a78b7ad42558cc328c262bbce1e2888c372b Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 15:11:18 +0100 Subject: [PATCH 32/42] fix: preserve icon/username on edit, improve test ordering Replace b.mc.EditMessage() with b.mc.Client.PatchPost() in the Mattermost bridge Send() to preserve override_username and override_icon_url Props when editing messages. Previously edits would reset to the bot's default icon and username. Also increase delay after image posts in the Teams test sequence from 1s to 3s to prevent "Test finished" arriving before the multi-image post. Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/mattermost.go | 19 +++++++++++++++++-- bridge/mattermost/test.go | 14 ++++++++++++-- bridge/msteams/test.go | 4 ++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 5454d39cdb..3de139e289 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -468,9 +468,24 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { } } - // Edit message if we have an ID + // Edit message if we have an ID — use PatchPost to preserve override props. if msg.ID != "" { - return b.mc.EditMessage(msg.ID, msg.Text) + props := model.StringInterface{ + "from_webhook": "true", + "override_username": strings.TrimSpace(msg.Username), + "matterbridge_" + b.uuid: true, + } + if msg.Avatar != "" { + props["override_icon_url"] = msg.Avatar + } + _, _, err := b.mc.Client.PatchPost(context.TODO(), msg.ID, &model.PostPatch{ + Message: &msg.Text, + Props: props, + }) + if err != nil { + return "", err + } + return msg.ID, nil } // Post normal message with override_username/icon so it appears as the diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 30b452f5a1..5333a02d95 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -81,10 +81,20 @@ func (b *Bmattermost) runTestSequence(channelName string) { post(":thumbsup: :tada: :rocket: :heart: :eyes: :flag-at:", rootID) time.Sleep(time.Second) - // Step 8: Edit the typo message + // Step 8: Edit the typo message — include Props to preserve override_username/icon. if typoID != "" { newText := "this message contained a typo" - _, _, err := b.mc.Client.PatchPost(context.TODO(), typoID, &model.PostPatch{Message: &newText}) + editProps := model.StringInterface{ + "from_webhook": "true", + "override_username": "matterbridge", + } + if b.GetString("IconURL") != "" { + editProps["override_icon_url"] = b.GetString("IconURL") + } + _, _, err := b.mc.Client.PatchPost(context.TODO(), typoID, &model.PostPatch{ + Message: &newText, + Props: editProps, + }) if err != nil { b.Log.Errorf("test: PatchPost failed: %s", err) } diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go index e544d9121b..89c700c2d4 100644 --- a/bridge/msteams/test.go +++ b/bridge/msteams/test.go @@ -280,7 +280,7 @@ func (b *Bmsteams) runTestSequence(channelName string) { postReplyWithImages(rootID, "Image test: PNG", []testImage{ {name: "demo.png", contentType: "image/png", data: testdata.DemoPNG}, }) - time.Sleep(time.Second) + time.Sleep(3 * time.Second) // Step 13: GIF — hostedContents only supports JPG/PNG; Teams client uses SharePoint for GIFs. postReply(rootID, "⚠️ Please manually check GIF file transmission from Teams to Mattermost — this test cannot upload files to your SharePoint.", nil) @@ -295,7 +295,7 @@ func (b *Bmsteams) runTestSequence(channelName string) { {name: "demo1.png", contentType: "image/png", data: testdata.DemoPNG}, {name: "demo2.png", contentType: "image/png", data: testdata.DemoPNG}, }) - time.Sleep(time.Second) + time.Sleep(3 * time.Second) // Step 16: Delete the marked message if deleteID != "" { From 8c2ed7348ef955c64191a2e0e05b1c629ad142ba Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 15:12:46 +0100 Subject: [PATCH 33/42] fix: pointer type for PostPatch.Props PostPatch.Props expects *model.StringInterface, not model.StringInterface. Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/mattermost.go | 2 +- bridge/mattermost/test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 3de139e289..efd31f8a5d 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -480,7 +480,7 @@ func (b *Bmattermost) Send(msg config.Message) (string, error) { } _, _, err := b.mc.Client.PatchPost(context.TODO(), msg.ID, &model.PostPatch{ Message: &msg.Text, - Props: props, + Props: &props, }) if err != nil { return "", err diff --git a/bridge/mattermost/test.go b/bridge/mattermost/test.go index 5333a02d95..f6e38c38ae 100644 --- a/bridge/mattermost/test.go +++ b/bridge/mattermost/test.go @@ -93,7 +93,7 @@ func (b *Bmattermost) runTestSequence(channelName string) { } _, _, err := b.mc.Client.PatchPost(context.TODO(), typoID, &model.PostPatch{ Message: &newText, - Props: editProps, + Props: &editProps, }) if err != nil { b.Log.Errorf("test: PatchPost failed: %s", err) From 4271a5a2ec8518e993c7f4165f3dc47844623c67 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 15:40:06 +0100 Subject: [PATCH 34/42] feat: add {DISPLAYNAME} placeholder for Teams RemoteNickFormat Extract FirstName + LastName from Mattermost user profiles and pass them via Extra["displayname"] to destination bridges. The Teams bridge expands {DISPLAYNAME} in RemoteNickFormat to show the full name (e.g. "Alexander Griesser") instead of just the username. Includes a per-bridge displayNameCache to avoid redundant API calls. Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/handlers.go | 5 ++++ bridge/mattermost/mattermost.go | 41 ++++++++++++++++++++++++++++----- bridge/msteams/msteams.go | 12 ++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/bridge/mattermost/handlers.go b/bridge/mattermost/handlers.go index 673fd5e3bf..8bf07878e3 100644 --- a/bridge/mattermost/handlers.go +++ b/bridge/mattermost/handlers.go @@ -184,6 +184,11 @@ func (b *Bmattermost) handleMatterClient(messages chan *config.Message) { } } + // Populate full display name (FirstName + LastName) for bridges that support it. + if dn := b.getDisplayName(rmsg.UserID); dn != "" { + rmsg.Extra["displayname"] = []interface{}{dn} + } + messages <- rmsg } } diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index efd31f8a5d..99c3f6666c 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -24,18 +24,20 @@ type Bmattermost struct { uuid string TeamID string *bridge.Config - avatarMap map[string]string - channelsMutex sync.RWMutex - channelInfoMap map[string]*config.ChannelInfo + avatarMap map[string]string + displayNameCache map[string]string + channelsMutex sync.RWMutex + channelInfoMap map[string]*config.ChannelInfo } const mattermostPlugin = "mattermost.plugin" func New(cfg *bridge.Config) bridge.Bridger { b := &Bmattermost{ - Config: cfg, - avatarMap: make(map[string]string), - channelInfoMap: make(map[string]*config.ChannelInfo), + Config: cfg, + avatarMap: make(map[string]string), + displayNameCache: make(map[string]string), + channelInfoMap: make(map[string]*config.ChannelInfo), } b.v6 = b.GetBool("v6") @@ -44,6 +46,26 @@ func New(cfg *bridge.Config) bridge.Bridger { return b } +// getDisplayName returns the full display name (FirstName + LastName) for a +// Mattermost user, using a cache to avoid redundant API calls. Returns "" if +// the user has no first/last name set. +func (b *Bmattermost) getDisplayName(userID string) string { + if dn, ok := b.displayNameCache[userID]; ok { + return dn + } + if b.mc == nil { + return "" + } + user, _, err := b.mc.Client.GetUser(context.TODO(), userID, "") + if err != nil || user == nil { + b.displayNameCache[userID] = "" + return "" + } + dn := strings.TrimSpace(user.FirstName + " " + user.LastName) + b.displayNameCache[userID] = dn + return dn +} + func (b *Bmattermost) Command(cmd string) string { return "" } @@ -259,6 +281,7 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { username = override } } + var displayName string if username == "" { user, _, userErr := b.mc.Client.GetUser(context.TODO(), post.UserId, "") if userErr == nil && user != nil { @@ -267,6 +290,9 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { } else { username = user.Username } + dn := strings.TrimSpace(user.FirstName + " " + user.LastName) + b.displayNameCache[post.UserId] = dn + displayName = dn } else { username = "unknown" } @@ -287,6 +313,9 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { ParentID: post.RootId, Extra: make(map[string][]interface{}), } + if displayName != "" { + rmsg.Extra["displayname"] = []interface{}{displayName} + } // Handle file attachments. for _, fileID := range post.FileIds { diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index ade85d1d8f..6f16398a37 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -650,8 +650,20 @@ func (b *Bmsteams) formatMessageHTML(msg config.Message, bodyHTML string) string originalNick = strings.TrimSpace(msg.Username) } + // Extract full display name from Extra (set by Mattermost bridge). + displayName := "" + if dns, ok := msg.Extra["displayname"]; ok && len(dns) > 0 { + if dn, ok := dns[0].(string); ok { + displayName = dn + } + } + if displayName == "" { + displayName = originalNick + } + // HTML-aware expansion. result := template + result = strings.ReplaceAll(result, "{DISPLAYNAME}", ""+htmlEscape(displayName)+"") result = strings.ReplaceAll(result, "{NICK}", ""+htmlEscape(originalNick)+"") result = strings.ReplaceAll(result, "{NOPINGNICK}", ""+htmlEscape(originalNick)+"") result = strings.ReplaceAll(result, "{PROTOCOL}", htmlEscape(msg.Protocol)) From 1632055446c707a3749abcba1fc96ed57aeada08 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 15:51:14 +0100 Subject: [PATCH 35/42] fix: resolve {DISPLAYNAME} in gateway and add debug logging - Add {DISPLAYNAME} placeholder to gateway modifyUsername() so it resolves for all bridges, not just Teams HTML formatting - Add debug logging to getDisplayName() to show FirstName/LastName/ Nickname values from Mattermost API - Add debug logging in Teams Send() to show Extra["displayname"] Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/mattermost.go | 9 ++++++++- bridge/msteams/msteams.go | 7 ++++++- gateway/gateway.go | 7 +++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index 99c3f6666c..b22994304e 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -57,10 +57,17 @@ func (b *Bmattermost) getDisplayName(userID string) string { return "" } user, _, err := b.mc.Client.GetUser(context.TODO(), userID, "") - if err != nil || user == nil { + if err != nil { + b.Log.Debugf("getDisplayName: GetUser failed for %s: %s", userID, err) + b.displayNameCache[userID] = "" + return "" + } + if user == nil { b.displayNameCache[userID] = "" return "" } + b.Log.Debugf("getDisplayName: user %s FirstName=%q LastName=%q Nickname=%q", + userID, user.FirstName, user.LastName, user.Nickname) dn := strings.TrimSpace(user.FirstName + " " + user.LastName) b.displayNameCache[userID] = dn return dn diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 6f16398a37..f27d66e69b 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -470,12 +470,17 @@ func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[s func (b *Bmsteams) Send(msg config.Message) (string, error) { b.Log.Debugf("=> Receiving %#v", msg) - // Debug: log nick resolution for troubleshooting RemoteNickFormat. + // Debug: log nick and displayname resolution for troubleshooting RemoteNickFormat. if nicks, ok := msg.Extra["nick"]; ok && len(nicks) > 0 { b.Log.Debugf("nick from Extra: %v, msg.Username: %s", nicks[0], msg.Username) } else { b.Log.Debugf("no nick in Extra, msg.Username: %s", msg.Username) } + if dns, ok := msg.Extra["displayname"]; ok && len(dns) > 0 { + b.Log.Debugf("displayname from Extra: %v", dns[0]) + } else { + b.Log.Debugf("no displayname in Extra") + } // Handle deletes from Mattermost → Teams. if msg.Event == config.EventMsgDelete && msg.ID != "" { diff --git a/gateway/gateway.go b/gateway/gateway.go index 3ed202c36f..2abede5aa8 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -556,6 +556,13 @@ func (gw *Gateway) modifyUsername(msg *config.Message, dest *bridge.Bridge) stri nick = strings.ReplaceAll(nick, "{NICK}", msg.Username) nick = strings.ReplaceAll(nick, "{USERID}", msg.UserID) nick = strings.ReplaceAll(nick, "{CHANNEL}", msg.Channel) + displayName := msg.Username + if dns, ok := msg.Extra["displayname"]; ok && len(dns) > 0 { + if dn, ok := dns[0].(string); ok && dn != "" { + displayName = dn + } + } + nick = strings.ReplaceAll(nick, "{DISPLAYNAME}", displayName) tengoNick, err := gw.modifyUsernameTengo(msg, br) if err != nil { gw.logger.Errorf("modifyUsernameTengo error: %s", err) From 68d850a26a438615f8b97570dde18b51f8ed3915 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 16:20:29 +0100 Subject: [PATCH 36/42] feat: graceful shutdown + time-based cache pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add signal handling (SIGINT/SIGTERM) in main() so the bridge flushes persistent caches before exiting. Previously, stopPersistentCaches() was defined but never called, causing replay entries to be lost on kill — leading to duplicate replays on restart. Add MessageCacheDuration config option (default "168h" = 7 days) to control how long message ID mappings are kept. Entries older than the configured duration are pruned hourly during the flush loop and on startup. Metadata keys (__last_seen__, __delta_token__) are never pruned. Each PersistentMsgEntry now carries a CreatedAt timestamp. Co-Authored-By: Claude Opus 4.6 --- bridge/config/config.go | 1 + gateway/gateway.go | 16 ++++++++- gateway/msgcache.go | 77 ++++++++++++++++++++++++++++++++++------- gateway/router.go | 7 ++++ matterbridge.go | 8 ++++- 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/bridge/config/config.go b/bridge/config/config.go index 15a172b079..7d7f7a99db 100644 --- a/bridge/config/config.go +++ b/bridge/config/config.go @@ -157,6 +157,7 @@ type Protocol struct { MediaServerDownload string MediaConvertTgs string // telegram MediaConvertWebPToPNG bool // telegram + MessageCacheDuration string // general, msteams, mattermost: max age of cache entries (default "168h" = 7 days) MessageCacheFile string // general, msteams, mattermost: persistent message ID cache file MessageDelay int // IRC, time in millisecond to wait between messages MessageFormat string // telegram diff --git a/gateway/gateway.go b/gateway/gateway.go index 2abede5aa8..9d167b5199 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -74,7 +74,8 @@ func New(rootLogger *logrus.Logger, cfg *config.Gateway, r *Router) *Gateway { if existing, ok := pathToCache[p]; ok { gw.BridgeCaches[br.Account] = existing } else { - cache := NewPersistentMsgCache(p, logger) + maxAge := parseCacheDuration(br.GetString("MessageCacheDuration")) + cache := NewPersistentMsgCache(p, maxAge, logger) pathToCache[p] = cache gw.BridgeCaches[br.Account] = cache } @@ -197,6 +198,19 @@ func (gw *Gateway) stopPersistentCaches() { } } +// parseCacheDuration parses a MessageCacheDuration string (e.g. "168h", "24h"). +// Returns 0 for empty/invalid values (caller should apply default). +func parseCacheDuration(s string) time.Duration { + if s == "" { + return 0 + } + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return 0 + } + return d +} + // AddBridge sets up a new bridge on startup. // // It's added in the gateway object with the specified configuration, and is diff --git a/gateway/msgcache.go b/gateway/msgcache.go index b2472c72f5..391e29522e 100644 --- a/gateway/msgcache.go +++ b/gateway/msgcache.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/json" "os" + "strings" "sync" "time" @@ -11,38 +12,50 @@ import ( // PersistentMsgEntry represents a single downstream message ID mapping. type PersistentMsgEntry struct { - Protocol string `json:"protocol"` - BridgeName string `json:"bridge_name"` - ID string `json:"id"` - ChannelID string `json:"channel_id"` + Protocol string `json:"protocol"` + BridgeName string `json:"bridge_name"` + ID string `json:"id"` + ChannelID string `json:"channel_id"` + CreatedAt time.Time `json:"created_at,omitempty"` } // PersistentMsgCache is a file-backed message ID cache that persists // cross-bridge message ID mappings across restarts. type PersistentMsgCache struct { - mu sync.Mutex - path string - data map[string][]PersistentMsgEntry - dirty bool - ticker *time.Ticker - stopCh chan struct{} - logger *logrus.Entry + mu sync.Mutex + path string + data map[string][]PersistentMsgEntry + dirty bool + ticker *time.Ticker + stopCh chan struct{} + logger *logrus.Entry + maxAge time.Duration + lastPrune time.Time } +const defaultMaxAge = 168 * time.Hour // 7 days +const pruneInterval = 1 * time.Hour + // NewPersistentMsgCache creates a new persistent cache backed by the given file path. // Returns nil if path is empty. Loads existing data on creation and starts a // background flush loop that writes changes to disk every 30 seconds. -func NewPersistentMsgCache(path string, logger *logrus.Entry) *PersistentMsgCache { +// maxAge controls how long message ID entries are kept; zero uses the default (7 days). +func NewPersistentMsgCache(path string, maxAge time.Duration, logger *logrus.Entry) *PersistentMsgCache { if path == "" { return nil } + if maxAge <= 0 { + maxAge = defaultMaxAge + } c := &PersistentMsgCache{ path: path, data: make(map[string][]PersistentMsgEntry), stopCh: make(chan struct{}), logger: logger, + maxAge: maxAge, } c.load() + c.prune() // clean up stale entries on startup c.ticker = time.NewTicker(30 * time.Second) go c.flushLoop() return c @@ -67,6 +80,9 @@ func (c *PersistentMsgCache) flushLoop() { for { select { case <-c.ticker.C: + if time.Since(c.lastPrune) >= pruneInterval { + c.prune() + } c.Flush() case <-c.stopCh: c.ticker.Stop() @@ -76,10 +92,45 @@ func (c *PersistentMsgCache) flushLoop() { } } -// Add stores a message ID mapping. +// prune removes message ID entries older than maxAge. +// Metadata keys (__last_seen__, __delta_token__) are never pruned. +func (c *PersistentMsgCache) prune() { + c.mu.Lock() + defer c.mu.Unlock() + cutoff := time.Now().Add(-c.maxAge) + pruned := 0 + for key, entries := range c.data { + if strings.HasPrefix(key, lastSeenPrefix) || strings.HasPrefix(key, deltaTokenPrefix) { + continue + } + if len(entries) == 0 { + delete(c.data, key) + pruned++ + continue + } + // Use CreatedAt of first entry as the age of this mapping. + // Zero time (old entries without CreatedAt) are pruned immediately. + t := entries[0].CreatedAt + if t.IsZero() || t.Before(cutoff) { + delete(c.data, key) + pruned++ + } + } + if pruned > 0 { + c.dirty = true + c.logger.Infof("pruned %d stale entries from message cache (older than %s)", pruned, c.maxAge) + } + c.lastPrune = time.Now() +} + +// Add stores a message ID mapping. Sets CreatedAt on all entries. func (c *PersistentMsgCache) Add(key string, entries []PersistentMsgEntry) { c.mu.Lock() defer c.mu.Unlock() + now := time.Now() + for i := range entries { + entries[i].CreatedAt = now + } c.data[key] = entries c.dirty = true } diff --git a/gateway/router.go b/gateway/router.go index c8f2a324cd..60bdf5d100 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -111,6 +111,13 @@ func (r *Router) Start() error { return nil } +// Stop performs a graceful shutdown: flushes and stops all persistent caches. +func (r *Router) Stop() { + for _, gw := range r.Gateways { + gw.stopPersistentCaches() + } +} + // disableBridge returns true and empties a bridge if we have IgnoreFailureOnStart configured // otherwise returns false func (r *Router) disableBridge(br *bridge.Bridge, err error) bool { diff --git a/matterbridge.go b/matterbridge.go index f5a18b65cf..269798ebc5 100644 --- a/matterbridge.go +++ b/matterbridge.go @@ -4,8 +4,10 @@ import ( "flag" "fmt" "os" + "os/signal" "runtime" "strings" + "syscall" "github.com/google/gops/agent" "github.com/matterbridge-org/matterbridge/bridge/config" @@ -67,7 +69,11 @@ func main() { logger.Fatalf("Starting gateway failed: %s", err) } logger.Printf("Gateway(s) started successfully. Now relaying messages") - select {} + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + logger.Printf("Received signal, shutting down...") + r.Stop() } func setupLogger() *logrus.Logger { From 5d7016bfe09ec9a178d516fe7ac406cbf8d6901a Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 16:38:31 +0100 Subject: [PATCH 37/42] fix: MarkMessageBridged empty-slice pruning, add replay dedup debug logging MarkMessageBridged stored empty PersistentMsgEntry slices, which prune() immediately deletes (len==0 check). Store a sentinel entry instead so marker entries survive across restarts. Add comprehensive debug logging to trace the replay dedup chain: - IsMessageBridged: log key lookup result and cache count - persistentCacheAdd: log key, entry count, and target cache (or SKIPPED) - Router replay dedup: log cache key, account, and hit/miss - Cache load: show msg vs metadata entry counts + sample keys - Cache flush: log entry counts when writing to disk Co-Authored-By: Claude Opus 4.6 --- gateway/gateway.go | 18 ++++++++++++++++-- gateway/msgcache.go | 22 +++++++++++++++++++++- gateway/router.go | 5 +++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/gateway/gateway.go b/gateway/gateway.go index 9d167b5199..033bcbab56 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -160,6 +160,15 @@ func (gw *Gateway) hasPersistentCache() bool { func (gw *Gateway) persistentCacheAdd(key string, entries []PersistentMsgEntry, sourceAccount string) { if cache, ok := gw.BridgeCaches[sourceAccount]; ok && cache != nil { cache.Add(key, entries) + gw.logger.Debugf("persistentCacheAdd: %s → %d entries (cache: %s)", key, len(entries), sourceAccount) + } else { + gw.logger.Debugf("persistentCacheAdd: %s SKIPPED (no cache for %s, have: %v)", key, sourceAccount, func() []string { + keys := make([]string, 0, len(gw.BridgeCaches)) + for k := range gw.BridgeCaches { + keys = append(keys, k) + } + return keys + }()) } } @@ -237,12 +246,15 @@ func (gw *Gateway) AddBridge(cfg *config.Bridge) error { Bridge: br, IsMessageBridged: func(protocol, msgID string) bool { key := protocol + " " + msgID - if _, exists := gw.persistentCacheGet(key); exists { + if entries, exists := gw.persistentCacheGet(key); exists { + gw.logger.Debugf("IsMessageBridged: %s found (direct, %d entries)", key, len(entries)) return true } if downstream := gw.persistentCacheFindDownstream(key); downstream != "" { + gw.logger.Debugf("IsMessageBridged: %s found (downstream of %s)", key, downstream) return true } + gw.logger.Debugf("IsMessageBridged: %s NOT found (caches: %d)", key, len(gw.BridgeCaches)) return false }, GetLastSeen: func(channelKey string) (time.Time, bool) { @@ -257,9 +269,11 @@ func (gw *Gateway) AddBridge(cfg *config.Bridge) error { }, MarkMessageBridged: func(protocol, msgID string) { key := protocol + " " + msgID + gw.logger.Debugf("MarkMessageBridged: %s", key) for _, cache := range gw.BridgeCaches { if cache != nil { - cache.Add(key, []PersistentMsgEntry{}) + // Store a sentinel entry (not empty) so prune() doesn't delete it. + cache.Add(key, []PersistentMsgEntry{{Protocol: protocol}}) } } }, diff --git a/gateway/msgcache.go b/gateway/msgcache.go index 391e29522e..b1e019f856 100644 --- a/gateway/msgcache.go +++ b/gateway/msgcache.go @@ -72,7 +72,19 @@ func (c *PersistentMsgCache) load() { if err := json.Unmarshal(f, &c.data); err != nil { c.logger.Warnf("failed to parse message cache %s: %s", c.path, err) } else { - c.logger.Infof("loaded %d entries from message cache %s", len(c.data), c.path) + // Count non-metadata entries and show a sample. + msgEntries := 0 + sample := make([]string, 0, 5) + for key := range c.data { + if !strings.HasPrefix(key, lastSeenPrefix) && !strings.HasPrefix(key, deltaTokenPrefix) { + msgEntries++ + if len(sample) < 5 { + sample = append(sample, key) + } + } + } + c.logger.Infof("loaded %d entries from message cache %s (%d msg, %d metadata, sample: %v)", + len(c.data), c.path, msgEntries, len(c.data)-msgEntries, sample) } } @@ -166,6 +178,13 @@ func (c *PersistentMsgCache) Flush() { if !c.dirty { return } + // Count non-metadata entries for logging. + msgEntries := 0 + for key := range c.data { + if !strings.HasPrefix(key, lastSeenPrefix) && !strings.HasPrefix(key, deltaTokenPrefix) { + msgEntries++ + } + } data, err := json.MarshalIndent(c.data, "", " ") if err != nil { c.logger.Errorf("failed to marshal message cache: %s", err) @@ -176,6 +195,7 @@ func (c *PersistentMsgCache) Flush() { return } c.dirty = false + c.logger.Infof("flushed message cache %s (%d msg entries, %d total keys)", c.path, msgEntries, len(c.data)) } // SetLastSeen stores the timestamp of the last processed message for a channel. diff --git a/gateway/router.go b/gateway/router.go index 60bdf5d100..7a73fbccab 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -162,9 +162,11 @@ func (r *Router) handleReceive() { if isReplay { if msg.ID != "" { cacheKey := msg.Protocol + " " + msg.ID + r.logger.Debugf("replay: dedup check for %s (account=%s)", cacheKey, msg.Account) alreadyBridged := false for _, gw := range r.Gateways { if !gw.hasPersistentCache() { + r.logger.Debugf("replay: gateway %s has no persistent cache", gw.Name) continue } if _, exists := gw.persistentCacheGet(cacheKey); exists { @@ -180,6 +182,7 @@ func (r *Router) handleReceive() { r.logger.Debugf("replay: skipping already-bridged message %s", cacheKey) continue } + r.logger.Debugf("replay: message %s NOT found in cache, will bridge", cacheKey) } msg.Event = "" // clear so downstream pipeline treats it as a normal message } @@ -229,6 +232,8 @@ func (r *Router) handleReceive() { } if len(entries) > 0 { gw.persistentCacheAdd(cacheKey, entries, msg.Account) + } else if isReplay { + r.logger.Debugf("replay: no cacheable entries for %s (msgIDs=%d)", cacheKey, len(msgIDs)) } // Update last-seen timestamp for the source channel. channelKey := msg.Channel + msg.Account From 41a285be3aec3f6a5ab871f8518f9a6e6824ce75 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 16:43:17 +0100 Subject: [PATCH 38/42] fix: make Stop() synchronous so cache flush completes before exit Stop() was non-blocking: it closed stopCh and returned immediately. The flushLoop goroutine would pick up the close and call Flush(), but main() returned first, killing the goroutine before the write completed. This caused all cache entries added during the run to be lost. Add doneCh that flushLoop closes via defer when it returns. Stop() now blocks on <-doneCh, ensuring the final Flush() finishes before main() exits. Co-Authored-By: Claude Opus 4.6 --- gateway/msgcache.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gateway/msgcache.go b/gateway/msgcache.go index b1e019f856..08ada5996a 100644 --- a/gateway/msgcache.go +++ b/gateway/msgcache.go @@ -28,6 +28,7 @@ type PersistentMsgCache struct { dirty bool ticker *time.Ticker stopCh chan struct{} + doneCh chan struct{} logger *logrus.Entry maxAge time.Duration lastPrune time.Time @@ -51,6 +52,7 @@ func NewPersistentMsgCache(path string, maxAge time.Duration, logger *logrus.Ent path: path, data: make(map[string][]PersistentMsgEntry), stopCh: make(chan struct{}), + doneCh: make(chan struct{}), logger: logger, maxAge: maxAge, } @@ -89,6 +91,7 @@ func (c *PersistentMsgCache) load() { } func (c *PersistentMsgCache) flushLoop() { + defer close(c.doneCh) for { select { case <-c.ticker.C: @@ -249,7 +252,8 @@ func (c *PersistentMsgCache) GetDeltaToken(channelKey string) (string, bool) { return entries[0].ID, true } -// Stop stops the background flush loop and performs a final flush. +// Stop stops the background flush loop and waits for the final flush to complete. func (c *PersistentMsgCache) Stop() { close(c.stopCh) + <-c.doneCh // block until flushLoop completes its final Flush() } From 1b8490ab37ea365b63bf94116b8485700fbaa28c Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 18:15:44 +0100 Subject: [PATCH 39/42] fix: security hardening and dead code cleanup for MSTeams bridge - HTML-escape filenames in all HTML contexts (img alt, anchor href/text, bold tags) to prevent XSS via crafted filenames - Add decodeChannelID() in updateMessage/deleteMessage for consistent channel ID handling in Graph API URLs - URL-encode filename in uploadToMediaServer() to prevent path traversal - Add domain validation for code snippet URLs (must be graph.microsoft.com) - Remove dead Workbook/Worksheets exploration code from findFile() - Replace spew debug dependency with standard %+v formatting - Remove verbose nick/displayname troubleshooting logs from Send() Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/handler.go | 8 +++++--- bridge/msteams/msteams.go | 27 +++++++-------------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/bridge/msteams/handler.go b/bridge/msteams/handler.go index b7d3ca154e..080a8132a6 100644 --- a/bridge/msteams/handler.go +++ b/bridge/msteams/handler.go @@ -22,8 +22,6 @@ func (b *Bmsteams) findFile(weburl string) (string, error) { if err != nil { return "", err } - itemRB.Workbook().Worksheets() - b.gc.Workbooks() item, err := itemRB.Request().Get(b.ctx) if err != nil { return "", err @@ -71,7 +69,7 @@ func (b *Bmsteams) notifyFileTooLarge(rmsg *config.Message, filename string, act parentID := rmsg.ID text := fmt.Sprintf("⚠️ File %s could not be transferred — file too large (%d MB, limit: %d MB).", - filename, actualSize/(1024*1024), maxSize/(1024*1024)) + htmlEscape(filename), actualSize/(1024*1024), maxSize/(1024*1024)) htmlType := msgraph.BodyTypeVHTML content := &msgraph.ItemBody{Content: &text, ContentType: &htmlType} chatMsg := &msgraph.ChatMessage{Body: content} @@ -153,6 +151,10 @@ func (b *Bmsteams) handleCodeSnippet(rmsg *config.Message, attach msgraph.ChatMe b.Log.Errorf("codesnippetUrl has unexpected size: %s", content.CodeSnippetURL) return } + if !strings.HasPrefix(content.CodeSnippetURL, "https://graph.microsoft.com/") { + b.Log.Errorf("codesnippetUrl has unexpected host: %s", content.CodeSnippetURL) + return + } resp, err := b.gc.Teams().Request().Client().Get(content.CodeSnippetURL) if err != nil { b.Log.Errorf("retrieving snippet content failed:%s", err) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index f27d66e69b..6a50060d42 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -18,7 +18,6 @@ import ( "github.com/matterbridge-org/matterbridge/bridge" "github.com/matterbridge-org/matterbridge/bridge/config" - "github.com/davecgh/go-spew/spew" "github.com/gomarkdown/markdown" mdhtml "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" @@ -354,7 +353,7 @@ func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[s } if b.GetBool("debug") { - b.Log.Debug("Msg dump: ", spew.Sdump(msg)) + b.Log.Debugf("Msg dump: %+v", msg) } if msg.From == nil || msg.From.User == nil { @@ -470,18 +469,6 @@ func (b *Bmsteams) processDelta(messages []msgraph.ChatMessage, replyToIDs map[s func (b *Bmsteams) Send(msg config.Message) (string, error) { b.Log.Debugf("=> Receiving %#v", msg) - // Debug: log nick and displayname resolution for troubleshooting RemoteNickFormat. - if nicks, ok := msg.Extra["nick"]; ok && len(nicks) > 0 { - b.Log.Debugf("nick from Extra: %v, msg.Username: %s", nicks[0], msg.Username) - } else { - b.Log.Debugf("no nick in Extra, msg.Username: %s", msg.Username) - } - if dns, ok := msg.Extra["displayname"]; ok && len(dns) > 0 { - b.Log.Debugf("displayname from Extra: %v", dns[0]) - } else { - b.Log.Debugf("no displayname in Extra") - } - // Handle deletes from Mattermost → Teams. if msg.Event == config.EventMsgDelete && msg.ID != "" { b.Log.Debugf("delete: soft-deleting Teams message ID %s", msg.ID) @@ -724,7 +711,7 @@ func (b *Bmsteams) updateMessage(msg config.Message) (string, error) { } teamID := b.GetString("TeamID") - channelID := msg.Channel + channelID := decodeChannelID(msg.Channel) messageID := msg.ID url := fmt.Sprintf("https://graph.microsoft.com/beta/teams/%s/channels/%s/messages/%s", @@ -763,7 +750,7 @@ func (b *Bmsteams) updateMessage(msg config.Message) (string, error) { // For replies, msg.ParentID must be set to the top-level message ID. func (b *Bmsteams) deleteMessage(msg config.Message) (string, error) { teamID := b.GetString("TeamID") - channelID := msg.Channel + channelID := decodeChannelID(msg.Channel) messageID := msg.ID var url string @@ -822,7 +809,7 @@ func (b *Bmsteams) uploadToMediaServer(fi config.FileInfo) (string, error) { } writer.Close() - resp, err := http.Post(serverURL+"/"+fi.Name, writer.FormDataContentType(), &buf) //nolint:gosec + resp, err := http.Post(serverURL+"/"+url.PathEscape(fi.Name), writer.FormDataContentType(), &buf) //nolint:gosec if err != nil { return "", err } @@ -907,7 +894,7 @@ func (b *Bmsteams) sendImageHostedContent(msg config.Message, files []config.Fil id := fmt.Sprintf("%d", i+1) bodyHTML += fmt.Sprintf( `%s`, - id, fi.Name, + id, htmlEscape(fi.Name), ) if i < len(files)-1 { bodyHTML += "
" @@ -1014,12 +1001,12 @@ func (b *Bmsteams) sendFileAsMessage(msg config.Message, fi config.FileInfo, cap case fileURL != "" && isImage: bodyText = fmt.Sprintf( `%s%s%s`, - usernameHTML, captionPart, fileURL, fi.Name, + usernameHTML, captionPart, htmlEscape(fileURL), htmlEscape(fi.Name), ) case fileURL != "": bodyText = fmt.Sprintf( `%s%s📎
%s`, - usernameHTML, captionPart, fileURL, fi.Name, + usernameHTML, captionPart, htmlEscape(fileURL), htmlEscape(fi.Name), ) default: // File can't be sent: no hostedContents support and no MediaServer URL. From ac71ea0feecc6b0101028a05895b1603cb735d18 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 18:40:38 +0100 Subject: [PATCH 40/42] fix: prevent replay of bridge-own messages after restart Teams side: skip messages in processReplay() where the author matches the bridge's own user ID (botID). This prevents test sequence messages and relayed messages from being replayed back to their source platform. Mattermost side: skip messages in replayMissedMessages() that have the matterbridge_srcid prop, which is set on all bridged messages regardless of bridge instance UUID. This fixes a race condition where the UUID-based prop check fails across restarts because the UUID is regenerated. Co-Authored-By: Claude Opus 4.6 --- bridge/mattermost/mattermost.go | 6 ++++++ bridge/msteams/msteams.go | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bridge/mattermost/mattermost.go b/bridge/mattermost/mattermost.go index b22994304e..e0e8db215b 100644 --- a/bridge/mattermost/mattermost.go +++ b/bridge/mattermost/mattermost.go @@ -269,6 +269,12 @@ func (b *Bmattermost) replayMissedMessages(channel config.ChannelInfo) { if _, ok := post.Props["matterbridge_test"]; ok { continue } + // Skip messages bridged from another platform (any bridge instance). + // The matterbridge_srcid prop is set on all bridged messages and + // survives across bridge restarts (unlike the UUID-based prop). + if _, ok := post.Props["matterbridge_srcid"]; ok { + continue + } } // Skip system messages. diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 6a50060d42..8ae41beb81 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -270,7 +270,14 @@ func (b *Bmsteams) processReplay(messages []msgraph.ChatMessage, replyToIDs map[ key, parentID := deltaMessageKey(msg, replyToIDs) - // Skip messages we sent. + // Skip messages posted by the bridge itself (e.g. test sequence, + // relayed messages from other platforms). These should never be + // replayed back — the source platform's replay handles them. + if msg.From.User.ID != nil && *msg.From.User.ID == b.botID { + continue + } + + // Skip messages we sent (in-memory, current run only). if _, wasSentByUs := b.sentIDs[*msg.ID]; wasSentByUs { continue } From d24e7149a96c56d0b5a0cdf85e98d820bd9b65de Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 18:50:46 +0100 Subject: [PATCH 41/42] fix: use data-mb-src marker instead of botID for replay dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The botID check broke Teams→Mattermost replay because delegated auth means botID == the authenticated user's ID, skipping ALL their messages. Replace with data-mb-src marker check which only matches bridge-posted messages. Also add the marker to test sequence postRoot/postReply. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/msteams.go | 10 ++++++---- bridge/msteams/test.go | 10 ++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/bridge/msteams/msteams.go b/bridge/msteams/msteams.go index 8ae41beb81..f685686b87 100644 --- a/bridge/msteams/msteams.go +++ b/bridge/msteams/msteams.go @@ -270,10 +270,12 @@ func (b *Bmsteams) processReplay(messages []msgraph.ChatMessage, replyToIDs map[ key, parentID := deltaMessageKey(msg, replyToIDs) - // Skip messages posted by the bridge itself (e.g. test sequence, - // relayed messages from other platforms). These should never be - // replayed back — the source platform's replay handles them. - if msg.From.User.ID != nil && *msg.From.User.ID == b.botID { + // Skip messages posted by the bridge itself. All messages sent + // by the bridge contain a hidden data-mb-src marker in the HTML + // body (added by formatMessageHTML and test sequences). Manually + // typed messages in Teams don't have this marker. + if msg.Body != nil && msg.Body.Content != nil && + strings.Contains(*msg.Body.Content, "data-mb-src=") { continue } diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go index 89c700c2d4..acbc28b9cf 100644 --- a/bridge/msteams/test.go +++ b/bridge/msteams/test.go @@ -30,6 +30,11 @@ func (b *Bmsteams) runTestSequence(channelName string) { // Helper to post a top-level message and return its ID. postRoot := func(text string, contentType *msgraph.BodyType) string { + // Add bridge marker so processReplay() skips test messages on restart. + text += `` + if contentType == nil { + contentType = &htmlType + } ct := b.gc.Teams().ID(teamID).Channels().ID(channelID).Messages().Request() content := &msgraph.ItemBody{Content: &text} if contentType != nil { @@ -46,6 +51,11 @@ func (b *Bmsteams) runTestSequence(channelName string) { // Helper to post a reply and return its ID. postReply := func(rootID, text string, contentType *msgraph.BodyType) string { + // Add bridge marker so processReplay() skips test messages on restart. + text += `` + if contentType == nil { + contentType = &htmlType + } ct := b.gc.Teams().ID(teamID).Channels().ID(channelID).Messages().ID(rootID).Replies().Request() content := &msgraph.ItemBody{Content: &text} if contentType != nil { From f43671d8a145e5bb8ae84899d2cb22288d4b2757 Mon Sep 17 00:00:00 2001 From: Alexander Griesser Date: Fri, 13 Mar 2026 18:54:59 +0100 Subject: [PATCH 42/42] fix: test.go compile error + skip unnecessary cache flushes Move htmlType declaration before closures that reference it. In SetDeltaToken/SetLastSeen, skip update when value is unchanged to avoid marking cache dirty on every poll cycle. Co-Authored-By: Claude Opus 4.6 --- bridge/msteams/test.go | 4 ++-- gateway/msgcache.go | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go index acbc28b9cf..690f9b9bcd 100644 --- a/bridge/msteams/test.go +++ b/bridge/msteams/test.go @@ -28,6 +28,8 @@ func (b *Bmsteams) runTestSequence(channelName string) { b.Log.Infof("test: starting test sequence in channel %s", channelName) + htmlType := msgraph.BodyTypeVHTML + // Helper to post a top-level message and return its ID. postRoot := func(text string, contentType *msgraph.BodyType) string { // Add bridge marker so processReplay() skips test messages on restart. @@ -151,8 +153,6 @@ func (b *Bmsteams) runTestSequence(channelName string) { // Do NOT add to updatedIDs — let poll() pick up the delete for relay. } - htmlType := msgraph.BodyTypeVHTML - // Step 1: Root message rootID := postRoot("🧪 Matterbridge Test Sequence
This is a root message to test the bridge relay.", &htmlType) if rootID == "" { diff --git a/gateway/msgcache.go b/gateway/msgcache.go index 08ada5996a..059cfa2cc4 100644 --- a/gateway/msgcache.go +++ b/gateway/msgcache.go @@ -206,9 +206,12 @@ func (c *PersistentMsgCache) Flush() { func (c *PersistentMsgCache) SetLastSeen(channelKey string, t time.Time) { c.mu.Lock() defer c.mu.Unlock() - c.data[lastSeenPrefix+channelKey] = []PersistentMsgEntry{{ - ID: t.Format(time.RFC3339Nano), - }} + key := lastSeenPrefix + channelKey + formatted := t.Format(time.RFC3339Nano) + if entries, ok := c.data[key]; ok && len(entries) > 0 && entries[0].ID == formatted { + return + } + c.data[key] = []PersistentMsgEntry{{ID: formatted}} c.dirty = true } @@ -235,9 +238,11 @@ const deltaTokenPrefix = "__delta_token__:" func (c *PersistentMsgCache) SetDeltaToken(channelKey, token string) { c.mu.Lock() defer c.mu.Unlock() - c.data[deltaTokenPrefix+channelKey] = []PersistentMsgEntry{{ - ID: token, - }} + key := deltaTokenPrefix + channelKey + if entries, ok := c.data[key]; ok && len(entries) > 0 && entries[0].ID == token { + return + } + c.data[key] = []PersistentMsgEntry{{ID: token}} c.dirty = true }