forked from emilevs/label_printer
4a0a055234
git-subtree-dir: faxmachine git-subtree-split: d23200bcfdedb9f8cc57e6a3c65b5ef93fcbfd19
535 lines
12 KiB
Go
535 lines
12 KiB
Go
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 <name>")
|
|
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 <content-URI>")
|
|
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 <on|off>")
|
|
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 <on|off>")
|
|
}
|
|
|
|
case "dithering":
|
|
if len(args) != 2 {
|
|
msg.Reply(ctx, "Usage: 📠 dithering <on|off>")
|
|
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 <on|off>")
|
|
}
|
|
|
|
case "concise":
|
|
if len(args) != 2 {
|
|
msg.Reply(ctx, "Usage: 📠 concise <on|off>")
|
|
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 <on|off>")
|
|
}
|
|
|
|
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)
|
|
}
|