package matrix import ( "context" "encoding/json" "fmt" "image" "io" "log" "os" "time" "git.sr.ht/~guacamolie/faxmachine/config" _ "github.com/mattn/go-sqlite3" "github.com/rs/zerolog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) var storeEventType = event.NewEventType("nl.guacamolie.faxmachine.store") type Matrix struct { Client *mautrix.Client inviteHandlers []InviteHandler messageHandlers []MessageHandler } func NewMatrix(ctx context.Context, conf config.Matrix) (*Matrix, error) { me := id.UserID(conf.UserID) client, err := mautrix.NewClient(me.Homeserver(), me, conf.AccessToken) if err != nil { return nil, fmt.Errorf("failed to initalize client") } if conf.MautrixLog != "" { out, err := os.OpenFile(conf.MautrixLog, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) if err != nil { return nil, fmt.Errorf("failed to mautrix log file %q: %w", conf.MautrixLog, err) } log := zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { w.Out = out w.TimeFormat = time.RFC3339 })).With().Timestamp().Logger() client.Log = log } client.DeviceID = id.DeviceID(conf.DeviceID) m := &Matrix{ Client: client, } syncer := mautrix.NewDefaultSyncer() // Prevent infinite loop of store events syncer.FilterJSON = &mautrix.Filter{ AccountData: mautrix.FilterPart{ Limit: 20, NotTypes: []event.Type{storeEventType}, }, } syncer.OnEventType(event.StateMember, m.handleStateMember) syncer.OnEventType(event.EventMessage, m.handleMessage) syncer.OnEvent(func(ctx context.Context, evt *event.Event) { if evt.Mautrix.EventSource == event.SourcePresence { // Not interesting enough to log return } pretty, err := json.MarshalIndent(evt, "", "\t") if err != nil { log.Printf("failed to marshal: %v\n", err) } log.Printf("OnEvent %s %s\n", evt.Mautrix.EventSource, pretty) }) client.Syncer = syncer store := mautrix.NewAccountDataStore(storeEventType.Type, client) client.Store = store if len(conf.FeelgoodKey) > 0 && conf.Database != "" { cryptoHelper, err := cryptohelper.NewCryptoHelper(m.Client, []byte(conf.FeelgoodKey), conf.Database) if err != nil { return nil, fmt.Errorf("failed to create crypto helper: %w", err) } if err := cryptoHelper.Init(ctx); err != nil { return nil, fmt.Errorf("failed to initalize crypto helper: %w", err) } m.Client.Crypto = cryptoHelper } return m, nil } func (m *Matrix) JoinedRooms(ctx context.Context) ([]Room, error) { resp, err := m.Client.JoinedRooms(ctx) if err != nil { return nil, err } rooms := []Room{} for _, roomID := range resp.JoinedRooms { rooms = append(rooms, m.Room(roomID)) } return rooms, nil } func (m *Matrix) SetPresence(ctx context.Context, status event.Presence) { m.Client.SyncPresence = event.PresenceOnline m.Client.SetPresence(ctx, event.PresenceOnline) } func (m *Matrix) Room(id id.RoomID) Room { return Room{ parent: m, ID: id, } } func (m *Matrix) ResolveAlias(ctx context.Context, alias id.RoomAlias) (Room, error) { resp, err := m.Client.ResolveAlias(ctx, alias) if err != nil { return Room{}, err } return m.Room(resp.RoomID), nil } type Invite struct { parent *Matrix Sender id.UserID RoomID id.RoomID } func (i *Invite) Accept(ctx context.Context) error { _, err := i.parent.Client.JoinRoomByID(ctx, i.RoomID) return err } func (i *Invite) Decline(ctx context.Context) error { _, err := i.parent.Client.LeaveRoom(ctx, i.RoomID) return err } type InviteHandler func(ctx context.Context, i Invite) func (m *Matrix) OnInvite(handler InviteHandler) { m.inviteHandlers = append(m.inviteHandlers, handler) } func (m *Matrix) handleStateMember(ctx context.Context, evt *event.Event) { if evt.Mautrix.EventSource&(event.SourceInvite|event.SourceState) == 0 { return } if evt.GetStateKey() != m.Client.UserID.String() { return } content := evt.Content.AsMember() if content.Membership != event.MembershipInvite { return } invite := Invite{ parent: m, Sender: evt.Sender, RoomID: evt.RoomID, } for _, handler := range m.inviteHandlers { handler(ctx, invite) } } type MessageHandler func(ctx context.Context, m Message) func (m *Matrix) OnMessage(handler MessageHandler) { m.messageHandlers = append(m.messageHandlers, handler) } func (m *Matrix) handleMessage(ctx context.Context, evt *event.Event) { if evt.Sender == m.Client.UserID { return } baseMessage := message{ parent: m, evt: evt, } var message Message = &baseMessage switch evt.Content.AsMessage().MsgType { case event.MsgText: message = &TextMessage{baseMessage} case event.MsgImage: message = &ImageMessage{baseMessage} } for _, handler := range m.messageHandlers { handler(ctx, message) } } type Message interface { ID() id.EventID Time() time.Time Sender() id.UserID Room() Room SendReceipt(ctx context.Context) error Reply(ctx context.Context, reply string) error React(ctx context.Context, reaction string) error } type message struct { parent *Matrix evt *event.Event } func (m *message) ID() id.EventID { return m.evt.ID } func (m *message) Time() time.Time { return time.UnixMilli(m.evt.Timestamp) } func (m *message) Sender() id.UserID { return m.evt.Sender } func (m *message) Room() Room { return m.parent.Room(m.evt.RoomID) } func (m *message) SendReceipt(ctx context.Context) error { return m.parent.Client. SendReceipt(ctx, m.evt.RoomID, m.evt.ID, event.ReceiptTypeRead, nil) } func (m *message) React(ctx context.Context, reaction string) error { _, err := m.parent.Client.SendReaction(ctx, m.evt.RoomID, m.evt.ID, reaction) return err } func (m *message) Reply(ctx context.Context, reply string) error { content := &event.MessageEventContent{ MsgType: event.MsgText, Body: reply, } // mautrix-go currently does not generate the correct fallback for m.file // and related events: // // https://spec.matrix.org/v1.8/client-server-api/#fallback-for-mimage-mvideo-maudio-and-mfile // // TODO: consider sending a patch to support this. content.SetReply(m.evt) _, err := m.parent.Client.SendMessageEvent(ctx, m.evt.RoomID, event.EventMessage, content) return err } type TextMessage struct { message } func (tm *TextMessage) Body() string { msg := tm.evt.Content.AsMessage() if msg.MsgType != event.MsgText { panic(fmt.Sprintf("expected msgtype of m.text, got %s", msg.MsgType)) } return msg.Body } type ImageMessage struct { message } func (im *ImageMessage) DownloadImage(ctx context.Context) (image.Image, error) { msg := im.evt.Content.AsMessage() if msg.MsgType != event.MsgImage { panic(fmt.Sprintf("expected msgtype of m.image, got %s", msg.MsgType)) } var imageReader io.ReadCloser if msg.File != nil { if err := msg.File.PrepareForDecryption(); err != nil { return nil, fmt.Errorf("unable to decrypt: %w", err) } imageURI, err := msg.File.URL.Parse() if err != nil { return nil, fmt.Errorf("invalid URI: %v", err) } reader, err := im.parent.Client.Download(ctx, imageURI) if err != nil { return nil, fmt.Errorf("download failed: %w", err) } imageReader = msg.File.DecryptStream(reader) } else { imageURI, err := msg.URL.Parse() if err != nil { return nil, fmt.Errorf("invalid URI: %v", err) } reader, err := im.parent.Client.Download(ctx, imageURI) if err != nil { return nil, fmt.Errorf("download failed: %w", err) } imageReader = reader } img, _, err := image.Decode(imageReader) if err != nil { return nil, fmt.Errorf("decode failed: %w", err) } return img, nil } func (im *ImageMessage) Caption() (string, bool) { msg := im.evt.Content.AsMessage() if msg.MsgType != event.MsgImage { panic(fmt.Sprintf("expected msgtype of m.image, got %s", msg.MsgType)) } // "If the filename is present, and its value is different than body, then // body is considered to be a caption, otherwise body is a filename. format // and formatted_body are only used for captions." // // https://spec.matrix.org/v1.10/client-server-api/#media-captions if msg.FileName != "" && msg.FileName != msg.Body { return msg.Body, true } else { return "", false } } func (im *ImageMessage) FileName() string { msg := im.evt.Content.AsMessage() if msg.MsgType != event.MsgImage { panic(fmt.Sprintf("expected msgtype of m.image, got %s", msg.MsgType)) } filename := msg.FileName if filename == "" { filename = msg.Body } return filename } type Room struct { parent *Matrix ID id.RoomID } func (r Room) Send(ctx context.Context, msg string) error { return r.sendMessageEvent(ctx, &event.MessageEventContent{ MsgType: event.MsgText, Body: msg, }) } func (r Room) SendEmote(ctx context.Context, msg string) error { return r.sendMessageEvent(ctx, &event.MessageEventContent{ MsgType: event.MsgEmote, Body: msg, }) } func (r Room) sendMessageEvent(ctx context.Context, evt *event.MessageEventContent) error { _, err := r.parent.Client.SendMessageEvent(ctx, r.ID, event.EventMessage, evt) return err } func (r Room) GetAccountData(ctx context.Context, name string, output any) error { return r.parent.Client.GetRoomAccountData(ctx, r.ID, name, output) } func (r Room) SetAccountData(ctx context.Context, name string, data any) error { return r.parent.Client.SetRoomAccountData(ctx, r.ID, name, data) } func (r Room) IsEncrypted(ctx context.Context) (bool, error) { return r.parent.Client.StateStore.IsEncrypted(ctx, r.ID) } func (r Room) EnableEncryption(ctx context.Context) error { _, err := r.parent.Client.SendStateEvent(ctx, r.ID, event.StateEncryption, "", event.EncryptionEventContent{ Algorithm: "m.megolm.v1.aes-sha2", RotationPeriodMillis: 604800000, RotationPeriodMessages: 100, }) return err } func (r Room) StartTyping(ctx context.Context, timeout time.Duration) error { _, err := r.parent.Client.UserTyping(ctx, r.ID, true, timeout) return err } func (r Room) StopTyping(ctx context.Context) error { _, err := r.parent.Client.UserTyping(ctx, r.ID, false, 0) return err }