1 // mmc -- Mattermost client
2 // Copyright (C) 2023-2024 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/>.
42 "github.com/davecgh/go-spew/spew"
43 "github.com/gorilla/websocket"
44 "github.com/mattermost/mattermost-server/v6/model"
45 "go.cypherpunks.su/netrc/v2"
46 "go.stargrave.org/mmc/internal"
50 Newwin = flag.String("newwin", "cmd/newwin", "Path to newwin command")
55 func rewriteIfChanged(fn string, data string) {
56 if their, err := os.ReadFile(fn); err != nil ||
57 !bytes.Equal([]byte(data), their) {
58 if err = os.WriteFile(fn, []byte(data), 0o666); err != nil {
64 func mkFifo(pth string) {
65 if _, err := os.Stat(pth); err == nil {
68 if err := syscall.Mkfifo(pth, 0666); err != nil {
74 entrypoint := flag.String("entrypoint", mmc.GetEntrypoint(), "Entrypoint")
75 spkiHash := flag.String("spki", mmc.GetSPKIHash(), "Entrypoint's SPKI hash")
76 notifyCmd := flag.String("notify", "cmd/notify", "Path to notification handler")
77 heartbeatCh := flag.String("heartbeat-ch", "town-square", "Channel for heartbeating")
79 log.SetFlags(log.Lshortfile)
80 log.SetOutput(os.Stdout)
81 UmaskCur = syscall.Umask(0)
82 syscall.Umask(UmaskCur)
86 DebugFd, err = os.OpenFile(
87 "debug", os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
94 entrypointURL, err := url.Parse(*entrypoint)
98 login, password := netrc.Find(entrypointURL.Hostname())
99 if login == "" || password == "" {
100 log.Fatalln("no credentials found for:", entrypointURL.Hostname())
102 c := model.NewAPIv4Client(*entrypoint)
103 c.HTTPClient.Transport = &http.Transport{
104 Proxy: http.ProxyFromEnvironment,
105 ForceAttemptHTTP2: true,
107 IdleConnTimeout: 90 * time.Second,
108 TLSHandshakeTimeout: 10 * time.Second,
109 ExpectContinueTimeout: 1 * time.Second,
110 TLSClientConfig: &tls.Config{
111 ServerName: entrypointURL.Hostname(),
112 InsecureSkipVerify: true,
113 VerifyPeerCertificate: mmc.NewVerifyPeerCertificate(*spkiHash),
117 c.Login(login, password)
118 me, resp, err := c.GetMe("")
121 spew.Fdump(DebugFd, resp)
126 spew.Fdump(DebugFd, me)
128 log.Println("logged in")
130 time.Sleep(mmc.SleepTime)
131 teams, resp, err := c.GetTeamsForUser(me.Id, "")
134 spew.Fdump(DebugFd, resp)
139 spew.Fdump(DebugFd, teams)
143 var updateQueue []string
144 Chans := make(map[string]*model.Channel)
145 time.Sleep(mmc.SleepTime)
146 page, resp, err := c.GetChannelsForTeamForUser(Team.Id, me.Id, false, "")
149 spew.Fdump(DebugFd, resp)
154 spew.Fdump(DebugFd, page)
156 for _, ch := range page {
161 pth := path.Join("chans", strings.ReplaceAll(ch.Name, ".", "_"))
162 updateQueue = append(updateQueue, pth, ch.Id)
163 os.MkdirAll(pth, 0777)
164 rewriteIfChanged(path.Join(pth, "id"), ch.Id+"\n")
165 rewriteIfChanged(path.Join(pth, "info"), fmt.Sprintf(
171 if _, err := os.Stat(path.Join(pth, mmc.OutRec)); err != nil &&
172 errors.Is(err, fs.ErrNotExist) {
173 if _, err = os.OpenFile(
174 path.Join(pth, mmc.OutRec), os.O_WRONLY|os.O_CREATE, 0o666,
180 usersPth := path.Join(pth, "users")
182 go func(ch *model.Channel) {
184 time.Sleep(mmc.SleepTime)
185 fd, err := os.OpenFile(
186 usersPth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
189 log.Println("OpenFile:", usersPth, err)
193 users, resp, err := c.GetUsersInChannel(ch.Id, n, mmc.PerPage, "")
196 spew.Fdump(DebugFd, resp)
198 log.Println("GetUsersInChannel:", err)
203 spew.Fdump(DebugFd, users)
205 for _, u := range users {
206 fmt.Fprintf(fd, "%s\n", u.Username)
208 if len(users) < mmc.PerPage {
216 pth = path.Join(pth, "in")
218 go func(ch *model.Channel) {
220 fd, err := os.OpenFile(pth, os.O_RDONLY, os.FileMode(0666))
224 data, err := io.ReadAll(fd)
229 if _, err = makePost(c, ch.Id, string(data)); err != nil {
230 log.Println("makePost:", err)
236 Users, err := mmc.GetUsers(c, DebugFd)
241 UsersDC := make(map[string]*model.Channel, len(Users))
242 for _, u := range Users {
243 pth := path.Join("users", strings.ReplaceAll(u.Username, ".", "_"))
244 os.MkdirAll(pth, 0777)
246 path.Join(pth, "name"),
247 fmt.Sprintf("%s %s\n", u.FirstName, u.LastName),
249 rewriteIfChanged(path.Join(pth, "email"), u.Email+"\n")
250 rewriteIfChanged(path.Join(pth, "id"), u.Id+"\n")
251 if _, err := os.Stat(path.Join(pth, mmc.OutRec)); err != nil &&
252 errors.Is(err, fs.ErrNotExist) {
253 if _, err = os.OpenFile(
254 path.Join(pth, mmc.OutRec), os.O_WRONLY|os.O_CREATE, 0o666,
260 if fi, err := os.Stat(path.Join(pth, mmc.OutRec)); err == nil && fi.Size() > 0 {
261 time.Sleep(mmc.SleepTime)
262 dc, resp, err := c.CreateDirectChannel(me.Id, u.Id)
265 spew.Fdump(DebugFd, resp)
267 log.Println("CreateDirectChannel:", err)
271 spew.Fdump(DebugFd, dc)
274 updateQueue = append(updateQueue, pth, dc.Id)
277 statusPth := path.Join(pth, "status")
279 go func(u *model.User) {
281 time.Sleep(mmc.SleepTime)
282 fd, err := os.OpenFile(
283 statusPth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
286 log.Println("OpenFile:", statusPth, err)
289 status, resp, err := c.GetUserStatus(u.Id, "")
292 spew.Fdump(DebugFd, resp)
294 log.Println("GetUserStatus:", err)
299 spew.Fdump(DebugFd, status)
301 fmt.Fprintf(fd, "%s\n", status.Status)
306 pth = path.Join(pth, "in")
308 go func(u *model.User) {
309 var dc *model.Channel
311 fd, err := os.OpenFile(pth, os.O_RDONLY, os.FileMode(0666))
315 data, err := io.ReadAll(fd)
322 dc, resp, err = c.CreateDirectChannel(me.Id, u.Id)
325 spew.Fdump(DebugFd, resp)
327 log.Println("CreateDirectChannel:", err)
332 spew.Fdump(DebugFd, dc)
335 if _, err = makePost(c, dc.Id, string(data)); err != nil {
336 log.Println("makePost:", err)
342 UserStatus := make(map[string]string)
343 var UserStatusM sync.RWMutex
345 pth := path.Join("users", "status")
348 time.Sleep(mmc.SleepTime)
349 fd, err := os.OpenFile(pth, os.O_WRONLY|os.O_APPEND, os.FileMode(0666))
351 log.Println("OpenFile:", pth, err)
354 agg := make(map[string][]string)
356 for name, status := range UserStatus {
357 agg[status] = append(agg[status], name)
359 UserStatusM.RUnlock()
360 statuses := make([]string, 0, len(agg))
361 for status := range agg {
362 sort.Strings(agg[status])
363 statuses = append(statuses, status)
365 sort.Strings(statuses)
366 for _, status := range statuses {
367 fmt.Fprintln(fd, status+":", strings.Join(agg[status], " "))
373 log.Println("syncing", len(updateQueue)/2, "rooms")
374 for len(updateQueue) > 0 {
375 err := updatePosts(c, Users, updateQueue[0], updateQueue[1])
377 log.Println("updatePosts:", err)
379 updateQueue = updateQueue[2:]
381 log.Println("sync done")
382 if *notifyCmd != "" {
383 exec.Command(*notifyCmd, "sync done").Run()
387 os.MkdirAll("file", 0777)
388 pthGet := path.Join("file", "get")
390 pthOut := path.Join("file", "out")
393 time.Sleep(mmc.SleepTime)
394 fd, err := os.OpenFile(pthGet, os.O_RDONLY, os.FileMode(0666))
398 data, err := io.ReadAll(fd)
403 fileId := strings.TrimRight(string(data), " \n")
404 if len(fileId) == 0 {
407 fi, resp, err := c.GetFileInfo(fileId)
409 spew.Fdump(DebugFd, fi, resp)
419 data, resp, err = c.GetFile(fileId)
422 spew.Fdump(DebugFd, resp)
427 fd, err = os.OpenFile(
428 pthOut, os.O_WRONLY|os.O_APPEND, os.FileMode(0666),
434 w := tar.NewWriter(fd)
435 if err = w.WriteHeader(&tar.Header{
436 Format: tar.FormatPAX,
437 Typeflag: tar.TypeReg,
439 Size: int64(len(data)),
441 ModTime: time.Unix(fi.CreateAt/1000, 0),
442 PAXRecords: map[string]string{
444 "MM.MIMEType": fi.MimeType,
451 if _, err = io.Copy(w, bytes.NewReader(data)); err != nil {
464 needsShutdown := make(chan os.Signal)
465 switch entrypointURL.Scheme {
467 entrypointURL.Scheme = "ws"
469 entrypointURL.Scheme = "wss"
471 log.Println("unhandled scheme:", entrypointURL.Scheme)
473 wc, err := model.NewWebSocketClient4WithDialer(
475 TLSClientConfig: &tls.Config{
476 ServerName: entrypointURL.Hostname(),
477 InsecureSkipVerify: true,
478 VerifyPeerCertificate: mmc.NewVerifyPeerCertificate(*spkiHash),
480 }, entrypointURL.String(), c.AuthToken,
488 t := time.NewTicker(time.Minute)
492 if wc.ListenError != nil {
493 log.Println("ListenError:", wc.ListenError)
494 needsShutdown <- syscall.SIGTERM
498 if *heartbeatCh != "" {
499 if _, _, err = c.ViewChannel(
501 &model.ChannelView{ChannelId: Chans[*heartbeatCh].Id},
503 log.Println("ChannelView:", err)
506 case <-wc.PingTimeoutChannel:
507 log.Println("PING timeout")
508 needsShutdown <- syscall.SIGTERM
510 case e := <-wc.EventChannel:
511 if e == nil || !e.IsValid() {
515 spew.Fdump(DebugFd, e)
519 if userId, ok := data["user_id"]; ok && userId.(string) != "" {
520 user = Users[userId.(string)]
522 log.Println("unknown user for event:", e)
526 switch eventType := e.EventType(); eventType {
527 case model.WebsocketEventTyping:
528 if *notifyCmd != "" {
529 exec.Command(*notifyCmd, "typing: "+user.Username).Run()
531 case model.WebsocketEventPostEdited,
532 model.WebsocketEventPostDeleted,
533 model.WebsocketEventPosted:
534 chName, ok := data["channel_name"].(string)
539 if err = json.NewDecoder(
540 strings.NewReader(data["post"].(string)),
541 ).Decode(&post); err != nil {
545 switch model.ChannelType(data["channel_type"].(string)) {
546 case model.ChannelTypeDirect:
547 userId := strings.TrimPrefix(chName, me.Id+"__")
548 userId = strings.TrimSuffix(userId, "__"+me.Id)
549 user := Users[userId]
551 log.Println("unknown user:", userId)
554 recipient = path.Join("users", user.Username)
555 case model.ChannelTypeOpen:
557 case model.ChannelTypePrivate:
559 case model.ChannelTypeGroup:
560 recipient = path.Join("chans", chName)
563 recipient, Users, []mmc.Post{{P: &post, E: eventType}},
567 case model.WebsocketEventStatusChange:
568 status := data["status"].(string)
570 UserStatus[user.Username] = status
572 if *notifyCmd != "" {
573 exec.Command(*notifyCmd, fmt.Sprintf(
574 "status: %s -> %s", user.Username, status,
577 case model.WebsocketEventHello:
578 case model.WebsocketEventChannelViewed:
582 case resp := <-wc.ResponseChannel:
583 if resp == nil || !resp.IsValid() {
587 spew.Fdump(DebugFd, resp)
589 statuses := make(map[string]string)
590 for userId, status := range resp.Data {
591 status, ok := status.(string)
595 user := Users[userId]
599 statuses[user.Username] = status
601 if len(statuses) > 0 {
603 for u := range UserStatus {
604 delete(UserStatus, u)
606 for u, status := range statuses {
607 UserStatus[u] = status
615 signal.Notify(needsShutdown, syscall.SIGTERM, syscall.SIGINT)
618 log.Println("finished")