label_printer/matrix/bot.go
TT-392 4a0a055234 Squashed 'faxmachine/' content from commit d23200b
git-subtree-dir: faxmachine
git-subtree-split: d23200bcfdedb9f8cc57e6a3c65b5ef93fcbfd19
2024-09-12 20:45:00 +02:00

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)
}