Thumbnail

rani/matterbridge.git

Clone URL: https://git.buni.party/rani/matterbridge.git

commit 5d63f8598a915d73c5c6e161251224a97ef09ddd Author: Lucian I. Last <li@last.nl> Date: Wed Nov 19 12:17:04 2025 +0000 feat: Add mastodon bridge 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 - [xmpp docs](xmpp/) - [xmpp settings](xmpp/settings.md)   - 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: - [matrix docs](matrix/) 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 # ------------------------------------------------------------------------------------------------------------------------------------- # irc | channel | #general | The # symbol is required and should be lowercase! # ------------------------------------------------------------------------------------------------------------------------------------- + # mastodon | channel | home | The channel can be home or local or @name@mastodon.social + # ------------------------------------------------------------------------------------------------------------------------------------- # | channel | general | This is the channel name as seen in the URL, not the display name # mattermost | channel id | ID:oc4wifyuojgw5f3nsuweesmz8w | This is the channel ID (only use if you know what you're doing) # -------------------------------------------------------------------------------------------------------------------------------------