Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 40 additions & 0 deletions bridge/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package config

import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
Expand Down Expand Up @@ -56,6 +58,38 @@ func (m Message) ParentValid() bool {
return m.ParentID != "" && !m.ParentNotFound()
}

// GetFileInfos extracts typed FileInfo list from the message.
//
// This method is guaranteed not to fail. The inner type casting should never
// fail, but will simply produce a warning if that's the case.
func (m Message) GetFileInfos(log *logrus.Entry) *[]*FileInfo {
var fileInfos []*FileInfo

for _, file := range m.Extra["file"] {
fileInfo, ok := file.(FileInfo)
if !ok {
// This should never happen, unless a bridge receiving an external message
// produces an invalid Extra field where the File is not valid FileInfo.
// TODO: log more information about the message for debugging.
log.Warn(FileCastError())
continue
}

fileInfos = append(fileInfos, &fileInfo)
}

return &fileInfos
}

// FileInfo is an attachment contained in a message.
//
// When receiving an attachment (eg. an image), a bridge should populate the
// Data/Size fields.
//
// When the media server is enabled, for services that don't support file upload
// (such as IRC), the gateway router will upload the file to the media server
// and populate the URL/SHA fields. The Data/Size fields are not removed
// in this process. See handleFiles in gateway/handlers.go
type FileInfo struct {
Name string
Data *[]byte
Expand All @@ -67,6 +101,12 @@ type FileInfo struct {
NativeID string
}

var errFileCast = errors.New("failed to cast config.FileInfo")

func FileCastError() error {
return fmt.Errorf("%w", errFileCast)
}

type ChannelInfo struct {
Name string
Account string
Expand Down
24 changes: 22 additions & 2 deletions bridge/helper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package helper

import (
"bytes"
"errors"
"fmt"
"image/png"
"io"
Expand All @@ -20,6 +21,12 @@ import (
"github.com/sirupsen/logrus"
)

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

// DownloadFile downloads the given non-authenticated URL.
func DownloadFile(url string) (*[]byte, error) {
return DownloadFileAuth(url, "")
Expand All @@ -42,8 +49,21 @@ func DownloadFileAuth(url string, auth string) (*[]byte, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
io.Copy(&buf, resp.Body)

if resp.StatusCode != http.StatusOK {
return nil, HttpGetNotOkError(url, resp.StatusCode)
}

_, err = io.Copy(&buf, resp.Body)
if err != nil {
return nil, err
}

err = resp.Body.Close()
if err != nil {
return nil, err
}

data := buf.Bytes()
return &data, nil
}
Expand Down
251 changes: 251 additions & 0 deletions bridge/mastodon/mastodon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package mastodon

import (
"bytes"
"context"
"errors"
"fmt"
"regexp"
"strings"

"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"

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

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 {
b, err2 := helper.DownloadFile(media.RemoteURL)
if err2 != nil {
// TODO: log
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.GetFileInfos(b.Log) {
attachment, err := b.c.UploadMediaFromMedia(ctx, &mastodon.Media{
File: bytes.NewReader(*file.Data),
Description: file.Comment,
})
if err != nil {
b.Log.Error(err)
continue
}

toot.MediaIDs = append(toot.MediaIDs, attachment.ID)
}

return b.c.PostStatus(ctx, &toot)
}
4 changes: 4 additions & 0 deletions docs/protocols/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ 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/)
Expand Down
Loading
Loading