commit 5d63f8598a915d73c5c6e161251224a97ef09ddd
Author: Lucian I. Last <li@last.nl>
Date: Wed Nov 19 12:17:04 2025 +0000
diff --git a/bridge/mastodon/mastodon.go b/bridge/mastodon/mastodon.go
new file mode 100644
index 0000000..e454ba6
--- /dev/null
+++ b/bridge/mastodon/mastodon.go
@@ -00 +1329 @@
+package mastodon
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/42wim/matterbridge/bridge"
+ "github.com/42wim/matterbridge/bridge/config"
+
+ mastodon "github.com/mattn/go-mastodon"
+)
+
+var (
+ htmlReplacementTag = regexp.MustCompile("<[^>]*>")
+ channelTypeHome = "home"
+ channelTypeLocal = "local"
+ channelTypeRemote = "remote"
+ channelTypeDirect = "direct"
+)
+
+var errInvalidChannel = errors.New("invalid channel name")
+
+func InvalidChannelError(name string) error {
+ return fmt.Errorf("%w: %s", errInvalidChannel, name)
+}
+
+var errHttpGet = errors.New("failed to get HTTP file")
+
+func HttpGetError(url string) error {
+ return fmt.Errorf("%w: %s", errHttpGet, url)
+}
+
+var errHttpGetNotOk = errors.New("HTTP server responded non-OK code")
+
+func HttpGetNotOkError(url string, code int) error {
+ return fmt.Errorf("%w: %s returned code %d", errHttpGetNotOk, url, code)
+}
+
+var errFileCast = errors.New("failed to cast config.FileInfo")
+
+func FileCastError() error {
+ return fmt.Errorf("%w", errFileCast)
+}
+
+type Bmastodon struct {
+ *bridge.Config
+
+ c *mastodon.Client
+ account *mastodon.Account
+
+ rooms []string
+ handles []context.CancelFunc
+}
+
+func New(cfg *bridge.Config) bridge.Bridger {
+ b := &Bmastodon{Config: cfg}
+ return b
+}
+
+func (b *Bmastodon) Connect() error {
+ b.Log.Infof("Connecting %s", b.GetString("Server"))
+
+ cfg := mastodon.Config{
+ Server: b.GetString("Server"),
+ ClientID: b.GetString("ClientID"),
+ ClientSecret: b.GetString("ClientSecret"),
+ AccessToken: b.GetString("AccessToken"),
+ }
+ b.c = mastodon.NewClient(&cfg)
+
+ var err error
+
+ b.account, err =
+ b.c.GetAccountCurrentUser(context.Background())
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (b *Bmastodon) Disconnect() error {
+ for _, ctxCancel := range b.handles {
+ ctxCancel()
+ }
+
+ return nil
+}
+
+func (b *Bmastodon) JoinChannel(channel config.ChannelInfo) error {
+ var (
+ channelType string
+ ch chan mastodon.Event
+ err error
+ )
+
+ ctx, ctxCancel := context.WithCancel(context.Background())
+
+ switch channel.Name {
+ case "home":
+ channelType = channelTypeHome
+ ch, err = b.c.StreamingUser(ctx)
+ case "local":
+ channelType = channelTypeLocal
+ ch, err = b.c.StreamingPublic(ctx, true)
+ case "remote":
+ channelType = channelTypeRemote
+ ch, err = b.c.StreamingPublic(ctx, false)
+ default:
+ if !strings.HasPrefix(channel.Name, "@") {
+ ctxCancel()
+ return InvalidChannelError(channel.Name)
+ }
+
+ channelType = channelTypeDirect
+ ch, err = b.c.StreamingDirect(ctx)
+ }
+
+ if err != nil {
+ ctxCancel()
+ return err
+ }
+
+ b.rooms = append(b.rooms, channel.Name)
+ b.handles = append(b.handles, ctxCancel)
+
+ go func() {
+ b.Log.Debugf("run golang channel on streaming api call, channel name: %v", channel.Name)
+
+ for msg := range ch {
+ switch t := msg.(type) {
+ case *mastodon.UpdateEvent:
+ switch channelType {
+ case channelTypeHome, channelTypeLocal, channelTypeRemote:
+ b.handleSendRemoteStatus(t.Status, channel.Name)
+ default:
+ b.Log.Debugf("run UpdateEvent on unsupported channelType: %s", channelType)
+ }
+ case *mastodon.ConversationEvent:
+ switch channelType {
+ case channelTypeHome, channelTypeLocal, channelTypeRemote:
+ // Not a conversation
+ b.Log.Debugf("run ConversationEvent on unsupported channelType: %s", channelType)
+ default:
+ b.handleSendRemoteStatus(t.Conversation.LastStatus, channel.Name)
+ }
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (b *Bmastodon) Send(msg config.Message) (string, error) {
+ ctx := context.Background()
+
+ // Standard Message Send
+ if msg.Event == "" {
+ sentMessage, err := b.handleSendingMessage(ctx, &msg)
+ if err != nil {
+ b.Log.Errorf("Could not send message to room %v from %v: %v", msg.Channel, msg.Username, err)
+
+ return "", nil
+ }
+
+ return string(sentMessage.ID), nil
+ }
+
+ // Message Deletion
+ if msg.Event == config.EventMsgDelete {
+ if msg.UserID != string(b.account.ID) {
+ b.Log.Errorf("Can not delete a status that is owned by a different account")
+ return "", nil
+ }
+
+ err := b.c.DeleteStatus(context.Background(), mastodon.ID(msg.ID))
+
+ return "", err
+ }
+
+ // Message is not a type that is currently supported
+ return "", nil
+}
+
+func (b *Bmastodon) handleSendRemoteStatus(msg *mastodon.Status, channel string) {
+ if msg.Account.ID == b.account.ID {
+ // Ignore messages that are from the bot user
+ return
+ }
+
+ remoteMessage := config.Message{
+ Text: htmlReplacementTag.ReplaceAllString(msg.Content, ""),
+ Channel: channel,
+ Username: msg.Account.DisplayName,
+ UserID: string(msg.Account.ID),
+ Account: b.Account,
+ Avatar: msg.Account.Avatar,
+ ID: string(msg.ID),
+ Extra: map[string][]any{},
+ }
+ if len(msg.MediaAttachments) > 0 {
+ remoteMessage.Extra["file"] = []any{}
+ }
+
+ for _, media := range msg.MediaAttachments {
+ resp, err2 := http.Get(media.RemoteURL)
+ if err2 != nil {
+ continue
+ }
+
+ defer func() {
+ err := resp.Body.Close()
+ if err != nil {
+ b.Log.Warn(err)
+ }
+ }()
+
+ if resp.StatusCode != http.StatusOK {
+ continue
+ }
+
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ continue
+ }
+
+ remoteMessage.Extra["file"] = append(remoteMessage.Extra["file"], config.FileInfo{
+ Name: media.Description,
+ Data: &b,
+ Size: int64(len(b)),
+ Avatar: false,
+ })
+ }
+
+ b.Log.Debugf("<= Message is %#v", remoteMessage)
+
+ b.Remote <- remoteMessage
+}
+
+func (b *Bmastodon) handleSendingMessage(ctx context.Context, msg *config.Message) (*mastodon.Status, error) {
+ toot := mastodon.Toot{
+ Status: msg.Text,
+ InReplyToID: "",
+ MediaIDs: []mastodon.ID{},
+ Sensitive: false,
+ SpoilerText: "",
+ Visibility: "public",
+ Language: "",
+ }
+ if strings.HasPrefix(msg.Channel, "#") {
+ toot.Status += " " + msg.Channel
+ }
+
+ if strings.HasPrefix(msg.Channel, "@") {
+ toot.Visibility = "private"
+ }
+
+ if msg.ParentID != "" {
+ toot.InReplyToID = mastodon.ID(msg.ParentID)
+ if toot.Visibility == "public" {
+ toot.Visibility = "unlisted"
+ }
+ }
+
+ for _, file := range msg.Extra["file"] {
+ attachment, err := b.extractFile(ctx, file)
+ if err != nil {
+ b.Log.Error(err)
+ continue
+ }
+
+ toot.MediaIDs = append(toot.MediaIDs, attachment.ID)
+ }
+
+ return b.c.PostStatus(ctx, &toot)
+}
+
+func (b *Bmastodon) extractFile(ctx context.Context, file interface{}) (*mastodon.Attachment, error) {
+ fileInfo, ok := file.(config.FileInfo)
+ if !ok {
+ return nil, FileCastError()
+ }
+
+ var (
+ r io.Reader
+ err error
+ resp *http.Response
+ )
+
+ defer func() {
+ if resp != nil {
+ err2 := resp.Body.Close()
+ if err2 != nil {
+ b.Log.Warn(err)
+ }
+ }
+ }()
+
+ if fileInfo.URL != "" {
+ resp, err = http.Get(fileInfo.URL)
+ if err != nil {
+ return nil, HttpGetError(fileInfo.URL)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, HttpGetNotOkError(fileInfo.URL, resp.StatusCode)
+ }
+
+ r = resp.Body
+ } else if fileInfo.Data != nil {
+ r = bytes.NewReader(*fileInfo.Data)
+ }
+
+ attachment, err := b.c.UploadMediaFromMedia(ctx, &mastodon.Media{
+ File: r,
+ Description: fileInfo.Comment,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return attachment, nil
+}
diff --git a/docs/protocols/README.md b/docs/protocols/README.md
index c853b31..94514d1 100644
--- a/docs/protocols/README.md
+++ b/docs/protocols/README.md
@@ -216 +2110 @@ Matterbridge supports many protocols, although not all of them support all featu
- Channel format: `channel_name` (for `channel_name@muc.server.org` where `muc.server.org` has been configured as `Muc` for the corresponding xmpp account)
+- [Mastodon](https://joinmastodon.org/)
+ - Matterbridge docs:
+ - [mastodon docs](mastodon/)
+ - [mastodon application](mastodon/application.md)
- [Matrix](https://matrix.org)
- Matterbridge docs:
diff --git a/docs/protocols/mastodon/README.md b/docs/protocols/mastodon/README.md
new file mode 100644
index 0000000..51dfd77
--- /dev/null
+++ b/docs/protocols/mastodon/README.md
@@ -00 +141 @@
+# Mastodon
+
+- Status: Working
+- Maintainers: @lil5
+- Features: home, local, remote, direct toots
+
+## Configuration
+
+> [!TIP]
+> For help getting a client id/secret/access token, see [application.md](application.md)
+
+**Basic configuration example:**
+
+```toml
+[mastodon]
+[mastodon.mymastodon]
+Server="https://mastodon.social"
+ClientID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ClientSecret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+AccessToken="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+```
+
+## FAQ
+
+### How to connect to a list?
+
+Currently the only supported lists are: home, local, remote
+
+```toml
+[[gateway.inout]]
+account="mastodon.mymastodon"
+channel="home"
+```
+
+### How to connect to a direct message?
+
+```toml
+[[gateway.inout]]
+account="mastodon.mymastodon"
+channel="@name@mastodon.social"
+```
\ No newline at end of file
diff --git a/docs/protocols/mastodon/application.md b/docs/protocols/mastodon/application.md
new file mode 100644
index 0000000..c4cab9f
--- /dev/null
+++ b/docs/protocols/mastodon/application.md
@@ -00 +128 @@
+# Add application to mastodon
+
+1. Create an mastodon Application
+
+ Go to this url, change the domain to your mastodon website: https://mastodon.social/settings/applications/new
+
+2. Set the following values:
+
+ **Application name:** `MatterBridge`
+
+ **Redirect URL:** `urn:ietf:wg:oauth:2.0:oob`
+
+ **Scopes:**
+
+ Check the following: `read`, `profile`, `write:conversations`, `write:statuses`
+
+ Then, save changes.
+
+3. Copy tokens to matterbridge.toml
+
+ ```toml
+ [mastodon]
+ [mastodon.mymastodon]
+ Server = "https://mastodon.social"
+ ClientID = "<Client key>"
+ ClientSecret = "<Client secret>"
+ AccessToken = "<Your access token>"
+ ```
diff --git a/gateway/bridgemap/bmastodon.go b/gateway/bridgemap/bmastodon.go
new file mode 100644
index 0000000..b51f66a
--- /dev/null
+++ b/gateway/bridgemap/bmastodon.go
@@ -00 +113 @@
+//go:build !nomastodon
+// +build !nomastodon
+
+package bridgemap
+
+import (
+ bmastodon "github.com/42wim/matterbridge/bridge/mastodon"
+)
+
+//nolint:gochecknoinits
+func init() {
+ FullMap["mastodon"] = bmastodon.New
+}
diff --git a/go.mod b/go.mod
index a64835b..14fe044 100644
--- a/go.mod
+++ b/go.mod
@@ -519 +5111 @@ require (
google.golang.org/protobuf v1.34.2
layeh.com/gumble v0.0.0-20221205141517-d1df60a3cc14
modernc.org/sqlite v1.32.0
+ github.com/mattn/go-mastodon v0.0.10
)
require (
+ github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Benau/go_rlottie v0.0.0-20210807002906-98c1b2421989 // indirect
github.com/Jeffail/gabs v1.4.0 // indirect
diff --git a/go.sum b/go.sum
index df67241..283abb4 100644
--- a/go.sum
+++ b/go.sum
@@ -2266 +2268 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-mastodon v0.0.10 h1:wz1d/aCkJOIkz46iv4eAqXHVreUMxydY1xBWrPBdDeE=
+github.com/mattn/go-mastodon v0.0.10/go.mod h1:YBofeqh7G6s787787NQR8erBYz6fKDu+KNMrn5RuD6Y=
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -3886 +3908 @@ github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj
github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
diff --git a/matterbridge.toml.sample b/matterbridge.toml.sample
index 530f40e..6efe8d4 100644
--- a/matterbridge.toml.sample
+++ b/matterbridge.toml.sample
@@ -5356 +53531 @@ Label=""
# REQUIRED
Team="myteam"
+###################################################################
+#
+# Mastodon
+#
+###################################################################
+
+[mastodon]
+[mastodon.myaccount]
+
+# Your mastodon instance url.
+# REQUIRED
+Server="https://mastodon.social"
+
+# Application client key
+# REQUIRED
+ClientID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
+# Application client secret
+# REQUIRED
+ClientSecret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
+# Application access token
+# REQUIRED
+AccessToken="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
###################################################################
# Microsoft teams section
# See https://github.com/42wim/matterbridge/wiki/MS-Teams-setup
@@ -17576 +17828 @@ enable=true
+ # mastodon | channel | home | The channel can be home or local or @name@mastodon.social
+ # -------------------------------------------------------------------------------------------------------------------------------------