// mmc -- Mattermost client // Copyright (C) 2023 Sergey Matveev // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package main import ( "archive/tar" "bytes" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "log" "os" "os/exec" "os/signal" "path" "sort" "strings" "syscall" "time" "github.com/davecgh/go-spew/spew" "github.com/mattermost/mattermost-server/v6/model" "go.stargrave.org/mmc" ) const CmdFile = "/FILE " var ( Newwin = flag.String("newwin", "cmd/newwin", "Path to newwin command") DebugFifo = flag.String("debug", "", "Path to debug FIFO to be created") DebugFd *os.File UmaskCur int ) func rewriteIfChanged(fn string, data string) { if their, err := os.ReadFile(fn); err != nil || bytes.Compare([]byte(data), their) != 0 { if err = os.WriteFile(fn, []byte(data), 0o666); err != nil { log.Fatalln(err) } } } func main() { entrypoint := flag.String("entrypoint", mmc.GetEntrypoint(), "Entrypoint") notifyCmd := flag.String("notify", "cmd/notify", "Path to notification handler") heartbeatCh := flag.String("heartbeat-ch", "town-square", "Channel for heartbeating") userStatusFifo := flag.String("user-status", "", "Path to FIFO for user statuses") flag.Parse() log.SetFlags(log.Lshortfile) log.SetOutput(os.Stdout) UmaskCur = syscall.Umask(0) syscall.Umask(UmaskCur) var err error if *DebugFifo != "" { DebugFd, err = os.OpenFile( *DebugFifo, os.O_WRONLY|os.O_APPEND, os.FileMode(0666), ) if err != nil { log.Fatalln(err) } defer DebugFd.Close() } login, password := mmc.FindInNetrc(*entrypoint) if login == "" || password == "" { log.Fatalln("no credentials found for:", *entrypoint) } c := model.NewAPIv4Client("https://" + *entrypoint) c.Login(login, password) me, resp, err := c.GetMe("") if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Fatalln(err) } if DebugFd != nil { spew.Fdump(DebugFd, me) } log.Println("logged in") time.Sleep(mmc.SleepTime) teams, resp, err := c.GetTeamsForUser(me.Id, "") if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Fatalln(err) } if DebugFd != nil { spew.Fdump(DebugFd, teams) } Team := teams[0] var updateQueue []string LastSent := time.Now() Users, err := mmc.GetUsers(c, DebugFd) if err != nil { log.Fatalln(err) } UsersDC := make(map[string]*model.Channel, len(Users)) userIds := make([]string, 0, len(Users)) for _, u := range Users { userIds = append(userIds, u.Id) pth := path.Join("users", strings.ReplaceAll(u.Username, ".", "_")) os.MkdirAll(pth, 0777) rewriteIfChanged( path.Join(pth, "name"), fmt.Sprintf("%s %s\n", u.FirstName, u.LastName), ) rewriteIfChanged(path.Join(pth, "email"), u.Email+"\n") rewriteIfChanged(path.Join(pth, "id"), u.Id+"\n") if _, err := os.Stat(path.Join(pth, mmc.OutRec)); err != nil && errors.Is(err, fs.ErrNotExist) { if _, err = os.OpenFile( path.Join(pth, mmc.OutRec), os.O_WRONLY|os.O_CREATE, 0o666, ); err != nil { log.Fatalln(err) } } if fi, err := os.Stat(path.Join(pth, mmc.OutRec)); err == nil && fi.Size() > 0 { time.Sleep(mmc.SleepTime) dc, resp, err := c.CreateDirectChannel(me.Id, u.Id) if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Println("CreateDirectChannel:", err) continue } if DebugFd != nil { spew.Fdump(DebugFd, dc) } UsersDC[u.Id] = dc updateQueue = append(updateQueue, pth, dc.Id) } statusPth := path.Join(pth, "status") os.Remove(statusPth) if err := syscall.Mkfifo(statusPth, 0666); err != nil { log.Fatalln(err) } go func(u *model.User) { for { time.Sleep(mmc.SleepTime) fd, err := os.OpenFile( statusPth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666), ) if err != nil { log.Println("OpenFile:", statusPth, err) continue } status, resp, err := c.GetUserStatus(u.Id, "") if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Println("GetUserStatus:", err) fd.Close() continue } if DebugFd != nil { spew.Fdump(DebugFd, status) } fmt.Fprintf(fd, "%s\n", status.Status) fd.Close() } }(u) pth = path.Join(pth, "in") os.Remove(pth) if err := syscall.Mkfifo(pth, 0666); err != nil { log.Fatalln(err) } go func(u *model.User) { var dc *model.Channel for { fd, err := os.OpenFile(pth, os.O_RDONLY, os.FileMode(0666)) if err != nil { continue } data, err := io.ReadAll(fd) fd.Close() if err != nil { continue } dc = UsersDC[u.Id] if dc == nil { dc, resp, err = c.CreateDirectChannel(me.Id, u.Id) if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Println("CreateDirectChannel:", err) continue } UsersDC[u.Id] = dc if DebugFd != nil { spew.Fdump(DebugFd, dc) } } if _, err = makePost(c, dc.Id, string(data)); err != nil { log.Println("makePost:", err) } LastSent = time.Now() } }(u) } UserStatus := make(map[string]string) updateUserStatus := func() { statuses, resp, err := c.GetUsersStatusesByIds(userIds) if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Fatalln(err) } if DebugFd != nil { spew.Fdump(DebugFd, teams) } for _, s := range statuses { UserStatus[Users[s.UserId].Username] = s.Status } } if *userStatusFifo != "" { updateUserStatus() go func() { for { time.Sleep(mmc.SleepTime) fd, err := os.OpenFile( *userStatusFifo, os.O_WRONLY|os.O_APPEND, os.FileMode(0666), ) if err != nil { log.Println("OpenFile:", *userStatusFifo, err) continue } statuses := make(map[string][]string) for name, status := range UserStatus { statuses[status] = append(statuses[status], name) } for status := range statuses { if status == "offline" { continue } sort.Strings(statuses[status]) fmt.Fprintln(fd, status+":", strings.Join(statuses[status], " ")) } fd.Close() } }() } Chans := make(map[string]*model.Channel) time.Sleep(mmc.SleepTime) page, resp, err := c.GetChannelsForTeamForUser(Team.Id, me.Id, false, "") if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Fatalln(err) } if DebugFd != nil { spew.Fdump(DebugFd, page) } for _, ch := range page { if ch.Type == "D" { continue } Chans[ch.Name] = ch pth := path.Join("chans", strings.ReplaceAll(ch.Name, ".", "_")) updateQueue = append(updateQueue, pth, ch.Id) os.MkdirAll(pth, 0777) rewriteIfChanged(path.Join(pth, "id"), ch.Id+"\n") rewriteIfChanged(path.Join(pth, "info"), fmt.Sprintf( "%s\n%s\n%s\n", ch.DisplayName, ch.Header, ch.Purpose, )) if _, err := os.Stat(path.Join(pth, mmc.OutRec)); err != nil && errors.Is(err, fs.ErrNotExist) { if _, err = os.OpenFile( path.Join(pth, mmc.OutRec), os.O_WRONLY|os.O_CREATE, 0o666, ); err != nil { log.Fatalln(err) } } usersPth := path.Join(pth, "users") os.Remove(usersPth) if err := syscall.Mkfifo(usersPth, 0666); err != nil { log.Fatalln(err) } go func(ch *model.Channel) { for { time.Sleep(mmc.SleepTime) fd, err := os.OpenFile( usersPth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666), ) if err != nil { log.Println("OpenFile:", usersPth, err) continue } for n := 0; ; n++ { users, resp, err := c.GetUsersInChannel(ch.Id, n, mmc.PerPage, "") if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Println("GetUsersInChannel:", err) fd.Close() continue } if DebugFd != nil { spew.Fdump(DebugFd, users) } for _, u := range users { fmt.Fprintf(fd, "%s\n", u.Username) } if len(users) < mmc.PerPage { break } } fd.Close() } }(ch) pth = path.Join(pth, "in") os.Remove(pth) if err := syscall.Mkfifo(pth, 0666); err != nil { log.Fatalln(err) } go func(ch *model.Channel) { for { fd, err := os.OpenFile(pth, os.O_RDONLY, os.FileMode(0666)) if err != nil { continue } data, err := io.ReadAll(fd) fd.Close() if err != nil { continue } if _, err = makePost(c, ch.Id, string(data)); err != nil { log.Println("makePost:", err) } LastSent = time.Now() } }(ch) } log.Println("syncing", len(updateQueue)/2, "rooms") for len(updateQueue) > 0 { err := updatePosts(c, Users, updateQueue[0], updateQueue[1]) if err != nil { log.Println("updatePosts:", err) } updateQueue = updateQueue[2:] } log.Println("sync done") if *notifyCmd != "" { exec.Command(*notifyCmd, "sync done").Run() } go func() { os.MkdirAll("file", 0777) pthGet := path.Join("file", "get") os.Remove(pthGet) if err := syscall.Mkfifo(pthGet, 0666); err != nil { log.Fatalln(err) } pthOut := path.Join("file", "out") os.Remove(pthOut) if err := syscall.Mkfifo(pthOut, 0666); err != nil { log.Fatalln(err) } for { time.Sleep(mmc.SleepTime) fd, err := os.OpenFile(pthGet, os.O_RDONLY, os.FileMode(0666)) if err != nil { continue } data, err := io.ReadAll(fd) fd.Close() if err != nil { continue } fileId := strings.TrimRight(string(data), " \n") if len(fileId) == 0 { continue } fi, resp, err := c.GetFileInfo(fileId) if DebugFd != nil { spew.Fdump(DebugFd, fi, resp) } if err != nil { log.Println(err) continue } if fi == nil { fmt.Println(resp) continue } data, resp, err = c.GetFile(fileId) if err != nil { if DebugFd != nil { spew.Fdump(DebugFd, resp) } log.Println(err) continue } fd, err = os.OpenFile( pthOut, os.O_WRONLY|os.O_APPEND, os.FileMode(0666), ) if err != nil { log.Println(err) continue } w := tar.NewWriter(fd) if err = w.WriteHeader(&tar.Header{ Format: tar.FormatPAX, Typeflag: tar.TypeReg, Name: fi.Name, Size: int64(len(data)), Mode: 0o666, ModTime: time.Unix(fi.CreateAt/1000, 0), PAXRecords: map[string]string{ "MM.FileId": fi.Id, "MM.MIMEType": fi.MimeType, }, }); err != nil { log.Println(err) fd.Close() continue } if _, err = io.Copy(w, bytes.NewReader(data)); err != nil { log.Println(err) fd.Close() continue } err = w.Close() fd.Close() if err != nil { log.Println(err) } } }() needsShutdown := make(chan os.Signal) wc, err := model.NewWebSocketClient4("wss://"+*entrypoint, c.AuthToken) if err != nil { log.Fatalln(err) } go func() { wc.Listen() t := time.NewTicker(time.Minute) for { select { case <-t.C: updateUserStatus() if time.Now().Before(LastSent.Add(time.Minute)) { continue } wc.SendMessage("ping", nil) if *heartbeatCh != "" { if _, _, err = c.ViewChannel( me.Id, &model.ChannelView{ChannelId: Chans[*heartbeatCh].Id}, ); err != nil { log.Println("ChannelView:", err) } } case <-wc.PingTimeoutChannel: log.Println("PING timeout") needsShutdown <- syscall.SIGTERM case e := <-wc.EventChannel: if e == nil || !e.IsValid() { continue } data := e.GetData() if DebugFd != nil { spew.Fdump(DebugFd, e.EventType(), data) } var user *model.User if userId, ok := data["user_id"]; ok && userId.(string) != "" { user = Users[userId.(string)] if user == nil { log.Println("unknown user for event:", e) continue } } switch eventType := e.EventType(); eventType { case model.WebsocketEventTyping: if *notifyCmd != "" { exec.Command(*notifyCmd, "typing: "+user.Username).Run() } case model.WebsocketEventPostEdited, model.WebsocketEventPostDeleted, model.WebsocketEventPosted: chName := data["channel_name"].(string) var post model.Post if err = json.NewDecoder( strings.NewReader(data["post"].(string)), ).Decode(&post); err != nil { log.Fatalln(err) } var recipient string switch model.ChannelType(data["channel_type"].(string)) { case model.ChannelTypeDirect: userId := strings.TrimPrefix(chName, me.Id+"__") userId = strings.TrimSuffix(userId, "__"+me.Id) user := Users[userId] if user == nil { log.Println("unknown user:", post) continue } recipient = path.Join("users", user.Username) case model.ChannelTypeOpen: fallthrough case model.ChannelTypePrivate: fallthrough case model.ChannelTypeGroup: recipient = path.Join("chans", chName) } if err = writePosts( recipient, Users, []mmc.Post{{P: &post, E: eventType}}, ); err != nil { log.Fatalln(err) } case model.WebsocketEventStatusChange: status := data["status"].(string) UserStatus[user.Username] = status if *notifyCmd != "" { exec.Command(*notifyCmd, fmt.Sprintf( "status: %s -> %s", user.Username, status, )).Run() } case model.WebsocketEventHello: case model.WebsocketEventChannelViewed: default: log.Println(e) } case resp := <-wc.ResponseChannel: if resp == nil || !resp.IsValid() { continue } if DebugFd != nil { spew.Fdump(DebugFd, resp) } if text, ok := resp.Data["text"].(string); ok && text == "pong" { LastSent = time.Now() } } } }() signal.Notify(needsShutdown, syscall.SIGTERM, syscall.SIGINT) <-needsShutdown c.Logout() log.Println("finished") }