Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
91aa9ea
Do not double prefix the username in messages
anx-ag Mar 12, 2026
1b54824
Support for hybrid (hook + token) mode
anx-ag Mar 12, 2026
139284c
Major MSTeams Overhaul - initial release
anx-ag Mar 12, 2026
67a99e4
Add @matterbridge test command for end-to-end relay testing
anx-ag Mar 12, 2026
a9553b0
Fix MM→Teams formatting, emoji mapping, and strikethrough
anx-ag Mar 12, 2026
312897e
Fix emoji format, strikethrough, and add nick debug logging
anx-ag Mar 12, 2026
5f1fa5a
Add list test steps and hostedContents image transfer for MM→Teams
anx-ag Mar 12, 2026
db9870d
Fix Teams→MM image transfer: strip hostedContents, webhook props, hyb…
anx-ag Mar 12, 2026
af7162b
Fix echo, mixed content, and GIF handling for MM→Teams file transfer
anx-ag Mar 12, 2026
fcf5afa
Fix duplicate delivery, thread ID mapping, echo, and unsupported file…
anx-ag Mar 12, 2026
d9ed49a
Fix multi-image bundling, MM thread ID mapping, and source-side notif…
anx-ag Mar 12, 2026
375c8d4
Fix deadlock: wrap b.Remote send in goroutine for unsupported file no…
anx-ag Mar 12, 2026
6c19a37
Post unsupported-file notification as thread reply with username matt…
anx-ag Mar 12, 2026
b646be0
Add image tests, GIF support, priority forwarding, and persistent mes…
anx-ag Mar 12, 2026
44ab6a1
Fix SetProp: use Props map directly for older Mattermost model
anx-ag Mar 12, 2026
cf308ac
Add hostedContents image relay, revert GIF support, add priority tests
anx-ag Mar 12, 2026
50df69c
Per-bridge MessageCacheFile, override_username for API posts, fix pri…
anx-ag Mar 12, 2026
1290244
Fix priority test: use simple posts (SetPostPriority not in Client4)
anx-ag Mar 12, 2026
62e2f9a
rework Mattermost Message Handling, fix MessageCacheFile Handling, fi…
anx-ag Mar 12, 2026
d7223dc
Add MediaDownloadSize enforcement for Teams, message replay on restar…
anx-ag Mar 13, 2026
f8cc784
Fix GetPostsSince: last param is bool (collapsedThreads), not string
anx-ag Mar 13, 2026
16ec7fd
Fix notifyFileTooLarge to post warning in Teams (source), remove $ord…
anx-ag Mar 13, 2026
b8df4ff
fix: move replayMissedMessages into goroutine to prevent JoinChannel …
anx-ag Mar 13, 2026
7ab79ec
fix: replay thread replies, add pagination, skip empty replays, consi…
anx-ag Mar 13, 2026
ff1060c
fix: prevent replay of bridge-generated errors, add timezone to repla…
anx-ag Mar 13, 2026
9de2046
fix: nil pointer crash in handleAttachments for messageReference atta…
anx-ag Mar 13, 2026
2e4fe3a
refactor: replace ReplayWindow with lastSeen-based replay
anx-ag Mar 13, 2026
272f700
refactor: replace Teams poll loop with Graph API delta queries
anx-ag Mar 13, 2026
3f9cb48
fix: prevent first-start message flooding with delta queries
anx-ag Mar 13, 2026
e8edab2
debug: add logging to verify if delta endpoint returns replies
anx-ag Mar 13, 2026
f8ecb5d
feat: delta-guided reply polling for Teams thread replies
anx-ag Mar 13, 2026
1958a78
fix: preserve icon/username on edit, improve test ordering
anx-ag Mar 13, 2026
8c2ed73
fix: pointer type for PostPatch.Props
anx-ag Mar 13, 2026
4271a5a
feat: add {DISPLAYNAME} placeholder for Teams RemoteNickFormat
anx-ag Mar 13, 2026
1632055
fix: resolve {DISPLAYNAME} in gateway and add debug logging
anx-ag Mar 13, 2026
68d850a
feat: graceful shutdown + time-based cache pruning
anx-ag Mar 13, 2026
5d7016b
fix: MarkMessageBridged empty-slice pruning, add replay dedup debug l…
anx-ag Mar 13, 2026
41a285b
fix: make Stop() synchronous so cache flush completes before exit
anx-ag Mar 13, 2026
1b8490a
fix: security hardening and dead code cleanup for MSTeams bridge
anx-ag Mar 13, 2026
ac71ea0
fix: prevent replay of bridge-own messages after restart
anx-ag Mar 13, 2026
d24e714
fix: use data-mb-src marker instead of botID for replay dedup
anx-ag Mar 13, 2026
f43671d
fix: test.go compile error + skip unnecessary cache flushes
anx-ag Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ 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)

// 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)

// 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
Expand Down
4 changes: 4 additions & 0 deletions bridge/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const (
EventUserTyping = "user_typing"
EventGetChannelMembers = "get_channel_members"
EventNoticeIRC = "notice_irc"
EventHistoricalMapping = "historical_mapping"
EventReplayMessage = "replay_message"
)

const ParentIDNotFound = "msg-parent-not-found"
Expand Down Expand Up @@ -155,6 +157,8 @@ 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
MessageLength int // IRC, max length of a message allowed
Expand Down
64 changes: 64 additions & 0 deletions bridge/helper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
94 changes: 84 additions & 10 deletions bridge/mattermost/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package bmattermost

import (
"context"
"fmt"
"strings"

"github.com/matterbridge-org/matterbridge/bridge/config"
"github.com/matterbridge-org/matterbridge/bridge/helper"
Expand Down Expand Up @@ -96,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) {
Expand Down Expand Up @@ -128,6 +145,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")
Expand All @@ -144,13 +170,25 @@ 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 != "" {
rmsg.Username = nick
}
}

// 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
}
}
Expand All @@ -170,22 +208,58 @@ func (b *Bmattermost) handleMatterHook(messages chan *config.Message) {
}

func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) {
var err error
var res, id 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)
id, err = b.mc.UploadFile(*fi.Data, channelID, fi.Name)
fileID, err := b.mc.UploadFile(*fi.Data, channelID, fi.Name)
if err != nil {
return "", err
b.Log.Errorf("upload file %s failed: %s", fi.Name, err)
continue
}
msg.Text = fi.Comment
if b.GetBool("PrefixMessagesWithNick") {
msg.Text = msg.Username + msg.Text
fileIDs = append(fileIDs, fileID)
if i == 0 {
firstComment = fi.Comment
}
res, err = b.mc.PostMessageWithFiles(channelID, msg.Text, msg.ParentID, []string{id})
}
return res, err

if len(fileIDs) == 0 {
return "", fmt.Errorf("no files uploaded successfully")
}

text := firstComment

// Build a single post with all files so they appear as one message
// with the bridged user's name and avatar via override_username.
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
}
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
}
return created.Id, nil
}

//nolint:forcetypeassert
Expand Down
13 changes: 10 additions & 3 deletions bridge/mattermost/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -227,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
}
Expand Down
Loading