// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2019 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero 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 Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package matrix import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "io/ioutil" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "runtime" dbg "runtime/debug" "time" "github.com/pkg/errors" "maunium.net/go/gomuks/lib/open" "maunium.net/go/gomuks/matrix/event" "maunium.net/go/mautrix" "maunium.net/go/mautrix/format" "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" ) // Container is a wrapper for a mautrix Client and some other stuff. // // It is used for all Matrix calls from the UI and Matrix event handlers. type Container struct { client *mautrix.Client syncer *GomuksSyncer gmx ifc.Gomuks ui ifc.GomuksUI config *config.Config history *HistoryManager running bool stop chan bool typing int64 } // NewContainer creates a new Container for the given Gomuks instance. func NewContainer(gmx ifc.Gomuks) *Container { c := &Container{ config: gmx.Config(), ui: gmx.UI(), gmx: gmx, } return c } // Client returns the underlying mautrix Client. func (c *Container) Client() *mautrix.Client { return c.client } type mxLogger struct{} func (log mxLogger) Debugfln(message string, args ...interface{}) { debug.Printf("[Matrix] "+message, args...) } // InitClient initializes the mautrix client and connects to the homeserver specified in the config. func (c *Container) InitClient() error { if len(c.config.HS) == 0 { return fmt.Errorf("no homeserver entered") } if c.client != nil { c.Stop() c.client = nil } var mxid, accessToken string if len(c.config.AccessToken) > 0 { accessToken = c.config.AccessToken mxid = c.config.UserID } var err error c.client, err = mautrix.NewClient(c.config.HS, mxid, accessToken) if err != nil { return err } c.client.Logger = mxLogger{} if c.history == nil { c.history, err = NewHistoryManager(c.config.HistoryPath) if err != nil { return errors.Wrap(err, "failed to initialize history") } } allowInsecure := len(os.Getenv("GOMUKS_ALLOW_INSECURE_CONNECTIONS")) > 0 if allowInsecure { c.client.Client = &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, } } c.stop = make(chan bool, 1) if len(accessToken) > 0 { go c.Start() } return nil } // Initialized returns whether or not the mautrix client is initialized (see InitClient()) func (c *Container) Initialized() bool { return c.client != nil } func (c *Container) PasswordLogin(user, password string) error { resp, err := c.client.Login(&mautrix.ReqLogin{ Type: "m.login.password", Identifier: mautrix.UserIdentifier{ Type: "m.id.user", User: user, }, Password: password, InitialDeviceDisplayName: "gomuks", }) if err != nil { return err } c.finishLogin(resp) return nil } func (c *Container) finishLogin(resp *mautrix.RespLogin) { c.client.SetCredentials(resp.UserID, resp.AccessToken) c.config.UserID = resp.UserID c.config.AccessToken = resp.AccessToken c.config.Save() go c.Start() } func respondHTML(w http.ResponseWriter, status int, message string) { w.Header().Add("Content-Type", "text/html") w.WriteHeader(status) _, _ = w.Write([]byte(fmt.Sprintf(` gomuks single-sign on

%s

