diff --git a/internal/plugins/chat/config.go b/internal/plugins/chat/config.go new file mode 100644 index 00000000..dd24835c --- /dev/null +++ b/internal/plugins/chat/config.go @@ -0,0 +1,23 @@ +package chat + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Config struct { + Enabled bool +} + +func (Config) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().Bool("chat.enabled", true, "whether to enable chat plugin") + if err := viper.BindPFlag("chat.enabled", cmd.PersistentFlags().Lookup("chat.enabled")); err != nil { + return err + } + + return nil +} + +func (s *Config) Set() { + s.Enabled = viper.GetBool("chat.enabled") +} diff --git a/internal/plugins/chat/manager.go b/internal/plugins/chat/manager.go new file mode 100644 index 00000000..79dff3d8 --- /dev/null +++ b/internal/plugins/chat/manager.go @@ -0,0 +1,193 @@ +package chat + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "github.com/demodesk/neko/pkg/auth" + "github.com/demodesk/neko/pkg/types" + "github.com/demodesk/neko/pkg/utils" +) + +func NewManager( + sessions types.SessionManager, + config *Config, +) *Manager { + logger := log.With().Str("module", "chat").Logger() + + return &Manager{ + logger: logger, + config: config, + sessions: sessions, + } +} + +type Manager struct { + logger zerolog.Logger + config *Config + sessions types.SessionManager +} + +func (m *Manager) isEnabledForSession(session types.Session) bool { + return m.config.Enabled && + settingsIsEnabled(m.sessions.Settings()) && + profileIsEnabled(session.Profile()) +} + +func (m *Manager) sendMessage(session types.Session, content Content) { + now := time.Now() + + // get all sessions that have chat enabled + var sessions []types.Session + m.sessions.Range(func(s types.Session) bool { + if m.isEnabledForSession(s) { + sessions = append(sessions, s) + } + // continue iteration over all sessions + return true + }) + + // send content to all sessions + for _, s := range sessions { + s.Send(CHAT_MESSAGE, Message{ + ID: session.ID(), + Created: now, + Content: content, + }) + } +} + +func (m *Manager) Start() error { + // send init message once a user connects + m.sessions.OnConnected(func(session types.Session) { + isEnabled := m.isEnabledForSession(session) + + // send init message + session.Send(CHAT_INIT, Init{ + Enabled: isEnabled, + }) + }) + + // do not proceed if chat is disabled in the config + if !m.config.Enabled { + return nil + } + + // on settings change, reinit if chat is enabled/disabled + m.sessions.OnSettingsChanged(func(session types.Session, new, old types.Settings) { + isEnabled := settingsIsEnabled(new) + wasEnabled := settingsIsEnabled(old) + + if !isEnabled && wasEnabled { + // if chat was enabled and is now disabled, broadcast to all sessions + // because it cannot be overridden by profile settings + m.sessions.Broadcast(CHAT_INIT, Init{ + Enabled: false, + }) + } + if isEnabled && !wasEnabled { + // if chat was disabled and is now enabled, loop over all sessions + // and send the init message (because it can be overridden by profile settings) + for _, s := range m.sessions.List() { + s.Send(CHAT_INIT, Init{ + Enabled: m.isEnabledForSession(s), + }) + } + } + }) + + // on profile change, reinit if chat is enabled/disabled + m.sessions.OnProfileChanged(func(session types.Session, new, old types.MemberProfile) { + isEnabled := profileIsEnabled(new) + wasEnabled := profileIsEnabled(old) + + if isEnabled != wasEnabled { + // only if the chat setting was changed, send the init message + session.Send(CHAT_INIT, Init{ + Enabled: m.isEnabledForSession(session), + }) + } + }) + + return nil +} + +func (m *Manager) Shutdown() error { + return nil +} + +func (m *Manager) Route(r types.Router) { + r.With(auth.AdminsOnly).Post("/", m.sendMessageHandler) +} + +func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMessage) bool { + switch msg.Event { + case CHAT_MESSAGE: + var content Content + if err := json.Unmarshal(msg.Payload, &content); err != nil { + m.logger.Error().Err(err).Msg("failed to unmarshal chat message") + // we processed the message, return true + return true + } + + m.sendMessage(session, content) + return true + } + return false +} + +func (m *Manager) sendMessageHandler(w http.ResponseWriter, r *http.Request) error { + session, ok := auth.GetSession(r) + if !ok { + return utils.HttpUnauthorized("session not found") + } + + enabled := m.isEnabledForSession(session) + if !enabled { + return utils.HttpForbidden("chat is disabled") + } + + content := Content{} + if err := utils.HttpJsonRequest(w, r, &content); err != nil { + return err + } + + m.sendMessage(session, content) + return utils.HttpSuccess(w) +} + +func settingsIsEnabled(s types.Settings) bool { + isEnabled := true + + settings, ok := s.Plugins["chat"] + // by default, allow chat if the plugin config is not present + if ok { + isEnabled, ok = settings.(bool) + // if the plugin is present but not a boolean, allow chat + if !ok { + isEnabled = true + } + } + + return isEnabled +} + +func profileIsEnabled(p types.MemberProfile) bool { + isEnabled := true + + settings, ok := p.Plugins["chat"] + // by default, allow chat if the plugin config is not present + if ok { + isEnabled, ok = settings.(bool) + // if the plugin is present but not a boolean, allow chat + if !ok { + isEnabled = true + } + } + + return isEnabled +} diff --git a/internal/plugins/chat/plugin.go b/internal/plugins/chat/plugin.go new file mode 100644 index 00000000..d4bc9463 --- /dev/null +++ b/internal/plugins/chat/plugin.go @@ -0,0 +1,35 @@ +package chat + +import ( + "github.com/demodesk/neko/pkg/types" +) + +type Plugin struct { + config *Config + manager *Manager +} + +func NewPlugin() *Plugin { + return &Plugin{ + config: &Config{}, + } +} + +func (p *Plugin) Name() string { + return PluginName +} + +func (p *Plugin) Config() types.PluginConfig { + return p.config +} + +func (p *Plugin) Start(m types.PluginManagers) error { + p.manager = NewManager(m.SessionManager, p.config) + m.ApiManager.AddRouter("/chat", p.manager.Route) + m.WebSocketManager.AddHandler(p.manager.WebSocketHandler) + return p.manager.Start() +} + +func (p *Plugin) Shutdown() error { + return p.manager.Shutdown() +} diff --git a/internal/plugins/chat/types.go b/internal/plugins/chat/types.go new file mode 100644 index 00000000..33e9d11a --- /dev/null +++ b/internal/plugins/chat/types.go @@ -0,0 +1,24 @@ +package chat + +import "time" + +const PluginName = "chat" + +const ( + CHAT_INIT = "chat/init" + CHAT_MESSAGE = "chat/message" +) + +type Init struct { + Enabled bool `json:"enabled"` +} + +type Content struct { + Text string `json:"text"` +} + +type Message struct { + ID string `json:"id"` + Created time.Time `json:"created"` + Content Content `json:"content"` +} diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go index 908b9aa6..24ada08f 100644 --- a/internal/plugins/manager.go +++ b/internal/plugins/manager.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/demodesk/neko/internal/config" + "github.com/demodesk/neko/internal/plugins/chat" "github.com/demodesk/neko/internal/plugins/filetransfer" "github.com/demodesk/neko/pkg/types" ) @@ -45,6 +46,7 @@ func New(config *config.Plugins) *ManagerCtx { // add built-in plugins manager.plugins.addPlugin(filetransfer.NewPlugin()) + manager.plugins.addPlugin(chat.NewPlugin()) return manager }