From 7bc948b0d02579d6ffe426b5e720f3362c107ea9 Mon Sep 17 00:00:00 2001 From: ludmila-lovelace Date: Fri, 18 Apr 2025 09:31:30 +1000 Subject: [PATCH] feat: add toast notifications (#527) --- cmd/state.go | 5 + go.mod | 6 +- go.sum | 13 +- internal/config/config.go | 12 +- internal/config/notifications.go | 25 ++++ internal/notifications/desktop_toast.go | 18 +++ .../notifications/desktop_toast_darwin.go | 19 +++ internal/notifications/notifications.go | 117 ++++++++++++++++++ internal/notifications/renderer.go | 92 ++++++++++++++ 9 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 internal/config/notifications.go create mode 100644 internal/notifications/desktop_toast.go create mode 100644 internal/notifications/desktop_toast_darwin.go create mode 100644 internal/notifications/notifications.go create mode 100644 internal/notifications/renderer.go diff --git a/cmd/state.go b/cmd/state.go index 837a271..1552a32 100644 --- a/cmd/state.go +++ b/cmd/state.go @@ -5,6 +5,7 @@ import ( "log/slog" "runtime" + "github.com/ayn2op/discordo/internal/notifications" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/gateway" "github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver" @@ -84,6 +85,10 @@ func (s *State) onMessageCreate(m *gateway.MessageCreateEvent) { if app.guildsTree.selectedChannelID.IsValid() && app.guildsTree.selectedChannelID == m.ChannelID { app.messagesText.createMessage(m.Message) } + + if err := notifications.HandleIncomingMessage(*s.State, m, app.cfg); err != nil { + slog.Error("Notification failed", "err", err) + } } func (s *State) onMessageDelete(m *gateway.MessageDeleteEvent) { diff --git a/go.mod b/go.mod index 312dbc4..e5b33c6 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.24.0 require ( github.com/BurntSushi/toml v1.5.0 github.com/atotto/clipboard v0.1.4 + github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb github.com/diamondburned/arikawa/v3 v3.4.0 github.com/diamondburned/ningen/v3 v3.0.1-0.20240808103805-f1a24c0da3d8 github.com/gdamore/tcell/v2 v2.8.1 + github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 github.com/rivo/tview v0.0.0-20250325173046-7b72abf45814 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.9.1 @@ -19,16 +21,18 @@ require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 github.com/spf13/pflag v1.0.6 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/twmb/murmur3 v1.1.8 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/sys v0.31.0 // indirect diff --git a/go.sum b/go.sum index f33257a..b9daf61 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw= +github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y= github.com/diamondburned/arikawa/v3 v3.4.0 h1:wI3Qv8h2E2dkeddF1I35nv4T6OQ3RtA21rbghW/fnd0= github.com/diamondburned/arikawa/v3 v3.4.0/go.mod h1:WVkbdenUfsCCkptIlqSglF4eo2/HSXv74eCqGnOZaYY= github.com/diamondburned/ningen/v3 v3.0.1-0.20240808103805-f1a24c0da3d8 h1:wgvgSzI4N+BHhCWhGhHKfW4gm0UtBVptiDaBGPdHmcs= @@ -44,8 +46,12 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= +github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -97,6 +103,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -111,8 +119,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= @@ -125,6 +131,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -231,6 +239,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/config/config.go b/internal/config/config.go index 3b6d82c..0e7e9fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,9 +32,10 @@ type ( Timestamps bool `toml:"timestamps"` TimestampsFormat string `toml:"timestamps_format"` - Identify Identify `toml:"identify"` - Keys Keys `toml:"keys"` - Theme Theme `toml:"theme"` + Identify Identify `toml:"identify"` + Keys Keys `toml:"keys"` + Theme Theme `toml:"theme"` + Notifications Notifications `toml:"notifications"` } ) @@ -57,8 +58,9 @@ func defaultConfig() *Config { UserAgent: consts.UserAgent, }, - Keys: defaultKeys(), - Theme: defaultTheme(), + Keys: defaultKeys(), + Theme: defaultTheme(), + Notifications: defaultNotifications(), } } diff --git a/internal/config/notifications.go b/internal/config/notifications.go new file mode 100644 index 0000000..d024f35 --- /dev/null +++ b/internal/config/notifications.go @@ -0,0 +1,25 @@ +package config + +type ( + Notifications struct { + Enabled bool `toml:"enabled"` + Duration int `toml:"duration"` + Sound Sound `toml:"sound"` + } + + Sound struct { + Enabled bool `toml:"enabled"` + OnlyOnPing bool `toml:"only_on_ping"` + } +) + +func defaultNotifications() Notifications { + return Notifications{ + Enabled: true, + Duration: 500, + Sound: Sound{ + Enabled: true, + OnlyOnPing: true, + }, + } +} diff --git a/internal/notifications/desktop_toast.go b/internal/notifications/desktop_toast.go new file mode 100644 index 0000000..2a4dcd6 --- /dev/null +++ b/internal/notifications/desktop_toast.go @@ -0,0 +1,18 @@ +//go:build !darwin + +package notifications + +import "github.com/gen2brain/beeep" + +func sendDesktopNotification(title string, body string, image string, playSound bool, duration int) error { + beeep.DefaultDuration = duration + + if err := beeep.Notify(title, body, image); err != nil { + return err + } + + if playSound { + return beeep.Beep(beeep.DefaultFreq, beeep.DefaultDuration) + } + return nil +} diff --git a/internal/notifications/desktop_toast_darwin.go b/internal/notifications/desktop_toast_darwin.go new file mode 100644 index 0000000..917a1b5 --- /dev/null +++ b/internal/notifications/desktop_toast_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package notifications + +import ( + gosxnotifier "github.com/deckarep/gosx-notifier" +) + +func sendDesktopNotification(title string, body string, image string, playSound bool, _ int) error { + notify := gosxnotifier.NewNotification(body) + notify.Title = title + notify.ContentImage = image + + if playSound { + notify.Sound = gosxnotifier.Default + } + + return notify.Push() +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go new file mode 100644 index 0000000..9e4b870 --- /dev/null +++ b/internal/notifications/notifications.go @@ -0,0 +1,117 @@ +package notifications + +import ( + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/consts" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/gateway" + "github.com/diamondburned/ningen/v3" + "github.com/diamondburned/ningen/v3/discordmd" +) + +func HandleIncomingMessage(s ningen.State, m *gateway.MessageCreateEvent, cfg *config.Config) error { + // Only display notification if enabled and unmuted + if !cfg.Notifications.Enabled || s.MessageMentions(&m.Message) == 0 || cfg.Identify.Status == discord.DoNotDisturbStatus { + return nil + } + + ch, err := s.Cabinet.Channel(m.ChannelID) + if err != nil { + return err + } + + isChannelDM := ch.Type == discord.DirectMessage || ch.Type == discord.GroupDM + guild := (*discord.Guild)(nil) + if !isChannelDM { + guild, err = s.Cabinet.Guild(ch.GuildID) + if err != nil { + return err + } + } + + // Render message + src := []byte(m.Content) + ast := discordmd.ParseWithMessage(src, *s.Cabinet, &m.Message, false) + buff := strings.Builder{} + if err := PlainTextRenderer.Render(&buff, src, ast); err != nil { + return err + } + + // Handle sent files + notifContent := buff.String() + if m.Message.Content == "" && len(m.Message.Attachments) > 0 { + notifContent = "Uploaded " + m.Message.Attachments[0].Filename + } + + if m.Author.DisplayOrTag() == "" || notifContent == "" { + return nil + } + + notifTitle := m.Author.DisplayOrTag() + if guild != nil { + member, _ := s.Member(ch.GuildID, m.Author.ID) + if member.Nick != "" { + notifTitle = member.Nick + } + + notifTitle = notifTitle + " (#" + ch.Name + ", " + guild.Name + ")" + } + + hash := m.Author.Avatar + if hash == "" { + hash = "default" + } + imagePath, err := getCachedProfileImage(hash, m.Author.AvatarURLWithType(discord.PNGImage)) + if err != nil { + slog.Error("Failed to retrieve avatar image for notification", "err", err) + } + + shouldChime := cfg.Notifications.Sound.Enabled && (!cfg.Notifications.Sound.OnlyOnPing || (isChannelDM || s.MessageMentions(&m.Message) == 3)) + if err := sendDesktopNotification(notifTitle, notifContent, imagePath, shouldChime, cfg.Notifications.Duration); err != nil { + return err + } + + return nil +} + +func getCachedProfileImage(avatarHash discord.Hash, url string) (string, error) { + path, err := os.UserCacheDir() + if err != nil { + return "", err + } + + path = filepath.Join(path, consts.Name) + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return "", err + } + + path = filepath.Join(path, avatarHash+".png") + if _, err := os.Stat(path); err == nil { + return path, nil + } + + image, err := os.Create(path) + if err != nil { + return "", err + } + defer image.Close() + + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if _, err := io.Copy(image, resp.Body); err != nil { + return "", err + } + + return path, nil +} diff --git a/internal/notifications/renderer.go b/internal/notifications/renderer.go new file mode 100644 index 0000000..e45f396 --- /dev/null +++ b/internal/notifications/renderer.go @@ -0,0 +1,92 @@ +package notifications + +import ( + "io" + + "github.com/diamondburned/ningen/v3/discordmd" + "github.com/yuin/goldmark/ast" + gmr "github.com/yuin/goldmark/renderer" +) + +// Using a modified version of the discordmd BasicRenderer +var PlainTextRenderer = newRenderer() + +type renderer struct { + config *gmr.Config +} + +func newRenderer() *renderer { + config := gmr.NewConfig() + return &renderer{config} +} + +func (r *renderer) AddOptions(opts ...gmr.Option) { + for _, opt := range opts { + opt.SetConfig(r.config) + } +} + +func (r *renderer) Render(w io.Writer, source []byte, n ast.Node) error { + return ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + switch n := n.(type) { + case *ast.Document: + // noop + case *ast.Blockquote: + io.WriteString(w, "\"") + case *ast.Heading: + io.WriteString(w, "\n") + case *ast.FencedCodeBlock: + io.WriteString(w, "\n") + + if entering { + for i := range n.Lines().Len() { + line := n.Lines().At(i) + io.WriteString(w, "| ") + w.Write(line.Value(source)) + } + } + case *ast.AutoLink: + if entering { + w.Write(n.URL(source)) + } + case *ast.Link: + if !entering { + io.WriteString(w, " ("+string(n.Destination)+")") + } + case *discordmd.Inline: + if n.Attr&discordmd.AttrSpoiler != 0 { + if entering { + io.WriteString(w, "*spoiler*") + } + return ast.WalkSkipChildren, nil + } + case *ast.Text: + if entering { + w.Write(n.Segment.Value(source)) + switch { + case n.HardLineBreak(): + io.WriteString(w, "\n\n") + case n.SoftLineBreak(): + io.WriteString(w, "\n") + } + } + case *discordmd.Mention: + if entering { + switch { + case n.Channel != nil: + io.WriteString(w, "#"+n.Channel.Name) + case n.GuildUser != nil: + io.WriteString(w, "@"+n.GuildUser.Username) + case n.GuildRole != nil: + io.WriteString(w, "@"+n.GuildRole.Name) + } + } + case *discordmd.Emoji: + if entering { + io.WriteString(w, ":"+string(n.Name)+":") + } + } + + return ast.WalkContinue, nil + }) +}