aboutsummaryrefslogblamecommitdiff
path: root/connector/mattermost/mattermost.go
blob: 86bf6b53a0761e042b1b3f4026d9d9ececb97068 (plain) (tree)
1
2
3
4
5
6
7
8
9


                  
                       
             

                   

                 
              

              
                                                    
                                                          
                                        









                                                             
                       
                       
                                
 


                                                                                                        
                                              
                                     
 








                                                                         
                                                                                

 




                                             
                                  






                                                        
                                              
                                                






                                                       











                                                  
                                                                  








                                                                   
                                          


                          





                                                        
 




                                                




                                                  

                        
                                                                             
                                         



                             



                                                                                           

                               
                                                                 
 
                  

















                                                              
                                                                                                                     



















                                                                  
                                                               

                                            
                                                                        

                                                     

                                                          



                                                                                               










                                                                                                     




                                                       

                                   
 



                                                                             

                                      
                        















                                                                        
                                            



















                                                                                




                                                






                                                                
                                                                                












                                                                                                                     

 
                                                          





                                       



                                                     
                                      
                 
                                   


                                                          
                                      
                 


                                                                                  
                                             




                                                                          
                                      


                                                                      
                
                                                       
         
 




                                                        
                                              



                                                        
                                              



                                                                                                 
                                              




                                                                    
                                                             
                              
                                                             
                                     
         
                                   

 



                                                          
                               


                                     
                                      
                                         




                                         
                                                                                                                                 

                               
                                      
                                       
                                  

                                                












                                                                                               
                 





                                                  
         


                                                          
 
 





                                                          




                                                               


                         


                                                                                
                              
                 
                                     


                                       
                                              
                                         







                                                                                                    
                                                                  
                                                                                              




                                                                                                              

                                                                                                                             





                                                                                                           
                                          






                                                                     
                                                                                          
                                                                                                   













                                                                                               
                                                                                          
                 
         
 
 
                                                         
                       

                                                                                 

















                                                                                                                    
                 
                














                                                                                                    
                                                                               
                                                  


         
                                                                                          




                                    

                                                                    


                                                                      



                         
 



                                                                        



                                                                                   



                                               
                                                                      




                                                                                                

                                                                                                                  





                                                                                      
                          
                 
                                                      
                                                        





                                                                        




                                                          

                                        

                                           
                  
                                                      


         
                                                                     



                                                             






                                                      



                                                                                                   










                                                                        
                               
 

                              
                                
                               

                                      




                                          
                       
                                                               
                                                     
                                                          
                                                             
                                                          
                                                              







                                                                                     


                                                                          
                                                           


                                                            
                                                                                                   


                 








                                                           
                                                                                         
                



                                                                      



                                                               
                                             

                                                        





                                                                                     


                                                                
                                                                                                 





                                                                               
package mattermost

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	_ "os"
	"strings"
	"sync"
	"time"

	"github.com/42wim/matterbridge/matterclient"
	"github.com/mattermost/mattermost-server/v5/model"
	log "github.com/sirupsen/logrus"

	. "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
)

// User id format: nickname@server
// Room id format: room_name@team@server

type Mattermost struct {
	handler Handler

	server   string
	username string
	teams    map[string]bool

	initial_members int // How many room members (maximum) to load when first joining a channel
	initial_backlog int // How many previous messages (maximum) to load when first joining a channel

	conn            *matterclient.MMClient
	handlerStopChan chan struct{}

	caches mmCaches
}

type mmCaches struct {
	sync.Mutex

	mmusers     map[string]string // map mm username to mm user id
	sentjoined  map[string]bool   // map username/room name to bool
	displayname map[UserID]string // map username to last displayname
	initsynced  map[string]bool   // chans for which init sync has been done
}

func (mm *Mattermost) SetHandler(h Handler) {
	mm.handler = h
}

func (mm *Mattermost) Protocol() string {
	return MATTERMOST_PROTOCOL
}

