diff options
| author | Mistivia <i@mistivia.com> | 2025-11-02 15:27:18 +0800 |
|---|---|---|
| committer | Mistivia <i@mistivia.com> | 2025-11-02 15:27:18 +0800 |
| commit | e9c24f4af7ed56760f6db7941827d09f6db9020b (patch) | |
| tree | 62128c43b883ce5e3148113350978755779bb5de /teleirc/matterbridge/bridge | |
| parent | 58d5e7cfda4781d8a57ec52aefd02983835c301a (diff) | |
add matterbridge
Diffstat (limited to 'teleirc/matterbridge/bridge')
61 files changed, 13272 insertions, 0 deletions
diff --git a/teleirc/matterbridge/bridge/api/api.go b/teleirc/matterbridge/bridge/api/api.go new file mode 100644 index 0000000..515007f --- /dev/null +++ b/teleirc/matterbridge/bridge/api/api.go @@ -0,0 +1,207 @@ +package api + +import ( + "encoding/json" + "net/http" + "sync" + "time" + + "gopkg.in/olahol/melody.v1" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + ring "github.com/zfjagann/golang-ring" +) + +type API struct { + Messages ring.Ring + sync.RWMutex + *bridge.Config + mrouter *melody.Melody +} + +type Message struct { + Text string `json:"text"` + Username string `json:"username"` + UserID string `json:"userid"` + Avatar string `json:"avatar"` + Gateway string `json:"gateway"` +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &API{Config: cfg} + e := echo.New() + e.HideBanner = true + e.HidePort = true + + b.mrouter = melody.New() + b.mrouter.HandleMessage(func(s *melody.Session, msg []byte) { + message := config.Message{} + err := json.Unmarshal(msg, &message) + if err != nil { + b.Log.Errorf("failed to decode message from byte[] '%s'", string(msg)) + return + } + b.handleWebsocketMessage(message, s) + }) + b.mrouter.HandleConnect(func(session *melody.Session) { + greet := b.getGreeting() + data, err := json.Marshal(greet) + if err != nil { + b.Log.Errorf("failed to encode message '%v'", greet) + return + } + err = session.Write(data) + if err != nil { + b.Log.Errorf("failed to write message '%s'", string(data)) + return + } + // TODO: send message history buffer from `b.Messages` here + }) + + b.Messages = ring.Ring{} + if b.GetInt("Buffer") != 0 { + b.Messages.SetCapacity(b.GetInt("Buffer")) + } + if b.GetString("Token") != "" { + e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { + return key == b.GetString("Token"), nil + })) + } + + // Set RemoteNickFormat to a sane default + if !b.IsKeySet("RemoteNickFormat") { + b.Log.Debugln("RemoteNickFormat is unset, defaulting to \"{NICK}\"") + b.Config.Config.Viper().Set(b.GetConfigKey("RemoteNickFormat"), "{NICK}") + } + + e.GET("/api/health", b.handleHealthcheck) + e.GET("/api/messages", b.handleMessages) + e.GET("/api/stream", b.handleStream) + e.GET("/api/websocket", b.handleWebsocket) + e.POST("/api/message", b.handlePostMessage) + go func() { + if b.GetString("BindAddress") == "" { + b.Log.Fatalf("No BindAddress configured.") + } + b.Log.Infof("Listening on %s", b.GetString("BindAddress")) + b.Log.Fatal(e.Start(b.GetString("BindAddress"))) + }() + return b +} + +func (b *API) Connect() error { + return nil +} + +func (b *API) Disconnect() error { + return nil +} + +func (b *API) JoinChannel(channel config.ChannelInfo) error { + return nil +} + +func (b *API) Send(msg config.Message) (string, error) { + b.Lock() + defer b.Unlock() + // ignore delete messages + if msg.Event == config.EventMsgDelete { + return "", nil + } + b.Log.Debugf("enqueueing message from %s on ring buffer", msg.Username) + b.Messages.Enqueue(msg) + + data, err := json.Marshal(msg) + if err != nil { + b.Log.Errorf("failed to encode message '%s'", msg) + } + _ = b.mrouter.Broadcast(data) + return "", nil +} + +func (b *API) handleHealthcheck(c echo.Context) error { + return c.String(http.StatusOK, "OK") +} + +func (b *API) handlePostMessage(c echo.Context) error { + message := config.Message{} + if err := c.Bind(&message); err != nil { + return err + } + // these values are fixed + message.Channel = "api" + message.Protocol = "api" + message.Account = b.Account + message.ID = "" + message.Timestamp = time.Now() + b.Log.Debugf("Sending message from %s on %s to gateway", message.Username, "api") + b.Remote <- message + return c.JSON(http.StatusOK, message) +} + +func (b *API) handleMessages(c echo.Context) error { + b.Lock() + defer b.Unlock() + c.JSONPretty(http.StatusOK, b.Messages.Values(), " ") + b.Messages = ring.Ring{} + return nil +} + +func (b *API) getGreeting() config.Message { + return config.Message{ + Event: config.EventAPIConnected, + Timestamp: time.Now(), + } +} + +func (b *API) handleStream(c echo.Context) error { + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + c.Response().WriteHeader(http.StatusOK) + greet := b.getGreeting() + if err := json.NewEncoder(c.Response()).Encode(greet); err != nil { + return err + } + c.Response().Flush() + for { + // TODO: this causes issues, messages should be broadcasted to all connected clients + msg := b.Messages.Dequeue() + if msg != nil { + if err := json.NewEncoder(c.Response()).Encode(msg); err != nil { + return err + } + c.Response().Flush() + } + time.Sleep(200 * time.Millisecond) + } +} + +func (b *API) handleWebsocketMessage(message config.Message, s *melody.Session) { + message.Channel = "api" + message.Protocol = "api" + message.Account = b.Account + message.ID = "" + message.Timestamp = time.Now() + + data, err := json.Marshal(message) + if err != nil { + b.Log.Errorf("failed to encode message for loopback '%v'", message) + return + } + _ = b.mrouter.BroadcastOthers(data, s) + + b.Log.Debugf("Sending websocket message from %s on %s to gateway", message.Username, "api") + b.Remote <- message +} + +func (b *API) handleWebsocket(c echo.Context) error { + err := b.mrouter.HandleRequest(c.Response(), c.Request()) + if err != nil { + b.Log.Errorf("error in websocket handling '%v'", err) + return err + } + + return nil +} diff --git a/teleirc/matterbridge/bridge/bridge.go b/teleirc/matterbridge/bridge/bridge.go new file mode 100644 index 0000000..ef71f97 --- /dev/null +++ b/teleirc/matterbridge/bridge/bridge.go @@ -0,0 +1,135 @@ +package bridge + +import ( + "log" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/sirupsen/logrus" +) + +type Bridger interface { + Send(msg config.Message) (string, error) + Connect() error + JoinChannel(channel config.ChannelInfo) error + Disconnect() error +} + +type Bridge struct { + Bridger + *sync.RWMutex + + Name string + Account string + Protocol string + Channels map[string]config.ChannelInfo + Joined map[string]bool + ChannelMembers *config.ChannelMembers + Log *logrus.Entry + Config config.Config + General *config.Protocol +} + +type Config struct { + *Bridge + + Remote chan config.Message +} + +// Factory is the factory function to create a bridge +type Factory func(*Config) Bridger + +func New(bridge *config.Bridge) *Bridge { + accInfo := strings.Split(bridge.Account, ".") + if len(accInfo) != 2 { + log.Fatalf("config failure, account incorrect: %s", bridge.Account) + } + + protocol := accInfo[0] + name := accInfo[1] + + return &Bridge{ + RWMutex: new(sync.RWMutex), + Channels: make(map[string]config.ChannelInfo), + Name: name, + Protocol: protocol, + Account: bridge.Account, + Joined: make(map[string]bool), + } +} + +func (b *Bridge) JoinChannels() error { + return b.joinChannels(b.Channels, b.Joined) +} + +// SetChannelMembers sets the newMembers to the bridge ChannelMembers +func (b *Bridge) SetChannelMembers(newMembers *config.ChannelMembers) { + b.Lock() + b.ChannelMembers = newMembers + b.Unlock() +} + +func (b *Bridge) joinChannels(channels map[string]config.ChannelInfo, exists map[string]bool) error { + for ID, channel := range channels { + if !exists[ID] { + b.Log.Infof("%s: joining %s (ID: %s)", b.Account, channel.Name, ID) + time.Sleep(time.Duration(b.GetInt("JoinDelay")) * time.Millisecond) + err := b.JoinChannel(channel) + if err != nil { + return err + } + exists[ID] = true + } + } + return nil +} + +func (b *Bridge) GetConfigKey(key string) string { + return b.Account + "." + key +} + +func (b *Bridge) IsKeySet(key string) bool { + return b.Config.IsKeySet(b.GetConfigKey(key)) || b.Config.IsKeySet("general."+key) +} + +func (b *Bridge) GetBool(key string) bool { + val, ok := b.Config.GetBool(b.GetConfigKey(key)) + if !ok { + val, _ = b.Config.GetBool("general." + key) + } + return val +} + +func (b *Bridge) GetInt(key string) int { + val, ok := b.Config.GetInt(b.GetConfigKey(key)) + if !ok { + val, _ = b.Config.GetInt("general." + key) + } + return val +} + +func (b *Bridge) GetString(key string) string { + val, ok := b.Config.GetString(b.GetConfigKey(key)) + if !ok { + val, _ = b.Config.GetString("general." + key) + } + return val +} + +func (b *Bridge) GetStringSlice(key string) []string { + val, ok := b.Config.GetStringSlice(b.GetConfigKey(key)) + if !ok { + val, _ = b.Config.GetStringSlice("general." + key) + } + return val +} + +func (b *Bridge) GetStringSlice2D(key string) [][]string { + val, ok := b.Config.GetStringSlice2D(b.GetConfigKey(key)) + if !ok { + val, _ = b.Config.GetStringSlice2D("general." + key) + } + return val +} diff --git a/teleirc/matterbridge/bridge/config/config.go b/teleirc/matterbridge/bridge/config/config.go new file mode 100644 index 0000000..18c6092 --- /dev/null +++ b/teleirc/matterbridge/bridge/config/config.go @@ -0,0 +1,441 @@ +package config + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +const ( + EventJoinLeave = "join_leave" + EventTopicChange = "topic_change" + EventFailure = "failure" + EventFileFailureSize = "file_failure_size" + EventAvatarDownload = "avatar_download" + EventRejoinChannels = "rejoin_channels" + EventUserAction = "user_action" + EventMsgDelete = "msg_delete" + EventFileDelete = "file_delete" + EventAPIConnected = "api_connected" + EventUserTyping = "user_typing" + EventGetChannelMembers = "get_channel_members" + EventNoticeIRC = "notice_irc" +) + +const ParentIDNotFound = "msg-parent-not-found" + +type Message struct { + Text string `json:"text"` + Channel string `json:"channel"` + Username string `json:"username"` + UserID string `json:"userid"` // userid on the bridge + Avatar string `json:"avatar"` + Account string `json:"account"` + Event string `json:"event"` + Protocol string `json:"protocol"` + Gateway string `json:"gateway"` + ParentID string `json:"parent_id"` + Timestamp time.Time `json:"timestamp"` + ID string `json:"id"` + Extra map[string][]interface{} +} + +func (m Message) ParentNotFound() bool { + return m.ParentID == ParentIDNotFound +} + +func (m Message) ParentValid() bool { + return m.ParentID != "" && !m.ParentNotFound() +} + +type FileInfo struct { + Name string + Data *[]byte + Comment string + URL string + Size int64 + Avatar bool + SHA string + NativeID string +} + +type ChannelInfo struct { + Name string + Account string + Direction string + ID string + SameChannel map[string]bool + Options ChannelOptions +} + +type ChannelMember struct { + Username string + Nick string + UserID string + ChannelID string + ChannelName string +} + +type ChannelMembers []ChannelMember + +type Protocol struct { + AllowMention []string // discord + AuthCode string // steam + BindAddress string // mattermost, slack // DEPRECATED + Buffer int // api + Charset string // irc + ClientID string // msteams + ColorNicks bool // only irc for now + Debug bool // general + DebugLevel int // only for irc now + DisableWebPagePreview bool // telegram + EditSuffix string // mattermost, slack, discord, telegram, gitter + EditDisable bool // mattermost, slack, discord, telegram, gitter + HTMLDisable bool // matrix + IconURL string // mattermost, slack + IgnoreFailureOnStart bool // general + IgnoreNicks string // all protocols + IgnoreMessages string // all protocols + Jid string // xmpp + JoinDelay string // all protocols + Label string // all protocols + Login string // mattermost, matrix + LogFile string // general + MediaDownloadBlackList []string + MediaDownloadPath string // Basically MediaServerUpload, but instead of uploading it, just write it to a file on the same server. + MediaDownloadSize int // all protocols + MediaServerDownload string + MediaServerUpload string + MediaConvertTgs string // telegram + MediaConvertWebPToPNG bool // telegram + MessageDelay int // IRC, time in millisecond to wait between messages + MessageFormat string // telegram + MessageLength int // IRC, max length of a message allowed + MessageQueue int // IRC, size of message queue for flood control + MessageSplit bool // IRC, split long messages with newlines on MessageLength instead of clipping + Muc string // xmpp + MxID string // matrix + Name string // all protocols + Nick string // all protocols + NickFormatter string // mattermost, slack + NickServNick string // IRC + NickServUsername string // IRC + NickServPassword string // IRC + NicksPerRow int // mattermost, slack + NoHomeServerSuffix bool // matrix + NoSendJoinPart bool // all protocols + NoTLS bool // mattermost, xmpp + Password string // IRC,mattermost,XMPP,matrix + PrefixMessagesWithNick bool // mattemost, slack + PreserveThreading bool // slack + Protocol string // all protocols + QuoteDisable bool // telegram + QuoteFormat string // telegram + QuoteLengthLimit int // telegram + RealName string // IRC + RejoinDelay int // IRC + ReplaceMessages [][]string // all protocols + ReplaceNicks [][]string // all protocols + RemoteNickFormat string // all protocols + RunCommands []string // IRC + Server string // IRC,mattermost,XMPP,discord,matrix + SessionFile string // msteams,whatsapp + ShowJoinPart bool // all protocols + ShowTopicChange bool // slack + ShowUserTyping bool // slack + ShowEmbeds bool // discord + SkipTLSVerify bool // IRC, mattermost + SkipVersionCheck bool // mattermost + StripNick bool // all protocols + StripMarkdown bool // irc + SyncTopic bool // slack + TengoModifyMessage string // general + Team string // mattermost, keybase + TeamID string // msteams + TenantID string // msteams + Token string // gitter, slack, discord, api, matrix + Topic string // zulip + URL string // mattermost, slack // DEPRECATED + UseAPI bool // mattermost, slack + UseLocalAvatar []string // discord + UseSASL bool // IRC + UseTLS bool // IRC + UseDiscriminator bool // discord + UseFirstName bool // telegram + UseUserName bool // discord, matrix, mattermost + UseInsecureURL bool // telegram + UserName string // IRC + VerboseJoinPart bool // IRC + WebhookBindAddress string // mattermost, slack + WebhookURL string // mattermost, slack +} + +type ChannelOptions struct { + Key string // irc, xmpp + WebhookURL string // discord + Topic string // zulip +} + +type Bridge struct { + Account string + Channel string + Options ChannelOptions + SameChannel bool +} + +type Gateway struct { + Name string + Enable bool + In []Bridge + Out []Bridge + InOut []Bridge +} + +type Tengo struct { + InMessage string + Message string + RemoteNickFormat string + OutMessage string +} + +type SameChannelGateway struct { + Name string + Enable bool + Channels []string + Accounts []string +} + +type BridgeValues struct { + API map[string]Protocol + IRC map[string]Protocol + Mattermost map[string]Protocol + Matrix map[string]Protocol + Slack map[string]Protocol + SlackLegacy map[string]Protocol + Steam map[string]Protocol + Gitter map[string]Protocol + XMPP map[string]Protocol + Discord map[string]Protocol + Telegram map[string]Protocol + Rocketchat map[string]Protocol + SSHChat map[string]Protocol + WhatsApp map[string]Protocol // TODO is this struct used? Search for "SlackLegacy" for example didn't return any results + Zulip map[string]Protocol + Keybase map[string]Protocol + Mumble map[string]Protocol + General Protocol + Tengo Tengo + Gateway []Gateway + SameChannelGateway []SameChannelGateway +} + +type Config interface { + Viper() *viper.Viper + BridgeValues() *BridgeValues + IsKeySet(key string) bool + GetBool(key string) (bool, bool) + GetInt(key string) (int, bool) + GetString(key string) (string, bool) + GetStringSlice(key string) ([]string, bool) + GetStringSlice2D(key string) ([][]string, bool) +} + +type config struct { + sync.RWMutex + + logger *logrus.Entry + v *viper.Viper + cv *BridgeValues +} + +// NewConfig instantiates a new configuration based on the specified configuration file path. +func NewConfig(rootLogger *logrus.Logger, cfgfile string) Config { + logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"}) + + viper.SetConfigFile(cfgfile) + input, err := ioutil.ReadFile(cfgfile) + if err != nil { + logger.Fatalf("Failed to read configuration file: %#v", err) + } + + cfgtype := detectConfigType(cfgfile) + mycfg := newConfigFromString(logger, input, cfgtype) + if mycfg.cv.General.LogFile != "" { + logfile, err := os.OpenFile(mycfg.cv.General.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err == nil { + logger.Info("Opening log file ", mycfg.cv.General.LogFile) + rootLogger.Out = logfile + } else { + logger.Warn("Failed to open ", mycfg.cv.General.LogFile) + } + } + if mycfg.cv.General.MediaDownloadSize == 0 { + mycfg.cv.General.MediaDownloadSize = 1000000 + } + viper.WatchConfig() + viper.OnConfigChange(func(e fsnotify.Event) { + logger.Println("Config file changed:", e.Name) + }) + return mycfg +} + +// detectConfigType detects JSON and YAML formats, defaults to TOML. +func detectConfigType(cfgfile string) string { + fileExt := filepath.Ext(cfgfile) + switch fileExt { + case ".json": + return "json" + case ".yaml", ".yml": + return "yaml" + } + return "toml" +} + +// NewConfigFromString instantiates a new configuration based on the specified string. +func NewConfigFromString(rootLogger *logrus.Logger, input []byte) Config { + logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"}) + return newConfigFromString(logger, input, "toml") +} + +func newConfigFromString(logger *logrus.Entry, input []byte, cfgtype string) *config { + viper.SetConfigType(cfgtype) + viper.SetEnvPrefix("matterbridge") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + viper.AutomaticEnv() + + if err := viper.ReadConfig(bytes.NewBuffer(input)); err != nil { + logger.Fatalf("Failed to parse the configuration: %s", err) + } + + cfg := &BridgeValues{} + if err := viper.Unmarshal(cfg); err != nil { + logger.Fatalf("Failed to load the configuration: %s", err) + } + return &config{ + logger: logger, + v: viper.GetViper(), + cv: cfg, + } +} + +func (c *config) BridgeValues() *BridgeValues { + return c.cv +} + +func (c *config) Viper() *viper.Viper { + return c.v +} + +func (c *config) IsKeySet(key string) bool { + c.RLock() + defer c.RUnlock() + return c.v.IsSet(key) +} + +func (c *config) GetBool(key string) (bool, bool) { + c.RLock() + defer c.RUnlock() + return c.v.GetBool(key), c.v.IsSet(key) +} + +func (c *config) GetInt(key string) (int, bool) { + c.RLock() + defer c.RUnlock() + return c.v.GetInt(key), c.v.IsSet(key) +} + +func (c *config) GetString(key string) (string, bool) { + c.RLock() + defer c.RUnlock() + return c.v.GetString(key), c.v.IsSet(key) +} + +func (c *config) GetStringSlice(key string) ([]string, bool) { + c.RLock() + defer c.RUnlock() + return c.v.GetStringSlice(key), c.v.IsSet(key) +} + +func (c *config) GetStringSlice2D(key string) ([][]string, bool) { + c.RLock() + defer c.RUnlock() + + res, ok := c.v.Get(key).([]interface{}) + if !ok { + return nil, false + } + var result [][]string + for _, entry := range res { + result2 := []string{} + for _, entry2 := range entry.([]interface{}) { + result2 = append(result2, entry2.(string)) + } + result = append(result, result2) + } + return result, true +} + +func GetIconURL(msg *Message, iconURL string) string { + info := strings.Split(msg.Account, ".") + protocol := info[0] + name := info[1] + iconURL = strings.Replace(iconURL, "{NICK}", msg.Username, -1) + iconURL = strings.Replace(iconURL, "{BRIDGE}", name, -1) + iconURL = strings.Replace(iconURL, "{PROTOCOL}", protocol, -1) + return iconURL +} + +type TestConfig struct { + Config + + Overrides map[string]interface{} +} + +func (c *TestConfig) IsKeySet(key string) bool { + _, ok := c.Overrides[key] + return ok || c.Config.IsKeySet(key) +} + +func (c *TestConfig) GetBool(key string) (bool, bool) { + val, ok := c.Overrides[key] + if ok { + return val.(bool), true + } + return c.Config.GetBool(key) +} + +func (c *TestConfig) GetInt(key string) (int, bool) { + if val, ok := c.Overrides[key]; ok { + return val.(int), true + } + return c.Config.GetInt(key) +} + +func (c *TestConfig) GetString(key string) (string, bool) { + if val, ok := c.Overrides[key]; ok { + return val.(string), true + } + return c.Config.GetString(key) +} + +func (c *TestConfig) GetStringSlice(key string) ([]string, bool) { + if val, ok := c.Overrides[key]; ok { + return val.([]string), true + } + return c.Config.GetStringSlice(key) +} + +func (c *TestConfig) GetStringSlice2D(key string) ([][]string, bool) { + if val, ok := c.Overrides[key]; ok { + return val.([][]string), true + } + return c.Config.GetStringSlice2D(key) +} diff --git a/teleirc/matterbridge/bridge/discord/discord.go b/teleirc/matterbridge/bridge/discord/discord.go new file mode 100644 index 0000000..5ae6c57 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/discord.go @@ -0,0 +1,384 @@ +package bdiscord + +import ( + "bytes" + "fmt" + "strings" + "sync" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/discord/transmitter" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/bwmarrin/discordgo" + lru "github.com/hashicorp/golang-lru" +) + +const ( + MessageLength = 1950 + cFileUpload = "file_upload" +) + +type Bdiscord struct { + *bridge.Config + + c *discordgo.Session + + nick string + userID string + guildID string + + channelsMutex sync.RWMutex + channels []*discordgo.Channel + channelInfoMap map[string]*config.ChannelInfo + + membersMutex sync.RWMutex + userMemberMap map[string]*discordgo.Member + nickMemberMap map[string]*discordgo.Member + + // Webhook specific logic + useAutoWebhooks bool + transmitter *transmitter.Transmitter + cache *lru.Cache +} + +func New(cfg *bridge.Config) bridge.Bridger { + newCache, err := lru.New(5000) + if err != nil { + cfg.Log.Fatalf("Could not create LRU cache: %v", err) + } + + b := &Bdiscord{ + Config: cfg, + cache: newCache, + } + + b.userMemberMap = make(map[string]*discordgo.Member) + b.nickMemberMap = make(map[string]*discordgo.Member) + b.channelInfoMap = make(map[string]*config.ChannelInfo) + + b.useAutoWebhooks = b.GetBool("AutoWebhooks") + if b.useAutoWebhooks { + b.Log.Debug("Using automatic webhooks") + } + return b +} + +func (b *Bdiscord) Connect() error { + var err error + token := b.GetString("Token") + b.Log.Info("Connecting") + if !strings.HasPrefix(b.GetString("Token"), "Bot ") { + token = "Bot " + b.GetString("Token") + } + // if we have a User token, remove the `Bot` prefix + if strings.HasPrefix(b.GetString("Token"), "User ") { + token = strings.Replace(b.GetString("Token"), "User ", "", -1) + } + + b.c, err = discordgo.New(token) + if err != nil { + return err + } + b.Log.Info("Connection succeeded") + b.c.AddHandler(b.messageCreate) + b.c.AddHandler(b.messageTyping) + b.c.AddHandler(b.messageUpdate) + b.c.AddHandler(b.messageDelete) + b.c.AddHandler(b.messageDeleteBulk) + b.c.AddHandler(b.memberAdd) + b.c.AddHandler(b.memberRemove) + b.c.AddHandler(b.memberUpdate) + if b.GetInt("debuglevel") == 1 { + b.c.AddHandler(b.messageEvent) + } + // Add privileged intent for guild member tracking. This is needed to track nicks + // for display names and @mention translation + b.c.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAllWithoutPrivileged | + discordgo.IntentsGuildMembers) + + err = b.c.Open() + if err != nil { + return err + } + guilds, err := b.c.UserGuilds(100, "", "") + if err != nil { + return err + } + userinfo, err := b.c.User("@me") + if err != nil { + return err + } + serverName := strings.Replace(b.GetString("Server"), "ID:", "", -1) + b.nick = userinfo.Username + b.userID = userinfo.ID + + // Try and find this account's guild, and populate channels + b.channelsMutex.Lock() + for _, guild := range guilds { + // Skip, if the server name does not match the visible name or the ID + if guild.Name != serverName && guild.ID != serverName { + continue + } + + // Complain about an ambiguous Server setting. Two Discord servers could have the same title! + // For IDs, practically this will never happen. It would only trigger if some server's name is also an ID. + if b.guildID != "" { + return fmt.Errorf("found multiple Discord servers with the same name %#v, expected to see only one", serverName) + } + + // Getting this guild's channel could result in a permission error + b.channels, err = b.c.GuildChannels(guild.ID) + if err != nil { + return fmt.Errorf("could not get %#v's channels: %w", b.GetString("Server"), err) + } + + b.guildID = guild.ID + } + b.channelsMutex.Unlock() + + // If we couldn't find a guild, we print extra debug information and return a nice error + if b.guildID == "" { + err = fmt.Errorf("could not find Discord server %#v", b.GetString("Server")) + b.Log.Error(err.Error()) + + // Print all of the possible server values + b.Log.Info("Possible server values:") + for _, guild := range guilds { + b.Log.Infof("\t- Server=%#v # by name", guild.Name) + b.Log.Infof("\t- Server=%#v # by ID", guild.ID) + } + + // If there are no results, we should say that + if len(guilds) == 0 { + b.Log.Info("\t- (none found)") + } + + return err + } + + // Legacy note: WebhookURL used to have an actual webhook URL that we would edit, + // but we stopped doing that due to Discord making rate limits more aggressive. + // + // Even older: the same WebhookURL used to be used by every channel, which is usually unexpected. + // This is no longer possible. + if b.GetString("WebhookURL") != "" { + message := "The global WebhookURL setting has been removed. " + message += "You can get similar \"webhook editing\" behaviour by replacing this line with `AutoWebhooks=true`. " + message += "If you rely on the old-OLD (non-editing) behaviour, can move the WebhookURL to specific channel sections." + b.Log.Errorln(message) + return fmt.Errorf("use of removed WebhookURL setting") + } + + if b.GetInt("debuglevel") == 2 { + b.Log.Debug("enabling even more discord debug") + b.c.Debug = true + } + + // Initialise webhook management + b.transmitter = transmitter.New(b.c, b.guildID, "matterbridge", b.useAutoWebhooks) + b.transmitter.Log = b.Log + + var webhookChannelIDs []string + for _, channel := range b.Channels { + channelID := b.getChannelID(channel.Name) // note(qaisjp): this readlocks channelsMutex + + // If a WebhookURL was not explicitly provided for this channel, + // there are two options: just a regular bot message (ugly) or this is should be webhook sent + if channel.Options.WebhookURL == "" { + // If it should be webhook sent, we should enforce this via the transmitter + if b.useAutoWebhooks { + webhookChannelIDs = append(webhookChannelIDs, channelID) + } + continue + } + + whID, whToken, ok := b.splitURL(channel.Options.WebhookURL) + if !ok { + return fmt.Errorf("failed to parse WebhookURL %#v for channel %#v", channel.Options.WebhookURL, channel.ID) + } + + b.transmitter.AddWebhook(channelID, &discordgo.Webhook{ + ID: whID, + Token: whToken, + GuildID: b.guildID, + ChannelID: channelID, + }) + } + + if b.useAutoWebhooks { + err = b.transmitter.RefreshGuildWebhooks(webhookChannelIDs) + if err != nil { + b.Log.WithError(err).Println("transmitter could not refresh guild webhooks") + return err + } + } + + // Obtaining guild members and initializing nickname mapping. + b.membersMutex.Lock() + defer b.membersMutex.Unlock() + members, err := b.c.GuildMembers(b.guildID, "", 1000) + if err != nil { + b.Log.Error("Error obtaining server members: ", err) + return err + } + for _, member := range members { + if member == nil { + b.Log.Warnf("Skipping missing information for a user.") + continue + } + b.userMemberMap[member.User.ID] = member + b.nickMemberMap[member.User.Username] = member + if member.Nick != "" { + b.nickMemberMap[member.Nick] = member + } + } + return nil +} + +func (b *Bdiscord) Disconnect() error { + return b.c.Close() +} + +func (b *Bdiscord) JoinChannel(channel config.ChannelInfo) error { + b.channelsMutex.Lock() + defer b.channelsMutex.Unlock() + + b.channelInfoMap[channel.ID] = &channel + return nil +} + +func (b *Bdiscord) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + channelID := b.getChannelID(msg.Channel) + if channelID == "" { + return "", fmt.Errorf("Could not find channelID for %v", msg.Channel) + } + + if msg.Event == config.EventUserTyping { + if b.GetBool("ShowUserTyping") { + err := b.c.ChannelTyping(channelID) + return "", err + } + return "", nil + } + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + msg.Text = "_" + msg.Text + "_" + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + } + + // Use webhook to send the message + useWebhooks := b.shouldMessageUseWebhooks(&msg) + if useWebhooks && msg.Event != config.EventMsgDelete && msg.ParentID == "" { + return b.handleEventWebhook(&msg, channelID) + } + + return b.handleEventBotUser(&msg, channelID) +} + +// handleEventDirect handles events via the bot user +func (b *Bdiscord) handleEventBotUser(msg *config.Message, channelID string) (string, error) { + b.Log.Debugf("Broadcasting using token (API)") + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + err := b.c.ChannelMessageDelete(channelID, msg.ID) + return "", err + } + + // Delete a file + if msg.Event == config.EventFileDelete { + if msg.ID == "" { + return "", nil + } + + if fi, ok := b.cache.Get(cFileUpload + msg.ID); ok { + err := b.c.ChannelMessageDelete(channelID, fi.(string)) // nolint:forcetypeassert + b.cache.Remove(cFileUpload + msg.ID) + return "", err + } + + return "", fmt.Errorf("file %s not found", msg.ID) + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(msg, b.General) { + rmsg.Text = helper.ClipMessage(rmsg.Text, MessageLength, b.GetString("MessageClipped")) + if _, err := b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text); err != nil { + b.Log.Errorf("Could not send message %#v: %s", rmsg, err) + } + } + // check if we have files to upload (from slack, telegram or mattermost) + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(msg, channelID) + } + } + + msg.Text = helper.ClipMessage(msg.Text, MessageLength, b.GetString("MessageClipped")) + msg.Text = b.replaceUserMentions(msg.Text) + + // Edit message + if msg.ID != "" { + _, err := b.c.ChannelMessageEdit(channelID, msg.ID, msg.Username+msg.Text) + return msg.ID, err + } + + m := discordgo.MessageSend{ + Content: msg.Username + msg.Text, + AllowedMentions: b.getAllowedMentions(), + } + + if msg.ParentValid() { + m.Reference = &discordgo.MessageReference{ + MessageID: msg.ParentID, + ChannelID: channelID, + GuildID: b.guildID, + } + } + + // Post normal message + res, err := b.c.ChannelMessageSendComplex(channelID, &m) + if err != nil { + return "", err + } + + return res.ID, nil +} + +// handleUploadFile handles native upload of files +func (b *Bdiscord) handleUploadFile(msg *config.Message, channelID string) (string, error) { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + file := discordgo.File{ + Name: fi.Name, + ContentType: "", + Reader: bytes.NewReader(*fi.Data), + } + m := discordgo.MessageSend{ + Content: msg.Username + fi.Comment, + Files: []*discordgo.File{&file}, + AllowedMentions: b.getAllowedMentions(), + } + res, err := b.c.ChannelMessageSendComplex(channelID, &m) + if err != nil { + return "", fmt.Errorf("file upload failed: %s", err) + } + + // link file_upload_nativeID (file ID from the original bridge) to our upload id + // so that we can remove this later when it eg needs to be deleted + b.cache.Add(cFileUpload+fi.NativeID, res.ID) + } + + return "", nil +} diff --git a/teleirc/matterbridge/bridge/discord/handlers.go b/teleirc/matterbridge/bridge/discord/handlers.go new file mode 100644 index 0000000..34cef55 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/handlers.go @@ -0,0 +1,281 @@ +package bdiscord + +import ( + "github.com/42wim/matterbridge/bridge/config" + "github.com/bwmarrin/discordgo" + "github.com/davecgh/go-spew/spew" +) + +func (b *Bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageDelete because it originates from a different guild") + return + } + rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EventMsgDelete, Text: config.EventMsgDelete} + rmsg.Channel = b.getChannelName(m.ChannelID) + + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +// TODO(qaisjp): if other bridges support bulk deletions, it could be fanned out centrally +func (b *Bdiscord) messageDeleteBulk(s *discordgo.Session, m *discordgo.MessageDeleteBulk) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageDeleteBulk because it originates from a different guild") + return + } + for _, msgID := range m.Messages { + rmsg := config.Message{ + Account: b.Account, + ID: msgID, + Event: config.EventMsgDelete, + Text: config.EventMsgDelete, + Channel: b.getChannelName(m.ChannelID), + } + + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg + } +} + +func (b *Bdiscord) messageEvent(s *discordgo.Session, m *discordgo.Event) { + b.Log.Debug(spew.Sdump(m.Struct)) +} + +func (b *Bdiscord) messageTyping(s *discordgo.Session, m *discordgo.TypingStart) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageTyping because it originates from a different guild") + return + } + if !b.GetBool("ShowUserTyping") { + return + } + + // Ignore our own typing messages + if m.UserID == b.userID { + return + } + + rmsg := config.Message{Account: b.Account, Event: config.EventUserTyping} + rmsg.Channel = b.getChannelName(m.ChannelID) + b.Remote <- rmsg +} + +func (b *Bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageUpdate because it originates from a different guild") + return + } + if b.GetBool("EditDisable") { + return + } + // only when message is actually edited + if m.Message.EditedTimestamp != nil { + b.Log.Debugf("Sending edit message") + m.Content += b.GetString("EditSuffix") + msg := &discordgo.MessageCreate{ + Message: m.Message, + } + b.messageCreate(s, msg) + } +} + +func (b *Bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageCreate because it originates from a different guild") + return + } + var err error + + // not relay our own messages + if m.Author.Username == b.nick { + return + } + // if using webhooks, do not relay if it's ours + if m.Author.Bot && b.transmitter.HasWebhook(m.Author.ID) { + return + } + + // add the url of the attachments to content + if len(m.Attachments) > 0 { + for _, attach := range m.Attachments { + m.Content = m.Content + "\n" + attach.URL + } + } + + rmsg := config.Message{Account: b.Account, Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg", UserID: m.Author.ID, ID: m.ID} + + b.Log.Debugf("== Receiving event %#v", m.Message) + + if m.Content != "" { + m.Message.Content = b.replaceChannelMentions(m.Message.Content) + rmsg.Text, err = m.ContentWithMoreMentionsReplaced(b.c) + if err != nil { + b.Log.Errorf("ContentWithMoreMentionsReplaced failed: %s", err) + rmsg.Text = m.ContentWithMentionsReplaced() + } + } + + // set channel name + rmsg.Channel = b.getChannelName(m.ChannelID) + + fromWebhook := m.WebhookID != "" + if !fromWebhook && !b.GetBool("UseUserName") { + rmsg.Username = b.getNick(m.Author, m.GuildID) + } else { + rmsg.Username = m.Author.Username + if !fromWebhook && b.GetBool("UseDiscriminator") { + rmsg.Username += "#" + m.Author.Discriminator + } + } + + // if we have embedded content add it to text + if b.GetBool("ShowEmbeds") && m.Message.Embeds != nil { + for _, embed := range m.Message.Embeds { + rmsg.Text += handleEmbed(embed) + } + } + + // no empty messages + if rmsg.Text == "" { + return + } + + // do we have a /me action + var ok bool + rmsg.Text, ok = b.replaceAction(rmsg.Text) + if ok { + rmsg.Event = config.EventUserAction + } + + // Replace emotes + rmsg.Text = replaceEmotes(rmsg.Text) + + // Add our parent id if it exists, and if it's not referring to a message in another channel + if ref := m.MessageReference; ref != nil && ref.ChannelID == m.ChannelID { + rmsg.ParentID = ref.MessageID + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", m.Author.Username, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func (b *Bdiscord) memberUpdate(s *discordgo.Session, m *discordgo.GuildMemberUpdate) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring memberUpdate because it originates from a different guild") + return + } + if m.Member == nil { + b.Log.Warnf("Received member update with no member information: %#v", m) + } + + b.membersMutex.Lock() + defer b.membersMutex.Unlock() + + if currMember, ok := b.userMemberMap[m.Member.User.ID]; ok { + b.Log.Debugf( + "%s: memberupdate: user %s (nick %s) changes nick to %s", + b.Account, + m.Member.User.Username, + b.userMemberMap[m.Member.User.ID].Nick, + m.Member.Nick, + ) + delete(b.nickMemberMap, currMember.User.Username) + delete(b.nickMemberMap, currMember.Nick) + delete(b.userMemberMap, m.Member.User.ID) + } + b.userMemberMap[m.Member.User.ID] = m.Member + b.nickMemberMap[m.Member.User.Username] = m.Member + if m.Member.Nick != "" { + b.nickMemberMap[m.Member.Nick] = m.Member + } +} + +func (b *Bdiscord) memberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring memberAdd because it originates from a different guild") + return + } + if b.GetBool("nosendjoinpart") { + return + } + if m.Member == nil { + b.Log.Warnf("Received member update with no member information: %#v", m) + return + } + username := m.Member.User.Username + if m.Member.Nick != "" { + username = m.Member.Nick + } + + rmsg := config.Message{ + Account: b.Account, + Event: config.EventJoinLeave, + Username: "system", + Text: username + " joins", + } + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func (b *Bdiscord) memberRemove(s *discordgo.Session, m *discordgo.GuildMemberRemove) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring memberRemove because it originates from a different guild") + return + } + if b.GetBool("nosendjoinpart") { + return + } + if m.Member == nil { + b.Log.Warnf("Received member update with no member information: %#v", m) + return + } + username := m.Member.User.Username + if m.Member.Nick != "" { + username = m.Member.Nick + } + + rmsg := config.Message{ + Account: b.Account, + Event: config.EventJoinLeave, + Username: "system", + Text: username + " leaves", + } + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func handleEmbed(embed *discordgo.MessageEmbed) string { + var t []string + var result string + + t = append(t, embed.Title) + t = append(t, embed.Description) + t = append(t, embed.URL) + + i := 0 + for _, e := range t { + if e == "" { + continue + } + + i++ + if i == 1 { + result += " embed: " + e + continue + } + + result += " - " + e + } + + if result != "" { + result += "\n" + } + + return result +} diff --git a/teleirc/matterbridge/bridge/discord/handlers_test.go b/teleirc/matterbridge/bridge/discord/handlers_test.go new file mode 100644 index 0000000..915d9b1 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/handlers_test.go @@ -0,0 +1,58 @@ +package bdiscord + +import ( + "testing" + + "github.com/bwmarrin/discordgo" + "github.com/stretchr/testify/assert" +) + +func TestHandleEmbed(t *testing.T) { + testcases := map[string]struct { + embed *discordgo.MessageEmbed + result string + }{ + "allempty": { + embed: &discordgo.MessageEmbed{}, + result: "", + }, + "one": { + embed: &discordgo.MessageEmbed{ + Title: "blah", + }, + result: " embed: blah\n", + }, + "two": { + embed: &discordgo.MessageEmbed{ + Title: "blah", + Description: "blah2", + }, + result: " embed: blah - blah2\n", + }, + "three": { + embed: &discordgo.MessageEmbed{ + Title: "blah", + Description: "blah2", + URL: "blah3", + }, + result: " embed: blah - blah2 - blah3\n", + }, + "twob": { + embed: &discordgo.MessageEmbed{ + Description: "blah2", + URL: "blah3", + }, + result: " embed: blah2 - blah3\n", + }, + "oneb": { + embed: &discordgo.MessageEmbed{ + URL: "blah3", + }, + result: " embed: blah3\n", + }, + } + + for name, tc := range testcases { + assert.Equalf(t, tc.result, handleEmbed(tc.embed), "Testcases %s", name) + } +} diff --git a/teleirc/matterbridge/bridge/discord/helpers.go b/teleirc/matterbridge/bridge/discord/helpers.go new file mode 100644 index 0000000..2e18f46 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/helpers.go @@ -0,0 +1,269 @@ +package bdiscord + +import ( + "errors" + "regexp" + "strings" + "unicode" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bdiscord) getAllowedMentions() *discordgo.MessageAllowedMentions { + // If AllowMention is not specified, then allow all mentions (default Discord behavior) + if !b.IsKeySet("AllowMention") { + return nil + } + + // Otherwise, allow only the mentions that are specified + allowedMentionTypes := make([]discordgo.AllowedMentionType, 0, 3) + for _, m := range b.GetStringSlice("AllowMention") { + switch m { + case "everyone": + allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeEveryone) + case "roles": + allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeRoles) + case "users": + allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeUsers) + } + } + + return &discordgo.MessageAllowedMentions{ + Parse: allowedMentionTypes, + } +} + +func (b *Bdiscord) getNick(user *discordgo.User, guildID string) string { + b.membersMutex.RLock() + defer b.membersMutex.RUnlock() + + if member, ok := b.userMemberMap[user.ID]; ok { + if member.Nick != "" { + // Only return if nick is set. + return member.Nick + } + // Otherwise return username. + return user.Username + } + + // If we didn't find nick, search for it. + member, err := b.c.GuildMember(guildID, user.ID) + if err != nil { + b.Log.Warnf("Failed to fetch information for member %#v on guild %#v: %s", user, guildID, err) + return user.Username + } else if member == nil { + b.Log.Warnf("Got no information for member %#v", user) + return user.Username + } + b.userMemberMap[user.ID] = member + b.nickMemberMap[member.User.Username] = member + if member.Nick != "" { + b.nickMemberMap[member.Nick] = member + return member.Nick + } + return user.Username +} + +func (b *Bdiscord) getGuildMemberByNick(nick string) (*discordgo.Member, error) { + b.membersMutex.RLock() + defer b.membersMutex.RUnlock() + + if member, ok := b.nickMemberMap[nick]; ok { + return member, nil + } + return nil, errors.New("Couldn't find guild member with nick " + nick) // This will most likely get ignored by the caller +} + +func (b *Bdiscord) getChannelID(name string) string { + if strings.Contains(name, "/") { + return b.getCategoryChannelID(name) + } + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + idcheck := strings.Split(name, "ID:") + if len(idcheck) > 1 { + return idcheck[1] + } + for _, channel := range b.channels { + if channel.Name == name && channel.Type == discordgo.ChannelTypeGuildText { + return channel.ID + } + } + return "" +} + +func (b *Bdiscord) getCategoryChannelID(name string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + res := strings.Split(name, "/") + // shouldn't happen because function should be only called from getChannelID + if len(res) != 2 { + return "" + } + catName, chanName := res[0], res[1] + for _, channel := range b.channels { + // if we have a parentID, lookup the name of that parent (category) + // and if it matches return it + if channel.Name == chanName && channel.ParentID != "" { + for _, cat := range b.channels { + if cat.ID == channel.ParentID && cat.Name == catName { + return channel.ID + } + } + } + } + return "" +} + +func (b *Bdiscord) getChannelName(id string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + for _, c := range b.channelInfoMap { + if c.Name == "ID:"+id { + // if we have ID: specified in our gateway configuration return this + return c.Name + } + } + + for _, channel := range b.channels { + if channel.ID == id { + return b.getCategoryChannelName(channel.Name, channel.ParentID) + } + } + return "" +} + +func (b *Bdiscord) getCategoryChannelName(name, parentID string) string { + var usesCat bool + // do we have a category configuration in the channel config + for _, c := range b.channelInfoMap { + if strings.Contains(c.Name, "/") { + usesCat = true + break + } + } + // configuration without category, return the normal channel name + if !usesCat { + return name + } + // create a category/channel response + for _, c := range b.channels { + if c.ID == parentID { + name = c.Name + "/" + name + } + } + return name +} + +var ( + // See https://discordapp.com/developers/docs/reference#message-formatting. + channelMentionRE = regexp.MustCompile("<#[0-9]+>") + userMentionRE = regexp.MustCompile("@[^@\n]{1,32}") + emoteRE = regexp.MustCompile(`<a?(:\w+:)\d+>`) +) + +func (b *Bdiscord) replaceChannelMentions(text string) string { + replaceChannelMentionFunc := func(match string) string { + channelID := match[2 : len(match)-1] + channelName := b.getChannelName(channelID) + + // If we don't have the channel refresh our list. + if channelName == "" { + var err error + b.channels, err = b.c.GuildChannels(b.guildID) + if err != nil { + return "#unknownchannel" + } + channelName = b.getChannelName(channelID) + } + return "#" + channelName + } + return channelMentionRE.ReplaceAllStringFunc(text, replaceChannelMentionFunc) +} + +func (b *Bdiscord) replaceUserMentions(text string) string { + replaceUserMentionFunc := func(match string) string { + var ( + err error + member *discordgo.Member + username string + ) + + usernames := enumerateUsernames(match[1:]) + for _, username = range usernames { + b.Log.Debugf("Testing mention: '%s'", username) + member, err = b.getGuildMemberByNick(username) + if err == nil { + break + } + } + if member == nil { + return match + } + return strings.Replace(match, "@"+username, member.User.Mention(), 1) + } + return userMentionRE.ReplaceAllStringFunc(text, replaceUserMentionFunc) +} + +func replaceEmotes(text string) string { + return emoteRE.ReplaceAllString(text, "$1") +} + +func (b *Bdiscord) replaceAction(text string) (string, bool) { + length := len(text) + if length > 1 && text[0] == '_' && text[length-1] == '_' { + return text[1 : length-1], true + } + return text, false +} + +// splitURL splits a webhookURL and returns the ID and token. +func (b *Bdiscord) splitURL(url string) (string, string, bool) { + const ( + expectedWebhookSplitCount = 7 + webhookIdxID = 5 + webhookIdxToken = 6 + ) + webhookURLSplit := strings.Split(url, "/") + if len(webhookURLSplit) != expectedWebhookSplitCount { + return "", "", false + } + return webhookURLSplit[webhookIdxID], webhookURLSplit[webhookIdxToken], true +} + +func enumerateUsernames(s string) []string { + onlySpace := true + for _, r := range s { + if !unicode.IsSpace(r) { + onlySpace = false + break + } + } + if onlySpace { + return nil + } + + var username, endSpace string + var usernames []string + skippingSpace := true + for _, r := range s { + if unicode.IsSpace(r) { + if !skippingSpace { + usernames = append(usernames, username) + skippingSpace = true + } + endSpace += string(r) + username += string(r) + } else { + endSpace = "" + username += string(r) + skippingSpace = false + } + } + if endSpace == "" { + usernames = append(usernames, username) + } + return usernames +} diff --git a/teleirc/matterbridge/bridge/discord/helpers_test.go b/teleirc/matterbridge/bridge/discord/helpers_test.go new file mode 100644 index 0000000..0f196d9 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/helpers_test.go @@ -0,0 +1,46 @@ +package bdiscord + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEnumerateUsernames(t *testing.T) { + testcases := map[string]struct { + match string + expectedUsernames []string + }{ + "only space": { + match: " \t\n \t", + expectedUsernames: nil, + }, + "single word": { + match: "veni", + expectedUsernames: []string{"veni"}, + }, + "single word with preceeding space": { + match: " vidi", + expectedUsernames: []string{" vidi"}, + }, + "single word with suffixed space": { + match: "vici ", + expectedUsernames: []string{"vici"}, + }, + "multi-word with varying whitespace": { + match: "just me and\tmy friends \t", + expectedUsernames: []string{ + "just", + "just me", + "just me and", + "just me and\tmy", + "just me and\tmy friends", + }, + }, + } + + for testname, testcase := range testcases { + foundUsernames := enumerateUsernames(testcase.match) + assert.Equalf(t, testcase.expectedUsernames, foundUsernames, "Should have found the expected usernames for testcase %s", testname) + } +} diff --git a/teleirc/matterbridge/bridge/discord/transmitter/transmitter.go b/teleirc/matterbridge/bridge/discord/transmitter/transmitter.go new file mode 100644 index 0000000..71407a1 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/transmitter/transmitter.go @@ -0,0 +1,257 @@ +// Package transmitter provides functionality for transmitting +// arbitrary webhook messages to Discord. +// +// The package provides the following functionality: +// +// - Creating new webhooks, whenever necessary +// - Loading webhooks that we have previously created +// - Sending new messages +// - Editing messages, via message ID +// - Deleting messages, via message ID +// +// The package has been designed for matterbridge, but with other +// Go bots in mind. The public API should be matterbridge-agnostic. +package transmitter + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + log "github.com/sirupsen/logrus" +) + +// A Transmitter represents a message manager for a single guild. +type Transmitter struct { + session *discordgo.Session + guild string + title string + autoCreate bool + + // channelWebhooks maps from a channel ID to a webhook instance + channelWebhooks map[string]*discordgo.Webhook + + mutex sync.RWMutex + + Log *log.Entry +} + +// ErrWebhookNotFound is returned when a valid webhook for this channel/message combination does not exist +var ErrWebhookNotFound = errors.New("webhook for this channel and message does not exist") + +// ErrPermissionDenied is returned if the bot does not have permission to manage webhooks. +// +// Bots can be granted a guild-wide permission and channel-specific permissions to manage webhooks. +// Despite potentially having guild-wide permission, channel specific overrides could deny a bot's permission to manage webhooks. +var ErrPermissionDenied = errors.New("missing 'Manage Webhooks' permission") + +// New returns a new Transmitter given a Discord session, guild ID, and title. +func New(session *discordgo.Session, guild string, title string, autoCreate bool) *Transmitter { + return &Transmitter{ + session: session, + guild: guild, + title: title, + autoCreate: autoCreate, + + channelWebhooks: make(map[string]*discordgo.Webhook), + + Log: log.NewEntry(log.StandardLogger()), + } +} + +// Send transmits a message to the given channel with the provided webhook data, and waits until Discord responds with message data. +func (t *Transmitter) Send(channelID string, params *discordgo.WebhookParams) (*discordgo.Message, error) { + wh, err := t.getOrCreateWebhook(channelID) + if err != nil { + return nil, err + } + + msg, err := t.session.WebhookExecute(wh.ID, wh.Token, true, params) + if err != nil { + return nil, fmt.Errorf("execute failed: %w", err) + } + + return msg, nil +} + +// Edit will edit a message in a channel, if possible. +func (t *Transmitter) Edit(channelID string, messageID string, params *discordgo.WebhookParams) error { + wh := t.getWebhook(channelID) + + if wh == nil { + return ErrWebhookNotFound + } + + uri := discordgo.EndpointWebhookToken(wh.ID, wh.Token) + "/messages/" + messageID + _, err := t.session.RequestWithBucketID("PATCH", uri, params, discordgo.EndpointWebhookToken("", "")) + if err != nil { + return err + } + + return nil +} + +// HasWebhook checks whether the transmitter is using a particular webhook. +func (t *Transmitter) HasWebhook(id string) bool { + t.mutex.RLock() + defer t.mutex.RUnlock() + + for _, wh := range t.channelWebhooks { + if wh.ID == id { + return true + } + } + + return false +} + +// AddWebhook allows you to register a channel's webhook with the transmitter. +func (t *Transmitter) AddWebhook(channelID string, webhook *discordgo.Webhook) bool { + t.Log.Debugf("Manually added webhook %#v to channel %#v", webhook.ID, channelID) + t.mutex.Lock() + defer t.mutex.Unlock() + + _, replaced := t.channelWebhooks[channelID] + t.channelWebhooks[channelID] = webhook + return replaced +} + +// RefreshGuildWebhooks loads "relevant" webhooks into the transmitter, with careful permission handling. +// +// Notes: +// +// - A webhook is "relevant" if it was created by this bot -- the ApplicationID should match the bot's ID. +// - The term "having permission" means having the "Manage Webhooks" permission. See ErrPermissionDenied for more information. +// - This function is additive and will not unload previously loaded webhooks. +// - A nil channelIDs slice is treated the same as an empty one. +// +// If the bot has guild-wide permission: +// +// 1. it will load any "relevant" webhooks from the entire guild +// 2. the given slice is ignored +// +// If the bot does not have guild-wide permission: +// +// 1. it will load any "relevant" webhooks in each channel +// 2. a single error will be returned if any error occurs (incl. if there is no permission for any of these channels) +// +// If any channel has more than one "relevant" webhook, it will randomly pick one. +func (t *Transmitter) RefreshGuildWebhooks(channelIDs []string) error { + t.Log.Debugln("Refreshing guild webhooks") + + botID, err := getDiscordUserID(t.session) + if err != nil { + return fmt.Errorf("could not get current user: %w", err) + } + + // Get all existing webhooks + hooks, err := t.session.GuildWebhooks(t.guild) + if err != nil { + switch { + case isDiscordPermissionError(err): + // We fallback on manually fetching hooks from individual channels + // if we don't have the "Manage Webhooks" permission globally. + // We can only do this if we were provided channelIDs, though. + if len(channelIDs) == 0 { + return ErrPermissionDenied + } + t.Log.Debugln("Missing global 'Manage Webhooks' permission, falling back on per-channel permission") + return t.fetchChannelsHooks(channelIDs, botID) + default: + return fmt.Errorf("could not get webhooks: %w", err) + } + } + + t.Log.Debugln("Refreshing guild webhooks using global permission") + t.assignHooksByAppID(hooks, botID, false) + return nil +} + +// createWebhook creates a webhook for a specific channel. +func (t *Transmitter) createWebhook(channel string) (*discordgo.Webhook, error) { + t.mutex.Lock() + defer t.mutex.Unlock() + + wh, err := t.session.WebhookCreate(channel, t.title+time.Now().Format(" 3:04:05PM"), "") + if err != nil { + return nil, err + } + + t.channelWebhooks[channel] = wh + return wh, nil +} + +func (t *Transmitter) getWebhook(channel string) *discordgo.Webhook { + t.mutex.RLock() + defer t.mutex.RUnlock() + + return t.channelWebhooks[channel] +} + +func (t *Transmitter) getOrCreateWebhook(channelID string) (*discordgo.Webhook, error) { + // If we have a webhook for this channel, immediately return it + wh := t.getWebhook(channelID) + if wh != nil { + return wh, nil + } + + // Early exit if we don't want to automatically create one + if !t.autoCreate { + return nil, ErrWebhookNotFound + } + + t.Log.Infof("Creating a webhook for %s\n", channelID) + wh, err := t.createWebhook(channelID) + if err != nil { + return nil, fmt.Errorf("could not create webhook: %w", err) + } + + return wh, nil +} + +// fetchChannelsHooks fetches hooks for the given channelIDs and calls assignHooksByAppID for each channel's hooks +func (t *Transmitter) fetchChannelsHooks(channelIDs []string, botID string) error { + // For each channel, search for relevant hooks + var failedHooks []string + for _, channelID := range channelIDs { + hooks, err := t.session.ChannelWebhooks(channelID) + if err != nil { + failedHooks = append(failedHooks, "\n- "+channelID+": "+err.Error()) + continue + } + t.assignHooksByAppID(hooks, botID, true) + } + + // Compose an error if any hooks failed + if len(failedHooks) > 0 { + return errors.New("failed to fetch hooks:" + strings.Join(failedHooks, "")) + } + + return nil +} + +func (t *Transmitter) assignHooksByAppID(hooks []*discordgo.Webhook, appID string, channelTargeted bool) { + logLine := "Picking up webhook" + if channelTargeted { + logLine += " (channel targeted)" + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + for _, wh := range hooks { + if wh.ApplicationID != appID { + continue + } + + t.channelWebhooks[wh.ChannelID] = wh + t.Log.WithFields(log.Fields{ + "id": wh.ID, + "name": wh.Name, + "channel": wh.ChannelID, + }).Println(logLine) + } +} diff --git a/teleirc/matterbridge/bridge/discord/transmitter/utils.go b/teleirc/matterbridge/bridge/discord/transmitter/utils.go new file mode 100644 index 0000000..042aa50 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/transmitter/utils.go @@ -0,0 +1,32 @@ +package transmitter + +import ( + "github.com/bwmarrin/discordgo" +) + +// isDiscordPermissionError returns false for nil, and true if a Discord RESTError with code discordgo.ErrorCodeMissionPermissions +func isDiscordPermissionError(err error) bool { + if err == nil { + return false + } + + restErr, ok := err.(*discordgo.RESTError) + if !ok { + return false + } + + return restErr.Message != nil && restErr.Message.Code == discordgo.ErrCodeMissingPermissions +} + +// getDiscordUserID gets own user ID from state, and fallback on API request +func getDiscordUserID(session *discordgo.Session) (string, error) { + if user := session.State.User; user != nil { + return user.ID, nil + } + + user, err := session.User("@me") + if err != nil { + return "", err + } + return user.ID, nil +} diff --git a/teleirc/matterbridge/bridge/discord/webhook.go b/teleirc/matterbridge/bridge/discord/webhook.go new file mode 100644 index 0000000..c34fc94 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/webhook.go @@ -0,0 +1,148 @@ +package bdiscord + +import ( + "bytes" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/bwmarrin/discordgo" +) + +// shouldMessageUseWebhooks checks if have a channel specific webhook, if we're not using auto webhooks +func (b *Bdiscord) shouldMessageUseWebhooks(msg *config.Message) bool { + if b.useAutoWebhooks { + return true + } + + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + if ci, ok := b.channelInfoMap[msg.Channel+b.Account]; ok { + if ci.Options.WebhookURL != "" { + return true + } + } + return false +} + +// maybeGetLocalAvatar checks if UseLocalAvatar contains the message's +// account or protocol, and if so, returns the Discord avatar (if exists) +func (b *Bdiscord) maybeGetLocalAvatar(msg *config.Message) string { + for _, val := range b.GetStringSlice("UseLocalAvatar") { + if msg.Protocol != val && msg.Account != val { + continue + } + + member, err := b.getGuildMemberByNick(msg.Username) + if err != nil { + return "" + } + + return member.User.AvatarURL("") + } + return "" +} + +// webhookSend send one or more message via webhook, taking care of file +// uploads (from slack, telegram or mattermost). +// Returns messageID and error. +func (b *Bdiscord) webhookSend(msg *config.Message, channelID string) (*discordgo.Message, error) { + var ( + res *discordgo.Message + err error + ) + + // If avatar is unset, mutate the message to include the local avatar (but only if settings say we should do this) + if msg.Avatar == "" { + msg.Avatar = b.maybeGetLocalAvatar(msg) + } + + // WebhookParams can have either `Content` or `File`. + + // We can't send empty messages. + if msg.Text != "" { + res, err = b.transmitter.Send( + channelID, + &discordgo.WebhookParams{ + Content: msg.Text, + Username: msg.Username, + AvatarURL: msg.Avatar, + AllowedMentions: b.getAllowedMentions(), + }, + ) + if err != nil { + b.Log.Errorf("Could not send text (%s) for message %#v: %s", msg.Text, msg, err) + } + } + + if msg.Extra != nil { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + file := discordgo.File{ + Name: fi.Name, + ContentType: "", + Reader: bytes.NewReader(*fi.Data), + } + content := fi.Comment + + _, e2 := b.transmitter.Send( + channelID, + &discordgo.WebhookParams{ + Username: msg.Username, + AvatarURL: msg.Avatar, + Files: []*discordgo.File{&file}, + Content: content, + AllowedMentions: b.getAllowedMentions(), + }, + ) + if e2 != nil { + b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, e2) + } + } + } + return res, err +} + +func (b *Bdiscord) handleEventWebhook(msg *config.Message, channelID string) (string, error) { + // skip events + if msg.Event != "" && msg.Event != config.EventUserAction && msg.Event != config.EventJoinLeave && msg.Event != config.EventTopicChange { + return "", nil + } + + // skip empty messages + if msg.Text == "" && (msg.Extra == nil || len(msg.Extra["file"]) == 0) { + b.Log.Debugf("Skipping empty message %#v", msg) + return "", nil + } + + msg.Text = helper.ClipMessage(msg.Text, MessageLength, b.GetString("MessageClipped")) + msg.Text = b.replaceUserMentions(msg.Text) + // discord username must be [0..32] max + if len(msg.Username) > 32 { + msg.Username = msg.Username[0:32] + } + + if msg.ID != "" { + b.Log.Debugf("Editing webhook message") + err := b.transmitter.Edit(channelID, msg.ID, &discordgo.WebhookParams{ + Content: msg.Text, + Username: msg.Username, + AllowedMentions: b.getAllowedMentions(), + }) + if err == nil { + return msg.ID, nil + } + b.Log.Errorf("Could not edit webhook message: %s", err) + } + + b.Log.Debugf("Processing webhook sending for message %#v", msg) + discordMsg, err := b.webhookSend(msg, channelID) + if err != nil { + b.Log.Errorf("Could not broadcast via webhook for message %#v: %s", msg, err) + return "", err + } + if discordMsg == nil { + return "", nil + } + + return discordMsg.ID, nil +} diff --git a/teleirc/matterbridge/bridge/gitter/gitter.go b/teleirc/matterbridge/bridge/gitter/gitter.go new file mode 100644 index 0000000..486fe43 --- /dev/null +++ b/teleirc/matterbridge/bridge/gitter/gitter.go @@ -0,0 +1,182 @@ +package bgitter + +import ( + "fmt" + "strings" + + "github.com/42wim/go-gitter" + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" +) + +type Bgitter struct { + c *gitter.Gitter + User *gitter.User + Users []gitter.User + Rooms []gitter.Room + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Bgitter{Config: cfg} +} + +func (b *Bgitter) Connect() error { + var err error + b.Log.Info("Connecting") + b.c = gitter.New(b.GetString("Token")) + b.User, err = b.c.GetUser() + if err != nil { + return err + } + b.Rooms, err = b.c.GetRooms() + if err != nil { + return err + } + b.Log.Info("Connection succeeded") + return nil +} + +func (b *Bgitter) Disconnect() error { + return nil + +} + +func (b *Bgitter) JoinChannel(channel config.ChannelInfo) error { + roomID, err := b.c.GetRoomId(channel.Name) + if err != nil { + return fmt.Errorf("Could not find roomID for %v. Please create the room on gitter.im", channel.Name) + } + room, err := b.c.GetRoom(roomID) + if err != nil { + return err + } + b.Rooms = append(b.Rooms, *room) + user, err := b.c.GetUser() + if err != nil { + return err + } + _, err = b.c.JoinRoom(roomID, user.ID) + if err != nil { + return err + } + users, _ := b.c.GetUsersInRoom(roomID) + b.Users = append(b.Users, users...) + stream := b.c.Stream(roomID) + go b.c.Listen(stream) + + go func(stream *gitter.Stream, room string) { + for event := range stream.Event { + switch ev := event.Data.(type) { + case *gitter.MessageReceived: + // ignore message sent from ourselves + if ev.Message.From.ID != b.User.ID { + b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Message.From.Username, b.Account) + rmsg := config.Message{Username: ev.Message.From.Username, Text: ev.Message.Text, Channel: room, + Account: b.Account, Avatar: b.getAvatar(ev.Message.From.Username), UserID: ev.Message.From.ID, + ID: ev.Message.ID} + if strings.HasPrefix(ev.Message.Text, "@"+ev.Message.From.Username) { + rmsg.Event = config.EventUserAction + rmsg.Text = strings.Replace(rmsg.Text, "@"+ev.Message.From.Username+" ", "", -1) + } + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg + } + case *gitter.GitterConnectionClosed: + b.Log.Errorf("connection with gitter closed for room %s", room) + } + } + }(stream, room.URI) + return nil +} + +func (b *Bgitter) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + roomID := b.getRoomID(msg.Channel) + if roomID == "" { + b.Log.Errorf("Could not find roomID for %v", msg.Channel) + return "", nil + } + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + // gitter has no delete message api so we edit message to "" + _, err := b.c.UpdateMessage(roomID, msg.ID, "") + if err != nil { + return "", err + } + return "", nil + } + + // Upload a file (in gitter case send the upload URL because gitter has no native upload support) + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + b.c.SendMessage(roomID, rmsg.Username+rmsg.Text) + } + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(&msg, roomID) + } + } + + // Edit message + if msg.ID != "" { + b.Log.Debugf("updating message with id %s", msg.ID) + _, err := b.c.UpdateMessage(roomID, msg.ID, msg.Username+msg.Text) + if err != nil { + return "", err + } + return "", nil + } + + // Post normal message + resp, err := b.c.SendMessage(roomID, msg.Username+msg.Text) + if err != nil { + return "", err + } + return resp.ID, nil +} + +func (b *Bgitter) getRoomID(channel string) string { + for _, v := range b.Rooms { + if v.URI == channel { + return v.ID + } + } + return "" +} + +func (b *Bgitter) getAvatar(user string) string { + var avatar string + if b.Users != nil { + for _, u := range b.Users { + if user == u.Username { + return u.AvatarURLSmall + } + } + } + return avatar +} + +func (b *Bgitter) handleUploadFile(msg *config.Message, roomID string) (string, error) { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.Comment != "" { + msg.Text += fi.Comment + ": " + } + if fi.URL != "" { + msg.Text = fi.URL + if fi.Comment != "" { + msg.Text = fi.Comment + ": " + fi.URL + } + } + _, err := b.c.SendMessage(roomID, msg.Username+msg.Text) + if err != nil { + return "", err + } + } + return "", nil +} diff --git a/teleirc/matterbridge/bridge/harmony/harmony.go b/teleirc/matterbridge/bridge/harmony/harmony.go new file mode 100644 index 0000000..14174c3 --- /dev/null +++ b/teleirc/matterbridge/bridge/harmony/harmony.go @@ -0,0 +1,252 @@ +package harmony + +import ( + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/harmony-development/shibshib" + chatv1 "github.com/harmony-development/shibshib/gen/chat/v1" + typesv1 "github.com/harmony-development/shibshib/gen/harmonytypes/v1" + profilev1 "github.com/harmony-development/shibshib/gen/profile/v1" +) + +type cachedProfile struct { + data *profilev1.GetProfileResponse + lastUpdated time.Time +} + +type Bharmony struct { + *bridge.Config + + c *shibshib.Client + profileCache map[uint64]cachedProfile +} + +func uToStr(in uint64) string { + return strconv.FormatUint(in, 10) +} + +func strToU(in string) (uint64, error) { + return strconv.ParseUint(in, 10, 64) +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bharmony{ + Config: cfg, + profileCache: map[uint64]cachedProfile{}, + } + + return b +} + +func (b *Bharmony) getProfile(u uint64) (*profilev1.GetProfileResponse, error) { + if v, ok := b.profileCache[u]; ok && time.Since(v.lastUpdated) < time.Minute*10 { + return v.data, nil + } + + resp, err := b.c.ProfileKit.GetProfile(&profilev1.GetProfileRequest{ + UserId: u, + }) + if err != nil { + if v, ok := b.profileCache[u]; ok { + return v.data, nil + } + return nil, err + } + b.profileCache[u] = cachedProfile{ + data: resp, + lastUpdated: time.Now(), + } + return resp, nil +} + +func (b *Bharmony) avatarFor(m *chatv1.Message) string { + if m.Overrides != nil { + return m.Overrides.GetAvatar() + } + + profi, err := b.getProfile(m.AuthorId) + if err != nil { + return "" + } + + return b.c.TransformHMCURL(profi.Profile.GetUserAvatar()) +} + +func (b *Bharmony) usernameFor(m *chatv1.Message) string { + if m.Overrides != nil { + return m.Overrides.GetUsername() + } + + profi, err := b.getProfile(m.AuthorId) + if err != nil { + return "" + } + + return profi.Profile.UserName +} + +func (b *Bharmony) toMessage(msg *shibshib.LocatedMessage) config.Message { + message := config.Message{} + message.Account = b.Account + message.UserID = uToStr(msg.Message.AuthorId) + message.Avatar = b.avatarFor(msg.Message) + message.Username = b.usernameFor(msg.Message) + message.Channel = uToStr(msg.ChannelID) + message.ID = uToStr(msg.MessageId) + + switch content := msg.Message.Content.Content.(type) { + case *chatv1.Content_EmbedMessage: + message.Text = "Embed" + case *chatv1.Content_AttachmentMessage: + var s strings.Builder + for idx, attach := range content.AttachmentMessage.Files { + s.WriteString(b.c.TransformHMCURL(attach.Id)) + if idx < len(content.AttachmentMessage.Files)-1 { + s.WriteString(", ") + } + } + message.Text = s.String() + case *chatv1.Content_PhotoMessage: + var s strings.Builder + for idx, attach := range content.PhotoMessage.GetPhotos() { + s.WriteString(attach.GetCaption().GetText()) + s.WriteString("\n") + s.WriteString(b.c.TransformHMCURL(attach.GetHmc())) + if idx < len(content.PhotoMessage.GetPhotos())-1 { + s.WriteString("\n\n") + } + } + message.Text = s.String() + case *chatv1.Content_TextMessage: + message.Text = content.TextMessage.Content.Text + } + + return message +} + +func (b *Bharmony) outputMessages() { + for { + msg := <-b.c.EventsStream() + + if msg.Message.AuthorId == b.c.UserID { + continue + } + + b.Remote <- b.toMessage(msg) + } +} + +func (b *Bharmony) GetUint64(conf string) uint64 { + num, err := strToU(b.GetString(conf)) + if err != nil { + log.Fatal(err) + } + + return num +} + +func (b *Bharmony) Connect() (err error) { + b.c, err = shibshib.NewClient(b.GetString("Homeserver"), b.GetString("Token"), b.GetUint64("UserID")) + if err != nil { + return + } + b.c.SubscribeToGuild(b.GetUint64("Community")) + + go b.outputMessages() + + return nil +} + +func (b *Bharmony) send(msg config.Message) (id string, err error) { + msgChan, err := strToU(msg.Channel) + if err != nil { + return + } + + retID, err := b.c.ChatKit.SendMessage(&chatv1.SendMessageRequest{ + GuildId: b.GetUint64("Community"), + ChannelId: msgChan, + Content: &chatv1.Content{ + Content: &chatv1.Content_TextMessage{ + TextMessage: &chatv1.Content_TextContent{ + Content: &chatv1.FormattedText{ + Text: msg.Text, + }, + }, + }, + }, + Overrides: &chatv1.Overrides{ + Username: &msg.Username, + Avatar: &msg.Avatar, + Reason: &chatv1.Overrides_Bridge{Bridge: &typesv1.Empty{}}, + }, + InReplyTo: nil, + EchoId: nil, + Metadata: nil, + }) + if err != nil { + err = fmt.Errorf("send: error sending message: %w", err) + log.Println(err.Error()) + } + + return uToStr(retID.MessageId), err +} + +func (b *Bharmony) delete(msg config.Message) (id string, err error) { + msgChan, err := strToU(msg.Channel) + if err != nil { + return "", err + } + + msgID, err := strToU(msg.ID) + if err != nil { + return "", err + } + + _, err = b.c.ChatKit.DeleteMessage(&chatv1.DeleteMessageRequest{ + GuildId: b.GetUint64("Community"), + ChannelId: msgChan, + MessageId: msgID, + }) + return "", err +} + +func (b *Bharmony) typing(msg config.Message) (id string, err error) { + msgChan, err := strToU(msg.Channel) + if err != nil { + return "", err + } + + _, err = b.c.ChatKit.Typing(&chatv1.TypingRequest{ + GuildId: b.GetUint64("Community"), + ChannelId: msgChan, + }) + return "", err +} + +func (b *Bharmony) Send(msg config.Message) (id string, err error) { + switch msg.Event { + case "": + return b.send(msg) + case config.EventMsgDelete: + return b.delete(msg) + case config.EventUserTyping: + return b.typing(msg) + default: + return "", nil + } +} + +func (b *Bharmony) JoinChannel(channel config.ChannelInfo) error { + return nil +} + +func (b *Bharmony) Disconnect() error { + return nil +} diff --git a/teleirc/matterbridge/bridge/helper/helper.go b/teleirc/matterbridge/bridge/helper/helper.go new file mode 100644 index 0000000..0208dff --- /dev/null +++ b/teleirc/matterbridge/bridge/helper/helper.go @@ -0,0 +1,250 @@ +package helper + +import ( + "bytes" + "fmt" + "image/png" + "io" + "net/http" + "regexp" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/image/webp" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "github.com/sirupsen/logrus" +) + +// DownloadFile downloads the given non-authenticated URL. +func DownloadFile(url string) (*[]byte, error) { + return DownloadFileAuth(url, "") +} + +// DownloadFileAuth downloads the given URL using the specified authentication token. +func DownloadFileAuth(url string, auth string) (*[]byte, error) { + var buf bytes.Buffer + client := &http.Client{ + Timeout: time.Second * 5, + } + req, err := http.NewRequest("GET", url, nil) + if auth != "" { + req.Header.Add("Authorization", auth) + } + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + io.Copy(&buf, resp.Body) + data := buf.Bytes() + return &data, nil +} + +// DownloadFileAuthRocket downloads the given URL using the specified Rocket user ID and authentication token. +func DownloadFileAuthRocket(url, token, userID string) (*[]byte, error) { + var buf bytes.Buffer + client := &http.Client{ + Timeout: time.Second * 5, + } + req, err := http.NewRequest("GET", url, nil) + + req.Header.Add("X-Auth-Token", token) + req.Header.Add("X-User-Id", userID) + + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + _, err = io.Copy(&buf, resp.Body) + data := buf.Bytes() + return &data, err +} + +// GetSubLines splits messages in newline-delimited lines. If maxLineLength is +// specified as non-zero GetSubLines will also clip long lines to the maximum +// length and insert a warning marker that the line was clipped. +// +// TODO: The current implementation has the inconvenient that it disregards +// word boundaries when splitting but this is hard to solve without potentially +// breaking formatting and other stylistic effects. +func GetSubLines(message string, maxLineLength int, clippingMessage string) []string { + if clippingMessage == "" { + clippingMessage = " <clipped message>" + } + + var lines []string + for _, line := range strings.Split(strings.TrimSpace(message), "\n") { + if line == "" { + // Prevent sending empty messages, so we'll skip this line + // if it has no content. + continue + } + + if maxLineLength == 0 || len([]byte(line)) <= maxLineLength { + lines = append(lines, line) + continue + } + + // !!! WARNING !!! + // Before touching the splitting logic below please ensure that you PROPERLY + // understand how strings, runes and range loops over strings work in Go. + // A good place to start is to read https://blog.golang.org/strings. :-) + var splitStart int + var startOfPreviousRune int + for i := range line { + if i-splitStart > maxLineLength-len([]byte(clippingMessage)) { + lines = append(lines, line[splitStart:startOfPreviousRune]+clippingMessage) + splitStart = startOfPreviousRune + } + startOfPreviousRune = i + } + // This last append is safe to do without looking at the remaining byte-length + // as we assume that the byte-length of the last rune will never exceed that of + // the byte-length of the clipping message. + lines = append(lines, line[splitStart:]) + } + return lines +} + +// HandleExtra manages the supplementary details stored inside a message's 'Extra' field map. +func HandleExtra(msg *config.Message, general *config.Protocol) []config.Message { + extra := msg.Extra + rmsg := []config.Message{} + for _, f := range extra[config.EventFileFailureSize] { + fi := f.(config.FileInfo) + text := fmt.Sprintf("file %s too big to download (%#v > allowed size: %#v)", fi.Name, fi.Size, general.MediaDownloadSize) + rmsg = append(rmsg, config.Message{ + Text: text, + Username: "<system> ", + Channel: msg.Channel, + Account: msg.Account, + }) + } + return rmsg +} + +// GetAvatar constructs a URL for a given user-avatar if it is available in the cache. +func GetAvatar(av map[string]string, userid string, general *config.Protocol) string { + if sha, ok := av[userid]; ok { + return general.MediaServerDownload + "/" + sha + "/" + userid + ".png" + } + return "" +} + +// HandleDownloadSize checks a specified filename against the configured download blacklist +// and checks a specified file-size against the configure limit. +func HandleDownloadSize(logger *logrus.Entry, msg *config.Message, name string, size int64, general *config.Protocol) error { + // check blacklist here + for _, entry := range general.MediaDownloadBlackList { + if entry != "" { + re, err := regexp.Compile(entry) + if err != nil { + logger.Errorf("incorrect regexp %s for %s", entry, msg.Account) + continue + } + if re.MatchString(name) { + return fmt.Errorf("Matching blacklist %s. Not downloading %s", entry, name) + } + } + } + logger.Debugf("Trying to download %#v with size %#v", name, size) + if int(size) > general.MediaDownloadSize { + msg.Event = config.EventFileFailureSize + msg.Extra[msg.Event] = append(msg.Extra[msg.Event], config.FileInfo{ + Name: name, + Comment: msg.Text, + Size: size, + }) + return fmt.Errorf("File %#v to large to download (%#v). MediaDownloadSize is %#v", name, size, general.MediaDownloadSize) + } + return nil +} + +// HandleDownloadData adds the data for a remote file into a Matterbridge gateway message. +func HandleDownloadData(logger *logrus.Entry, msg *config.Message, name, comment, url string, data *[]byte, general *config.Protocol) { + HandleDownloadData2(logger, msg, name, "", comment, url, data, general) +} + +// HandleDownloadData adds the data for a remote file into a Matterbridge gateway message. +func HandleDownloadData2(logger *logrus.Entry, msg *config.Message, name, id, comment, url string, data *[]byte, general *config.Protocol) { + var avatar bool + logger.Debugf("Download OK %#v %#v", name, len(*data)) + if msg.Event == config.EventAvatarDownload { + avatar = true + } + msg.Extra["file"] = append(msg.Extra["file"], config.FileInfo{ + Name: name, + Data: data, + URL: url, + Comment: comment, + Avatar: avatar, + NativeID: id, + }) +} + +var emptyLineMatcher = regexp.MustCompile("\n+") + +// RemoveEmptyNewLines collapses consecutive newline characters into a single one and +// trims any preceding or trailing newline characters as well. +func RemoveEmptyNewLines(msg string) string { + return emptyLineMatcher.ReplaceAllString(strings.Trim(msg, "\n"), "\n") +} + +// ClipMessage trims a message to the specified length if it exceeds it and adds a warning +// to the message in case it does so. +func ClipMessage(text string, length int, clippingMessage string) string { + if clippingMessage == "" { + clippingMessage = " <clipped message>" + } + + if len(text) > length { + text = text[:length-len(clippingMessage)] + if r, size := utf8.DecodeLastRuneInString(text); r == utf8.RuneError { + text = text[:len(text)-size] + } + text += clippingMessage + } + return text +} + +// ParseMarkdown takes in an input string as markdown and parses it to html +func ParseMarkdown(input string) string { + extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode + markdownParser := parser.NewWithExtensions(extensions) + renderer := html.NewRenderer(html.RendererOptions{ + Flags: 0, + }) + parsedMarkdown := markdown.ToHTML([]byte(input), markdownParser, renderer) + res := string(parsedMarkdown) + res = strings.TrimPrefix(res, "<p>") + res = strings.TrimSuffix(res, "</p>\n") + return res +} + +// ConvertWebPToPNG converts input data (which should be WebP format) to PNG format +func ConvertWebPToPNG(data *[]byte) error { + r := bytes.NewReader(*data) + m, err := webp.Decode(r) + if err != nil { + return err + } + var output []byte + w := bytes.NewBuffer(output) + if err := png.Encode(w, m); err != nil { + return err + } + *data = w.Bytes() + return nil +} diff --git a/teleirc/matterbridge/bridge/helper/helper_test.go b/teleirc/matterbridge/bridge/helper/helper_test.go new file mode 100644 index 0000000..76e548e --- /dev/null +++ b/teleirc/matterbridge/bridge/helper/helper_test.go @@ -0,0 +1,127 @@ +package helper + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testLineLength = 64 + +var lineSplittingTestCases = map[string]struct { + input string + splitOutput []string + nonSplitOutput []string +}{ + "Short single-line message": { + input: "short", + splitOutput: []string{"short"}, + nonSplitOutput: []string{"short"}, + }, + "Long single-line message": { + input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + splitOutput: []string{ + "Lorem ipsum dolor sit amet, consectetur adipis <clipped message>", + "cing elit, sed do eiusmod tempor incididunt ut <clipped message>", + " labore et dolore magna aliqua.", + }, + nonSplitOutput: []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."}, + }, + "Short multi-line message": { + input: "I\ncan't\nget\nno\nsatisfaction!", + splitOutput: []string{ + "I", + "can't", + "get", + "no", + "satisfaction!", + }, + nonSplitOutput: []string{ + "I", + "can't", + "get", + "no", + "satisfaction!", + }, + }, + "Long multi-line message": { + input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n" + + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n" + + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n" + + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + splitOutput: []string{ + "Lorem ipsum dolor sit amet, consectetur adipis <clipped message>", + "cing elit, sed do eiusmod tempor incididunt ut <clipped message>", + " labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercita <clipped message>", + "tion ullamco laboris nisi ut aliquip ex ea com <clipped message>", + "modo consequat.", + "Duis aute irure dolor in reprehenderit in volu <clipped message>", + "ptate velit esse cillum dolore eu fugiat nulla <clipped message>", + " pariatur.", + "Excepteur sint occaecat cupidatat non proident <clipped message>", + ", sunt in culpa qui officia deserunt mollit an <clipped message>", + "im id est laborum.", + }, + nonSplitOutput: []string{ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + }, + }, + "Message ending with new-line.": { + input: "Newline ending\n", + splitOutput: []string{"Newline ending"}, + nonSplitOutput: []string{"Newline ending"}, + }, + "Long message containing UTF-8 multi-byte runes": { + input: "不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說", + splitOutput: []string{ + "不布人個我此而及單石業喜資富下 <clipped message>", + "我河下日沒一我臺空達的常景便物 <clipped message>", + "沒為……子大我別名解成?生賣的 <clipped message>", + "全直黑,我自我結毛分洲了世當, <clipped message>", + "是政福那是東;斯說", + }, + nonSplitOutput: []string{"不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說"}, + }, +} + +func TestGetSubLines(t *testing.T) { + for testname, testcase := range lineSplittingTestCases { + splitLines := GetSubLines(testcase.input, testLineLength, "") + assert.Equalf(t, testcase.splitOutput, splitLines, "'%s' testcase should give expected lines with splitting.", testname) + for _, splitLine := range splitLines { + byteLength := len([]byte(splitLine)) + assert.True(t, byteLength <= testLineLength, "Splitted line '%s' of testcase '%s' should not exceed the maximum byte-length (%d vs. %d).", splitLine, testcase, byteLength, testLineLength) + } + + nonSplitLines := GetSubLines(testcase.input, 0, "") + assert.Equalf(t, testcase.nonSplitOutput, nonSplitLines, "'%s' testcase should give expected lines without splitting.", testname) + } +} + +func TestConvertWebPToPNG(t *testing.T) { + if os.Getenv("LOCAL_TEST") == "" { + t.Skip() + } + + input, err := ioutil.ReadFile("test.webp") + if err != nil { + t.Fail() + } + + d := &input + err = ConvertWebPToPNG(d) + if err != nil { + t.Fail() + } + + err = ioutil.WriteFile("test.png", *d, 0o644) // nolint:gosec + if err != nil { + t.Fail() + } +} diff --git a/teleirc/matterbridge/bridge/helper/libtgsconverter.go b/teleirc/matterbridge/bridge/helper/libtgsconverter.go new file mode 100644 index 0000000..3069b34 --- /dev/null +++ b/teleirc/matterbridge/bridge/helper/libtgsconverter.go @@ -0,0 +1,35 @@ +//go:build cgolottie + +package helper + +import ( + "fmt" + + "github.com/Benau/tgsconverter/libtgsconverter" + "github.com/sirupsen/logrus" +) + +func CanConvertTgsToX() error { + return nil +} + +// ConvertTgsToX convert input data (which should be tgs format) to any format supported by libtgsconverter +func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error { + options := libtgsconverter.NewConverterOptions() + options.SetExtension(outputFormat) + blob, err := libtgsconverter.ImportFromData(*data, options) + if err != nil { + return fmt.Errorf("failed to run libtgsconverter.ImportFromData: %s", err.Error()) + } + + *data = blob + return nil +} + +func SupportsFormat(format string) bool { + return libtgsconverter.SupportsExtension(format) +} + +func LottieBackend() string { + return "libtgsconverter" +} diff --git a/teleirc/matterbridge/bridge/helper/lottie_convert.go b/teleirc/matterbridge/bridge/helper/lottie_convert.go new file mode 100644 index 0000000..ffbe95d --- /dev/null +++ b/teleirc/matterbridge/bridge/helper/lottie_convert.go @@ -0,0 +1,90 @@ +//go:build !cgolottie + +package helper + +import ( + "io/ioutil" + "os" + "os/exec" + + "github.com/sirupsen/logrus" +) + +// CanConvertTgsToX Checks whether the external command necessary for ConvertTgsToX works. +func CanConvertTgsToX() error { + // We depend on the fact that `lottie_convert.py --help` has exit status 0. + // Hyrum's Law predicted this, and Murphy's Law predicts that this will break eventually. + // However, there is no alternative like `lottie_convert.py --is-properly-installed` + cmd := exec.Command("lottie_convert.py", "--help") + return cmd.Run() +} + +// ConvertTgsToWebP convert input data (which should be tgs format) to WebP format +// This relies on an external command, which is ugly, but works. +func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error { + // lottie can't handle input from a pipe, so write to a temporary file: + tmpInFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-input-*.tgs") + if err != nil { + return err + } + tmpInFileName := tmpInFile.Name() + defer func() { + if removeErr := os.Remove(tmpInFileName); removeErr != nil { + logger.Errorf("Could not delete temporary (input) file %s: %v", tmpInFileName, removeErr) + } + }() + // lottie can handle writing to a pipe, but there is no way to do that platform-independently. + // "/dev/stdout" won't work on Windows, and "-" upsets Cairo for some reason. So we need another file: + tmpOutFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-output-*.data") + if err != nil { + return err + } + tmpOutFileName := tmpOutFile.Name() + defer func() { + if removeErr := os.Remove(tmpOutFileName); removeErr != nil { + logger.Errorf("Could not delete temporary (output) file %s: %v", tmpOutFileName, removeErr) + } + }() + + if _, writeErr := tmpInFile.Write(*data); writeErr != nil { + return writeErr + } + // Must close before calling lottie to avoid data races: + if closeErr := tmpInFile.Close(); closeErr != nil { + return closeErr + } + + // Call lottie to transform: + cmd := exec.Command("lottie_convert.py", "--input-format", "lottie", "--output-format", outputFormat, tmpInFileName, tmpOutFileName) + cmd.Stdout = nil + cmd.Stderr = nil + // NB: lottie writes progress into to stderr in all cases. + _, stderr := cmd.Output() + if stderr != nil { + // 'stderr' already contains some parts of Stderr, because it was set to 'nil'. + return stderr + } + dataContents, err := ioutil.ReadFile(tmpOutFileName) + if err != nil { + return err + } + + *data = dataContents + return nil +} + +func SupportsFormat(format string) bool { + switch format { + case "png": + fallthrough + case "webp": + return true + default: + return false + } + return false +} + +func LottieBackend() string { + return "lottie_convert.py" +} diff --git a/teleirc/matterbridge/bridge/irc/charset.go b/teleirc/matterbridge/bridge/irc/charset.go new file mode 100644 index 0000000..57872ec --- /dev/null +++ b/teleirc/matterbridge/bridge/irc/charset.go @@ -0,0 +1,32 @@ +package birc + +import ( + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/japanese" + "golang.org/x/text/encoding/korean" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/encoding/traditionalchinese" + "golang.org/x/text/encoding/unicode" +) + +var encoders = map[string]encoding.Encoding{ + "utf-8": unicode.UTF8, + "iso-2022-jp": japanese.ISO2022JP, + "big5": traditionalchinese.Big5, + "gbk": simplifiedchinese.GBK, + "euc-kr": korean.EUCKR, + "gb2312": simplifiedchinese.HZGB2312, + "shift-jis": japanese.ShiftJIS, + "euc-jp": japanese.EUCJP, + "gb18030": simplifiedchinese.GB18030, +} + +func toUTF8(from string, input string) string { + enc, ok := encoders[from] + if !ok { + return input + } + + res, _ := enc.NewDecoder().String(input) + return res +} diff --git a/teleirc/matterbridge/bridge/irc/handlers.go b/teleirc/matterbridge/bridge/irc/handlers.go new file mode 100644 index 0000000..74db768 --- /dev/null +++ b/teleirc/matterbridge/bridge/irc/handlers.go @@ -0,0 +1,265 @@ +package birc + +import ( + "bytes" + "fmt" + "io/ioutil" + "strconv" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/lrstanley/girc" + "github.com/paulrosania/go-charset/charset" + "github.com/saintfish/chardet" + + // We need to import the 'data' package as an implicit dependency. + // See: https://godoc.org/github.com/paulrosania/go-charset/charset + _ "github.com/paulrosania/go-charset/data" +) + +func (b *Birc) handleCharset(msg *config.Message) error { + if b.GetString("Charset") != "" { + switch b.GetString("Charset") { + case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp": + msg.Text = toUTF8(b.GetString("Charset"), msg.Text) + default: + buf := new(bytes.Buffer) + w, err := charset.NewWriter(b.GetString("Charset"), buf) + if err != nil { + b.Log.Errorf("charset to utf-8 conversion failed: %s", err) + return err + } + fmt.Fprint(w, msg.Text) + w.Close() + msg.Text = buf.String() + } + } + return nil +} + +// handleFiles returns true if we have handled the files, otherwise return false +func (b *Birc) handleFiles(msg *config.Message) bool { + if msg.Extra == nil { + return false + } + for _, rmsg := range helper.HandleExtra(msg, b.General) { + b.Local <- rmsg + } + if len(msg.Extra["file"]) == 0 { + return false + } + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.Comment != "" { + msg.Text += fi.Comment + " : " + } + if fi.URL != "" { + msg.Text = fi.URL + if fi.Comment != "" { + msg.Text = fi.Comment + " : " + fi.URL + } + } + b.Local <- config.Message{Text: msg.Text, Username: msg.Username, Channel: msg.Channel, Event: msg.Event} + } + return true +} + +func (b *Birc) handleInvite(client *girc.Client, event girc.Event) { + if len(event.Params) != 2 { + return + } + + channel := event.Params[1] + + b.Log.Debugf("got invite for %s", channel) + + if _, ok := b.channels[channel]; ok { + b.i.Cmd.Join(channel) + } +} + +func (b *Birc) handleJoinPart(client *girc.Client, event girc.Event) { + if len(event.Params) == 0 { + b.Log.Debugf("handleJoinPart: empty Params? %#v", event) + return + } + channel := strings.ToLower(event.Params[0]) + if event.Command == "KICK" && event.Params[1] == b.Nick { + b.Log.Infof("Got kicked from %s by %s", channel, event.Source.Name) + time.Sleep(time.Duration(b.GetInt("RejoinDelay")) * time.Second) + b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: channel, Account: b.Account, Event: config.EventRejoinChannels} + return + } + if event.Command == "QUIT" { + if event.Source.Name == b.Nick && strings.Contains(event.Last(), "Ping timeout") { + b.Log.Infof("%s reconnecting ..", b.Account) + b.Remote <- config.Message{Username: "system", Text: "reconnect", Channel: channel, Account: b.Account, Event: config.EventFailure} + return + } + } + if event.Source.Name != b.Nick { + if b.GetBool("nosendjoinpart") { + return + } + msg := config.Message{Username: "system", Text: event.Source.Name + " " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave} + if b.GetBool("verbosejoinpart") { + b.Log.Debugf("<= Sending verbose JOIN_LEAVE event from %s to gateway", b.Account) + msg = config.Message{Username: "system", Text: event.Source.Name + " (" + event.Source.Ident + "@" + event.Source.Host + ") " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave} + } else { + b.Log.Debugf("<= Sending JOIN_LEAVE event from %s to gateway", b.Account) + } + b.Log.Debugf("<= Message is %#v", msg) + b.Remote <- msg + return + } + b.Log.Debugf("handle %#v", event) +} + +func (b *Birc) handleNewConnection(client *girc.Client, event girc.Event) { + b.Log.Debug("Registering callbacks") + i := b.i + b.Nick = event.Params[0] + + i.Handlers.AddBg("PRIVMSG", b.handlePrivMsg) + i.Handlers.AddBg("CTCP_ACTION", b.handlePrivMsg) + i.Handlers.Add(girc.RPL_TOPICWHOTIME, b.handleTopicWhoTime) + i.Handlers.AddBg(girc.NOTICE, b.handleNotice) + i.Handlers.AddBg("JOIN", b.handleJoinPart) + i.Handlers.AddBg("PART", b.handleJoinPart) + i.Handlers.AddBg("QUIT", b.handleJoinPart) + i.Handlers.AddBg("KICK", b.handleJoinPart) + i.Handlers.Add("INVITE", b.handleInvite) +} + +func (b *Birc) handleNickServ() { + if !b.GetBool("UseSASL") && b.GetString("NickServNick") != "" && b.GetString("NickServPassword") != "" { + b.Log.Debugf("Sending identify to nickserv %s", b.GetString("NickServNick")) + b.i.Cmd.Message(b.GetString("NickServNick"), "IDENTIFY "+b.GetString("NickServPassword")) + } + if strings.EqualFold(b.GetString("NickServNick"), "Q@CServe.quakenet.org") { + b.Log.Debugf("Authenticating %s against %s", b.GetString("NickServUsername"), b.GetString("NickServNick")) + b.i.Cmd.Message(b.GetString("NickServNick"), "AUTH "+b.GetString("NickServUsername")+" "+b.GetString("NickServPassword")) + } + // give nickserv some slack + time.Sleep(time.Second * 5) + b.authDone = true +} + +func (b *Birc) handleNotice(client *girc.Client, event girc.Event) { + if strings.Contains(event.String(), "This nickname is registered") && event.Source.Name == b.GetString("NickServNick") { + b.handleNickServ() + } else { + b.handlePrivMsg(client, event) + } +} + +func (b *Birc) handleOther(client *girc.Client, event girc.Event) { + if b.GetInt("DebugLevel") == 1 { + if event.Command != "CLIENT_STATE_UPDATED" && + event.Command != "CLIENT_GENERAL_UPDATED" { + b.Log.Debugf("%#v", event.String()) + } + return + } + switch event.Command { + case "372", "375", "376", "250", "251", "252", "253", "254", "255", "265", "266", "002", "003", "004", "005": + return + } + b.Log.Debugf("%#v", event.String()) +} + +func (b *Birc) handleOtherAuth(client *girc.Client, event girc.Event) { + b.handleNickServ() + b.handleRunCommands() + // we are now fully connected + // only send on first connection + if b.FirstConnection { + b.connected <- nil + } +} + +func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) { + if b.skipPrivMsg(event) { + return + } + + rmsg := config.Message{ + Username: event.Source.Name, + Channel: strings.ToLower(event.Params[0]), + Account: b.Account, + UserID: event.Source.Ident + "@" + event.Source.Host, + } + + b.Log.Debugf("== Receiving PRIVMSG: %s %s %#v", event.Source.Name, event.Last(), event) + + // set action event + if event.IsAction() { + rmsg.Event = config.EventUserAction + } + + // set NOTICE event + if event.Command == "NOTICE" { + rmsg.Event = config.EventNoticeIRC + } + + // strip action, we made an event if it was an action + rmsg.Text += event.StripAction() + + // start detecting the charset + mycharset := b.GetString("Charset") + if mycharset == "" { + // detect what were sending so that we convert it to utf-8 + detector := chardet.NewTextDetector() + result, err := detector.DetectBest([]byte(rmsg.Text)) + if err != nil { + b.Log.Infof("detection failed for rmsg.Text: %#v", rmsg.Text) + return + } + b.Log.Debugf("detected %s confidence %#v", result.Charset, result.Confidence) + mycharset = result.Charset + // if we're not sure, just pick ISO-8859-1 + if result.Confidence < 80 { + mycharset = "ISO-8859-1" + } + } + switch mycharset { + case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp": + rmsg.Text = toUTF8(b.GetString("Charset"), rmsg.Text) + default: + r, err := charset.NewReader(mycharset, strings.NewReader(rmsg.Text)) + if err != nil { + b.Log.Errorf("charset to utf-8 conversion failed: %s", err) + return + } + output, _ := ioutil.ReadAll(r) + rmsg.Text = string(output) + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", event.Params[0], b.Account) + b.Remote <- rmsg +} + +func (b *Birc) handleRunCommands() { + for _, cmd := range b.GetStringSlice("RunCommands") { + cmd = strings.ReplaceAll(cmd, "{BOTNICK}", b.Nick) + if err := b.i.Cmd.SendRaw(cmd); err != nil { + b.Log.Errorf("RunCommands %s failed: %s", cmd, err) + } + time.Sleep(time.Second) + } +} + +func (b *Birc) handleTopicWhoTime(client *girc.Client, event girc.Event) { + parts := strings.Split(event.Params[2], "!") + t, err := strconv.ParseInt(event.Params[3], 10, 64) + if err != nil { + b.Log.Errorf("Invalid time stamp: %s", event.Params[3]) + } + user := parts[0] + if len(parts) > 1 { + user += " [" + parts[1] + "]" + } + b.Log.Debugf("%s: Topic set by %s [%s]", event.Command, user, time.Unix(t, 0)) +} diff --git a/teleirc/matterbridge/bridge/irc/irc.go b/teleirc/matterbridge/bridge/irc/irc.go new file mode 100644 index 0000000..7202df5 --- /dev/null +++ b/teleirc/matterbridge/bridge/irc/irc.go @@ -0,0 +1,415 @@ +package birc + +import ( + "crypto/tls" + "errors" + "fmt" + "hash/crc32" + "io/ioutil" + "net" + "sort" + "strconv" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/lrstanley/girc" + stripmd "github.com/writeas/go-strip-markdown" + + // We need to import the 'data' package as an implicit dependency. + // See: https://godoc.org/github.com/paulrosania/go-charset/charset + _ "github.com/paulrosania/go-charset/data" +) + +type Birc struct { + i *girc.Client + Nick string + names map[string][]string + connected chan error + Local chan config.Message // local queue for flood control + FirstConnection, authDone bool + MessageDelay, MessageQueue, MessageLength int + channels map[string]bool + + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Birc{} + b.Config = cfg + b.Nick = b.GetString("Nick") + b.names = make(map[string][]string) + b.connected = make(chan error) + b.channels = make(map[string]bool) + + if b.GetInt("MessageDelay") == 0 { + b.MessageDelay = 1300 + } else { + b.MessageDelay = b.GetInt("MessageDelay") + } + if b.GetInt("MessageQueue") == 0 { + b.MessageQueue = 30 + } else { + b.MessageQueue = b.GetInt("MessageQueue") + } + if b.GetInt("MessageLength") == 0 { + b.MessageLength = 400 + } else { + b.MessageLength = b.GetInt("MessageLength") + } + b.FirstConnection = true + return b +} + +func (b *Birc) Command(msg *config.Message) string { + if msg.Text == "!users" { + b.i.Handlers.Add(girc.RPL_NAMREPLY, b.storeNames) + b.i.Handlers.Add(girc.RPL_ENDOFNAMES, b.endNames) + b.i.Cmd.SendRaw("NAMES " + msg.Channel) //nolint:errcheck + } + return "" +} + +func (b *Birc) Connect() error { + if b.GetBool("UseSASL") && b.GetString("TLSClientCertificate") != "" { + return errors.New("you can't enable SASL and TLSClientCertificate at the same time") + } + + b.Local = make(chan config.Message, b.MessageQueue+10) + b.Log.Infof("Connecting %s", b.GetString("Server")) + + i, err := b.getClient() + if err != nil { + return err + } + + if b.GetBool("UseSASL") { + i.Config.SASL = &girc.SASLPlain{ + User: b.GetString("NickServNick"), + Pass: b.GetString("NickServPassword"), + } + } + + i.Handlers.Add(girc.RPL_WELCOME, b.handleNewConnection) + i.Handlers.Add(girc.RPL_ENDOFMOTD, b.handleOtherAuth) + i.Handlers.Add(girc.ERR_NOMOTD, b.handleOtherAuth) + i.Handlers.Add(girc.ALL_EVENTS, b.handleOther) + b.i = i + + go b.doConnect() + + err = <-b.connected + if err != nil { + return fmt.Errorf("connection failed %s", err) + } + b.Log.Info("Connection succeeded") + b.FirstConnection = false + if b.GetInt("DebugLevel") == 0 { + i.Handlers.Clear(girc.ALL_EVENTS) + } + go b.doSend() + return nil +} + +func (b *Birc) Disconnect() error { + b.i.Close() + close(b.Local) + return nil +} + +func (b *Birc) JoinChannel(channel config.ChannelInfo) error { + b.channels[channel.Name] = true + // need to check if we have nickserv auth done before joining channels + for { + if b.authDone { + break + } + time.Sleep(time.Second) + } + if channel.Options.Key != "" { + b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name) + b.i.Cmd.JoinKey(channel.Name, channel.Options.Key) + } else { + b.i.Cmd.Join(channel.Name) + } + return nil +} + +func (b *Birc) Send(msg config.Message) (string, error) { + // ignore delete messages + if msg.Event == config.EventMsgDelete { + return "", nil + } + + b.Log.Debugf("=> Receiving %#v", msg) + + // we can be in between reconnects #385 + if !b.i.IsConnected() { + b.Log.Error("Not connected to server, dropping message") + return "", nil + } + + // Execute a command + if strings.HasPrefix(msg.Text, "!") { + b.Command(&msg) + } + + // convert to specified charset + if err := b.handleCharset(&msg); err != nil { + return "", err + } + + // handle files, return if we're done here + if ok := b.handleFiles(&msg); ok { + return "", nil + } + + var msgLines []string + if b.GetBool("StripMarkdown") { + msg.Text = stripmd.Strip(msg.Text) + } + + if b.GetBool("MessageSplit") { + msgLines = helper.GetSubLines(msg.Text, b.MessageLength, b.GetString("MessageClipped")) + } else { + msgLines = helper.GetSubLines(msg.Text, 0, b.GetString("MessageClipped")) + } + for i := range msgLines { + if len(b.Local) >= b.MessageQueue { + b.Log.Debugf("flooding, dropping message (queue at %d)", len(b.Local)) + return "", nil + } + + msg.Text = msgLines[i] + b.Local <- msg + } + return "", nil +} + +func (b *Birc) doConnect() { + for { + if err := b.i.Connect(); err != nil { + b.Log.Errorf("disconnect: error: %s", err) + if b.FirstConnection { + b.connected <- err + return + } + } else { + b.Log.Info("disconnect: client requested quit") + } + b.Log.Info("reconnecting in 30 seconds...") + time.Sleep(30 * time.Second) + b.i.Handlers.Clear(girc.RPL_WELCOME) + b.i.Handlers.Add(girc.RPL_WELCOME, func(client *girc.Client, event girc.Event) { + b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: "", Account: b.Account, Event: config.EventRejoinChannels} + // set our correct nick on reconnect if necessary + b.Nick = event.Source.Name + }) + } +} + +// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-" +func sanitizeNick(nick string) string { + sanitize := func(r rune) rune { + if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) { + return '-' + } + return r + } + return strings.Map(sanitize, nick) +} + +func (b *Birc) doSend() { + rate := time.Millisecond * time.Duration(b.MessageDelay) + throttle := time.NewTicker(rate) + for msg := range b.Local { + <-throttle.C + username := msg.Username + // Optional support for the proposed RELAYMSG extension, described at + // https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md + // nolint:nestif + if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) && + b.GetBool("UseRelayMsg") { + username = sanitizeNick(username) + text := msg.Text + + // Work around girc chomping leading commas on single word messages? + if strings.HasPrefix(text, ":") && !strings.ContainsRune(text, ' ') { + text = ":" + text + } + + if msg.Event == config.EventUserAction { + b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck + } else { + b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username) + b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck + } + } else { + if b.GetBool("Colornicks") { + checksum := crc32.ChecksumIEEE([]byte(msg.Username)) + colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes + username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username) + } + switch msg.Event { + case config.EventUserAction: + b.i.Cmd.Action(msg.Channel, username+msg.Text) + case config.EventNoticeIRC: + b.Log.Debugf("Sending notice to channel %s", msg.Channel) + b.i.Cmd.Notice(msg.Channel, username+msg.Text) + default: + b.Log.Debugf("Sending to channel %s", msg.Channel) + b.i.Cmd.Message(msg.Channel, username+msg.Text) + } + } + } +} + +// validateInput validates the server/port/nick configuration. Returns a *girc.Client if successful +func (b *Birc) getClient() (*girc.Client, error) { + server, portstr, err := net.SplitHostPort(b.GetString("Server")) + if err != nil { + return nil, err + } + port, err := strconv.Atoi(portstr) + if err != nil { + return nil, err + } + user := b.GetString("UserName") + if user == "" { + user = b.GetString("Nick") + } + // fix strict user handling of girc + for !girc.IsValidUser(user) { + if len(user) == 1 || len(user) == 0 { + user = "matterbridge" + break + } + user = user[1:] + } + realName := b.GetString("RealName") + if realName == "" { + realName = b.GetString("Nick") + } + + debug := ioutil.Discard + if b.GetInt("DebugLevel") == 2 { + debug = b.Log.Writer() + } + + pingDelay, err := time.ParseDuration(b.GetString("pingdelay")) + if err != nil || pingDelay == 0 { + pingDelay = time.Minute + } + + b.Log.Debugf("setting pingdelay to %s", pingDelay) + + tlsConfig, err := b.getTLSConfig() + if err != nil { + return nil, err + } + + i := girc.New(girc.Config{ + Server: server, + ServerPass: b.GetString("Password"), + Port: port, + Nick: b.GetString("Nick"), + User: user, + Name: realName, + SSL: b.GetBool("UseTLS"), + Bind: b.GetString("Bind"), + TLSConfig: tlsConfig, + PingDelay: pingDelay, + // skip gIRC internal rate limiting, since we have our own throttling + AllowFlood: true, + Debug: debug, + SupportedCaps: map[string][]string{"overdrivenetworks.com/relaymsg": nil, "draft/relaymsg": nil}, + }) + return i, nil +} + +func (b *Birc) endNames(client *girc.Client, event girc.Event) { + channel := event.Params[1] + sort.Strings(b.names[channel]) + maxNamesPerPost := (300 / b.nicksPerRow()) * b.nicksPerRow() + for len(b.names[channel]) > maxNamesPerPost { + b.Remote <- config.Message{ + Username: b.Nick, Text: b.formatnicks(b.names[channel][0:maxNamesPerPost]), + Channel: channel, Account: b.Account, + } + b.names[channel] = b.names[channel][maxNamesPerPost:] + } + b.Remote <- config.Message{ + Username: b.Nick, Text: b.formatnicks(b.names[channel]), + Channel: channel, Account: b.Account, + } + b.names[channel] = nil + b.i.Handlers.Clear(girc.RPL_NAMREPLY) + b.i.Handlers.Clear(girc.RPL_ENDOFNAMES) +} + +func (b *Birc) skipPrivMsg(event girc.Event) bool { + // Our nick can be changed + b.Nick = b.i.GetNick() + + // freenode doesn't send 001 as first reply + if event.Command == "NOTICE" && len(event.Params) != 2 { + return true + } + // don't forward queries to the bot + if event.Params[0] == b.Nick { + return true + } + // don't forward message from ourself + if event.Source != nil { + if event.Source.Name == b.Nick { + return true + } + } + // don't forward messages we sent via RELAYMSG + if relayedNick, ok := event.Tags.Get("draft/relaymsg"); ok && relayedNick == b.Nick { + return true + } + // This is the old name of the cap sent in spoofed messages; I've kept this in + // for compatibility reasons + if relayedNick, ok := event.Tags.Get("relaymsg"); ok && relayedNick == b.Nick { + return true + } + return false +} + +func (b *Birc) nicksPerRow() int { + return 4 +} + +func (b *Birc) storeNames(client *girc.Client, event girc.Event) { + channel := event.Params[2] + b.names[channel] = append( + b.names[channel], + strings.Split(strings.TrimSpace(event.Last()), " ")...) +} + +func (b *Birc) formatnicks(nicks []string) string { + return strings.Join(nicks, ", ") + " currently on IRC" +} + +func (b *Birc) getTLSConfig() (*tls.Config, error) { + server, _, _ := net.SplitHostPort(b.GetString("server")) + + tlsConfig := &tls.Config{ + InsecureSkipVerify: b.GetBool("skiptlsverify"), //nolint:gosec + ServerName: server, + } + + if filename := b.GetString("TLSClientCertificate"); filename != "" { + cert, err := tls.LoadX509KeyPair(filename, filename) + if err != nil { + return nil, err + } + + tlsConfig.Certificates = []tls.Certificate{cert} + } + + return tlsConfig, nil +} diff --git a/teleirc/matterbridge/bridge/keybase/handlers.go b/teleirc/matterbridge/bridge/keybase/handlers.go new file mode 100644 index 0000000..a29208d --- /dev/null +++ b/teleirc/matterbridge/bridge/keybase/handlers.go @@ -0,0 +1,59 @@ +package bkeybase + +import ( + "strconv" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1" +) + +func (b *Bkeybase) handleKeybase() { + sub, err := b.kbc.ListenForNewTextMessages() + if err != nil { + b.Log.Errorf("Error listening: %s", err.Error()) + } + + go func() { + for { + msg, err := sub.Read() + if err != nil { + b.Log.Errorf("failed to read message: %s", err.Error()) + } + + if msg.Message.Content.TypeName != "text" { + continue + } + + if msg.Message.Sender.Username == b.kbc.GetUsername() { + continue + } + + b.handleMessage(msg.Message) + + } + }() +} + +func (b *Bkeybase) handleMessage(msg chat1.MsgSummary) { + b.Log.Debugf("== Receiving event: %#v", msg) + if msg.Channel.TopicName != b.channel || msg.Channel.Name != b.team { + return + } + + if msg.Sender.Username != b.kbc.GetUsername() { + + // TODO download avatar + + // Create our message + rmsg := config.Message{Username: msg.Sender.Username, Text: msg.Content.Text.Body, UserID: string(msg.Sender.Uid), Channel: msg.Channel.TopicName, ID: strconv.Itoa(int(msg.Id)), Account: b.Account} + + // Text must be a string + if msg.Content.TypeName != "text" { + b.Log.Errorf("message is not text") + return + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", msg.Sender.Username, msg.Channel.Name) + b.Remote <- rmsg + } +} diff --git a/teleirc/matterbridge/bridge/keybase/keybase.go b/teleirc/matterbridge/bridge/keybase/keybase.go new file mode 100644 index 0000000..d41e95f --- /dev/null +++ b/teleirc/matterbridge/bridge/keybase/keybase.go @@ -0,0 +1,106 @@ +package bkeybase + +import ( + "io/ioutil" + "os" + "path/filepath" + "strconv" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/keybase/go-keybase-chat-bot/kbchat" +) + +// Bkeybase bridge structure +type Bkeybase struct { + kbc *kbchat.API + user string + channel string + team string + *bridge.Config +} + +// New initializes Bkeybase object and sets team +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bkeybase{Config: cfg} + b.team = b.Config.GetString("Team") + return b +} + +// Connect starts keybase API and listener loop +func (b *Bkeybase) Connect() error { + var err error + b.Log.Infof("Connecting %s", b.GetString("Team")) + + // use default keybase location (`keybase`) + b.kbc, err = kbchat.Start(kbchat.RunOptions{}) + if err != nil { + return err + } + b.user = b.kbc.GetUsername() + b.Log.Info("Connection succeeded") + go b.handleKeybase() + return nil +} + +// Disconnect doesn't do anything for now +func (b *Bkeybase) Disconnect() error { + return nil +} + +// JoinChannel sets channel name in struct +func (b *Bkeybase) JoinChannel(channel config.ChannelInfo) error { + if _, err := b.kbc.JoinChannel(b.team, channel.Name); err != nil { + return err + } + b.channel = channel.Name + return nil +} + +// Send receives bridge messages and sends them to Keybase chat room +func (b *Bkeybase) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + // Handle /me events + if msg.Event == config.EventUserAction { + msg.Text = "_" + msg.Text + "_" + } + + // Delete message if we have an ID + // Delete message not supported by keybase go library yet + + // Edit message if we have an ID + // kbchat lib does not support message editing yet + + if len(msg.Extra["file"]) > 0 { + // Upload a file + dir, err := ioutil.TempDir("", "matterbridge") + if err != nil { + return "", err + } + defer os.RemoveAll(dir) + + for _, f := range msg.Extra["file"] { + fname := f.(config.FileInfo).Name + fdata := *f.(config.FileInfo).Data + fcaption := f.(config.FileInfo).Comment + fpath := filepath.Join(dir, fname) + + if err = ioutil.WriteFile(fpath, fdata, 0600); err != nil { + return "", err + } + + _, _ = b.kbc.SendAttachmentByTeam(b.team, &b.channel, fpath, fcaption) + } + + return "", nil + } + + // Send regular message + text := msg.Username + msg.Text + resp, err := b.kbc.SendMessageByTeamName(b.team, &b.channel, text) + if err != nil { + return "", err + } + return strconv.Itoa(int(*resp.Result.MessageID)), err +} diff --git a/teleirc/matterbridge/bridge/matrix/helpers.go b/teleirc/matterbridge/bridge/matrix/helpers.go new file mode 100644 index 0000000..5a91f74 --- /dev/null +++ b/teleirc/matterbridge/bridge/matrix/helpers.go @@ -0,0 +1,215 @@ +package bmatrix + +import ( + "encoding/json" + "errors" + "fmt" + "html" + "strings" + "time" + + matrix "github.com/matterbridge/gomatrix" +) + +func newMatrixUsername(username string) *matrixUsername { + mUsername := new(matrixUsername) + + // check if we have a </tag>. if we have, we don't escape HTML. #696 + if htmlTag.MatchString(username) { + mUsername.formatted = username + // remove the HTML formatting for beautiful push messages #1188 + mUsername.plain = htmlReplacementTag.ReplaceAllString(username, "") + } else { + mUsername.formatted = html.EscapeString(username) + mUsername.plain = username + } + + return mUsername +} + +// getRoomID retrieves a matching room ID from the channel name. +func (b *Bmatrix) getRoomID(channel string) string { + b.RLock() + defer b.RUnlock() + for ID, name := range b.RoomMap { + if name == channel { + return ID + } + } + + return "" +} + +// interface2Struct marshals and immediately unmarshals an interface. +// Useful for converting map[string]interface{} to a struct. +func interface2Struct(in interface{}, out interface{}) error { + jsonObj, err := json.Marshal(in) + if err != nil { + return err //nolint:wrapcheck + } + + return json.Unmarshal(jsonObj, out) +} + +// getDisplayName retrieves the displayName for mxid, querying the homeserver if the mxid is not in the cache. +func (b *Bmatrix) getDisplayName(mxid string) string { + if b.GetBool("UseUserName") { + return mxid[1:] + } + + b.RLock() + if val, present := b.NicknameMap[mxid]; present { + b.RUnlock() + + return val.displayName + } + b.RUnlock() + + displayName, err := b.mc.GetDisplayName(mxid) + var httpError *matrix.HTTPError + if errors.As(err, &httpError) { + b.Log.Warnf("Couldn't retrieve the display name for %s", mxid) + } + + if err != nil { + return b.cacheDisplayName(mxid, mxid[1:]) + } + + return b.cacheDisplayName(mxid, displayName.DisplayName) +} + +// cacheDisplayName stores the mapping between a mxid and a display name, to be reused later without performing a query to the homserver. +// Note that old entries are cleaned when this function is called. +func (b *Bmatrix) cacheDisplayName(mxid string, displayName string) string { + now := time.Now() + + // scan to delete old entries, to stop memory usage from becoming too high with old entries. + // In addition, we also detect if another user have the same username, and if so, we append their mxids to their usernames to differentiate them. + toDelete := []string{} + conflict := false + + b.Lock() + for mxid, v := range b.NicknameMap { + // to prevent username reuse across matrix servers - or even on the same server, append + // the mxid to the username when there is a conflict + if v.displayName == displayName { + conflict = true + // TODO: it would be nice to be able to rename previous messages from this user. + // The current behavior is that only users with clashing usernames and *that have spoken since the bridge last started* will get their mxids shown, and I don't know if that's the expected behavior. + v.displayName = fmt.Sprintf("%s (%s)", displayName, mxid) + b.NicknameMap[mxid] = v + } + + if now.Sub(v.lastUpdated) > 10*time.Minute { + toDelete = append(toDelete, mxid) + } + } + + if conflict { + displayName = fmt.Sprintf("%s (%s)", displayName, mxid) + } + + for _, v := range toDelete { + delete(b.NicknameMap, v) + } + + b.NicknameMap[mxid] = NicknameCacheEntry{ + displayName: displayName, + lastUpdated: now, + } + b.Unlock() + + return displayName +} + +// handleError converts errors into httpError. +//nolint:exhaustivestruct +func handleError(err error) *httpError { + var mErr matrix.HTTPError + if !errors.As(err, &mErr) { + return &httpError{ + Err: "not a HTTPError", + } + } + + var httpErr httpError + + if err := json.Unmarshal(mErr.Contents, &httpErr); err != nil { + return &httpError{ + Err: "unmarshal failed", + } + } + + return &httpErr +} + +func (b *Bmatrix) containsAttachment(content map[string]interface{}) bool { + // Skip empty messages + if content["msgtype"] == nil { + return false + } + + // Only allow image,video or file msgtypes + if !(content["msgtype"].(string) == "m.image" || + content["msgtype"].(string) == "m.video" || + content["msgtype"].(string) == "m.file") { + return false + } + + return true +} + +// getAvatarURL returns the avatar URL of the specified sender. +func (b *Bmatrix) getAvatarURL(sender string) string { + urlPath := b.mc.BuildURL("profile", sender, "avatar_url") + + s := struct { + AvatarURL string `json:"avatar_url"` + }{} + + err := b.mc.MakeRequest("GET", urlPath, nil, &s) + if err != nil { + b.Log.Errorf("getAvatarURL failed: %s", err) + + return "" + } + + url := strings.ReplaceAll(s.AvatarURL, "mxc://", b.GetString("Server")+"/_matrix/media/r0/thumbnail/") + if url != "" { + url += "?width=37&height=37&method=crop" + } + + return url +} + +// handleRatelimit handles the ratelimit errors and return if we're ratelimited and the amount of time to sleep +func (b *Bmatrix) handleRatelimit(err error) (time.Duration, bool) { + httpErr := handleError(err) + if httpErr.Errcode != "M_LIMIT_EXCEEDED" { + return 0, false + } + + b.Log.Debugf("ratelimited: %s", httpErr.Err) + b.Log.Infof("getting ratelimited by matrix, sleeping approx %d seconds before retrying", httpErr.RetryAfterMs/1000) + + return time.Duration(httpErr.RetryAfterMs) * time.Millisecond, true +} + +// retry function will check if we're ratelimited and retries again when backoff time expired +// returns original error if not 429 ratelimit +func (b *Bmatrix) retry(f func() error) error { + b.rateMutex.Lock() + defer b.rateMutex.Unlock() + + for { + if err := f(); err != nil { + if backoff, ok := b.handleRatelimit(err); ok { + time.Sleep(backoff) + } else { + return err + } + } else { + return nil + } + } +} diff --git a/teleirc/matterbridge/bridge/matrix/matrix.go b/teleirc/matterbridge/bridge/matrix/matrix.go new file mode 100644 index 0000000..49fc33b --- /dev/null +++ b/teleirc/matterbridge/bridge/matrix/matrix.go @@ -0,0 +1,718 @@ +package bmatrix + +import ( + "bytes" + "fmt" + "mime" + "regexp" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + matrix "github.com/matterbridge/gomatrix" +) + +var ( + htmlTag = regexp.MustCompile("</.*?>") + htmlReplacementTag = regexp.MustCompile("<[^>]*>") +) + +type NicknameCacheEntry struct { + displayName string + lastUpdated time.Time +} + +type Bmatrix struct { + mc *matrix.Client + UserID string + NicknameMap map[string]NicknameCacheEntry + RoomMap map[string]string + rateMutex sync.RWMutex + sync.RWMutex + *bridge.Config +} + +type httpError struct { + Errcode string `json:"errcode"` + Err string `json:"error"` + RetryAfterMs int `json:"retry_after_ms"` +} + +type matrixUsername struct { + plain string + formatted string +} + +// SubTextMessage represents the new content of the message in edit messages. +type SubTextMessage struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` + FormattedBody string `json:"formatted_body,omitempty"` + Format string `json:"format,omitempty"` +} + +// MessageRelation explains how the current message relates to a previous message. +// Notably used for message edits. +type MessageRelation struct { + EventID string `json:"event_id"` + Type string `json:"rel_type"` +} + +type EditedMessage struct { + NewContent SubTextMessage `json:"m.new_content"` + RelatedTo MessageRelation `json:"m.relates_to"` + matrix.TextMessage +} + +type InReplyToRelationContent struct { + EventID string `json:"event_id"` +} + +type InReplyToRelation struct { + InReplyTo InReplyToRelationContent `json:"m.in_reply_to"` +} + +type ReplyMessage struct { + RelatedTo InReplyToRelation `json:"m.relates_to"` + matrix.TextMessage +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bmatrix{Config: cfg} + b.RoomMap = make(map[string]string) + b.NicknameMap = make(map[string]NicknameCacheEntry) + return b +} + +func (b *Bmatrix) Connect() error { + var err error + b.Log.Infof("Connecting %s", b.GetString("Server")) + if b.GetString("MxID") != "" && b.GetString("Token") != "" { + b.mc, err = matrix.NewClient( + b.GetString("Server"), b.GetString("MxID"), b.GetString("Token"), + ) + if err != nil { + return err + } + b.UserID = b.GetString("MxID") + b.Log.Info("Using existing Matrix credentials") + } else { + b.mc, err = matrix.NewClient(b.GetString("Server"), "", "") + if err != nil { + return err + } + resp, err := b.mc.Login(&matrix.ReqLogin{ + Type: "m.login.password", + User: b.GetString("Login"), + Password: b.GetString("Password"), + Identifier: matrix.NewUserIdentifier(b.GetString("Login")), + }) + if err != nil { + return err + } + b.mc.SetCredentials(resp.UserID, resp.AccessToken) + b.UserID = resp.UserID + b.Log.Info("Connection succeeded") + } + go b.handlematrix() + return nil +} + +func (b *Bmatrix) Disconnect() error { + return nil +} + +func (b *Bmatrix) JoinChannel(channel config.ChannelInfo) error { + return b.retry(func() error { + resp, err := b.mc.JoinRoom(channel.Name, "", nil) + if err != nil { + return err + } + + b.Lock() + b.RoomMap[resp.RoomID] = channel.Name + b.Unlock() + + return nil + }) +} + +func (b *Bmatrix) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + channel := b.getRoomID(msg.Channel) + b.Log.Debugf("Channel %s maps to channel id %s", msg.Channel, channel) + + username := newMatrixUsername(msg.Username) + + body := username.plain + msg.Text + formattedBody := username.formatted + helper.ParseMarkdown(msg.Text) + + if b.GetBool("SpoofUsername") { + // https://spec.matrix.org/v1.3/client-server-api/#mroommember + type stateMember struct { + AvatarURL string `json:"avatar_url,omitempty"` + DisplayName string `json:"displayname"` + Membership string `json:"membership"` + } + + // TODO: reset username afterwards with DisplayName: null ? + m := stateMember{ + AvatarURL: "", + DisplayName: username.plain, + Membership: "join", + } + + _, err := b.mc.SendStateEvent(channel, "m.room.member", b.UserID, m) + if err == nil { + body = msg.Text + formattedBody = helper.ParseMarkdown(msg.Text) + } + } + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + m := matrix.TextMessage{ + MsgType: "m.emote", + Body: body, + FormattedBody: formattedBody, + Format: "org.matrix.custom.html", + } + + if b.GetBool("HTMLDisable") { + m.Format = "" + m.FormattedBody = "" + } + + msgID := "" + + err := b.retry(func() error { + resp, err := b.mc.SendMessageEvent(channel, "m.room.message", m) + if err != nil { + return err + } + + msgID = resp.EventID + + return err + }) + + return msgID, err + } + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + + msgID := "" + + err := b.retry(func() error { + resp, err := b.mc.RedactEvent(channel, msg.ID, &matrix.ReqRedact{}) + if err != nil { + return err + } + + msgID = resp.EventID + + return err + }) + + return msgID, err + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + rmsg := rmsg + + err := b.retry(func() error { + _, err := b.mc.SendText(channel, rmsg.Username+rmsg.Text) + + return err + }) + if err != nil { + b.Log.Errorf("sendText failed: %s", err) + } + } + // check if we have files to upload (from slack, telegram or mattermost) + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFiles(&msg, channel) + } + } + + // Edit message if we have an ID + if msg.ID != "" { + rmsg := EditedMessage{ + TextMessage: matrix.TextMessage{ + Body: body, + MsgType: "m.text", + Format: "org.matrix.custom.html", + FormattedBody: formattedBody, + }, + } + + rmsg.NewContent = SubTextMessage{ + Body: rmsg.TextMessage.Body, + FormattedBody: rmsg.TextMessage.FormattedBody, + Format: rmsg.TextMessage.Format, + MsgType: "m.text", + } + + if b.GetBool("HTMLDisable") { + rmsg.TextMessage.Format = "" + rmsg.TextMessage.FormattedBody = "" + rmsg.NewContent.Format = "" + rmsg.NewContent.FormattedBody = "" + } + + rmsg.RelatedTo = MessageRelation{ + EventID: msg.ID, + Type: "m.replace", + } + + err := b.retry(func() error { + _, err := b.mc.SendMessageEvent(channel, "m.room.message", rmsg) + + return err + }) + if err != nil { + return "", err + } + + return msg.ID, nil + } + + // Use notices to send join/leave events + if msg.Event == config.EventJoinLeave { + m := matrix.TextMessage{ + MsgType: "m.notice", + Body: body, + FormattedBody: formattedBody, + Format: "org.matrix.custom.html", + } + + if b.GetBool("HTMLDisable") { + m.Format = "" + m.FormattedBody = "" + } + + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err + } + + if msg.ParentValid() { + m := ReplyMessage{ + TextMessage: matrix.TextMessage{ + MsgType: "m.text", + Body: body, + FormattedBody: formattedBody, + Format: "org.matrix.custom.html", + }, + } + + if b.GetBool("HTMLDisable") { + m.TextMessage.Format = "" + m.TextMessage.FormattedBody = "" + } + + m.RelatedTo = InReplyToRelation{ + InReplyTo: InReplyToRelationContent{ + EventID: msg.ParentID, + }, + } + + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err + } + + if b.GetBool("HTMLDisable") { + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendText(channel, body) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err + } + + // Post normal message with HTML support (eg riot.im) + var ( + resp *matrix.RespSendEvent + err error + ) + + err = b.retry(func() error { + resp, err = b.mc.SendFormattedText(channel, body, formattedBody) + + return err + }) + if err != nil { + return "", err + } + + return resp.EventID, err +} + +func (b *Bmatrix) handlematrix() { + syncer := b.mc.Syncer.(*matrix.DefaultSyncer) + syncer.OnEventType("m.room.redaction", b.handleEvent) + syncer.OnEventType("m.room.message", b.handleEvent) + syncer.OnEventType("m.room.member", b.handleMemberChange) + go func() { + for { + if b == nil { + return + } + if err := b.mc.Sync(); err != nil { + b.Log.Println("Sync() returned ", err) + } + } + }() +} + +func (b *Bmatrix) handleEdit(ev *matrix.Event, rmsg config.Message) bool { + relationInterface, present := ev.Content["m.relates_to"] + newContentInterface, present2 := ev.Content["m.new_content"] + if !(present && present2) { + return false + } + + var relation MessageRelation + if err := interface2Struct(relationInterface, &relation); err != nil { + b.Log.Warnf("Couldn't parse 'm.relates_to' object with value %#v", relationInterface) + return false + } + + var newContent SubTextMessage + if err := interface2Struct(newContentInterface, &newContent); err != nil { + b.Log.Warnf("Couldn't parse 'm.new_content' object with value %#v", newContentInterface) + return false + } + + if relation.Type != "m.replace" { + return false + } + + rmsg.ID = relation.EventID + rmsg.Text = newContent.Body + b.Remote <- rmsg + + return true +} + +func (b *Bmatrix) handleReply(ev *matrix.Event, rmsg config.Message) bool { + relationInterface, present := ev.Content["m.relates_to"] + if !present { + return false + } + + var relation InReplyToRelation + if err := interface2Struct(relationInterface, &relation); err != nil { + // probably fine + return false + } + + body := rmsg.Text + + if !b.GetBool("keepquotedreply") { + for strings.HasPrefix(body, "> ") { + lineIdx := strings.IndexRune(body, '\n') + if lineIdx == -1 { + body = "" + } else { + body = body[(lineIdx + 1):] + } + } + } + + rmsg.Text = body + rmsg.ParentID = relation.InReplyTo.EventID + b.Remote <- rmsg + + return true +} + +func (b *Bmatrix) handleMemberChange(ev *matrix.Event) { + // Update the displayname on join messages, according to https://matrix.org/docs/spec/client_server/r0.6.1#events-on-change-of-profile-information + if ev.Content["membership"] == "join" { + if dn, ok := ev.Content["displayname"].(string); ok { + b.cacheDisplayName(ev.Sender, dn) + } + } +} + +func (b *Bmatrix) handleEvent(ev *matrix.Event) { + b.Log.Debugf("== Receiving event: %#v", ev) + if ev.Sender != b.UserID { + b.RLock() + channel, ok := b.RoomMap[ev.RoomID] + b.RUnlock() + if !ok { + b.Log.Debugf("Unknown room %s", ev.RoomID) + return + } + + // Create our message + rmsg := config.Message{ + Username: b.getDisplayName(ev.Sender), + Channel: channel, + Account: b.Account, + UserID: ev.Sender, + ID: ev.ID, + Avatar: b.getAvatarURL(ev.Sender), + } + + // Remove homeserver suffix if configured + if b.GetBool("NoHomeServerSuffix") { + re := regexp.MustCompile("(.*?):.*") + rmsg.Username = re.ReplaceAllString(rmsg.Username, `$1`) + } + + // Delete event + if ev.Type == "m.room.redaction" { + rmsg.Event = config.EventMsgDelete + rmsg.ID = ev.Redacts + rmsg.Text = config.EventMsgDelete + b.Remote <- rmsg + return + } + + // Text must be a string + if rmsg.Text, ok = ev.Content["body"].(string); !ok { + b.Log.Errorf("Content[body] is not a string: %T\n%#v", + ev.Content["body"], ev.Content) + return + } + + // Do we have a /me action + if ev.Content["msgtype"].(string) == "m.emote" { + rmsg.Event = config.EventUserAction + } + + // Is it an edit? + if b.handleEdit(ev, rmsg) { + return + } + + // Is it a reply? + if b.handleReply(ev, rmsg) { + return + } + + // Do we have attachments + if b.containsAttachment(ev.Content) { + err := b.handleDownloadFile(&rmsg, ev.Content) + if err != nil { + b.Log.Errorf("download failed: %#v", err) + } + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Sender, b.Account) + b.Remote <- rmsg + + // not crucial, so no ratelimit check here + if err := b.mc.MarkRead(ev.RoomID, ev.ID); err != nil { + b.Log.Errorf("couldn't mark message as read %s", err.Error()) + } + } +} + +// handleDownloadFile handles file download +func (b *Bmatrix) handleDownloadFile(rmsg *config.Message, content map[string]interface{}) error { + var ( + ok bool + url, name, msgtype, mtype string + info map[string]interface{} + size float64 + ) + + rmsg.Extra = make(map[string][]interface{}) + if url, ok = content["url"].(string); !ok { + return fmt.Errorf("url isn't a %T", url) + } + url = strings.Replace(url, "mxc://", b.GetString("Server")+"/_matrix/media/v1/download/", -1) + + if info, ok = content["info"].(map[string]interface{}); !ok { + return fmt.Errorf("info isn't a %T", info) + } + if size, ok = info["size"].(float64); !ok { + return fmt.Errorf("size isn't a %T", size) + } + if name, ok = content["body"].(string); !ok { + return fmt.Errorf("name isn't a %T", name) + } + if msgtype, ok = content["msgtype"].(string); !ok { + return fmt.Errorf("msgtype isn't a %T", msgtype) + } + if mtype, ok = info["mimetype"].(string); !ok { + return fmt.Errorf("mtype isn't a %T", mtype) + } + + // check if we have an image uploaded without extension + if !strings.Contains(name, ".") { + if msgtype == "m.image" { + mext, _ := mime.ExtensionsByType(mtype) + if len(mext) > 0 { + name += mext[0] + } + } else { + // just a default .png extension if we don't have mime info + name += ".png" + } + } + + // check if the size is ok + err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General) + if err != nil { + return err + } + // actually download the file + data, err := helper.DownloadFile(url) + if err != nil { + return fmt.Errorf("download %s failed %#v", url, err) + } + // add the downloaded data to the message + helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General) + return nil +} + +// handleUploadFiles handles native upload of files. +func (b *Bmatrix) handleUploadFiles(msg *config.Message, channel string) (string, error) { + for _, f := range msg.Extra["file"] { + if fi, ok := f.(config.FileInfo); ok { + b.handleUploadFile(msg, channel, &fi) + } + } + return "", nil +} + +// handleUploadFile handles native upload of a file. +func (b *Bmatrix) handleUploadFile(msg *config.Message, channel string, fi *config.FileInfo) { + username := newMatrixUsername(msg.Username) + content := bytes.NewReader(*fi.Data) + sp := strings.Split(fi.Name, ".") + mtype := mime.TypeByExtension("." + sp[len(sp)-1]) + // image and video uploads send no username, we have to do this ourself here #715 + err := b.retry(func() error { + _, err := b.mc.SendFormattedText(channel, username.plain+fi.Comment, username.formatted+fi.Comment) + + return err + }) + if err != nil { + b.Log.Errorf("file comment failed: %#v", err) + } + + b.Log.Debugf("uploading file: %s %s", fi.Name, mtype) + + var res *matrix.RespMediaUpload + + err = b.retry(func() error { + res, err = b.mc.UploadToContentRepo(content, mtype, int64(len(*fi.Data))) + + return err + }) + + if err != nil { + b.Log.Errorf("file upload failed: %#v", err) + return + } + + switch { + case strings.Contains(mtype, "video"): + b.Log.Debugf("sendVideo %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendVideo(channel, fi.Name, res.ContentURI) + + return err + }) + if err != nil { + b.Log.Errorf("sendVideo failed: %#v", err) + } + case strings.Contains(mtype, "image"): + b.Log.Debugf("sendImage %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendImage(channel, fi.Name, res.ContentURI) + + return err + }) + if err != nil { + b.Log.Errorf("sendImage failed: %#v", err) + } + case strings.Contains(mtype, "audio"): + b.Log.Debugf("sendAudio %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.AudioMessage{ + MsgType: "m.audio", + Body: fi.Name, + URL: res.ContentURI, + Info: matrix.AudioInfo{ + Mimetype: mtype, + Size: uint(len(*fi.Data)), + }, + }) + + return err + }) + if err != nil { + b.Log.Errorf("sendAudio failed: %#v", err) + } + default: + b.Log.Debugf("sendFile %s", res.ContentURI) + err = b.retry(func() error { + _, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.FileMessage{ + MsgType: "m.file", + Body: fi.Name, + URL: res.ContentURI, + Info: matrix.FileInfo{ + Mimetype: mtype, + Size: uint(len(*fi.Data)), + }, + }) + + return err + }) + if err != nil { + b.Log.Errorf("sendFile failed: %#v", err) + } + } + b.Log.Debugf("result: %#v", res) +} diff --git a/teleirc/matterbridge/bridge/matrix/matrix_test.go b/teleirc/matterbridge/bridge/matrix/matrix_test.go new file mode 100644 index 0000000..846d9a4 --- /dev/null +++ b/teleirc/matterbridge/bridge/matrix/matrix_test.go @@ -0,0 +1,28 @@ +package bmatrix + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlainUsername(t *testing.T) { + uut := newMatrixUsername("MyUser") + + assert.Equal(t, "MyUser", uut.formatted) + assert.Equal(t, "MyUser", uut.plain) +} + +func TestHTMLUsername(t *testing.T) { + uut := newMatrixUsername("<b>MyUser</b>") + + assert.Equal(t, "<b>MyUser</b>", uut.formatted) + assert.Equal(t, "MyUser", uut.plain) +} + +func TestFancyUsername(t *testing.T) { + uut := newMatrixUsername("<MyUser>") + + assert.Equal(t, "<MyUser>", uut.formatted) + assert.Equal(t, "<MyUser>", uut.plain) +} diff --git a/teleirc/matterbridge/bridge/mattermost/handlers.go b/teleirc/matterbridge/bridge/mattermost/handlers.go new file mode 100644 index 0000000..8c4ea36 --- /dev/null +++ b/teleirc/matterbridge/bridge/mattermost/handlers.go @@ -0,0 +1,215 @@ +package bmattermost + +import ( + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/matterbridge/matterclient" + "github.com/mattermost/mattermost-server/v6/model" +) + +// handleDownloadAvatar downloads the avatar of userid from channel +// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful. +// logs an error message if it fails +func (b *Bmattermost) handleDownloadAvatar(userid string, channel string) { + rmsg := config.Message{ + Username: "system", + Text: "avatar", + Channel: channel, + Account: b.Account, + UserID: userid, + Event: config.EventAvatarDownload, + Extra: make(map[string][]interface{}), + } + if _, ok := b.avatarMap[userid]; !ok { + var ( + data []byte + err error + ) + data, _, err = b.mc.Client.GetProfileImage(userid, "") + if err != nil { + b.Log.Errorf("ProfileImage download failed for %#v %s", userid, err) + return + } + + err = helper.HandleDownloadSize(b.Log, &rmsg, userid+".png", int64(len(data)), b.General) + if err != nil { + b.Log.Error(err) + return + } + helper.HandleDownloadData(b.Log, &rmsg, userid+".png", rmsg.Text, "", &data, b.General) + b.Remote <- rmsg + } +} + +//nolint:wrapcheck +func (b *Bmattermost) handleDownloadFile(rmsg *config.Message, id string) error { + url, _, _ := b.mc.Client.GetFileLink(id) + finfo, _, err := b.mc.Client.GetFileInfo(id) + if err != nil { + return err + } + err = helper.HandleDownloadSize(b.Log, rmsg, finfo.Name, finfo.Size, b.General) + if err != nil { + return err + } + data, _, err := b.mc.Client.DownloadFile(id, true) + if err != nil { + return err + } + helper.HandleDownloadData(b.Log, rmsg, finfo.Name, rmsg.Text, url, &data, b.General) + return nil +} + +func (b *Bmattermost) handleMatter() { + messages := make(chan *config.Message) + if b.GetString("WebhookBindAddress") != "" { + b.Log.Debugf("Choosing webhooks based receiving") + go b.handleMatterHook(messages) + } else { + if b.GetString("Token") != "" { + b.Log.Debugf("Choosing token based receiving") + } else { + b.Log.Debugf("Choosing login/password based receiving") + } + // if for some reason we only want to sent stuff to mattermost but not receive, return + if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") != "" && b.GetString("Token") == "" && b.GetString("Login") == "" { + b.Log.Debugf("No WebhookBindAddress specified, only WebhookURL. You will not receive messages from mattermost, only sending is possible.") + } + go b.handleMatterClient(messages) + } + var ok bool + for message := range messages { + message.Avatar = helper.GetAvatar(b.avatarMap, message.UserID, b.General) + message.Account = b.Account + message.Text, ok = b.replaceAction(message.Text) + if ok { + message.Event = config.EventUserAction + } + b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account) + b.Log.Debugf("<= Message is %#v", message) + b.Remote <- *message + } +} + +//nolint:cyclop +func (b *Bmattermost) handleMatterClient(messages chan *config.Message) { + for message := range b.mc.MessageChan { + b.Log.Debugf("%#v %#v", message.Raw.GetData(), message.Raw.EventType()) + + if b.skipMessage(message) { + b.Log.Debugf("Skipped message: %#v", message) + continue + } + + channelName := b.getChannelName(message.Post.ChannelId) + if channelName == "" { + channelName = message.Channel + } + + // only download avatars if we have a place to upload them (configured mediaserver) + if b.General.MediaServerUpload != "" || b.General.MediaDownloadPath != "" { + b.handleDownloadAvatar(message.UserID, channelName) + } + + b.Log.Debugf("== Receiving event %#v", message) + + rmsg := &config.Message{ + Username: message.Username, + UserID: message.UserID, + Channel: channelName, + Text: message.Text, + ID: message.Post.Id, + ParentID: message.Post.RootId, // ParentID is obsolete with mattermost + Extra: make(map[string][]interface{}), + } + + // handle mattermost post properties (override username and attachments) + b.handleProps(rmsg, message) + + // create a text for bridges that don't support native editing + if message.Raw.EventType() == model.WebsocketEventPostEdited && !b.GetBool("EditDisable") { + rmsg.Text = message.Text + b.GetString("EditSuffix") + } + + if message.Raw.EventType() == model.WebsocketEventPostDeleted { + rmsg.Event = config.EventMsgDelete + } + + for _, id := range message.Post.FileIds { + err := b.handleDownloadFile(rmsg, id) + if err != nil { + b.Log.Errorf("download failed: %s", err) + } + } + + // Use nickname instead of username if defined + if !b.GetBool("useusername") { + if nick := b.mc.GetNickName(rmsg.UserID); nick != "" { + rmsg.Username = nick + } + } + + messages <- rmsg + } +} + +func (b *Bmattermost) handleMatterHook(messages chan *config.Message) { + for { + message := b.mh.Receive() + b.Log.Debugf("Receiving from matterhook %#v", message) + + messages <- &config.Message{ + UserID: message.UserID, + Username: message.UserName, + Text: message.Text, + Channel: message.ChannelName, + } + } +} + +func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) { + var err error + var res, id string + channelID := b.getChannelID(msg.Channel) + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + id, err = b.mc.UploadFile(*fi.Data, channelID, fi.Name) + if err != nil { + return "", err + } + msg.Text = fi.Comment + if b.GetBool("PrefixMessagesWithNick") { + msg.Text = msg.Username + msg.Text + } + res, err = b.mc.PostMessageWithFiles(channelID, msg.Text, msg.ParentID, []string{id}) + } + return res, err +} + +//nolint:forcetypeassert +func (b *Bmattermost) handleProps(rmsg *config.Message, message *matterclient.Message) { + props := message.Post.Props + if props == nil { + return + } + if _, ok := props["override_username"].(string); ok { + rmsg.Username = props["override_username"].(string) + } + if _, ok := props["attachments"].([]interface{}); ok { + rmsg.Extra["attachments"] = props["attachments"].([]interface{}) + if rmsg.Text != "" { + return + } + + for _, attachment := range rmsg.Extra["attachments"] { + attach := attachment.(map[string]interface{}) + if attach["text"].(string) != "" { + rmsg.Text += attach["text"].(string) + continue + } + if attach["fallback"].(string) != "" { + rmsg.Text += attach["fallback"].(string) + } + } + } +} diff --git a/teleirc/matterbridge/bridge/mattermost/helpers.go b/teleirc/matterbridge/bridge/mattermost/helpers.go new file mode 100644 index 0000000..cca1c4e --- /dev/null +++ b/teleirc/matterbridge/bridge/mattermost/helpers.go @@ -0,0 +1,283 @@ +package bmattermost + +import ( + "net/http" + "strings" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/42wim/matterbridge/matterhook" + "github.com/matterbridge/matterclient" + "github.com/mattermost/mattermost-server/v6/model" +) + +func (b *Bmattermost) doConnectWebhookBind() error { + switch { + case b.GetString("WebhookURL") != "": + b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)") + b.mh = matterhook.New(b.GetString("WebhookURL"), + matterhook.Config{ + InsecureSkipVerify: b.GetBool("SkipTLSVerify"), + BindAddress: b.GetString("WebhookBindAddress"), + }) + case b.GetString("Token") != "": + b.Log.Info("Connecting using token (sending)") + err := b.apiLogin() + if err != nil { + return err + } + case b.GetString("Login") != "": + b.Log.Info("Connecting using login/password (sending)") + err := b.apiLogin() + if err != nil { + return err + } + default: + b.Log.Info("Connecting using webhookbindaddress (receiving)") + b.mh = matterhook.New(b.GetString("WebhookURL"), + matterhook.Config{ + InsecureSkipVerify: b.GetBool("SkipTLSVerify"), + BindAddress: b.GetString("WebhookBindAddress"), + }) + } + return nil +} + +func (b *Bmattermost) doConnectWebhookURL() error { + b.Log.Info("Connecting using webhookurl (sending)") + b.mh = matterhook.New(b.GetString("WebhookURL"), + matterhook.Config{ + InsecureSkipVerify: b.GetBool("SkipTLSVerify"), + DisableServer: true, + }) + if b.GetString("Token") != "" { + b.Log.Info("Connecting using token (receiving)") + err := b.apiLogin() + if err != nil { + return err + } + } else if b.GetString("Login") != "" { + b.Log.Info("Connecting using login/password (receiving)") + err := b.apiLogin() + if err != nil { + return err + } + } + return nil +} + +//nolint:wrapcheck +func (b *Bmattermost) apiLogin() error { + password := b.GetString("Password") + if b.GetString("Token") != "" { + password = "token=" + b.GetString("Token") + } + + b.mc = matterclient.New(b.GetString("Login"), password, b.GetString("Team"), b.GetString("Server"), "") + if b.GetBool("debug") { + b.mc.SetLogLevel("debug") + } + b.mc.SkipTLSVerify = b.GetBool("SkipTLSVerify") + b.mc.SkipVersionCheck = b.GetBool("SkipVersionCheck") + b.mc.NoTLS = b.GetBool("NoTLS") + b.Log.Infof("Connecting %s (team: %s) on %s", b.GetString("Login"), b.GetString("Team"), b.GetString("Server")) + + if err := b.mc.Login(); err != nil { + return err + } + + b.Log.Info("Connection succeeded") + b.TeamID = b.mc.GetTeamID() + return nil +} + +// replaceAction replace the message with the correct action (/me) code +func (b *Bmattermost) replaceAction(text string) (string, bool) { + if strings.HasPrefix(text, "*") && strings.HasSuffix(text, "*") { + return strings.Replace(text, "*", "", -1), true + } + return text, false +} + +func (b *Bmattermost) cacheAvatar(msg *config.Message) (string, error) { + fi := msg.Extra["file"][0].(config.FileInfo) + /* if we have a sha we have successfully uploaded the file to the media server, + so we can now cache the sha */ + if fi.SHA != "" { + b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID) + b.avatarMap[msg.UserID] = fi.SHA + } + return "", nil +} + +// sendWebhook uses the configured WebhookURL to send the message +func (b *Bmattermost) sendWebhook(msg config.Message) (string, error) { + // skip events + if msg.Event != "" { + return "", nil + } + + if b.GetBool("PrefixMessagesWithNick") { + msg.Text = msg.Username + msg.Text + } + + if msg.Extra != nil { + // this sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + rmsg := rmsg // scopelint + iconURL := config.GetIconURL(&rmsg, b.GetString("iconurl")) + matterMessage := matterhook.OMessage{ + IconURL: iconURL, + Channel: rmsg.Channel, + UserName: rmsg.Username, + Text: rmsg.Text, + Props: make(map[string]interface{}), + } + matterMessage.Props["matterbridge_"+b.uuid] = true + if err := b.mh.Send(matterMessage); err != nil { + b.Log.Errorf("sendWebhook failed: %s ", err) + } + } + + // webhook doesn't support file uploads, so we add the url manually + if len(msg.Extra["file"]) > 0 { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.URL != "" { + msg.Text += " " + fi.URL + } + } + } + } + + iconURL := config.GetIconURL(&msg, b.GetString("iconurl")) + matterMessage := matterhook.OMessage{ + IconURL: iconURL, + Channel: msg.Channel, + UserName: msg.Username, + Text: msg.Text, + Props: make(map[string]interface{}), + } + if msg.Avatar != "" { + matterMessage.IconURL = msg.Avatar + } + matterMessage.Props["matterbridge_"+b.uuid] = true + err := b.mh.Send(matterMessage) + if err != nil { + b.Log.Info(err) + return "", err + } + return "", nil +} + +// skipMessages returns true if this message should not be handled +//nolint:gocyclo,cyclop +func (b *Bmattermost) skipMessage(message *matterclient.Message) bool { + // Handle join/leave + if message.Type == "system_join_leave" || + message.Type == "system_join_channel" || + message.Type == "system_leave_channel" { + if b.GetBool("nosendjoinpart") { + return true + } + + channelName := b.getChannelName(message.Post.ChannelId) + if channelName == "" { + channelName = message.Channel + } + + b.Log.Debugf("Sending JOIN_LEAVE event from %s to gateway", b.Account) + b.Remote <- config.Message{ + Username: "system", + Text: message.Text, + Channel: channelName, + Account: b.Account, + Event: config.EventJoinLeave, + } + return true + } + + // Handle edited messages + if (message.Raw.EventType() == model.WebsocketEventPostEdited) && b.GetBool("EditDisable") { + return true + } + + // Ignore non-post messages + if message.Post == nil { + b.Log.Debugf("ignoring nil message.Post: %#v", message) + return true + } + + // Ignore messages sent from matterbridge + if message.Post.Props != nil { + if _, ok := message.Post.Props["matterbridge_"+b.uuid].(bool); ok { + b.Log.Debug("sent by matterbridge, ignoring") + return true + } + } + + // Ignore messages sent from a user logged in as the bot + if b.mc.User.Username == message.Username { + b.Log.Debug("message from same user as bot, ignoring") + return true + } + + // if the message has reactions don't repost it (for now, until we can correlate reaction with message) + if message.Post.HasReactions { + return true + } + + // ignore messages from other teams than ours + if message.Raw.GetData()["team_id"].(string) != b.TeamID { + b.Log.Debug("message from other team, ignoring") + return true + } + + // only handle posted, edited or deleted events + if !(message.Raw.EventType() == "posted" || message.Raw.EventType() == model.WebsocketEventPostEdited || + message.Raw.EventType() == model.WebsocketEventPostDeleted) { + return true + } + return false +} + +func (b *Bmattermost) getVersion() string { + proto := "https" + + if b.GetBool("notls") { + proto = "http" + } + + resp, err := http.Get(proto + "://" + b.GetString("server")) + if err != nil { + b.Log.Error("failed getting version") + return "" + } + + defer resp.Body.Close() + + return resp.Header.Get("X-Version-Id") +} + +func (b *Bmattermost) getChannelID(name string) string { + idcheck := strings.Split(name, "ID:") + if len(idcheck) > 1 { + return idcheck[1] + } + + return b.mc.GetChannelID(name, b.TeamID) +} + +func (b *Bmattermost) getChannelName(id string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + for _, c := range b.channelInfoMap { + if c.Name == "ID:"+id { + // if we have ID: specified in our gateway configuration return this + return c.Name + } + } + + return "" +} diff --git a/teleirc/matterbridge/bridge/mattermost/mattermost.go b/teleirc/matterbridge/bridge/mattermost/mattermost.go new file mode 100644 index 0000000..2b44dcf --- /dev/null +++ b/teleirc/matterbridge/bridge/mattermost/mattermost.go @@ -0,0 +1,193 @@ +package bmattermost + +import ( + "errors" + "fmt" + "strings" + "sync" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/42wim/matterbridge/matterhook" + "github.com/matterbridge/matterclient" + "github.com/rs/xid" +) + +type Bmattermost struct { + mh *matterhook.Client + mc *matterclient.Client + v6 bool + uuid string + TeamID string + *bridge.Config + avatarMap map[string]string + channelsMutex sync.RWMutex + channelInfoMap map[string]*config.ChannelInfo +} + +const mattermostPlugin = "mattermost.plugin" + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bmattermost{ + Config: cfg, + avatarMap: make(map[string]string), + channelInfoMap: make(map[string]*config.ChannelInfo), + } + + b.v6 = b.GetBool("v6") + b.uuid = xid.New().String() + + return b +} + +func (b *Bmattermost) Command(cmd string) string { + return "" +} + +func (b *Bmattermost) Connect() error { + if b.Account == mattermostPlugin { + return nil + } + + if strings.HasPrefix(b.getVersion(), "6.") || strings.HasPrefix(b.getVersion(), "7.") { + if !b.v6 { + b.v6 = true + } + } + + if b.GetString("WebhookBindAddress") != "" { + if err := b.doConnectWebhookBind(); err != nil { + return err + } + go b.handleMatter() + return nil + } + switch { + case b.GetString("WebhookURL") != "": + if err := b.doConnectWebhookURL(); err != nil { + return err + } + go b.handleMatter() + return nil + case b.GetString("Token") != "": + b.Log.Info("Connecting using token (sending and receiving)") + err := b.apiLogin() + if err != nil { + return err + } + go b.handleMatter() + case b.GetString("Login") != "": + b.Log.Info("Connecting using login/password (sending and receiving)") + b.Log.Infof("Using mattermost v6 methods: %t", b.v6) + err := b.apiLogin() + if err != nil { + return err + } + go b.handleMatter() + } + if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" && + b.GetString("Login") == "" && b.GetString("Token") == "" { + return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token/Login/Password/Server/Team configured") + } + return nil +} + +func (b *Bmattermost) Disconnect() error { + return nil +} + +func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error { + if b.Account == mattermostPlugin { + return nil + } + + b.channelsMutex.Lock() + b.channelInfoMap[channel.ID] = &channel + b.channelsMutex.Unlock() + + // we can only join channels using the API + if b.GetString("WebhookURL") == "" && b.GetString("WebhookBindAddress") == "" { + id := b.getChannelID(channel.Name) + if id == "" { + return fmt.Errorf("Could not find channel ID for channel %s", channel.Name) + } + + return b.mc.JoinChannel(id) + } + + return nil +} + +func (b *Bmattermost) Send(msg config.Message) (string, error) { + if b.Account == mattermostPlugin { + return "", nil + } + b.Log.Debugf("=> Receiving %#v", msg) + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + msg.Text = "*" + msg.Text + "*" + } + + // map the file SHA to our user (caches the avatar) + if msg.Event == config.EventAvatarDownload { + return b.cacheAvatar(&msg) + } + + // Use webhook to send the message + if b.GetString("WebhookURL") != "" { + return b.sendWebhook(msg) + } + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + + return msg.ID, b.mc.DeleteMessage(msg.ID) + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + msg.Text = fmt.Sprintf("[thread]: %s", msg.Text) + } + + // we only can reply to the root of the thread, not to a specific ID (like discord for example does) + if msg.ParentID != "" { + post, _, err := b.mc.Client.GetPost(msg.ParentID, "") + if err != nil { + b.Log.Errorf("getting post %s failed: %s", msg.ParentID, err) + } + if post.RootId != "" { + msg.ParentID = post.RootId + } + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + if _, err := b.mc.PostMessage(b.getChannelID(rmsg.Channel), rmsg.Username+rmsg.Text, msg.ParentID); err != nil { + b.Log.Errorf("PostMessage failed: %s", err) + } + } + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(&msg) + } + } + + // Prepend nick if configured + if b.GetBool("PrefixMessagesWithNick") { + msg.Text = msg.Username + msg.Text + } + + // Edit message if we have an ID + if msg.ID != "" { + return b.mc.EditMessage(msg.ID, msg.Text) + } + + // Post normal message + return b.mc.PostMessage(b.getChannelID(msg.Channel), msg.Text, msg.ParentID) +} diff --git a/teleirc/matterbridge/bridge/msteams/handler.go b/teleirc/matterbridge/bridge/msteams/handler.go new file mode 100644 index 0000000..c8f0c46 --- /dev/null +++ b/teleirc/matterbridge/bridge/msteams/handler.go @@ -0,0 +1,101 @@ +package bmsteams + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + + msgraph "github.com/yaegashi/msgraph.go/beta" +) + +func (b *Bmsteams) findFile(weburl string) (string, error) { + itemRB, err := b.gc.GetDriveItemByURL(b.ctx, weburl) + if err != nil { + return "", err + } + itemRB.Workbook().Worksheets() + b.gc.Workbooks() + item, err := itemRB.Request().Get(b.ctx) + if err != nil { + return "", err + } + if url, ok := item.GetAdditionalData("@microsoft.graph.downloadUrl"); ok { + return url.(string), nil + } + return "", nil +} + +// handleDownloadFile handles file download +func (b *Bmsteams) handleDownloadFile(rmsg *config.Message, filename, weburl string) error { + realURL, err := b.findFile(weburl) + if err != nil { + return err + } + // Actually download the file. + data, err := helper.DownloadFile(realURL) + if err != nil { + return fmt.Errorf("download %s failed %#v", weburl, err) + } + + // If a comment is attached to the file(s) it is in the 'Text' field of the teams messge event + // and should be added as comment to only one of the files. We reset the 'Text' field to ensure + // that the comment is not duplicated. + comment := rmsg.Text + rmsg.Text = "" + helper.HandleDownloadData(b.Log, rmsg, filename, comment, weburl, data, b.General) + return nil +} + +func (b *Bmsteams) handleAttachments(rmsg *config.Message, msg msgraph.ChatMessage) { + for _, a := range msg.Attachments { + //remove the attachment tags from the text + rmsg.Text = attachRE.ReplaceAllString(rmsg.Text, "") + + //handle a code snippet (code block) + if *a.ContentType == "application/vnd.microsoft.card.codesnippet" { + b.handleCodeSnippet(rmsg, a) + continue + } + + //handle the download + err := b.handleDownloadFile(rmsg, *a.Name, *a.ContentURL) + if err != nil { + b.Log.Errorf("download of %s failed: %s", *a.Name, err) + } + } +} + +type AttachContent struct { + Language string `json:"language"` + CodeSnippetURL string `json:"codeSnippetUrl"` +} + +func (b *Bmsteams) handleCodeSnippet(rmsg *config.Message, attach msgraph.ChatMessageAttachment) { + var content AttachContent + err := json.Unmarshal([]byte(*attach.Content), &content) + if err != nil { + b.Log.Errorf("unmarshal codesnippet failed: %s", err) + return + } + s := strings.Split(content.CodeSnippetURL, "/") + if len(s) != 13 { + b.Log.Errorf("codesnippetUrl has unexpected size: %s", content.CodeSnippetURL) + return + } + resp, err := b.gc.Teams().Request().Client().Get(content.CodeSnippetURL) + if err != nil { + b.Log.Errorf("retrieving snippet content failed:%s", err) + return + } + defer resp.Body.Close() + res, err := ioutil.ReadAll(resp.Body) + if err != nil { + b.Log.Errorf("reading snippet data failed: %s", err) + return + } + rmsg.Text = rmsg.Text + "\n```" + content.Language + "\n" + string(res) + "\n```\n" +} diff --git a/teleirc/matterbridge/bridge/msteams/msteams.go b/teleirc/matterbridge/bridge/msteams/msteams.go new file mode 100644 index 0000000..27d7bee --- /dev/null +++ b/teleirc/matterbridge/bridge/msteams/msteams.go @@ -0,0 +1,229 @@ +package bmsteams + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/davecgh/go-spew/spew" + + "github.com/mattn/godown" + msgraph "github.com/yaegashi/msgraph.go/beta" + "github.com/yaegashi/msgraph.go/msauth" + + "golang.org/x/oauth2" +) + +var ( + defaultScopes = []string{"openid", "profile", "offline_access", "Group.Read.All", "Group.ReadWrite.All"} + attachRE = regexp.MustCompile(`<attachment id=.*?attachment>`) +) + +type Bmsteams struct { + gc *msgraph.GraphServiceRequestBuilder + ctx context.Context + botID string + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Bmsteams{Config: cfg} +} + +func (b *Bmsteams) Connect() error { + tokenCachePath := b.GetString("sessionFile") + if tokenCachePath == "" { + tokenCachePath = "msteams_session.json" + } + ctx := context.Background() + m := msauth.NewManager() + m.LoadFile(tokenCachePath) //nolint:errcheck + ts, err := m.DeviceAuthorizationGrant(ctx, b.GetString("TenantID"), b.GetString("ClientID"), defaultScopes, nil) + if err != nil { + return err + } + err = m.SaveFile(tokenCachePath) + if err != nil { + b.Log.Errorf("Couldn't save sessionfile in %s: %s", tokenCachePath, err) + } + // make file readable only for matterbridge user + err = os.Chmod(tokenCachePath, 0o600) + if err != nil { + b.Log.Errorf("Couldn't change permissions for %s: %s", tokenCachePath, err) + } + httpClient := oauth2.NewClient(ctx, ts) + graphClient := msgraph.NewClient(httpClient) + b.gc = graphClient + b.ctx = ctx + + err = b.setBotID() + if err != nil { + return err + } + b.Log.Info("Connection succeeded") + return nil +} + +func (b *Bmsteams) Disconnect() error { + return nil +} + +func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error { + go func(name string) { + for { + err := b.poll(name) + if err != nil { + b.Log.Errorf("polling failed for %s: %s. retrying in 5 seconds", name, err) + } + time.Sleep(time.Second * 5) + } + }(channel.Name) + return nil +} + +func (b *Bmsteams) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + if msg.ParentValid() { + return b.sendReply(msg) + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + msg.Text = fmt.Sprintf("[thread]: %s", msg.Text) + } + + ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().Request() + 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 +} + +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. + + 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 +} + +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 +} + +//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) + } +} + +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 +} + +func (b *Bmsteams) convertToMD(text string) string { + if !strings.Contains(text, "<div>") { + 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() +} diff --git a/teleirc/matterbridge/bridge/mumble/codec.go b/teleirc/matterbridge/bridge/mumble/codec.go new file mode 100644 index 0000000..1306e40 --- /dev/null +++ b/teleirc/matterbridge/bridge/mumble/codec.go @@ -0,0 +1,70 @@ +package bmumble + +import ( + "fmt" + + "layeh.com/gumble/gumble" +) + +// This is a dummy implementation of a Gumble audio codec which claims +// to implement Opus, but does not actually do anything. This serves +// as a workaround until https://github.com/layeh/gumble/pull/61 is +// merged. +// See https://github.com/42wim/matterbridge/issues/1750 for details. + +const ( + audioCodecIDOpus = 4 +) + +func registerNullCodecAsOpus() { + codec := &NullCodec{ + encoder: &NullAudioEncoder{}, + decoder: &NullAudioDecoder{}, + } + gumble.RegisterAudioCodec(audioCodecIDOpus, codec) +} + +type NullCodec struct { + encoder *NullAudioEncoder + decoder *NullAudioDecoder +} + +func (c *NullCodec) ID() int { + return audioCodecIDOpus +} + +func (c *NullCodec) NewEncoder() gumble.AudioEncoder { + e := &NullAudioEncoder{} + return e +} + +func (c *NullCodec) NewDecoder() gumble.AudioDecoder { + d := &NullAudioDecoder{} + return d +} + +type NullAudioEncoder struct{} + +func (e *NullAudioEncoder) ID() int { + return audioCodecIDOpus +} + +func (e *NullAudioEncoder) Encode(pcm []int16, mframeSize, maxDataBytes int) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (e *NullAudioEncoder) Reset() { +} + +type NullAudioDecoder struct{} + +func (d *NullAudioDecoder) ID() int { + return audioCodecIDOpus +} + +func (d *NullAudioDecoder) Decode(data []byte, frameSize int) ([]int16, error) { + return nil, fmt.Errorf("not implemented") +} + +func (d *NullAudioDecoder) Reset() { +} diff --git a/teleirc/matterbridge/bridge/mumble/handlers.go b/teleirc/matterbridge/bridge/mumble/handlers.go new file mode 100644 index 0000000..8120159 --- /dev/null +++ b/teleirc/matterbridge/bridge/mumble/handlers.go @@ -0,0 +1,152 @@ +package bmumble + +import ( + "strconv" + "time" + + "layeh.com/gumble/gumble" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" +) + +func (b *Bmumble) handleServerConfig(event *gumble.ServerConfigEvent) { + b.serverConfigUpdate <- *event +} + +func (b *Bmumble) handleTextMessage(event *gumble.TextMessageEvent) { + sender := "unknown" + if event.TextMessage.Sender != nil { + sender = event.TextMessage.Sender.Name + } + // If the text message is received before receiving a ServerSync + // and UserState, Client.Self or Self.Channel are nil + if event.Client.Self == nil || event.Client.Self.Channel == nil { + b.Log.Warn("Connection bootstrap not finished, discarding text message") + return + } + // Convert Mumble HTML messages to markdown + parts, err := b.convertHTMLtoMarkdown(event.TextMessage.Message) + if err != nil { + b.Log.Error(err) + } + now := time.Now().UTC() + for i, part := range parts { + // Construct matterbridge message and pass on to the gateway + rmsg := config.Message{ + Channel: strconv.FormatUint(uint64(event.Client.Self.Channel.ID), 10), + Username: sender, + UserID: sender + "@" + b.Host, + Account: b.Account, + } + if part.Image == nil { + rmsg.Text = part.Text + } else { + fname := b.Account + "_" + strconv.FormatInt(now.UnixNano(), 10) + "_" + strconv.Itoa(i) + part.FileExtension + rmsg.Extra = make(map[string][]interface{}) + if err = helper.HandleDownloadSize(b.Log, &rmsg, fname, int64(len(part.Image)), b.General); err != nil { + b.Log.WithError(err).Warn("not including image in message") + continue + } + helper.HandleDownloadData(b.Log, &rmsg, fname, "", "", &part.Image, b.General) + } + b.Log.Debugf("Sending message to gateway: %+v", rmsg) + b.Remote <- rmsg + } +} + +func (b *Bmumble) handleConnect(event *gumble.ConnectEvent) { + // Set the user's "bio"/comment + if comment := b.GetString("UserComment"); comment != "" && event.Client.Self != nil { + event.Client.Self.SetComment(comment) + } + // No need to talk or listen + event.Client.Self.SetSelfDeafened(true) + event.Client.Self.SetSelfMuted(true) + // if the Channel variable is set, this is a reconnect -> rejoin channel + if b.Channel != nil { + if err := b.doJoin(event.Client, *b.Channel); err != nil { + b.Log.Error(err) + } + b.Remote <- config.Message{ + Username: "system", + Text: "rejoin", + Channel: "", + Account: b.Account, + Event: config.EventRejoinChannels, + } + } +} + +func (b *Bmumble) handleJoinLeave(event *gumble.UserChangeEvent) { + // Ignore events happening before setup is done + if b.Channel == nil { + return + } + if b.GetBool("nosendjoinpart") { + return + } + b.Log.Debugf("Received gumble user change event: %+v", event) + + text := "" + switch { + case event.Type&gumble.UserChangeKicked > 0: + text = " was kicked" + case event.Type&gumble.UserChangeBanned > 0: + text = " was banned" + case event.Type&gumble.UserChangeDisconnected > 0: + if event.User.Channel != nil && event.User.Channel.ID == *b.Channel { + text = " left" + } + case event.Type&gumble.UserChangeConnected > 0: + if event.User.Channel != nil && event.User.Channel.ID == *b.Channel { + text = " joined" + } + case event.Type&gumble.UserChangeChannel > 0: + // Treat Mumble channel changes the same as connects/disconnects; as far as matterbridge is concerned, they are identical + if event.User.Channel != nil && event.User.Channel.ID == *b.Channel { + text = " joined" + } else { + text = " left" + } + } + + if text != "" { + b.Remote <- config.Message{ + Username: "system", + Text: event.User.Name + text, + Channel: strconv.FormatUint(uint64(*b.Channel), 10), + Account: b.Account, + Event: config.EventJoinLeave, + } + } +} + +func (b *Bmumble) handleUserModified(event *gumble.UserChangeEvent) { + // Ignore events happening before setup is done + if b.Channel == nil { + return + } + + if event.Type&gumble.UserChangeChannel > 0 { + // Someone attempted to move the user out of the configured channel; attempt to join back + if err := b.doJoin(event.Client, *b.Channel); err != nil { + b.Log.Error(err) + } + } +} + +func (b *Bmumble) handleUserChange(event *gumble.UserChangeEvent) { + // The UserChangeEvent is used for both the gumble client itself as well as other clients + if event.User != event.Client.Self { + // other users + b.handleJoinLeave(event) + } else { + // gumble user + b.handleUserModified(event) + } +} + +func (b *Bmumble) handleDisconnect(event *gumble.DisconnectEvent) { + b.connected <- *event +} diff --git a/teleirc/matterbridge/bridge/mumble/helpers.go b/teleirc/matterbridge/bridge/mumble/helpers.go new file mode 100644 index 0000000..c828df2 --- /dev/null +++ b/teleirc/matterbridge/bridge/mumble/helpers.go @@ -0,0 +1,143 @@ +package bmumble + +import ( + "fmt" + "mime" + "net/http" + "regexp" + "strings" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/mattn/godown" + "github.com/vincent-petithory/dataurl" +) + +type MessagePart struct { + Text string + FileExtension string + Image []byte +} + +func (b *Bmumble) decodeImage(uri string, parts *[]MessagePart) error { + // Decode the data:image/... URI + image, err := dataurl.DecodeString(uri) + if err != nil { + b.Log.WithError(err).Info("No image extracted") + return err + } + // Determine the file extensions for that image + ext, err := mime.ExtensionsByType(image.MediaType.ContentType()) + if err != nil || len(ext) == 0 { + b.Log.WithError(err).Infof("No file extension registered for MIME type '%s'", image.MediaType.ContentType()) + return err + } + // Add the image to the MessagePart slice + *parts = append(*parts, MessagePart{"", ext[0], image.Data}) + return nil +} + +func (b *Bmumble) tokenize(t *string) ([]MessagePart, error) { + // `^(.*?)` matches everything before the image + // `!\[[^\]]*\]\(` matches the `]+)` matches the data: URI used by Mumble + // `\)` matches the closing parenthesis after the URI + // `(.*)$` matches the remaining text to be examined in the next iteration + p := regexp.MustCompile(`^(?ms)(.*?)!\[[^\]]*\]\((data:image\/[^)]+)\)(.*)$`) + remaining := *t + var parts []MessagePart + for { + tokens := p.FindStringSubmatch(remaining) + if tokens == nil { + // no match -> remaining string is non-image text + pre := strings.TrimSpace(remaining) + if len(pre) > 0 { + parts = append(parts, MessagePart{pre, "", nil}) + } + return parts, nil + } + + // tokens[1] is the text before the image + if len(tokens[1]) > 0 { + pre := strings.TrimSpace(tokens[1]) + parts = append(parts, MessagePart{pre, "", nil}) + } + // tokens[2] is the image URL + uri, err := dataurl.UnescapeToString(strings.TrimSpace(strings.ReplaceAll(tokens[2], " ", ""))) + if err != nil { + b.Log.WithError(err).Info("URL unescaping failed") + remaining = strings.TrimSpace(tokens[3]) + continue + } + err = b.decodeImage(uri, &parts) + if err != nil { + b.Log.WithError(err).Info("Decoding the image failed") + } + // tokens[3] is the text after the image, processed in the next iteration + remaining = strings.TrimSpace(tokens[3]) + } +} + +func (b *Bmumble) convertHTMLtoMarkdown(html string) ([]MessagePart, error) { + var sb strings.Builder + err := godown.Convert(&sb, strings.NewReader(html), nil) + if err != nil { + return nil, err + } + markdown := sb.String() + b.Log.Debugf("### to markdown: %s", markdown) + return b.tokenize(&markdown) +} + +func (b *Bmumble) extractFiles(msg *config.Message) []config.Message { + var messages []config.Message + if msg.Extra == nil || len(msg.Extra["file"]) == 0 { + return messages + } + // Create a separate message for each file + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + imsg := config.Message{ + Channel: msg.Channel, + Username: msg.Username, + UserID: msg.UserID, + Account: msg.Account, + Protocol: msg.Protocol, + Timestamp: msg.Timestamp, + Event: "mumble_image", + } + // If no data is present for the file, send a link instead + if fi.Data == nil || len(*fi.Data) == 0 { + if len(fi.URL) > 0 { + imsg.Text = fmt.Sprintf(`<a href="%s">%s</a>`, fi.URL, fi.URL) + messages = append(messages, imsg) + } else { + b.Log.Infof("Not forwarding file without local data") + } + continue + } + mimeType := http.DetectContentType(*fi.Data) + // Mumble only supports images natively, send a link instead + if !strings.HasPrefix(mimeType, "image/") { + if len(fi.URL) > 0 { + imsg.Text = fmt.Sprintf(`<a href="%s">%s</a>`, fi.URL, fi.URL) + messages = append(messages, imsg) + } else { + b.Log.Infof("Not forwarding file of type %s", mimeType) + } + continue + } + mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) + // Build data:image/...;base64,... style image URL and embed image directly into the message + du := dataurl.New(*fi.Data, mimeType) + dataURL, err := du.MarshalText() + if err != nil { + b.Log.WithError(err).Infof("Image Serialization into data URL failed (type: %s, length: %d)", mimeType, len(*fi.Data)) + continue + } + imsg.Text = fmt.Sprintf(`<img src="%s"/>`, dataURL) + messages = append(messages, imsg) + } + // Remove files from original message + msg.Extra["file"] = nil + return messages +} diff --git a/teleirc/matterbridge/bridge/mumble/mumble.go b/teleirc/matterbridge/bridge/mumble/mumble.go new file mode 100644 index 0000000..945cf55 --- /dev/null +++ b/teleirc/matterbridge/bridge/mumble/mumble.go @@ -0,0 +1,263 @@ +package bmumble + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "net" + "strconv" + "strings" + "time" + + "layeh.com/gumble/gumble" + "layeh.com/gumble/gumbleutil" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + stripmd "github.com/writeas/go-strip-markdown" + + // We need to import the 'data' package as an implicit dependency. + // See: https://godoc.org/github.com/paulrosania/go-charset/charset + _ "github.com/paulrosania/go-charset/data" +) + +type Bmumble struct { + client *gumble.Client + Nick string + Host string + Channel *uint32 + local chan config.Message + running chan error + connected chan gumble.DisconnectEvent + serverConfigUpdate chan gumble.ServerConfigEvent + serverConfig gumble.ServerConfigEvent + tlsConfig tls.Config + + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bmumble{} + b.Config = cfg + b.Nick = b.GetString("Nick") + b.local = make(chan config.Message) + b.running = make(chan error) + b.connected = make(chan gumble.DisconnectEvent) + b.serverConfigUpdate = make(chan gumble.ServerConfigEvent) + return b +} + +func (b *Bmumble) Connect() error { + b.Log.Infof("Connecting %s", b.GetString("Server")) + host, portstr, err := net.SplitHostPort(b.GetString("Server")) + if err != nil { + return err + } + b.Host = host + _, err = strconv.Atoi(portstr) + if err != nil { + return err + } + + if err = b.buildTLSConfig(); err != nil { + return err + } + + go b.doSend() + go b.connectLoop() + err = <-b.running + return err +} + +func (b *Bmumble) Disconnect() error { + return b.client.Disconnect() +} + +func (b *Bmumble) JoinChannel(channel config.ChannelInfo) error { + cid, err := strconv.ParseUint(channel.Name, 10, 32) + if err != nil { + return err + } + channelID := uint32(cid) + if b.Channel != nil && *b.Channel != channelID { + b.Log.Fatalf("Cannot join channel ID '%d', already joined to channel ID %d", channelID, *b.Channel) + return errors.New("the Mumble bridge can only join a single channel") + } + b.Channel = &channelID + return b.doJoin(b.client, channelID) +} + +func (b *Bmumble) Send(msg config.Message) (string, error) { + // Only process text messages + b.Log.Debugf("=> Received local message %#v", msg) + if msg.Event != "" && msg.Event != config.EventUserAction && msg.Event != config.EventJoinLeave { + return "", nil + } + + attachments := b.extractFiles(&msg) + b.local <- msg + for _, a := range attachments { + b.local <- a + } + return "", nil +} + +func (b *Bmumble) buildTLSConfig() error { + b.tlsConfig = tls.Config{} + // Load TLS client certificate keypair required for registered user authentication + if cpath := b.GetString("TLSClientCertificate"); cpath != "" { + if ckey := b.GetString("TLSClientKey"); ckey != "" { + cert, err := tls.LoadX509KeyPair(cpath, ckey) + if err != nil { + return err + } + b.tlsConfig.Certificates = []tls.Certificate{cert} + } + } + // Load TLS CA used for server verification. If not provided, the Go system trust anchor is used + if capath := b.GetString("TLSCACertificate"); capath != "" { + ca, err := ioutil.ReadFile(capath) + if err != nil { + return err + } + b.tlsConfig.RootCAs = x509.NewCertPool() + b.tlsConfig.RootCAs.AppendCertsFromPEM(ca) + } + b.tlsConfig.InsecureSkipVerify = b.GetBool("SkipTLSVerify") + return nil +} + +func (b *Bmumble) connectLoop() { + firstConnect := true + for { + err := b.doConnect() + if firstConnect { + b.running <- err + } + if err != nil { + b.Log.Errorf("Connection to server failed: %#v", err) + if firstConnect { + break + } else { + b.Log.Info("Retrying in 10s") + time.Sleep(10 * time.Second) + continue + } + } + firstConnect = false + d := <-b.connected + switch d.Type { + case gumble.DisconnectError: + b.Log.Errorf("Lost connection to the server (%s), attempting reconnect", d.String) + continue + case gumble.DisconnectKicked: + b.Log.Errorf("Kicked from the server (%s), attempting reconnect", d.String) + continue + case gumble.DisconnectBanned: + b.Log.Errorf("Banned from the server (%s), not attempting reconnect", d.String) + close(b.connected) + close(b.running) + return + case gumble.DisconnectUser: + b.Log.Infof("Disconnect successful") + close(b.connected) + close(b.running) + return + } + } +} + +func (b *Bmumble) doConnect() error { + // Create new gumble config and attach event handlers + gumbleConfig := gumble.NewConfig() + gumbleConfig.Attach(gumbleutil.Listener{ + ServerConfig: b.handleServerConfig, + TextMessage: b.handleTextMessage, + Connect: b.handleConnect, + Disconnect: b.handleDisconnect, + UserChange: b.handleUserChange, + }) + gumbleConfig.Username = b.GetString("Nick") + if password := b.GetString("Password"); password != "" { + gumbleConfig.Password = password + } + + registerNullCodecAsOpus() + client, err := gumble.DialWithDialer(new(net.Dialer), b.GetString("Server"), gumbleConfig, &b.tlsConfig) + if err != nil { + return err + } + b.client = client + return nil +} + +func (b *Bmumble) doJoin(client *gumble.Client, channelID uint32) error { + channel, ok := client.Channels[channelID] + if !ok { + return fmt.Errorf("no channel with ID %d", channelID) + } + client.Self.Move(channel) + return nil +} + +func (b *Bmumble) doSend() { + // Message sending loop that makes sure server-side + // restrictions and client-side message traits don't conflict + // with each other. + for { + select { + case serverConfig := <-b.serverConfigUpdate: + b.Log.Debugf("Received server config update: AllowHTML=%#v, MaximumMessageLength=%#v", serverConfig.AllowHTML, serverConfig.MaximumMessageLength) + b.serverConfig = serverConfig + case msg := <-b.local: + b.processMessage(&msg) + } + } +} + +func (b *Bmumble) processMessage(msg *config.Message) { + b.Log.Debugf("Processing message %s", msg.Text) + + allowHTML := true + if b.serverConfig.AllowHTML != nil { + allowHTML = *b.serverConfig.AllowHTML + } + + // If this is a specially generated image message, send it unmodified + if msg.Event == "mumble_image" { + if allowHTML { + b.client.Self.Channel.Send(msg.Username+msg.Text, false) + } else { + b.Log.Info("Can't send image, server does not allow HTML messages") + } + return + } + + // Don't process empty messages + if len(msg.Text) == 0 { + return + } + // If HTML is allowed, convert markdown into HTML, otherwise strip markdown + if allowHTML { + msg.Text = helper.ParseMarkdown(msg.Text) + } else { + msg.Text = stripmd.Strip(msg.Text) + } + + // If there is a maximum message length, split and truncate the lines + var msgLines []string + if maxLength := b.serverConfig.MaximumMessageLength; maxLength != nil { + msgLines = helper.GetSubLines(msg.Text, *maxLength-len(msg.Username), b.GetString("MessageClipped")) + } else { + msgLines = helper.GetSubLines(msg.Text, 0, b.GetString("MessageClipped")) + } + // Send the individual lines + for i := range msgLines { + // Remove unnecessary newline character, since either way we're sending it as individual lines + msgLines[i] = strings.TrimSuffix(msgLines[i], "\n") + b.client.Self.Channel.Send(msg.Username+msgLines[i], false) + } +} diff --git a/teleirc/matterbridge/bridge/nctalk/nctalk.go b/teleirc/matterbridge/bridge/nctalk/nctalk.go new file mode 100644 index 0000000..82acba4 --- /dev/null +++ b/teleirc/matterbridge/bridge/nctalk/nctalk.go @@ -0,0 +1,295 @@ +package nctalk + +import ( + "context" + "crypto/tls" + "strconv" + "strings" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + + "gomod.garykim.dev/nc-talk/ocs" + "gomod.garykim.dev/nc-talk/room" + "gomod.garykim.dev/nc-talk/user" +) + +type Btalk struct { + user *user.TalkUser + rooms []Broom + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Btalk{Config: cfg} +} + +type Broom struct { + room *room.TalkRoom + ctx context.Context + ctxCancel context.CancelFunc +} + +func (b *Btalk) Connect() error { + b.Log.Info("Connecting") + tconfig := &user.TalkUserConfig{ + TLSConfig: &tls.Config{ + InsecureSkipVerify: b.GetBool("SkipTLSVerify"), //nolint:gosec + }, + } + var err error + b.user, err = user.NewUser(b.GetString("Server"), b.GetString("Login"), b.GetString("Password"), tconfig) + if err != nil { + b.Log.Error("Config could not be used") + return err + } + _, err = b.user.Capabilities() + if err != nil { + b.Log.Error("Cannot Connect") + return err + } + b.Log.Info("Connected") + return nil +} + +func (b *Btalk) Disconnect() error { + for _, r := range b.rooms { + r.ctxCancel() + } + return nil +} + +func (b *Btalk) JoinChannel(channel config.ChannelInfo) error { + tr, err := room.NewTalkRoom(b.user, channel.Name) + if err != nil { + return err + } + newRoom := Broom{ + room: tr, + } + newRoom.ctx, newRoom.ctxCancel = context.WithCancel(context.Background()) + c, err := newRoom.room.ReceiveMessages(newRoom.ctx) + if err != nil { + return err + } + b.rooms = append(b.rooms, newRoom) + + go func() { + for msg := range c { + msg := msg + + if msg.Error != nil { + b.Log.Errorf("Fatal message poll error: %s\n", msg.Error) + + return + } + + // Ignore messages that are from the bot user + if msg.ActorID == b.user.User || msg.ActorType == "bridged" { + continue + } + + // Handle deleting messages + if msg.MessageType == ocs.MessageSystem && msg.Parent != nil && msg.Parent.MessageType == ocs.MessageDelete { + b.handleDeletingMessage(&msg, &newRoom) + continue + } + + // Handle sending messages + if msg.MessageType == ocs.MessageComment { + b.handleSendingMessage(&msg, &newRoom) + continue + } + + } + }() + return nil +} + +func (b *Btalk) Send(msg config.Message) (string, error) { + r := b.getRoom(msg.Channel) + if r == nil { + b.Log.Errorf("Could not find room for %v", msg.Channel) + return "", nil + } + + // Standard Message Send + if msg.Event == "" { + // Handle sending files if they are included + err := b.handleSendingFile(&msg, r) + if err != nil { + b.Log.Errorf("Could not send files in message to room %v from %v: %v", msg.Channel, msg.Username, err) + + return "", nil + } + + sentMessage, err := b.sendText(r, &msg, msg.Text) + if err != nil { + b.Log.Errorf("Could not send message to room %v from %v: %v", msg.Channel, msg.Username, err) + + return "", nil + } + return strconv.Itoa(sentMessage.ID), nil + } + + // Message Deletion + if msg.Event == config.EventMsgDelete { + messageID, err := strconv.Atoi(msg.ID) + if err != nil { + return "", err + } + data, err := r.room.DeleteMessage(messageID) + if err != nil { + return "", err + } + return strconv.Itoa(data.ID), nil + } + + // Message is not a type that is currently supported + return "", nil +} + +func (b *Btalk) getRoom(token string) *Broom { + for _, r := range b.rooms { + if r.room.Token == token { + return &r + } + } + return nil +} + +func (b *Btalk) sendText(r *Broom, msg *config.Message, text string) (*ocs.TalkRoomMessageData, error) { + messageToSend := &room.Message{Message: msg.Username + text} + + if b.GetBool("SeparateDisplayName") { + messageToSend.Message = text + messageToSend.ActorDisplayName = msg.Username + } + + return r.room.SendComplexMessage(messageToSend) +} + +func (b *Btalk) handleFiles(mmsg *config.Message, message *ocs.TalkRoomMessageData) error { + for _, parameter := range message.MessageParameters { + if parameter.Type == ocs.ROSTypeFile { + // Get the file + file, err := b.user.DownloadFile(parameter.Path) + if err != nil { + return err + } + + if mmsg.Extra == nil { + mmsg.Extra = make(map[string][]interface{}) + } + + mmsg.Extra["file"] = append(mmsg.Extra["file"], config.FileInfo{ + Name: parameter.Name, + Data: file, + Size: int64(len(*file)), + Avatar: false, + }) + } + } + + return nil +} + +func (b *Btalk) handleSendingFile(msg *config.Message, r *Broom) error { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.URL == "" { + continue + } + + message := "" + if fi.Comment != "" { + message += fi.Comment + " " + } + message += fi.URL + _, err := b.sendText(r, msg, message) + if err != nil { + return err + } + } + + return nil +} + +func (b *Btalk) handleSendingMessage(msg *ocs.TalkRoomMessageData, r *Broom) { + remoteMessage := config.Message{ + Text: formatRichObjectString(msg.Message, msg.MessageParameters), + Channel: r.room.Token, + Username: DisplayName(msg, b.guestSuffix()), + UserID: msg.ActorID, + Account: b.Account, + } + // It is possible for the ID to not be set on older versions of Talk so we only set it if + // the ID is not blank + if msg.ID != 0 { + remoteMessage.ID = strconv.Itoa(msg.ID) + } + + // Handle Files + err := b.handleFiles(&remoteMessage, msg) + if err != nil { + b.Log.Errorf("Error handling file: %#v", msg) + + return + } + + b.Log.Debugf("<= Message is %#v", remoteMessage) + b.Remote <- remoteMessage +} + +func (b *Btalk) handleDeletingMessage(msg *ocs.TalkRoomMessageData, r *Broom) { + remoteMessage := config.Message{ + Event: config.EventMsgDelete, + Text: config.EventMsgDelete, + Channel: r.room.Token, + ID: strconv.Itoa(msg.Parent.ID), + Account: b.Account, + } + b.Log.Debugf("<= Message being deleted is %#v", remoteMessage) + b.Remote <- remoteMessage +} + +func (b *Btalk) guestSuffix() string { + guestSuffix := " (Guest)" + if b.IsKeySet("GuestSuffix") { + guestSuffix = b.GetString("GuestSuffix") + } + + return guestSuffix +} + +// Spec: https://github.com/nextcloud/server/issues/1706#issue-182308785 +func formatRichObjectString(message string, parameters map[string]ocs.RichObjectString) string { + for id, parameter := range parameters { + text := parameter.Name + + switch parameter.Type { + case ocs.ROSTypeUser, ocs.ROSTypeGroup: + text = "@" + text + case ocs.ROSTypeFile: + if parameter.Link != "" { + text = parameter.Name + } + } + + message = strings.ReplaceAll(message, "{"+id+"}", text) + } + + return message +} + +func DisplayName(msg *ocs.TalkRoomMessageData, suffix string) string { + if msg.ActorType == ocs.ActorGuest { + if msg.ActorDisplayName == "" { + return "Guest" + } + + return msg.ActorDisplayName + suffix + } + + return msg.ActorDisplayName +} diff --git a/teleirc/matterbridge/bridge/rocketchat/handlers.go b/teleirc/matterbridge/bridge/rocketchat/handlers.go new file mode 100644 index 0000000..03b66ea --- /dev/null +++ b/teleirc/matterbridge/bridge/rocketchat/handlers.go @@ -0,0 +1,136 @@ +package brocketchat + +import ( + "fmt" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/matterbridge/Rocket.Chat.Go.SDK/models" +) + +func (b *Brocketchat) handleRocket() { + messages := make(chan *config.Message) + if b.GetString("WebhookBindAddress") != "" { + b.Log.Debugf("Choosing webhooks based receiving") + go b.handleRocketHook(messages) + } else { + b.Log.Debugf("Choosing login/password based receiving") + go b.handleRocketClient(messages) + } + for message := range messages { + message.Account = b.Account + b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account) + b.Log.Debugf("<= Message is %#v", message) + b.Remote <- *message + } +} + +func (b *Brocketchat) handleRocketHook(messages chan *config.Message) { + for { + message := b.rh.Receive() + b.Log.Debugf("Receiving from rockethook %#v", message) + // do not loop + if message.UserName == b.GetString("Nick") { + continue + } + messages <- &config.Message{ + UserID: message.UserID, + Username: message.UserName, + Text: message.Text, + Channel: message.ChannelName, + } + } +} + +func (b *Brocketchat) handleStatusEvent(ev models.Message, rmsg *config.Message) bool { + switch ev.Type { + case "": + // this is a normal message, no processing needed + // return true so the message is not dropped + return true + case sUserJoined, sUserLeft: + rmsg.Event = config.EventJoinLeave + return true + case sRoomChangedTopic: + rmsg.Event = config.EventTopicChange + return true + } + b.Log.Debugf("Dropping message with unknown type: %s", ev.Type) + return false +} + +func (b *Brocketchat) handleRocketClient(messages chan *config.Message) { + for message := range b.messageChan { + message := message + // skip messages with same ID, apparently messages get duplicated for an unknown reason + if _, ok := b.cache.Get(message.ID); ok { + continue + } + b.cache.Add(message.ID, true) + b.Log.Debugf("message %#v", message) + m := message + if b.skipMessage(&m) { + b.Log.Debugf("Skipped message: %#v", message) + continue + } + + rmsg := &config.Message{Text: message.Msg, + Username: message.User.UserName, + Channel: b.getChannelName(message.RoomID), + Account: b.Account, + UserID: message.User.ID, + ID: message.ID, + Extra: make(map[string][]interface{}), + } + + b.handleAttachments(&message, rmsg) + + // handleStatusEvent returns false if the message should be dropped + // in that case it is probably some modification to the channel we do not want to relay + if b.handleStatusEvent(m, rmsg) { + messages <- rmsg + } + } +} + +func (b *Brocketchat) handleAttachments(message *models.Message, rmsg *config.Message) { + if rmsg.Text == "" { + for _, attachment := range message.Attachments { + if attachment.Title != "" { + rmsg.Text = attachment.Title + "\n" + } + if attachment.Title != "" && attachment.Text != "" { + rmsg.Text += "\n" + } + if attachment.Text != "" { + rmsg.Text += attachment.Text + } + } + } + + for i := range message.Attachments { + if err := b.handleDownloadFile(rmsg, &message.Attachments[i]); err != nil { + b.Log.Errorf("Could not download incoming file: %#v", err) + } + } +} + +func (b *Brocketchat) handleDownloadFile(rmsg *config.Message, file *models.Attachment) error { + downloadURL := b.GetString("server") + file.TitleLink + data, err := helper.DownloadFileAuthRocket(downloadURL, b.user.Token, b.user.ID) + if err != nil { + return fmt.Errorf("download %s failed %#v", downloadURL, err) + } + helper.HandleDownloadData(b.Log, rmsg, file.Title, rmsg.Text, downloadURL, data, b.General) + return nil +} + +func (b *Brocketchat) handleUploadFile(msg *config.Message) error { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if err := b.uploadFile(&fi, b.getChannelID(msg.Channel)); err != nil { + return err + } + } + return nil +} diff --git a/teleirc/matterbridge/bridge/rocketchat/helpers.go b/teleirc/matterbridge/bridge/rocketchat/helpers.go new file mode 100644 index 0000000..936f631 --- /dev/null +++ b/teleirc/matterbridge/bridge/rocketchat/helpers.go @@ -0,0 +1,201 @@ +package brocketchat + +import ( + "context" + "io/ioutil" + "mime" + "net/http" + "net/url" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/42wim/matterbridge/hook/rockethook" + "github.com/42wim/matterbridge/matterhook" + "github.com/matterbridge/Rocket.Chat.Go.SDK/models" + "github.com/matterbridge/Rocket.Chat.Go.SDK/realtime" + "github.com/matterbridge/Rocket.Chat.Go.SDK/rest" + "github.com/nelsonken/gomf" +) + +func (b *Brocketchat) doConnectWebhookBind() error { + switch { + case b.GetString("WebhookURL") != "": + b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)") + b.mh = matterhook.New(b.GetString("WebhookURL"), + matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"), + DisableServer: true}) + b.rh = rockethook.New(b.GetString("WebhookURL"), rockethook.Config{BindAddress: b.GetString("WebhookBindAddress")}) + case b.GetString("Login") != "": + b.Log.Info("Connecting using login/password (sending)") + err := b.apiLogin() + if err != nil { + return err + } + default: + b.Log.Info("Connecting using webhookbindaddress (receiving)") + b.rh = rockethook.New(b.GetString("WebhookURL"), rockethook.Config{BindAddress: b.GetString("WebhookBindAddress")}) + } + return nil +} + +func (b *Brocketchat) doConnectWebhookURL() error { + b.Log.Info("Connecting using webhookurl (sending)") + b.mh = matterhook.New(b.GetString("WebhookURL"), + matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"), + DisableServer: true}) + if b.GetString("Login") != "" { + b.Log.Info("Connecting using login/password (receiving)") + err := b.apiLogin() + if err != nil { + return err + } + } + return nil +} + +func (b *Brocketchat) apiLogin() error { + b.Log.Debugf("handling apiLogin()") + credentials := &models.UserCredentials{Email: b.GetString("login"), Password: b.GetString("password")} + if b.GetString("Token") != "" { + credentials = &models.UserCredentials{ID: b.GetString("Login"), Token: b.GetString("Token")} + } + myURL, err := url.Parse(b.GetString("server")) + if err != nil { + return err + } + client, err := realtime.NewClient(myURL, b.GetBool("debug")) + b.c = client + if err != nil { + return err + } + restclient := rest.NewClient(myURL, b.GetBool("debug")) + user, err := b.c.Login(credentials) + if err != nil { + return err + } + b.user = user + b.r = restclient + err = b.r.Login(credentials) + if err != nil { + return err + } + b.Log.Info("Connection succeeded") + return nil +} + +func (b *Brocketchat) getChannelName(id string) string { + b.RLock() + defer b.RUnlock() + if name, ok := b.channelMap[id]; ok { + return name + } + return "" +} + +func (b *Brocketchat) getChannelID(name string) string { + b.RLock() + defer b.RUnlock() + for k, v := range b.channelMap { + if v == name || v == "#"+name { + return k + } + } + return "" +} + +func (b *Brocketchat) skipMessage(message *models.Message) bool { + return message.User.ID == b.user.ID +} + +func (b *Brocketchat) uploadFile(fi *config.FileInfo, channel string) error { + fb := gomf.New() + if err := fb.WriteField("description", fi.Comment); err != nil { + return err + } + sp := strings.Split(fi.Name, ".") + mtype := mime.TypeByExtension("." + sp[len(sp)-1]) + if !strings.Contains(mtype, "image") && !strings.Contains(mtype, "video") { + return nil + } + if err := fb.WriteFile("file", fi.Name, mtype, *fi.Data); err != nil { + return err + } + req, err := fb.GetHTTPRequest(context.TODO(), b.GetString("server")+"/api/v1/rooms.upload/"+channel) + if err != nil { + return err + } + req.Header.Add("X-Auth-Token", b.user.Token) + req.Header.Add("X-User-Id", b.user.ID) + client := &http.Client{ + Timeout: time.Second * 5, + } + resp, err := client.Do(req) + if err != nil { + return err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != 200 { + b.Log.Errorf("failed: %#v", string(body)) + } + return nil +} + +// sendWebhook uses the configured WebhookURL to send the message +func (b *Brocketchat) sendWebhook(msg *config.Message) error { + // skip events + if msg.Event != "" { + return nil + } + + if b.GetBool("PrefixMessagesWithNick") { + msg.Text = msg.Username + msg.Text + } + if msg.Extra != nil { + // this sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE + for _, rmsg := range helper.HandleExtra(msg, b.General) { + rmsg := rmsg // scopelint + iconURL := config.GetIconURL(&rmsg, b.GetString("iconurl")) + matterMessage := matterhook.OMessage{ + IconURL: iconURL, + Channel: rmsg.Channel, + UserName: rmsg.Username, + Text: rmsg.Text, + Props: make(map[string]interface{}), + } + if err := b.mh.Send(matterMessage); err != nil { + b.Log.Errorf("sendWebhook failed: %s ", err) + } + } + + // webhook doesn't support file uploads, so we add the url manually + if len(msg.Extra["file"]) > 0 { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.URL != "" { + msg.Text += fi.URL + } + } + } + } + iconURL := config.GetIconURL(msg, b.GetString("iconurl")) + matterMessage := matterhook.OMessage{ + IconURL: iconURL, + Channel: msg.Channel, + UserName: msg.Username, + Text: msg.Text, + } + if msg.Avatar != "" { + matterMessage.IconURL = msg.Avatar + } + err := b.mh.Send(matterMessage) + if err != nil { + b.Log.Info(err) + return err + } + return nil +} diff --git a/teleirc/matterbridge/bridge/rocketchat/rocketchat.go b/teleirc/matterbridge/bridge/rocketchat/rocketchat.go new file mode 100644 index 0000000..405bead --- /dev/null +++ b/teleirc/matterbridge/bridge/rocketchat/rocketchat.go @@ -0,0 +1,181 @@ +package brocketchat + +import ( + "errors" + "strings" + "sync" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/42wim/matterbridge/hook/rockethook" + "github.com/42wim/matterbridge/matterhook" + lru "github.com/hashicorp/golang-lru" + "github.com/matterbridge/Rocket.Chat.Go.SDK/models" + "github.com/matterbridge/Rocket.Chat.Go.SDK/realtime" + "github.com/matterbridge/Rocket.Chat.Go.SDK/rest" +) + +type Brocketchat struct { + mh *matterhook.Client + rh *rockethook.Client + c *realtime.Client + r *rest.Client + cache *lru.Cache + *bridge.Config + messageChan chan models.Message + channelMap map[string]string + user *models.User + sync.RWMutex +} + +const ( + sUserJoined = "uj" + sUserLeft = "ul" + sRoomChangedTopic = "room_changed_topic" +) + +func New(cfg *bridge.Config) bridge.Bridger { + newCache, err := lru.New(100) + if err != nil { + cfg.Log.Fatalf("Could not create LRU cache for rocketchat bridge: %v", err) + } + b := &Brocketchat{ + Config: cfg, + messageChan: make(chan models.Message), + channelMap: make(map[string]string), + cache: newCache, + } + b.Log.Debugf("enabling rocketchat") + return b +} + +func (b *Brocketchat) Command(cmd string) string { + return "" +} + +func (b *Brocketchat) Connect() error { + if b.GetString("WebhookBindAddress") != "" { + if err := b.doConnectWebhookBind(); err != nil { + return err + } + go b.handleRocket() + return nil + } + switch { + case b.GetString("WebhookURL") != "": + if err := b.doConnectWebhookURL(); err != nil { + return err + } + go b.handleRocket() + return nil + case b.GetString("Login") != "": + b.Log.Info("Connecting using login/password (sending and receiving)") + err := b.apiLogin() + if err != nil { + return err + } + go b.handleRocket() + } + if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" && + b.GetString("Login") == "" { + return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Login/Password/Server configured") + } + return nil +} + +func (b *Brocketchat) Disconnect() error { + return nil +} + +func (b *Brocketchat) JoinChannel(channel config.ChannelInfo) error { + if b.c == nil { + return nil + } + id, err := b.c.GetChannelId(strings.TrimPrefix(channel.Name, "#")) + if err != nil { + return err + } + b.Lock() + b.channelMap[id] = channel.Name + b.Unlock() + mychannel := &models.Channel{ID: id, Name: strings.TrimPrefix(channel.Name, "#")} + if err := b.c.JoinChannel(id); err != nil { + return err + } + if err := b.c.SubscribeToMessageStream(mychannel, b.messageChan); err != nil { + return err + } + return nil +} + +func (b *Brocketchat) Send(msg config.Message) (string, error) { + // strip the # if people has set this + msg.Channel = strings.TrimPrefix(msg.Channel, "#") + channel := &models.Channel{ID: b.getChannelID(msg.Channel), Name: msg.Channel} + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + msg.Text = "_" + msg.Text + "_" + } + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + return msg.ID, b.c.DeleteMessage(&models.Message{ID: msg.ID}) + } + + // Use webhook to send the message + if b.GetString("WebhookURL") != "" { + return "", b.sendWebhook(&msg) + } + + // Prepend nick if configured + if b.GetBool("PrefixMessagesWithNick") { + msg.Text = msg.Username + msg.Text + } + + // Edit message if we have an ID + if msg.ID != "" { + return msg.ID, b.c.EditMessage(&models.Message{ID: msg.ID, Msg: msg.Text, RoomID: b.getChannelID(msg.Channel)}) + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + // strip the # if people has set this + rmsg.Channel = strings.TrimPrefix(rmsg.Channel, "#") + smsg := &models.Message{ + RoomID: b.getChannelID(rmsg.Channel), + Msg: rmsg.Username + rmsg.Text, + PostMessage: models.PostMessage{ + Avatar: rmsg.Avatar, + Alias: rmsg.Username, + }, + } + if _, err := b.c.SendMessage(smsg); err != nil { + b.Log.Errorf("SendMessage failed: %s", err) + } + } + if len(msg.Extra["file"]) > 0 { + return "", b.handleUploadFile(&msg) + } + } + + smsg := &models.Message{ + RoomID: channel.ID, + Msg: msg.Text, + PostMessage: models.PostMessage{ + Avatar: msg.Avatar, + Alias: msg.Username, + }, + } + + rmsg, err := b.c.SendMessage(smsg) + if rmsg == nil { + return "", err + } + return rmsg.ID, err +} diff --git a/teleirc/matterbridge/bridge/slack/handlers.go b/teleirc/matterbridge/bridge/slack/handlers.go new file mode 100644 index 0000000..2424442 --- /dev/null +++ b/teleirc/matterbridge/bridge/slack/handlers.go @@ -0,0 +1,415 @@ +package bslack + +import ( + "errors" + "fmt" + "html" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/slack-go/slack" +) + +// ErrEventIgnored is for events that should be ignored +var ErrEventIgnored = errors.New("this event message should ignored") + +func (b *Bslack) handleSlack() { + messages := make(chan *config.Message) + if b.GetString(incomingWebhookConfig) != "" && b.GetString(tokenConfig) == "" { + b.Log.Debugf("Choosing webhooks based receiving") + go b.handleMatterHook(messages) + } else { + b.Log.Debugf("Choosing token based receiving") + go b.handleSlackClient(messages) + } + time.Sleep(time.Second) + b.Log.Debug("Start listening for Slack messages") + for message := range messages { + // don't do any action on deleted/typing messages + if message.Event != config.EventUserTyping && message.Event != config.EventMsgDelete && + message.Event != config.EventFileDelete { + b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account) + // cleanup the message + message.Text = b.replaceMention(message.Text) + message.Text = b.replaceVariable(message.Text) + message.Text = b.replaceChannel(message.Text) + message.Text = b.replaceURL(message.Text) + message.Text = b.replaceb0rkedMarkDown(message.Text) + message.Text = html.UnescapeString(message.Text) + + // Add the avatar + message.Avatar = b.users.getAvatar(message.UserID) + } + + b.Log.Debugf("<= Message is %#v", message) + b.Remote <- *message + } +} + +func (b *Bslack) handleSlackClient(messages chan *config.Message) { + for msg := range b.rtm.IncomingEvents { + if msg.Type != sUserTyping && msg.Type != sHello && msg.Type != sLatencyReport { + b.Log.Debugf("== Receiving event %#v", msg.Data) + } + switch ev := msg.Data.(type) { + case *slack.UserTypingEvent: + if !b.GetBool("ShowUserTyping") { + continue + } + rmsg, err := b.handleTypingEvent(ev) + if err == ErrEventIgnored { + continue + } else if err != nil { + b.Log.Errorf("%#v", err) + continue + } + + messages <- rmsg + case *slack.MessageEvent: + if b.skipMessageEvent(ev) { + b.Log.Debugf("Skipped message: %#v", ev) + continue + } + rmsg, err := b.handleMessageEvent(ev) + if err != nil { + b.Log.Errorf("%#v", err) + continue + } + messages <- rmsg + case *slack.FileDeletedEvent: + rmsg, err := b.handleFileDeletedEvent(ev) + if err != nil { + b.Log.Printf("%#v", err) + continue + } + messages <- rmsg + case *slack.OutgoingErrorEvent: + b.Log.Debugf("%#v", ev.Error()) + case *slack.ChannelJoinedEvent: + // When we join a channel we update the full list of users as + // well as the information for the channel that we joined as this + // should now tell that we are a member of it. + b.channels.registerChannel(ev.Channel) + case *slack.ConnectedEvent: + b.si = ev.Info + b.channels.populateChannels(true) + b.users.populateUsers(true) + case *slack.InvalidAuthEvent: + b.Log.Fatalf("Invalid Token %#v", ev) + case *slack.ConnectionErrorEvent: + b.Log.Errorf("Connection failed %#v %#v", ev.Error(), ev.ErrorObj) + case *slack.MemberJoinedChannelEvent: + b.users.populateUser(ev.User) + case *slack.HelloEvent, *slack.LatencyReport, *slack.ConnectingEvent: + continue + case *slack.UserChangeEvent: + b.users.invalidateUser(ev.User.ID) + default: + b.Log.Debugf("Unhandled incoming event: %T", ev) + } + } +} + +func (b *Bslack) handleMatterHook(messages chan *config.Message) { + for { + message := b.mh.Receive() + b.Log.Debugf("receiving from matterhook (slack) %#v", message) + if message.UserName == "slackbot" { + continue + } + messages <- &config.Message{ + Username: message.UserName, + Text: message.Text, + Channel: message.ChannelName, + } + } +} + +// skipMessageEvent skips event that need to be skipped :-) +func (b *Bslack) skipMessageEvent(ev *slack.MessageEvent) bool { + switch ev.SubType { + case sChannelLeave, sChannelJoin: + return b.GetBool(noSendJoinConfig) + case sPinnedItem, sUnpinnedItem: + return true + case sChannelTopic, sChannelPurpose: + // Skip the event if our bot/user account changed the topic/purpose + if ev.User == b.si.User.ID { + return true + } + } + + // Check for our callback ID + hasOurCallbackID := false + if len(ev.Blocks.BlockSet) == 1 { + block, ok := ev.Blocks.BlockSet[0].(*slack.SectionBlock) + hasOurCallbackID = ok && block.BlockID == "matterbridge_"+b.uuid + } + + if ev.SubMessage != nil { + // It seems ev.SubMessage.Edited == nil when slack unfurls. + // Do not forward these messages. See Github issue #266. + if ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp && + ev.SubMessage.Edited == nil { + return true + } + // see hidden subtypes at https://api.slack.com/events/message + // these messages are sent when we add a message to a thread #709 + if ev.SubType == "message_replied" && ev.Hidden { + return true + } + if len(ev.SubMessage.Blocks.BlockSet) == 1 { + block, ok := ev.SubMessage.Blocks.BlockSet[0].(*slack.SectionBlock) + hasOurCallbackID = ok && block.BlockID == "matterbridge_"+b.uuid + } + } + + // Skip any messages that we made ourselves or from 'slackbot' (see #527). + if ev.Username == sSlackBotUser || + (b.rtm != nil && ev.Username == b.si.User.Name) || hasOurCallbackID { + return true + } + + if len(ev.Files) > 0 { + return b.filesCached(ev.Files) + } + return false +} + +func (b *Bslack) filesCached(files []slack.File) bool { + for i := range files { + if !b.fileCached(&files[i]) { + return false + } + } + return true +} + +// handleMessageEvent handles the message events. Together with any called sub-methods, +// this method implements the following event processing pipeline: +// +// 1. Check if the message should be ignored. +// NOTE: This is not actually part of the method below but is done just before it +// is called via the 'skipMessageEvent()' method. +// 2. Populate the Matterbridge message that will be sent to the router based on the +// received event and logic that is common to all events that are not skipped. +// 3. Detect and handle any message that is "status" related (think join channel, etc.). +// This might result in an early exit from the pipeline and passing of the +// pre-populated message to the Matterbridge router. +// 4. Handle the specific case of messages that edit existing messages depending on +// configuration. +// 5. Handle any attachments of the received event. +// 6. Check that the Matterbridge message that we end up with after at the end of the +// pipeline is valid before sending it to the Matterbridge router. +func (b *Bslack) handleMessageEvent(ev *slack.MessageEvent) (*config.Message, error) { + rmsg, err := b.populateReceivedMessage(ev) + if err != nil { + return nil, err + } + + // Handle some message types early. + if b.handleStatusEvent(ev, rmsg) { + return rmsg, nil + } + + b.handleAttachments(ev, rmsg) + + // Verify that we have the right information and the message + // is well-formed before sending it out to the router. + if len(ev.Files) == 0 && (rmsg.Text == "" || rmsg.Username == "") { + if ev.BotID != "" { + // This is probably a webhook we couldn't resolve. + return nil, fmt.Errorf("message handling resulted in an empty bot message (probably an incoming webhook we couldn't resolve): %#v", ev) + } + if ev.SubMessage != nil { + return nil, fmt.Errorf("message handling resulted in an empty message: %#v with submessage %#v", ev, ev.SubMessage) + } + return nil, fmt.Errorf("message handling resulted in an empty message: %#v", ev) + } + return rmsg, nil +} + +func (b *Bslack) handleFileDeletedEvent(ev *slack.FileDeletedEvent) (*config.Message, error) { + if rawChannel, ok := b.cache.Get(cfileDownloadChannel + ev.FileID); ok { + channel, err := b.channels.getChannelByID(rawChannel.(string)) + if err != nil { + return nil, err + } + + return &config.Message{ + Event: config.EventFileDelete, + Text: config.EventFileDelete, + Channel: channel.Name, + Account: b.Account, + ID: ev.FileID, + Protocol: b.Protocol, + }, nil + } + + return nil, fmt.Errorf("channel ID for file ID %s not found", ev.FileID) +} + +func (b *Bslack) handleStatusEvent(ev *slack.MessageEvent, rmsg *config.Message) bool { + switch ev.SubType { + case sChannelJoined, sMemberJoined: + // There's no further processing needed on channel events + // so we return 'true'. + return true + case sChannelJoin, sChannelLeave: + rmsg.Username = sSystemUser + rmsg.Event = config.EventJoinLeave + case sChannelTopic, sChannelPurpose: + b.channels.populateChannels(false) + rmsg.Event = config.EventTopicChange + case sMessageChanged: + rmsg.Text = ev.SubMessage.Text + // handle deleted thread starting messages + if ev.SubMessage.Text == "This message was deleted." { + rmsg.Event = config.EventMsgDelete + return true + } + case sMessageDeleted: + rmsg.Text = config.EventMsgDelete + rmsg.Event = config.EventMsgDelete + rmsg.ID = ev.DeletedTimestamp + // If a message is being deleted we do not need to process + // the event any further so we return 'true'. + return true + case sMeMessage: + rmsg.Event = config.EventUserAction + } + return false +} + +func getMessageTitle(attach *slack.Attachment) string { + if attach.TitleLink != "" { + return fmt.Sprintf("[%s](%s)\n", attach.Title, attach.TitleLink) + } + return attach.Title +} + +func (b *Bslack) handleAttachments(ev *slack.MessageEvent, rmsg *config.Message) { + // File comments are set by the system (because there is no username given). + if ev.SubType == sFileComment { + rmsg.Username = sSystemUser + } + + // See if we have some text in the attachments. + if rmsg.Text == "" { + for i, attach := range ev.Attachments { + if attach.Text != "" { + if attach.Title != "" { + rmsg.Text = getMessageTitle(&ev.Attachments[i]) + } + rmsg.Text += attach.Text + if attach.Footer != "" { + rmsg.Text += "\n\n" + attach.Footer + } + } else { + rmsg.Text = attach.Fallback + } + } + } + + // Save the attachments, so that we can send them to other slack (compatible) bridges. + if len(ev.Attachments) > 0 { + rmsg.Extra[sSlackAttachment] = append(rmsg.Extra[sSlackAttachment], ev.Attachments) + } + + // If we have files attached, download them (in memory) and put a pointer to it in msg.Extra. + for i := range ev.Files { + // keep reference in cache on which channel we added this file + b.cache.Add(cfileDownloadChannel+ev.Files[i].ID, ev.Channel) + if err := b.handleDownloadFile(rmsg, &ev.Files[i], false); err != nil { + b.Log.Errorf("Could not download incoming file: %#v", err) + } + } +} + +func (b *Bslack) handleTypingEvent(ev *slack.UserTypingEvent) (*config.Message, error) { + if ev.User == b.si.User.ID { + return nil, ErrEventIgnored + } + channelInfo, err := b.channels.getChannelByID(ev.Channel) + if err != nil { + return nil, err + } + return &config.Message{ + Channel: channelInfo.Name, + Account: b.Account, + Event: config.EventUserTyping, + }, nil +} + +// handleDownloadFile handles file download +func (b *Bslack) handleDownloadFile(rmsg *config.Message, file *slack.File, retry bool) error { + if b.fileCached(file) { + return nil + } + // Check that the file is neither too large nor blacklisted. + if err := helper.HandleDownloadSize(b.Log, rmsg, file.Name, int64(file.Size), b.General); err != nil { + b.Log.WithError(err).Infof("Skipping download of incoming file.") + return nil + } + + // Actually download the file. + data, err := helper.DownloadFileAuth(file.URLPrivateDownload, "Bearer "+b.GetString(tokenConfig)) + if err != nil { + return fmt.Errorf("download %s failed %#v", file.URLPrivateDownload, err) + } + + if len(*data) != file.Size && !retry { + b.Log.Debugf("Data size (%d) is not equal to size declared (%d)\n", len(*data), file.Size) + time.Sleep(1 * time.Second) + return b.handleDownloadFile(rmsg, file, true) + } + + // If a comment is attached to the file(s) it is in the 'Text' field of the Slack messge event + // and should be added as comment to only one of the files. We reset the 'Text' field to ensure + // that the comment is not duplicated. + comment := rmsg.Text + rmsg.Text = "" + helper.HandleDownloadData2(b.Log, rmsg, file.Name, file.ID, comment, file.URLPrivateDownload, data, b.General) + return nil +} + +// handleGetChannelMembers handles messages containing the GetChannelMembers event +// Sends a message to the router containing *config.ChannelMembers +func (b *Bslack) handleGetChannelMembers(rmsg *config.Message) bool { + if rmsg.Event != config.EventGetChannelMembers { + return false + } + + cMembers := b.channels.getChannelMembers(b.users) + + extra := make(map[string][]interface{}) + extra[config.EventGetChannelMembers] = append(extra[config.EventGetChannelMembers], cMembers) + msg := config.Message{ + Extra: extra, + Event: config.EventGetChannelMembers, + Account: b.Account, + } + + b.Log.Debugf("sending msg to remote %#v", msg) + b.Remote <- msg + + return true +} + +// fileCached implements Matterbridge's caching logic for files +// shared via Slack. +// +// We consider that a file was cached if its ID was added in the last minute or +// it's name was registered in the last 10 seconds. This ensures that an +// identically named file but with different content will be uploaded correctly +// (the assumption is that such name collisions will not occur within the given +// timeframes). +func (b *Bslack) fileCached(file *slack.File) bool { + if ts, ok := b.cache.Get("file" + file.ID); ok && time.Since(ts.(time.Time)) < time.Minute { + return true + } else if ts, ok = b.cache.Get("filename" + file.Name); ok && time.Since(ts.(time.Time)) < 10*time.Second { + return true + } + return false +} diff --git a/teleirc/matterbridge/bridge/slack/helpers.go b/teleirc/matterbridge/bridge/slack/helpers.go new file mode 100644 index 0000000..e46e272 --- /dev/null +++ b/teleirc/matterbridge/bridge/slack/helpers.go @@ -0,0 +1,255 @@ +package bslack + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" +) + +// populateReceivedMessage shapes the initial Matterbridge message that we will forward to the +// router before we apply message-dependent modifications. +func (b *Bslack) populateReceivedMessage(ev *slack.MessageEvent) (*config.Message, error) { + // Use our own func because rtm.GetChannelInfo doesn't work for private channels. + channel, err := b.channels.getChannelByID(ev.Channel) + if err != nil { + return nil, err + } + + rmsg := &config.Message{ + Text: ev.Text, + Channel: channel.Name, + Account: b.Account, + ID: ev.Timestamp, + Extra: make(map[string][]interface{}), + ParentID: ev.ThreadTimestamp, + Protocol: b.Protocol, + } + if b.useChannelID { + rmsg.Channel = "ID:" + channel.ID + } + + // Handle 'edit' messages. + if ev.SubMessage != nil && !b.GetBool(editDisableConfig) { + rmsg.ID = ev.SubMessage.Timestamp + if ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp { + b.Log.Debugf("SubMessage %#v", ev.SubMessage) + rmsg.Text = ev.SubMessage.Text + b.GetString(editSuffixConfig) + } + } + + // For edits, only submessage has thread ts. + // Ensures edits to threaded messages maintain their prefix hint on the + // unthreaded end. + if ev.SubMessage != nil { + rmsg.ParentID = ev.SubMessage.ThreadTimestamp + } + + if err = b.populateMessageWithUserInfo(ev, rmsg); err != nil { + return nil, err + } + return rmsg, err +} + +func (b *Bslack) populateMessageWithUserInfo(ev *slack.MessageEvent, rmsg *config.Message) error { + if ev.SubType == sMessageDeleted || ev.SubType == sFileComment { + return nil + } + + // First, deal with bot-originating messages but only do so when not using webhooks: we + // would not be able to distinguish which bot would be sending them. + if err := b.populateMessageWithBotInfo(ev, rmsg); err != nil { + return err + } + + // Second, deal with "real" users if we have the necessary information. + var userID string + switch { + case ev.User != "": + userID = ev.User + case ev.SubMessage != nil && ev.SubMessage.User != "": + userID = ev.SubMessage.User + default: + return nil + } + + user := b.users.getUser(userID) + if user == nil { + return fmt.Errorf("could not find information for user with id %s", ev.User) + } + + rmsg.UserID = user.ID + rmsg.Username = user.Name + if user.Profile.DisplayName != "" { + rmsg.Username = user.Profile.DisplayName + } + if b.GetBool("UseFullName") && user.Profile.RealName != "" { + rmsg.Username = user.Profile.RealName + } + return nil +} + +func (b *Bslack) populateMessageWithBotInfo(ev *slack.MessageEvent, rmsg *config.Message) error { + if ev.BotID == "" || b.GetString(outgoingWebhookConfig) != "" { + return nil + } + + var err error + var bot *slack.Bot + for { + bot, err = b.rtm.GetBotInfo(ev.BotID) + if err == nil { + break + } + + if err = handleRateLimit(b.Log, err); err != nil { + b.Log.Errorf("Could not retrieve bot information: %#v", err) + return err + } + } + b.Log.Debugf("Found bot %#v", bot) + + if bot.Name != "" { + rmsg.Username = bot.Name + if ev.Username != "" { + rmsg.Username = ev.Username + } + rmsg.UserID = bot.ID + } + return nil +} + +var ( + mentionRE = regexp.MustCompile(`<@([a-zA-Z0-9]+)>`) + channelRE = regexp.MustCompile(`<#[a-zA-Z0-9]+\|(.+?)>`) + variableRE = regexp.MustCompile(`<!((?:subteam\^)?[a-zA-Z0-9]+)(?:\|@?(.+?))?>`) + urlRE = regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) + codeFenceRE = regexp.MustCompile(`(?m)^` + "```" + `\w+$`) + topicOrPurposeRE = regexp.MustCompile(`(?s)(@.+) (cleared|set)(?: the)? channel (topic|purpose)(?:: (.*))?`) +) + +func (b *Bslack) extractTopicOrPurpose(text string) (string, string) { + r := topicOrPurposeRE.FindStringSubmatch(text) + if len(r) == 5 { + action, updateType, extracted := r[2], r[3], r[4] + switch action { + case "set": + return updateType, extracted + case "cleared": + return updateType, "" + } + } + b.Log.Warnf("Encountered channel topic or purpose change message with unexpected format: %s", text) + return "unknown", "" +} + +// @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users +func (b *Bslack) replaceMention(text string) string { + replaceFunc := func(match string) string { + userID := strings.Trim(match, "@<>") + if username := b.users.getUsername(userID); userID != "" { + return "@" + username + } + return match + } + return mentionRE.ReplaceAllStringFunc(text, replaceFunc) +} + +// @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users +func (b *Bslack) replaceChannel(text string) string { + for _, r := range channelRE.FindAllStringSubmatch(text, -1) { + text = strings.Replace(text, r[0], "#"+r[1], 1) + } + return text +} + +// @see https://api.slack.com/docs/message-formatting#variables +func (b *Bslack) replaceVariable(text string) string { + for _, r := range variableRE.FindAllStringSubmatch(text, -1) { + if r[2] != "" { + text = strings.Replace(text, r[0], "@"+r[2], 1) + } else { + text = strings.Replace(text, r[0], "@"+r[1], 1) + } + } + return text +} + +// @see https://api.slack.com/docs/message-formatting#linking_to_urls +func (b *Bslack) replaceURL(text string) string { + return urlRE.ReplaceAllString(text, "[${2}](${1})") +} + +func (b *Bslack) replaceb0rkedMarkDown(text string) string { + // taken from https://github.com/mattermost/mattermost-server/blob/master/app/slackimport.go + // + regexReplaceAllString := []struct { + regex *regexp.Regexp + rpl string + }{ + // bold + { + regexp.MustCompile(`(^|[\s.;,])\*(\S[^*\n]+)\*`), + "$1**$2**", + }, + // strikethrough + { + regexp.MustCompile(`(^|[\s.;,])\~(\S[^~\n]+)\~`), + "$1~~$2~~", + }, + // single paragraph blockquote + // Slack converts > character to > + { + regexp.MustCompile(`(?sm)^>`), + ">", + }, + } + for _, rule := range regexReplaceAllString { + text = rule.regex.ReplaceAllString(text, rule.rpl) + } + return text +} + +func (b *Bslack) replaceCodeFence(text string) string { + return codeFenceRE.ReplaceAllString(text, "```") +} + +// getUsersInConversation returns an array of userIDs that are members of channelID +func (b *Bslack) getUsersInConversation(channelID string) ([]string, error) { + channelMembers := []string{} + for { + queryParams := &slack.GetUsersInConversationParameters{ + ChannelID: channelID, + } + + members, nextCursor, err := b.sc.GetUsersInConversation(queryParams) + if err != nil { + if err = handleRateLimit(b.Log, err); err != nil { + return channelMembers, fmt.Errorf("Could not retrieve users in channels: %#v", err) + } + continue + } + + channelMembers = append(channelMembers, members...) + + if nextCursor == "" { + break + } + queryParams.Cursor = nextCursor + } + return channelMembers, nil +} + +func handleRateLimit(log *logrus.Entry, err error) error { + rateLimit, ok := err.(*slack.RateLimitedError) + if !ok { + return err + } + log.Infof("Rate-limited by Slack. Sleeping for %v", rateLimit.RetryAfter) + time.Sleep(rateLimit.RetryAfter) + return nil +} diff --git a/teleirc/matterbridge/bridge/slack/helpers_test.go b/teleirc/matterbridge/bridge/slack/helpers_test.go new file mode 100644 index 0000000..fe3ba41 --- /dev/null +++ b/teleirc/matterbridge/bridge/slack/helpers_test.go @@ -0,0 +1,36 @@ +package bslack + +import ( + "io/ioutil" + "testing" + + "github.com/42wim/matterbridge/bridge" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestExtractTopicOrPurpose(t *testing.T) { + testcases := map[string]struct { + input string + wantChangeType string + wantOutput string + }{ + "success - topic type": {"@someone set channel topic: foo bar", "topic", "foo bar"}, + "success - purpose type": {"@someone set channel purpose: foo bar", "purpose", "foo bar"}, + "success - one line": {"@someone set channel topic: foo bar", "topic", "foo bar"}, + "success - multi-line": {"@someone set channel topic: foo\nbar", "topic", "foo\nbar"}, + "success - cleared": {"@someone cleared channel topic", "topic", ""}, + "error - unhandled": {"some unmatched message", "unknown", ""}, + } + + logger := logrus.New() + logger.SetOutput(ioutil.Discard) + cfg := &bridge.Config{Bridge: &bridge.Bridge{Log: logrus.NewEntry(logger)}} + b := newBridge(cfg) + for name, tc := range testcases { + gotChangeType, gotOutput := b.extractTopicOrPurpose(tc.input) + + assert.Equalf(t, tc.wantChangeType, gotChangeType, "This testcase failed: %s", name) + assert.Equalf(t, tc.wantOutput, gotOutput, "This testcase failed: %s", name) + } +} diff --git a/teleirc/matterbridge/bridge/slack/legacy.go b/teleirc/matterbridge/bridge/slack/legacy.go new file mode 100644 index 0000000..d89d286 --- /dev/null +++ b/teleirc/matterbridge/bridge/slack/legacy.go @@ -0,0 +1,80 @@ +package bslack + +import ( + "errors" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/matterhook" + "github.com/slack-go/slack" +) + +type BLegacy struct { + *Bslack +} + +func NewLegacy(cfg *bridge.Config) bridge.Bridger { + b := &BLegacy{Bslack: newBridge(cfg)} + b.legacy = true + return b +} + +func (b *BLegacy) Connect() error { + b.RLock() + defer b.RUnlock() + if b.GetString(incomingWebhookConfig) != "" { + switch { + case b.GetString(outgoingWebhookConfig) != "": + b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)") + b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{ + InsecureSkipVerify: b.GetBool(skipTLSConfig), + BindAddress: b.GetString(incomingWebhookConfig), + }) + case b.GetString(tokenConfig) != "": + b.Log.Info("Connecting using token (sending)") + b.sc = slack.New(b.GetString(tokenConfig)) + b.rtm = b.sc.NewRTM() + go b.rtm.ManageConnection() + b.Log.Info("Connecting using webhookbindaddress (receiving)") + b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{ + InsecureSkipVerify: b.GetBool(skipTLSConfig), + BindAddress: b.GetString(incomingWebhookConfig), + }) + default: + b.Log.Info("Connecting using webhookbindaddress (receiving)") + b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{ + InsecureSkipVerify: b.GetBool(skipTLSConfig), + BindAddress: b.GetString(incomingWebhookConfig), + }) + } + go b.handleSlack() + return nil + } + if b.GetString(outgoingWebhookConfig) != "" { + b.Log.Info("Connecting using webhookurl (sending)") + b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{ + InsecureSkipVerify: b.GetBool(skipTLSConfig), + DisableServer: true, + }) + if b.GetString(tokenConfig) != "" { + b.Log.Info("Connecting using token (receiving)") + b.sc = slack.New(b.GetString(tokenConfig), slack.OptionDebug(b.GetBool("debug"))) + b.channels = newChannelManager(b.Log, b.sc) + b.users = newUserManager(b.Log, b.sc) + b.rtm = b.sc.NewRTM() + go b.rtm.ManageConnection() + go b.handleSlack() + } + } else if b.GetString(tokenConfig) != "" { + b.Log.Info("Connecting using token (sending and receiving)") + b.sc = slack.New(b.GetString(tokenConfig), slack.OptionDebug(b.GetBool("debug"))) + b.channels = newChannelManager(b.Log, b.sc) + b.users = newUserManager(b.Log, b.sc) + b.rtm = b.sc.NewRTM() + go b.rtm.ManageConnection() + go b.handleSlack() + } + if b.GetString(incomingWebhookConfig) == "" && b.GetString(outgoingWebhookConfig) == "" && b.GetString(tokenConfig) == "" { + return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token configured") + } + return nil +} diff --git a/teleirc/matterbridge/bridge/slack/slack.go b/teleirc/matterbridge/bridge/slack/slack.go new file mode 100644 index 0000000..c39c608 --- /dev/null +++ b/teleirc/matterbridge/bridge/slack/slack.go @@ -0,0 +1,566 @@ +package bslack + +import ( + "bytes" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/42wim/matterbridge/matterhook" + lru "github.com/hashicorp/golang-lru" + "github.com/rs/xid" + "github.com/slack-go/slack" +) + +type Bslack struct { + sync.RWMutex + *bridge.Config + + mh *matterhook.Client + sc *slack.Client + rtm *slack.RTM + si *slack.Info + + cache *lru.Cache + uuid string + useChannelID bool + + channels *channels + users *users + legacy bool +} + +const ( + sHello = "hello" + sChannelJoin = "channel_join" + sChannelLeave = "channel_leave" + sChannelJoined = "channel_joined" + sMemberJoined = "member_joined_channel" + sMessageChanged = "message_changed" + sMessageDeleted = "message_deleted" + sSlackAttachment = "slack_attachment" + sPinnedItem = "pinned_item" + sUnpinnedItem = "unpinned_item" + sChannelTopic = "channel_topic" + sChannelPurpose = "channel_purpose" + sFileComment = "file_comment" + sMeMessage = "me_message" + sUserTyping = "user_typing" + sLatencyReport = "latency_report" + sSystemUser = "system" + sSlackBotUser = "slackbot" + cfileDownloadChannel = "file_download_channel" + + tokenConfig = "Token" + incomingWebhookConfig = "WebhookBindAddress" + outgoingWebhookConfig = "WebhookURL" + skipTLSConfig = "SkipTLSVerify" + useNickPrefixConfig = "PrefixMessagesWithNick" + editDisableConfig = "EditDisable" + editSuffixConfig = "EditSuffix" + iconURLConfig = "iconurl" + noSendJoinConfig = "nosendjoinpart" + messageLength = 3000 +) + +func New(cfg *bridge.Config) bridge.Bridger { + // Print a deprecation warning for legacy non-bot tokens (#527). + token := cfg.GetString(tokenConfig) + if token != "" && !strings.HasPrefix(token, "xoxb") { + cfg.Log.Warn("Non-bot token detected. It is STRONGLY recommended to use a proper bot-token instead.") + cfg.Log.Warn("Legacy tokens may be deprecated by Slack at short notice. See the Matterbridge GitHub wiki for a migration guide.") + cfg.Log.Warn("See https://github.com/42wim/matterbridge/wiki/Slack-bot-setup") + return NewLegacy(cfg) + } + return newBridge(cfg) +} + +func newBridge(cfg *bridge.Config) *Bslack { + newCache, err := lru.New(5000) + if err != nil { + cfg.Log.Fatalf("Could not create LRU cache for Slack bridge: %v", err) + } + b := &Bslack{ + Config: cfg, + uuid: xid.New().String(), + cache: newCache, + } + return b +} + +func (b *Bslack) Command(cmd string) string { + return "" +} + +func (b *Bslack) Connect() error { + b.RLock() + defer b.RUnlock() + + if b.GetString(incomingWebhookConfig) == "" && b.GetString(outgoingWebhookConfig) == "" && b.GetString(tokenConfig) == "" { + return errors.New("no connection method found: WebhookBindAddress, WebhookURL or Token need to be configured") + } + + // If we have a token we use the Slack websocket-based RTM for both sending and receiving. + if token := b.GetString(tokenConfig); token != "" { + b.Log.Info("Connecting using token") + + b.sc = slack.New(token, slack.OptionDebug(b.GetBool("Debug"))) + + b.channels = newChannelManager(b.Log, b.sc) + b.users = newUserManager(b.Log, b.sc) + + b.rtm = b.sc.NewRTM() + go b.rtm.ManageConnection() + go b.handleSlack() + return nil + } + + // In absence of a token we fall back to incoming and outgoing Webhooks. + b.mh = matterhook.New( + "", + matterhook.Config{ + InsecureSkipVerify: b.GetBool("SkipTLSVerify"), + DisableServer: true, + }, + ) + if b.GetString(outgoingWebhookConfig) != "" { + b.Log.Info("Using specified webhook for outgoing messages.") + b.mh.Url = b.GetString(outgoingWebhookConfig) + } + if b.GetString(incomingWebhookConfig) != "" { + b.Log.Info("Setting up local webhook for incoming messages.") + b.mh.BindAddress = b.GetString(incomingWebhookConfig) + b.mh.DisableServer = false + go b.handleSlack() + } + return nil +} + +func (b *Bslack) Disconnect() error { + return b.rtm.Disconnect() +} + +// JoinChannel only acts as a verification method that checks whether Matterbridge's +// Slack integration is already member of the channel. This is because Slack does not +// allow apps or bots to join channels themselves and they need to be invited +// manually by a user. +func (b *Bslack) JoinChannel(channel config.ChannelInfo) error { + // We can only join a channel through the Slack API. + if b.sc == nil { + return nil + } + + // try to join a channel when in legacy + if b.legacy { + _, _, _, err := b.sc.JoinConversation(channel.Name) + if err != nil { + switch err.Error() { + case "name_taken", "restricted_action": + case "default": + return err + } + } + } + + b.channels.populateChannels(false) + + channelInfo, err := b.channels.getChannel(channel.Name) + if err != nil { + return fmt.Errorf("could not join channel: %#v", err) + } + + if strings.HasPrefix(channel.Name, "ID:") { + b.useChannelID = true + channel.Name = channelInfo.Name + } + + // we can't join a channel unless we are using legacy tokens #651 + if !channelInfo.IsMember && !b.legacy { + return fmt.Errorf("slack integration that matterbridge is using is not member of channel '%s', please add it manually", channelInfo.Name) + } + return nil +} + +func (b *Bslack) Reload(cfg *bridge.Config) (string, error) { + return "", nil +} + +func (b *Bslack) Send(msg config.Message) (string, error) { + // Too noisy to log like other events + if msg.Event != config.EventUserTyping { + b.Log.Debugf("=> Receiving %#v", msg) + } + + msg.Text = helper.ClipMessage(msg.Text, messageLength, b.GetString("MessageClipped")) + msg.Text = b.replaceCodeFence(msg.Text) + + // Make a action /me of the message + if msg.Event == config.EventUserAction { + msg.Text = "_" + msg.Text + "_" + } + + // Use webhook to send the message + if b.GetString(outgoingWebhookConfig) != "" && b.GetString(tokenConfig) == "" { + return "", b.sendWebhook(msg) + } + return b.sendRTM(msg) +} + +// sendWebhook uses the configured WebhookURL to send the message +func (b *Bslack) sendWebhook(msg config.Message) error { + // Skip events. + if msg.Event != "" { + return nil + } + + if b.GetBool(useNickPrefixConfig) { + msg.Text = msg.Username + msg.Text + } + + if msg.Extra != nil { + // This sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE. + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + rmsg := rmsg // scopelint + iconURL := config.GetIconURL(&rmsg, b.GetString(iconURLConfig)) + matterMessage := matterhook.OMessage{ + IconURL: iconURL, + Channel: msg.Channel, + UserName: rmsg.Username, + Text: rmsg.Text, + } + if err := b.mh.Send(matterMessage); err != nil { + b.Log.Errorf("Failed to send message: %v", err) + } + } + + // Webhook doesn't support file uploads, so we add the URL manually. + for _, f := range msg.Extra["file"] { + fi, ok := f.(config.FileInfo) + if !ok { + b.Log.Errorf("Received a file with unexpected content: %#v", f) + continue + } + if fi.URL != "" { + msg.Text += " " + fi.URL + } + } + } + + // If we have native slack_attachments add them. + var attachs []slack.Attachment + for _, attach := range msg.Extra[sSlackAttachment] { + attachs = append(attachs, attach.([]slack.Attachment)...) + } + + iconURL := config.GetIconURL(&msg, b.GetString(iconURLConfig)) + matterMessage := matterhook.OMessage{ + IconURL: iconURL, + Attachments: attachs, + Channel: msg.Channel, + UserName: msg.Username, + Text: msg.Text, + } + if msg.Avatar != "" { + matterMessage.IconURL = msg.Avatar + } + if err := b.mh.Send(matterMessage); err != nil { + b.Log.Errorf("Failed to send message via webhook: %#v", err) + return err + } + return nil +} + +func (b *Bslack) sendRTM(msg config.Message) (string, error) { + // Handle channelmember messages. + if handled := b.handleGetChannelMembers(&msg); handled { + return "", nil + } + + channelInfo, err := b.channels.getChannel(msg.Channel) + if err != nil { + return "", fmt.Errorf("could not send message: %v", err) + } + if msg.Event == config.EventUserTyping { + if b.GetBool("ShowUserTyping") { + b.rtm.SendMessage(b.rtm.NewTypingMessage(channelInfo.ID)) + } + return "", nil + } + + var handled bool + + // Handle topic/purpose updates. + if handled, err = b.handleTopicOrPurpose(&msg, channelInfo); handled { + return "", err + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + msg.Text = fmt.Sprintf("[thread]: %s", msg.Text) + } + + // Handle message deletions. + if handled, err = b.deleteMessage(&msg, channelInfo); handled { + return msg.ID, err + } + + // Prepend nickname if configured. + if b.GetBool(useNickPrefixConfig) { + msg.Text = msg.Username + msg.Text + } + + // Handle message edits. + if handled, err = b.editMessage(&msg, channelInfo); handled { + return msg.ID, err + } + + // Upload a file if it exists. + if len(msg.Extra) > 0 { + extraMsgs := helper.HandleExtra(&msg, b.General) + for i := range extraMsgs { + rmsg := &extraMsgs[i] + rmsg.Text = rmsg.Username + rmsg.Text + _, err = b.postMessage(rmsg, channelInfo) + if err != nil { + b.Log.Error(err) + } + } + // Upload files if necessary (from Slack, Telegram or Mattermost). + return b.uploadFile(&msg, channelInfo.ID) + } + + // Post message. + return b.postMessage(&msg, channelInfo) +} + +func (b *Bslack) updateTopicOrPurpose(msg *config.Message, channelInfo *slack.Channel) error { + var updateFunc func(channelID string, value string) (*slack.Channel, error) + + incomingChangeType, text := b.extractTopicOrPurpose(msg.Text) + switch incomingChangeType { + case "topic": + updateFunc = b.rtm.SetTopicOfConversation + case "purpose": + updateFunc = b.rtm.SetPurposeOfConversation + default: + b.Log.Errorf("Unhandled type received from extractTopicOrPurpose: %s", incomingChangeType) + return nil + } + for { + _, err := updateFunc(channelInfo.ID, text) + if err == nil { + return nil + } + if err = handleRateLimit(b.Log, err); err != nil { + return err + } + } +} + +// handles updating topic/purpose and determining whether to further propagate update messages. +func (b *Bslack) handleTopicOrPurpose(msg *config.Message, channelInfo *slack.Channel) (bool, error) { + if msg.Event != config.EventTopicChange { + return false, nil + } + + if b.GetBool("SyncTopic") { + return true, b.updateTopicOrPurpose(msg, channelInfo) + } + + // Pass along to normal message handlers. + if b.GetBool("ShowTopicChange") { + return false, nil + } + + // Swallow message as handled no-op. + return true, nil +} + +func (b *Bslack) deleteMessage(msg *config.Message, channelInfo *slack.Channel) (bool, error) { + if msg.Event != config.EventMsgDelete { + return false, nil + } + + // Some protocols echo deletes, but with an empty ID. + if msg.ID == "" { + return true, nil + } + + for { + _, _, err := b.rtm.DeleteMessage(channelInfo.ID, msg.ID) + if err == nil { + return true, nil + } + + if err = handleRateLimit(b.Log, err); err != nil { + b.Log.Errorf("Failed to delete user message from Slack: %#v", err) + return true, err + } + } +} + +func (b *Bslack) editMessage(msg *config.Message, channelInfo *slack.Channel) (bool, error) { + if msg.ID == "" { + return false, nil + } + messageOptions := b.prepareMessageOptions(msg) + for { + _, _, _, err := b.rtm.UpdateMessage(channelInfo.ID, msg.ID, messageOptions...) + if err == nil { + return true, nil + } + + if err = handleRateLimit(b.Log, err); err != nil { + b.Log.Errorf("Failed to edit user message on Slack: %#v", err) + return true, err + } + } +} + +func (b *Bslack) postMessage(msg *config.Message, channelInfo *slack.Channel) (string, error) { + // don't post empty messages + if msg.Text == "" { + return "", nil + } + messageOptions := b.prepareMessageOptions(msg) + for { + _, id, err := b.rtm.PostMessage(channelInfo.ID, messageOptions...) + if err == nil { + return id, nil + } + + if err = handleRateLimit(b.Log, err); err != nil { + b.Log.Errorf("Failed to sent user message to Slack: %#v", err) + return "", err + } + } +} + +// uploadFile handles native upload of files +func (b *Bslack) uploadFile(msg *config.Message, channelID string) (string, error) { + var messageID string + for _, f := range msg.Extra["file"] { + fi, ok := f.(config.FileInfo) + if !ok { + b.Log.Errorf("Received a file with unexpected content: %#v", f) + continue + } + if msg.Text == fi.Comment { + msg.Text = "" + } + // Because the result of the UploadFile is slower than the MessageEvent from slack + // we can't match on the file ID yet, so we have to match on the filename too. + ts := time.Now() + b.Log.Debugf("Adding file %s to cache at %s with timestamp", fi.Name, ts.String()) + b.cache.Add("filename"+fi.Name, ts) + initialComment := fmt.Sprintf("File from %s", msg.Username) + if fi.Comment != "" { + initialComment += fmt.Sprintf(" with comment: %s", fi.Comment) + } + res, err := b.sc.UploadFile(slack.FileUploadParameters{ + Reader: bytes.NewReader(*fi.Data), + Filename: fi.Name, + Channels: []string{channelID}, + InitialComment: initialComment, + ThreadTimestamp: msg.ParentID, + }) + if err != nil { + b.Log.Errorf("uploadfile %#v", err) + return "", err + } + if res.ID != "" { + b.Log.Debugf("Adding file ID %s to cache with timestamp %s", res.ID, ts.String()) + b.cache.Add("file"+res.ID, ts) + + // search for message id by uploaded file in private/public channels, get thread timestamp from uploaded file + if v, ok := res.Shares.Private[channelID]; ok && len(v) > 0 { + messageID = v[0].Ts + } + if v, ok := res.Shares.Public[channelID]; ok && len(v) > 0 { + messageID = v[0].Ts + } + } + } + return messageID, nil +} + +func (b *Bslack) prepareMessageOptions(msg *config.Message) []slack.MsgOption { + params := slack.NewPostMessageParameters() + if b.GetBool(useNickPrefixConfig) { + params.AsUser = true + } + params.Username = msg.Username + params.LinkNames = 1 // replace mentions + params.IconURL = config.GetIconURL(msg, b.GetString(iconURLConfig)) + params.ThreadTimestamp = msg.ParentID + if msg.Avatar != "" { + params.IconURL = msg.Avatar + } + + var attachments []slack.Attachment + // add file attachments + attachments = append(attachments, b.createAttach(msg.Extra)...) + // add slack attachments (from another slack bridge) + if msg.Extra != nil { + for _, attach := range msg.Extra[sSlackAttachment] { + attachments = append(attachments, attach.([]slack.Attachment)...) + } + } + + var opts []slack.MsgOption + opts = append(opts, + // provide regular text field (fallback used in Slack notifications, etc.) + slack.MsgOptionText(msg.Text, false), + + // add a callback ID so we can see we created it + slack.MsgOptionBlocks(slack.NewSectionBlock( + slack.NewTextBlockObject(slack.MarkdownType, msg.Text, false, false), + nil, nil, + slack.SectionBlockOptionBlockID("matterbridge_"+b.uuid), + )), + + slack.MsgOptionEnableLinkUnfurl(), + ) + opts = append(opts, slack.MsgOptionAttachments(attachments...)) + opts = append(opts, slack.MsgOptionPostMessageParameters(params)) + return opts +} + +func (b *Bslack) createAttach(extra map[string][]interface{}) []slack.Attachment { + var attachements []slack.Attachment + for _, v := range extra["attachments"] { + entry := v.(map[string]interface{}) + s := slack.Attachment{ + Fallback: extractStringField(entry, "fallback"), + Color: extractStringField(entry, "color"), + Pretext: extractStringField(entry, "pretext"), + AuthorName: extractStringField(entry, "author_name"), + AuthorLink: extractStringField(entry, "author_link"), + AuthorIcon: extractStringField(entry, "author_icon"), + Title: extractStringField(entry, "title"), + TitleLink: extractStringField(entry, "title_link"), + Text: extractStringField(entry, "text"), + ImageURL: extractStringField(entry, "image_url"), + ThumbURL: extractStringField(entry, "thumb_url"), + Footer: extractStringField(entry, "footer"), + FooterIcon: extractStringField(entry, "footer_icon"), + } + attachements = append(attachements, s) + } + return attachements +} + +func extractStringField(data map[string]interface{}, field string) string { + if rawValue, found := data[field]; found { + if value, ok := rawValue.(string); ok { + return value + } + } + return "" +} diff --git a/teleirc/matterbridge/bridge/slack/users_channels.go b/teleirc/matterbridge/bridge/slack/users_channels.go new file mode 100644 index 0000000..85b944b --- /dev/null +++ b/teleirc/matterbridge/bridge/slack/users_channels.go @@ -0,0 +1,343 @@ +package bslack + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" +) + +const minimumRefreshInterval = 10 * time.Second + +type users struct { + log *logrus.Entry + sc *slack.Client + + users map[string]*slack.User + usersMutex sync.RWMutex + usersSyncPoints map[string]chan struct{} + + refreshInProgress bool + earliestRefresh time.Time + refreshMutex sync.Mutex +} + +func newUserManager(log *logrus.Entry, sc *slack.Client) *users { + return &users{ + log: log, + sc: sc, + users: make(map[string]*slack.User), + usersSyncPoints: make(map[string]chan struct{}), + earliestRefresh: time.Now(), + } +} + +func (b *users) getUser(id string) *slack.User { + b.usersMutex.RLock() + user, ok := b.users[id] + b.usersMutex.RUnlock() + if ok { + return user + } + b.populateUser(id) + b.usersMutex.RLock() + defer b.usersMutex.RUnlock() + + return b.users[id] +} + +func (b *users) getUsername(id string) string { + if user := b.getUser(id); user != nil { + if user.Profile.DisplayName != "" { + return user.Profile.DisplayName + } + return user.Name + } + b.log.Warnf("Could not find user with ID '%s'", id) + return "" +} + +func (b *users) getAvatar(id string) string { + if user := b.getUser(id); user != nil { + return user.Profile.Image48 + } + return "" +} + +func (b *users) populateUser(userID string) { + for { + b.usersMutex.Lock() + _, exists := b.users[userID] + if exists { + // already in cache + b.usersMutex.Unlock() + return + } + + if syncPoint, ok := b.usersSyncPoints[userID]; ok { + // Another goroutine is already populating this user for us so wait on it to finish. + b.usersMutex.Unlock() + <-syncPoint + // We do not return and iterate again to check that the entry does indeed exist + // in case the previous query failed for some reason. + } else { + b.usersSyncPoints[userID] = make(chan struct{}) + defer func() { + // Wake up any waiting goroutines and remove the synchronization point. + close(b.usersSyncPoints[userID]) + delete(b.usersSyncPoints, userID) + }() + break + } + } + + // Do not hold the lock while fetching information from Slack + // as this might take an unbounded amount of time. + b.usersMutex.Unlock() + + user, err := b.sc.GetUserInfo(userID) + if err != nil { + b.log.Debugf("GetUserInfo failed for %v: %v", userID, err) + return + } + + b.usersMutex.Lock() + defer b.usersMutex.Unlock() + + // Register user information. + b.users[userID] = user +} + +func (b *users) invalidateUser(userID string) { + b.usersMutex.Lock() + defer b.usersMutex.Unlock() + delete(b.users, userID) +} + +func (b *users) populateUsers(wait bool) { + b.refreshMutex.Lock() + if !wait && (time.Now().Before(b.earliestRefresh) || b.refreshInProgress) { + b.log.Debugf("Not refreshing user list as it was done less than %v ago.", minimumRefreshInterval) + b.refreshMutex.Unlock() + + return + } + for b.refreshInProgress { + b.refreshMutex.Unlock() + time.Sleep(time.Second) + b.refreshMutex.Lock() + } + b.refreshInProgress = true + b.refreshMutex.Unlock() + + newUsers := map[string]*slack.User{} + pagination := b.sc.GetUsersPaginated(slack.GetUsersOptionLimit(200)) + count := 0 + for { + var err error + pagination, err = pagination.Next(context.Background()) + time.Sleep(time.Second) + if err != nil { + if pagination.Done(err) { + break + } + + if err = handleRateLimit(b.log, err); err != nil { + b.log.Errorf("Could not retrieve users: %#v", err) + return + } + continue + } + + for i := range pagination.Users { + newUsers[pagination.Users[i].ID] = &pagination.Users[i] + } + b.log.Debugf("getting %d users", len(pagination.Users)) + count++ + // more > 2000 users, slack will complain and ratelimit. break + if count > 10 { + b.log.Info("Large slack detected > 2000 users, skipping loading complete userlist.") + break + } + } + + b.usersMutex.Lock() + defer b.usersMutex.Unlock() + b.users = newUsers + + b.refreshMutex.Lock() + defer b.refreshMutex.Unlock() + b.earliestRefresh = time.Now().Add(minimumRefreshInterval) + b.refreshInProgress = false +} + +type channels struct { + log *logrus.Entry + sc *slack.Client + + channelsByID map[string]*slack.Channel + channelsByName map[string]*slack.Channel + channelsMutex sync.RWMutex + + channelMembers map[string][]string + channelMembersMutex sync.RWMutex + + refreshInProgress bool + earliestRefresh time.Time + refreshMutex sync.Mutex +} + +func newChannelManager(log *logrus.Entry, sc *slack.Client) *channels { + return &channels{ + log: log, + sc: sc, + channelsByID: make(map[string]*slack.Channel), + channelsByName: make(map[string]*slack.Channel), + earliestRefresh: time.Now(), + } +} + +func (b *channels) getChannel(channel string) (*slack.Channel, error) { + if strings.HasPrefix(channel, "ID:") { + return b.getChannelByID(strings.TrimPrefix(channel, "ID:")) + } + return b.getChannelByName(channel) +} + +func (b *channels) getChannelByName(name string) (*slack.Channel, error) { + return b.getChannelBy(name, b.channelsByName) +} + +func (b *channels) getChannelByID(id string) (*slack.Channel, error) { + return b.getChannelBy(id, b.channelsByID) +} + +func (b *channels) getChannelBy(lookupKey string, lookupMap map[string]*slack.Channel) (*slack.Channel, error) { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + if channel, ok := lookupMap[lookupKey]; ok { + return channel, nil + } + return nil, fmt.Errorf("channel %s not found", lookupKey) +} + +func (b *channels) getChannelMembers(users *users) config.ChannelMembers { + b.channelMembersMutex.RLock() + defer b.channelMembersMutex.RUnlock() + + membersInfo := config.ChannelMembers{} + for channelID, members := range b.channelMembers { + for _, member := range members { + channelName := "" + userName := "" + userNick := "" + user := users.getUser(member) + if user != nil { + userName = user.Name + userNick = user.Profile.DisplayName + } + channel, _ := b.getChannelByID(channelID) + if channel != nil { + channelName = channel.Name + } + memberInfo := config.ChannelMember{ + Username: userName, + Nick: userNick, + UserID: member, + ChannelID: channelID, + ChannelName: channelName, + } + membersInfo = append(membersInfo, memberInfo) + } + } + return membersInfo +} + +func (b *channels) registerChannel(channel slack.Channel) { + b.channelsMutex.Lock() + defer b.channelsMutex.Unlock() + + b.channelsByID[channel.ID] = &channel + b.channelsByName[channel.Name] = &channel +} + +func (b *channels) populateChannels(wait bool) { + b.refreshMutex.Lock() + if !wait && (time.Now().Before(b.earliestRefresh) || b.refreshInProgress) { + b.log.Debugf("Not refreshing channel list as it was done less than %v seconds ago.", minimumRefreshInterval) + b.refreshMutex.Unlock() + return + } + for b.refreshInProgress { + b.refreshMutex.Unlock() + time.Sleep(time.Second) + b.refreshMutex.Lock() + } + b.refreshInProgress = true + b.refreshMutex.Unlock() + + newChannelsByID := map[string]*slack.Channel{} + newChannelsByName := map[string]*slack.Channel{} + newChannelMembers := make(map[string][]string) + + // We only retrieve public and private channels, not IMs + // and MPIMs as those do not have a channel name. + queryParams := &slack.GetConversationsParameters{ + ExcludeArchived: true, + Types: []string{"public_channel,private_channel"}, + Limit: 1000, + } + for { + channels, nextCursor, err := b.sc.GetConversations(queryParams) + if err != nil { + if err = handleRateLimit(b.log, err); err != nil { + b.log.Errorf("Could not retrieve channels: %#v", err) + return + } + continue + } + + for i := range channels { + newChannelsByID[channels[i].ID] = &channels[i] + newChannelsByName[channels[i].Name] = &channels[i] + // also find all the members in every channel + // comment for now, issues on big slacks + /* + members, err := b.getUsersInConversation(channels[i].ID) + if err != nil { + if err = b.handleRateLimit(err); err != nil { + b.Log.Errorf("Could not retrieve channel members: %#v", err) + return + } + continue + } + newChannelMembers[channels[i].ID] = members + */ + } + + if nextCursor == "" { + break + } + queryParams.Cursor = nextCursor + } + + b.channelsMutex.Lock() + defer b.channelsMutex.Unlock() + b.channelsByID = newChannelsByID + b.channelsByName = newChannelsByName + + b.channelMembersMutex.Lock() + defer b.channelMembersMutex.Unlock() + b.channelMembers = newChannelMembers + + b.refreshMutex.Lock() + defer b.refreshMutex.Unlock() + b.earliestRefresh = time.Now().Add(minimumRefreshInterval) + b.refreshInProgress = false +} diff --git a/teleirc/matterbridge/bridge/sshchat/sshchat.go b/teleirc/matterbridge/bridge/sshchat/sshchat.go new file mode 100644 index 0000000..6b78c22 --- /dev/null +++ b/teleirc/matterbridge/bridge/sshchat/sshchat.go @@ -0,0 +1,169 @@ +package bsshchat + +import ( + "bufio" + "io" + "strings" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/shazow/ssh-chat/sshd" +) + +type Bsshchat struct { + r *bufio.Scanner + w io.WriteCloser + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Bsshchat{Config: cfg} +} + +func (b *Bsshchat) Connect() error { + b.Log.Infof("Connecting %s", b.GetString("Server")) + + // connHandler will be called by 'sshd.ConnectShell()' below + // once the connection is established in order to handle it. + connErr := make(chan error, 1) // Needs to be buffered. + connSignal := make(chan struct{}) + connHandler := func(r io.Reader, w io.WriteCloser) error { + b.r = bufio.NewScanner(r) + b.r.Scan() + b.w = w + if _, err := b.w.Write([]byte("/theme mono\r\n/quiet\r\n")); err != nil { + return err + } + close(connSignal) // Connection is established so we can signal the success. + return b.handleSSHChat() + } + + go func() { + // As a successful connection will result in this returning after the Connection + // method has already returned point we NEED to have a buffered channel to still + // be able to write. + connErr <- sshd.ConnectShell(b.GetString("Server"), b.GetString("Nick"), connHandler) + }() + + select { + case err := <-connErr: + b.Log.Error("Connection failed") + return err + case <-connSignal: + } + b.Log.Info("Connection succeeded") + return nil +} + +func (b *Bsshchat) Disconnect() error { + return nil +} + +func (b *Bsshchat) JoinChannel(channel config.ChannelInfo) error { + return nil +} + +func (b *Bsshchat) Send(msg config.Message) (string, error) { + // ignore delete messages + if msg.Event == config.EventMsgDelete { + return "", nil + } + b.Log.Debugf("=> Receiving %#v", msg) + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + if _, err := b.w.Write([]byte(rmsg.Username + rmsg.Text + "\r\n")); err != nil { + b.Log.Errorf("Could not send extra message: %#v", err) + } + } + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(&msg) + } + } + _, err := b.w.Write([]byte(msg.Username + msg.Text + "\r\n")) + return "", err +} + +/* +func (b *Bsshchat) sshchatKeepAlive() chan bool { + done := make(chan bool) + go func() { + ticker := time.NewTicker(90 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + b.Log.Debugf("PING") + err := b.xc.PingC2S("", "") + if err != nil { + b.Log.Debugf("PING failed %#v", err) + } + case <-done: + return + } + } + }() + return done +} +*/ + +func stripPrompt(s string) string { + pos := strings.LastIndex(s, "\033[K") + if pos < 0 { + return s + } + return s[pos+3:] +} + +func (b *Bsshchat) handleSSHChat() error { + /* + done := b.sshchatKeepAlive() + defer close(done) + */ + wait := true + for { + if b.r.Scan() { + // ignore messages from ourselves + if !strings.Contains(b.r.Text(), "\033[K") { + continue + } + if strings.Contains(b.r.Text(), "Rate limiting is in effect") { + continue + } + // skip our own messages + if !strings.HasPrefix(b.r.Text(), "["+b.GetString("Nick")+"] \x1b") { + continue + } + res := strings.Split(stripPrompt(b.r.Text()), ":") + if res[0] == "-> Set theme" { + wait = false + b.Log.Debugf("mono found, allowing") + continue + } + if !wait { + b.Log.Debugf("<= Message %#v", res) + rmsg := config.Message{Username: res[0], Text: strings.TrimSpace(strings.Join(res[1:], ":")), Channel: "sshchat", Account: b.Account, UserID: "nick"} + b.Remote <- rmsg + } + } + } +} + +func (b *Bsshchat) handleUploadFile(msg *config.Message) (string, error) { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.Comment != "" { + msg.Text += fi.Comment + ": " + } + if fi.URL != "" { + msg.Text = fi.URL + if fi.Comment != "" { + msg.Text = fi.Comment + ": " + fi.URL + } + } + if _, err := b.w.Write([]byte(msg.Username + msg.Text + "\r\n")); err != nil { + b.Log.Errorf("Could not send file message: %#v", err) + } + } + return "", nil +} diff --git a/teleirc/matterbridge/bridge/steam/handlers.go b/teleirc/matterbridge/bridge/steam/handlers.go new file mode 100644 index 0000000..fa5015c --- /dev/null +++ b/teleirc/matterbridge/bridge/steam/handlers.go @@ -0,0 +1,126 @@ +package bsteam + +import ( + "fmt" + "strconv" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/Philipp15b/go-steam" + "github.com/Philipp15b/go-steam/protocol/steamlang" +) + +func (b *Bsteam) handleChatMsg(e *steam.ChatMsgEvent) { + b.Log.Debugf("Receiving ChatMsgEvent: %#v", e) + b.Log.Debugf("<= Sending message from %s on %s to gateway", b.getNick(e.ChatterId), b.Account) + var channel int64 + if e.ChatRoomId == 0 { + channel = int64(e.ChatterId) + } else { + // for some reason we have to remove 0x18000000000000 + // TODO + // https://github.com/42wim/matterbridge/pull/630#discussion_r238102751 + // channel = int64(e.ChatRoomId) & 0xfffffffffffff + channel = int64(e.ChatRoomId) - 0x18000000000000 + } + msg := config.Message{ + Username: b.getNick(e.ChatterId), + Text: e.Message, + Channel: strconv.FormatInt(channel, 10), + Account: b.Account, + UserID: strconv.FormatInt(int64(e.ChatterId), 10), + } + b.Remote <- msg +} + +func (b *Bsteam) handleEvents() { + myLoginInfo := &steam.LogOnDetails{ + Username: b.GetString("Login"), + Password: b.GetString("Password"), + AuthCode: b.GetString("AuthCode"), + } + // TODO Attempt to read existing auth hash to avoid steam guard. + // Maybe works + //myLoginInfo.SentryFileHash, _ = ioutil.ReadFile("sentry") + for event := range b.c.Events() { + switch e := event.(type) { + case *steam.ChatMsgEvent: + b.handleChatMsg(e) + case *steam.PersonaStateEvent: + b.Log.Debugf("PersonaStateEvent: %#v\n", e) + b.Lock() + b.userMap[e.FriendId] = e.Name + b.Unlock() + case *steam.ConnectedEvent: + b.c.Auth.LogOn(myLoginInfo) + case *steam.MachineAuthUpdateEvent: + // TODO sentry files for 2 auth + /* + b.Log.Info("authupdate", e) + b.Log.Info("hash", e.Hash) + ioutil.WriteFile("sentry", e.Hash, 0666) + */ + case *steam.LogOnFailedEvent: + b.Log.Info("Logon failed", e) + err := b.handleLogOnFailed(e, myLoginInfo) + if err != nil { + b.Log.Error(err) + return + } + case *steam.LoggedOnEvent: + b.Log.Debugf("LoggedOnEvent: %#v", e) + b.connected <- struct{}{} + b.Log.Debugf("setting online") + b.c.Social.SetPersonaState(steamlang.EPersonaState_Online) + case *steam.DisconnectedEvent: + b.Log.Info("Disconnected") + b.Log.Info("Attempting to reconnect...") + b.c.Connect() + case steam.FatalErrorEvent: + b.Log.Errorf("steam FatalErrorEvent: %#v", e) + default: + b.Log.Debugf("unknown event %#v", e) + } + } +} + +func (b *Bsteam) handleLogOnFailed(e *steam.LogOnFailedEvent, myLoginInfo *steam.LogOnDetails) error { + switch e.Result { + case steamlang.EResult_AccountLoginDeniedNeedTwoFactor: + b.Log.Info("Steam guard isn't letting me in! Enter 2FA code:") + var code string + fmt.Scanf("%s", &code) + // TODO https://github.com/42wim/matterbridge/pull/630#discussion_r238103978 + myLoginInfo.TwoFactorCode = code + case steamlang.EResult_AccountLogonDenied: + b.Log.Info("Steam guard isn't letting me in! Enter auth code:") + var code string + fmt.Scanf("%s", &code) + // TODO https://github.com/42wim/matterbridge/pull/630#discussion_r238103978 + myLoginInfo.AuthCode = code + case steamlang.EResult_InvalidLoginAuthCode: + return fmt.Errorf("Steam guard: invalid login auth code: %#v ", e.Result) + default: + return fmt.Errorf("LogOnFailedEvent: %#v ", e.Result) + // TODO: Handle EResult_InvalidLoginAuthCode + } + return nil +} + +// handleFileInfo handles config.FileInfo and adds correct file comment or URL to msg.Text. +// Returns error if cast fails. +func (b *Bsteam) handleFileInfo(msg *config.Message, f interface{}) error { + if _, ok := f.(config.FileInfo); !ok { + return fmt.Errorf("handleFileInfo cast failed %#v", f) + } + fi := f.(config.FileInfo) + if fi.Comment != "" { + msg.Text += fi.Comment + ": " + } + if fi.URL != "" { + msg.Text = fi.URL + if fi.Comment != "" { + msg.Text = fi.Comment + ": " + fi.URL + } + } + return nil +} diff --git a/teleirc/matterbridge/bridge/steam/steam.go b/teleirc/matterbridge/bridge/steam/steam.go new file mode 100644 index 0000000..5a577a2 --- /dev/null +++ b/teleirc/matterbridge/bridge/steam/steam.go @@ -0,0 +1,95 @@ +package bsteam + +import ( + "fmt" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/Philipp15b/go-steam" + "github.com/Philipp15b/go-steam/protocol/steamlang" + "github.com/Philipp15b/go-steam/steamid" +) + +type Bsteam struct { + c *steam.Client + connected chan struct{} + userMap map[steamid.SteamId]string + sync.RWMutex + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + b := &Bsteam{Config: cfg} + b.userMap = make(map[steamid.SteamId]string) + b.connected = make(chan struct{}) + return b +} + +func (b *Bsteam) Connect() error { + b.Log.Info("Connecting") + b.c = steam.NewClient() + go b.handleEvents() + go b.c.Connect() + select { + case <-b.connected: + b.Log.Info("Connection succeeded") + case <-time.After(time.Second * 30): + return fmt.Errorf("connection timed out") + } + return nil +} + +func (b *Bsteam) Disconnect() error { + b.c.Disconnect() + return nil + +} + +func (b *Bsteam) JoinChannel(channel config.ChannelInfo) error { + id, err := steamid.NewId(channel.Name) + if err != nil { + return err + } + b.c.Social.JoinChat(id) + return nil +} + +func (b *Bsteam) Send(msg config.Message) (string, error) { + // ignore delete messages + if msg.Event == config.EventMsgDelete { + return "", nil + } + id, err := steamid.NewId(msg.Channel) + if err != nil { + return "", err + } + + // Handle files + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, rmsg.Username+rmsg.Text) + } + for i := range msg.Extra["file"] { + if err := b.handleFileInfo(&msg, msg.Extra["file"][i]); err != nil { + b.Log.Error(err) + } + b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, msg.Username+msg.Text) + } + return "", nil + } + + b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, msg.Username+msg.Text) + return "", nil +} + +func (b *Bsteam) getNick(id steamid.SteamId) string { + b.RLock() + defer b.RUnlock() + if name, ok := b.userMap[id]; ok { + return name + } + return "unknown" +} diff --git a/teleirc/matterbridge/bridge/telegram/handlers.go b/teleirc/matterbridge/bridge/telegram/handlers.go new file mode 100644 index 0000000..a9d9bd6 --- /dev/null +++ b/teleirc/matterbridge/bridge/telegram/handlers.go @@ -0,0 +1,580 @@ +package btelegram + +import ( + "fmt" + "html" + "path/filepath" + "strconv" + "strings" + "unicode/utf16" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/davecgh/go-spew/spew" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func (b *Btelegram) handleUpdate(rmsg *config.Message, message, posted, edited *tgbotapi.Message) *tgbotapi.Message { + // handle channels + if posted != nil { + if posted.Text == "/chatId" { + chatID := strconv.FormatInt(posted.Chat.ID, 10) + + _, err := b.Send(config.Message{ + Channel: chatID, + Text: fmt.Sprintf("ID of this chat: %s", chatID), + }) + if err != nil { + b.Log.Warnf("Unable to send chatID to %s", chatID) + } + } else { + message = posted + rmsg.Text = message.Text + } + } + + // edited channel message + if edited != nil && !b.GetBool("EditDisable") { + message = edited + rmsg.Text = rmsg.Text + message.Text + b.GetString("EditSuffix") + } + return message +} + +// handleChannels checks if it's a channel message and if the message is a new or edited messages +func (b *Btelegram) handleChannels(rmsg *config.Message, message *tgbotapi.Message, update tgbotapi.Update) *tgbotapi.Message { + return b.handleUpdate(rmsg, message, update.ChannelPost, update.EditedChannelPost) +} + +// handleGroups checks if it's a group message and if the message is a new or edited messages +func (b *Btelegram) handleGroups(rmsg *config.Message, message *tgbotapi.Message, update tgbotapi.Update) *tgbotapi.Message { + return b.handleUpdate(rmsg, message, update.Message, update.EditedMessage) +} + +// handleForwarded handles forwarded messages +func (b *Btelegram) handleForwarded(rmsg *config.Message, message *tgbotapi.Message) { + if message.ForwardDate == 0 { + return + } + + if message.ForwardFromChat != nil && message.ForwardFrom == nil { + rmsg.Text = "Forwarded from " + message.ForwardFromChat.Title + ": " + rmsg.Text + return + } + + if message.ForwardFrom == nil { + rmsg.Text = "Forwarded from " + unknownUser + ": " + rmsg.Text + return + } + + usernameForward := "" + if b.GetBool("UseFirstName") { + usernameForward = message.ForwardFrom.FirstName + } + if b.GetBool("UseFullName") { + usernameForward = message.ForwardFrom.FirstName + " " + message.ForwardFrom.LastName + } + + if usernameForward == "" { + usernameForward = message.ForwardFrom.UserName + if usernameForward == "" { + usernameForward = message.ForwardFrom.FirstName + } + } + + if usernameForward == "" { + usernameForward = unknownUser + } + + rmsg.Text = "Forwarded from " + usernameForward + ": " + rmsg.Text +} + +// handleQuoting handles quoting of previous messages +func (b *Btelegram) handleQuoting(rmsg *config.Message, message *tgbotapi.Message) { + if message.ReplyToMessage != nil { + usernameReply := "" + if message.ReplyToMessage.From != nil { + if b.GetBool("UseFirstName") { + usernameReply = message.ReplyToMessage.From.FirstName + } + if b.GetBool("UseFullName") { + usernameReply = message.ReplyToMessage.From.FirstName + " " + message.ReplyToMessage.From.LastName + } + if usernameReply == "" { + usernameReply = message.ReplyToMessage.From.UserName + if usernameReply == "" { + usernameReply = message.ReplyToMessage.From.FirstName + } + } + } + if usernameReply == "" { + usernameReply = unknownUser + } + if !b.GetBool("QuoteDisable") { + quote := message.ReplyToMessage.Text + if quote == "" { + quote = message.ReplyToMessage.Caption + } + rmsg.Text = b.handleQuote(rmsg.Text, usernameReply, quote) + } + } +} + +// handleUsername handles the correct setting of the username +func (b *Btelegram) handleUsername(rmsg *config.Message, message *tgbotapi.Message) { + if message.From != nil { + rmsg.UserID = strconv.FormatInt(message.From.ID, 10) + if b.GetBool("UseFirstName") { + rmsg.Username = message.From.FirstName + } + if b.GetBool("UseFullName") { + rmsg.Username = message.From.FirstName + " " + message.From.LastName + } + if rmsg.Username == "" { + rmsg.Username = message.From.UserName + if rmsg.Username == "" { + rmsg.Username = message.From.FirstName + } + } + // only download avatars if we have a place to upload them (configured mediaserver) + if b.General.MediaServerUpload != "" || (b.General.MediaServerDownload != "" && b.General.MediaDownloadPath != "") { + b.handleDownloadAvatar(message.From.ID, rmsg.Channel) + } + } + + if message.SenderChat != nil { //nolint:nestif + rmsg.UserID = strconv.FormatInt(message.SenderChat.ID, 10) + if b.GetBool("UseFirstName") { + rmsg.Username = message.SenderChat.FirstName + } + if b.GetBool("UseFullName") { + rmsg.Username = message.SenderChat.FirstName + " " + message.SenderChat.LastName + } + + if rmsg.Username == "" || rmsg.Username == "Channel_Bot" { + rmsg.Username = message.SenderChat.UserName + + if rmsg.Username == "" || rmsg.Username == "Channel_Bot" { + rmsg.Username = message.SenderChat.FirstName + } + } + // only download avatars if we have a place to upload them (configured mediaserver) + if b.General.MediaServerUpload != "" || (b.General.MediaServerDownload != "" && b.General.MediaDownloadPath != "") { + b.handleDownloadAvatar(message.SenderChat.ID, rmsg.Channel) + } + } + + // if we really didn't find a username, set it to unknown + if rmsg.Username == "" { + rmsg.Username = unknownUser + } +} + +func (b *Btelegram) handleRecv(updates <-chan tgbotapi.Update) { + for update := range updates { + b.Log.Debugf("== Receiving event: %#v", update.Message) + + if update.Message == nil && update.ChannelPost == nil && + update.EditedMessage == nil && update.EditedChannelPost == nil { + b.Log.Info("Received event without messages, skipping.") + continue + } + + if b.GetInt("debuglevel") == 1 { + spew.Dump(update.Message) + } + + var message *tgbotapi.Message + + rmsg := config.Message{Account: b.Account, Extra: make(map[string][]interface{})} + + // handle channels + message = b.handleChannels(&rmsg, message, update) + + // handle groups + message = b.handleGroups(&rmsg, message, update) + + if message == nil { + b.Log.Error("message is nil, this shouldn't happen.") + continue + } + + // set the ID's from the channel or group message + rmsg.ID = strconv.Itoa(message.MessageID) + rmsg.Channel = strconv.FormatInt(message.Chat.ID, 10) + + // preserve threading from telegram reply + if message.ReplyToMessage != nil { + rmsg.ParentID = strconv.Itoa(message.ReplyToMessage.MessageID) + } + + // handle entities (adding URLs) + b.handleEntities(&rmsg, message) + + // handle username + b.handleUsername(&rmsg, message) + + // handle any downloads + err := b.handleDownload(&rmsg, message) + if err != nil { + b.Log.Errorf("download failed: %s", err) + } + + // handle forwarded messages + b.handleForwarded(&rmsg, message) + + // quote the previous message + b.handleQuoting(&rmsg, message) + + if rmsg.Text != "" || len(rmsg.Extra) > 0 { + // Comment the next line out due to avoid removing empty lines in Telegram + // rmsg.Text = helper.RemoveEmptyNewLines(rmsg.Text) + // channels don't have (always?) user information. see #410 + if message.From != nil { + rmsg.Avatar = helper.GetAvatar(b.avatarMap, strconv.FormatInt(message.From.ID, 10), b.General) + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg + } + } +} + +// handleDownloadAvatar downloads the avatar of userid from channel +// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful. +// logs an error message if it fails +func (b *Btelegram) handleDownloadAvatar(userid int64, channel string) { + rmsg := config.Message{ + Username: "system", + Text: "avatar", + Channel: channel, + Account: b.Account, + UserID: strconv.FormatInt(userid, 10), + Event: config.EventAvatarDownload, + Extra: make(map[string][]interface{}), + } + + if _, ok := b.avatarMap[strconv.FormatInt(userid, 10)]; ok { + return + } + + photos, err := b.c.GetUserProfilePhotos(tgbotapi.UserProfilePhotosConfig{UserID: userid, Limit: 1}) + if err != nil { + b.Log.Errorf("Userprofile download failed for %#v %s", userid, err) + } + + if len(photos.Photos) > 0 { + photo := photos.Photos[0][0] + url := b.getFileDirectURL(photo.FileID) + name := strconv.FormatInt(userid, 10) + ".png" + b.Log.Debugf("trying to download %#v fileid %#v with size %#v", name, photo.FileID, photo.FileSize) + + err := helper.HandleDownloadSize(b.Log, &rmsg, name, int64(photo.FileSize), b.General) + if err != nil { + b.Log.Error(err) + return + } + data, err := helper.DownloadFile(url) + if err != nil { + b.Log.Errorf("download %s failed %#v", url, err) + return + } + helper.HandleDownloadData(b.Log, &rmsg, name, rmsg.Text, "", data, b.General) + b.Remote <- rmsg + } +} + +func (b *Btelegram) maybeConvertTgs(name *string, data *[]byte) { + format := b.GetString("MediaConvertTgs") + if helper.SupportsFormat(format) { + b.Log.Debugf("Format supported by %s, converting %v", helper.LottieBackend(), name) + } else { + // Otherwise, no conversion was requested. Trying to run the usual webp + // converter would fail, because '.tgs.webp' is actually a gzipped JSON + // file, and has nothing to do with WebP. + return + } + err := helper.ConvertTgsToX(data, format, b.Log) + if err != nil { + b.Log.Errorf("conversion failed: %v", err) + } else { + *name = strings.Replace(*name, "tgs.webp", format, 1) + } +} + +func (b *Btelegram) maybeConvertWebp(name *string, data *[]byte) { + if b.GetBool("MediaConvertWebPToPNG") { + b.Log.Debugf("WebP to PNG conversion enabled, converting %v", name) + err := helper.ConvertWebPToPNG(data) + if err != nil { + b.Log.Errorf("conversion failed: %v", err) + } else { + *name = strings.Replace(*name, ".webp", ".png", 1) + } + } +} + +// handleDownloadFile handles file download +func (b *Btelegram) handleDownload(rmsg *config.Message, message *tgbotapi.Message) error { + size := 0 + var url, name, text string + switch { + case message.Sticker != nil: + text, name, url = b.getDownloadInfo(message.Sticker.FileID, ".webp", true) + size = message.Sticker.FileSize + case message.Voice != nil: + text, name, url = b.getDownloadInfo(message.Voice.FileID, ".ogg", true) + size = message.Voice.FileSize + case message.Video != nil: + text, name, url = b.getDownloadInfo(message.Video.FileID, "", true) + size = message.Video.FileSize + case message.Audio != nil: + text, name, url = b.getDownloadInfo(message.Audio.FileID, "", true) + size = message.Audio.FileSize + case message.Document != nil: + _, _, url = b.getDownloadInfo(message.Document.FileID, "", false) + size = message.Document.FileSize + name = message.Document.FileName + text = " " + message.Document.FileName + " : " + url + case message.Photo != nil: + photos := message.Photo + size = photos[len(photos)-1].FileSize + text, name, url = b.getDownloadInfo(photos[len(photos)-1].FileID, "", true) + } + + // if name is empty we didn't match a thing to download + if name == "" { + return nil + } + // use the URL instead of native upload + if b.GetBool("UseInsecureURL") { + b.Log.Debugf("Setting message text to :%s", text) + rmsg.Text += text + return nil + } + // if we have a file attached, download it (in memory) and put a pointer to it in msg.Extra + err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General) + if err != nil { + return err + } + data, err := helper.DownloadFile(url) + if err != nil { + return err + } + + if strings.HasSuffix(name, ".tgs.webp") { + b.maybeConvertTgs(&name, data) + } else if strings.HasSuffix(name, ".webp") { + b.maybeConvertWebp(&name, data) + } + + // rename .oga to .ogg https://github.com/42wim/matterbridge/issues/906#issuecomment-741793512 + if strings.HasSuffix(name, ".oga") && message.Audio != nil { + name = strings.Replace(name, ".oga", ".ogg", 1) + } + + helper.HandleDownloadData(b.Log, rmsg, name, message.Caption, "", data, b.General) + return nil +} + +func (b *Btelegram) getDownloadInfo(id string, suffix string, urlpart bool) (string, string, string) { + url := b.getFileDirectURL(id) + name := "" + if urlpart { + urlPart := strings.Split(url, "/") + name = urlPart[len(urlPart)-1] + } + if suffix != "" && !strings.HasSuffix(name, suffix) && !strings.HasSuffix(name, ".webm") { + name += suffix + } + text := " " + url + return text, name, url +} + +// handleDelete handles message deleting +func (b *Btelegram) handleDelete(msg *config.Message, chatid int64) (string, error) { + if msg.ID == "" { + return "", nil + } + + msgid, err := strconv.Atoi(msg.ID) + if err != nil { + return "", err + } + + cfg := tgbotapi.NewDeleteMessage(chatid, msgid) + _, err = b.c.Request(cfg) + + return "", err +} + +// handleEdit handles message editing. +func (b *Btelegram) handleEdit(msg *config.Message, chatid int64) (string, error) { + msgid, err := strconv.Atoi(msg.ID) + if err != nil { + return "", err + } + if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick { + b.Log.Debug("Using mode HTML - nick only") + msg.Text = html.EscapeString(msg.Text) + } + m := tgbotapi.NewEditMessageText(chatid, msgid, msg.Username+msg.Text) + switch b.GetString("MessageFormat") { + case HTMLFormat: + b.Log.Debug("Using mode HTML") + m.ParseMode = tgbotapi.ModeHTML + case "Markdown": + b.Log.Debug("Using mode markdown") + m.ParseMode = tgbotapi.ModeMarkdown + case MarkdownV2: + b.Log.Debug("Using mode MarkdownV2") + m.ParseMode = MarkdownV2 + } + if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick { + b.Log.Debug("Using mode HTML - nick only") + m.ParseMode = tgbotapi.ModeHTML + } + _, err = b.c.Send(m) + if err != nil { + return "", err + } + return "", nil +} + +// handleUploadFile handles native upload of files +func (b *Btelegram) handleUploadFile(msg *config.Message, chatid int64, parentID int) (string, error) { + var media []interface{} + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + file := tgbotapi.FileBytes{ + Name: fi.Name, + Bytes: *fi.Data, + } + + if b.GetString("MessageFormat") == HTMLFormat { + fi.Comment = makeHTML(html.EscapeString(fi.Comment)) + } + + switch filepath.Ext(fi.Name) { + case ".jpg", ".jpe", ".png": + pc := tgbotapi.NewInputMediaPhoto(file) + if fi.Comment != "" { + pc.Caption, pc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment) + } + media = append(media, pc) + case ".mp4", ".m4v": + vc := tgbotapi.NewInputMediaVideo(file) + if fi.Comment != "" { + vc.Caption, vc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment) + } + media = append(media, vc) + case ".mp3", ".oga": + ac := tgbotapi.NewInputMediaAudio(file) + if fi.Comment != "" { + ac.Caption, ac.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment) + } + media = append(media, ac) + case ".ogg": + voc := tgbotapi.NewVoice(chatid, file) + voc.Caption, voc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment) + voc.ReplyToMessageID = parentID + res, err := b.c.Send(voc) + if err != nil { + return "", err + } + return strconv.Itoa(res.MessageID), nil + default: + dc := tgbotapi.NewInputMediaDocument(file) + if fi.Comment != "" { + dc.Caption, dc.ParseMode = TGGetParseMode(b, msg.Username, fi.Comment) + } + media = append(media, dc) + } + } + + return b.sendMediaFiles(msg, chatid, parentID, media) +} + +func (b *Btelegram) handleQuote(message, quoteNick, quoteMessage string) string { + format := b.GetString("quoteformat") + if format == "" { + format = "{MESSAGE} (re @{QUOTENICK}: {QUOTEMESSAGE})" + } + quoteMessagelength := len([]rune(quoteMessage)) + if b.GetInt("QuoteLengthLimit") != 0 && quoteMessagelength >= b.GetInt("QuoteLengthLimit") { + runes := []rune(quoteMessage) + quoteMessage = string(runes[0:b.GetInt("QuoteLengthLimit")]) + if quoteMessagelength > b.GetInt("QuoteLengthLimit") { + quoteMessage += "..." + } + } + format = strings.Replace(format, "{MESSAGE}", message, -1) + format = strings.Replace(format, "{QUOTENICK}", quoteNick, -1) + format = strings.Replace(format, "{QUOTEMESSAGE}", quoteMessage, -1) + return format +} + +// handleEntities handles messageEntities +func (b *Btelegram) handleEntities(rmsg *config.Message, message *tgbotapi.Message) { + if message.Entities == nil { + return + } + + indexMovedBy := 0 + prevLinkOffset := -1 + + for _, e := range message.Entities { + + asRunes := utf16.Encode([]rune(rmsg.Text)) + + if e.Type == "text_link" { + offset := e.Offset + indexMovedBy + url, err := e.ParseURL() + if err != nil { + b.Log.Errorf("entity text_link url parse failed: %s", err) + continue + } + utfEncodedString := utf16.Encode([]rune(rmsg.Text)) + if offset+e.Length > len(utfEncodedString) { + b.Log.Errorf("entity length is too long %d > %d", offset+e.Length, len(utfEncodedString)) + continue + } + rmsg.Text = string(utf16.Decode(asRunes[:offset+e.Length])) + " ( " + url.String() + " ) " + string(utf16.Decode(asRunes[offset+e.Length:])) + indexMovedBy += len(url.String()) + 3 + prevLinkOffset = e.Offset + } + + if e.Offset == prevLinkOffset { + continue + } + + if e.Type == "code" { + offset := e.Offset + indexMovedBy + rmsg.Text = string(utf16.Decode(asRunes[:offset])) + "`" + string(utf16.Decode(asRunes[offset:offset+e.Length])) + "`" + string(utf16.Decode(asRunes[offset+e.Length:])) + indexMovedBy += 2 + } + + if e.Type == "pre" { + offset := e.Offset + indexMovedBy + rmsg.Text = string(utf16.Decode(asRunes[:offset])) + "```\n" + string(utf16.Decode(asRunes[offset:offset+e.Length])) + "```\n" + string(utf16.Decode(asRunes[offset+e.Length:])) + indexMovedBy += 8 + } + + if e.Type == "bold" { + offset := e.Offset + indexMovedBy + rmsg.Text = string(utf16.Decode(asRunes[:offset])) + "*" + string(utf16.Decode(asRunes[offset:offset+e.Length])) + "*" + string(utf16.Decode(asRunes[offset+e.Length:])) + indexMovedBy += 2 + } + if e.Type == "italic" { + offset := e.Offset + indexMovedBy + rmsg.Text = string(utf16.Decode(asRunes[:offset])) + "_" + string(utf16.Decode(asRunes[offset:offset+e.Length])) + "_" + string(utf16.Decode(asRunes[offset+e.Length:])) + indexMovedBy += 2 + } + if e.Type == "strike" { + offset := e.Offset + indexMovedBy + rmsg.Text = string(utf16.Decode(asRunes[:offset])) + "~" + string(utf16.Decode(asRunes[offset:offset+e.Length])) + "~" + string(utf16.Decode(asRunes[offset+e.Length:])) + indexMovedBy += 2 + } + } +} diff --git a/teleirc/matterbridge/bridge/telegram/html.go b/teleirc/matterbridge/bridge/telegram/html.go new file mode 100644 index 0000000..4aa9fd3 --- /dev/null +++ b/teleirc/matterbridge/bridge/telegram/html.go @@ -0,0 +1,74 @@ +package btelegram + +import ( + "bytes" + + "github.com/russross/blackfriday" +) + +type customHTML struct { + blackfriday.Renderer +} + +func (options *customHTML) Paragraph(out *bytes.Buffer, text func() bool) { + marker := out.Len() + + if !text() { + out.Truncate(marker) + return + } + out.WriteString("\n") +} + +func (options *customHTML) BlockCode(out *bytes.Buffer, text []byte, lang string) { + out.WriteString("<pre>") + + out.WriteString(string(text)) + out.WriteString("</pre>\n") +} + +func (options *customHTML) CodeSpan(out *bytes.Buffer, text []byte) { + out.WriteString("<code>") + out.WriteString(string(text)) + out.WriteString("</code>") +} + +func (options *customHTML) Header(out *bytes.Buffer, text func() bool, level int, id string) { + options.Paragraph(out, text) +} + +func (options *customHTML) HRule(out *bytes.Buffer) { + out.WriteByte('\n') //nolint:errcheck +} + +func (options *customHTML) BlockQuote(out *bytes.Buffer, text []byte) { + out.WriteString("> ") + out.Write(text) + out.WriteByte('\n') +} + +func (options *customHTML) LineBreak(out *bytes.Buffer) { + out.WriteByte('\n') +} + +func (options *customHTML) List(out *bytes.Buffer, text func() bool, flags int) { + options.Paragraph(out, text) +} + +func (options *customHTML) ListItem(out *bytes.Buffer, text []byte, flags int) { + out.WriteString("- ") + out.Write(text) + out.WriteByte('\n') +} + +func makeHTML(input string) string { + return string(blackfriday.Markdown([]byte(input), + &customHTML{blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML|blackfriday.HTML_SKIP_IMAGES, "", "")}, + blackfriday.EXTENSION_NO_INTRA_EMPHASIS| + blackfriday.EXTENSION_FENCED_CODE| + blackfriday.EXTENSION_AUTOLINK| + blackfriday.EXTENSION_SPACE_HEADERS| + blackfriday.EXTENSION_HEADER_IDS| + blackfriday.EXTENSION_BACKSLASH_LINE_BREAK| + blackfriday.EXTENSION_DEFINITION_LISTS)) +} diff --git a/teleirc/matterbridge/bridge/telegram/telegram.go b/teleirc/matterbridge/bridge/telegram/telegram.go new file mode 100644 index 0000000..43cb818 --- /dev/null +++ b/teleirc/matterbridge/bridge/telegram/telegram.go @@ -0,0 +1,205 @@ +package btelegram + +import ( + "fmt" + "html" + "log" + "strconv" + "strings" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +const ( + unknownUser = "unknown" + HTMLFormat = "HTML" + HTMLNick = "htmlnick" + MarkdownV2 = "MarkdownV2" +) + +type Btelegram struct { + c *tgbotapi.BotAPI + *bridge.Config + avatarMap map[string]string // keep cache of userid and avatar sha +} + +func New(cfg *bridge.Config) bridge.Bridger { + tgsConvertFormat := cfg.GetString("MediaConvertTgs") + if tgsConvertFormat != "" { + err := helper.CanConvertTgsToX() + if err != nil { + log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but %s does not appear to work:\n%#v", tgsConvertFormat, helper.LottieBackend(), err) + } + if !helper.SupportsFormat(tgsConvertFormat) { + log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but %s doesn't support it.", tgsConvertFormat, helper.LottieBackend()) + } + } + return &Btelegram{Config: cfg, avatarMap: make(map[string]string)} +} + +func (b *Btelegram) Connect() error { + var err error + b.Log.Info("Connecting") + b.c, err = tgbotapi.NewBotAPI(b.GetString("Token")) + if err != nil { + b.Log.Debugf("%#v", err) + return err + } + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates := b.c.GetUpdatesChan(u) + b.Log.Info("Connection succeeded") + go b.handleRecv(updates) + return nil +} + +func (b *Btelegram) Disconnect() error { + return nil +} + +func (b *Btelegram) JoinChannel(channel config.ChannelInfo) error { + return nil +} + +func TGGetParseMode(b *Btelegram, username string, text string) (textout string, parsemode string) { + textout = username + text + if b.GetString("MessageFormat") == HTMLFormat { + b.Log.Debug("Using mode HTML") + parsemode = tgbotapi.ModeHTML + } + if b.GetString("MessageFormat") == "Markdown" { + b.Log.Debug("Using mode markdown") + parsemode = tgbotapi.ModeMarkdown + } + if b.GetString("MessageFormat") == MarkdownV2 { + b.Log.Debug("Using mode MarkdownV2") + parsemode = MarkdownV2 + } + if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick { + b.Log.Debug("Using mode HTML - nick only") + textout = username + html.EscapeString(text) + parsemode = tgbotapi.ModeHTML + } + return textout, parsemode +} + +func (b *Btelegram) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + // get the chatid + chatid, err := strconv.ParseInt(msg.Channel, 10, 64) + if err != nil { + return "", err + } + + // map the file SHA to our user (caches the avatar) + if msg.Event == config.EventAvatarDownload { + return b.cacheAvatar(&msg) + } + + if b.GetString("MessageFormat") == HTMLFormat { + msg.Text = makeHTML(html.EscapeString(msg.Text)) + } + + // Delete message + if msg.Event == config.EventMsgDelete { + return b.handleDelete(&msg, chatid) + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + msg.Text = fmt.Sprintf("[reply]: %s", msg.Text) + } + + var parentID int + if msg.ParentID != "" { + parentID, _ = b.intParentID(msg.ParentID) + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + if _, msgErr := b.sendMessage(chatid, rmsg.Username, rmsg.Text, parentID); msgErr != nil { + b.Log.Errorf("sendMessage failed: %s", msgErr) + } + } + // check if we have files to upload (from slack, telegram or mattermost) + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(&msg, chatid, parentID) + } + } + + // edit the message if we have a msg ID + if msg.ID != "" { + return b.handleEdit(&msg, chatid) + } + + // Post normal message + // TODO: recheck it. + // Ignore empty text field needs for prevent double messages from whatsapp to telegram + // when sending media with text caption + if msg.Text != "" { + return b.sendMessage(chatid, msg.Username, msg.Text, parentID) + } + + return "", nil +} + +func (b *Btelegram) getFileDirectURL(id string) string { + res, err := b.c.GetFileDirectURL(id) + if err != nil { + return "" + } + return res +} + +func (b *Btelegram) sendMessage(chatid int64, username, text string, parentID int) (string, error) { + m := tgbotapi.NewMessage(chatid, "") + m.Text, m.ParseMode = TGGetParseMode(b, username, text) + m.ReplyToMessageID = parentID + m.DisableWebPagePreview = b.GetBool("DisableWebPagePreview") + + res, err := b.c.Send(m) + if err != nil { + return "", err + } + return strconv.Itoa(res.MessageID), nil +} + +// sendMediaFiles native upload media files via media group +func (b *Btelegram) sendMediaFiles(msg *config.Message, chatid int64, parentID int, media []interface{}) (string, error) { + if len(media) == 0 { + return "", nil + } + mg := tgbotapi.MediaGroupConfig{ChatID: chatid, ChannelUsername: msg.Username, Media: media, ReplyToMessageID: parentID} + messages, err := b.c.SendMediaGroup(mg) + if err != nil { + return "", err + } + // return first message id + return strconv.Itoa(messages[0].MessageID), nil +} + +// intParentID return integer parent id for telegram message +func (b *Btelegram) intParentID(parentID string) (int, error) { + pid, err := strconv.Atoi(parentID) + if err != nil { + return 0, err + } + return pid, nil +} + +func (b *Btelegram) cacheAvatar(msg *config.Message) (string, error) { + fi := msg.Extra["file"][0].(config.FileInfo) + /* if we have a sha we have successfully uploaded the file to the media server, + so we can now cache the sha */ + if fi.SHA != "" { + b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID) + b.avatarMap[msg.UserID] = fi.SHA + } + return "", nil +} diff --git a/teleirc/matterbridge/bridge/vk/vk.go b/teleirc/matterbridge/bridge/vk/vk.go new file mode 100644 index 0000000..7faa5b4 --- /dev/null +++ b/teleirc/matterbridge/bridge/vk/vk.go @@ -0,0 +1,333 @@ +package bvk + +import ( + "bytes" + "context" + "regexp" + "strconv" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + + "github.com/SevereCloud/vksdk/v2/api" + "github.com/SevereCloud/vksdk/v2/events" + longpoll "github.com/SevereCloud/vksdk/v2/longpoll-bot" + "github.com/SevereCloud/vksdk/v2/object" +) + +const ( + audioMessage = "audio_message" + document = "doc" + photo = "photo" + video = "video" + graffiti = "graffiti" + sticker = "sticker" + wall = "wall" +) + +type user struct { + lastname, firstname, avatar string +} + +type Bvk struct { + c *api.VK + lp *longpoll.LongPoll + usernamesMap map[int]user // cache of user names and avatar URLs + *bridge.Config +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Bvk{usernamesMap: make(map[int]user), Config: cfg} +} + +func (b *Bvk) Connect() error { + b.Log.Info("Connecting") + b.c = api.NewVK(b.GetString("Token")) + + var err error + b.lp, err = longpoll.NewLongPollCommunity(b.c) + if err != nil { + b.Log.Debugf("%#v", err) + + return err + } + + b.lp.MessageNew(func(ctx context.Context, obj events.MessageNewObject) { + b.handleMessage(obj.Message, false) + }) + + b.Log.Info("Connection succeeded") + + go func() { + err := b.lp.Run() + if err != nil { + b.Log.WithError(err).Fatal("Enable longpoll in group management") + } + }() + + return nil +} + +func (b *Bvk) Disconnect() error { + b.lp.Shutdown() + + return nil +} + +func (b *Bvk) JoinChannel(channel config.ChannelInfo) error { + return nil +} + +func (b *Bvk) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + peerID, err := strconv.Atoi(msg.Channel) + if err != nil { + return "", err + } + + params := api.Params{} + + text := msg.Username + msg.Text + + if msg.Extra != nil { + if len(msg.Extra["file"]) > 0 { + // generate attachments string + attachment, urls := b.uploadFiles(msg.Extra, peerID) + params["attachment"] = attachment + text += urls + } + } + + params["message"] = text + + if msg.ID == "" { + // New message + params["random_id"] = time.Now().Unix() + params["peer_ids"] = msg.Channel + + res, e := b.c.MessagesSendPeerIDs(params) + if e != nil { + return "", err + } + + return strconv.Itoa(res[0].ConversationMessageID), nil + } + // Edit message + messageID, err := strconv.ParseInt(msg.ID, 10, 64) + if err != nil { + return "", err + } + + params["peer_id"] = peerID + params["conversation_message_id"] = messageID + + _, err = b.c.MessagesEdit(params) + if err != nil { + return "", err + } + + return msg.ID, nil +} + +func (b *Bvk) getUser(id int) user { + u, found := b.usernamesMap[id] + if !found { + b.Log.Debug("Fetching username for ", id) + + if id >= 0 { + result, _ := b.c.UsersGet(api.Params{ + "user_ids": id, + "fields": "photo_200", + }) + + resUser := result[0] + u = user{lastname: resUser.LastName, firstname: resUser.FirstName, avatar: resUser.Photo200} + b.usernamesMap[id] = u + } else { + result, _ := b.c.GroupsGetByID(api.Params{ + "group_id": id * -1, + }) + + resGroup := result[0] + u = user{lastname: resGroup.Name, avatar: resGroup.Photo200} + } + } + + return u +} + +func (b *Bvk) handleMessage(msg object.MessagesMessage, isFwd bool) { + b.Log.Debug("ChatID: ", msg.PeerID) + // fetch user info + u := b.getUser(msg.FromID) + + rmsg := config.Message{ + Text: msg.Text, + Username: u.firstname + " " + u.lastname, + Avatar: u.avatar, + Channel: strconv.Itoa(msg.PeerID), + Account: b.Account, + UserID: strconv.Itoa(msg.FromID), + ID: strconv.Itoa(msg.ConversationMessageID), + Extra: make(map[string][]interface{}), + } + + if msg.ReplyMessage != nil { + ur := b.getUser(msg.ReplyMessage.FromID) + rmsg.Text = "Re: " + ur.firstname + " " + ur.lastname + "\n" + rmsg.Text + } + + if isFwd { + rmsg.Username = "Fwd: " + rmsg.Username + } + + if len(msg.Attachments) > 0 { + urls, text := b.getFiles(msg.Attachments) + + if text != "" { + rmsg.Text += "\n" + text + } + + // download + b.downloadFiles(&rmsg, urls) + } + + if len(msg.FwdMessages) > 0 { + rmsg.Text += strconv.Itoa(len(msg.FwdMessages)) + " forwarded messages" + } + + b.Remote <- rmsg + + if len(msg.FwdMessages) > 0 { + // recursive processing of forwarded messages + for _, m := range msg.FwdMessages { + m.PeerID = msg.PeerID + b.handleMessage(m, true) + } + } +} + +func (b *Bvk) uploadFiles(extra map[string][]interface{}, peerID int) (string, string) { + var attachments []string + text := "" + + for _, f := range extra["file"] { + fi := f.(config.FileInfo) + + if fi.Comment != "" { + text += fi.Comment + "\n" + } + a, err := b.uploadFile(fi, peerID) + if err != nil { + b.Log.WithError(err).Error("File upload error ", fi.Name) + } + + attachments = append(attachments, a) + } + + return strings.Join(attachments, ","), text +} + +func (b *Bvk) uploadFile(file config.FileInfo, peerID int) (string, error) { + r := bytes.NewReader(*file.Data) + + photoRE := regexp.MustCompile(".(jpg|jpe|png)$") + if photoRE.MatchString(file.Name) { + // BUG(VK): for community chat peerID=0 + p, err := b.c.UploadMessagesPhoto(0, r) + if err != nil { + return "", err + } + + return photo + strconv.Itoa(p[0].OwnerID) + "_" + strconv.Itoa(p[0].ID), nil + } + + var doctype string + if strings.Contains(file.Name, ".ogg") { + doctype = audioMessage + } else { + doctype = document + } + + doc, err := b.c.UploadMessagesDoc(peerID, doctype, file.Name, "", r) + if err != nil { + return "", err + } + + switch doc.Type { + case audioMessage: + return document + strconv.Itoa(doc.AudioMessage.OwnerID) + "_" + strconv.Itoa(doc.AudioMessage.ID), nil + case document: + return document + strconv.Itoa(doc.Doc.OwnerID) + "_" + strconv.Itoa(doc.Doc.ID), nil + } + + return "", nil +} + +func (b *Bvk) getFiles(attachments []object.MessagesMessageAttachment) ([]string, string) { + var urls []string + var text []string + + for _, a := range attachments { + switch a.Type { + case photo: + var resolution float64 = 0 + url := a.Photo.Sizes[0].URL + for _, size := range a.Photo.Sizes { + r := size.Height * size.Width + if resolution < r { + resolution = r + url = size.URL + } + } + + urls = append(urls, url) + + case document: + urls = append(urls, a.Doc.URL) + + case graffiti: + urls = append(urls, a.Graffiti.URL) + + case audioMessage: + urls = append(urls, a.AudioMessage.DocsDocPreviewAudioMessage.LinkOgg) + + case sticker: + var resolution float64 = 0 + url := a.Sticker.Images[0].URL + for _, size := range a.Sticker.Images { + r := size.Height * size.Width + if resolution < r { + resolution = r + url = size.URL + } + } + urls = append(urls, url+".png") + case video: + text = append(text, "https://vk.com/video"+strconv.Itoa(a.Video.OwnerID)+"_"+strconv.Itoa(a.Video.ID)) + + case wall: + text = append(text, "https://vk.com/wall"+strconv.Itoa(a.Wall.FromID)+"_"+strconv.Itoa(a.Wall.ID)) + + default: + text = append(text, "This attachment is not supported ("+a.Type+")") + } + } + + return urls, strings.Join(text, "\n") +} + +func (b *Bvk) downloadFiles(rmsg *config.Message, urls []string) { + for _, url := range urls { + data, err := helper.DownloadFile(url) + if err == nil { + urlPart := strings.Split(url, "/") + name := strings.Split(urlPart[len(urlPart)-1], "?")[0] + helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General) + } + } +} diff --git a/teleirc/matterbridge/bridge/whatsapp/handlers.go b/teleirc/matterbridge/bridge/whatsapp/handlers.go new file mode 100644 index 0000000..8f9fef1 --- /dev/null +++ b/teleirc/matterbridge/bridge/whatsapp/handlers.go @@ -0,0 +1,382 @@ +// nolint:goconst +package bwhatsapp + +import ( + "fmt" + "mime" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/Rhymen/go-whatsapp" + "github.com/jpillora/backoff" +) + +/* +Implement handling messages coming from WhatsApp +Check: +- https://github.com/Rhymen/go-whatsapp#add-message-handlers +- https://github.com/Rhymen/go-whatsapp/blob/master/handler.go +- https://github.com/tulir/mautrix-whatsapp/tree/master/whatsapp-ext for more advanced command handling +*/ + +// HandleError received from WhatsApp +func (b *Bwhatsapp) HandleError(err error) { + // ignore received invalid data errors. https://github.com/42wim/matterbridge/issues/843 + // ignore tag 174 errors. https://github.com/42wim/matterbridge/issues/1094 + if strings.Contains(err.Error(), "error processing data: received invalid data") || + strings.Contains(err.Error(), "invalid string with tag 174") { + return + } + + switch err.(type) { + case *whatsapp.ErrConnectionClosed, *whatsapp.ErrConnectionFailed: + b.reconnect(err) + default: + switch err { + case whatsapp.ErrConnectionTimeout: + b.reconnect(err) + default: + b.Log.Errorf("%v", err) + } + } +} + +func (b *Bwhatsapp) reconnect(err error) { + bf := &backoff.Backoff{ + Min: time.Second, + Max: 5 * time.Minute, + Jitter: true, + } + + for { + d := bf.Duration() + + b.Log.Errorf("Connection failed, underlying error: %v", err) + b.Log.Infof("Waiting %s...", d) + + time.Sleep(d) + + b.Log.Info("Reconnecting...") + + err := b.conn.Restore() + if err == nil { + bf.Reset() + b.startedAt = uint64(time.Now().Unix()) + + return + } + } +} + +// HandleTextMessage sent from WhatsApp, relay it to the brige +func (b *Bwhatsapp) HandleTextMessage(message whatsapp.TextMessage) { + if message.Info.FromMe { + return + } + // whatsapp sends last messages to show context , cut them + if message.Info.Timestamp < b.startedAt { + return + } + + groupJID := message.Info.RemoteJid + senderJID := message.Info.SenderJid + + if len(senderJID) == 0 { + if message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } + } + + // translate sender's JID to the nicest username we can get + senderName := b.getSenderName(senderJID) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } + + extText := message.Info.Source.Message.ExtendedTextMessage + if extText != nil && extText.ContextInfo != nil && extText.ContextInfo.MentionedJid != nil { + // handle user mentions + for _, mentionedJID := range extText.ContextInfo.MentionedJid { + numberAndSuffix := strings.SplitN(mentionedJID, "@", 2) + + // mentions comes as telephone numbers and we don't want to expose it to other bridges + // replace it with something more meaninful to others + mention := b.getSenderNotify(numberAndSuffix[0] + "@s.whatsapp.net") + if mention == "" { + mention = "someone" + } + + message.Text = strings.Replace(message.Text, "@"+numberAndSuffix[0], "@"+mention, 1) + } + } + + rmsg := config.Message{ + UserID: senderJID, + Username: senderName, + Text: message.Text, + Channel: groupJID, + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + // ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string + ID: message.Info.Id, + } + + if avatarURL, exists := b.userAvatars[senderJID]; exists { + rmsg.Avatar = avatarURL + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleImageMessage sent from WhatsApp, relay it to the brige +// nolint:funlen +func (b *Bwhatsapp) HandleImageMessage(message whatsapp.ImageMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } + + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } + + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } + + rmsg := config.Message{ + UserID: senderJID, + Username: senderName, + Channel: message.Info.RemoteJid, + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: message.Info.Id, + } + + if avatarURL, exists := b.userAvatars[senderJID]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(message.Type) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + // rename .jfif to .jpg https://github.com/42wim/matterbridge/issues/1292 + if fileExt[0] == ".jfif" { + fileExt[0] = ".jpg" + } + + // rename .jpe to .jpg https://github.com/42wim/matterbridge/issues/1463 + if fileExt[0] == ".jpe" { + fileExt[0] = ".jpg" + } + + filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0]) + + b.Log.Debugf("Trying to download %s with type %s", filename, message.Type) + + data, err := message.Download() + if err != nil { + b.Log.Errorf("Download image failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleVideoMessage downloads video messages +func (b *Bwhatsapp) HandleVideoMessage(message whatsapp.VideoMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } + + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } + + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } + + rmsg := config.Message{ + UserID: senderJID, + Username: senderName, + Channel: message.Info.RemoteJid, + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: message.Info.Id, + } + + if avatarURL, exists := b.userAvatars[senderJID]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(message.Type) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + if len(fileExt) == 0 { + fileExt = append(fileExt, ".mp4") + } + + filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0]) + + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type) + + data, err := message.Download() + if err != nil { + b.Log.Errorf("Download video failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleAudioMessage downloads audio messages +func (b *Bwhatsapp) HandleAudioMessage(message whatsapp.AudioMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } + + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } + + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } + + rmsg := config.Message{ + UserID: senderJID, + Username: senderName, + Channel: message.Info.RemoteJid, + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: message.Info.Id, + } + + if avatarURL, exists := b.userAvatars[senderJID]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(message.Type) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + if len(fileExt) == 0 { + fileExt = append(fileExt, ".ogg") + } + + filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0]) + + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type) + + data, err := message.Download() + if err != nil { + b.Log.Errorf("Download audio failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, "audio message", "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleDocumentMessage downloads documents +func (b *Bwhatsapp) HandleDocumentMessage(message whatsapp.DocumentMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } + + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } + + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } + + rmsg := config.Message{ + UserID: senderJID, + Username: senderName, + Channel: message.Info.RemoteJid, + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: message.Info.Id, + } + + if avatarURL, exists := b.userAvatars[senderJID]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(message.Type) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + filename := fmt.Sprintf("%v", message.FileName) + + b.Log.Debugf("Trying to download %s with extension %s and type %s", filename, fileExt, message.Type) + + data, err := message.Download() + if err != nil { + b.Log.Errorf("Download document message failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, "document", "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} diff --git a/teleirc/matterbridge/bridge/whatsapp/helpers.go b/teleirc/matterbridge/bridge/whatsapp/helpers.go new file mode 100644 index 0000000..9d39d36 --- /dev/null +++ b/teleirc/matterbridge/bridge/whatsapp/helpers.go @@ -0,0 +1,162 @@ +package bwhatsapp + +import ( + "encoding/gob" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + qrcodeTerminal "github.com/Baozisoftware/qrcode-terminal-go" + "github.com/Rhymen/go-whatsapp" +) + +type ProfilePicInfo struct { + URL string `json:"eurl"` + Tag string `json:"tag"` + Status int16 `json:"status"` +} + +func qrFromTerminal(invert bool) chan string { + qr := make(chan string) + + go func() { + terminal := qrcodeTerminal.New() + + if invert { + terminal = qrcodeTerminal.New2(qrcodeTerminal.ConsoleColors.BrightWhite, qrcodeTerminal.ConsoleColors.BrightBlack, qrcodeTerminal.QRCodeRecoveryLevels.Medium) + } + + terminal.Get(<-qr).Print() + }() + + return qr +} + +func (b *Bwhatsapp) readSession() (whatsapp.Session, error) { + session := whatsapp.Session{} + sessionFile := b.Config.GetString(sessionFile) + + if sessionFile == "" { + return session, errors.New("if you won't set SessionFile then you will need to scan QR code on every restart") + } + + file, err := os.Open(sessionFile) + if err != nil { + return session, err + } + + defer file.Close() + + decoder := gob.NewDecoder(file) + + return session, decoder.Decode(&session) +} + +func (b *Bwhatsapp) writeSession(session whatsapp.Session) error { + sessionFile := b.Config.GetString(sessionFile) + + if sessionFile == "" { + // we already sent a warning while starting the bridge, so let's be quiet here + return nil + } + + file, err := os.Create(sessionFile) + if err != nil { + return err + } + + defer file.Close() + + encoder := gob.NewEncoder(file) + + return encoder.Encode(session) +} + +func (b *Bwhatsapp) restoreSession() (*whatsapp.Session, error) { + session, err := b.readSession() + if err != nil { + b.Log.Warn(err.Error()) + } + + b.Log.Debugln("Restoring WhatsApp session..") + + session, err = b.conn.RestoreWithSession(session) + if err != nil { + // restore session connection timed out (I couldn't get over it without logging in again) + return nil, errors.New("failed to restore session: " + err.Error()) + } + + b.Log.Debugln("Session restored successfully!") + + return &session, nil +} + +func (b *Bwhatsapp) getSenderName(senderJid string) string { + if sender, exists := b.users[senderJid]; exists { + if sender.Name != "" { + return sender.Name + } + // if user is not in phone contacts + // it is the most obvious scenario unless you sync your phone contacts with some remote updated source + // users can change it in their WhatsApp settings -> profile -> click on Avatar + if sender.Notify != "" { + return sender.Notify + } + + if sender.Short != "" { + return sender.Short + } + } + + // try to reload this contact + if _, err := b.conn.Contacts(); err != nil { + b.Log.Errorf("error on update of contacts: %v", err) + } + + if contact, exists := b.conn.Store.Contacts[senderJid]; exists { + // Add it to the user map + b.users[senderJid] = contact + + if contact.Name != "" { + return contact.Name + } + // if user is not in phone contacts + // same as above + return contact.Notify + } + + return "" +} + +func (b *Bwhatsapp) getSenderNotify(senderJid string) string { + if sender, exists := b.users[senderJid]; exists { + return sender.Notify + } + + return "" +} + +func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*ProfilePicInfo, error) { + data, err := b.conn.GetProfilePicThumb(jid) + if err != nil { + return nil, fmt.Errorf("failed to get avatar: %v", err) + } + + content := <-data + info := &ProfilePicInfo{} + + err = json.Unmarshal([]byte(content), info) + if err != nil { + return info, fmt.Errorf("failed to unmarshal avatar info: %v", err) + } + + return info, nil +} + +func isGroupJid(identifier string) bool { + return strings.HasSuffix(identifier, "@g.us") || + strings.HasSuffix(identifier, "@temp") || + strings.HasSuffix(identifier, "@broadcast") +} diff --git a/teleirc/matterbridge/bridge/whatsapp/whatsapp.go b/teleirc/matterbridge/bridge/whatsapp/whatsapp.go new file mode 100644 index 0000000..bb0dfe5 --- /dev/null +++ b/teleirc/matterbridge/bridge/whatsapp/whatsapp.go @@ -0,0 +1,341 @@ +package bwhatsapp + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/Rhymen/go-whatsapp" +) + +const ( + // Account config parameters + cfgNumber = "Number" + qrOnWhiteTerminal = "QrOnWhiteTerminal" + sessionFile = "SessionFile" +) + +// Bwhatsapp Bridge structure keeping all the information needed for relying +type Bwhatsapp struct { + *bridge.Config + + session *whatsapp.Session + conn *whatsapp.Conn + startedAt uint64 + + users map[string]whatsapp.Contact + userAvatars map[string]string +} + +// New Create a new WhatsApp bridge. This will be called for each [whatsapp.<server>] entry you have in the config file +func New(cfg *bridge.Config) bridge.Bridger { + number := cfg.GetString(cfgNumber) + + cfg.Log.Warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + cfg.Log.Warn("This bridge is deprecated and not supported anymore. Use the new multidevice whatsapp bridge") + cfg.Log.Warn("See https://github.com/42wim/matterbridge#building-with-whatsapp-beta-multidevice-support for more info") + cfg.Log.Warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + + if number == "" { + cfg.Log.Fatalf("Missing configuration for WhatsApp bridge: Number") + } + + b := &Bwhatsapp{ + Config: cfg, + + users: make(map[string]whatsapp.Contact), + userAvatars: make(map[string]string), + } + + return b +} + +// Connect to WhatsApp. Required implementation of the Bridger interface +func (b *Bwhatsapp) Connect() error { + number := b.GetString(cfgNumber) + if number == "" { + return errors.New("whatsapp's telephone number need to be configured") + } + + b.Log.Debugln("Connecting to WhatsApp..") + conn, err := whatsapp.NewConn(20 * time.Second) + if err != nil { + return errors.New("failed to connect to WhatsApp: " + err.Error()) + } + + b.conn = conn + + b.conn.AddHandler(b) + b.Log.Debugln("WhatsApp connection successful") + + // load existing session in order to keep it between restarts + b.session, err = b.restoreSession() + if err != nil { + b.Log.Warn(err.Error()) + } + + // login to a new session + if b.session == nil { + if err = b.Login(); err != nil { + return err + } + } + + b.startedAt = uint64(time.Now().Unix()) + + _, err = b.conn.Contacts() + if err != nil { + return fmt.Errorf("error on update of contacts: %v", err) + } + + // see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013 + for len(b.conn.Store.Contacts) == 0 { + b.conn.Contacts() // nolint:errcheck + + <-time.After(1 * time.Second) + } + + // map all the users + for id, contact := range b.conn.Store.Contacts { + if !isGroupJid(id) && id != "status@broadcast" { + // it is user + b.users[id] = contact + } + } + + // get user avatar asynchronously + go func() { + b.Log.Debug("Getting user avatars..") + + for jid := range b.users { + info, err := b.GetProfilePicThumb(jid) + if err != nil { + b.Log.Warnf("Could not get profile photo of %s: %v", jid, err) + } else { + b.Lock() + b.userAvatars[jid] = info.URL + b.Unlock() + } + } + + b.Log.Debug("Finished getting avatars..") + }() + + return nil +} + +// Login to WhatsApp creating a new session. This will require to scan a QR code on your mobile device +func (b *Bwhatsapp) Login() error { + b.Log.Debugln("Logging in..") + + invert := b.GetBool(qrOnWhiteTerminal) // false is the default + qrChan := qrFromTerminal(invert) + + session, err := b.conn.Login(qrChan) + if err != nil { + b.Log.Warnln("Failed to log in:", err) + + return err + } + + b.session = &session + + b.Log.Infof("Logged into session: %#v", session) + b.Log.Infof("Connection: %#v", b.conn) + + err = b.writeSession(session) + if err != nil { + fmt.Fprintf(os.Stderr, "error saving session: %v\n", err) + } + + return nil +} + +// Disconnect is called while reconnecting to the bridge +// Required implementation of the Bridger interface +func (b *Bwhatsapp) Disconnect() error { + // We could Logout, but that would close the session completely and would require a new QR code scan + // https://github.com/Rhymen/go-whatsapp/blob/c31092027237441cffba1b9cb148eadf7c83c3d2/session.go#L377-L381 + return nil +} + +// JoinChannel Join a WhatsApp group specified in gateway config as channel='number-id@g.us' or channel='Channel name' +// Required implementation of the Bridger interface +// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16 +func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error { + byJid := isGroupJid(channel.Name) + + // see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013 + for len(b.conn.Store.Contacts) == 0 { + b.conn.Contacts() // nolint:errcheck + <-time.After(1 * time.Second) + } + + // verify if we are member of the given group + if byJid { + // channel.Name specifies static group jID, not the name + if _, exists := b.conn.Store.Contacts[channel.Name]; !exists { + return fmt.Errorf("account doesn't belong to group with jid %s", channel.Name) + } + + return nil + } + + // channel.Name specifies group name that might change, warn about it + var jids []string + for id, contact := range b.conn.Store.Contacts { + if isGroupJid(id) && contact.Name == channel.Name { + jids = append(jids, id) + } + } + + switch len(jids) { + case 0: + // didn't match any group - print out possibilites + for id, contact := range b.conn.Store.Contacts { + if isGroupJid(id) { + b.Log.Infof("%s %s", contact.Jid, contact.Name) + } + } + + return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name) + case 1: + return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", jids[0], channel.Name) + default: + return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, jids) + } +} + +// Post a document message from the bridge to WhatsApp +func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) { + fi := msg.Extra["file"][0].(config.FileInfo) + + // Post document message + message := whatsapp.DocumentMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: msg.Channel, + }, + Title: fi.Name, + FileName: fi.Name, + Type: filetype, + Content: bytes.NewReader(*fi.Data), + } + + b.Log.Debugf("=> Sending %#v", msg) + + // create message ID + // TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented + idBytes := make([]byte, 10) + if _, err := rand.Read(idBytes); err != nil { + b.Log.Warn(err.Error()) + } + + message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes)) + _, err := b.conn.Send(message) + + return message.Info.Id, err +} + +// Post an image message from the bridge to WhatsApp +// Handle, for sure image/jpeg, image/png and image/gif MIME types +func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) { + fi := msg.Extra["file"][0].(config.FileInfo) + + // Post image message + message := whatsapp.ImageMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: msg.Channel, + }, + Type: filetype, + Caption: msg.Username + fi.Comment, + Content: bytes.NewReader(*fi.Data), + } + + b.Log.Debugf("=> Sending %#v", msg) + + // create message ID + // TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented + idBytes := make([]byte, 10) + if _, err := rand.Read(idBytes); err != nil { + b.Log.Warn(err.Error()) + } + + message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes)) + _, err := b.conn.Send(message) + + return message.Info.Id, err +} + +// Send a message from the bridge to WhatsApp +// Required implementation of the Bridger interface +// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16 +func (b *Bwhatsapp) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + // No message ID in case action is executed on a message sent before the bridge was started + // and then the bridge cache doesn't have this message ID mapped + return "", nil + } + + _, err := b.conn.RevokeMessage(msg.Channel, msg.ID, true) + + return "", err + } + + // Edit message + if msg.ID != "" { + b.Log.Debugf("updating message with id %s", msg.ID) + + if b.GetString("editsuffix") != "" { + msg.Text += b.GetString("EditSuffix") + } else { + msg.Text += " (edited)" + } + } + + // Handle Upload a file + if msg.Extra["file"] != nil { + fi := msg.Extra["file"][0].(config.FileInfo) + filetype := mime.TypeByExtension(filepath.Ext(fi.Name)) + + b.Log.Debugf("Extra file is %#v", filetype) + + // TODO: add different types + // TODO: add webp conversion + switch filetype { + case "image/jpeg", "image/png", "image/gif": + return b.PostImageMessage(msg, filetype) + default: + return b.PostDocumentMessage(msg, filetype) + } + } + + // Post text message + message := whatsapp.TextMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: msg.Channel, // which equals to group id + }, + Text: msg.Username + msg.Text, + } + + b.Log.Debugf("=> Sending %#v", msg) + + return b.conn.Send(message) +} + +// TODO do we want that? to allow login with QR code from a bridged channel? https://github.com/tulir/mautrix-whatsapp/blob/513eb18e2d59bada0dd515ee1abaaf38a3bfe3d5/commands.go#L76 +//func (b *Bwhatsapp) Command(cmd string) string { +// return "" +//} diff --git a/teleirc/matterbridge/bridge/whatsappmulti/handlers.go b/teleirc/matterbridge/bridge/whatsappmulti/handlers.go new file mode 100644 index 0000000..34dce47 --- /dev/null +++ b/teleirc/matterbridge/bridge/whatsappmulti/handlers.go @@ -0,0 +1,338 @@ +// +build whatsappmulti + +package bwhatsapp + +import ( + "fmt" + "mime" + "strings" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + + "go.mau.fi/whatsmeow/binary/proto" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// nolint:gocritic +func (b *Bwhatsapp) eventHandler(evt interface{}) { + switch e := evt.(type) { + case *events.Message: + b.handleMessage(e) + } +} + +func (b *Bwhatsapp) handleMessage(message *events.Message) { + msg := message.Message + switch { + case msg == nil, message.Info.IsFromMe, message.Info.Timestamp.Before(b.startedAt): + return + } + + b.Log.Infof("Receiving message %#v", msg) + + switch { + case msg.Conversation != nil || msg.ExtendedTextMessage != nil: + b.handleTextMessage(message.Info, msg) + case msg.VideoMessage != nil: + b.handleVideoMessage(message) + case msg.AudioMessage != nil: + b.handleAudioMessage(message) + case msg.DocumentMessage != nil: + b.handleDocumentMessage(message) + case msg.ImageMessage != nil: + b.handleImageMessage(message) + } +} + +// nolint:funlen +func (b *Bwhatsapp) handleTextMessage(messageInfo types.MessageInfo, msg *proto.Message) { + senderJID := messageInfo.Sender + channel := messageInfo.Chat + + senderName := b.getSenderName(messageInfo) + + if msg.GetExtendedTextMessage() == nil && msg.GetConversation() == "" { + b.Log.Debugf("message without text content? %#v", msg) + return + } + + var text string + + // nolint:nestif + if msg.GetExtendedTextMessage() == nil { + text = msg.GetConversation() + } else { + text = msg.GetExtendedTextMessage().GetText() + ci := msg.GetExtendedTextMessage().GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + if ci.MentionedJid != nil { + // handle user mentions + for _, mentionedJID := range ci.MentionedJid { + numberAndSuffix := strings.SplitN(mentionedJID, "@", 2) + + // mentions comes as telephone numbers and we don't want to expose it to other bridges + // replace it with something more meaninful to others + mention := b.getSenderNotify(types.NewJID(numberAndSuffix[0], types.DefaultUserServer)) + + text = strings.Replace(text, "@"+numberAndSuffix[0], "@"+mention, 1) + } + } + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Text: text, + Channel: channel.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + // ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string + ID: messageInfo.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleImageMessage sent from WhatsApp, relay it to the brige +func (b *Bwhatsapp) handleImageMessage(msg *events.Message) { + imsg := msg.Message.GetImageMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(msg.Info) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + // rename .jfif to .jpg https://github.com/42wim/matterbridge/issues/1292 + if fileExt[0] == ".jfif" { + fileExt[0] = ".jpg" + } + + // rename .jpe to .jpg https://github.com/42wim/matterbridge/issues/1463 + if fileExt[0] == ".jpe" { + fileExt[0] = ".jpg" + } + + filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + + b.Log.Debugf("Trying to download %s with type %s", filename, imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download image failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleVideoMessage downloads video messages +func (b *Bwhatsapp) handleVideoMessage(msg *events.Message) { + imsg := msg.Message.GetVideoMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(msg.Info) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + if len(fileExt) == 0 { + fileExt = append(fileExt, ".mp4") + } + + filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, imsg.GetFileLength(), imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download video failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleAudioMessage downloads audio messages +func (b *Bwhatsapp) handleAudioMessage(msg *events.Message) { + imsg := msg.Message.GetAudioMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(msg.Info) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + if len(fileExt) == 0 { + fileExt = append(fileExt, ".ogg") + } + + filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, imsg.GetFileLength(), imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download video failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, "audio message", "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleDocumentMessage downloads documents +func (b *Bwhatsapp) handleDocumentMessage(msg *events.Message) { + imsg := msg.Message.GetDocumentMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(msg.Info) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + filename := fmt.Sprintf("%v", imsg.GetFileName()) + + b.Log.Debugf("Trying to download %s with extension %s and type %s", filename, fileExt, imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download document message failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} diff --git a/teleirc/matterbridge/bridge/whatsappmulti/helpers.go b/teleirc/matterbridge/bridge/whatsappmulti/helpers.go new file mode 100644 index 0000000..75d9f5f --- /dev/null +++ b/teleirc/matterbridge/bridge/whatsappmulti/helpers.go @@ -0,0 +1,124 @@ +//go:build whatsappmulti +// +build whatsappmulti + +package bwhatsapp + +import ( + "fmt" + "strings" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" +) + +type ProfilePicInfo struct { + URL string `json:"eurl"` + Tag string `json:"tag"` + Status int16 `json:"status"` +} + +func (b *Bwhatsapp) reloadContacts() { + if _, err := b.wc.Store.Contacts.GetAllContacts(); err != nil { + b.Log.Errorf("error on update of contacts: %v", err) + } + + allcontacts, err := b.wc.Store.Contacts.GetAllContacts() + if err != nil { + b.Log.Errorf("error on update of contacts: %v", err) + } + + if len(allcontacts) > 0 { + b.contacts = allcontacts + } +} + +func (b *Bwhatsapp) getSenderName(info types.MessageInfo) string { + // Parse AD JID + var senderJid types.JID + senderJid.User, senderJid.Server = info.Sender.User, info.Sender.Server + + sender, exists := b.contacts[senderJid] + + if !exists || (sender.FullName == "" && sender.FirstName == "") { + b.reloadContacts() // Contacts may need to be reloaded + sender, exists = b.contacts[senderJid] + } + + if exists && sender.FullName != "" { + return sender.FullName + } + + if info.PushName != "" { + return info.PushName + } + + if exists && sender.FirstName != "" { + return sender.FirstName + } + + return "Someone" +} + +func (b *Bwhatsapp) getSenderNotify(senderJid types.JID) string { + sender, exists := b.contacts[senderJid] + + if !exists || (sender.FullName == "" && sender.PushName == "" && sender.FirstName == "") { + b.reloadContacts() // Contacts may need to be reloaded + sender, exists = b.contacts[senderJid] + } + + if !exists { + return "someone" + } + + if exists && sender.FullName != "" { + return sender.FullName + } + + if exists && sender.PushName != "" { + return sender.PushName + } + + if exists && sender.FirstName != "" { + return sender.FirstName + } + + return "someone" +} + +func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*types.ProfilePictureInfo, error) { + pjid, _ := types.ParseJID(jid) + + info, err := b.wc.GetProfilePictureInfo(pjid, &whatsmeow.GetProfilePictureParams{ + Preview: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to get avatar: %v", err) + } + + return info, nil +} + +func isGroupJid(identifier string) bool { + return strings.HasSuffix(identifier, "@g.us") || + strings.HasSuffix(identifier, "@temp") || + strings.HasSuffix(identifier, "@broadcast") +} + +func (b *Bwhatsapp) getDevice() (*store.Device, error) { + device := &store.Device{} + + storeContainer, err := sqlstore.New("sqlite", "file:"+b.Config.GetString("sessionfile")+".db?_foreign_keys=on&_pragma=busy_timeout=10000", nil) + if err != nil { + return device, fmt.Errorf("failed to connect to database: %v", err) + } + + device, err = storeContainer.GetFirstDevice() + if err != nil { + return device, fmt.Errorf("failed to get device: %v", err) + } + + return device, nil +} diff --git a/teleirc/matterbridge/bridge/whatsappmulti/whatsapp.go b/teleirc/matterbridge/bridge/whatsappmulti/whatsapp.go new file mode 100644 index 0000000..76e4ae4 --- /dev/null +++ b/teleirc/matterbridge/bridge/whatsappmulti/whatsapp.go @@ -0,0 +1,413 @@ +//go:build whatsappmulti +// +build whatsappmulti + +package bwhatsapp + +import ( + "context" + "errors" + "fmt" + "mime" + "os" + "path/filepath" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/mdp/qrterminal" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/binary/proto" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" + + goproto "google.golang.org/protobuf/proto" + + _ "modernc.org/sqlite" // needed for sqlite +) + +const ( + // Account config parameters + cfgNumber = "Number" +) + +// Bwhatsapp Bridge structure keeping all the information needed for relying +type Bwhatsapp struct { + *bridge.Config + + startedAt time.Time + wc *whatsmeow.Client + contacts map[types.JID]types.ContactInfo + users map[string]types.ContactInfo + userAvatars map[string]string +} + +// New Create a new WhatsApp bridge. This will be called for each [whatsapp.<server>] entry you have in the config file +func New(cfg *bridge.Config) bridge.Bridger { + number := cfg.GetString(cfgNumber) + + if number == "" { + cfg.Log.Fatalf("Missing configuration for WhatsApp bridge: Number") + } + + b := &Bwhatsapp{ + Config: cfg, + + users: make(map[string]types.ContactInfo), + userAvatars: make(map[string]string), + } + + return b +} + +// Connect to WhatsApp. Required implementation of the Bridger interface +func (b *Bwhatsapp) Connect() error { + device, err := b.getDevice() + if err != nil { + return err + } + + number := b.GetString(cfgNumber) + if number == "" { + return errors.New("whatsapp's telephone number need to be configured") + } + + b.Log.Debugln("Connecting to WhatsApp..") + + b.wc = whatsmeow.NewClient(device, waLog.Stdout("Client", "INFO", true)) + b.wc.AddEventHandler(b.eventHandler) + + firstlogin := false + var qrChan <-chan whatsmeow.QRChannelItem + if b.wc.Store.ID == nil { + firstlogin = true + qrChan, err = b.wc.GetQRChannel(context.Background()) + if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { + return errors.New("failed to to get QR channel:" + err.Error()) + } + } + + err = b.wc.Connect() + if err != nil { + return errors.New("failed to connect to WhatsApp: " + err.Error()) + } + + if b.wc.Store.ID == nil { + for evt := range qrChan { + if evt.Event == "code" { + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + } else { + b.Log.Infof("QR channel result: %s", evt.Event) + } + } + } + + // disconnect and reconnect on our first login/pairing + // for some reason the GetJoinedGroups in JoinChannel doesn't work on first login + if firstlogin { + b.wc.Disconnect() + time.Sleep(time.Second) + + err = b.wc.Connect() + if err != nil { + return errors.New("failed to connect to WhatsApp: " + err.Error()) + } + } + + b.Log.Infoln("WhatsApp connection successful") + + b.contacts, err = b.wc.Store.Contacts.GetAllContacts() + if err != nil { + return errors.New("failed to get contacts: " + err.Error()) + } + + b.startedAt = time.Now() + + // map all the users + for id, contact := range b.contacts { + if !isGroupJid(id.String()) && id.String() != "status@broadcast" { + // it is user + b.users[id.String()] = contact + } + } + + // get user avatar asynchronously + b.Log.Info("Getting user avatars..") + + for jid := range b.users { + info, err := b.GetProfilePicThumb(jid) + if err != nil { + b.Log.Warnf("Could not get profile photo of %s: %v", jid, err) + } else { + b.Lock() + if info != nil { + b.userAvatars[jid] = info.URL + } + b.Unlock() + } + } + + b.Log.Info("Finished getting avatars..") + + return nil +} + +// Disconnect is called while reconnecting to the bridge +// Required implementation of the Bridger interface +func (b *Bwhatsapp) Disconnect() error { + b.wc.Disconnect() + + return nil +} + +// JoinChannel Join a WhatsApp group specified in gateway config as channel='number-id@g.us' or channel='Channel name' +// Required implementation of the Bridger interface +// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16 +func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error { + byJid := isGroupJid(channel.Name) + + groups, err := b.wc.GetJoinedGroups() + if err != nil { + return err + } + + // verify if we are member of the given group + if byJid { + gJID, err := types.ParseJID(channel.Name) + if err != nil { + return err + } + + for _, group := range groups { + if group.JID == gJID { + return nil + } + } + } + + foundGroups := []string{} + + for _, group := range groups { + if group.Name == channel.Name { + foundGroups = append(foundGroups, group.Name) + } + } + + switch len(foundGroups) { + case 0: + // didn't match any group - print out possibilites + for _, group := range groups { + b.Log.Infof("%s %s", group.JID, group.Name) + } + return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name) + case 1: + return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", foundGroups[0], channel.Name) + default: + return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, foundGroups) + } +} + +// Post a document message from the bridge to WhatsApp +func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + fi := msg.Extra["file"][0].(config.FileInfo) + + caption := msg.Username + fi.Comment + + resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaDocument) + if err != nil { + return "", err + } + + // Post document message + var message proto.Message + + message.DocumentMessage = &proto.DocumentMessage{ + Title: &fi.Name, + FileName: &fi.Name, + Mimetype: &filetype, + Caption: &caption, + MediaKey: resp.MediaKey, + FileEncSha256: resp.FileEncSHA256, + FileSha256: resp.FileSHA256, + FileLength: goproto.Uint64(resp.FileLength), + Url: &resp.URL, + } + + b.Log.Debugf("=> Sending %#v as a document", msg) + + ID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID}) + + return ID, err +} + +// Post an image message from the bridge to WhatsApp +// Handle, for sure image/jpeg, image/png and image/gif MIME types +func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + fi := msg.Extra["file"][0].(config.FileInfo) + + caption := msg.Username + fi.Comment + + resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaImage) + if err != nil { + return "", err + } + + var message proto.Message + + message.ImageMessage = &proto.ImageMessage{ + Mimetype: &filetype, + Caption: &caption, + MediaKey: resp.MediaKey, + FileEncSha256: resp.FileEncSHA256, + FileSha256: resp.FileSHA256, + FileLength: goproto.Uint64(resp.FileLength), + Url: &resp.URL, + } + + b.Log.Debugf("=> Sending %#v as an image", msg) + + ID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID}) + + return ID, err +} + +// Post a video message from the bridge to WhatsApp +func (b *Bwhatsapp) PostVideoMessage(msg config.Message, filetype string) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + fi := msg.Extra["file"][0].(config.FileInfo) + + caption := msg.Username + fi.Comment + + resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaVideo) + if err != nil { + return "", err + } + + var message proto.Message + + message.VideoMessage = &proto.VideoMessage{ + Mimetype: &filetype, + Caption: &caption, + MediaKey: resp.MediaKey, + FileEncSha256: resp.FileEncSHA256, + FileSha256: resp.FileSHA256, + FileLength: goproto.Uint64(resp.FileLength), + Url: &resp.URL, + } + + b.Log.Debugf("=> Sending %#v as a video", msg) + + ID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID}) + + return ID, err +} + +// Post audio inline +func (b *Bwhatsapp) PostAudioMessage(msg config.Message, filetype string) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + fi := msg.Extra["file"][0].(config.FileInfo) + + resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaAudio) + if err != nil { + return "", err + } + + var message proto.Message + + message.AudioMessage = &proto.AudioMessage{ + Mimetype: &filetype, + MediaKey: resp.MediaKey, + FileEncSha256: resp.FileEncSHA256, + FileSha256: resp.FileSHA256, + FileLength: goproto.Uint64(resp.FileLength), + Url: &resp.URL, + } + + b.Log.Debugf("=> Sending %#v as audio", msg) + + ID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID}) + + var captionMessage proto.Message + caption := msg.Username + fi.Comment + "\u2B06" // the char on the end is upwards arrow emoji + captionMessage.Conversation = &caption + + captionID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(context.TODO(), groupJID, &captionMessage, whatsmeow.SendRequestExtra{ID: captionID}) + + return ID, err +} + +// Send a message from the bridge to WhatsApp +func (b *Bwhatsapp) Send(msg config.Message) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + b.Log.Debugf("=> Receiving %#v", msg) + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + // No message ID in case action is executed on a message sent before the bridge was started + // and then the bridge cache doesn't have this message ID mapped + return "", nil + } + + _, err := b.wc.RevokeMessage(groupJID, msg.ID) + + return "", err + } + + // Edit message + if msg.ID != "" { + b.Log.Debugf("updating message with id %s", msg.ID) + + if b.GetString("editsuffix") != "" { + msg.Text += b.GetString("EditSuffix") + } else { + msg.Text += " (edited)" + } + } + + // Handle Upload a file + if msg.Extra["file"] != nil { + fi := msg.Extra["file"][0].(config.FileInfo) + filetype := mime.TypeByExtension(filepath.Ext(fi.Name)) + + b.Log.Debugf("Extra file is %#v", filetype) + + // TODO: add different types + // TODO: add webp conversion + switch filetype { + case "image/jpeg", "image/png", "image/gif": + return b.PostImageMessage(msg, filetype) + case "video/mp4", "video/3gpp": // TODO: Check if codecs are supported by WA + return b.PostVideoMessage(msg, filetype) + case "audio/ogg": + return b.PostAudioMessage(msg, "audio/ogg; codecs=opus") // TODO: Detect if it is actually OPUS + case "audio/aac", "audio/mp4", "audio/amr", "audio/mpeg": + return b.PostAudioMessage(msg, filetype) + default: + return b.PostDocumentMessage(msg, filetype) + } + } + + text := msg.Username + msg.Text + + var message proto.Message + + message.Conversation = &text + + ID := whatsmeow.GenerateMessageID() + _, err := b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID}) + + return ID, err +} diff --git a/teleirc/matterbridge/bridge/xmpp/handler.go b/teleirc/matterbridge/bridge/xmpp/handler.go new file mode 100644 index 0000000..731998d --- /dev/null +++ b/teleirc/matterbridge/bridge/xmpp/handler.go @@ -0,0 +1,34 @@ +package bxmpp + +import ( + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/matterbridge/go-xmpp" +) + +// handleDownloadAvatar downloads the avatar of userid from channel +// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful. +// logs an error message if it fails +func (b *Bxmpp) handleDownloadAvatar(avatar xmpp.AvatarData) { + rmsg := config.Message{ + Username: "system", + Text: "avatar", + Channel: b.parseChannel(avatar.From), + Account: b.Account, + UserID: avatar.From, + Event: config.EventAvatarDownload, + Extra: make(map[string][]interface{}), + } + if _, ok := b.avatarMap[avatar.From]; !ok { + b.Log.Debugf("Avatar.From: %s", avatar.From) + + err := helper.HandleDownloadSize(b.Log, &rmsg, avatar.From+".png", int64(len(avatar.Data)), b.General) + if err != nil { + b.Log.Error(err) + return + } + helper.HandleDownloadData(b.Log, &rmsg, avatar.From+".png", rmsg.Text, "", &avatar.Data, b.General) + b.Log.Debugf("Avatar download complete") + b.Remote <- rmsg + } +} diff --git a/teleirc/matterbridge/bridge/xmpp/helpers.go b/teleirc/matterbridge/bridge/xmpp/helpers.go new file mode 100644 index 0000000..eb6a536 --- /dev/null +++ b/teleirc/matterbridge/bridge/xmpp/helpers.go @@ -0,0 +1,30 @@ +package bxmpp + +import ( + "regexp" + + "github.com/42wim/matterbridge/bridge/config" +) + +var pathRegex = regexp.MustCompile("[^a-zA-Z0-9]+") + +// GetAvatar constructs a URL for a given user-avatar if it is available in the cache. +func getAvatar(av map[string]string, userid string, general *config.Protocol) string { + if hash, ok := av[userid]; ok { + // NOTE: This does not happen in bridge/helper/helper.go but messes up XMPP + id := pathRegex.ReplaceAllString(userid, "_") + return general.MediaServerDownload + "/" + hash + "/" + id + ".png" + } + return "" +} + +func (b *Bxmpp) cacheAvatar(msg *config.Message) string { + fi := msg.Extra["file"][0].(config.FileInfo) + /* if we have a sha we have successfully uploaded the file to the media server, + so we can now cache the sha */ + if fi.SHA != "" { + b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID) + b.avatarMap[msg.UserID] = fi.SHA + } + return "" +} diff --git a/teleirc/matterbridge/bridge/xmpp/xmpp.go b/teleirc/matterbridge/bridge/xmpp/xmpp.go new file mode 100644 index 0000000..cf58a2f --- /dev/null +++ b/teleirc/matterbridge/bridge/xmpp/xmpp.go @@ -0,0 +1,461 @@ +package bxmpp + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/jpillora/backoff" + "github.com/matterbridge/go-xmpp" + "github.com/rs/xid" +) + +type Bxmpp struct { + *bridge.Config + + startTime time.Time + xc *xmpp.Client + xmppMap map[string]string + connected bool + sync.RWMutex + + avatarAvailability map[string]bool + avatarMap map[string]string +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Bxmpp{ + Config: cfg, + xmppMap: make(map[string]string), + avatarAvailability: make(map[string]bool), + avatarMap: make(map[string]string), + } +} + +func (b *Bxmpp) Connect() error { + b.Log.Infof("Connecting %s", b.GetString("Server")) + if err := b.createXMPP(); err != nil { + b.Log.Debugf("%#v", err) + return err + } + + b.Log.Info("Connection succeeded") + go b.manageConnection() + return nil +} + +func (b *Bxmpp) Disconnect() error { + return nil +} + +func (b *Bxmpp) JoinChannel(channel config.ChannelInfo) error { + if channel.Options.Key != "" { + b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name) + b.xc.JoinProtectedMUC(channel.Name+"@"+b.GetString("Muc"), b.GetString("Nick"), channel.Options.Key, xmpp.NoHistory, 0, nil) + } else { + b.xc.JoinMUCNoHistory(channel.Name+"@"+b.GetString("Muc"), b.GetString("Nick")) + } + return nil +} + +func (b *Bxmpp) Send(msg config.Message) (string, error) { + // should be fixed by using a cache instead of dropping + if !b.Connected() { + return "", fmt.Errorf("bridge %s not connected, dropping message %#v to bridge", b.Account, msg) + } + // ignore delete messages + if msg.Event == config.EventMsgDelete { + return "", nil + } + + b.Log.Debugf("=> Receiving %#v", msg) + + if msg.Event == config.EventAvatarDownload { + return b.cacheAvatar(&msg), nil + } + + // Make a action /me of the message, prepend the username with it. + // https://xmpp.org/extensions/xep-0245.html + if msg.Event == config.EventUserAction { + msg.Username = "/me " + msg.Username + } + + // Upload a file (in XMPP case send the upload URL because XMPP has no native upload support). + var err error + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + b.Log.Debugf("=> Sending attachement message %#v", rmsg) + if b.GetString("WebhookURL") != "" { + err = b.postSlackCompatibleWebhook(msg) + } else { + _, err = b.xc.Send(xmpp.Chat{ + Type: "groupchat", + Remote: rmsg.Channel + "@" + b.GetString("Muc"), + Text: rmsg.Username + rmsg.Text, + }) + } + + if err != nil { + b.Log.WithError(err).Error("Unable to send message with share URL.") + } + } + if len(msg.Extra["file"]) > 0 { + return "", b.handleUploadFile(&msg) + } + } + + if b.GetString("WebhookURL") != "" { + b.Log.Debugf("Sending message using Webhook") + err := b.postSlackCompatibleWebhook(msg) + if err != nil { + b.Log.Errorf("Failed to send message using webhook: %s", err) + return "", err + } + + return "", nil + } + + // Post normal message. + var msgReplaceID string + msgID := xid.New().String() + if msg.ID != "" { + msgReplaceID = msg.ID + } + b.Log.Debugf("=> Sending message %#v", msg) + if _, err := b.xc.Send(xmpp.Chat{ + Type: "groupchat", + Remote: msg.Channel + "@" + b.GetString("Muc"), + Text: msg.Username + msg.Text, + ID: msgID, + ReplaceID: msgReplaceID, + }); err != nil { + return "", err + } + return msgID, nil +} + +func (b *Bxmpp) postSlackCompatibleWebhook(msg config.Message) error { + type XMPPWebhook struct { + Username string `json:"username"` + Text string `json:"text"` + } + webhookBody, err := json.Marshal(XMPPWebhook{ + Username: msg.Username, + Text: msg.Text, + }) + if err != nil { + b.Log.Errorf("Failed to marshal webhook: %s", err) + return err + } + + resp, err := http.Post(b.GetString("WebhookURL")+"/"+url.QueryEscape(msg.Channel), "application/json", bytes.NewReader(webhookBody)) + if err != nil { + b.Log.Errorf("Failed to POST webhook: %s", err) + return err + } + + resp.Body.Close() + return nil +} + +func (b *Bxmpp) createXMPP() error { + var serverName string + switch { + case !b.GetBool("Anonymous"): + if !strings.Contains(b.GetString("Jid"), "@") { + return fmt.Errorf("the Jid %s doesn't contain an @", b.GetString("Jid")) + } + serverName = strings.Split(b.GetString("Jid"), "@")[1] + case !strings.Contains(b.GetString("Server"), ":"): + serverName = strings.Split(b.GetString("Server"), ":")[0] + default: + serverName = b.GetString("Server") + } + + tc := &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: b.GetBool("SkipTLSVerify"), // nolint: gosec + } + + xmpp.DebugWriter = b.Log.Writer() + + options := xmpp.Options{ + Host: b.GetString("Server"), + User: b.GetString("Jid"), + Password: b.GetString("Password"), + NoTLS: true, + StartTLS: !b.GetBool("NoTLS"), + TLSConfig: tc, + Debug: b.GetBool("debug"), + Session: true, + Status: "", + StatusMessage: "", + Resource: "", + InsecureAllowUnencryptedAuth: b.GetBool("NoTLS"), + } + var err error + b.xc, err = options.NewClient() + return err +} + +func (b *Bxmpp) manageConnection() { + b.setConnected(true) + initial := true + bf := &backoff.Backoff{ + Min: time.Second, + Max: 5 * time.Minute, + Jitter: true, + } + + // Main connection loop. Each iteration corresponds to a successful + // connection attempt and the subsequent handling of the connection. + for { + if initial { + initial = false + } else { + b.Remote <- config.Message{ + Username: "system", + Text: "rejoin", + Channel: "", + Account: b.Account, + Event: config.EventRejoinChannels, + } + } + + if err := b.handleXMPP(); err != nil { + b.Log.WithError(err).Error("Disconnected.") + b.setConnected(false) + } + + // Reconnection loop using an exponential back-off strategy. We + // only break out of the loop if we have successfully reconnected. + for { + d := bf.Duration() + b.Log.Infof("Reconnecting in %s.", d) + time.Sleep(d) + + b.Log.Infof("Reconnecting now.") + if err := b.createXMPP(); err == nil { + b.setConnected(true) + bf.Reset() + break + } + b.Log.Warn("Failed to reconnect.") + } + } +} + +func (b *Bxmpp) xmppKeepAlive() chan bool { + done := make(chan bool) + go func() { + ticker := time.NewTicker(90 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + b.Log.Debugf("PING") + if err := b.xc.PingC2S("", ""); err != nil { + b.Log.Debugf("PING failed %#v", err) + } + case <-done: + return + } + } + }() + return done +} + +func (b *Bxmpp) handleXMPP() error { + b.startTime = time.Now() + + done := b.xmppKeepAlive() + defer close(done) + + for { + m, err := b.xc.Recv() + if err != nil { + // An error together with AvatarData is non-fatal + switch m.(type) { + case xmpp.AvatarData: + continue + default: + return err + } + } + + switch v := m.(type) { + case xmpp.Chat: + if v.Type == "groupchat" { + b.Log.Debugf("== Receiving %#v", v) + + // Skip invalid messages. + if b.skipMessage(v) { + continue + } + + var event string + if strings.Contains(v.Text, "has set the subject to:") { + event = config.EventTopicChange + } + + available, sok := b.avatarAvailability[v.Remote] + avatar := "" + if !sok { + b.Log.Debugf("Requesting avatar data") + b.avatarAvailability[v.Remote] = false + b.xc.AvatarRequestData(v.Remote) + } else if available { + avatar = getAvatar(b.avatarMap, v.Remote, b.General) + } + + msgID := v.ID + if v.ReplaceID != "" { + msgID = v.ReplaceID + } + rmsg := config.Message{ + Username: b.parseNick(v.Remote), + Text: v.Text, + Channel: b.parseChannel(v.Remote), + Account: b.Account, + Avatar: avatar, + UserID: v.Remote, + ID: msgID, + Event: event, + } + + // Check if we have an action event. + var ok bool + rmsg.Text, ok = b.replaceAction(rmsg.Text) + if ok { + rmsg.Event = config.EventUserAction + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg + } + case xmpp.AvatarData: + b.handleDownloadAvatar(v) + b.avatarAvailability[v.From] = true + b.Log.Debugf("Avatar for %s is now available", v.From) + case xmpp.Presence: + // Do nothing. + } + } +} + +func (b *Bxmpp) replaceAction(text string) (string, bool) { + if strings.HasPrefix(text, "/me ") { + return strings.Replace(text, "/me ", "", -1), true + } + return text, false +} + +// handleUploadFile handles native upload of files +func (b *Bxmpp) handleUploadFile(msg *config.Message) error { + var urlDesc string + + for _, file := range msg.Extra["file"] { + fileInfo := file.(config.FileInfo) + if fileInfo.Comment != "" { + msg.Text += fileInfo.Comment + ": " + } + if fileInfo.URL != "" { + msg.Text = fileInfo.URL + if fileInfo.Comment != "" { + msg.Text = fileInfo.Comment + ": " + fileInfo.URL + urlDesc = fileInfo.Comment + } + } + if _, err := b.xc.Send(xmpp.Chat{ + Type: "groupchat", + Remote: msg.Channel + "@" + b.GetString("Muc"), + Text: msg.Username + msg.Text, + }); err != nil { + return err + } + + if fileInfo.URL != "" { + if _, err := b.xc.SendOOB(xmpp.Chat{ + Type: "groupchat", + Remote: msg.Channel + "@" + b.GetString("Muc"), + Ooburl: fileInfo.URL, + Oobdesc: urlDesc, + }); err != nil { + b.Log.WithError(err).Warn("Failed to send share URL.") + } + } + } + return nil +} + +func (b *Bxmpp) parseNick(remote string) string { + s := strings.Split(remote, "@") + if len(s) > 1 { + s = strings.Split(s[1], "/") + if len(s) == 2 { + return s[1] // nick + } + } + return "" +} + +func (b *Bxmpp) parseChannel(remote string) string { + s := strings.Split(remote, "@") + if len(s) >= 2 { + return s[0] // channel + } + return "" +} + +// skipMessage skips messages that need to be skipped +func (b *Bxmpp) skipMessage(message xmpp.Chat) bool { + // skip messages from ourselves + if b.parseNick(message.Remote) == b.GetString("Nick") { + return true + } + + // skip empty messages + if message.Text == "" { + return true + } + + // skip subject messages + if strings.Contains(message.Text, "</subject>") { + return true + } + + // do not show subjects on connect #732 + if strings.Contains(message.Text, "has set the subject to:") && time.Since(b.startTime) < time.Second*5 { + return true + } + + // Ignore messages posted by our webhook + if b.GetString("WebhookURL") != "" && strings.Contains(message.ID, "webhookbot") { + return true + } + + // skip delayed messages + return !message.Stamp.IsZero() && time.Since(message.Stamp).Minutes() > 5 +} + +func (b *Bxmpp) setConnected(state bool) { + b.Lock() + b.connected = state + defer b.Unlock() +} + +func (b *Bxmpp) Connected() bool { + b.RLock() + defer b.RUnlock() + return b.connected +} diff --git a/teleirc/matterbridge/bridge/zulip/zulip.go b/teleirc/matterbridge/bridge/zulip/zulip.go new file mode 100644 index 0000000..c912b6f --- /dev/null +++ b/teleirc/matterbridge/bridge/zulip/zulip.go @@ -0,0 +1,216 @@ +package bzulip + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strconv" + "strings" + "sync" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/42wim/matterbridge/version" + gzb "github.com/matterbridge/gozulipbot" +) + +type Bzulip struct { + q *gzb.Queue + bot *gzb.Bot + streams map[int]string + *bridge.Config + sync.RWMutex +} + +func New(cfg *bridge.Config) bridge.Bridger { + return &Bzulip{Config: cfg, streams: make(map[int]string)} +} + +func (b *Bzulip) Connect() error { + bot := gzb.Bot{APIKey: b.GetString("token"), APIURL: b.GetString("server") + "/api/v1/", Email: b.GetString("login"), UserAgent: fmt.Sprintf("matterbridge/%s", version.Release)} + bot.Init() + q, err := bot.RegisterAll() + b.q = q + b.bot = &bot + if err != nil { + b.Log.Errorf("Connect() %#v", err) + return err + } + // init stream + b.getChannel(0) + b.Log.Info("Connection succeeded") + go b.handleQueue() + return nil +} + +func (b *Bzulip) Disconnect() error { + return nil +} + +func (b *Bzulip) JoinChannel(channel config.ChannelInfo) error { + return nil +} + +func (b *Bzulip) Send(msg config.Message) (string, error) { + b.Log.Debugf("=> Receiving %#v", msg) + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + _, err := b.bot.UpdateMessage(msg.ID, "") + return "", err + } + + // Upload a file if it exists + if msg.Extra != nil { + for _, rmsg := range helper.HandleExtra(&msg, b.General) { + b.sendMessage(rmsg) + } + if len(msg.Extra["file"]) > 0 { + return b.handleUploadFile(&msg) + } + } + + // edit the message if we have a msg ID + if msg.ID != "" { + _, err := b.bot.UpdateMessage(msg.ID, msg.Username+msg.Text) + return "", err + } + + // Post normal message + return b.sendMessage(msg) +} + +func (b *Bzulip) getChannel(id int) string { + if name, ok := b.streams[id]; ok { + return name + } + streams, err := b.bot.GetRawStreams() + if err != nil { + b.Log.Errorf("getChannel: %#v", err) + return "" + } + for _, stream := range streams.Streams { + b.streams[stream.StreamID] = stream.Name + } + if name, ok := b.streams[id]; ok { + return name + } + return "" +} + +func (b *Bzulip) handleQueue() error { + for { + messages, err := b.q.GetEvents() + switch err { + case gzb.BackoffError: + time.Sleep(time.Second * 5) + case gzb.NoJSONError: + b.Log.Error("Response wasn't JSON, server down or restarting? sleeping 10 seconds") + time.Sleep(time.Second * 10) + case gzb.BadEventQueueError: + b.Log.Info("got a bad event queue id error, reconnecting") + b.bot.Queues = nil + for { + b.q, err = b.bot.RegisterAll() + if err != nil { + b.Log.Errorf("reconnecting failed: %s. Sleeping 10 seconds", err) + time.Sleep(time.Second * 10) + } + break + } + case gzb.HeartbeatError: + b.Log.Debug("heartbeat received.") + default: + b.Log.Debugf("receiving error: %#v", err) + time.Sleep(time.Second * 10) + } + if err != nil { + continue + } + for _, m := range messages { + b.Log.Debugf("== Receiving %#v", m) + // ignore our own messages + if m.SenderEmail == b.GetString("login") { + continue + } + + avatarURL := m.AvatarURL + if !strings.HasPrefix(avatarURL, "http") { + avatarURL = b.GetString("server") + avatarURL + } + + rmsg := config.Message{ + Username: m.SenderFullName, + Text: m.Content, + Channel: b.getChannel(m.StreamID) + "/topic:" + m.Subject, + Account: b.Account, + UserID: strconv.Itoa(m.SenderID), + Avatar: avatarURL, + } + b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg + } + + time.Sleep(time.Second * 3) + } +} + +func (b *Bzulip) sendMessage(msg config.Message) (string, error) { + topic := "" + if strings.Contains(msg.Channel, "/topic:") { + res := strings.Split(msg.Channel, "/topic:") + topic = res[1] + msg.Channel = res[0] + } + m := gzb.Message{ + Stream: msg.Channel, + Topic: topic, + Content: msg.Username + msg.Text, + } + resp, err := b.bot.Message(m) + if err != nil { + return "", err + } + if resp != nil { + defer resp.Body.Close() + res, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + var jr struct { + ID int `json:"id"` + } + err = json.Unmarshal(res, &jr) + if err != nil { + return "", err + } + return strconv.Itoa(jr.ID), nil + } + return "", nil +} + +func (b *Bzulip) handleUploadFile(msg *config.Message) (string, error) { + for _, f := range msg.Extra["file"] { + fi := f.(config.FileInfo) + if fi.Comment != "" { + msg.Text += fi.Comment + ": " + } + if fi.URL != "" { + msg.Text = fi.URL + if fi.Comment != "" { + msg.Text = fi.Comment + ": " + fi.URL + } + } + _, err := b.sendMessage(*msg) + if err != nil { + return "", err + } + } + return "", nil +} |
