gomuks/matrix/matrix.go

465 lines
12 KiB
Go
Raw Normal View History

// 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/>.
2018-03-18 20:24:03 +01:00
package matrix
import (
"bytes"
2018-03-25 19:30:34 +02:00
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
2018-03-16 15:24:11 +01:00
"strings"
2018-03-14 23:14:39 +01:00
"time"
"maunium.net/go/gomatrix"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix/pushrules"
"maunium.net/go/gomuks/matrix/rooms"
)
2018-03-21 22:29:58 +01:00
// Container is a wrapper for a gomatrix Client and some other stuff.
//
// It is used for all Matrix calls from the UI and Matrix event handlers.
2018-03-18 20:24:03 +01:00
type Container struct {
2018-03-13 20:58:43 +01:00
client *gomatrix.Client
syncer *GomuksSyncer
2018-03-18 20:24:03 +01:00
gmx ifc.Gomuks
ui ifc.GomuksUI
config *config.Config
running bool
stop chan bool
2018-03-14 23:14:39 +01:00
typing int64
}
2018-03-21 22:29:58 +01:00
// NewContainer creates a new Container for the given Gomuks instance.
func NewContainer(gmx ifc.Gomuks) *Container {
2018-03-18 20:24:03 +01:00
c := &Container{
2018-03-13 20:58:43 +01:00
config: gmx.Config(),
ui: gmx.UI(),
gmx: gmx,
}
2018-03-13 20:58:43 +01:00
return c
}
2018-03-21 22:29:58 +01:00
// InitClient initializes the gomatrix client and connects to the homeserver specified in the config.
2018-03-18 20:24:03 +01:00
func (c *Container) InitClient() error {
if len(c.config.HS) == 0 {
return fmt.Errorf("no homeserver in config")
}
2018-03-13 20:58:43 +01:00
if c.client != nil {
c.Stop()
c.client = nil
}
var mxid, accessToken string
if c.config.Session != nil {
accessToken = c.config.Session.AccessToken
2018-03-21 22:29:58 +01:00
mxid = c.config.UserID
}
var err error
2018-03-13 20:58:43 +01:00
c.client, err = gomatrix.NewClient(c.config.HS, mxid, accessToken)
if err != nil {
return err
}
c.stop = make(chan bool, 1)
2018-03-21 22:29:58 +01:00
if c.config.Session != nil && len(accessToken) > 0 {
go c.Start()
}
return nil
}
2018-03-21 22:29:58 +01:00
// Initialized returns whether or not the gomatrix client is initialized (see InitClient())
2018-03-18 20:24:03 +01:00
func (c *Container) Initialized() bool {
2018-03-13 20:58:43 +01:00
return c.client != nil
}
2018-03-21 22:29:58 +01:00
// Login sends a password login request with the given username and password.
2018-03-18 20:24:03 +01:00
func (c *Container) Login(user, password string) error {
2018-03-13 20:58:43 +01:00
resp, err := c.client.Login(&gomatrix.ReqLogin{
Type: "m.login.password",
User: user,
Password: password,
})
if err != nil {
return err
}
2018-03-13 20:58:43 +01:00
c.client.SetCredentials(resp.UserID, resp.AccessToken)
2018-03-21 22:29:58 +01:00
c.config.UserID = 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
}
2018-03-21 22:29:58 +01:00
// Stop stops the Matrix syncer.
2018-03-18 20:24:03 +01:00
func (c *Container) Stop() {
2018-03-15 20:28:21 +01:00
if c.running {
c.stop <- true
c.client.StopSync()
}
2018-03-13 20:58:43 +01:00
}
2018-03-21 22:29:58 +01:00
// Client returns the underlying gomatrix client object.
2018-03-18 20:24:03 +01:00
func (c *Container) Client() *gomatrix.Client {
return c.client
}
2018-03-21 22:29:58 +01:00
// UpdatePushRules fetches the push notification rules from the server and stores them in the current Session object.
func (c *Container) UpdatePushRules() {
debug.Print("Updating push rules...")
2018-03-21 22:29:58 +01:00
resp, err := pushrules.GetPushRules(c.client)
if err != nil {
debug.Print("Failed to fetch push rules:", err)
}
c.config.Session.PushRules = resp
}
2018-03-21 22:29:58 +01:00
// PushRules returns the push notification rules. If no push rules are cached, UpdatePushRules() will be called first.
func (c *Container) PushRules() *pushrules.PushRuleset {
if c.config.Session.PushRules == nil {
c.UpdatePushRules()
}
return c.config.Session.PushRules
}
2018-03-21 22:29:58 +01:00
// UpdateRoomList fetches the list of rooms the user has joined and sends them to the UI.
2018-03-18 20:24:03 +01:00
func (c *Container) UpdateRoomList() {
resp, err := c.client.JoinedRooms()
2018-03-13 20:58:43 +01:00
if err != nil {
2018-03-20 11:16:32 +01:00
respErr, _ := err.(gomatrix.HTTPError).WrappedError.(gomatrix.RespError)
if respErr.ErrCode == "M_UNKNOWN_TOKEN" {
c.OnLogout()
return
}
2018-03-18 20:24:03 +01:00
debug.Print("Error fetching room list:", err)
return
2018-03-13 20:58:43 +01:00
}
2018-03-18 20:24:03 +01:00
c.ui.MainView().SetRooms(resp.JoinedRooms)
}
2018-03-21 22:29:58 +01:00
// OnLogout stops the syncer and moves the UI back to the login view.
2018-03-20 11:16:32 +01:00
func (c *Container) OnLogout() {
c.Stop()
c.ui.SetView(ifc.ViewLogin)
}
2018-03-21 22:29:58 +01:00
// OnLogin initializes the syncer and updates the room list.
2018-03-18 20:24:03 +01:00
func (c *Container) OnLogin() {
2018-03-13 20:58:43 +01:00
c.client.Store = c.config.Session
c.syncer = NewGomuksSyncer(c.config.Session)
c.syncer.OnEventType("m.room.message", c.HandleMessage)
c.syncer.OnEventType("m.room.member", c.HandleMembership)
c.syncer.OnEventType("m.typing", c.HandleTyping)
c.syncer.OnEventType("m.push_rules", c.HandlePushRules)
c.syncer.OnEventType("m.tag", c.HandleTag)
c.client.Syncer = c.syncer
c.UpdateRoomList()
}
2018-03-21 22:29:58 +01:00
// Start moves the UI to the main view, calls OnLogin() and runs the syncer forever until stopped with Stop()
2018-03-18 20:24:03 +01:00
func (c *Container) Start() {
defer c.gmx.Recover()
2018-03-18 20:24:03 +01:00
c.ui.SetView(ifc.ViewMain)
c.OnLogin()
2018-03-20 11:16:32 +01:00
if c.client == nil {
return
}
2018-03-18 20:24:03 +01:00
debug.Print("Starting sync...")
c.running = true
for {
select {
case <-c.stop:
2018-03-18 20:24:03 +01:00
debug.Print("Stopping sync...")
c.running = false
return
default:
2018-03-13 20:58:43 +01:00
if err := c.client.Sync(); err != nil {
2018-03-18 20:24:03 +01:00
debug.Print("Sync() errored", err)
2018-03-13 20:58:43 +01:00
} else {
2018-03-18 20:24:03 +01:00
debug.Print("Sync() returned without error")
}
}
}
}
2018-03-21 22:29:58 +01:00
// HandleMessage is the event handler for the m.room.message timeline event.
2018-03-18 20:24:03 +01:00
func (c *Container) HandleMessage(evt *gomatrix.Event) {
mainView := c.ui.MainView()
roomView := mainView.GetRoom(evt.RoomID)
if roomView == nil {
return
}
message := mainView.ParseEvent(roomView, evt)
if message != nil {
if c.syncer.FirstSyncDone {
pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
}
roomView.AddMessage(message, ifc.AppendMessage)
2018-03-20 11:16:32 +01:00
c.ui.Render()
}
2018-03-13 20:58:43 +01:00
}
2018-03-21 22:29:58 +01:00
// HandlePushRules is the event handler for the m.push_rules account data event.
func (c *Container) HandlePushRules(evt *gomatrix.Event) {
debug.Print("Received updated push rules")
var err error
2018-03-21 22:29:58 +01:00
c.config.Session.PushRules, err = pushrules.EventToPushRules(evt)
if err != nil {
debug.Print("Failed to convert event to push rules:", err)
}
}
2018-03-25 19:30:34 +02:00
// HandleTag is the event handler for the m.tag account data event.
func (c *Container) HandleTag(evt *gomatrix.Event) {
debug.Print("Received updated tags")
dat, _ := json.MarshalIndent(&evt.Content, "", " ")
debug.Print(string(dat))
}
func (c *Container) processOwnMembershipChange(evt *gomatrix.Event) {
membership, _ := evt.Content["membership"].(string)
prevMembership := "leave"
if evt.Unsigned.PrevContent != nil {
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
}
if membership == prevMembership {
return
}
2018-03-23 13:44:36 +01:00
switch membership {
case "join":
c.ui.MainView().AddRoom(evt.RoomID)
2018-03-23 13:44:36 +01:00
case "leave":
c.ui.MainView().RemoveRoom(evt.RoomID)
}
}
2018-03-21 22:29:58 +01:00
// HandleMembership is the event handler for the m.room.membership state event.
2018-03-18 20:24:03 +01:00
func (c *Container) HandleMembership(evt *gomatrix.Event) {
if evt.StateKey != nil && *evt.StateKey == c.config.Session.UserID {
c.processOwnMembershipChange(evt)
}
2018-03-26 17:04:10 +02:00
mainView := c.ui.MainView()
roomView := mainView.GetRoom(evt.RoomID)
if roomView == nil {
return
}
message := mainView.ParseEvent(roomView, evt)
if message != nil {
// TODO this shouldn't be necessary
roomView.MxRoom().UpdateState(evt)
// TODO This should probably also be in a different place
roomView.UpdateUserList()
if c.syncer.FirstSyncDone {
pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
}
roomView.AddMessage(message, ifc.AppendMessage)
2018-03-20 11:16:32 +01:00
c.ui.Render()
2018-03-16 15:24:11 +01:00
}
}
2018-03-21 22:29:58 +01:00
// HandleTyping is the event handler for the m.typing event.
2018-03-18 20:24:03 +01:00
func (c *Container) HandleTyping(evt *gomatrix.Event) {
2018-03-15 17:21:14 +01:00
users := evt.Content["user_ids"].([]interface{})
strUsers := make([]string, len(users))
for i, user := range users {
strUsers[i] = user.(string)
}
2018-03-15 19:53:04 +01:00
c.ui.MainView().SetTyping(evt.RoomID, strUsers)
2018-03-14 23:14:39 +01:00
}
2018-03-21 22:29:58 +01:00
// SendMessage sends a message with the given text to the given room.
2018-03-22 20:44:46 +01:00
func (c *Container) SendMessage(roomID, msgtype, text string) (string, error) {
defer c.gmx.Recover()
2018-03-15 18:45:52 +01:00
c.SendTyping(roomID, false)
2018-03-22 20:44:46 +01:00
resp, err := c.client.SendMessageEvent(roomID, "m.room.message",
gomatrix.TextMessage{MsgType: msgtype, Body: text})
if err != nil {
return "", err
}
return resp.EventID, nil
}
2018-03-14 23:14:39 +01:00
2018-03-21 22:29:58 +01:00
// SendTyping sets whether or not the user is typing in the given room.
2018-03-18 20:24:03 +01:00
func (c *Container) SendTyping(roomID string, typing bool) {
defer c.gmx.Recover()
2018-03-18 20:24:03 +01:00
ts := time.Now().Unix()
if c.typing > ts && typing {
2018-03-14 23:14:39 +01:00
return
}
2018-03-15 18:45:52 +01:00
if typing {
2018-03-21 22:29:58 +01:00
c.client.UserTyping(roomID, true, 20000)
c.typing = ts + 15
2018-03-15 18:45:52 +01:00
} else {
c.client.UserTyping(roomID, false, 0)
c.typing = 0
}
2018-03-14 23:14:39 +01:00
}
2018-03-21 22:29:58 +01:00
// JoinRoom makes the current user try to join the given room.
2018-03-18 20:24:03 +01:00
func (c *Container) JoinRoom(roomID string) error {
2018-03-16 15:24:11 +01:00
if len(roomID) == 0 {
return fmt.Errorf("invalid room ID")
}
server := ""
if roomID[0] == '!' {
server = roomID[strings.Index(roomID, ":")+1:]
}
2018-03-18 20:24:03 +01:00
_, err := c.client.JoinRoom(roomID, server, nil)
if err != nil {
return err
}
return nil
}
2018-03-21 22:29:58 +01:00
// LeaveRoom makes the current user leave the given room.
2018-03-18 20:24:03 +01:00
func (c *Container) LeaveRoom(roomID string) error {
if len(roomID) == 0 {
return fmt.Errorf("invalid room ID")
}
_, err := c.client.LeaveRoom(roomID)
2018-03-16 15:24:11 +01:00
if err != nil {
return err
}
return nil
}
2018-03-21 22:29:58 +01:00
// getState requests the state of the given room.
2018-03-18 20:24:03 +01:00
func (c *Container) getState(roomID string) []*gomatrix.Event {
2018-03-15 18:45:52 +01:00
content := make([]*gomatrix.Event, 0)
err := c.client.StateEvent(roomID, "", "", &content)
if err != nil {
2018-03-18 20:24:03 +01:00
debug.Print("Error getting state of", roomID, err)
2018-03-14 23:14:39 +01:00
return nil
}
2018-03-15 18:45:52 +01:00
return content
2018-03-14 23:14:39 +01:00
}
2018-03-21 22:29:58 +01:00
// GetHistory fetches room history.
2018-03-18 20:24:03 +01:00
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
}
2018-03-21 22:29:58 +01:00
// GetRoom gets the room instance stored in the session.
//
// If the room doesn't have any state events stored, its state will be updated.
2018-03-18 20:24:03 +01:00
func (c *Container) GetRoom(roomID string) *rooms.Room {
room := c.config.Session.GetRoom(roomID)
if room != nil && len(room.State) == 0 {
2018-03-16 15:24:11 +01:00
events := c.getState(room.ID)
if events != nil {
for _, event := range events {
room.UpdateState(event)
}
2018-03-15 18:45:52 +01:00
}
2018-03-14 23:14:39 +01:00
}
2018-03-16 15:24:11 +01:00
return room
2018-03-14 23:14:39 +01:00
}
var mxcRegex = regexp.MustCompile("mxc://(.+)/(.+)")
func (c *Container) Download(mxcURL string) (data []byte, fullID string, err error) {
parts := mxcRegex.FindStringSubmatch(mxcURL)
if parts == nil || len(parts) != 3 {
err = fmt.Errorf("invalid matrix content URL")
return
}
hs := parts[1]
id := parts[2]
fullID = fmt.Sprintf("%s/%s", hs, id)
cacheFile := c.getCachePath(hs, id)
if _, err = os.Stat(cacheFile); err != nil {
data, err = ioutil.ReadFile(cacheFile)
if err == nil {
return
}
}
dlURL, _ := url.Parse(c.client.HomeserverURL.String())
dlURL.Path = path.Join(dlURL.Path, "/_matrix/media/v1/download", hs, id)
var resp *http.Response
resp, err = c.client.Client.Get(dlURL.String())
if err != nil {
return
}
defer resp.Body.Close()
var buf bytes.Buffer
_, err = io.Copy(&buf, resp.Body)
if err != nil {
return
}
data = buf.Bytes()
err = ioutil.WriteFile(cacheFile, data, 0600)
return
}
func (c *Container) getCachePath(homeserver, fileID string) string {
dir := filepath.Join(c.config.MediaDir, homeserver)
err := os.MkdirAll(dir, 0700)
if err != nil {
return ""
}
return filepath.Join(dir, fileID)
}