diff options
author | Tulir Asokan <tulir@maunium.net> | 2018-03-18 21:24:03 +0200 |
---|---|---|
committer | Tulir Asokan <tulir@maunium.net> | 2018-03-18 21:24:03 +0200 |
commit | 72945c9a284b6858594f1e8a43743c397e90c380 (patch) | |
tree | c4dc096f97c546dcc546d50385e2909e2e10b82d /matrix | |
parent | 0509b195625c959a7b5556e3baae4f869c4d62f6 (diff) |
Organize files
Diffstat (limited to 'matrix')
-rw-r--r-- | matrix/matrix.go | 284 | ||||
-rw-r--r-- | matrix/room/member.go | 51 | ||||
-rw-r--r-- | matrix/room/room.go | 145 | ||||
-rw-r--r-- | matrix/sync.go | 126 |
4 files changed, 606 insertions, 0 deletions
diff --git a/matrix/matrix.go b/matrix/matrix.go new file mode 100644 index 0000000..7652188 --- /dev/null +++ b/matrix/matrix.go @@ -0,0 +1,284 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package matrix + +import ( + "fmt" + "strings" + "time" + + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/interface" + rooms "maunium.net/go/gomuks/matrix/room" + "maunium.net/go/gomuks/ui/debug" + "maunium.net/go/gomuks/ui/widget" +) + +type Container struct { + client *gomatrix.Client + gmx ifc.Gomuks + ui ifc.GomuksUI + config *config.Config + running bool + stop chan bool + + typing int64 +} + +func NewMatrixContainer(gmx ifc.Gomuks) *Container { + c := &Container{ + config: gmx.Config(), + ui: gmx.UI(), + gmx: gmx, + } + + return c +} + +func (c *Container) InitClient() error { + if len(c.config.HS) == 0 { + return fmt.Errorf("no homeserver in config") + } + + if c.client != nil { + c.Stop() + c.client = nil + } + + var mxid, accessToken string + if c.config.Session != nil { + accessToken = c.config.Session.AccessToken + mxid = c.config.MXID + } + + var err error + c.client, err = gomatrix.NewClient(c.config.HS, mxid, accessToken) + if err != nil { + return err + } + + c.stop = make(chan bool, 1) + + if c.config.Session != nil { + go c.Start() + } + return nil +} + +func (c *Container) Initialized() bool { + return c.client != nil +} + +func (c *Container) Login(user, password string) error { + resp, err := c.client.Login(&gomatrix.ReqLogin{ + Type: "m.login.password", + User: user, + Password: password, + }) + if err != nil { + return err + } + c.client.SetCredentials(resp.UserID, resp.AccessToken) + c.config.MXID = resp.UserID + c.config.Save() + + c.config.Session = c.config.NewSession(resp.UserID) + c.config.Session.AccessToken = resp.AccessToken + c.config.Session.Save() + + go c.Start() + + return nil +} + +func (c *Container) Stop() { + if c.running { + c.stop <- true + c.client.StopSync() + } +} + +func (c *Container) Client() *gomatrix.Client { + return c.client +} + +func (c *Container) UpdateRoomList() { + resp, err := c.client.JoinedRooms() + if err != nil { + debug.Print("Error fetching room list:", err) + return + } + + c.ui.MainView().SetRooms(resp.JoinedRooms) +} + +func (c *Container) OnLogin() { + c.client.Store = c.config.Session + + syncer := NewGomuksSyncer(c.config.Session) + syncer.OnEventType("m.room.message", c.HandleMessage) + syncer.OnEventType("m.room.member", c.HandleMembership) + syncer.OnEventType("m.typing", c.HandleTyping) + c.client.Syncer = syncer + + c.UpdateRoomList() +} + +func (c *Container) Start() { + defer c.gmx.Recover() + + c.ui.SetView(ifc.ViewMain) + c.OnLogin() + + debug.Print("Starting sync...") + c.running = true + for { + select { + case <-c.stop: + debug.Print("Stopping sync...") + c.running = false + return + default: + if err := c.client.Sync(); err != nil { + debug.Print("Sync() errored", err) + } else { + debug.Print("Sync() returned without error") + } + } + } +} + +func (c *Container) HandleMessage(evt *gomatrix.Event) { + room, message := c.ui.MainView().ProcessMessageEvent(evt) + if room != nil { + room.AddMessage(message, widget.AppendMessage) + } +} + +func (c *Container) HandleMembership(evt *gomatrix.Event) { + const Hour = 1 * 60 * 60 * 1000 + if evt.Unsigned.Age > Hour { + return + } + + room, message := c.ui.MainView().ProcessMembershipEvent(evt, true) + if room != nil { + // TODO this shouldn't be necessary + room.Room.UpdateState(evt) + // TODO This should probably also be in a different place + room.UpdateUserList() + + room.AddMessage(message, widget.AppendMessage) + } +} + +func (c *Container) HandleTyping(evt *gomatrix.Event) { + users := evt.Content["user_ids"].([]interface{}) + + strUsers := make([]string, len(users)) + for i, user := range users { + strUsers[i] = user.(string) + } + c.ui.MainView().SetTyping(evt.RoomID, strUsers) +} + +func (c *Container) SendMessage(roomID, message string) { + c.gmx.Recover() + c.SendTyping(roomID, false) + c.client.SendText(roomID, message) +} + +func (c *Container) SendTyping(roomID string, typing bool) { + c.gmx.Recover() + ts := time.Now().Unix() + if c.typing > ts && typing { + return + } + + if typing { + c.client.UserTyping(roomID, true, 5000) + c.typing = ts + 5 + } else { + c.client.UserTyping(roomID, false, 0) + c.typing = 0 + } +} + +func (c *Container) JoinRoom(roomID string) error { + if len(roomID) == 0 { + return fmt.Errorf("invalid room ID") + } + + server := "" + if roomID[0] == '!' { + server = roomID[strings.Index(roomID, ":")+1:] + } + + _, err := c.client.JoinRoom(roomID, server, nil) + if err != nil { + return err + } + + // TODO probably safe to remove + // c.ui.MainView().AddRoom(resp.RoomID) + return nil +} + +func (c *Container) LeaveRoom(roomID string) error { + if len(roomID) == 0 { + return fmt.Errorf("invalid room ID") + } + + _, err := c.client.LeaveRoom(roomID) + if err != nil { + return err + } + + return nil +} + +func (c *Container) getState(roomID string) []*gomatrix.Event { + content := make([]*gomatrix.Event, 0) + err := c.client.StateEvent(roomID, "", "", &content) + if err != nil { + debug.Print("Error getting state of", roomID, err) + return nil + } + return content +} + +func (c *Container) GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error) { + resp, err := c.client.Messages(roomID, prevBatch, "", 'b', limit) + if err != nil { + return nil, "", err + } + return resp.Chunk, resp.End, nil +} + +func (c *Container) GetRoom(roomID string) *rooms.Room { + room := c.config.Session.GetRoom(roomID) + if room != nil && len(room.State) == 0 { + events := c.getState(room.ID) + if events != nil { + for _, event := range events { + room.UpdateState(event) + } + } + } + return room +} diff --git a/matrix/room/member.go b/matrix/room/member.go new file mode 100644 index 0000000..474d2fd --- /dev/null +++ b/matrix/room/member.go @@ -0,0 +1,51 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package room + +import ( + "maunium.net/go/gomatrix" +) + +type Member struct { + UserID string `json:"-"` + Membership string `json:"membership"` + DisplayName string `json:"displayname"` + AvatarURL string `json:"avatar_url"` +} + +func eventToRoomMember(userID string, event *gomatrix.Event) *Member { + if event == nil { + return &Member{ + UserID: userID, + Membership: "leave", + } + } + membership, _ := event.Content["membership"].(string) + avatarURL, _ := event.Content["avatar_url"].(string) + + displayName, _ := event.Content["displayname"].(string) + if len(displayName) == 0 { + displayName = userID + } + + return &Member{ + UserID: userID, + Membership: membership, + DisplayName: displayName, + AvatarURL: avatarURL, + } +} diff --git a/matrix/room/room.go b/matrix/room/room.go new file mode 100644 index 0000000..4b3bda2 --- /dev/null +++ b/matrix/room/room.go @@ -0,0 +1,145 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package room + +import ( + "maunium.net/go/gomatrix" +) + +// Room represents a single Matrix room. +type Room struct { + *gomatrix.Room + + PrevBatch string + memberCache map[string]*Member + nameCache string + topicCache string +} + +// UpdateState updates the room's current state with the given Event. This will clobber events based +// on the type/state_key combination. +func (room *Room) UpdateState(event *gomatrix.Event) { + _, exists := room.State[event.Type] + if !exists { + room.State[event.Type] = make(map[string]*gomatrix.Event) + } + switch event.Type { + case "m.room.member": + room.memberCache = nil + case "m.room.name": + case "m.room.canonical_alias": + case "m.room.alias": + room.nameCache = "" + case "m.room.topic": + room.topicCache = "" + } + room.State[event.Type][*event.StateKey] = event +} + +// GetStateEvent returns the state event for the given type/state_key combo, or nil. +func (room *Room) GetStateEvent(eventType string, stateKey string) *gomatrix.Event { + stateEventMap, _ := room.State[eventType] + event, _ := stateEventMap[stateKey] + return event +} + +// GetStateEvents returns the state events for the given type. +func (room *Room) GetStateEvents(eventType string) map[string]*gomatrix.Event { + stateEventMap, _ := room.State[eventType] + return stateEventMap +} + +// GetTopic returns the topic of the room. +func (room *Room) GetTopic() string { + if len(room.topicCache) == 0 { + topicEvt := room.GetStateEvent("m.room.topic", "") + if topicEvt != nil { + room.topicCache, _ = topicEvt.Content["topic"].(string) + } + } + return room.topicCache +} + +// GetTitle returns the display title of the room. +func (room *Room) GetTitle() string { + if len(room.nameCache) == 0 { + nameEvt := room.GetStateEvent("m.room.name", "") + if nameEvt != nil { + room.nameCache, _ = nameEvt.Content["name"].(string) + } + } + if len(room.nameCache) == 0 { + canonicalAliasEvt := room.GetStateEvent("m.room.canonical_alias", "") + if canonicalAliasEvt != nil { + room.nameCache, _ = canonicalAliasEvt.Content["alias"].(string) + } + } + if len(room.nameCache) == 0 { + // TODO the spec says clients should not use m.room.aliases for room names. + // However, Riot also uses m.room.aliases, so this is here now. + aliasEvents := room.GetStateEvents("m.room.aliases") + for _, event := range aliasEvents { + aliases, _ := event.Content["aliases"].([]interface{}) + if len(aliases) > 0 { + room.nameCache, _ = aliases[0].(string) + break + } + } + } + if len(room.nameCache) == 0 { + // TODO follow other title rules in spec + room.nameCache = room.ID + } + return room.nameCache +} + +func (room *Room) createMemberCache() map[string]*Member { + cache := make(map[string]*Member) + events := room.GetStateEvents("m.room.member") + if events != nil { + for userID, event := range events { + member := eventToRoomMember(userID, event) + if member.Membership != "leave" { + cache[member.UserID] = member + } + } + } + room.memberCache = cache + return cache +} + +func (room *Room) GetMembers() map[string]*Member { + if len(room.memberCache) == 0 { + room.createMemberCache() + } + return room.memberCache +} + +func (room *Room) GetMember(userID string) *Member { + if len(room.memberCache) == 0 { + room.createMemberCache() + } + member, _ := room.memberCache[userID] + return member +} + +// NewRoom creates a new Room with the given ID +func NewRoom(roomID string) *Room { + return &Room{ + Room: gomatrix.NewRoom(roomID), + } +} diff --git a/matrix/sync.go b/matrix/sync.go new file mode 100644 index 0000000..ab5d047 --- /dev/null +++ b/matrix/sync.go @@ -0,0 +1,126 @@ +package matrix + +import ( + "encoding/json" + "fmt" + "runtime/debug" + "time" + + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" +) + +// GomuksSyncer is the default syncing implementation. You can either write your own syncer, or selectively +// replace parts of this default syncer (e.g. the ProcessResponse method). The default syncer uses the observer +// pattern to notify callers about incoming events. See GomuksSyncer.OnEventType for more information. +type GomuksSyncer struct { + Session *config.Session + listeners map[string][]gomatrix.OnEventListener // event type to listeners array +} + +// NewGomuksSyncer returns an instantiated GomuksSyncer +func NewGomuksSyncer(session *config.Session) *GomuksSyncer { + return &GomuksSyncer{ + Session: session, + listeners: make(map[string][]gomatrix.OnEventListener), + } +} + +func (s *GomuksSyncer) ProcessResponse(res *gomatrix.RespSync, since string) (err error) { + if !s.shouldProcessResponse(res, since) { + return + } + // gdebug.Print("Processing sync response", since, res) + + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("ProcessResponse panicked! userID=%s since=%s panic=%s\n%s", s.Session.MXID, since, r, debug.Stack()) + } + }() + + for _, event := range res.Presence.Events { + s.notifyListeners(event) + } + for roomID, roomData := range res.Rooms.Join { + room := s.Session.GetRoom(roomID) + for _, event := range roomData.State.Events { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + for _, event := range roomData.Timeline.Events { + event.RoomID = roomID + s.notifyListeners(event) + } + for _, event := range roomData.Ephemeral.Events { + event.RoomID = roomID + s.notifyListeners(event) + } + + if len(room.PrevBatch) == 0 { + room.PrevBatch = roomData.Timeline.PrevBatch + } + } + for roomID, roomData := range res.Rooms.Invite { + room := s.Session.GetRoom(roomID) + for _, event := range roomData.State.Events { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + } + for roomID, roomData := range res.Rooms.Leave { + room := s.Session.GetRoom(roomID) + for _, event := range roomData.Timeline.Events { + if event.StateKey != nil { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + } + + if len(room.PrevBatch) == 0 { + room.PrevBatch = roomData.Timeline.PrevBatch + } + } + return +} + +// OnEventType allows callers to be notified when there are new events for the given event type. +// There are no duplicate checks. +func (s *GomuksSyncer) OnEventType(eventType string, callback gomatrix.OnEventListener) { + _, exists := s.listeners[eventType] + if !exists { + s.listeners[eventType] = []gomatrix.OnEventListener{} + } + s.listeners[eventType] = append(s.listeners[eventType], callback) +} + +// shouldProcessResponse returns true if the response should be processed. May modify the response to remove +// stuff that shouldn't be processed. +func (s *GomuksSyncer) shouldProcessResponse(resp *gomatrix.RespSync, since string) bool { + if since == "" { + return false + } + return true +} + +func (s *GomuksSyncer) notifyListeners(event *gomatrix.Event) { + listeners, exists := s.listeners[event.Type] + if !exists { + return + } + for _, fn := range listeners { + fn(event) + } +} + +// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. +func (s *GomuksSyncer) OnFailedSync(res *gomatrix.RespSync, err error) (time.Duration, error) { + return 10 * time.Second, nil +} + +// GetFilterJSON returns a filter with a timeline limit of 50. +func (s *GomuksSyncer) GetFilterJSON(userID string) json.RawMessage { + return json.RawMessage(`{"room":{"timeline":{"limit":50}}}`) +} |