label_printer/epson/faxmachine/matrix/matrix.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
}