package matrix import ( "context" "fmt" _ "image/gif" _ "image/jpeg" _ "image/png" "log" "log/slog" "strings" "time" "github.com/google/shlex" "github.com/lestrrat-go/dither" "github.com/nfnt/resize" _ "golang.org/x/image/webp" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "git.sr.ht/~guacamolie/faxmachine/config" "git.sr.ht/~guacamolie/faxmachine/escpos" "git.sr.ht/~guacamolie/faxmachine/printjob" ) var faxmachineStatusEventType = event.NewEventType("nl.guacamolie.faxmachine.status") type faxmachineStatusContent struct { Status event.Presence `json:"status"` } var faxmachineConfigEventType = event.NewEventType("nl.guacamolie.faxmachine.config") type roomConfig struct { SendStatusMessages bool `json:"send_status_messages"` SkipDithering bool `json:"skip_dithering"` Concise bool `json:"concise"` } func (rc *roomConfig) load(ctx context.Context, r Room) error { return r.GetAccountData(ctx, faxmachineConfigEventType.Type, rc) } func (rc *roomConfig) save(ctx context.Context, r Room) error { return r.SetAccountData(ctx, faxmachineConfigEventType.Type, rc) } type Bot struct { matrix *Matrix users map[id.UserID]config.MatrixUser adminRoom Room logger *slog.Logger } func NewBot(ctx context.Context, conf config.Matrix, logger *slog.Logger) (*Bot, error) { m, err := NewMatrix(ctx, conf) if err != nil { return nil, err } users := map[id.UserID]config.MatrixUser{} for k, mu := range conf.Users { users[id.UserID(k)] = mu } var adminRoom Room if strings.HasPrefix(conf.AdminRoom, "#") { room, err := m.ResolveAlias(ctx, id.RoomAlias(conf.AdminRoom)) if err != nil { return nil, fmt.Errorf("couldn't resolve room alias of admin room: %w", err) } adminRoom = room } else if strings.HasPrefix(conf.AdminRoom, "!") { adminRoom = m.Room(id.RoomID(conf.AdminRoom)) } b := &Bot{ matrix: m, users: users, adminRoom: adminRoom, logger: logger, } b.matrix.OnInvite(func(ctx context.Context, i Invite) { if i.RoomID == b.adminRoom.ID { if err := i.Accept(ctx); err != nil { log.Printf("failed to join admin room: %v\n", err) } return } if b.isAuthorized(i.Sender) { if err := i.Accept(ctx); err != nil { log.Printf("failed to join user room: %v\n", err) } return } i.Decline(ctx) }) b.matrix.OnMessage(func(ctx context.Context, msg Message) { if msg.Room() != b.adminRoom { return } if msg, ok := msg.(*TextMessage); ok { if !strings.HasPrefix(msg.Body(), "!") { return } b.execAdminCommand(ctx, msg) } }) return b, nil } func (b *Bot) Subscribe(ctx context.Context, queue chan<- printjob.Job) { b.matrix.OnMessage(func(ctx context.Context, msg Message) { if msg.Room() == b.adminRoom { return } msg.SendReceipt(ctx) if msg, ok := msg.(*TextMessage); ok { if strings.HasPrefix(msg.Body(), "📠") { b.handleCommand(ctx, msg) return } } b.logger.Info("received print job", slog.Group("event", "id", msg.ID(), "sender", msg.Sender(), "room", msg.Room().ID, ), ) var config roomConfig config.load(ctx, msg.Room()) queue <- &job{msg: msg, config: config} }) go func() { err := b.matrix.Client.SyncWithContext(ctx) slog.Error("Matix sync stopped", "error", err) }() } func (b *Bot) execAdminCommand(ctx context.Context, msg *TextMessage) { cmd, err := shlex.Split(msg.Body()) if err != nil { msg.Room().Send(ctx, fmt.Sprintf("Couldn't parse command: %v", err)) return } switch cmd[0] { case "!displayname": if len(cmd) <= 1 { msg.Room().Send(ctx, "SYNTAX: !displayname ") return } if err := b.matrix.Client.SetDisplayName(ctx, cmd[1]); err != nil { msg.Room().Send(ctx, fmt.Sprintf("Couldn't update display name: %v", err)) return } msg.Room().Send(ctx, "I have updated my display name") case "!avatar": if len(cmd) <= 1 { msg.Room().Send(ctx, "SYNTAX: !avatar ") return } uri, err := id.ParseContentURI(cmd[1]) if err != nil { msg.Room().Send(ctx, fmt.Sprintf("Invalid content URI: %v", err)) return } if err := b.matrix.Client.SetAvatarURL(ctx, uri); err != nil { msg.Room().Send(ctx, fmt.Sprintf("Failed to set avatar: %v", err)) return } msg.Room().Send(ctx, "Avatar updated successfully") case "!encrypt": rooms, err := b.matrix.JoinedRooms(ctx) if err != nil { msg.Room().Send(ctx, fmt.Sprintf("Failed to query joined rooms: %v", err)) } for i, room := range rooms { // Don't trigger rate limits time.Sleep(100 * time.Millisecond) prefix := fmt.Sprintf("[%02d/%02d] %s", i+1, len(rooms), room.ID) encrypted, err := room.IsEncrypted(ctx) if err != nil { msg.Room().Send(ctx, fmt.Sprintf("%s: failed to get encryption status: %v", prefix, err)) continue } if encrypted { msg.Room().Send(ctx, fmt.Sprintf("%s: encryption already enabled", prefix)) continue } if err := room.EnableEncryption(ctx); err != nil { msg.Room().Send(ctx, fmt.Sprintf("%s: failed to enable encryption: %v", prefix, err)) continue } } default: msg.Room().Send(ctx, "Unrecognized command") } } func (b *Bot) handleCommand(ctx context.Context, msg *TextMessage) { command := strings.TrimPrefix(msg.Body(), "📠") args := strings.Fields(command) if len(args) == 0 { msg.Reply(ctx, "Hello?") return } var config roomConfig config.load(ctx, msg.Room()) switch args[0] { case "ping": msg.Reply(ctx, "pong!") case "send-status": if len(args) != 2 { msg.Reply(ctx, "Usage: 📠 send-status ") return } switch args[1] { case "on": config.SendStatusMessages = true if err := config.save(ctx, msg.Room()); err != nil { msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) return } msg.Reply(ctx, "I will tell you whenever I lose or gain connection with the printer.") case "off": config.SendStatusMessages = false if err := config.save(ctx, msg.Room()); err != nil { msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) return } msg.Reply(ctx, "I won't tell you anymore if I lose or gain connection with the printer.") default: msg.Reply(ctx, "Usage: 📠 send-status ") } case "dithering": if len(args) != 2 { msg.Reply(ctx, "Usage: 📠 dithering ") return } switch args[1] { case "on": config.SkipDithering = false if err := config.save(ctx, msg.Room()); err != nil { msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) return } msg.Reply(ctx, "I have enabled dithering") case "off": config.SkipDithering = true if err := config.save(ctx, msg.Room()); err != nil { msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) return } msg.Reply(ctx, "I have disabled dithering") default: msg.Reply(ctx, "Usage: 📠 dithering ") } case "concise": if len(args) != 2 { msg.Reply(ctx, "Usage: 📠 concise ") return } switch args[1] { case "on": config.Concise = true if err := config.save(ctx, msg.Room()); err != nil { msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) return } msg.React(ctx, "✅") case "off": config.Concise = false if err := config.save(ctx, msg.Room()); err != nil { msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) return } msg.Reply(ctx, "Your command has been received loud and clear!"+ "I, Faxmachine the great, hereby announce that I shall no longer be concise when formulating my responses.") default: msg.Reply(ctx, "Usage: 📠 concise ") } default: msg.Reply(ctx, "Unrecognized command") } } func (b *Bot) isAuthorized(user id.UserID) bool { _, ok := b.users[user] return ok } type job struct { msg Message config roomConfig stopTyping func() } func (j *job) keepTypingUntilDone(ctx context.Context) { for { err := j.msg.Room().StartTyping(ctx, 4*time.Second) if err != nil { slog.Error("error sending typing start", "error", err) } select { case <-time.After(2 * time.Second): continue case <-ctx.Done(): ctx := context.WithoutCancel(ctx) err := j.msg.Room().StopTyping(ctx) if err != nil { slog.Error("error sending typing stop", "error", err) } return } } } func (j *job) Sender() string { return j.msg.Sender().String() } func (j *job) Time() time.Time { return j.msg.Time() } func (j *job) Description() string { switch msg := j.msg.(type) { case *TextMessage: return msg.Body() case *ImageMessage: if c, ok := msg.Caption(); ok { return c } return msg.FileName() default: return "" } } func (j *job) Reply(ctx context.Context, msg string) error { return j.msg.Reply(ctx, msg) } func (j *job) Print(ctx context.Context, p *escpos.Printer) error { var typingCtx context.Context typingCtx, j.stopTyping = context.WithCancel(ctx) go j.keepTypingUntilDone(typingCtx) switch msg := j.msg.(type) { case *TextMessage: _, err := fmt.Fprintf(p, "%s\n", msg.Body()) return err case *ImageMessage: img, err := msg.DownloadImage(ctx) if err != nil { return fmt.Errorf("couldn't get image: %w", err) } if !j.config.SkipDithering { img = resize.Resize(512, 0, img, resize.Lanczos3) img = dither.Monochrome(dither.FloydSteinberg, img, 1.18) } if err := p.PrintImage(img); err != nil { return fmt.Errorf("couldn't print image: %w", err) } if caption, ok := msg.Caption(); ok { _, err := fmt.Fprintf(p, "\n%s\n", caption) if err != nil { return fmt.Errorf("couldn't print image caption: %w", err) } } return nil default: return fmt.Errorf("unsupported message type") } } func (j *job) OnPrinted(ctx context.Context) { defer j.stopTyping() var err error if j.config.Concise { err = j.msg.React(ctx, "✅") } else { switch msg := j.msg.(type) { case *TextMessage: err = msg.Reply(ctx, "I have printed these words") case *ImageMessage: if j.config.SkipDithering { err = msg.Reply(ctx, "I have printed this image without dithering") } else { err = msg.Reply(ctx, "I have printed this image") } } } if err != nil { slog.Error("failed to reply", "err", err) } } func (j *job) OnShredded(ctx context.Context, err error) { defer j.stopTyping() reply := fmt.Sprintf("Print failed: %v", err) if err := j.msg.Reply(ctx, reply); err != nil { slog.Error("failed to reply", "err", err) } } func (b *Bot) BecomeOnline(ctx context.Context) { b.matrix.SetPresence(ctx, event.PresenceOnline) rooms, err := b.matrix.JoinedRooms(ctx) if err != nil { log.Printf("failed to query joined rooms: %v", err) } for _, room := range rooms { b.sendStatus(ctx, room, event.PresenceOnline) } } func (b *Bot) BecomeUnavailable(ctx context.Context) { rooms, err := b.matrix.JoinedRooms(ctx) if err != nil { log.Printf("failed to query joined rooms: %v", err) } for _, room := range rooms { b.sendStatus(ctx, room, event.PresenceUnavailable) } b.matrix.SetPresence(ctx, event.PresenceUnavailable) } func (b *Bot) sendStatus(ctx context.Context, room Room, status event.Presence) error { var config roomConfig config.load(ctx, room) if !config.SendStatusMessages { return nil } var roomStatus faxmachineStatusContent if err := room.GetAccountData(ctx, faxmachineStatusEventType.Type, &roomStatus); err != nil { roomStatus.Status = event.PresenceOffline } if roomStatus.Status == status { return nil } b.logger.Debug("sending status message", "status", status, "room", room) roomStatus.Status = status if err := room.SetAccountData(ctx, faxmachineStatusEventType.Type, &roomStatus); err != nil { return fmt.Errorf("failed to set room status: %w", err) } if err := room.SendEmote(ctx, fmt.Sprintf("is now %s", status)); err != nil { return fmt.Errorf("failed to send emote: %w", err) } return nil } func (b *Bot) sendInAdminRoom(ctx context.Context, msg string) error { return b.adminRoom.Send(ctx, msg) }