Repository: ayn2op/discordo
Pull request #527: feat: add toast notifications
Author: login: ludmila-lovelace
Created: 2025-04-14T15:11:33Z | Merged: 2025-04-17T23:31:31Z

create a toast notification (using https://github.com/gen2brain/beeep) when a message is received. supports custom configuration, guild nicknames, muted channels, and avatar caching

closes #466 

## Screenshots
### Server message
![image](https://github.com/user-attachments/assets/2f717676-e0dc-4eee-a70b-ce92fdd15cb6)

### Private message (with attachment)
![image](https://github.com/user-attachments/assets/c99be699-11fa-4cdd-9d7a-fae8e51ccb13)

Linked issue #466: [Feature Request] Message-/ping-notifications
I would love to see a notification for new incoming direct-messages or/and server-pings, I haven't been able to receive notifications for now (using linux KDE plasma/steam deck in desktop mode) but would love to see a implementation of system-wide notifications if possible!

Diff:
diff --git a/cmd/state.go b/cmd/state.go
index 837a27107..1552a329f 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 312dbc49b..e5b33c630 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 f33257a66..b9daf618b 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 3b6d82c8f..0e7e9fbde 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 000000000..d024f3502
--- /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 000000000..2a4dcd61b
--- /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 000000000..917a1b5ce
--- /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 000000000..9e4b8707c
--- /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 000000000..e45f396b0
--- /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
+	})
+}


Reviews:
user: login: ayn2op
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-16T22:31:53Z
user: login: ayn2op
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-16T22:33:43Z
user: login: ayn2op
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-16T22:36:37Z
user: login: ayn2op
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-16T22:45:13Z
user: login: ludmila-lovelace
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:46:23Z
user: login: ayn2op
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T22:06:06Z
user: login: ayn2op
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T22:13:48Z
user: login: ayn2op
body: LGTM! 🚀 🥳 
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-17T23:25:18Z

Review comments:
diff_hunk: @@ -0,0 +1,25 @@
+package config
+
+type (
+	Notifications struct {
+		Enabled   bool   `toml:"enabled"`
+		Duration  int    `toml:"duration"`
+		PlayChime Chimes `toml:"play_chime"`
path: internal/config/notifications.go
user: login: ayn2op
body: What do you think about renaming this field to `sounds` (play_chime feels too specific).
created_at: 2025-04-16T22:31:53Z
author_association: OWNER
diff_hunk: @@ -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.Ping
path: internal/notifications/desktop_toast_darwin.go
user: login: ayn2op
body: Instead of `gosxnotifier.Ping`, `Default` might be far more applicable here.
Reference: <https://github.com/mirtchovski/gosxnotifier/blob/master/gosx-notifier.go#L12>
created_at: 2025-04-16T22:33:42Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,118 @@
+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.PlayChime.Enabled && (!cfg.Notifications.PlayChime.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()
+
+	_, err = io.Copy(image, resp.Body)
+	if err != nil {
+		return "", err
+	}
path: internal/notifications/notifications.go
user: login: ayn2op
body: You can inline this if statement:
```suggestion
	if _, err := io.Copy(image, resp.Body); err != nil {
		return "", err
	}
```
created_at: 2025-04-16T22:36:37Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,118 @@
+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.PlayChime.Enabled && (!cfg.Notifications.PlayChime.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")
path: internal/notifications/notifications.go
user: login: ayn2op
body: ```suggestion
	path = filepath.Join(path, avatarHash, ".png")
```
created_at: 2025-04-16T22:45:13Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,118 @@
+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.PlayChime.Enabled && (!cfg.Notifications.PlayChime.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")
path: internal/notifications/notifications.go
user: login: ludmila-lovelace
body: i don't think `filepath.Join` treats extensions differently to regular path arguments (see [example](https://go.dev/play/p/1g7LCprw8pF)) so i think this one should be left as-is
created_at: 2025-04-17T06:46:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+package config
+
+type (
+	Notifications struct {
+		Enabled  bool   `toml:"enabled"`
+		Duration int    `toml:"duration"`
+		Sounds   Sounds `toml:"sounds"`
path: internal/config/notifications.go
user: login: ayn2op
body: Now that I think about it, it might be better to rename this field to `sound` (representing singular noun instead).
created_at: 2025-04-17T22:06:06Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,25 @@
+package config
+
+type (
+	Notifications struct {
+		Enabled  bool   `toml:"enabled"`
+		Duration int    `toml:"duration"`
+		Sounds   Sounds `toml:"sounds"`
path: internal/config/notifications.go
user: login: ayn2op
body: Everything else looks good, this PR can be merged after this change is addressed.
created_at: 2025-04-17T22:13:48Z
author_association: OWNER

PR comments:
user: login: ayn2op
created_at: 2025-04-14T21:50:16Z
body: I just ran this locally on macOS; it does not display the notification banner/toast, but it does play the macOS beep sound.
author_association: OWNER
user: login: ludmila-lovelace
created_at: 2025-04-14T23:32:37Z
body: @ayn2op might be related to this - https://github.com/gen2brain/beeep/issues/67? let me know if the fix mentioned there works for macOS

i will try to test on Windows later today as well

edit: beep sound and banner are both working on Windows 11
![image](https://github.com/user-attachments/assets/8833a9fa-e786-4844-9909-2bb099152747)
author_association: CONTRIBUTOR
user: login: ludmila-lovelace
created_at: 2025-04-16T02:23:05Z
body: update: i set up a macOS VM to test this and, while the workaround fixes the issue, it's a bit tedious and the notification isn't very pretty. instead, i'm thinking we can use [gosx-notifier](https://github.com/deckarep/gosx-notifier) for Darwin builds (the library is unmaintained but i wasn't able to find a better alternative)

on first notification:
![Screenshot_20250416_120517](https://github.com/user-attachments/assets/cbd5468d-ec3b-4339-b20f-6c37dab185f1)

after allowing:
![Screenshot_20250416_115948](https://github.com/user-attachments/assets/5a207cab-f173-41b8-ba01-98daee05e4cb)
author_association: CONTRIBUTOR