Add support of Chat Notifications

This commit is contained in:
Dmitrii Okunev
2024-10-28 19:33:14 +00:00
parent e0bc4885ba
commit 0d5fbafa24
7 changed files with 165 additions and 79 deletions

1
go.mod
View File

@@ -205,6 +205,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/ebitengine/oto/v3 v3.3.1
github.com/getsentry/sentry-go v0.28.1
github.com/go-andiamo/splitter v1.2.5
github.com/go-git/go-git/v5 v5.12.0
github.com/go-ng/xmath v0.0.0-20230704233441-028f5ea62335
github.com/go-yaml/yaml v2.1.0+incompatible

2
go.sum
View File

@@ -227,6 +227,8 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-andiamo/splitter v1.2.5 h1:P3NovWMY2V14TJJSolXBvlOmGSZo3Uz+LtTl2bsV/eY=
github.com/go-andiamo/splitter v1.2.5/go.mod h1:8WHU24t9hcMKU5FXDQb1hysSEC/GPuivIp0uKY1J8gw=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=

View File

@@ -27,6 +27,10 @@ func Eval[T any](
if value == "" {
return result, nil
}
if v, ok := any(value).(T); ok {
return v, nil
}
_, err = fmt.Sscanf(value, "%v", &result)
if err != nil {
return result, fmt.Errorf("unable to scan value '%v' into %T: %w", value, result, err)

View File

@@ -21,6 +21,7 @@ import (
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube"
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
"github.com/xaionaro-go/streamctl/pkg/xsync"
)
type chatUI struct {
@@ -153,6 +154,26 @@ func (ui *chatUI) onReceiveMessage(
ui.List.Refresh()
})
observability.Go(ctx, func() {
notificationsEnabled := xsync.DoR1(ctx, &ui.Panel.configLocker, func() bool {
return ui.Panel.Config.Chat.NotificationsEnabled()
})
if !notificationsEnabled {
return
}
logger.Debugf(ctx, "SendNotification")
defer logger.Debugf(ctx, "/SendNotification")
ui.Panel.app.SendNotification(&fyne.Notification{
Title: string(msg.Platform) + " chat message",
Content: msg.Username + ": " + msg.Message,
})
})
observability.Go(ctx, func() {
soundEnabled := xsync.DoR1(ctx, &ui.Panel.configLocker, func() bool {
return ui.Panel.Config.Chat.ReceiveMessageSoundAlarmEnabled()
})
if !soundEnabled {
return
}
concurrentCount := atomic.AddInt32(&ui.CurrentlyPlayingChatMessageSoundCount, 1)
defer atomic.AddInt32(&ui.CurrentlyPlayingChatMessageSoundCount, -1)
logger.Debugf(ctx, "PlayChatMessage (count: %d)", concurrentCount)
@@ -166,6 +187,18 @@ func (ui *chatUI) onReceiveMessage(
logger.Errorf(ctx, "unable to playback the chat message sound: %v", err)
}
})
observability.Go(ctx, func() {
commandTemplate := xsync.DoR1(ctx, &ui.Panel.configLocker, func() string {
return ui.Panel.Config.Chat.CommandOnReceiveMessage
})
if commandTemplate == "" {
return
}
logger.Debugf(ctx, "CommandOnReceiveMessage: <%s>", commandTemplate)
defer logger.Debugf(ctx, "/CommandOnReceiveMessage")
ui.Panel.execCommand(ctx, commandTemplate, msg)
})
}
func (ui *chatUI) listLength() int {

View File

@@ -27,12 +27,27 @@ type OAuthConfig struct {
} `yaml:"listen_ports"`
}
type ChatConfig struct {
CommandOnReceiveMessage string `yaml:"command_on_receive_message,omitempty"`
EnableNotifications *bool `yaml:"enable_notifications,omitempty"`
EnableReceiveMessageSoundAlarm *bool `yaml:"enable_receive_message_sound_alarm,omitempty"`
}
func (cfg ChatConfig) NotificationsEnabled() bool {
return cfg.EnableNotifications == nil || *cfg.EnableNotifications
}
func (cfg ChatConfig) ReceiveMessageSoundAlarmEnabled() bool {
return cfg.EnableReceiveMessageSoundAlarm == nil || *cfg.EnableReceiveMessageSoundAlarm
}
type Config struct {
RemoteStreamDAddr string `yaml:"streamd_remote"`
BuiltinStreamD streamd.Config `yaml:"streamd_builtin"`
Screenshot ScreenshotConfig `yaml:"screenshot"`
Browser BrowserConfig `yaml:"browser"`
OAuth OAuthConfig `yaml:"oauth"`
Chat ChatConfig `yaml:"chat"`
}
func DefaultConfig() Config {

View File

@@ -23,6 +23,7 @@ import (
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
child_process_manager "github.com/AgustinSRG/go-child-process-manager"
"github.com/facebookincubator/go-belt"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger"
@@ -78,6 +79,7 @@ type Panel struct {
app fyne.App
Config Config
configLocker xsync.RWMutex
streamMutex xsync.Mutex
updateTimerHandler *updateTimerHandler
profilesOrder []streamcontrol.ProfileName
@@ -1479,9 +1481,16 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
return fmt.Errorf("unable to get config: %w", err)
}
return xsync.DoA2R1(ctx, &p.configLocker, p.openSettingsWindowNoLock, ctx, cfg)
}
func (p *Panel) openSettingsWindowNoLock(
ctx context.Context,
streamDCfg *streamdconfig.Config,
) error {
{
var buf bytes.Buffer
_, err := cfg.WriteTo(&buf)
_, err := streamDCfg.WriteTo(&buf)
if err != nil {
logger.Warnf(ctx, "unable to serialize the config: %v", err)
} else {
@@ -1506,20 +1515,20 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
w := p.app.NewWindow(AppName + ": Settings")
resizeWindow(w, fyne.NewSize(400, 900))
if obsCfg, ok := cfg.Backends[obs.ID]; ok {
if obsCfg, ok := streamDCfg.Backends[obs.ID]; ok {
logger.Debugf(ctx, "current OBS config: %#+v", obsCfg)
}
cmdBeforeStartStream, _ := cfg.Backends[obs.ID].GetCustomString(
cmdBeforeStartStream, _ := streamDCfg.Backends[obs.ID].GetCustomString(
config.CustomConfigKeyBeforeStreamStart,
)
cmdBeforeStopStream, _ := cfg.Backends[obs.ID].GetCustomString(
cmdBeforeStopStream, _ := streamDCfg.Backends[obs.ID].GetCustomString(
config.CustomConfigKeyBeforeStreamStop,
)
cmdAfterStartStream, _ := cfg.Backends[obs.ID].GetCustomString(
cmdAfterStartStream, _ := streamDCfg.Backends[obs.ID].GetCustomString(
config.CustomConfigKeyAfterStreamStart,
)
cmdAfterStopStream, _ := cfg.Backends[obs.ID].GetCustomString(
cmdAfterStopStream, _ := streamDCfg.Backends[obs.ID].GetCustomString(
config.CustomConfigKeyAfterStreamStop,
)
@@ -1532,18 +1541,36 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
afterStopStreamCommandEntry := widget.NewEntry()
afterStopStreamCommandEntry.SetText(cmdAfterStopStream)
afterReceivedChatMessage := widget.NewEntry()
afterReceivedChatMessage.SetText(p.Config.Chat.CommandOnReceiveMessage)
enableChatNotifications := widget.NewCheck(
"Enable on-screen notifications for chat messages",
func(b bool) {},
)
enableChatNotifications.SetChecked(p.Config.Chat.NotificationsEnabled())
enableChatMessageSoundsAlerts := widget.NewCheck(
"Enable sound alerts for chat messages",
func(b bool) {},
)
enableChatMessageSoundsAlerts.SetChecked(p.Config.Chat.ReceiveMessageSoundAlarmEnabled())
oldScreenshoterEnabled := p.Config.Screenshot.Enabled != nil && *p.Config.Screenshot.Enabled
mpvPathEntry := widget.NewEntry()
mpvPathEntry.SetText(cfg.StreamServer.VideoPlayer.MPV.Path)
mpvPathEntry.SetText(streamDCfg.StreamServer.VideoPlayer.MPV.Path)
mpvPathEntry.OnChanged = func(s string) {
cfg.StreamServer.VideoPlayer.MPV.Path = s
streamDCfg.StreamServer.VideoPlayer.MPV.Path = s
}
cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
w.Close()
})
saveButton := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() {
p.Config.Chat.CommandOnReceiveMessage = afterReceivedChatMessage.Text
p.Config.Chat.EnableNotifications = ptr(enableChatNotifications.Checked)
p.Config.Chat.EnableReceiveMessageSoundAlarm = ptr(enableChatMessageSoundsAlerts.Checked)
if err := p.SaveConfig(ctx); err != nil {
p.DisplayError(fmt.Errorf("unable to save the local config: %w", err))
} else {
@@ -1553,7 +1580,7 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
}
}
obsCfg := cfg.Backends[obs.ID]
obsCfg := streamDCfg.Backends[obs.ID]
obsCfg.SetCustomString(
config.CustomConfigKeyBeforeStreamStart, beforeStartStreamCommandEntry.Text)
obsCfg.SetCustomString(
@@ -1562,9 +1589,9 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
config.CustomConfigKeyAfterStreamStart, afterStartStreamCommandEntry.Text)
obsCfg.SetCustomString(
config.CustomConfigKeyAfterStreamStop, afterStopStreamCommandEntry.Text)
cfg.Backends[obs.ID] = obsCfg
streamDCfg.Backends[obs.ID] = obsCfg
if err := p.SetStreamDConfig(ctx, cfg); err != nil {
if err := p.SetStreamDConfig(ctx, streamDCfg); err != nil {
p.DisplayError(fmt.Errorf("unable to update the remote config: %w", err))
} else {
if err := p.StreamD.SaveConfig(ctx); err != nil {
@@ -1667,14 +1694,14 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
widget.NewRichTextFromMarkdown(`# Streaming platforms`),
container.NewHBox(
widget.NewButtonWithIcon("(Re-)login in OBS", theme.LoginIcon(), func() {
if cfg.Backends[obs.ID] == nil {
obs.InitConfig(cfg.Backends)
if streamDCfg.Backends[obs.ID] == nil {
obs.InitConfig(streamDCfg.Backends)
}
cfg.Backends[obs.ID].Enable = nil
cfg.Backends[obs.ID].Config = obs.PlatformSpecificConfig{}
streamDCfg.Backends[obs.ID].Enable = nil
streamDCfg.Backends[obs.ID].Config = obs.PlatformSpecificConfig{}
if err := p.SetStreamDConfig(ctx, cfg); err != nil {
if err := p.SetStreamDConfig(ctx, streamDCfg); err != nil {
p.DisplayError(err)
return
}
@@ -1688,14 +1715,14 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
),
container.NewHBox(
widget.NewButtonWithIcon("(Re-)login in Twitch", theme.LoginIcon(), func() {
if cfg.Backends[twitch.ID] == nil {
twitch.InitConfig(cfg.Backends)
if streamDCfg.Backends[twitch.ID] == nil {
twitch.InitConfig(streamDCfg.Backends)
}
cfg.Backends[twitch.ID].Enable = nil
cfg.Backends[twitch.ID].Config = twitch.PlatformSpecificConfig{}
streamDCfg.Backends[twitch.ID].Enable = nil
streamDCfg.Backends[twitch.ID].Config = twitch.PlatformSpecificConfig{}
if err := p.SetStreamDConfig(ctx, cfg); err != nil {
if err := p.SetStreamDConfig(ctx, streamDCfg); err != nil {
p.DisplayError(err)
return
}
@@ -1709,14 +1736,14 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
),
container.NewHBox(
widget.NewButtonWithIcon("(Re-)login in Kick", theme.LoginIcon(), func() {
if cfg.Backends[kick.ID] == nil {
kick.InitConfig(cfg.Backends)
if streamDCfg.Backends[kick.ID] == nil {
kick.InitConfig(streamDCfg.Backends)
}
cfg.Backends[kick.ID].Enable = nil
cfg.Backends[kick.ID].Config = kick.PlatformSpecificConfig{}
streamDCfg.Backends[kick.ID].Enable = nil
streamDCfg.Backends[kick.ID].Config = kick.PlatformSpecificConfig{}
if err := p.SetStreamDConfig(ctx, cfg); err != nil {
if err := p.SetStreamDConfig(ctx, streamDCfg); err != nil {
p.DisplayError(err)
return
}
@@ -1730,13 +1757,13 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
),
container.NewHBox(
widget.NewButtonWithIcon("(Re-)login in YouTube", theme.LoginIcon(), func() {
if cfg.Backends[youtube.ID] == nil {
youtube.InitConfig(cfg.Backends)
if streamDCfg.Backends[youtube.ID] == nil {
youtube.InitConfig(streamDCfg.Backends)
}
cfg.Backends[youtube.ID].Enable = nil
cfg.Backends[youtube.ID].Config = youtube.PlatformSpecificConfig{}
if err := p.SetStreamDConfig(ctx, cfg); err != nil {
streamDCfg.Backends[youtube.ID].Enable = nil
streamDCfg.Backends[youtube.ID].Config = youtube.PlatformSpecificConfig{}
if err := p.SetStreamDConfig(ctx, streamDCfg); err != nil {
p.DisplayError(err)
return
}
@@ -1750,6 +1777,11 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
),
widget.NewSeparator(),
widget.NewSeparator(),
widget.NewRichTextFromMarkdown(`# Chat`),
enableChatNotifications,
enableChatMessageSoundsAlerts,
widget.NewSeparator(),
widget.NewSeparator(),
widget.NewRichTextFromMarkdown(`# Dashboard`),
enableScreenshotSendingCheckbox,
widget.NewLabel("The screen/display to screenshot:"),
@@ -1780,6 +1812,9 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
beforeStopStreamCommandEntry,
widget.NewLabel("Run command on stream stop (after):"),
afterStopStreamCommandEntry,
widget.NewSeparator(),
widget.NewLabel("Run command on receiving a chat message (after):"),
afterReceivedChatMessage,
),
container.NewHBox(
cancelButton,
@@ -2457,8 +2492,12 @@ func (p *Panel) getSelectedProfile() Profile {
return xsync.DoA2R1(ctx, &p.configCacheLocker, getProfile, p.configCache, *p.selectedProfileName)
}
func (p *Panel) execCommand(ctx context.Context, cmdString string) {
cmdExpanded, err := expandCommand(cmdString)
func (p *Panel) execCommand(
ctx context.Context,
cmdString string,
execContext any,
) {
cmdExpanded, err := expandCommand(ctx, cmdString, execContext)
if err != nil {
p.DisplayError(err)
}
@@ -2469,12 +2508,21 @@ func (p *Panel) execCommand(ctx context.Context, cmdString string) {
logger.Infof(ctx, "executing %s with arguments %v", cmdExpanded[0], cmdExpanded[1:])
cmd := exec.Command(cmdExpanded[0], cmdExpanded[1:]...)
err = child_process_manager.ConfigureCommand(cmd)
if err != nil {
logger.Errorf(ctx, "unable to configure the command so that the process will die automatically: %v", err)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
observability.Go(ctx, func() {
err := cmd.Run()
if err != nil {
if err == nil {
err = child_process_manager.AddChildProcess(cmd.Process)
if err != nil {
logger.Errorf(ctx, "unable to add the process to the clean up list: %v", err)
}
} else {
p.DisplayError(err)
}
@@ -2669,7 +2717,7 @@ func (p *Panel) startStream(ctx context.Context) {
platCfg = p.configCache.Backends[obs.ID]
})
if onStreamStart, ok := platCfg.GetCustomString(config.CustomConfigKeyAfterStreamStart); ok {
p.execCommand(ctx, onStreamStart)
p.execCommand(ctx, onStreamStart, nil)
}
p.startStopButton.Refresh()
@@ -2736,7 +2784,7 @@ func (p *Panel) stopStreamNoLock(ctx context.Context) {
platCfg = p.configCache.Backends[obs.ID]
})
if onStreamStop, ok := platCfg.GetCustomString(config.CustomConfigKeyAfterStreamStop); ok {
p.execCommand(ctx, onStreamStop)
p.execCommand(ctx, onStreamStop, nil)
}
p.startStopButton.SetText(startStreamString())

View File

@@ -1,60 +1,43 @@
package streampanel
import (
"strings"
"context"
"fmt"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/go-andiamo/splitter"
"github.com/xaionaro-go/streamctl/pkg/expression"
)
func splitWithQuotes(s string) []string {
var result []string
var current string
inQuotes := false
quoteChar := byte(0)
var commandSplitter splitter.Splitter
for i := 0; i < len(s); i++ {
c := s[i]
if inQuotes {
if c == quoteChar {
inQuotes = false
quoteChar = 0
} else {
current += string(c)
}
} else {
switch c {
case ' ', '\t':
if len(current) > 0 {
result = append(result, current)
current = ""
}
case '\'', '"':
inQuotes = true
quoteChar = c
default:
current += string(c)
}
}
}
if len(current) > 0 {
result = append(result, current)
}
return result
func init() {
commandSplitter = splitter.MustCreateSplitter(
' ',
splitter.DoubleQuotesBackSlashEscaped,
).AddDefaultOptions(
splitter.Trim(" \t\r\n"),
splitter.IgnoreEmpties,
splitter.UnescapeQuotes,
)
}
func expandCommand(cmdString string) ([]string, error) {
cmdStringExpanded, err := expression.Eval[string](expression.Expression(cmdString), nil)
func expandCommand(
ctx context.Context,
cmdString string,
context any,
) ([]string, error) {
cmdStringExpanded, err := expression.Eval[string](expression.Expression(cmdString), context)
if err != nil {
return nil, err
}
cmdStringExpandedClean := strings.Trim(cmdStringExpanded, " \t\r\n")
if len(cmdStringExpandedClean) == 0 {
return nil, nil
logger.Debugf(ctx, "expanded command is: <%s>", cmdStringExpanded)
args, err := commandSplitter.Split(cmdStringExpanded)
if err != nil {
return nil, fmt.Errorf("unable to split '%s': %w", cmdStringExpanded, err)
}
return splitWithQuotes(cmdStringExpandedClean), nil
return args, nil
}