422 lines
9.9 KiB
Go
422 lines
9.9 KiB
Go
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
|
|
}
|