`, message))) } func (c *Container) SingleSignOn() error { loginURL := c.client.BuildURLWithQuery([]string{"login", "sso", "redirect"}, map[string]string{ "redirectUrl": "http://localhost:29325", }) err := open.Open(loginURL) if err != nil { return err } errChan := make(chan error, 1) server := &http.Server{Addr: ":29325"} server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loginToken := r.URL.Query().Get("loginToken") if len(loginToken) == 0 { respondHTML(w, http.StatusBadRequest, "Missing loginToken parameter") return } resp, err := c.client.Login(&mautrix.ReqLogin{ Type: "m.login.token", Token: loginToken, InitialDeviceDisplayName: "gomuks", }) if err != nil { respondHTML(w, http.StatusForbidden, err.Error()) errChan <- err return } respondHTML(w, http.StatusOK, fmt.Sprintf("Successfully logged in as %s", resp.UserID)) c.finishLogin(resp) go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err = server.Shutdown(ctx) if err != nil { debug.Printf("Failed to shut down SSO server: %v\n", err) } errChan <- err }() }) err = server.ListenAndServe() if err != nil { return err } err = <-errChan return err } // Login sends a password login request with the given username and password. func (c *Container) Login(user, password string) error { resp, err := c.client.GetLoginFlows() if err != nil { return err } if len(resp.Flows) == 1 && resp.Flows[0].Type == "m.login.password" { return c.PasswordLogin(user, password) } else if len(resp.Flows) == 2 && resp.Flows[0].Type == "m.login.sso" && resp.Flows[1].Type == "m.login.token" { return c.SingleSignOn() } else { return fmt.Errorf("no supported login flows") } } // Logout revokes the access token, stops the syncer and calls the OnLogout() method of the UI. func (c *Container) Logout() { c.client.Logout() c.config.DeleteSession() c.Stop() c.client = nil c.ui.OnLogout() } // Stop stops the Matrix syncer. func (c *Container) Stop() { if c.running { debug.Print("Stopping Matrix container...") c.stop <- true c.client.StopSync() debug.Print("Closing history manager...") err := c.history.Close() if err != nil { debug.Print("Error closing history manager:", err) } c.history = nil } } // 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...") resp, err := pushrules.GetPushRules(c.client) if err != nil { debug.Print("Failed to fetch push rules:", err) c.config.PushRules = &pushrules.PushRuleset{} } else { c.config.PushRules = resp } c.config.SavePushRules() } // 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.PushRules == nil { c.UpdatePushRules() } return c.config.PushRules } var AccountDataGomuksPreferences = mautrix.NewEventType("net.maunium.gomuks.preferences") // OnLogin initializes the syncer and updates the room list. func (c *Container) OnLogin() { c.ui.OnLogin() c.client.Store = c.config debug.Print("Initializing syncer") c.syncer = NewGomuksSyncer(c.config) c.syncer.OnEventType(mautrix.EventMessage, c.HandleMessage) c.syncer.OnEventType(mautrix.EventEncrypted, c.HandleMessage) c.syncer.OnEventType(mautrix.EventSticker, c.HandleMessage) c.syncer.OnEventType(mautrix.EventReaction, c.HandleMessage) c.syncer.OnEventType(mautrix.EventRedaction, c.HandleRedaction) c.syncer.OnEventType(mautrix.StateAliases, c.HandleMessage) c.syncer.OnEventType(mautrix.StateCanonicalAlias, c.HandleMessage) c.syncer.OnEventType(mautrix.StateTopic, c.HandleMessage) c.syncer.OnEventType(mautrix.StateRoomName, c.HandleMessage) c.syncer.OnEventType(mautrix.StateMember, c.HandleMembership) c.syncer.OnEventType(mautrix.EphemeralEventReceipt, c.HandleReadReceipt) c.syncer.OnEventType(mautrix.EphemeralEventTyping, c.HandleTyping) c.syncer.OnEventType(mautrix.AccountDataDirectChats, c.HandleDirectChatInfo) c.syncer.OnEventType(mautrix.AccountDataPushRules, c.HandlePushRules) c.syncer.OnEventType(mautrix.AccountDataRoomTags, c.HandleTag) c.syncer.OnEventType(AccountDataGomuksPreferences, c.HandlePreferences) c.syncer.InitDoneCallback = func() { debug.Print("Initial sync done") c.config.AuthCache.InitialSyncDone = true debug.Print("Updating title caches") for _, room := range c.config.Rooms.Map { room.GetTitle() } debug.Print("Cleaning cached rooms from memory") c.config.Rooms.ForceClean() debug.Print("Saving all data") c.config.SaveAll() debug.Print("Adding rooms to UI") c.ui.MainView().SetRooms(c.config.Rooms) c.ui.Render() // The initial sync can be a bit heavy, so we force run the GC here // after cleaning up rooms from memory above. debug.Print("Running GC") runtime.GC() dbg.FreeOSMemory() } c.client.Syncer = c.syncer debug.Print("Setting existing rooms") c.ui.MainView().SetRooms(c.config.Rooms) debug.Print("OnLogin() done.") } // Start moves the UI to the main view, calls OnLogin() and runs the syncer forever until stopped with Stop() func (c *Container) Start() { defer debug.Recover() c.OnLogin() if c.client == nil { return } 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 { if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.Code == http.StatusUnauthorized { debug.Print("Sync() errored with ", err, " -> logging out") c.Logout() } else { debug.Print("Sync() errored", err) } } else { debug.Print("Sync() returned without error") } } } } func (c *Container) HandlePreferences(source EventSource, evt *mautrix.Event) { if source&EventSourceAccountData == 0 { return } orig := c.config.Preferences err := json.Unmarshal(evt.Content.VeryRaw, &c.config.Preferences) if err != nil { debug.Print("Failed to parse updated preferences:", err) return } debug.Print("Updated preferences:", orig, "->", c.config.Preferences) if c.config.AuthCache.InitialSyncDone { c.ui.HandleNewPreferences() } } func (c *Container) SendPreferencesToMatrix() { defer debug.Recover() debug.Print("Sending updated preferences:", c.config.Preferences) u := c.client.BuildURL("user", c.config.UserID, "account_data", "net.maunium.gomuks.preferences") _, err := c.client.MakeRequest("PUT", u, &c.config.Preferences, nil) if err != nil { debug.Print("Failed to update preferences:", err) } } func (c *Container) HandleRedaction(source EventSource, evt *mautrix.Event) { room := c.GetOrCreateRoom(evt.RoomID) var redactedEvt *event.Event err := c.history.Update(room, evt.Redacts, func(redacted *event.Event) error { redacted.Unsigned.RedactedBy = evt.ID redacted.Unsigned.RedactedBecause = evt redactedEvt = redacted return nil }) if err != nil { debug.Print("Failed to mark", evt.Redacts, "as redacted:", err) return } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { return } roomView := c.ui.MainView().GetRoom(evt.RoomID) if roomView == nil { debug.Printf("Failed to handle event %v: No room view found.", evt) return } roomView.AddRedaction(redactedEvt) if c.syncer.FirstSyncDone { c.ui.Render() } } func (c *Container) HandleEdit(room *rooms.Room, editsID string, editEvent *event.Event) { var origEvt *event.Event err := c.history.Update(room, editsID, func(evt *event.Event) error { evt.Gomuks.Edits = append(evt.Gomuks.Edits, editEvent) origEvt = evt return nil }) if err != nil { debug.Print("Failed to store edit in history db:", err) return } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { return } roomView := c.ui.MainView().GetRoom(editEvent.RoomID) if roomView == nil { debug.Printf("Failed to handle edit event %v: No room view found.", editEvent) return } roomView.AddEdit(origEvt) if c.syncer.FirstSyncDone { c.ui.Render() } } func (c *Container) HandleReaction(room *rooms.Room, reactsTo string, reactEvent *event.Event) { rel := reactEvent.Content.GetRelatesTo() var origEvt *event.Event err := c.history.Update(room, reactsTo, func(evt *event.Event) error { if evt.Unsigned.Relations.Annotations.Map == nil { evt.Unsigned.Relations.Annotations.Map = make(map[string]int) } val, _ := evt.Unsigned.Relations.Annotations.Map[rel.Key] evt.Unsigned.Relations.Annotations.Map[rel.Key] = val + 1 origEvt = evt return nil }) if err != nil { debug.Print("Failed to store reaction in history db:", err) return } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { return } roomView := c.ui.MainView().GetRoom(reactEvent.RoomID) if roomView == nil { debug.Printf("Failed to handle edit event %v: No room view found.", reactEvent) return } roomView.AddReaction(origEvt, rel.Key) if c.syncer.FirstSyncDone { c.ui.Render() } } // HandleMessage is the event handler for the m.room.message timeline event. func (c *Container) HandleMessage(source EventSource, mxEvent *mautrix.Event) { room := c.GetOrCreateRoom(mxEvent.RoomID) if source&EventSourceLeave != 0 { room.HasLeft = true return } else if source&EventSourceState != 0 { return } if editID := mxEvent.Content.GetRelatesTo().GetReplaceID(); len(editID) > 0 { c.HandleEdit(room, editID, event.Wrap(mxEvent)) return } else if reactionID := mxEvent.Content.GetRelatesTo().GetAnnotationID(); mxEvent.Type == mautrix.EventReaction && len(reactionID) > 0 { c.HandleReaction(room, reactionID, event.Wrap(mxEvent)) return } events, err := c.history.Append(room, []*mautrix.Event{mxEvent}) if err != nil { debug.Printf("Failed to add event %s to history: %v", mxEvent.ID, err) } evt := events[0] if !c.config.AuthCache.InitialSyncDone { room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) return } mainView := c.ui.MainView() roomView := mainView.GetRoom(evt.RoomID) if roomView == nil { debug.Printf("Failed to handle event %v: No room view found.", evt) return } if !room.Loaded() { pushRules := c.PushRules().GetActions(room, evt.Event).Should() shouldNotify := pushRules.Notify || !pushRules.NotifySpecified if !shouldNotify { room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) room.AddUnread(evt.ID, shouldNotify, pushRules.Highlight) mainView.Bump(room) return } } message := roomView.AddEvent(evt) if message != nil { roomView.MxRoom().LastReceivedMessage = message.Time() if c.syncer.FirstSyncDone { pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt.Event).Should() mainView.NotifyMessage(roomView.MxRoom(), message, pushRules) c.ui.Render() } } else { debug.Printf("Parsing event %s type %s %v from %s in %s failed (ParseEvent() returned nil).", evt.ID, evt.Type.String(), evt.Content.Raw, evt.Sender, evt.RoomID) } } // HandleMembership is the event handler for the m.room.member state event. func (c *Container) HandleMembership(source EventSource, evt *mautrix.Event) { isLeave := source&EventSourceLeave != 0 isTimeline := source&EventSourceTimeline != 0 if isLeave { c.GetOrCreateRoom(evt.RoomID).HasLeft = true } isNonTimelineLeave := isLeave && !isTimeline if !c.config.AuthCache.InitialSyncDone && isNonTimelineLeave { return } else if evt.StateKey != nil && *evt.StateKey == c.config.UserID { c.processOwnMembershipChange(evt) } else if !isTimeline && (!c.config.AuthCache.InitialSyncDone || isLeave) { // We don't care about other users' membership events in the initial sync or chats we've left. return } c.HandleMessage(source, evt) } func (c *Container) processOwnMembershipChange(evt *mautrix.Event) { membership := evt.Content.Membership prevMembership := mautrix.MembershipLeave if evt.Unsigned.PrevContent != nil { prevMembership = evt.Unsigned.PrevContent.Membership } debug.Printf("Processing own membership change: %s->%s in %s", prevMembership, membership, evt.RoomID) if membership == prevMembership { return } room := c.GetRoom(evt.RoomID) switch membership { case "join": if c.config.AuthCache.InitialSyncDone { c.ui.MainView().AddRoom(room) } room.HasLeft = false case "leave": if c.config.AuthCache.InitialSyncDone { c.ui.MainView().RemoveRoom(room) } room.HasLeft = true room.Unload() case "invite": // TODO handle debug.Printf("%s invited the user to %s", evt.Sender, evt.RoomID) } } func (c *Container) parseReadReceipt(evt *mautrix.Event) (largestTimestampEvent string) { var largestTimestamp int64 for eventID, rawContent := range evt.Content.Raw { content, ok := rawContent.(map[string]interface{}) if !ok { continue } mRead, ok := content["m.read"].(map[string]interface{}) if !ok { continue } myInfo, ok := mRead[c.config.UserID].(map[string]interface{}) if !ok { continue } ts, ok := myInfo["ts"].(float64) if int64(ts) > largestTimestamp { largestTimestamp = int64(ts) largestTimestampEvent = eventID } } return } func (c *Container) HandleReadReceipt(source EventSource, evt *mautrix.Event) { if source&EventSourceLeave != 0 { return } lastReadEvent := c.parseReadReceipt(evt) if len(lastReadEvent) == 0 { return } room := c.GetRoom(evt.RoomID) if room != nil { room.MarkRead(lastReadEvent) if c.config.AuthCache.InitialSyncDone { c.ui.Render() } } } func (c *Container) parseDirectChatInfo(evt *mautrix.Event) map[*rooms.Room]bool { directChats := make(map[*rooms.Room]bool) for _, rawRoomIDList := range evt.Content.Raw { roomIDList, ok := rawRoomIDList.([]interface{}) if !ok { continue } for _, rawRoomID := range roomIDList { roomID, ok := rawRoomID.(string) if !ok { continue } room := c.GetOrCreateRoom(roomID) if room != nil && !room.HasLeft { directChats[room] = true } } } return directChats } func (c *Container) HandleDirectChatInfo(source EventSource, evt *mautrix.Event) { directChats := c.parseDirectChatInfo(evt) for _, room := range c.config.Rooms.Map { shouldBeDirect := directChats[room] if shouldBeDirect != room.IsDirect { room.IsDirect = shouldBeDirect if c.config.AuthCache.InitialSyncDone { c.ui.MainView().UpdateTags(room) } } } } // HandlePushRules is the event handler for the m.push_rules account data event. func (c *Container) HandlePushRules(source EventSource, evt *mautrix.Event) { debug.Print("Received updated push rules") var err error c.config.PushRules, err = pushrules.EventToPushRules(evt) if err != nil { debug.Print("Failed to convert event to push rules:", err) return } c.config.SavePushRules() } // HandleTag is the event handler for the m.tag account data event. func (c *Container) HandleTag(source EventSource, evt *mautrix.Event) { room := c.GetOrCreateRoom(evt.RoomID) newTags := make([]rooms.RoomTag, len(evt.Content.RoomTags)) index := 0 for tag, info := range evt.Content.RoomTags { order := "0.5" if len(info.Order) > 0 { order = info.Order.String() } newTags[index] = rooms.RoomTag{ Tag: tag, Order: order, } index++ } room.RawTags = newTags if c.config.AuthCache.InitialSyncDone { mainView := c.ui.MainView() mainView.UpdateTags(room) } } // HandleTyping is the event handler for the m.typing event. func (c *Container) HandleTyping(source EventSource, evt *mautrix.Event) { if !c.config.AuthCache.InitialSyncDone { return } c.ui.MainView().SetTyping(evt.RoomID, evt.Content.TypingUserIDs) } func (c *Container) MarkRead(roomID, eventID string) { urlPath := c.client.BuildURL("rooms", roomID, "receipt", "m.read", eventID) c.client.MakeRequest("POST", urlPath, struct{}{}, nil) } var mentionRegex = regexp.MustCompile("\\[(.+?)]\\(https://matrix.to/#/@.+?:.+?\\)") var roomRegex = regexp.MustCompile("\\[.+?]\\(https://matrix.to/#/(#.+?:[^/]+?)\\)") func (c *Container) PrepareMarkdownMessage(roomID string, msgtype mautrix.MessageType, text string, edit *event.Event) *event.Event { content := format.RenderMarkdown(text) content.MsgType = msgtype // Remove markdown link stuff from plaintext mentions and room links content.Body = mentionRegex.ReplaceAllString(content.Body, "$1") content.Body = roomRegex.ReplaceAllString(content.Body, "$1") if edit != nil { contentCopy := content content.NewContent = &contentCopy content.Body = "* " + content.Body if len(content.FormattedBody) > 0 { content.FormattedBody = "* " + content.FormattedBody } content.RelatesTo = &mautrix.RelatesTo{ Type: mautrix.RelReplace, EventID: edit.ID, } } txnID := c.client.TxnID() localEcho := event.Wrap(&mautrix.Event{ ID: txnID, Sender: c.config.UserID, Type: mautrix.EventMessage, Timestamp: time.Now().UnixNano() / 1e6, RoomID: roomID, Content: content, Unsigned: mautrix.Unsigned{ TransactionID: txnID, }, }) localEcho.Gomuks.OutgoingState = event.StateLocalEcho if edit != nil { localEcho.ID = edit.ID localEcho.Gomuks.Edits = []*event.Event{localEcho} } return localEcho } // SendMarkdownMessage sends a message with the given markdown text to the given room. func (c *Container) SendEvent(event *event.Event) (string, error) { defer debug.Recover() c.SendTyping(event.RoomID, false) resp, err := c.client.SendMessageEvent(event.RoomID, event.Type, event.Content, mautrix.ReqSendEvent{TransactionID: event.Unsigned.TransactionID}) if err != nil { return "", err } return resp.EventID, nil } // SendTyping sets whether or not the user is typing in the given room. func (c *Container) SendTyping(roomID string, typing bool) { defer debug.Recover() ts := time.Now().Unix() if c.typing > ts && typing { return } if typing { c.client.UserTyping(roomID, true, 20000) c.typing = ts + 15 } else { c.client.UserTyping(roomID, false, 0) c.typing = 0 } } // CreateRoom attempts to create a new room and join the user. func (c *Container) CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error) { resp, err := c.client.CreateRoom(req) if err != nil { return nil, err } room := c.GetOrCreateRoom(resp.RoomID) return room, nil } // JoinRoom makes the current user try to join the given room. func (c *Container) JoinRoom(roomID, server string) (*rooms.Room, error) { resp, err := c.client.JoinRoom(roomID, server, nil) if err != nil { return nil, err } room := c.GetOrCreateRoom(resp.RoomID) room.HasLeft = false return room, nil } // LeaveRoom makes the current user leave the given room. func (c *Container) LeaveRoom(roomID string) error { _, err := c.client.LeaveRoom(roomID) if err != nil { return err } node := c.GetOrCreateRoom(roomID) node.HasLeft = true node.Unload() return nil } func (c *Container) FetchMembers(room *rooms.Room) error { members, err := c.client.Members(room.ID, mautrix.ReqMembers{At: room.LastPrevBatch}) if err != nil { return err } for _, evt := range members.Chunk { room.UpdateState(evt) } room.MembersFetched = true return nil } // GetHistory fetches room history. func (c *Container) GetHistory(room *rooms.Room, limit int) ([]*event.Event, error) { events, err := c.history.Load(room, limit) if err != nil { return nil, err } if len(events) > 0 { debug.Printf("Loaded %d events for %s from local cache", len(events), room.ID) return events, nil } resp, err := c.client.Messages(room.ID, room.PrevBatch, "", 'b', limit) if err != nil { return nil, err } debug.Printf("Loaded %d events for %s from server from %s to %s", len(resp.Chunk), room.ID, resp.Start, resp.End) for _, evt := range resp.State { room.UpdateState(evt) } room.PrevBatch = resp.End c.config.Rooms.Put(room) if len(resp.Chunk) == 0 { return []*event.Event{}, nil } events, err = c.history.Prepend(room, resp.Chunk) if err != nil { return nil, err } return events, nil } func (c *Container) GetEvent(room *rooms.Room, eventID string) (*event.Event, error) { evt, err := c.history.Get(room, eventID) if err != nil && err != EventNotFoundError { debug.Printf("Failed to get event %s from local cache: %v", eventID, err) } else if evt != nil { debug.Printf("Found event %s in local cache", eventID) return evt, err } mxEvent, err := c.client.GetEvent(room.ID, eventID) if err != nil { return nil, err } evt = event.Wrap(mxEvent) debug.Printf("Loaded event %s from server", eventID) return evt, nil } // GetOrCreateRoom gets the room instance stored in the session. func (c *Container) GetOrCreateRoom(roomID string) *rooms.Room { return c.config.Rooms.GetOrCreate(roomID) } // GetRoom gets the room instance stored in the session. func (c *Container) GetRoom(roomID string) *rooms.Room { return c.config.Rooms.Get(roomID) } var mxcRegex = regexp.MustCompile("mxc://(.+)/(.+)") // Download fetches the given Matrix content (mxc) URL and returns the data, homeserver, file ID and potential errors. // // The file will be either read from the media cache (if found) or downloaded from the server. func (c *Container) Download(mxcURL string) (data []byte, hs, id 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] cacheFile := c.GetCachePath(hs, id) var info os.FileInfo if info, err = os.Stat(cacheFile); err == nil && !info.IsDir() { data, err = ioutil.ReadFile(cacheFile) if err == nil { return } } data, err = c.download(hs, id, cacheFile) return } func (c *Container) GetDownloadURL(hs, id string) string { dlURL, _ := url.Parse(c.client.HomeserverURL.String()) if dlURL.Scheme == "" { dlURL.Scheme = "https" } dlURL.Path = path.Join(dlURL.Path, "/_matrix/media/v1/download", hs, id) return dlURL.String() } func (c *Container) download(hs, id, cacheFile string) (data []byte, err error) { var resp *http.Response resp, err = c.client.Client.Get(c.GetDownloadURL(hs, id)) 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 } // GetCachePath gets the path to the cached version of the given homeserver:fileID combination. // The file may or may not exist, use Download() to ensure it has been cached. 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) }