package ntfy import ( "context" "encoding/json" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "log" "log/slog" "net/http" "strings" "time" "github.com/lestrrat-go/dither" "github.com/nfnt/resize" _ "golang.org/x/image/webp" "nhooyr.io/websocket" "git.sr.ht/~guacamolie/faxmachine/config" "git.sr.ht/~guacamolie/faxmachine/escpos" "git.sr.ht/~guacamolie/faxmachine/printjob" ) type Instance struct { logger *slog.Logger host string accessToken string topics map[string]config.Topic } func NewInstance(conf config.Ntfy, logger *slog.Logger) *Instance { return &Instance{ host: conf.Host, accessToken: conf.AccessToken, topics: conf.Topics, logger: logger, } } type message struct { Event string `json:"event"` Topic string `json:"topic"` Message string `json:"message"` Time int64 `json:"time"` Attachment attachment `json:"attachment"` } type attachment struct { Name string `json:"name"` Type string `json:"type"` Size int `json:"size"` URL string `json:"url"` } func (i *Instance) Subscribe(ctx context.Context, queue chan<- printjob.Job) error { topicNames := make([]string, 0, len(i.topics)) for k := range i.topics { topicNames = append(topicNames, k) } uri := fmt.Sprintf("wss://%s/%s/ws", i.host, strings.Join(topicNames, ",")) header := make(http.Header) if i.accessToken != "" { header.Set("Authorization", fmt.Sprintf("Bearer %s", i.accessToken)) } conn, _, err := websocket.Dial(ctx, uri, &websocket.DialOptions{ HTTPHeader: header, }) if err != nil { return fmt.Errorf("couldn't connect to instance: %v", err) } go i.drain(ctx, conn, queue) return nil } func (i *Instance) drain(ctx context.Context, conn *websocket.Conn, queue chan<- printjob.Job) { for { _, data, err := conn.Read(ctx) if err != nil { log.Printf("failed to read: %v\n", err) return } var msg message if err := json.Unmarshal(data, &msg); err != nil { log.Printf("failed to unmarshal: %v\n", err) return } if msg.Event != "message" { continue } sender := i.topics[msg.Topic].Name i.logger.Info("received print job", slog.Group("topic", slog.String("topic", msg.Topic), slog.String("name", sender), ), slog.Group("message", slog.String("message", msg.Message), slog.String("attachment", msg.Attachment.URL), ), ) select { case queue <- &job{sender, msg}: default: } } } type job struct { sender string msg message } func (j *job) Sender() string { return j.msg.Topic } func (j *job) Time() time.Time { return time.Unix(j.msg.Time, 0) } func (j *job) Description() string { switch { case j.msg.Message != "": return j.msg.Message case j.msg.Attachment.Name != "": return j.msg.Attachment.Name default: return "" } } func (j *job) Print(ctx context.Context, p *escpos.Printer) error { text := j.msg.Message if len(text) > 2000 { text = text[:2000] } if strings.HasPrefix(text, "You received a file: ") { text = "" } text, _ = strings.CutPrefix(text, "[guac::prefix]") if j.msg.Attachment.URL != "" { resp, err := http.Get(j.msg.Attachment.URL) if err != nil { return fmt.Errorf("failed to get attachment: %w", err) } img, _, err := image.Decode(resp.Body) if err != nil { return fmt.Errorf("failed to decode image: %w", err) } 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("failed to print image: %w", err) } } if text != "" { fmt.Fprintf(p, "\n%s\n", text) } return nil } func (j *job) OnPrinted(ctx context.Context) { // Nothing to do since we can't reply to the job submitter. } func (j *job) OnShredded(ctx context.Context, err error) { // Nothing to do since we can't reply to the job submitter. }