func (mm *Mattermost) Configure(c Configuration) error {
	if mm.conn != nil {
		mm.Close()
	}

	// Reinitialize shared data structures
	mm.handlerStopChan = make(chan struct{})

	mm.caches.mmusers = make(map[string]string)
	mm.caches.sentjoined = make(map[string]bool)
	mm.caches.displayname = make(map[UserID]string)
	mm.caches.initsynced = make(map[string]bool)

	// Read config
	var err error

	mm.server, err = c.GetString("server")
	if err != nil {
		return err
	}

	mm.username, err = c.GetString("username")
	if err != nil {
		return err
	}

	mm.initial_members, err = c.GetInt("initial_members", 100)
	if err != nil {
		return err
	}

	mm.initial_backlog, err = c.GetInt("initial_backlog", 1000)
	if err != nil {
		return err
	}

	teams, err := c.GetString("teams")
	if err != nil {
		return err
	}
	mm.teams = map[string]bool{}
	anyteam := ""
	for _, team := range strings.Split(teams, ",") {
		anyteam = strings.TrimSpace(team)
		mm.teams[anyteam] = true
	}

	notls, err := c.GetBool("no_tls", false)
	if err != nil {
		return err
	}

	password, _ := c.GetString("password", "")
	token, _ := c.GetString("token", "")
	if token != "" {
		password = "token=" + token
	}

	// Try to log in
	mm.conn = matterclient.New(mm.username, password, anyteam, mm.server)
	mm.conn.Credentials.NoTLS = notls
	err = mm.conn.Login()
	if err != nil {
		return err
	}

	// Try to start listening for messages
	// Everytime the listener reconnects, mm.handleConnected does a sync of room status
	mm.conn.OnWsConnect = mm.handleConnected
	go mm.conn.WsReceiver()
	go mm.conn.StatusLoop()
	go mm.handleLoop(mm.conn.MessageChan, mm.handlerStopChan)

	return nil
}

func (mm *Mattermost) User() UserID {
	return UserID(mm.username + "@" + mm.server)
}

func (mm *Mattermost) getTeamIdByName(name string) string {
	for _, team := range mm.conn.OtherTeams {
		if team.Team.Name == name {
			return team.Id
		}
	}
	return ""
}

func (mm *Mattermost) checkRoomId(id RoomID) (string, error) {
	x := strings.Split(string(id), "@")
	if len(x) == 1 {
		return "", fmt.Errorf("Please write whole room ID with team and server: %s@<team>@%s", id, mm.server)
	}
	if len(x) == 2 {
		return x[0], nil
	}
	if len(x) != 3 || x[2] != mm.server {
		return "", fmt.Errorf("Invalid room ID: %s", id)
	}

	team_id := mm.getTeamIdByName(x[1])
	if team_id == "" {
		return "", fmt.Errorf("Team not found: %s", id)
	}

	ch_id := mm.conn.GetChannelId(x[0], team_id)
	if ch_id == "" {
		return "", fmt.Errorf("Channel not found: %s", id)
	}
	return ch_id, nil
}

func (mm *Mattermost) reverseRoomId(id string) (bool, RoomID) {
	team := mm.conn.GetChannelTeamId(id)
	if team == "" {
		return true, RoomID(fmt.Sprintf("%s@%s", id, mm.server))
	} else {
		teamName := mm.conn.GetTeamName(team)
		if u, ok := mm.teams[teamName]; ok && u {
			name := mm.conn.GetChannelName(id)
			return true, RoomID(fmt.Sprintf("%s@%s@%s", name, teamName, mm.server))
		} else {
			return false, ""
		}
	}
}

func (mm *Mattermost) checkUserId(id UserID) (string, error) {
	x := strings.Split(string(id), "@")
	if len(x) == 1 {
		return "", fmt.Errorf("Please write whole user ID with server: %s@%s", id, mm.server)
	}
	if len(x) != 2 || x[1] != mm.server {
		return "", fmt.Errorf("Invalid user ID: %s", id)
	}

	mm.caches.Lock()
	defer mm.caches.Unlock()

	if user_id, ok := mm.caches.mmusers[x[0]]; ok {
		return user_id, nil
	}

	u, resp := mm.conn.Client.GetUserByUsername(x[0], "")
	if u == nil || resp.Error != nil {
		return "", fmt.Errorf("Not found: %s (%s)", x[0], resp.Error)
	}
	mm.caches.mmusers[x[0]] = u.Id

	return u.Id, nil
}

func (mm *Mattermost) SetUserInfo(info *UserInfo) error {
	return fmt.Errorf("Not implemented")
}

func (mm *Mattermost) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
	ch, err := mm.checkRoomId(roomId)
	if err != nil {
		return err
	}

	if info.Topic != "" {
		mm.conn.UpdateChannelHeader(ch, info.Topic)
	}

	if info.Picture.MediaObject != nil {
		err = fmt.Errorf("Not supported: channel picture on mattermost")
	}

	if info.Name != "" {
		err = fmt.Errorf("Not supported: channel name on mattermost")
	}

	return err
}

func (mm *Mattermost) Join(roomId RoomID) error {
	ch, err := mm.checkRoomId(roomId)
	if err != nil {
		return err
	}

	return mm.conn.JoinChannel(ch)
}

