diff --git a/_examples/rpc/rpc.go b/_examples/rpc/rpc.go new file mode 100644 index 000000000..9925be79f --- /dev/null +++ b/_examples/rpc/rpc.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "os/signal" + "syscall" + + "github.com/disgoorg/log" + "github.com/disgoorg/snowflake/v2" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/rest" + "github.com/disgoorg/disgo/rpc" +) + +var ( + clientID = snowflake.GetEnv("disgo_client_id") + clientSecret = os.Getenv("disgo_client_secret") + channelID = snowflake.GetEnv("disgo_channel_id") +) + +func main() { + log.SetLevel(log.LevelDebug) + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Info("example is starting...") + + oauth2Client := rest.NewOAuth2(rest.NewClient("")) + + client, err := rpc.NewClient(clientID) + if err != nil { + log.Fatal(err) + return + } + defer client.Close() + + var tokenRs *discord.AccessTokenResponse + if err = client.Send(rpc.Message{ + Cmd: rpc.CmdAuthorize, + Args: rpc.CmdArgsAuthorize{ + ClientID: clientID, + Scopes: []discord.OAuth2Scope{discord.OAuth2ScopeRPC, discord.OAuth2ScopeMessagesRead}, + }, + }, rpc.NewHandler(func(data rpc.CmdRsAuthorize) { + tokenRs, err = oauth2Client.GetAccessToken(clientID, clientSecret, data.Code, "http://localhost") + if err != nil { + log.Fatal(err) + } + })); err != nil { + log.Fatal(err) + } + + if err = client.Send(rpc.Message{ + Cmd: rpc.CmdAuthenticate, + Args: rpc.CmdArgsAuthenticate{ + AccessToken: tokenRs.AccessToken, + }, + }, nil); err != nil { + log.Fatal(err) + } + + if err = client.Subscribe(rpc.EventMessageCreate, rpc.CmdArgsSubscribeMessage{ + ChannelID: channelID, + }, rpc.NewHandler(func(data rpc.EventDataMessageCreate) { + log.Info("message: ", data.Message.Content) + })); err != nil { + log.Fatal(err) + } + + log.Info("example is now running. Press CTRL-C to exit.") + s := make(chan os.Signal, 1) + signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + <-s +} diff --git a/discord/activity.go b/discord/activity.go index 0f4f019bf..d6781814f 100644 --- a/discord/activity.go +++ b/discord/activity.go @@ -24,10 +24,10 @@ const ( // Activity represents the fields of a user's presence type Activity struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` Type ActivityType `json:"type"` - URL *string `json:"url"` + URL *string `json:"url,omitempty"` CreatedAt time.Time `json:"created_at"` Timestamps *ActivityTimestamps `json:"timestamps,omitempty"` SyncID *string `json:"sync_id,omitempty"` @@ -40,7 +40,7 @@ type Activity struct { Secrets *ActivitySecrets `json:"secrets,omitempty"` Instance *bool `json:"instance,omitempty"` Flags ActivityFlags `json:"flags,omitempty"` - Buttons []string `json:"buttons"` + Buttons []string `json:"buttons,omitempty"` } func (a *Activity) UnmarshalJSON(data []byte) error { diff --git a/discord/application.go b/discord/application.go index d3045bae8..1925bdd79 100644 --- a/discord/application.go +++ b/discord/application.go @@ -72,6 +72,14 @@ type ApplicationUpdate struct { Tags []string `json:"tags,omitempty"` } +type OAuth2Application struct { + ID snowflake.ID `json:"id"` + Name string `json:"name"` + Icon *string `json:"icon,omitempty"` + Description string `json:"description"` + RPCOrigins []string `json:"rpc_origins"` +} + type PartialApplication struct { ID snowflake.ID `json:"id"` Flags ApplicationFlags `json:"flags"` diff --git a/go.mod b/go.mod index 2517f4d80..61f43d8fc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/disgoorg/disgo go 1.18 require ( + github.com/Microsoft/go-winio v0.5.2 github.com/disgoorg/json v1.1.0 github.com/disgoorg/log v1.2.1 github.com/disgoorg/snowflake/v2 v2.0.1 diff --git a/go.sum b/go.sum index 5f0f4570e..5c0d92e2e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 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= @@ -13,9 +15,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sasha-s/go-csync v0.0.0-20210812194225-61421b77c44b h1:qYTY2tN72LhgDj2rtWG+LI6TXFl2ygFQQ4YezfVaGQE= github.com/sasha-s/go-csync v0.0.0-20210812194225-61421b77c44b/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= @@ -24,6 +28,8 @@ golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI= golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/rest/oauth2.go b/rest/oauth2.go index a09484307..a90148cb5 100644 --- a/rest/oauth2.go +++ b/rest/oauth2.go @@ -115,9 +115,11 @@ func (s *oAuth2Impl) UpdateCurrentUserApplicationRoleConnection(bearerToken stri func (s *oAuth2Impl) exchangeAccessToken(clientID snowflake.ID, clientSecret string, grantType discord.GrantType, codeOrRefreshToken string, redirectURI string, opts ...RequestOpt) (exchange *discord.AccessTokenResponse, err error) { values := url.Values{ - "client_id": []string{clientID.String()}, - "client_secret": []string{clientSecret}, - "grant_type": []string{grantType.String()}, + "client_id": []string{clientID.String()}, + "grant_type": []string{grantType.String()}, + } + if clientSecret != "" { + values["client_secret"] = []string{clientSecret} } switch grantType { case discord.GrantTypeAuthorizationCode: diff --git a/rpc/client.go b/rpc/client.go new file mode 100644 index 000000000..264c59397 --- /dev/null +++ b/rpc/client.go @@ -0,0 +1,237 @@ +package rpc + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "time" + + "github.com/disgoorg/json" + "github.com/disgoorg/log" + "github.com/disgoorg/snowflake/v2" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/internal/insecurerandstr" +) + +var Version = 1 + +type TransportCreate func(clientID snowflake.ID, origin string) (Transport, error) + +type Transport interface { + NextWriter() (io.WriteCloser, error) + NextReader() (io.Reader, error) + Close() error +} + +type Client interface { + Logger() log.Logger + ServerConfig() ServerConfig + User() discord.User + V() int + Transport() Transport + + Subscribe(event Event, args CmdArgs, handler Handler) error + Unsubscribe(event Event, args CmdArgs) error + Send(message Message, handler Handler) error + Close() +} + +func NewClient(clientID snowflake.ID, opts ...ConfigOpt) (Client, error) { + config := DefaultConfig() + config.Apply(opts) + + client := &clientImpl{ + logger: config.Logger, + eventHandlers: map[Event]Handler{}, + commandHandlers: map[string]internalHandler{}, + readyChan: make(chan struct{}, 1), + } + + if config.Transport == nil { + var err error + config.Transport, err = config.TransportCreate(clientID, config.Origin) + if err != nil { + return nil, err + } + } + client.transport = config.Transport + + go client.listen(client.transport) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-client.readyChan: + } + + return client, nil +} + +type clientImpl struct { + logger log.Logger + transport Transport + + eventHandlers map[Event]Handler + commandHandlers map[string]internalHandler + + readyChan chan struct{} + user discord.User + serverConfig ServerConfig + v int +} + +func (c *clientImpl) Logger() log.Logger { + return c.logger +} + +func (c *clientImpl) ServerConfig() ServerConfig { + return c.serverConfig +} + +func (c *clientImpl) User() discord.User { + return c.user +} + +func (c *clientImpl) V() int { + return c.v +} + +func (c *clientImpl) Transport() Transport { + return c.transport +} + +func (c *clientImpl) send(r io.Reader) error { + writer, err := c.transport.NextWriter() + if err != nil { + return err + } + defer writer.Close() + + buff := new(bytes.Buffer) + newWriter := io.MultiWriter(writer, buff) + + _, err = io.Copy(newWriter, r) + if err != nil { + return err + } + + data, _ := io.ReadAll(buff) + c.logger.Tracef("Sending message: data: %s", string(data)) + + return err +} + +func (c *clientImpl) Subscribe(event Event, args CmdArgs, handler Handler) error { + if _, ok := c.eventHandlers[event]; ok { + return errors.New("event already subscribed") + } + c.eventHandlers[event] = handler + return c.Send(Message{ + Cmd: CmdSubscribe, + Args: args, + Event: event, + }, nil) +} + +func (c *clientImpl) Unsubscribe(event Event, args CmdArgs) error { + if _, ok := c.eventHandlers[event]; ok { + delete(c.eventHandlers, event) + return c.Send(Message{ + Cmd: CmdUnsubscribe, + Args: args, + Event: event, + }, nil) + } + return nil +} + +func (c *clientImpl) Send(message Message, handler Handler) error { + nonce := insecurerandstr.RandStr(32) + buff := new(bytes.Buffer) + + message.Nonce = nonce + if err := json.NewEncoder(buff).Encode(message); err != nil { + return err + } + + errChan := make(chan error, 1) + + c.commandHandlers[nonce] = internalHandler{ + handler: handler, + errChan: errChan, + } + if err := c.send(buff); err != nil { + delete(c.commandHandlers, nonce) + close(errChan) + return err + } + return <-errChan +} + +func (c *clientImpl) listen(transport Transport) { +loop: + for { + reader, err := transport.NextReader() + if errors.Is(err, net.ErrClosed) { + c.logger.Error("Connection closed") + break loop + } + if err != nil { + c.logger.Errorf("Error reading message: %s", err) + continue + } + + data, err := io.ReadAll(reader) + if err != nil { + c.logger.Errorf("Error reading message: %s", err) + continue + } + c.logger.Tracef("Received message: data: %s", string(data)) + + reader = bytes.NewReader(data) + + var v Message + if err = json.NewDecoder(reader).Decode(&v); err != nil { + c.logger.Errorf("failed to decode message: %s", err) + continue + } + + if v.Cmd == CmdDispatch { + if d, ok := v.Data.(EventDataReady); ok { + c.readyChan <- struct{}{} + c.user = d.User + c.serverConfig = d.Config + c.v = d.V + } + if handler, ok := c.eventHandlers[v.Event]; ok { + handler.Handle(v.Data) + } + continue + } + if handler, ok := c.commandHandlers[v.Nonce]; ok { + if v.Event == EventError { + handler.errChan <- v.Data.(EventDataError) + } else { + if handler.handler != nil { + handler.handler.Handle(v.Data) + } + handler.errChan <- nil + } + close(handler.errChan) + delete(c.commandHandlers, v.Nonce) + } else { + c.logger.Errorf("No handler for nonce: %s", v.Nonce) + } + } +} + +func (c *clientImpl) Close() { + if c.transport != nil { + _ = c.transport.Close() + } +} diff --git a/rpc/cmd.go b/rpc/cmd.go new file mode 100644 index 000000000..6fcc81a4a --- /dev/null +++ b/rpc/cmd.go @@ -0,0 +1,343 @@ +package rpc + +import ( + "time" + + "github.com/disgoorg/snowflake/v2" + + "github.com/disgoorg/disgo/discord" +) + +type CmdArgsAuthorize struct { + ClientID snowflake.ID `json:"client_id"` + Scopes []discord.OAuth2Scope `json:"scopes"` + RPCToken string `json:"rpc_token,omitempty"` + Username string `json:"username,omitempty"` +} + +func (CmdArgsAuthorize) cmdArgs() {} + +type CmdRsAuthorize struct { + Code string `json:"code"` +} + +func (CmdRsAuthorize) messageData() {} + +type CmdArgsAuthenticate struct { + AccessToken string `json:"access_token"` +} + +func (CmdArgsAuthenticate) cmdArgs() {} + +type CmdRsAuthenticate struct { + User discord.User `json:"user"` + Scopes []discord.OAuth2Scope `json:"scopes"` + Expires time.Time `json:"expires"` + Application discord.OAuth2Application `json:"application"` +} + +func (CmdRsAuthenticate) messageData() {} + +type CmdArgsGetGuild struct { + GuildID snowflake.ID `json:"guild_id"` + Timeout int `json:"timeout"` +} + +func (CmdArgsGetGuild) cmdArgs() {} + +type PartialGuild struct { + ID snowflake.ID `json:"id"` + Name string `json:"name"` + IconURL *string `json:"icon_url,omitempty"` +} + +type CmdRsGetGuild struct { + PartialGuild +} + +func (CmdRsGetGuild) messageData() {} + +type CmdRsGetGuilds struct { + Guilds []PartialGuild `json:"guilds"` +} + +func (CmdRsGetGuilds) messageData() {} + +type CmdArgsGetChannel struct { + ChannelID snowflake.ID `json:"channel_id"` +} + +func (CmdArgsGetChannel) cmdArgs() {} + +type PartialChannel struct { + ID snowflake.ID `json:"id"` + GuildID *snowflake.ID `json:"guild_id,omitempty"` + Name string `json:"name"` + Type discord.ChannelType `json:"type"` + Topic *string `json:"topic,omitempty"` + Bitrate int `json:"bitrate,omitempty"` + UserLimit int `json:"user_limit,omitempty"` + Position int `json:"position,omitempty"` + VoiceStates []VoiceState `json:"voice_states,omitempty"` + Messages []Message `json:"messages,omitempty"` +} + +type VoiceState struct { + discord.VoiceState + Volume int `json:"volume"` + Pan Pan `json:"pan"` +} + +type Pan struct { + Left float32 `json:"left"` + Right float32 `json:"right"` +} + +type CmdRsGetChannel struct { + PartialChannel +} + +func (CmdRsGetChannel) messageData() {} + +type CmdArgsGetChannels struct { + GuildID snowflake.ID `json:"guild_id"` +} + +func (CmdArgsGetChannels) cmdArgs() {} + +type CmdRsGetChannels struct { + Channels []PartialChannel `json:"channels"` +} + +func (CmdRsGetChannels) messageData() {} + +type CmdArgsSetUserVoiceSettings struct { + UserID snowflake.ID `json:"user_id"` + Pan *Pan `json:"pan,omitempty"` + Volume *int `json:"volume,omitempty"` + Mute *bool `json:"mute,omitempty"` +} + +func (CmdArgsSetUserVoiceSettings) cmdArgs() {} + +type CmdRsSetUserVoiceSettings struct { + UserID snowflake.ID `json:"user_id"` + Pan Pan `json:"pan"` + Volume int `json:"volume"` + Mute bool `json:"mute"` +} + +func (CmdRsSetUserVoiceSettings) messageData() {} + +type CmdArgsSelectVoiceChannel struct { + ChannelID snowflake.ID `json:"channel_id"` + Timeout int `json:"timeout"` + Force bool `json:"force"` +} + +func (CmdArgsSelectVoiceChannel) cmdArgs() {} + +type CmdRsSelectVoiceChannel struct { + PartialChannel +} + +func (CmdRsSelectVoiceChannel) messageData() {} + +type CmdRsGetSelectedVoiceChannel struct { + *PartialChannel +} + +func (CmdRsGetSelectedVoiceChannel) messageData() {} + +type CmdArgsSelectTextChannel struct { + ChannelID *snowflake.ID `json:"channel_id"` + Timeout int `json:"timeout"` +} + +func (CmdArgsSelectTextChannel) cmdArgs() {} + +type CmdRsSelectTextChannel struct { + *PartialChannel +} + +func (CmdRsSelectTextChannel) messageData() {} + +type CmdRsGetVoiceSettings struct { + VoiceSettings +} + +type VoiceSettings struct { + Input VoiceSettingsIO `json:"input"` + Output VoiceSettingsIO `json:"output"` + Mode VoiceSettingsMode `json:"mode"` + AutomaticGainControl bool `json:"automatic_gain_control"` + EchoCancellation bool `json:"echo_cancellation"` + NoiseSuppression bool `json:"noise_suppression"` + QOS bool `json:"qos"` + SilenceWarning bool `json:"silence_warning"` + Deaf bool `json:"deaf"` + Mute bool `json:"mute"` +} + +type Device struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type VoiceSettingsIO struct { + DeviceID string `json:"device_id"` + Volume int `json:"volume"` + AvailableDevices []Device `json:"available_devices"` +} + +type VoiceSettingsModeType string + +const ( + VoiceSettingsModeTypePushToTalk VoiceSettingsModeType = "PUSH_TO_TALK" + VoiceSettingsModeTypeActivity VoiceSettingsModeType = "ACTIVITY" +) + +type VoiceSettingsMode struct { + Type VoiceSettingsModeType `json:"type"` + AutoThreshold bool `json:"auto_threshold"` + Threshold int `json:"threshold"` + Shortcut ShortcutKeyCombo `json:"shortcut"` + Delay float32 `json:"delay"` +} + +type ShortcutKeyComboType int + +const ( + ShortcutKeyComboTypeKeyboardKey ShortcutKeyComboType = iota + ShortcutKeyComboTypeMouseButton + ShortcutKeyComboTypeModifierKey + ShortcutKeyComboTypeGamepadButton +) + +type ShortcutKeyCombo struct { + Type ShortcutKeyComboType `json:"type"` + Code int `json:"code"` + Name string `json:"name"` +} + +func (CmdRsGetVoiceSettings) messageData() {} + +type CmdArgsSetVoiceSettings struct { + Input *VoiceSettings `json:"input"` + Output *VoiceSettings `json:"output"` + Mode *VoiceSettingsMode `json:"mode"` + AutomaticGainControl *bool `json:"automatic_gain_control"` + EchoCancellation *bool `json:"echo_cancellation"` + NoiseSuppression *bool `json:"noise_suppression"` + QOS *bool `json:"qos"` + SilenceWarning *bool `json:"silence_warning"` + Deaf *bool `json:"deaf"` + Mute *bool `json:"mute"` +} + +func (CmdArgsSetVoiceSettings) cmdArgs() {} + +type CmdRsSetVoiceSettings struct { + VoiceSettings +} + +func (CmdRsSetVoiceSettings) messageData() {} + +type DeviceType string + +const ( + DeviceTypeAudioInput DeviceType = "audioinput" + DeviceTypeAudioOutput DeviceType = "audiooutput" + DeviceTypeVideoInput DeviceType = "videoinput" +) + +type DeviceVendor struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type DeviceModel struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type CertifiedDevice struct { + Type DeviceType `json:"type"` + ID string `json:"id"` + Vendor DeviceVendor `json:"vendor"` + Model DeviceModel `json:"model"` + Related []string `json:"related"` + EchoCancellation bool `json:"echo_cancellation"` + NoiseSuppression bool `json:"noise_suppression"` + AutomaticGainControl bool `json:"automatic_gain_control"` + HardwareMute bool `json:"hardware_mute"` +} + +type CmdArgsSetCertifiedDevices struct { + Devices []CertifiedDevice `json:"devices"` +} + +func (CmdArgsSetCertifiedDevices) cmdArgs() {} + +type CmdArgsSendActivityJoinInvite struct { + UserID snowflake.ID `json:"user_id"` +} + +func (CmdArgsSendActivityJoinInvite) cmdArgs() {} + +type CmdArgsCloseActivityRequest struct { + UserID snowflake.ID `json:"user_id"` +} + +func (CmdArgsCloseActivityRequest) cmdArgs() {} + +type CmdArgsSetActivity struct { + PID int `json:"pid"` + Activity discord.Activity `json:"activity"` +} + +func (CmdArgsSetActivity) cmdArgs() {} + +type CmdRsSetActivity struct { + discord.Activity +} + +func (CmdRsSetActivity) messageData() {} + +type CmdArgsSubscribe interface { + CmdArgs + cmdArgsSubscribe() +} + +type CmdArgsSubscribeMessage struct { + ChannelID snowflake.ID `json:"channel_id"` +} + +func (CmdArgsSubscribeMessage) cmdArgs() {} +func (CmdArgsSubscribeMessage) cmdArgsSubscribe() {} + +type CmdArgsSubscribeGuild struct { + GuildID snowflake.ID `json:"guild_id"` +} + +func (CmdArgsSubscribeGuild) cmdArgs() {} +func (CmdArgsSubscribeGuild) cmdArgsSubscribe() {} + +type CmdArgsSubscribeSpeaking struct { + ChannelID snowflake.ID `json:"channel_id"` +} + +func (CmdArgsSubscribeSpeaking) cmdArgs() {} +func (CmdArgsSubscribeSpeaking) cmdArgsSubscribe() {} + +type CmdRsSubscribe struct { + Evt string `json:"evt"` +} + +func (CmdRsSubscribe) messageData() {} + +type CmdRsUnsubscribe struct { + Evt string `json:"evt"` +} + +func (CmdRsUnsubscribe) messageData() {} diff --git a/rpc/config.go b/rpc/config.go new file mode 100644 index 000000000..d0b005f12 --- /dev/null +++ b/rpc/config.go @@ -0,0 +1,73 @@ +package rpc + +import ( + "github.com/disgoorg/log" +) + +// DefaultConfig is the configuration which is used by default +func DefaultConfig() *Config { + return &Config{ + Logger: log.Default(), + TransportCreate: NewIPCTransport, + } +} + +type Config struct { + Logger log.Logger + Transport Transport + TransportCreate TransportCreate + Origin string +} + +// ConfigOpt can be used to supply optional parameters to NewIPCClient or NewWSClient +type ConfigOpt func(config *Config) + +// Apply applies the given ConfigOpt(s) to the Config +func (c *Config) Apply(opts []ConfigOpt) { + for _, opt := range opts { + opt(c) + } +} + +// WithLogger applies a custom logger to the rpc Client +func WithLogger(logger log.Logger) ConfigOpt { + return func(config *Config) { + config.Logger = logger + } +} + +// WithTransport applies your own Transport to the rpc Client +func WithTransport(transport Transport) ConfigOpt { + return func(config *Config) { + config.Transport = transport + } +} + +// WithTransportCreate applies a custom logger to the rpc Client +func WithTransportCreate(transportCreate TransportCreate) ConfigOpt { + return func(config *Config) { + config.TransportCreate = transportCreate + } +} + +// WithIPCTransport applies the ipc Transport to the rpc Client +func WithIPCTransport() ConfigOpt { + return func(config *Config) { + config.TransportCreate = NewIPCTransport + } +} + +// WithWSTransport applies the ws Transport to the rpc Client +func WithWSTransport(origin string) ConfigOpt { + return func(config *Config) { + config.TransportCreate = NewWSTransport + config.Origin = origin + } +} + +// WithOrigin sets the origin for the ws Transport +func WithOrigin(origin string) ConfigOpt { + return func(config *Config) { + config.Origin = origin + } +} diff --git a/rpc/event.go b/rpc/event.go new file mode 100644 index 000000000..65c39588e --- /dev/null +++ b/rpc/event.go @@ -0,0 +1,176 @@ +package rpc + +import ( + "fmt" + + "github.com/disgoorg/snowflake/v2" + + "github.com/disgoorg/disgo/discord" +) + +type ServerConfig struct { + CDNHost string `json:"cdn_host"` + APIEndpoint string `json:"api_endpoint"` + Environment string `json:"environment"` +} + +type EventDataReady struct { + V int `json:"v"` + Config ServerConfig `json:"config"` + User discord.User `json:"user"` +} + +func (EventDataReady) messageData() {} + +var _ error = (*EventDataError)(nil) + +type EventDataError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e EventDataError) Error() string { + return fmt.Sprintf("%d: %s", e.Code, e.Message) +} + +func (EventDataError) messageData() {} + +type EventDataGuildStatus struct { + Guild PartialGuild `json:"guild"` +} + +func (EventDataGuildStatus) messageData() {} + +type EventDataGuildCreate struct { + ID snowflake.ID `json:"id"` + Name string `json:"name"` +} + +func (EventDataGuildCreate) messageData() {} + +type EventDataChannelCreate struct { + ID snowflake.ID `json:"id"` + Name string `json:"name"` + Type discord.ChannelType `json:"type"` +} + +func (EventDataChannelCreate) messageData() {} + +type EventDataVoiceChannelSelect struct { + ChannelID *snowflake.ID `json:"channel_id"` + GuildID *snowflake.ID `json:"guild_id"` +} + +func (EventDataVoiceChannelSelect) messageData() {} + +type EventDataVoiceSettingsUpdate struct { + VoiceSettings +} + +func (EventDataVoiceSettingsUpdate) messageData() {} + +type EventDataVoiceStateCreate struct { + VoiceState +} + +func (EventDataVoiceStateCreate) messageData() {} + +type EventDataVoiceStateUpdate struct { + VoiceState +} + +func (EventDataVoiceStateUpdate) messageData() {} + +type EventDataVoiceStateDelete struct { + VoiceState +} + +func (EventDataVoiceStateDelete) messageData() {} + +type VoiceStateType string + +const ( + VoiceStateTypeDisconnected VoiceStateType = "DISCONNECTED" + VoiceStateTypeAwaitingEndpoint VoiceStateType = "AWAITING_ENDPOINT" + VoiceStateTypeAuthenticating VoiceStateType = "AUTHENTICATING" + VoiceStateTypeConnecting VoiceStateType = "CONNECTING" + VoiceStateTypeConnected VoiceStateType = "CONNECTED" + VoiceStateTypeVoiceDisconnected VoiceStateType = "VOICE_DISCONNECTED" + VoiceStateTypeVoiceConnecting VoiceStateType = "VOICE_CONNECTING" + VoiceStateTypeVoiceConnected VoiceStateType = "VOICE_CONNECTED" + VoiceStateTypeNoRoute VoiceStateType = "NO_ROUTE" + VoiceStateTypeICEChecking VoiceStateType = "ICE_CHECKING" +) + +type EventDataVoiceConnectionStatus struct { + State VoiceStateType `json:"state"` + Hostname string `json:"hostname"` + Pings []float32 `json:"pings"` + AveragePing float32 `json:"average_ping"` + LastPing float32 `json:"last_ping"` +} + +func (EventDataVoiceConnectionStatus) messageData() {} + +type EventDataMessageCreate struct { + ChannelID snowflake.ID `json:"channel_id"` + Message discord.Message `json:"message"` +} + +func (EventDataMessageCreate) messageData() {} + +type EventDataMessageUpdate struct { + ChannelID snowflake.ID `json:"channel_id"` + Message discord.Message `json:"message"` +} + +func (EventDataMessageUpdate) messageData() {} + +type EventDataMessageDelete struct { + ChannelID snowflake.ID `json:"channel_id"` + Message struct { + ID snowflake.ID `json:"id"` + } `json:"message"` +} + +func (EventDataMessageDelete) messageData() {} + +type EventDataSpeakingStart struct { + UserID snowflake.ID `json:"user_id"` +} + +func (EventDataSpeakingStart) messageData() {} + +type EventDataSpeakingStop struct { + UserID snowflake.ID `json:"user_id"` +} + +func (EventDataSpeakingStop) messageData() {} + +type EventDataNotificationCreate struct { + ChannelID snowflake.ID `json:"channel_id"` + Message discord.Message `json:"message"` + IconURL string `json:"icon_url"` + Title string `json:"title"` + Body string `json:"body"` +} + +func (EventDataNotificationCreate) messageData() {} + +type EventDataActivityJoin struct { + Secret string `json:"secret"` +} + +func (EventDataActivityJoin) messageData() {} + +type EventDataActivitySpectate struct { + Secret string `json:"secret"` +} + +func (EventDataActivitySpectate) messageData() {} + +type EventDataActivityJoinRequest struct { + User discord.User `json:"user"` +} + +func (EventDataActivityJoinRequest) messageData() {} diff --git a/rpc/handler.go b/rpc/handler.go new file mode 100644 index 000000000..34d5ea4a6 --- /dev/null +++ b/rpc/handler.go @@ -0,0 +1,26 @@ +package rpc + +type internalHandler struct { + handler Handler + errChan chan error +} + +type Handler interface { + Handle(data MessageData) +} + +func NewHandler[T MessageData](handler func(data T)) Handler { + return &defaultHandler[T]{ + handler: handler, + } +} + +type defaultHandler[T MessageData] struct { + handler func(data T) +} + +func (h *defaultHandler[T]) Handle(data MessageData) { + if d, ok := data.(T); ok { + h.handler(d) + } +} diff --git a/rpc/ipc_transport.go b/rpc/ipc_transport.go new file mode 100644 index 000000000..525e6a6f0 --- /dev/null +++ b/rpc/ipc_transport.go @@ -0,0 +1,145 @@ +package rpc + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/json" + "io" + "net" + + "github.com/disgoorg/snowflake/v2" +) + +type Handshake struct { + V int `json:"v"` + ClientID snowflake.ID `json:"client_id"` +} + +type OpCode int32 + +const ( + OpCodeHandshake OpCode = iota + OpCodeFrame + OpCodeClose + OpCodePing + OpCodePong +) + +func NewIPCTransport(clientID snowflake.ID, _ string) (Transport, error) { + var ( + conn net.Conn + err error + ) + for i := 0; i < 10; i++ { + conn, err = openPipe(getDiscordIPCPath(i)) + if err == nil { + break + } + } + if err != nil { + return nil, err + } + + t := &ipcTransport{ + conn: conn, + w: bufio.NewWriter(conn), + } + + if err = t.handshake(clientID); err != nil { + return nil, err + } + + return t, nil +} + +type ipcTransport struct { + conn net.Conn + w *bufio.Writer +} + +func (t *ipcTransport) handshake(clientID snowflake.ID) error { + w, err := t.nextWriter(OpCodeHandshake) + if err != nil { + return err + } + defer func() { + _ = w.Close() + }() + + return json.NewEncoder(w).Encode(Handshake{ + V: Version, + ClientID: clientID, + }) +} + +func (t *ipcTransport) NextWriter() (io.WriteCloser, error) { + return t.nextWriter(OpCodeFrame) +} + +func (t *ipcTransport) nextWriter(opCode OpCode) (io.WriteCloser, error) { + return &messageWriter{ + t: t, + opCode: opCode, + }, nil +} + +type messageWriter struct { + t *ipcTransport + opCode OpCode +} + +func (w *messageWriter) Write(p []byte) (int, error) { + if err := binary.Write(w.t.w, binary.LittleEndian, w.opCode); err != nil { + return 0, err + } + + if err := binary.Write(w.t.w, binary.LittleEndian, int32(len(p))); err != nil { + return 0, err + } + + return w.t.w.Write(p) +} + +func (w *messageWriter) Close() error { + return w.t.w.Flush() +} + +func (t *ipcTransport) NextReader() (io.Reader, error) { + var opCode OpCode + if err := binary.Read(t.conn, binary.LittleEndian, &opCode); err != nil { + return nil, err + } + + if opCode == OpCodeClose { + _ = t.Close() + return nil, net.ErrClosed + } + + var length int32 + if err := binary.Read(t.conn, binary.LittleEndian, &length); err != nil { + return nil, err + } + + data := make([]byte, length) + if _, err := t.conn.Read(data); err != nil { + return nil, err + } + + if opCode == OpCodePing { + if w, err := t.nextWriter(OpCodePong); err == nil { + _, _ = w.Write(data) + _ = w.Close() + } + return t.NextReader() + } + + return bytes.NewReader(data), nil +} + +func (t *ipcTransport) Close() error { + if err := binary.Write(t.conn, binary.LittleEndian, OpCodeClose); err != nil { + return err + } + return t.conn.Close() +} diff --git a/rpc/message.go b/rpc/message.go new file mode 100644 index 000000000..8b1407ba2 --- /dev/null +++ b/rpc/message.go @@ -0,0 +1,298 @@ +package rpc + +import ( + "fmt" + + "github.com/disgoorg/json" +) + +type Message struct { + Cmd Cmd `json:"cmd"` + Args CmdArgs `json:"args,omitempty"` + + Event Event `json:"evt,omitempty"` + Data MessageData `json:"data,omitempty"` + + Nonce string `json:"nonce,omitempty"` +} + +func (m *Message) UnmarshalJSON(data []byte) error { + type message Message + var v struct { + Data json.RawMessage `json:"data"` + message + } + + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + m.Cmd = v.Cmd + m.Event = v.Event + m.Nonce = v.Nonce + + var ( + messageData MessageData + err error + ) + + if v.Event == EventError { + var d EventDataError + err = json.Unmarshal(v.Data, &d) + messageData = d + } else { + switch v.Cmd { + case CmdDispatch: + switch v.Event { + case EventReady: + var d EventDataReady + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventGuildStatus: + var d EventDataGuildStatus + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventGuildCreate: + var d EventDataGuildCreate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventChannelCreate: + var d EventDataChannelCreate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventVoiceChannelSelect: + var d EventDataVoiceChannelSelect + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventVoiceSettingsUpdate: + var d EventDataVoiceSettingsUpdate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventVoiceStateCreate: + var d EventDataVoiceStateCreate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventVoiceStateUpdate: + var d EventDataVoiceStateUpdate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventVoiceStateDelete: + var d EventDataVoiceStateDelete + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventVoiceConnectionStatus: + var d EventDataVoiceConnectionStatus + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventMessageCreate: + var d EventDataMessageCreate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventMessageUpdate: + var d EventDataMessageUpdate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventMessageDelete: + var d EventDataMessageDelete + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventSpeakingStart: + var d EventDataSpeakingStart + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventSpeakingStop: + var d EventDataSpeakingStop + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventNotificationCreate: + var d EventDataNotificationCreate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventActivityJoin: + var d EventDataActivityJoin + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventActivitySpectate: + var d EventDataActivitySpectate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case EventActivityJoinRequest: + var d EventDataActivityJoinRequest + err = json.Unmarshal(v.Data, &d) + messageData = d + + default: + err = fmt.Errorf("unknown event: %s", v.Event) + } + + case CmdAuthorize: + var d CmdRsAuthorize + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdAuthenticate: + var d CmdRsAuthenticate + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdGetGuild: + var d CmdRsGetGuild + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdGetGuilds: + var d CmdRsGetGuilds + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdGetChannel: + var d CmdRsGetChannel + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdGetChannels: + var d CmdRsGetChannels + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdSetUserVoiceSettings: + var d CmdRsSetUserVoiceSettings + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdSelectVoiceChannel: + var d CmdRsSelectVoiceChannel + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdGetSelectedVoiceChannel: + var d CmdRsGetSelectedVoiceChannel + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdSelectTextChannel: + var d CmdRsSelectTextChannel + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdGetVoiceSettings: + var d CmdRsGetVoiceSettings + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdSetVoiceSettings: + var d CmdRsSetVoiceSettings + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdSetCertifiedDevices: + // no response data + + case CmdSendActivityJoinInvite: + // no response data + + case CmdCloseActivityRequest: + // no response data + + case CmdSubscribe: + var d CmdRsSubscribe + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdUnsubscribe: + var d CmdRsUnsubscribe + err = json.Unmarshal(v.Data, &d) + messageData = d + + case CmdSetActivity: + var d CmdRsSetActivity + err = json.Unmarshal(v.Data, &d) + messageData = d + + default: + err = fmt.Errorf("unknown cmd: %s", v.Cmd) + } + } + + if err != nil { + return err + } + + m.Data = messageData + return nil +} + +type Cmd string + +const ( + CmdDispatch Cmd = "DISPATCH" + CmdAuthorize Cmd = "AUTHORIZE" + CmdAuthenticate Cmd = "AUTHENTICATE" + CmdGetGuild Cmd = "GET_GUILD" + CmdGetGuilds Cmd = "GET_GUILDS" + CmdGetChannel Cmd = "GET_CHANNEL" + CmdGetChannels Cmd = "GET_CHANNELS" + CmdSubscribe Cmd = "SUBSCRIBE" + CmdUnsubscribe Cmd = "UNSUBSCRIBE" + CmdSetUserVoiceSettings Cmd = "SET_USER_VOICE_SETTINGS" + CmdSelectVoiceChannel Cmd = "SELECT_VOICE_CHANNEL" + CmdGetSelectedVoiceChannel Cmd = "GET_SELECTED_VOICE_CHANNEL" + CmdSelectTextChannel Cmd = "SELECT_TEXT_CHANNEL" + CmdGetVoiceSettings Cmd = "GET_VOICE_SETTINGS" + CmdSetVoiceSettings Cmd = "SET_VOICE_SETTINGS" + CmdSetCertifiedDevices Cmd = "SET_CERTIFIED_DEVICES" + CmdSetActivity Cmd = "SET_ACTIVITY" + CmdSendActivityJoinInvite Cmd = "SEND_ACTIVITY_JOIN_INVITE" + CmdCloseActivityRequest Cmd = "CLOSE_ACTIVITY_REQUEST" +) + +type CmdArgs interface { + cmdArgs() +} + +type Event string + +const ( + EventReady Event = "READY" + EventError Event = "ERROR" + EventGuildStatus Event = "GUILD_STATUS" + EventGuildCreate Event = "GUILD" + EventChannelCreate Event = "CHANNEL_CREATE" + EventVoiceChannelSelect Event = "VOICE_CHANNEL_SELECT" + EventVoiceStateCreate Event = "VOICE_STATE_CREATE" + EventVoiceStateUpdate Event = "VOICE_STATE_UPDATE" + EventVoiceStateDelete Event = "VOICE_STATE_DELETE" + EventVoiceSettingsUpdate Event = "VOICE_SETTINGS_UPDATE" + EventVoiceConnectionStatus Event = "VOICE_CONNECTION_STATUS" + EventSpeakingStart Event = "SPEAKING_START" + EventSpeakingStop Event = "SPEAKING_STOP" + EventMessageCreate Event = "MESSAGE_CREATE" + EventMessageUpdate Event = "MESSAGE_UPDATE" + EventMessageDelete Event = "MESSAGE_DELETE" + EventNotificationCreate Event = "NOTIFICATION_CREATE" + EventActivityJoin Event = "ACTIVITY_JOIN" + EventActivitySpectate Event = "ACTIVITY_SPECTATE" + EventActivityJoinRequest Event = "ACTIVITY_JOIN_REQUEST" +) + +type MessageData interface { + messageData() +} diff --git a/rpc/pipe_unix.go b/rpc/pipe_unix.go new file mode 100644 index 000000000..ecd5e934e --- /dev/null +++ b/rpc/pipe_unix.go @@ -0,0 +1,26 @@ +//go:build !windows + +package rpc + +import ( + "fmt" + "net" + "os" +) + +var paths = []string{"XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"} + +func getDiscordIPCPath(i int) string { + tmpPath := "/tmp" + for _, path := range paths { + if v := os.Getenv(path); v != "" { + tmpPath = v + break + } + } + return fmt.Sprintf("%sdiscord-ipc-%d", tmpPath, i) +} + +func openPipe(path string) (net.Conn, error) { + return net.Dial("unix", path) +} diff --git a/rpc/pipe_win.go b/rpc/pipe_win.go new file mode 100644 index 000000000..c11d1af22 --- /dev/null +++ b/rpc/pipe_win.go @@ -0,0 +1,18 @@ +//go:build windows + +package rpc + +import ( + "fmt" + "net" + + "github.com/Microsoft/go-winio" +) + +func getDiscordIPCPath(i int) string { + return fmt.Sprintf("\\\\?\\pipe\\discord-ipc-%d", i) +} + +func openPipe(path string) (net.Conn, error) { + return winio.DialPipe(path, nil) +} diff --git a/rpc/ws_transport.go b/rpc/ws_transport.go new file mode 100644 index 000000000..8de40c7e4 --- /dev/null +++ b/rpc/ws_transport.go @@ -0,0 +1,63 @@ +package rpc + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/disgoorg/snowflake/v2" + "github.com/gorilla/websocket" +) + +const WSVersion = 1 + +func NewWSTransport(clientID snowflake.ID, origin string) (Transport, error) { + var ( + conn *websocket.Conn + err error + ) + for port := 6463; port < 6472; port++ { + conn, err = openWS(clientID, origin, port) + if err == nil { + break + } + } + if err != nil { + return nil, err + } + + return &wsTransport{ + conn: conn, + }, nil +} + +type wsTransport struct { + conn *websocket.Conn +} + +func (t *wsTransport) NextWriter() (io.WriteCloser, error) { + return t.conn.NextWriter(websocket.TextMessage) +} + +func (t *wsTransport) NextReader() (io.Reader, error) { + mt, reader, err := t.conn.NextReader() + if err != nil { + return nil, err + } + if mt != websocket.TextMessage { + return nil, errors.New("invalid message type") + } + return reader, nil +} + +func (t *wsTransport) Close() error { + return t.conn.Close() +} + +func openWS(clientID snowflake.ID, origin string, port int) (*websocket.Conn, error) { + conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d?v=%d&client_id=%d&encoding=json", port, WSVersion, clientID), http.Header{ + "Origin": []string{origin}, + }) + return conn, err +}