1 // mmc -- Mattermost client
2 // Copyright (C) 2023 Sergey Matveev <stargrave@stargrave.org>
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU Affero General Public License as
6 // published by the Free Software Foundation, either version 3 of the
9 // This program is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU Affero General Public License for more details.
14 // You should have received a copy of the GNU Affero General Public License
15 // along with this program. If not, see <http://www.gnu.org/licenses/>.
39 "github.com/davecgh/go-spew/spew"
40 "github.com/mattermost/mattermost-server/v6/model"
41 "go.stargrave.org/mmc"
44 const CmdFile = "/FILE "
47 Newwin = flag.String("newwin", "cmd/newwin", "Path to newwin command")
52 func rewriteIfChanged(fn string, data string) {
53 if their, err := os.ReadFile(fn); err != nil ||
54 bytes.Compare([]byte(data), their) != 0 {
55 if err = os.WriteFile(fn, []byte(data), 0o666); err != nil {
61 func mkFifo(pth string) {
62 if _, err := os.Stat(pth); err == nil {
65 if err := syscall.Mkfifo(pth, 0666); err != nil {
71 entrypoint := flag.String("entrypoint", mmc.GetEntrypoint(), "Entrypoint")
72 notifyCmd := flag.String("notify", "cmd/notify", "Path to notification handler")
73 heartbeatCh := flag.String("heartbeat-ch", "town-square", "Channel for heartbeating")
75 log.SetFlags(log.Lshortfile)
76 log.SetOutput(os.Stdout)
77 UmaskCur = syscall.Umask(0)
78 syscall.Umask(UmaskCur)
82 DebugFd, err = os.OpenFile(
83 "debug", os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
90 login, password := mmc.FindInNetrc(*entrypoint)
91 if login == "" || password == "" {
92 log.Fatalln("no credentials found for:", *entrypoint)
94 c := model.NewAPIv4Client("https://" + *entrypoint)
95 c.Login(login, password)
96 me, resp, err := c.GetMe("")
99 spew.Fdump(DebugFd, resp)
104 spew.Fdump(DebugFd, me)
106 log.Println("logged in")
108 time.Sleep(mmc.SleepTime)
109 teams, resp, err := c.GetTeamsForUser(me.Id, "")
112 spew.Fdump(DebugFd, resp)
117 spew.Fdump(DebugFd, teams)
121 var updateQueue []string
122 Chans := make(map[string]*model.Channel)
123 time.Sleep(mmc.SleepTime)
124 page, resp, err := c.GetChannelsForTeamForUser(Team.Id, me.Id, false, "")
127 spew.Fdump(DebugFd, resp)
132 spew.Fdump(DebugFd, page)
134 for _, ch := range page {
139 pth := path.Join("chans", strings.ReplaceAll(ch.Name, ".", "_"))
140 updateQueue = append(updateQueue, pth, ch.Id)
141 os.MkdirAll(pth, 0777)
142 rewriteIfChanged(path.Join(pth, "id"), ch.Id+"\n")
143 rewriteIfChanged(path.Join(pth, "info"), fmt.Sprintf(
149 if _, err := os.Stat(path.Join(pth, mmc.OutRec)); err != nil &&
150 errors.Is(err, fs.ErrNotExist) {
151 if _, err = os.OpenFile(
152 path.Join(pth, mmc.OutRec), os.O_WRONLY|os.O_CREATE, 0o666,
158 usersPth := path.Join(pth, "users")
160 go func(ch *model.Channel) {
162 time.Sleep(mmc.SleepTime)
163 fd, err := os.OpenFile(
164 usersPth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
167 log.Println("OpenFile:", usersPth, err)
171 users, resp, err := c.GetUsersInChannel(ch.Id, n, mmc.PerPage, "")
174 spew.Fdump(DebugFd, resp)
176 log.Println("GetUsersInChannel:", err)
181 spew.Fdump(DebugFd, users)
183 for _, u := range users {
184 fmt.Fprintf(fd, "%s\n", u.Username)
186 if len(users) < mmc.PerPage {
194 pth = path.Join(pth, "in")
196 go func(ch *model.Channel) {
198 fd, err := os.OpenFile(pth, os.O_RDONLY, os.FileMode(0666))
202 data, err := io.ReadAll(fd)
207 if _, err = makePost(c, ch.Id, string(data)); err != nil {
208 log.Println("makePost:", err)
214 Users, err := mmc.GetUsers(c, DebugFd)
219 UsersDC := make(map[string]*model.Channel, len(Users))
220 for _, u := range Users {
221 pth := path.Join("users", strings.ReplaceAll(u.Username, ".", "_"))
222 os.MkdirAll(pth, 0777)
224 path.Join(pth, "name"),
225 fmt.Sprintf("%s %s\n", u.FirstName, u.LastName),
227 rewriteIfChanged(path.Join(pth, "email"), u.Email+"\n")
228 rewriteIfChanged(path.Join(pth, "id"), u.Id+"\n")
229 if _, err := os.Stat(path.Join(pth, mmc.OutRec)); err != nil &&
230 errors.Is(err, fs.ErrNotExist) {
231 if _, err = os.OpenFile(
232 path.Join(pth, mmc.OutRec), os.O_WRONLY|os.O_CREATE, 0o666,
238 if fi, err := os.Stat(path.Join(pth, mmc.OutRec)); err == nil && fi.Size() > 0 {
239 time.Sleep(mmc.SleepTime)
240 dc, resp, err := c.CreateDirectChannel(me.Id, u.Id)
243 spew.Fdump(DebugFd, resp)
245 log.Println("CreateDirectChannel:", err)
249 spew.Fdump(DebugFd, dc)
252 updateQueue = append(updateQueue, pth, dc.Id)
255 statusPth := path.Join(pth, "status")
257 go func(u *model.User) {
259 time.Sleep(mmc.SleepTime)
260 fd, err := os.OpenFile(
261 statusPth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
264 log.Println("OpenFile:", statusPth, err)
267 status, resp, err := c.GetUserStatus(u.Id, "")
270 spew.Fdump(DebugFd, resp)
272 log.Println("GetUserStatus:", err)
277 spew.Fdump(DebugFd, status)
279 fmt.Fprintf(fd, "%s\n", status.Status)
284 pth = path.Join(pth, "in")
286 go func(u *model.User) {
287 var dc *model.Channel
289 fd, err := os.OpenFile(pth, os.O_RDONLY, os.FileMode(0666))
293 data, err := io.ReadAll(fd)
300 dc, resp, err = c.CreateDirectChannel(me.Id, u.Id)
303 spew.Fdump(DebugFd, resp)
305 log.Println("CreateDirectChannel:", err)
310 spew.Fdump(DebugFd, dc)
313 if _, err = makePost(c, dc.Id, string(data)); err != nil {
314 log.Println("makePost:", err)
320 UserStatus := make(map[string]string)
321 var UserStatusM sync.RWMutex
323 pth := path.Join("users", "status")
326 time.Sleep(mmc.SleepTime)
327 fd, err := os.OpenFile(pth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666))
329 log.Println("OpenFile:", pth, err)
332 agg := make(map[string][]string)
334 for name, status := range UserStatus {
335 agg[status] = append(agg[status], name)
337 UserStatusM.RUnlock()
338 statuses := make([]string, 0, len(agg))
339 for status := range agg {
340 sort.Strings(agg[status])
341 statuses = append(statuses, status)
343 sort.Strings(statuses)
344 for _, status := range statuses {
345 fmt.Fprintln(fd, status+":", strings.Join(agg[status], " "))
351 log.Println("syncing", len(updateQueue)/2, "rooms")
352 for len(updateQueue) > 0 {
353 err := updatePosts(c, Users, updateQueue[0], updateQueue[1])
355 log.Println("updatePosts:", err)
357 updateQueue = updateQueue[2:]
359 log.Println("sync done")
360 if *notifyCmd != "" {
361 exec.Command(*notifyCmd, "sync done").Run()
365 os.MkdirAll("file", 0777)
366 pthGet := path.Join("file", "get")
368 pthOut := path.Join("file", "out")
371 time.Sleep(mmc.SleepTime)
372 fd, err := os.OpenFile(pthGet, os.O_RDONLY, os.FileMode(0666))
376 data, err := io.ReadAll(fd)
381 fileId := strings.TrimRight(string(data), " \n")
382 if len(fileId) == 0 {
385 fi, resp, err := c.GetFileInfo(fileId)
387 spew.Fdump(DebugFd, fi, resp)
397 data, resp, err = c.GetFile(fileId)
400 spew.Fdump(DebugFd, resp)
405 fd, err = os.OpenFile(
406 pthOut, os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
412 w := tar.NewWriter(fd)
413 if err = w.WriteHeader(&tar.Header{
414 Format: tar.FormatPAX,
415 Typeflag: tar.TypeReg,
417 Size: int64(len(data)),
419 ModTime: time.Unix(fi.CreateAt/1000, 0),
420 PAXRecords: map[string]string{
422 "MM.MIMEType": fi.MimeType,
429 if _, err = io.Copy(w, bytes.NewReader(data)); err != nil {
442 needsShutdown := make(chan os.Signal)
443 wc, err := model.NewWebSocketClient4("wss://"+*entrypoint, c.AuthToken)
450 t := time.NewTicker(time.Minute)
454 if wc.ListenError != nil {
455 log.Println("ListenError:", wc.ListenError)
456 needsShutdown <- syscall.SIGTERM
460 if *heartbeatCh != "" {
461 if _, _, err = c.ViewChannel(
463 &model.ChannelView{ChannelId: Chans[*heartbeatCh].Id},
465 log.Println("ChannelView:", err)
468 case <-wc.PingTimeoutChannel:
469 log.Println("PING timeout")
470 needsShutdown <- syscall.SIGTERM
472 case e := <-wc.EventChannel:
473 if e == nil || !e.IsValid() {
477 spew.Fdump(DebugFd, e)
481 if userId, ok := data["user_id"]; ok && userId.(string) != "" {
482 user = Users[userId.(string)]
484 log.Println("unknown user for event:", e)
488 switch eventType := e.EventType(); eventType {
489 case model.WebsocketEventTyping:
490 if *notifyCmd != "" {
491 exec.Command(*notifyCmd, "typing: "+user.Username).Run()
493 case model.WebsocketEventPostEdited,
494 model.WebsocketEventPostDeleted,
495 model.WebsocketEventPosted:
496 chName, ok := data["channel_name"].(string)
501 if err = json.NewDecoder(
502 strings.NewReader(data["post"].(string)),
503 ).Decode(&post); err != nil {
507 switch model.ChannelType(data["channel_type"].(string)) {
508 case model.ChannelTypeDirect:
509 userId := strings.TrimPrefix(chName, me.Id+"__")
510 userId = strings.TrimSuffix(userId, "__"+me.Id)
511 user := Users[userId]
513 log.Println("unknown user:", post)
516 recipient = path.Join("users", user.Username)
517 case model.ChannelTypeOpen:
519 case model.ChannelTypePrivate:
521 case model.ChannelTypeGroup:
522 recipient = path.Join("chans", chName)
525 recipient, Users, []mmc.Post{{P: &post, E: eventType}},
529 case model.WebsocketEventStatusChange:
530 status := data["status"].(string)
532 UserStatus[user.Username] = status
534 if *notifyCmd != "" {
535 exec.Command(*notifyCmd, fmt.Sprintf(
536 "status: %s -> %s", user.Username, status,
539 case model.WebsocketEventHello:
540 case model.WebsocketEventChannelViewed:
544 case resp := <-wc.ResponseChannel:
545 if resp == nil || !resp.IsValid() {
549 spew.Fdump(DebugFd, resp)
551 statuses := make(map[string]string)
552 for userId, status := range resp.Data {
553 status, ok := status.(string)
557 user := Users[userId]
561 statuses[user.Username] = status
563 if len(statuses) > 0 {
565 for u := range UserStatus {
566 delete(UserStatus, u)
568 for u, status := range statuses {
569 UserStatus[u] = status
577 signal.Notify(needsShutdown, syscall.SIGTERM, syscall.SIGINT)
580 log.Println("finished")