func (mm *Mattermost) Invite(userId UserID, roomId RoomID) error {
	if roomId == "" {
		_, err := mm.checkUserId(userId)
		return err
	}

	return fmt.Errorf("Not supported: invite on mattermost")
}

func (mm *Mattermost) Leave(roomId RoomID) {
	// Not supported? TODO
}

func (mm *Mattermost) SearchForUsers(query string) ([]UserSearchResult, error) {
	query = strings.ToLower(query)
	ret := []UserSearchResult{}
	for _, user := range mm.conn.Users {
		if strings.Contains(strings.ToLower(user.Username), query) ||
			strings.Contains(strings.ToLower(user.Nickname), query) ||
			strings.Contains(strings.ToLower(user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME)), query) {
			ret = append(ret, UserSearchResult{
				ID:          UserID(fmt.Sprintf("%s@%s", user.Username, mm.server)),
				DisplayName: user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME),
			})
		}
	}
	return ret, nil
}

func (mm *Mattermost) Send(event *Event) (string, error) {
	post := &model.Post{
		Message: event.Text,
	}
	if event.Type == EVENT_ACTION {
		post.Type = "me"
	}

	if event.Room != "" {
		ch, err := mm.checkRoomId(event.Room)
		if err != nil {
			return "", err
		}
		post.ChannelId = ch
	} else if event.Recipient != "" {
		ui, err := mm.checkUserId(event.Recipient)
		if err != nil {
			return "", err
		}

		_, resp := mm.conn.Client.CreateDirectChannel(mm.conn.User.Id, ui)
		if resp.Error != nil {
			return "", resp.Error
		}
		channelName := model.GetDMNameFromIds(ui, mm.conn.User.Id)

		err = mm.conn.UpdateChannels()
		if err != nil {
			return "", err
		}

		post.ChannelId = mm.conn.GetChannelId(channelName, "")
	} else {
		return "", fmt.Errorf("Invalid target")
	}

	if event.Attachments != nil {
		post.FileIds = []string{}
		for _, file := range event.Attachments {
			rdr, err := file.Read()
			if err != nil {
				return "", err
			}
			defer rdr.Close()
			data, err := ioutil.ReadAll(rdr)
			if err != nil {
				return "", err
			}
			up_file, err := mm.conn.UploadFile(data, post.ChannelId, file.Filename())
			if err != nil {
				log.Warnf("UploadFile error: %s", err)
				return "", err
			}
			post.FileIds = append(post.FileIds, up_file)
		}
	}

	created_post, resp := mm.conn.Client.CreatePost(post)
	if resp.Error != nil {
		log.Warnf("CreatePost error: %s", resp.Error)
		return "", resp.Error
	}
	return created_post.Id, nil
}

func (mm *Mattermost) UserCommand(cm string) {
	mm.handler.SystemMessage("Command not supported.")
}

func (mm *Mattermost) Close() {
	if mm.conn != nil {
		mm.conn.WsQuit = true
	}
	if mm.handlerStopChan != nil {
		close(mm.handlerStopChan)
		mm.handlerStopChan = nil
	}
}

func (mm *Mattermost) handleConnected() {
	mm.handler.SystemMessage(fmt.Sprintf("(Re-)connected to mattermost (%s@%s), doing channel sync", mm.username, mm.server))

	// Initial channel sync
	chans := mm.conn.GetChannels()
	doneCh := make(map[string]bool)
	for _, ch := range chans {
		if _, ok := doneCh[ch.Id]; !ok {
			doneCh[ch.Id] = true
			go mm.syncChannel(ch)
		}
	}
}

func (mm *Mattermost) syncChannel(ch *model.Channel) {
	// The first time we see a chan, sync everything (member list, names, profile pictures)
	must_initsync := func() bool {
		mm.caches.Lock()
		defer mm.caches.Unlock()

		if x, ok := mm.caches.initsynced[ch.Id]; ok && x {
			return false
		}
		mm.caches.initsynced[ch.Id] = true
		return true
	}()

	if must_initsync {
		mm.initSyncChannel(ch)
	}

	// The following times, only sync missing messages
	mm.backlogChannel(ch)
}

