feat: add toast notifications (#527)

This commit is contained in:
ludmila-lovelace 2025-04-18 09:31:30 +10:00 • committed by GitHub
parent c21ba4ed8d
commit 7bc948b0d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 299 additions and 8 deletions

View file

@ -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) {

6
go.mod
View file

@ -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

13
go.sum
View file

@ -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=

View file

@ -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(),
}
}

View file

@ -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,
},
}
}

View file

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

View file

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

View file

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

View file

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