aboutsummaryrefslogtreecommitdiff
path: root/matrix
diff options
context:
space:
mode:
Diffstat (limited to 'matrix')
-rw-r--r--matrix/matrix.go284
-rw-r--r--matrix/room/member.go51
-rw-r--r--matrix/room/room.go145
-rw-r--r--matrix/sync.go126
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}}}`)
+}