func (mm *Mattermost) initSyncChannel(ch *model.Channel) {
	if len(strings.Split(ch.Name, "__")) == 2 {
		// DM channel
		// Update remote user info
		users := strings.Split(ch.Name, "__")
		for _, uid := range users {
			if uid != mm.conn.User.Id {
				user := mm.conn.GetUser(uid)
				if user != nil {
					mm.updateUserInfo(user)
				}
			}
		}
	} else {
		interested, id := mm.reverseRoomId(ch.Id)
		if !interested {
			// Skip channels that are not in teams we want to bridge
			return
		}
		mm.handler.Joined(id)

		// Update room info
		room_info := &RoomInfo{
			Name:  ch.DisplayName,
			Topic: ch.Header,
		}
		for _, t := range mm.conn.OtherTeams {
			if t.Id == ch.TeamId {
				if t.Team.DisplayName != "" {
					room_info.Name = t.Team.DisplayName + " / " + room_info.Name
				} else {
					room_info.Name = t.Team.Name + " / " + room_info.Name
				}
				if t.Team.LastTeamIconUpdate > 0 {
					room_info.Picture = SMediaObject{&LazyBlobMediaObject{
						ObjectFilename: fmt.Sprintf("%s-%d",
							t.Team.Name,
							t.Team.LastTeamIconUpdate),
						GetFn: func(o *LazyBlobMediaObject) error {
							team_img, resp := mm.conn.Client.GetTeamIcon(t.Id, "")
							if resp.Error != nil {
								log.Warnf("Could not get team image: %s", resp.Error.Error())
								return resp.Error
							}
							o.ObjectData = team_img
							o.ObjectMimetype = http.DetectContentType(team_img)
							return nil
						},
					}}
				}
				break
			}
		}
		mm.handler.RoomInfoUpdated(id, UserID(""), room_info)

		// Update member list
		// TODO (when this will be slow, i.e. hundreds of members): do only a diff
		members, resp := mm.conn.Client.GetChannelMembers(ch.Id, 0, mm.initial_members, "")
		if resp.Error == nil {
			for _, mem := range *members {
				if mem.UserId == mm.conn.User.Id {
					continue
				}
				user := mm.conn.GetUser(mem.UserId)
				if user != nil {
					mm.ensureJoined(user, id)
					mm.updateUserInfo(user)
				} else {
					log.Warnf("Could not find joined user: %s", mem.UserId)
				}
			}
		} else {
			log.Warnf("Could not get channel members: %s", resp.Error.Error())
		}
	}
}

func (mm *Mattermost) backlogChannel(ch *model.Channel) {
	// Read backlog
	last_seen_post := mm.handler.CacheGet(fmt.Sprintf("last_seen_%s", ch.Id))
	if last_seen_post != "" {
		const NUM_PER_PAGE = 100
		page := 0
		backlogs := []*model.PostList{}
		for {
			backlog, resp := mm.conn.Client.GetPostsAfter(ch.Id, last_seen_post, page, NUM_PER_PAGE, "")
			if resp.Error == nil {
				backlogs = append(backlogs, backlog)
				if len(backlog.Order) == NUM_PER_PAGE {
					page += 1
				} else {
					break
				}
			} else {
				break
			}
		}
		for i := 0; i < len(backlogs); i++ {
			mm.processBacklog(ch, backlogs[i])
		}
	} else {
		backlog, resp := mm.conn.Client.GetPostsForChannel(ch.Id, 0, mm.initial_backlog, "")
		if resp.Error == nil {
			mm.processBacklog(ch, backlog)
		} else {
			log.Warnf("Could not get channel backlog: %s", resp.Error)
		}
	}
}

func (mm *Mattermost) processBacklog(ch *model.Channel, backlog *model.PostList) {
	for i := 0; i < len(backlog.Order); i++ {
		post_id := backlog.Order[len(backlog.Order)-i-1]
		post := backlog.Posts[post_id]
		post_time := time.Unix(post.CreateAt/1000, 0)
		post.Message = fmt.Sprintf("[%s] %s",
			post_time.Format("2006-01-02 15:04 MST"), post.Message)
		mm.handlePost(ch.Name, post, true)
	}
}

func (mm *Mattermost) handleLoop(msgCh chan *matterclient.Message, quitCh chan struct{}) {
	for {
		select {
		case <-quitCh:
			break
		case msg := <-msgCh:
			log.Tracef("Mattermost: %#v\n", msg)
			log.Tracef("Mattermost raw: %#v\n", msg.Raw)
			err := mm.handlePosted(msg.Raw)
			if err != nil {
				log.Warnf("Mattermost error: %s", err)
			}
		}
	}
}

