feat: add openinapp plugin to open chat links in shared neko instance (#682)

* feat: add openinapp plugin to open chat links in shared neko instance

* lint.

* feat(openinapp): implement open link handler and route for opening links.

* feat(openinapp): switched to http backend and removed example in docker-compose.yaml

* lint.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
This commit is contained in:
Paul Troeger
2026-08-09 22:53:39 +02:00
committed by GitHub
parent 365944d1ea
commit cc014571cc
16 changed files with 338 additions and 4 deletions
@@ -13,6 +13,7 @@ import (
"github.com/m1k1o/neko/server/internal/api/room"
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
@@ -199,6 +200,14 @@ func (s *session) wsToBackend(msg []byte) error {
case oldEvent.FILETRANSFER_REFRESH:
return s.toBackend(filetransfer.FILETRANSFER_UPDATE, nil)
// Open In App Events
case openinapp.OPENINAPP_OPENLINK:
request := &openinapp.Url{}
if err := json.Unmarshal(msg, request); err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/openinapp/openlink", request, nil)
// Screen Events
case oldEvent.SCREEN_RESOLUTION:
response := &types.ScreenSize{}
+16
View File
@@ -14,6 +14,7 @@ import (
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
@@ -615,6 +616,21 @@ func (s *session) wsToClient(msg []byte) error {
Files: files,
})
// Open In App Events
case openinapp.OPENINAPP_INIT:
request := &openinapp.Init{}
if err := json.Unmarshal(data.Payload, request); err != nil {
return err
}
return s.toClient(&struct {
Event string `json:"event"`
Enabled bool `json:"enabled"`
}{
Event: openinapp.OPENINAPP_INIT,
Enabled: request.Enabled,
})
// Screen Events
case event.SCREEN_UPDATED:
request := &message.ScreenSizeUpdate{}
+2
View File
@@ -13,6 +13,7 @@ import (
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
)
@@ -47,6 +48,7 @@ func New(config *config.Plugins) *ManagerCtx {
// add built-in plugins
manager.plugins.addPlugin(filetransfer.NewPlugin())
manager.plugins.addPlugin(chat.NewPlugin())
manager.plugins.addPlugin(openinapp.NewPlugin())
return manager
}
@@ -0,0 +1,30 @@
package openinapp
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Config struct {
Enabled bool
OpenCommand string
}
func (Config) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().Bool("openinapp.enabled", false, "whether to enable openinapp plugin")
if err := viper.BindPFlag("openinapp.enabled", cmd.PersistentFlags().Lookup("openinapp.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("openinapp.open_command", "xdg-open", "command used to open URLs inside the app")
if err := viper.BindPFlag("openinapp.open_command", cmd.PersistentFlags().Lookup("openinapp.open_command")); err != nil {
return err
}
return nil
}
func (s *Config) Set() {
s.Enabled = viper.GetBool("openinapp.enabled")
s.OpenCommand = viper.GetString("openinapp.open_command")
}
@@ -0,0 +1,94 @@
package openinapp
import (
"encoding/json"
"net/http"
"net/url"
"os/exec"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko/server/pkg/auth"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
func NewManager(
sessions types.SessionManager,
config *Config,
) *Manager {
logger := log.With().Str("module", "openinapp").Logger()
return &Manager{
logger: logger,
config: config,
sessions: sessions,
}
}
type Manager struct {
logger zerolog.Logger
config *Config
sessions types.SessionManager
}
func (m *Manager) Route(r types.Router) {
r.Post("/openlink", m.openLinkHandler)
}
func (m *Manager) openLinkHandler(w http.ResponseWriter, r *http.Request) error {
session, ok := auth.GetSession(r)
if !ok {
return utils.HttpUnauthorized("session not found")
}
if !m.config.Enabled {
return utils.HttpForbidden("openinapp is disabled")
}
if !session.IsHost() && (!session.Profile().CanHost || !m.sessions.Settings().ImplicitHosting) {
return utils.HttpForbidden("only the host can open links")
}
var req Url
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return utils.HttpBadRequest().WithInternalErr(err).Msg("failed to parse request body")
}
parsedUrl, err := url.Parse(req.Text)
if err != nil {
return utils.HttpBadRequest().WithInternalErr(err).Msg("failed to parse URL")
}
if parsedUrl.Scheme != "http" && parsedUrl.Scheme != "https" {
return utils.HttpBadRequest().Msg("URL must use http or https scheme")
}
parts := strings.Fields(m.config.OpenCommand)
if len(parts) == 0 {
return utils.HttpInternalServerError().Msg("open command is not configured")
}
if err := exec.Command(parts[0], append(parts[1:], parsedUrl.String())...).Start(); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err).Msg("failed to start open command")
}
return utils.HttpSuccess(w, nil)
}
func (m *Manager) Start() error {
// send init message once a user connects
m.sessions.OnConnected(func(session types.Session) {
session.Send(OPENINAPP_INIT, Init{
Enabled: m.config.Enabled,
})
})
return nil
}
func (m *Manager) Shutdown() error {
return nil
}
@@ -0,0 +1,34 @@
package openinapp
import (
"github.com/m1k1o/neko/server/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("/openinapp", p.manager.Route)
return p.manager.Start()
}
func (p *Plugin) Shutdown() error {
return p.manager.Shutdown()
}
@@ -0,0 +1,16 @@
package openinapp
const PluginName = "openinapp"
const (
OPENINAPP_INIT = "openinapp/init"
OPENINAPP_OPENLINK = "openinapp/openlink"
)
type Init struct {
Enabled bool `json:"enabled"`
}
type Url struct {
Text string `json:"text"`
}