From 1be09c7ccd0ea7f59598d5b9f68a6d372180aba4 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sat, 9 May 2020 23:17:15 +0200 Subject: [PATCH] Initial bunch of code --- .gitignore | 1 + cmd_join.go | 66 +++++++++++++++ cmd_listChannels.go | 44 ++++++++++ cmd_part.go | 66 +++++++++++++++ commands.go | 42 ++++++++++ go.mod | 15 ++++ go.sum | 27 ++++++ handler.go | 122 ++++++++++++++++++++++++++++ main.go | 87 ++++++++++++++++++++ messages.go | 73 +++++++++++++++++ sioclient/eio.go | 194 ++++++++++++++++++++++++++++++++++++++++++++ sioclient/go.mod | 8 ++ sioclient/go.sum | 4 + sioclient/sio.go | 181 +++++++++++++++++++++++++++++++++++++++++ 14 files changed, 930 insertions(+) create mode 100644 .gitignore create mode 100644 cmd_join.go create mode 100644 cmd_listChannels.go create mode 100644 cmd_part.go create mode 100644 commands.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler.go create mode 100644 main.go create mode 100644 messages.go create mode 100644 sioclient/eio.go create mode 100644 sioclient/go.mod create mode 100644 sioclient/go.sum create mode 100644 sioclient/sio.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/cmd_join.go b/cmd_join.go new file mode 100644 index 0000000..3e3a855 --- /dev/null +++ b/cmd_join.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/Luzifer/lounge-control/sioclient" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +func init() { + registerCommand("join", commandJoin) +} + +func commandJoin(args []string) handlerFunc { + if len(args) == 0 { + log.Fatal("No channels given to join") + } + + return addGenericHandler(func(pType string, msg *sioclient.Message) error { + if pType != "init" { + return nil + } + + // After join command is finished we can execute the joins + network := initData.NetworkByNameOrUUID(cfg.Network) + if network == nil { + return errors.New("Network not found") + } + + var lobby *channel + for _, c := range network.Channels { + if c.Type == "lobby" { + lobby = &c + break + } + } + + if lobby == nil { + return errors.New("Unable to find lobby for network") + } + + for _, ch := range args { + if !strings.HasPrefix(ch, "#") { + ch = "#" + ch + } + + msg, err := sioclient.NewMessage(sioclient.MessageTypeEvent, 0, "input", map[string]interface{}{ + "text": fmt.Sprintf("/join %s", ch), + "target": lobby.ID, + }) + if err != nil { + return errors.Wrap(err, "Unable to compose join message") + } + + if err = msg.Send(client); err != nil { + return errors.Wrap(err, "Unable to send join message") + } + } + + interrupt <- os.Interrupt + return nil + }) +} diff --git a/cmd_listChannels.go b/cmd_listChannels.go new file mode 100644 index 0000000..74e3c60 --- /dev/null +++ b/cmd_listChannels.go @@ -0,0 +1,44 @@ +package main + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/Luzifer/lounge-control/sioclient" +) + +func init() { + registerCommand("list-channels", commandListChannels) +} + +func commandListChannels(args []string) handlerFunc { + return addGenericHandler(func(pType string, msg *sioclient.Message) error { + if pType != "init" { + return nil + } + + network := initData.NetworkByNameOrUUID(cfg.Network) + if network == nil { + return errors.New("Network not found") + } + + var channels []string + + for _, c := range network.Channels { + if c.Type == "lobby" { + continue + } + + channels = append(channels, c.Name) + } + + sort.Strings(channels) + + fmt.Println(strings.Join(channels, "\n")) + interrupt <- os.Interrupt + return nil + }) +} diff --git a/cmd_part.go b/cmd_part.go new file mode 100644 index 0000000..ce5b293 --- /dev/null +++ b/cmd_part.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/Luzifer/lounge-control/sioclient" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +func init() { + registerCommand("part", commandPart) +} + +func commandPart(args []string) handlerFunc { + if len(args) == 0 { + log.Fatal("No channels given to part") + } + + return addGenericHandler(func(pType string, msg *sioclient.Message) error { + if pType != "init" { + return nil + } + + // After join command is finished we can execute the joins + network := initData.NetworkByNameOrUUID(cfg.Network) + if network == nil { + return errors.New("Network not found") + } + + var lobby *channel + for _, c := range network.Channels { + if c.Type == "lobby" { + lobby = &c + break + } + } + + if lobby == nil { + return errors.New("Unable to find lobby for network") + } + + for _, ch := range args { + if !strings.HasPrefix(ch, "#") { + ch = "#" + ch + } + + msg, err := sioclient.NewMessage(sioclient.MessageTypeEvent, 0, "input", map[string]interface{}{ + "text": fmt.Sprintf("/part %s", ch), + "target": lobby.ID, + }) + if err != nil { + return errors.Wrap(err, "Unable to compose part message") + } + + if err = msg.Send(client); err != nil { + return errors.Wrap(err, "Unable to send part message") + } + } + + interrupt <- os.Interrupt + return nil + }) +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..7e9745e --- /dev/null +++ b/commands.go @@ -0,0 +1,42 @@ +package main + +import ( + "sort" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/Luzifer/lounge-control/sioclient" +) + +type commandFunc func(args []string) handlerFunc +type handlerFunc func(msg *sioclient.Message) error +type typedHandlerFunc func(mType string, msg *sioclient.Message) error + +var ( + commands = map[string]commandFunc{} + commandsMutex = new(sync.RWMutex) +) + +func availableCommands() (cmds []string) { + commandsMutex.RLock() + defer commandsMutex.RUnlock() + + for k := range commands { + cmds = append(cmds, k) + } + + sort.Strings(cmds) + return cmds +} + +func registerCommand(cmd string, cf commandFunc) { + commandsMutex.Lock() + defer commandsMutex.Unlock() + + if _, ok := commands[cmd]; ok { + log.Fatalf("Duplicate registration of command %q", cmd) + } + + commands[cmd] = cf +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab0f36a --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/Luzifer/lounge-control + +go 1.14 + +replace github.com/Luzifer/lounge-control/sioclient => ./sioclient + +require ( + github.com/Luzifer/lounge-control/sioclient v0.0.0-00010101000000-000000000000 + github.com/Luzifer/rconfig/v2 v2.2.1 + github.com/gorilla/websocket v1.4.2 // indirect + github.com/pkg/errors v0.9.1 + github.com/sacOO7/go-logger v0.0.0-20180719173527-9ac9add5a50d // indirect + github.com/sacOO7/gowebsocket v0.0.0-20180719182212-1436bb906a4e + github.com/sirupsen/logrus v1.6.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b8a3863 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/Luzifer/rconfig v1.2.0 h1:waD1sqasGVSQSrExpLrQ9Q1JmMaltrS391VdOjWXP/I= +github.com/Luzifer/rconfig/v2 v2.2.1 h1:zcDdLQlnlzwcBJ8E0WFzOkQE1pCMn3EbX0dFYkeTczg= +github.com/Luzifer/rconfig/v2 v2.2.1/go.mod h1:OKIX0/JRZrPJ/ZXXWklQEFXA6tBfWaljZbW37w+sqBw= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sacOO7/go-logger v0.0.0-20180719173527-9ac9add5a50d h1:5T+fbRuQbpi+WZtB2yfuu59r00F6T2HV/zGYrwX8nvE= +github.com/sacOO7/go-logger v0.0.0-20180719173527-9ac9add5a50d/go.mod h1:L5EJe2k8GwpBoGXDRLAEs58R239jpZuE7NNEtW+T7oo= +github.com/sacOO7/gowebsocket v0.0.0-20180719182212-1436bb906a4e h1:yG1sLAkFltiFiwIpKdiA2CVIPxRL4P9OywNnfq45ivg= +github.com/sacOO7/gowebsocket v0.0.0-20180719182212-1436bb906a4e/go.mod h1:4a2a9BlxB807BaME8FJzQRLrZwYKj0cWjon25PlIssM= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 h1:WB265cn5OpO+hK3pikC9hpP1zI/KTwmyMFKloW9eOVc= +gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..3a91f87 --- /dev/null +++ b/handler.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "github.com/Luzifer/lounge-control/sioclient" +) + +func addGenericHandler(f typedHandlerFunc) handlerFunc { + return func(msg *sioclient.Message) error { + if msg.Type != sioclient.MessageTypeEvent { + // We don't care about anything but events + return nil + } + + pType, err := msg.PayloadType() + if err != nil { + log.Printf("Event message had no payload type: %#v - %s", msg, err) + return nil + } + + switch pType { + + case "auth:failed": + log.Fatal("Login failed") + + case "auth:start": + msg, err := sioclient.NewMessage(sioclient.MessageTypeEvent, 0, "auth:perform", map[string]string{"user": cfg.Username, "password": cfg.Password}) + if err != nil { + return errors.Wrap(err, "Unable to create auth:peform") + } + + if err := msg.Send(client); err != nil { + return errors.Wrap(err, "Unable to create payload") + } + + case "init": + if err := json.Unmarshal(msg.Payload[1], &initData); err != nil { + return errors.Wrap(err, "Unable to parse init payload") + } + + } + + return f(pType, msg) + } +} + +// DEPRECATED: Only storing code for now +func handleMessage(msg *sioclient.Message) error { + if msg.Type != sioclient.MessageTypeEvent { + // We don't care about anything but events + return nil + } + + pType, err := msg.PayloadType() + if err != nil { + log.Printf("Event message had no payload type: %#v - %s", msg, err) + return nil + } + + switch pType { + + case "auth:failed": + log.Fatal("Login failed") + + case "auth:start": + msg, err := sioclient.NewMessage(sioclient.MessageTypeEvent, 0, "auth:perform", map[string]string{"user": cfg.Username, "password": cfg.Password}) + if err != nil { + return errors.Wrap(err, "Unable to create auth:peform") + } + + if err := msg.Send(client); err != nil { + return errors.Wrap(err, "Unable to create payload") + } + + case "auth:success": + log.Info("Logged in successfully") + + case "init": + if err := json.Unmarshal(msg.Payload[1], &initData); err != nil { + return errors.Wrap(err, "Unable to parse init payload") + } + + case "msg": + // Message in channel + var payload chatMessage + if err := json.Unmarshal(msg.Payload[1], &payload); err != nil { + return errors.Wrap(err, "Unable to parse init payload") + } + + switch payload.Msg.Type { + + case "join", "part": + // Don't care + + case "message", "notice": + //log.Infof("Msg: %#v", payload) + + case "unhandled": + log.Infof("CMD: %s", msg.Payload[1]) + + default: + log.Infof("Unhandled message %q: %#v", payload.Msg.Type, payload) + + } + + case "commands", "configuration", "names", "open", "push:issubscribed", "users": + // Drop irrelevantt messages + + default: + if len(msg.Payload) == 2 { + log.Warnf("Recieved unhandled message %q: %s", pType, msg.Payload[1]) + return nil + } + log.Warnf("Recieved unhandled message %q: %#v", pType, msg) + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..42e6f5e --- /dev/null +++ b/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/Luzifer/lounge-control/sioclient" + "github.com/Luzifer/rconfig/v2" +) + +var ( + cfg = struct { + LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` + Network string `flag:"network,n" description:"Name or UUID of the network to act on"` + Password string `flag:"password,p" description:"Password for the given username" validate:"nonzero"` + SocketURL string `flag:"socket-url" description:"URL to TheLounge websocket (i.e. 'wss://example.com/socket.io/')" validate:"nonzero"` + Username string `flag:"username,u" description:"Username to log into the socket" validate:"nonzero"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + }{} + + client *sioclient.Client + initData initMessage + interrupt = make(chan os.Signal, 1) + + version = "dev" +) + +func init() { + rconfig.AutoEnv(true) + if err := rconfig.ParseAndValidate(&cfg); err != nil { + log.Fatalf("Unable to parse commandline options: %s", err) + } + + if cfg.VersionAndExit { + fmt.Printf("lounge-control %s\n", version) + os.Exit(0) + } + + if l, err := log.ParseLevel(cfg.LogLevel); err != nil { + log.WithError(err).Fatal("Unable to parse log level") + } else { + log.SetLevel(l) + } +} + +func main() { + signal.Notify(interrupt, os.Interrupt) + + args := rconfig.Args()[1:] + if len(args) == 0 { + log.Fatalf("No command given. Available commands: %s", strings.Join(availableCommands(), ", ")) + } + + commandsMutex.RLock() + cf, ok := commands[args[0]] + commandsMutex.RUnlock() + if !ok { + log.Fatalf("Unknown command %q. Available commands: %s", args[0], strings.Join(availableCommands(), ", ")) + } + + var err error + client, err = sioclient.New(sioclient.Config{ + MessageHandler: cf(args[1:]), + URL: cfg.SocketURL, + }) + if err != nil { + log.WithError(err).Fatal("Unable to connect to server") + } + defer client.Close() + + for { + select { + + case <-interrupt: + return + + case err := <-client.EIO.Errors(): + log.WithError(err).Error("Error in in command / socket") + return + + } + } +} diff --git a/messages.go b/messages.go new file mode 100644 index 0000000..a2da845 --- /dev/null +++ b/messages.go @@ -0,0 +1,73 @@ +package main + +import "time" + +type chatMessageContent struct { + Command string `json:"command"` + From struct { + Mode string `json:"mode"` + Nick string `json:"nick"` + } `json:"from"` + Highlight bool `json:"highlight"` + ID int `json:"id"` + Params []string `json:"params"` + Previews []interface{} `json:"previews"` + Self bool `json:"self"` + Text string `json:"text"` + Time time.Time `json:"time"` + Type string `json:"type"` + Users []string `json:"users"` +} + +type chatMessage struct { + Chan int `json:"chan"` + Msg chatMessageContent `json:"msg"` + Unread int `json:"unread"` +} + +type channel struct { + Name string `json:"name"` + Type string `json:"type"` + ID int `json:"id"` + Messages []chatMessageContent `json:"messages"` + TotalMessages int `json:"totalMessages"` + Key string `json:"key"` + Topic string `json:"topic"` + State int `json:"state"` + FirstUnread int `json:"firstUnread"` + Unread int `json:"unread"` + Highlight int `json:"highlight"` + Users []string `json:"users"` +} + +type network struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Nick string `json:"nick"` + Channels []channel `json:"channels"` + ServerOptions struct { + CHANTYPES []string `json:"CHANTYPES"` + PREFIX []string `json:"PREFIX"` + NETWORK string `json:"NETWORK"` + } `json:"serverOptions"` + Status struct { + Connected bool `json:"connected"` + Secure bool `json:"secure"` + } `json:"status"` +} + +type initMessage struct { + Active int `json:"active"` + Networks []network `json:"networks"` + Token string `json:"token"` +} + +func (i initMessage) NetworkByNameOrUUID(id string) *network { + for _, n := range i.Networks { + if n.Name == id || n.UUID == id { + return &n + } + } + + return nil +} diff --git a/sioclient/eio.go b/sioclient/eio.go new file mode 100644 index 0000000..4a7068c --- /dev/null +++ b/sioclient/eio.go @@ -0,0 +1,194 @@ +package sioclient + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" +) + +type EIOMessageType int + +const ( + EIOMessageTypeOpen EIOMessageType = iota + EIOMessageTypeClose + EIOMessageTypePing + EIOMessageTypePong + EIOMessageTypeMessage + EIOMessageTypeUpgrade + EIOMessageTypeNoop +) + +var ErrNotConnected = errors.New("Websocket is not connected") + +type EIOClientConfig struct { + MessageHandlerBinary func([]byte) error + MessageHandlerText func([]byte) error + URL string +} + +type eioSessionStart struct { + SID string `json:"sid"` + Upgrades []string `json:"upgrades"` + PingTimeout int64 `json:"pingTimeout"` + PingInterval int64 `json:"pingInterval"` +} + +type EIOClient struct { + cfg EIOClientConfig + dialer *websocket.Dialer + errC chan error + isConnected bool + writeMutex *sync.Mutex + ws *websocket.Conn +} + +func NewEIOClient(config EIOClientConfig) (*EIOClient, error) { + var ( + client = new(EIOClient) + err error + ) + + socketURL, err := url.Parse(config.URL) + if err != nil { + return nil, errors.Wrap(err, "Unable to parse URL") + } + + qVars := socketURL.Query() + qVars.Set("EIO", "3") + qVars.Set("transport", "websocket") + + socketURL.RawQuery = qVars.Encode() + + client.cfg = config + client.errC = make(chan error, 10) + client.writeMutex = new(sync.Mutex) + client.ws, _, err = client.dialer.Dial(socketURL.String(), http.Header{}) + if err != nil { + return nil, errors.Wrap(err, "Unable to dial to given URL") + } + + // Mark connection as established + client.isConnected = true + + defaultCloseHandler := client.ws.CloseHandler() + client.ws.SetCloseHandler(func(code int, text string) error { + client.isConnected = false + return defaultCloseHandler(code, text) + }) + + go func() { + for client.isConnected { + messageType, message, err := client.ws.ReadMessage() + if err != nil { + client.errC <- err + continue + } + + if err = client.handleMessage(messageType, message); err != nil { + client.errC <- err + continue + } + } + }() + + return client, nil +} + +func (e EIOClient) Close() error { return e.ws.Close() } + +func (e EIOClient) Errors() <-chan error { return e.errC } + +func (e EIOClient) SendTextMessage(t EIOMessageType, data string) error { + if !e.isConnected { + return ErrNotConnected + } + + e.writeMutex.Lock() + defer e.writeMutex.Unlock() + + return errors.Wrap( + e.ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("%d%s", t, data))), + "Unable to transmit message", + ) +} + +func (e EIOClient) handleMessage(messageType int, message []byte) error { + if len(message) < 1 { + return errors.New("Empty message received") + } + + var mType EIOMessageType + + switch messageType { + + case websocket.TextMessage: + v, err := strconv.Atoi(string(message[0])) + if err != nil { + return errors.Wrap(err, "Unable to parse message type") + } + mType = EIOMessageType(v) + + case websocket.BinaryMessage: + mType = EIOMessageType(message[0]) + + } + + switch mType { + + case EIOMessageTypeOpen: + var handshake eioSessionStart + + if err := json.Unmarshal(message[1:], &handshake); err != nil { + return errors.Wrap(err, "Unable to unmarshal handshake") + } + + // Start pinger + go func() { + for t := time.NewTicker(time.Duration(handshake.PingInterval) * time.Millisecond); e.isConnected; <-t.C { + e.SendTextMessage(EIOMessageTypePing, "") + } + }() + + case EIOMessageTypeClose: + e.ws.Close() + + case EIOMessageTypePing: + e.SendTextMessage(EIOMessageTypePong, "") + + case EIOMessageTypePong: + // Ignore + + case EIOMessageTypeMessage: + var hdl func([]byte) error + + switch messageType { + case websocket.TextMessage: + hdl = e.cfg.MessageHandlerText + case websocket.BinaryMessage: + hdl = e.cfg.MessageHandlerBinary + } + + if err := hdl(message[1:]); err != nil { + return errors.Wrap(err, "Failed to handle message") + } + + case EIOMessageTypeUpgrade: + // Ignore? + + case EIOMessageTypeNoop: + // Noop! + + default: + return errors.Errorf("Received unknown EIO message type %d", mType) + + } + + return nil +} diff --git a/sioclient/go.mod b/sioclient/go.mod new file mode 100644 index 0000000..e5466ac --- /dev/null +++ b/sioclient/go.mod @@ -0,0 +1,8 @@ +module github.com/Luzifer/lounge-control/sioclient + +go 1.14 + +require ( + github.com/gorilla/websocket v1.4.2 + github.com/pkg/errors v0.9.1 +) diff --git a/sioclient/go.sum b/sioclient/go.sum new file mode 100644 index 0000000..d27541e --- /dev/null +++ b/sioclient/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/sioclient/sio.go b/sioclient/sio.go new file mode 100644 index 0000000..7d3a94e --- /dev/null +++ b/sioclient/sio.go @@ -0,0 +1,181 @@ +package sioclient + +import ( + "bytes" + "encoding/json" + "strconv" + + "github.com/pkg/errors" +) + +type MessageType int + +const ( + MessageTypeConnect MessageType = iota + MessageTypeDisconnect + MessageTypeEvent + MessageTypeAck + MessageTypeError + MessageTypeBinaryEvent + MessageTypeBinaryAck +) + +type Config struct { + MessageHandler func(*Message) error + URL string +} + +type Client struct { + EIO *EIOClient + + cfg Config +} + +func New(c Config) (*Client, error) { + var ( + client = new(Client) + err error + ) + + client.cfg = c + + if client.EIO, err = NewEIOClient(EIOClientConfig{ + MessageHandlerText: client.handleTextMessage, + URL: c.URL, + }); err != nil { + return nil, errors.Wrap(err, "Unable to create EIO client") + } + + return client, nil +} + +func (c Client) Close() error { + return c.EIO.Close() +} + +func (c Client) handleTextMessage(msg []byte) error { + m, err := c.parseProto(msg) + if err != nil { + return errors.Wrap(err, "Unable to parse message") + } + + return c.cfg.MessageHandler(m) +} + +type Message struct { + Type MessageType + Namespace string + ID int + Payload []json.RawMessage +} + +func NewMessage(sType MessageType, id int, payloadType string, data interface{}) (*Message, error) { + out := &Message{ + Type: sType, + Namespace: "/", + ID: id, + Payload: make([]json.RawMessage, 2), + } + + var err error + + if out.Payload[0], err = json.Marshal(payloadType); err != nil { + return nil, errors.Wrap(err, "Unable to marshal payloadType") + } + + if out.Payload[1], err = json.Marshal(data); err != nil { + return nil, errors.Wrap(err, "Unable to marshal data") + } + + return out, nil +} + +func (m Message) Encode() (string, error) { + data, err := json.Marshal(m.Payload) + if err != nil { + return "", errors.Wrap(err, "Unable to marshal payload") + } + + var msg = new(bytes.Buffer) + msg.WriteString(strconv.Itoa(int(m.Type))) + + if m.Namespace != "" && m.Namespace != "/" { + msg.WriteString(m.Namespace + ",") + } + + if m.ID > 0 { + msg.WriteString(strconv.Itoa(m.ID)) + } + + msg.Write(data) + + return msg.String(), nil +} + +func (m Message) PayloadType() (string, error) { + if len(m.Payload) == 0 { + return "", errors.New("No payload type available") + } + + var t string + err := json.Unmarshal(m.Payload[0], &t) + return t, errors.Wrap(err, "Unable to unmarshal payload type") +} + +func (m Message) Send(c *Client) error { + raw, err := m.Encode() + if err != nil { + return errors.Wrap(err, "Unable to encode message") + } + + return c.EIO.SendTextMessage(EIOMessageTypeMessage, raw) +} + +func (m Message) UnmarshalPayload(out interface{}) error { return json.Unmarshal(m.Payload[1], out) } + +func (c Client) parseProto(msg []byte) (*Message, error) { + var ( + err error + outMsg = new(Message) + ptr int + ) + + if len(msg) < 1 { + return nil, errors.New("Message was empty") + } + + // Get message type + mType, err := strconv.Atoi(string(msg[ptr])) + if err != nil { + return nil, errors.Wrap(err, "Unable to parse message type") + } + outMsg.Type = MessageType(mType) + ptr++ + + // Message contains only message type + if len(msg[ptr:]) == 0 { + return outMsg, nil + } + + // Binary + if outMsg.Type == MessageTypeBinaryEvent || outMsg.Type == MessageTypeBinaryAck { + return nil, errors.New("Binary is not supported") + } + + // Check for namespace + if msg[ptr] == '/' { + return nil, errors.New("Namespaces is not supported") + } + + // Read message ID if any + if outMsg.ID, err = strconv.Atoi(string(msg[ptr])); err == nil { + ptr++ + } + + // If there is no more data we have an empty message + if len(msg[ptr:]) == 0 || outMsg.Type == MessageTypeConnect { + return outMsg, nil + } + + return outMsg, errors.Wrap(json.Unmarshal(msg[ptr:], &outMsg.Payload), "Unable to unmarshal message") +}