func (mm *Mattermost) updateUserInfo(user *model.User) {
	userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server))
	userDisp := user.GetDisplayName(model.SHOW_NICKNAME_FULLNAME)

	mm.caches.Lock()
	defer mm.caches.Unlock()

	if lastdn, ok := mm.caches.displayname[userId]; !ok || lastdn != userDisp {
		ui := &UserInfo{
			DisplayName: userDisp,
		}
		if user.LastPictureUpdate > 0 {
			ui.Avatar = SMediaObject{&LazyBlobMediaObject{
				ObjectFilename: fmt.Sprintf("%s-%d",
					user.Username,
					user.LastPictureUpdate),
				GetFn: func(o *LazyBlobMediaObject) error {
					img, resp := mm.conn.Client.GetProfileImage(user.Id, "")
					if resp.Error != nil {
						log.Warnf("Could not get profile picture: %s", resp.Error.Error())
						return resp.Error
					}
					o.ObjectData = img
					o.ObjectMimetype = http.DetectContentType(img)
					return nil
				},
			}}
		}
		mm.handler.UserInfoUpdated(userId, ui)
		mm.caches.displayname[userId] = userDisp
	}
}

func (mm *Mattermost) ensureJoined(user *model.User, roomId RoomID) {
	userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server))
	cache_key := fmt.Sprintf("%s / %s", userId, roomId)

	mm.caches.Lock()
	defer mm.caches.Unlock()

	if _, ok := mm.caches.sentjoined[cache_key]; !ok {
		mm.handler.Event(&Event{
			Author: userId,
			Room:   roomId,
			Type:   EVENT_JOIN,
		})
		mm.caches.sentjoined[cache_key] = true
	}
}

func (mm *Mattermost) handlePosted(msg *model.WebSocketEvent) error {
	channel_name, ok := msg.Data["channel_name"].(string)
	if !ok {
		return nil
	}
	post_str := msg.Data["post"].(string)
	var post model.Post
	err := json.Unmarshal([]byte(post_str), &post)
	if err != nil {
		return err
	}

	return mm.handlePost(channel_name, &post, false)
}

func (mm *Mattermost) handlePost(channel_name string, post *model.Post, only_messages bool) error {
	// Skip self messages
	if post.UserId == mm.conn.User.Id {
		return nil
	}

	// Find sending user
	user := mm.conn.GetUser(post.UserId)
	if user == nil {
		return fmt.Errorf("Invalid user")
	}
	userId := UserID(fmt.Sprintf("%s@%s", user.Username, mm.server))
	mm.updateUserInfo(user)

	// Build message event
	msg_ev := &Event{
		Id:     post.Id,
		Author: userId,
		Text:   post.Message,
		Type:   EVENT_MESSAGE,
	}
	if post.Type == "me" {
		msg_ev.Type = EVENT_ACTION
	}

	// Handle files
	if post.Metadata != nil && post.Metadata.Files != nil {
		msg_ev.Attachments = []SMediaObject{}
		for _, file := range post.Metadata.Files {
			media_object := &LazyBlobMediaObject{
				ObjectFilename: file.Name,
				ObjectMimetype: file.MimeType,
				GetFn: func(o *LazyBlobMediaObject) error {
					blob, resp := mm.conn.Client.GetFile(file.Id)
					if resp.Error != nil {
						return resp.Error
					}
					o.ObjectData = blob
					return nil
				},
			}
			if file.Width > 0 {
				media_object.ObjectImageSize = &ImageSize{
					Width:  file.Width,
					Height: file.Height,
				}
			}
			msg_ev.Attachments = append(msg_ev.Attachments, SMediaObject{media_object})
		}
	}

	// Dispatch as PM or as room message
	if len(strings.Split(channel_name, "__")) == 2 {
		// Private message, no need to find room id
		if user.Id == mm.conn.User.Id {
			// Skip self sent messages
			return nil
		}

		mm.handler.Event(msg_ev)
		mm.handler.CachePut(fmt.Sprintf("last_seen_%s", post.ChannelId), post.Id)
	} else {
		interested, roomId := mm.reverseRoomId(post.ChannelId)
		if !interested {
			return nil
		}
		if roomId == "" {
			return fmt.Errorf("Invalid channel id")
		}

		mm.ensureJoined(user, roomId)

		if post.Type == "system_header_change" {
			if !only_messages {
				new_header := post.Props["new_header"].(string)
				mm.handler.RoomInfoUpdated(roomId, userId, &RoomInfo{
					Topic: new_header,
				})
			}
		} else if post.Type == "" || post.Type == "me" {
			msg_ev.Room = roomId
			mm.handler.Event(msg_ev)
			mm.handler.CachePut(fmt.Sprintf("last_seen_%s", post.ChannelId), post.Id)
		} else {
			return fmt.Errorf("Unhandled post type: %s", post.Type)
		}
	}
	return nil
}