to Teams .
+ preCodeLangRE := regexp.MustCompile(``)
+ result = preCodeLangRE.ReplaceAllString(result, ``)
+ 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)
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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
+ }
+
+ // 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)
+ }
+
+ // 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))
+ 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", "
")
+
+ 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.
+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) {
+ // 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 {
+ ContentType string `json:"contentType"`
+ Content string `json:"content"`
+ } `json:"body"`
+ }
+
+ var patch patchBody
+ patch.Body.ContentType = "html"
+ patch.Body.Content = htmlText
+
+ jsonData, err := json.Marshal(patch)
+ if err != nil {
+ return "", err
+ }
+
+ teamID := b.GetString("TeamID")
+ channelID := decodeChannelID(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 := decodeChannelID(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+"/"+url.PathEscape(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) != ""
+}
+
+// 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 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")
+ }
+
+ 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"`
+ }
+
+ 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(
+ `
`,
+ id, htmlEscape(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: hosted,
+ }
+
+ 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()
+
+ respBody, _ := io.ReadAll(resp.Body)
+
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("sendImageHostedContent failed: %d %s", resp.StatusCode, string(respBody))
+ }
+
+ // 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{}{}
+ b.updatedIDs[result.ID] = time.Now().Add(30 * time.Second)
+ return result.ID, nil
+ }
+ return "", nil
+}
+
+// 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)
+
+ contentType := msgraph.BodyTypeVHTML
+ var bodyText string
+
+ 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
+ }
+ }
+
+ usernameHTML := b.formatMessageHTML(msg, "")
+ captionPart := ""
+ if captionHTML != "" {
+ captionPart = captionHTML + "
"
+ }
+
+ switch {
+ case fileURL != "" && isImage:
+ bodyText = fmt.Sprintf(
+ `%s%s
`,
+ usernameHTML, captionPart, htmlEscape(fileURL), htmlEscape(fi.Name),
+ )
+ case fileURL != "":
+ bodyText = fmt.Sprintf(
+ `%s%s📎 %s`,
+ usernameHTML, captionPart, htmlEscape(fileURL), htmlEscape(fi.Name),
+ )
+ default:
+ // 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))
+ // 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("⚠️ 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,
+ Username: "matterbridge",
+ ParentID: fakeID,
+ Extra: make(map[string][]interface{}),
+ }
+ }()
+ return fakeID, nil
+ }
+
+ content := &msgraph.ItemBody{
+ Content: &bodyText,
+ ContentType: &contentType,
+ }
+ 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()
+ 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)
+ }
+ if err != nil {
+ return "", err
+ }
+ 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
}
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()
+
+ // 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}
- 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
+ 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{}{}
+ b.updatedIDs[*res.ID] = time.Now().Add(30 * time.Second)
+ return *res.ID, nil
}
-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
+// 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) 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.
+//
//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)
- }
+ channelKey := channelName + b.Account
+ 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
+ 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)
+ }
+
+ // 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 fmt.Errorf("initial fetchDelta: %w", err)
+ }
+
+ 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)
+ 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, 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 (%d root messages tracked)", channelName, len(rootMsgCreated))
+
+ // 3. Poll loop.
+ for {
+ 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 fmt.Errorf("fetchDelta: %w", err)
+ }
+
+ 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 {
+ b.SetDeltaToken(channelKey, deltaLink)
+ }
+ }
+ }
}
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)?(div|p)(\s[^>]*)?>`).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 inline
tags that reference hostedContents URLs — these are
+ // Teams-internal image URLs that require authentication and would produce
+ // broken markdown like .
+ // 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)>(.*?)(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, "")
+
+ // 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())
}
diff --git a/bridge/msteams/test.go b/bridge/msteams/test.go
new file mode 100644
index 0000000000..690f9b9bcd
--- /dev/null
+++ b/bridge/msteams/test.go
@@ -0,0 +1,319 @@
+package bmsteams
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/matterbridge-org/matterbridge/testdata"
+ 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)
+
+ 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.
+ 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 {
+ 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 {
+ // 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 {
+ 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.
+ }
+
+ // 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: Unordered list
+ postReply(rootID, "- Item one
- Item two
- Item three
", &htmlType)
+ time.Sleep(time.Second)
+
+ // Step 11: Ordered list
+ postReply(rootID, "- First point
- Second point
- Third point
", &htmlType)
+ time.Sleep(time.Second)
+
+ 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(`
`, 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(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)
+ time.Sleep(time.Second)
+
+ // 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(3 * time.Second)
+
+ // Step 16: Delete the marked message
+ if deleteID != "" {
+ deleteReply(rootID, deleteID)
+ }
+
+ // 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 7ca84e4f49..033bcbab56 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
+ BridgeCaches map[string]*PersistentMsgCache // per-bridge persistent caches, keyed by Account
logger *logrus.Entry
}
@@ -58,6 +59,28 @@ 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 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 {
+ p := br.GetString("MessageCacheFile")
+ if p == "" {
+ continue
+ }
+ if existing, ok := pathToCache[p]; ok {
+ gw.BridgeCaches[br.Account] = existing
+ } else {
+ maxAge := parseCacheDuration(br.GetString("MessageCacheDuration"))
+ cache := NewPersistentMsgCache(p, maxAge, logger)
+ pathToCache[p] = cache
+ gw.BridgeCaches[br.Account] = cache
+ }
+ }
+
return gw
}
@@ -78,9 +101,125 @@ func (gw *Gateway) FindCanonicalMsgID(protocol string, mID string) string {
}
}
}
+
+ // Fallback to persistent cache if LRU missed.
+ if gw.hasPersistentCache() {
+ // Check if ID is a direct key in persistent cache.
+ if entries, ok := gw.persistentCacheGet(ID); ok {
+ gw.restoreToCacheFindCanonical(ID, entries)
+ return ID
+ }
+ // Check if ID is a downstream value.
+ if canonical := gw.persistentCacheFindDownstream(ID); canonical != "" {
+ if entries, ok := gw.persistentCacheGet(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
+}
+
+// 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 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)
+ 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
+ }())
+ }
+}
+
+// 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
+ }
+ }
+}
+
+// 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
@@ -105,6 +244,56 @@ 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 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) {
+ for _, cache := range gw.BridgeCaches {
+ if cache != nil {
+ if t, ok := cache.GetLastSeen(channelKey); ok {
+ return t, true
+ }
+ }
+ }
+ return time.Time{}, false
+ },
+ MarkMessageBridged: func(protocol, msgID string) {
+ key := protocol + " " + msgID
+ gw.logger.Debugf("MarkMessageBridged: %s", key)
+ for _, cache := range gw.BridgeCaches {
+ if cache != nil {
+ // Store a sentinel entry (not empty) so prune() doesn't delete it.
+ cache.Add(key, []PersistentMsgEntry{{Protocol: protocol}})
+ }
+ }
+ },
+ 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 {
@@ -283,6 +472,23 @@ func (gw *Gateway) getDestMsgID(msgID string, dest *bridge.Bridge, channel *conf
}
}
}
+
+ // Fallback to persistent cache if LRU missed.
+ 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 {
+ 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 ""
}
@@ -378,6 +584,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)
@@ -476,6 +689,19 @@ 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}
+
+ // 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..059cfa2cc4
--- /dev/null
+++ b/gateway/msgcache.go
@@ -0,0 +1,264 @@
+package gateway
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "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"`
+ 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{}
+ doneCh 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.
+// 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{}),
+ doneCh: 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
+}
+
+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 {
+ // 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)
+ }
+}
+
+func (c *PersistentMsgCache) flushLoop() {
+ defer close(c.doneCh)
+ for {
+ select {
+ case <-c.ticker.C:
+ if time.Since(c.lastPrune) >= pruneInterval {
+ c.prune()
+ }
+ c.Flush()
+ case <-c.stopCh:
+ c.ticker.Stop()
+ c.Flush()
+ return
+ }
+ }
+}
+
+// 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
+}
+
+// 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
+ }
+ // 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)
+ 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
+ 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.
+// 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()
+ 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
+}
+
+// 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__:"
+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()
+ 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
+}
+
+// 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 waits for the final flush to complete.
+func (c *PersistentMsgCache) Stop() {
+ close(c.stopCh)
+ <-c.doneCh // block until flushLoop completes its final Flush()
+}
diff --git a/gateway/router.go b/gateway/router.go
index a8aad1aaef..7a73fbccab 100644
--- a/gateway/router.go
+++ b/gateway/router.go
@@ -2,6 +2,7 @@ package gateway
import (
"fmt"
+ "strings"
"sync"
"time"
@@ -110,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 {
@@ -143,6 +151,42 @@ 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
+ }
+
+ // 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
+ 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 {
+ 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
+ }
+ 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
+ }
+
filesHandled := false
for _, gw := range r.Gateways {
// record all the message ID's of the different bridges
@@ -161,7 +205,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 +214,130 @@ 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.hasPersistentCache() && 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.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
+ if cache, ok := gw.BridgeCaches[msg.Account]; ok && cache != nil {
+ cache.SetLastSeen(channelKey, msg.Timestamp)
+ }
}
}
}
}
}
+// 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.hasPersistentCache() {
+ 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.persistentCacheGet(sourceKey); !exists {
+ gw.persistentCacheAdd(sourceKey, []PersistentMsgEntry{{
+ Protocol: localBridge.Protocol,
+ BridgeName: localBridge.Name,
+ ID: localKey,
+ ChannelID: localChannelID,
+ }}, sourceBridge.Account)
+ }
+
+ // Store: localKey → points to source bridge (e.g., "msteams TEAMS456" → mattermost entry)
+ if _, exists := gw.persistentCacheGet(localKey); !exists && sourceChannelID != "" {
+ gw.persistentCacheAdd(localKey, []PersistentMsgEntry{{
+ Protocol: sourceBridge.Protocol,
+ BridgeName: sourceBridge.Name,
+ ID: sourceKey,
+ ChannelID: sourceChannelID,
+ }}, msg.Account)
+ }
+ }
+}
+
+// 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/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 {
diff --git a/testdata/demo.gif b/testdata/demo.gif
new file mode 100644
index 0000000000..9dd5d0c524
Binary files /dev/null and b/testdata/demo.gif differ
diff --git a/testdata/demo.png b/testdata/demo.png
new file mode 100644
index 0000000000..89cac59824
Binary files /dev/null and b/testdata/demo.png differ
diff --git a/testdata/embed.go b/testdata/embed.go
new file mode 100644
index 0000000000..5f7a5de5f1
--- /dev/null
+++ b/testdata/embed.go
@@ -0,0 +1,11 @@
+// Package testdata provides embedded demo images for the @matterbridge test sequence.
+// Place demo.png and demo.gif in this directory before building.
+package testdata
+
+import _ "embed"
+
+//go:embed demo.png
+var DemoPNG []byte
+
+//go:embed demo.gif
+var DemoGIF []byte