diff --git a/README.md b/README.md index ef65c73e..d4ed8118 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ - [Download](#download) - [Homebrew](#homebrew) - [Scoop](#scoop) + - [WinGet](#winget) - [Manual Download](#manual-download) - [Usage](#usage) - [Commands](#commands) @@ -33,6 +34,22 @@ scoop install twitch-cli This will install it into your path, and it'll be callable via `twitch`. +### WinGet + +Alternatively on Windows you can use [WinGet](https://learn.microsoft.com/en-us/windows/package-manager/winget/) for installing the CLI + +To install via Winget, run: + +```sh +winget install Twitch.TwitchCLI +``` + +To update, run: + +```sh +winget update Twitch.TwitchCLI +``` + ### Manual Download To download, go to the [Releases tab of GitHub](https://github.com/twitchdev/twitch-cli/releases). The examples in the documentation assume you have put this into your PATH and renamed to `twitch` (or symlinked as such). diff --git a/cmd/configure.go b/cmd/configure.go index af1fa3b5..1551a9d7 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -73,7 +73,7 @@ func configureCmdRun(cmd *cobra.Command, args []string) error { } if err := viper.WriteConfigAs(configPath); err != nil { - fmt.Errorf("Failed to write configuration: %v", err.Error()) + return fmt.Errorf("Failed to write configuration: %v", err.Error()) } fmt.Println("Updated configuration.") diff --git a/cmd/events.go b/cmd/events.go index ce0cf17d..09bd8614 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/twitchdev/twitch-cli/internal/events" + configure_event "github.com/twitchdev/twitch-cli/internal/events/configure" "github.com/twitchdev/twitch-cli/internal/events/trigger" "github.com/twitchdev/twitch-cli/internal/events/types" "github.com/twitchdev/twitch-cli/internal/events/verify" @@ -25,6 +26,7 @@ var ( forwardAddress string event string transport string + noConfig bool fromUser string toUser string giftUser string @@ -51,15 +53,16 @@ var ( // websocketCmd-specific flags var ( - wsDebug bool - wsStrict bool - wsClient string - wsSubscription string - wsStatus string - wsReason string - wsServerIP string - wsServerPort int - wsSSL bool + wsDebug bool + wsStrict bool + wsClient string + wsSubscription string + wsStatus string + wsReason string + wsServerIP string + wsServerPort int + wsSSL bool + wsFeatureEnabled bool ) var eventCmd = &cobra.Command{ @@ -106,7 +109,8 @@ var websocketCmd = &cobra.Command{ Example: fmt.Sprintf(` twitch event websocket start-server twitch event websocket reconnect twitch event websocket close --session=e411cc1e_a2613d4e --reason=4006 - twitch event websocket subscription --status=user_removed --subscription=82a855-fae8-93bff0`, + twitch event websocket subscription --status=user_removed --subscription=82a855-fae8-93bff0 + twitch event websocket keepalive --session=e411cc1e_a2613d4e --enabled=false`, ), Aliases: []string{ "websockets", @@ -127,16 +131,25 @@ var startWebsocketServerCmd = &cobra.Command{ Deprecated: `use "twitch event websocket start-server" instead.`, } +var configureEventCmd = &cobra.Command{ + Use: "configure", + Short: "Allows users to configure defaults for the twitch event subcommands.", + RunE: configureEventRun, + Example: `twitch event configure`, +} + func init() { rootCmd.AddCommand(eventCmd) - eventCmd.AddCommand(triggerCmd, retriggerCmd, verifyCmd, websocketCmd, startWebsocketServerCmd) + eventCmd.AddCommand(triggerCmd, retriggerCmd, verifyCmd, websocketCmd, startWebsocketServerCmd, configureEventCmd) + eventCmd.Flags().BoolVarP(&noConfig, "no-config", "D", false, "Disables the use of the configuration, if it exists.") // trigger flags //// flags for forwarding functionality/changing payloads triggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event (webhook only).") triggerCmd.Flags().StringVarP(&transport, "transport", "T", "webhook", fmt.Sprintf("Preferred transport method for event. Defaults to /EventSub.\nSupported values: %s", events.ValidTransports())) triggerCmd.Flags().StringVarP(&secret, "secret", "s", "", "Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length.") + triggerCmd.Flags().BoolVarP(&noConfig, "no-config", "D", false, "Disables the use of the configuration, if it exists.") // trigger flags //// per-topic flags @@ -149,7 +162,7 @@ func init() { triggerCmd.Flags().StringVarP(&subscriptionStatus, "subscription-status", "r", "enabled", "Status of the Subscription object (.subscription.status in JSON). Defaults to \"enabled\".") triggerCmd.Flags().StringVarP(&itemID, "item-id", "i", "", "Manually set the ID of the event payload item (for example the reward ID in redemption events). For stream events, this is the game ID.") triggerCmd.Flags().StringVarP(&itemName, "item-name", "n", "", "Manually set the name of the event payload item (for example the reward ID in redemption events). For stream events, this is the game title.") - triggerCmd.Flags().Int64VarP(&cost, "cost", "C", 0, "Amount of subscriptions, bits, or channel points redeemed/used in the event.") + triggerCmd.Flags().Int64VarP(&cost, "cost", "C", 0, "Amount of drops, subscriptions, bits, or channel points redeemed/used in the event.") triggerCmd.Flags().StringVarP(&description, "description", "d", "", "Title the stream should be updated with.") triggerCmd.Flags().StringVarP(&gameID, "game-id", "G", "", "Sets the game/category ID for applicable events.") triggerCmd.Flags().StringVarP(&tier, "tier", "", "", "Sets the subscription tier. Valid values are 1000, 2000, and 3000.") @@ -167,6 +180,7 @@ func init() { retriggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event (webhook only).") retriggerCmd.Flags().StringVarP(&eventID, "id", "i", "", "ID of the event to be refired.") retriggerCmd.Flags().StringVarP(&secret, "secret", "s", "", "Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length.") + retriggerCmd.Flags().BoolVarP(&noConfig, "no-config", "D", false, "Disables the use of the configuration, if it exists.") retriggerCmd.MarkFlagRequired("id") // verify-subscription flags @@ -176,7 +190,8 @@ func init() { verifyCmd.Flags().StringVar(×tamp, "timestamp", "", "Sets the timestamp to be used in payloads and headers. Must be in RFC3339Nano format.") verifyCmd.Flags().StringVarP(&eventID, "subscription-id", "u", "", "Manually set the subscription/event ID of the event itself.") // TODO: This description will need to change with https://github.com/twitchdev/twitch-cli/issues/184 verifyCmd.Flags().StringVarP(&version, "version", "v", "", "Chooses the EventSub version used for a specific event. Not required for most events.") - verifyCmd.MarkFlagRequired("forward-address") + verifyCmd.Flags().BoolVarP(&noConfig, "no-config", "D", false, "Disables the use of the configuration, if it exists.") + verifyCmd.Flags().StringVarP(&toUser, "broadcaster", "b", "", "User ID of the broadcaster for the verification event.") // websocket flags /// flags for start-server @@ -192,6 +207,11 @@ func init() { websocketCmd.Flags().StringVar(&wsSubscription, "subscription", "", `Subscription to target with your server command. Used with "websocket subscription".`) websocketCmd.Flags().StringVar(&wsStatus, "status", "", `Changes the status of an existing subscription. Used with "websocket subscription".`) websocketCmd.Flags().StringVar(&wsReason, "reason", "", `Sets the close reason when sending a Close message to the client. Used with "websocket close".`) + websocketCmd.Flags().BoolVar(&wsFeatureEnabled, "enabled", false, "Sets on/off for the specified feature.") + + // configure flags + configureEventCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event (webhook only).") + configureEventCmd.Flags().StringVarP(&secret, "secret", "s", "", "Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length.") } func triggerCmdRun(cmd *cobra.Command, args []string) error { @@ -204,8 +224,14 @@ func triggerCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf(websubDeprecationNotice) } - if secret != "" && (len(secret) < 10 || len(secret) > 100) { - return fmt.Errorf("Invalid secret provided. Secrets must be between 10-100 characters") + defaults := configure_event.GetEventConfiguration(noConfig) + + if secret != "" { + if len(secret) < 10 || len(secret) > 100 { + return fmt.Errorf("Invalid secret provided. Secrets must be between 10-100 characters") + } + } else { + secret = defaults.Secret } // Validate that the forward address is actually a URL @@ -214,6 +240,8 @@ func triggerCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } + } else { + forwardAddress = defaults.ForwardAddress } for i := 0; i < count; i++ { @@ -260,8 +288,21 @@ func retriggerCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf(websubDeprecationNotice) } - if secret != "" && (len(secret) < 10 || len(secret) > 100) { - return fmt.Errorf("Invalid secret provided. Secrets must be between 10-100 characters") + defaults := configure_event.GetEventConfiguration(noConfig) + + if secret != "" { + if len(secret) < 10 || len(secret) > 100 { + return fmt.Errorf("Invalid secret provided. Secrets must be between 10-100 characters") + } + } else { + secret = defaults.Secret + } + + if forwardAddress == "" { + if defaults.ForwardAddress == "" { + return fmt.Errorf("if a default configuration is not set, forward-address must be provided") + } + forwardAddress = defaults.ForwardAddress } res, err := trigger.RefireEvent(eventID, trigger.TriggerParameters{ @@ -287,8 +328,14 @@ func verifyCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf(websubDeprecationNotice) } - if secret != "" && (len(secret) < 10 || len(secret) > 100) { - return fmt.Errorf("Invalid secret provided. Secrets must be between 10-100 characters") + defaults := configure_event.GetEventConfiguration(noConfig) + + if secret != "" { + if len(secret) < 10 || len(secret) > 100 { + return fmt.Errorf("Invalid secret provided. Secrets must be between 10-100 characters") + } + } else { + secret = defaults.Secret } // Validate that the forward address is actually a URL @@ -297,6 +344,8 @@ func verifyCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } + } else { + forwardAddress = defaults.ForwardAddress } if timestamp == "" { @@ -313,12 +362,14 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` } _, err := verify.VerifyWebhookSubscription(verify.VerifyParameters{ - Event: args[0], - Transport: transport, - ForwardAddress: forwardAddress, - Secret: secret, - Timestamp: timestamp, - EventID: eventID, + Event: args[0], + Transport: transport, + ForwardAddress: forwardAddress, + Secret: secret, + Timestamp: timestamp, + EventID: eventID, + BroadcasterUserID: toUser, + Version: version, }) if err != nil { @@ -345,6 +396,7 @@ func websocketCmdRun(cmd *cobra.Command, args []string) error { Subscription: wsSubscription, SubscriptionStatus: wsStatus, CloseReason: wsReason, + FeatureEnabled: wsFeatureEnabled, }) return err @@ -352,3 +404,10 @@ func websocketCmdRun(cmd *cobra.Command, args []string) error { return nil } + +func configureEventRun(cmd *cobra.Command, args []string) error { + return configure_event.ConfigureEvents(configure_event.EventConfigurationParams{ + ForwardAddress: forwardAddress, + Secret: secret, + }) +} diff --git a/cmd/token.go b/cmd/token.go index bebb5f45..b7b8556a 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -21,6 +21,7 @@ var validateToken string var overrideClientId string var tokenServerPort int var tokenServerIP string +var redirectHost string // loginCmd represents the login command var loginCmd = &cobra.Command{ @@ -37,8 +38,9 @@ func init() { loginCmd.Flags().StringVarP(&revokeToken, "revoke", "r", "", "Instead of generating a new token, revoke the one passed to this parameter.") loginCmd.Flags().StringVarP(&validateToken, "validate", "v", "", "Instead of generating a new token, validate the one passed to this parameter.") loginCmd.Flags().StringVar(&overrideClientId, "client-id", "", "Override/manually set client ID for token actions. By default client ID from CLI config will be used.") - loginCmd.Flags().StringVar(&tokenServerIP, "ip", "localhost", "Manually set the IP address to be binded to for the User Token web server.") + loginCmd.Flags().StringVar(&tokenServerIP, "ip", "", "Manually set the IP address to be bound to for the User Token web server.") loginCmd.Flags().IntVarP(&tokenServerPort, "port", "p", 3000, "Manually set the port to be used for the User Token web server.") + loginCmd.Flags().StringVar(&redirectHost, "redirect-host", "localhost", "Manually set the host to be used for the redirect URL") } func loginCmdRun(cmd *cobra.Command, args []string) error { @@ -46,11 +48,15 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { clientSecret = viper.GetString("clientSecret") webserverPort := strconv.Itoa(tokenServerPort) - redirectURL := fmt.Sprintf("http://%v:%v", tokenServerIP, webserverPort) + redirectURL := fmt.Sprintf("http://%v:%v", redirectHost, webserverPort) if clientID == "" || clientSecret == "" { println("No Client ID or Secret found in configuration. Triggering configuration now.") - configureCmd.Run(cmd, args) + err := configureCmd.RunE(cmd, args) + if err != nil { + return err + } + clientID = viper.GetString("clientId") clientSecret = viper.GetString("clientSecret") } @@ -76,7 +82,7 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { p.URL = login.ValidateTokenURL r, err := login.ValidateCredentials(p) if err != nil { - return fmt.Errorf("Failed to validate: %v", err.Error()) + return fmt.Errorf("failed to validate: %v", err.Error()) } tokenType := "App Access Token" @@ -105,7 +111,7 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { fmt.Println(white("- %v\n", s)) } } - } else if isUserToken == true { + } else if isUserToken { p.URL = login.UserCredentialsURL login.UserCredentialsLogin(p, tokenServerIP, webserverPort) } else { diff --git a/docs/event.md b/docs/event.md index 828dfbf3..9a88cb10 100644 --- a/docs/event.md +++ b/docs/event.md @@ -2,17 +2,31 @@ - [Events](#events) - [Description](#description) + - [Configure](#configure) - [Trigger](#trigger) - [Retrigger](#retrigger) - [Verify-Subscription](#verify-subscription) - - [Websocket](#websocket) + - [WebSocket](#websocket) ## Description -The `event` product contains commands to trigger mock events for local webhook testing or migration. +The `event` command contains subcommands to trigger mock events for local webhook testing or migration. All commands exit the program with a non-zero exit code when the command fails, including when an event does not exist, or when the mock EventSub WebSocket server does not start correctly. + +## Configure + +Used to configure the forwarding address and/or the secret used with the `trigger`, `verify-subscription`, and `retrigger` subcommands. + +**Flags** + +| Flag | Shorthand | Description | Example | Required? (Y/N) | +|---------------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------|-----------------| +| `--forward-address` | `-F` | Web server address for where to send mock events. | `-F https://localhost:8080` | N | +| `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | + + ## Trigger Used to either create or send mock events for use with local webhooks testing. @@ -92,6 +106,7 @@ This command can take either the Event or Alias listed as an argument. It is pre | `--gift-user` | `-g` | Used only for subcription-based events, denotes the gifting user ID. | `-g 44635596` | N | | `--item-id` | `-i` | Manually set the ID of the event payload item (for example the reward ID in redemption events or game in stream events). | `-i 032e4a6c-4aef-11eb-a9f5-1f703d1f0b92` | N | | `--item-name` | `-n` | Manually set the name of the event payload item (for example the reward ID in redemption events or game name in stream events). | `-n "Science & Technology"` | N | +| `--no-config` | `-D` | Disables the use of the configuration values should they exist. | `-D` | N | | `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | | `--session` | | WebSocket session to target. Only used when forwarding to WebSocket servers with --transport=websocket | `--session e411cc1e_a2613d4e` | N | | `--subscription-id` | `-u` | Manually set the subscription/event ID of the event itself. | `-u 5d3aed06-d019-11ed-afa1-0242ac120002` | N | @@ -101,10 +116,11 @@ This command can take either the Event or Alias listed as an argument. It is pre | `--to-user` | `-t` | Denotes the receiver's TUID of the event, usually the broadcaster. | `-t 44635596` | N | | `--transport` | `-T` | The method used to send events. Can either be `webhook` or `websocket`. Default is `webhook`. | `-T webhook` | N | +**Examples** ```sh -twitch event trigger subscribe -F https://localhost:8080/ // triggers a randomly generated subscribe event and forwards to the localhost:8080 server -twitch event trigger cheer -f 1234 -t 4567 // generates JSON for a cheer event from user 1234 to user 4567 +twitch event trigger subscribe -F https://localhost:8080/ # triggers a randomly generated subscribe event and forwards to the localhost:8080 server +twitch event trigger cheer -f 1234 -t 4567 # generates JSON for a cheer event from user 1234 to user 4567 ``` ## Retrigger @@ -134,17 +150,19 @@ None |---------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|-----------------| | `--forward-address` | `-F` | Web server address for where to send mock events. | `-F https://localhost:8080` | N | | `--id` | `-i` | The ID of the event to refire. | `-i ` | Y | +| `--no-config` | `-D` | Disables the use of the configuration values should they exist. | `-D` | N | | `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | + **Examples** ```sh -twitch event retrigger -i "713f3254-0178-9757-7439-d779400c0999" -F https://localhost:8080/ // triggers the previous cheer event to localhost:8080 +twitch event retrigger -i "713f3254-0178-9757-7439-d779400c0999" -F https://localhost:8080/ # triggers the previous cheer event to localhost:8080 ``` ## Verify-Subscription -Allows you to test if your webserver responds to subscription requests properly. +Allows you to test if your webserver responds to subscription requests properly. The `forward-address` flag is required *unless* you have configured a default forwarding address via `twitch event configure -F
`. **Args** @@ -154,14 +172,16 @@ This command takes the same arguments as [Trigger](#trigger). | Flag | Shorthand | Description | Example | Required? (Y/N) | |---------------------|-----------|----------------------------------------------------------------------------------------------------------------------|-----------------------------|-----------------| +| `--broadcaster` | `-b` | The broadcaster's user ID to be used for verification | `-b 1234` | N | | `--forward-address` | `-F` | Web server address for where to send mock subscription. | `-F https://localhost:8080` | Y | +| `--no-config` | `-D` | Disables the use of the configuration values should they exist. | `-D` | N | | `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC and must be 10-100 characters in length. | `-s testsecret` | N | | `--transport` | `-T` | The method used to send events. Default is `eventsub`. | `-T eventsub` | N | **Examples** ```sh -twitch event verify-subscription cheer -F https://localhost:8080/ // triggers a fake "cheer" EventSub subscription and validates if localhost responds properly +twitch event verify-subscription cheer -F https://localhost:8080/ # triggers a fake "cheer" EventSub subscription and validates if localhost responds properly ``` ## WebSocket @@ -191,6 +211,7 @@ Provides access to a mock EventSub WebSocket server. More information can be fou | `--reason` | | Specifies the Close message code you wish to close a client’s connection with. Only used with "twitch websocket close" | `twitch event websocket close --reason=4006` | | `--status` | | Specifies the Status code you wish to override an existing subscription’s status to. Only used with "twitch websocket close" | `twitch event websocket subscription --status=user_removed` | | `--subscription` | | Specifies the subscription ID you wish to target. Only used with “twitch websocket subscription”. | `twitch event websocket subscription --subscription=48d3-b9a-f84c` | +| `--enabled` | | Sets on/off for the specified feature. | `twitch event websocket keepalive --session=e411cc1e_a2613d4e --enabled=false` | **Examples** @@ -199,4 +220,5 @@ twitch event websocket start-server twitch event websocket reconnect twitch event websocket close --session=e411cc1e_a2613d4e --reason=4006 twitch event websocket subscription --status=user_removed --subscription=82a855-fae8-93bff0 -``` \ No newline at end of file +twitch event websocket keepalive --session=e411cc1e_a2613d4e --enabled=false +``` diff --git a/docs/token.md b/docs/token.md index 4be61388..41fe4502 100644 --- a/docs/token.md +++ b/docs/token.md @@ -97,6 +97,14 @@ Access tokens can be revoked with: twitch token -r 0123456789abcdefghijABCDEFGHIJ ``` +## Alternate IP for User Token Webserver + +If you'd like to bind the webserver used for user tokens (`-u` flag), you can override it with the `--ip` flag. For example: + +``` +twitch token -u --ip 127.0.0.1" +``` + ## Alternate Port Port 3000 on localhost is used by default when fetching User Access Tokens. The `-p` flag can be used to change to another port if another service is already occupying that port. For example: @@ -108,6 +116,19 @@ twitch token -u -p 3030 -s "moderator:manage:shoutouts moderator:manage:shield_m NOTE: You must update the first entry in the _OAuth Redirect URLs_ section of your app's management page in the [Developer's Application Console](https://dev.twitch.tv/console/apps) to match the new port number. Make sure there is no `/` at the end of the URL (e.g. use `http://localhost:3030` and not `http://localhost:3030/`) and that the URL is the first entry in the list if there is more than one. +## Alternate Host + +If you'd like to change the hostname for one reason or another (e.g. binding to a local domain), you can use the `--redirect-host` to change the domain. You should _not_ prefix it with `http` or `https`. + +Example: + +``` +twitch token -u --redirect-host contoso.com +``` + +NOTE: You must update the first entry in the _OAuth Redirect URLs_ section of your app's management page in the [Developer's Application Console](https://dev.twitch.tv/console/apps) to match the new port number. Make sure there is no `/` at the end of the URL (e.g. use `http://localhost:3030` and not `http://localhost:3030/`) and that the URL is the first entry in the list if there is more than one. + + ## Errors This error occurs when there's a problem with the OAuth Redirect URLs. Check in the app's management page in the [Developer's Application Console](https://dev.twitch.tv/console/apps) to ensure the first entry is set to `http://localhost:3000`. Specifically, verify that your using `http` and not `https` and that the URL does not end with a `/`. (If you've changed ports with the `-p` flag, ensure those numbers match as well) @@ -126,14 +147,16 @@ None. **Flags** -| Flag | Shorthand | Description | Example | Required? (Y/N) | -|----------------|-----------|-------------------------------------------------------------------------------------------------------|----------------------------------------------|-----------------| -| `--user-token` | `-u` | Whether to fetch a user token or not. Default is false. | `token -u` | N | -| `--scopes` | `-s` | The space separated scopes to use when getting a user token. | `-s "user:read:email user_read"` | N | -| `--revoke` | `-r` | Instead of generating a new token, revoke the one passed to this parameter. | `-r 0123456789abcdefghijABCDEFGHIJ` | N | -| `--port` | `-p` | Override/manually set the port for token actions. (The default is 3000) | `-p 3030` | N | -| `--client-id` | | Override/manually set client ID for token actions. By default client ID from CLI config will be used. | `--client-id uo6dggojyb8d6soh92zknwmi5ej1q2` | N | - +| Flag | Shorthand | Description | Example | Required? (Y/N) | +|-------------------|-----------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------|-----------------| +| `--user-token` | `-u` | Whether to fetch a user token or not. Default is false. | `token -u` | N | +| `--scopes` | `-s` | The space separated scopes to use when getting a user token. | `-s "user:read:email user_read"` | N | +| `--revoke` | `-r` | Instead of generating a new token, revoke the one passed to this parameter. | `-r 0123456789abcdefghijABCDEFGHIJ` | N | +| `--validate` | `-v` | Instead of generating a new token, validate the one passed to this parameter. | `-v 0123456789abcdefghijABCDEFGHIJ` | N | +| `--ip` | | Manually set the port to be used for the User Token web server. The default binds to all interfaces. (0.0.0.0) | `--ip 127.0.0.1` | N | +| `--port` | `-p` | Override/manually set the port for token actions. (The default is 3000) | `-p 3030` | N | +| `--client-id` | | Override/manually set client ID for token actions. By default client ID from CLI config will be used. | `--client-id uo6dggojyb8d6soh92zknwmi5ej1q2` | N | +| `--redirect-host` | | Override/manually set the redirect host token actions. The default is `localhost` | `--redirect-host contoso.com` | N | ## Notes diff --git a/internal/database/_schema.sql b/internal/database/_schema.sql index b79aeabc..1f8d1798 100644 --- a/internal/database/_schema.sql +++ b/internal/database/_schema.sql @@ -294,9 +294,9 @@ create table stream_schedule( id text not null primary key, broadcaster_id text not null, starttime text not null, - endtime text not null, - timezone text not null, - is_vacation boolean not null default false, + endtime text not null, + timezone text not null, + is_vacation boolean not null default false, is_recurring boolean not null default false, is_canceled boolean not null default false, title text, diff --git a/internal/database/channel_points_redemptions.go b/internal/database/channel_points_redemptions.go index 0a59c6b0..2fc99ccc 100644 --- a/internal/database/channel_points_redemptions.go +++ b/internal/database/channel_points_redemptions.go @@ -15,7 +15,7 @@ type ChannelPointsRedemption struct { UserLogin string `db:"user_login" dbi:"false" json:"user_login"` UserName string `db:"user_name" dbi:"false" json:"user_name"` UserInput sql.NullString `db:"user_input" json:"-"` - RealUserInput *string `json:"user_input"` + RealUserInput string `json:"user_input"` RedemptionStatus string `db:"redemption_status" json:"status"` RedeemedAt string `db:"redeemed_at" json:"redeemed_at"` RewardID string `db:"reward_id" json:"-"` @@ -25,7 +25,7 @@ type ChannelPointsRedemption struct { type ChannelPointsRedemptionRewardInfo struct { ID string `dbi:"false" db:"red_id" json:"id" dbs:"red.id"` Title string `dbi:"false" db:"title" json:"title"` - RewardPrompt string `dbi:"false" db:"reward_prompt" json:"reward_prompt"` + RewardPrompt string `dbi:"false" db:"reward_prompt" json:"prompt"` Cost int `dbi:"false" db:"cost" json:"cost"` } @@ -50,9 +50,9 @@ func (q *Query) GetChannelPointsRedemption(cpr ChannelPointsRedemption, sort str if err != nil { return nil, err } - red.RealUserInput = &red.UserInput.String + red.RealUserInput = red.UserInput.String if !red.UserInput.Valid { - red.RealUserInput = nil + red.RealUserInput = "" } r = append(r, red) } diff --git a/internal/database/init.go b/internal/database/init.go index cdc46fcc..99ae6f35 100644 --- a/internal/database/init.go +++ b/internal/database/init.go @@ -10,7 +10,7 @@ import ( "github.com/jmoiron/sqlx" ) -const currentVersion = 6 +const currentVersion = 7 type migrateMap struct { SQL string @@ -53,6 +53,10 @@ ALTER TABLE users ADD COLUMN content_labels text not null default '';`, SQL: `DROP TABLE IF EXISTS stream_tags;`, Message: `Removing deprecated stream_tags from database.`, }, + 7: { + SQL: `ALTER TABLE stream_schedule DROP COLUMN timezone;`, + Message: `Removing deprecated stream_schedule.timezone from database`, + }, } func checkAndUpdate(db sqlx.DB) error { @@ -120,7 +124,7 @@ create table predictions ( id text not null primary key, broadcaster_id text not create table prediction_outcomes ( id text not null primary key, title text not null, users int not null default 0, channel_points int not null default 0, color text not null, prediction_id text not null, foreign key (prediction_id) references predictions(id) ); create table prediction_predictions ( prediction_id text not null, user_id text not null, amount int not null, outcome_id text not null, primary key(prediction_id, user_id), foreign key(user_id) references users(id), foreign key(prediction_id) references predictions(id), foreign key(outcome_id) references prediction_outcomes(id) ); create table clips ( id text not null primary key, broadcaster_id text not null, creator_id text not null, video_id text not null, game_id text not null, title text not null, view_count int default 0, created_at text not null, duration real not null, vod_offset int default 0, foreign key (broadcaster_id) references users(id), foreign key (creator_id) references users(id) ); -create table stream_schedule( id text not null primary key, broadcaster_id text not null, starttime text not null, endtime text not null, timezone text not null, is_vacation boolean not null default false, is_recurring boolean not null default false, is_canceled boolean not null default false, title text, category_id text, foreign key(broadcaster_id) references users(id), foreign key (category_id) references categories(id)); +create table stream_schedule( id text not null primary key, broadcaster_id text not null, starttime text not null, endtime text not null, is_vacation boolean not null default false, is_recurring boolean not null default false, is_canceled boolean not null default false, title text, category_id text, foreign key(broadcaster_id) references users(id), foreign key (category_id) references categories(id)); create table chat_settings( broadcaster_id text not null primary key, slow_mode boolean not null default 0, slow_mode_wait_time int not null default 10, follower_mode boolean not null default 0, follower_mode_duration int not null default 60, subscriber_mode boolean not null default 0, emote_mode boolean not null default 0, unique_chat_mode boolean not null default 0, non_moderator_chat_delay boolean not null default 0, non_moderator_chat_delay_duration int not null default 10, shieldmode_is_active boolean not null default 0, shieldmode_moderator_id text not null default '', shieldmode_moderator_login text not null default '', shieldmode_moderator_name text not null default '', shieldmode_last_activated text not null default '' ); create table vips ( broadcaster_id text not null, user_id text not null, created_at text not null default '', primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) );` diff --git a/internal/database/predictions.go b/internal/database/predictions.go index 75551276..58248263 100644 --- a/internal/database/predictions.go +++ b/internal/database/predictions.go @@ -11,7 +11,7 @@ type Prediction struct { WinningOutcomeID *string `db:"winning_outcome_id" json:"winning_outcome_id"` PredictionWindow int `db:"prediction_window" json:"prediction_window"` Status string `db:"status" json:"status"` - StartedAt string `db:"created_at" json:"started_at"` + StartedAt string `db:"created_at" json:"created_at"` EndedAt *string `db:"ended_at" json:"ended_at"` LockedAt *string `db:"locked_at" json:"locked_at"` Outcomes []PredictionOutcome `json:"outcomes"` diff --git a/internal/database/schedule.go b/internal/database/schedule.go index 5bf9c4e9..c3516358 100644 --- a/internal/database/schedule.go +++ b/internal/database/schedule.go @@ -24,7 +24,6 @@ type ScheduleSegment struct { IsVacation bool `db:"is_vacation" json:"-"` Category *SegmentCategory `json:"category"` UserID string `db:"broadcaster_id" json:"-"` - Timezone string `db:"timezone" json:"timezone,omitempty"` CategoryID *string `db:"category_id" json:"-"` CategoryName *string `db:"category_name" dbi:"false" json:"-"` IsCanceled *bool `db:"is_canceled" json:"-"` diff --git a/internal/database/subscriptions.go b/internal/database/subscriptions.go index f73b2df6..ae462ba3 100644 --- a/internal/database/subscriptions.go +++ b/internal/database/subscriptions.go @@ -16,9 +16,9 @@ type Subscription struct { UserLogin string `db:"user_login" json:"user_login"` UserName string `db:"user_name" json:"user_name"` IsGift bool `db:"is_gift" json:"is_gift"` - GifterID *sql.NullString `db:"gifter_id" json:"gifter_id,omitempty"` - GifterName *sql.NullString `db:"gifter_name" json:"gifter_name,omitempty"` - GifterLogin *sql.NullString `db:"gifter_login" json:"gifter_login,omitempty"` + GifterID *sql.NullString `db:"gifter_id" json:"gifter_id"` + GifterName *sql.NullString `db:"gifter_name" json:"gifter_name"` + GifterLogin *sql.NullString `db:"gifter_login" json:"gifter_login"` Tier string `db:"tier" json:"tier"` CreatedAt string `db:"created_at" json:"-"` // calculated fields diff --git a/internal/database/videos.go b/internal/database/videos.go index 556f2021..180b4499 100644 --- a/internal/database/videos.go +++ b/internal/database/videos.go @@ -20,7 +20,7 @@ type Video struct { Viewable string `db:"viewable" json:"viewable"` ViewCount int `db:"view_count" json:"view_count"` Duration string `db:"duration" json:"duration"` - VideoLanguage string `db:"video_language" json:"video_language"` + VideoLanguage string `db:"video_language" json:"language"` MutedSegments []VideoMutedSegment `json:"muted_segments"` CategoryID *string `db:"category_id" dbs:"v.category_id" json:"-"` Type string `db:"type" json:"type"` @@ -32,7 +32,7 @@ type Video struct { type VideoMutedSegment struct { VideoID string `db:"video_id" json:"-"` - VideoOffset int `db:"video_offset" json:"video_offset"` + VideoOffset int `db:"video_offset" json:"offset"` Duration int `db:"duration" json:"duration"` } @@ -54,7 +54,7 @@ type Clip struct { // calculated fields URL string `json:"url"` ThumbnailURL string `json:"thumbnail_url"` - EmbedURL string `json:"embed_urL"` + EmbedURL string `json:"embed_url"` StartedAt string `db:"started_at" dbi:"false" json:"-"` EndedAt string `db:"ended_at" dbi:"false" json:"-"` } @@ -129,6 +129,7 @@ func (q *Query) InsertVideo(v Video) error { func (q *Query) DeleteVideo(id string) error { tx := q.DB.MustBegin() + tx.MustExec("delete from stream_markers where video_id=$1", id) tx.MustExec("delete from video_muted_segments where video_id=$1", id) tx.MustExec("delete from videos where id = $1", id) return tx.Commit() diff --git a/internal/events/configure/configure.go b/internal/events/configure/configure.go new file mode 100644 index 00000000..dc5d8f8e --- /dev/null +++ b/internal/events/configure/configure.go @@ -0,0 +1,58 @@ +package configure_event + +import ( + "fmt" + "net/url" + + "github.com/spf13/viper" + "github.com/twitchdev/twitch-cli/internal/util" +) + +type EventConfigurationParams struct { + Secret string + ForwardAddress string +} + +func ConfigureEvents(p EventConfigurationParams) error { + var err error + if p.ForwardAddress == "" && p.Secret == "" { + return fmt.Errorf("you must provide at least one of --secret or --forward-address") + } + + // Validate that the forward address is actually a URL + if len(p.ForwardAddress) > 0 { + _, err := url.ParseRequestURI(p.ForwardAddress) + if err != nil { + return err + } + viper.Set("forwardAddress", p.ForwardAddress) + } + if p.Secret != "" { + if len(p.Secret) < 10 || len(p.Secret) > 100 { + return fmt.Errorf("invalid secret provided. Secrets must be between 10-100 characters") + } + viper.Set("eventSecret", p.Secret) + } + + configPath, err := util.GetConfigPath() + if err != nil { + return err + } + + if err := viper.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write configuration: %v", err.Error()) + } + + fmt.Println("Updated configuration.") + return nil +} + +func GetEventConfiguration(noConfig bool) EventConfigurationParams { + if noConfig { + return EventConfigurationParams{} + } + return EventConfigurationParams{ + ForwardAddress: viper.GetString("forwardAddress"), + Secret: viper.GetString("eventSecret"), + } +} diff --git a/internal/events/configure/configure_test.go b/internal/events/configure/configure_test.go new file mode 100644 index 00000000..66759097 --- /dev/null +++ b/internal/events/configure/configure_test.go @@ -0,0 +1,36 @@ +package configure_event_test + +import ( + "testing" + + "github.com/spf13/viper" + configure_event "github.com/twitchdev/twitch-cli/internal/events/configure" + "github.com/twitchdev/twitch-cli/test_setup" +) + +func TestWriteEventConfig(t *testing.T) { + a := test_setup.SetupTestEnv(t) + defaultForwardAddress := "http://localhost:3000/" + defaultSecret := "12345678910" + test_config := configure_event.EventConfigurationParams{ + ForwardAddress: defaultForwardAddress, + Secret: defaultSecret, + } + + // test a good config writes correctly + a.NoError(configure_event.ConfigureEvents(test_config)) + + a.Equal(defaultForwardAddress, viper.Get("forwardAddress")) + a.Equal(defaultSecret, viper.Get("eventSecret")) + + // test for secret length validation + test_config.Secret = "1" + a.Error(configure_event.ConfigureEvents(test_config)) + a.NotEqual("1", viper.Get("eventSecret")) + test_config.Secret = defaultSecret + + // test for forward address validation + test_config.ForwardAddress = "not a url" + a.Error(configure_event.ConfigureEvents(test_config)) + a.NotEqual("not a url", viper.Get("forwardAddress")) +} diff --git a/internal/events/types/drop/drop.go b/internal/events/types/drop/drop.go index afa3bd98..ab8c470d 100644 --- a/internal/events/types/drop/drop.go +++ b/internal/events/types/drop/drop.go @@ -41,6 +41,41 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven case models.TransportWebhook: campaignId := util.RandomGUID() + dropEvents := []models.DropsEntitlementEventSubEvent{ + { + ID: util.RandomGUID(), + Data: models.DropsEntitlementEventSubEventData{ + OrganizationID: params.FromUserID, + CategoryID: params.GameID, + CategoryName: "Special Events", + CampaignID: campaignId, + EntitlementID: util.RandomGUID(), + BenefitID: params.ItemID, + UserID: params.ToUserID, + UserName: params.ToUserName, + UserLogin: params.ToUserName, + CreatedAt: params.Timestamp, + }, + }, + } + + for i := int64(1); i < params.Cost; i++ { + // for the new events, we'll use the entitlement above except generating new users as to avoid conflicting drops + dropEvents = append(dropEvents, models.DropsEntitlementEventSubEvent{ + ID: util.RandomGUID(), + Data: models.DropsEntitlementEventSubEventData{ + OrganizationID: params.FromUserID, + CategoryID: params.GameID, + CategoryName: "Special Events", + CampaignID: campaignId, + EntitlementID: util.RandomGUID(), + BenefitID: params.ItemID, + UserID: util.RandomUserID(), + UserName: params.ToUserName, + UserLogin: params.ToUserName, + CreatedAt: params.Timestamp, + }}) + } body := &models.DropsEntitlementEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -59,23 +94,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven Cost: 0, CreatedAt: params.Timestamp, }, - Events: []models.DropsEntitlementEventSubEvent{ - { - ID: util.RandomGUID(), - Data: models.DropsEntitlementEventSubEventData{ - OrganizationID: params.FromUserID, - CategoryID: params.GameID, - CategoryName: "Special Events", - CampaignID: campaignId, - EntitlementID: util.RandomGUID(), - BenefitID: params.ItemID, - UserID: params.ToUserID, - UserName: params.ToUserName, - UserLogin: params.ToUserName, - CreatedAt: params.Timestamp, - }, - }, - }, + Events: dropEvents, } event, err = json.Marshal(body) if err != nil { diff --git a/internal/events/verify/subscription_verify.go b/internal/events/verify/subscription_verify.go index 11b21b8c..647bd8ff 100644 --- a/internal/events/verify/subscription_verify.go +++ b/internal/events/verify/subscription_verify.go @@ -19,13 +19,14 @@ import ( ) type VerifyParameters struct { - Transport string - Timestamp string - Event string - ForwardAddress string - Secret string - EventID string - Version string + Transport string + Timestamp string + Event string + ForwardAddress string + Secret string + EventID string + Version string + BroadcasterUserID string } type VerifyResponse struct { @@ -55,7 +56,11 @@ func VerifyWebhookSubscription(p VerifyParameters) (VerifyResponse, error) { p.EventID = util.RandomGUID() } - body, err := generateWebhookSubscriptionBody(p.Transport, p.EventID, event.GetTopic(p.Transport, p.Event), event.SubscriptionVersion(), challenge, p.ForwardAddress) + if p.BroadcasterUserID == "" { + p.BroadcasterUserID = util.RandomUserID() + } + + body, err := generateWebhookSubscriptionBody(p.Transport, p.EventID, event.GetTopic(p.Transport, p.Event), event.SubscriptionVersion(), p.BroadcasterUserID, challenge, p.ForwardAddress) if err != nil { return VerifyResponse{}, err } @@ -133,7 +138,7 @@ func VerifyWebhookSubscription(p VerifyParameters) (VerifyResponse, error) { return r, nil } -func generateWebhookSubscriptionBody(transport string, eventID string, event string, subscriptionVersion string, challenge string, callback string) (trigger.TriggerResponse, error) { +func generateWebhookSubscriptionBody(transport string, eventID string, event string, subscriptionVersion string, broadcaster string, challenge string, callback string) (trigger.TriggerResponse, error) { var res []byte var err error ts := util.GetTimestamp().Format(time.RFC3339Nano) @@ -147,7 +152,7 @@ func generateWebhookSubscriptionBody(transport string, eventID string, event str Type: event, Version: subscriptionVersion, Condition: models.EventsubCondition{ - BroadcasterUserID: util.RandomUserID(), + BroadcasterUserID: broadcaster, }, Transport: models.EventsubTransport{ Method: "webhook", diff --git a/internal/events/websocket/mock_server/client.go b/internal/events/websocket/mock_server/client.go index 6ac29f38..495d2b66 100644 --- a/internal/events/websocket/mock_server/client.go +++ b/internal/events/websocket/mock_server/client.go @@ -13,6 +13,7 @@ type Client struct { mutex sync.Mutex ConnectedAtTimestamp string // RFC3339Nano timestamp indicating when the client connected to the server connectionUrl string + KeepAliveEnabled bool mustSubscribeTimer *time.Timer keepAliveChanOpen bool diff --git a/internal/events/websocket/mock_server/manager.go b/internal/events/websocket/mock_server/manager.go index 3c1cb2e9..cd923a5d 100644 --- a/internal/events/websocket/mock_server/manager.go +++ b/internal/events/websocket/mock_server/manager.go @@ -154,6 +154,7 @@ func StartWebsocketServer(enableDebug bool, ip string, port int, enableSSL bool, rpc.RegisterHandler("EventSubWebSocketForwardEvent", RPCFireEventSubHandler) rpc.RegisterHandler("EventSubWebSocketCloseClient", RPCCloseHandler) rpc.RegisterHandler("EventSubWebSocketSubscription", RPCSubscriptionHandler) + rpc.RegisterHandler("EventSubWebSocketKeepalive", RPCKeepaliveHandler) rpc.StartBackgroundServer() // TODO: Interactive shell maybe? diff --git a/internal/events/websocket/mock_server/rpc_handler.go b/internal/events/websocket/mock_server/rpc_handler.go index df1254ad..c46d19c1 100644 --- a/internal/events/websocket/mock_server/rpc_handler.go +++ b/internal/events/websocket/mock_server/rpc_handler.go @@ -28,6 +28,8 @@ func ResolveRPCName(cmd string) string { return "EventSubWebSocketCloseClient" } else if cmd == "subscription" { return "EventSubWebSocketSubscription" + } else if cmd == "keepalive" { + return "EventSubWebSocketKeepalive" } else { return "" } @@ -151,8 +153,6 @@ func RPCCloseHandler(args rpc.RPCArgs) rpc.RPCResponse { } } - clientName := args.Variables["ClientName"] - if serverManager.reconnectTesting { log.Printf("Error on RPC call (EventSubWebSocketCloseClient): Could not activate while reconnect testing is active.") return rpc.RPCResponse{ @@ -170,21 +170,21 @@ func RPCCloseHandler(args rpc.RPCArgs) rpc.RPCResponse { } } - cn := clientName - if sessionRegex.MatchString(clientName) { + clientName := args.Variables["ClientName"] + if sessionRegex.MatchString(args.Variables["ClientName"]) { // Client name given was formatted as _. We must extract it - sessionRegexExec := sessionRegex.FindAllStringSubmatch(clientName, -1) - cn = sessionRegexExec[0][2] + sessionRegexExec := sessionRegex.FindAllStringSubmatch(args.Variables["ClientName"], -1) + clientName = sessionRegexExec[0][2] } server.muClients.Lock() - client, ok := server.Clients.Get(cn) + client, ok := server.Clients.Get(clientName) if !ok { server.muClients.Unlock() return rpc.RPCResponse{ ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, - DetailedInfo: "Client [" + cn + "] does not exist on WebSocket server.", + DetailedInfo: "Client [" + clientName + "] does not exist on WebSocket server.", } } @@ -273,3 +273,67 @@ func RPCSubscriptionHandler(args rpc.RPCArgs) rpc.RPCResponse { ResponseCode: COMMAND_RESPONSE_SUCCESS, } } + +func RPCKeepaliveHandler(args rpc.RPCArgs) rpc.RPCResponse { + if args.Variables["FeatureEnabled"] == "" || args.Variables["ClientName"] == "" { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_MISSING_FLAG, + DetailedInfo: "Command \"keepalive\" requires flags --session and --enabled" + + "\n\nExample: twitch event websocket keepalive --session=e411cc1e_a2613d4e --enabled=false", + } + } + + enabled, err := strconv.ParseBool(args.Variables["FeatureEnabled"]) + if err != nil { + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_MISSING_FLAG, + DetailedInfo: "Command \"keepalive\" requires --enabled to be \"true\" or \"false\"" + + "\n\nExample: twitch event websocket keepalive --session=e411cc1e_a2613d4e --enabled=false", + } + } + + if serverManager.reconnectTesting { + log.Printf("Error on RPC call (EventSubWebSocketCloseClient): Could not activate while reconnect testing is active.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Cannot activate this command while reconnect testing is active.", + } + } + + server, ok := serverManager.serverList.Get(serverManager.primaryServer) + if !ok { + log.Printf("Error on RPC call (EventSubWebSocketCloseClient): Primary server not in server list.") + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Primary server not in server list.", + } + } + + clientName := args.Variables["ClientName"] + if sessionRegex.MatchString(args.Variables["ClientName"]) { + // Client name given was formatted as _. We must extract it + sessionRegexExec := sessionRegex.FindAllStringSubmatch(args.Variables["ClientName"], -1) + clientName = sessionRegexExec[0][2] + } + + server.muClients.Lock() + + client, ok := server.Clients.Get(clientName) + if !ok { + server.muClients.Unlock() + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_FAILED_ON_SERVER, + DetailedInfo: "Client [" + clientName + "] does not exist on WebSocket server.", + } + } + + client.KeepAliveEnabled = enabled + + server.muClients.Unlock() + + log.Printf("RPC set status on client feature [KeepAliveEnabled] for client [%v]: %v", clientName, enabled) + + return rpc.RPCResponse{ + ResponseCode: COMMAND_RESPONSE_SUCCESS, + } +} diff --git a/internal/events/websocket/mock_server/server.go b/internal/events/websocket/mock_server/server.go index 484358fd..b776f383 100644 --- a/internal/events/websocket/mock_server/server.go +++ b/internal/events/websocket/mock_server/server.go @@ -60,6 +60,7 @@ func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request) conn: conn, ConnectedAtTimestamp: connectedAtTimestamp, connectionUrl: fmt.Sprintf("%v://%v/ws", serverManager.protocolHttp, r.Host), + KeepAliveEnabled: true, keepAliveChanOpen: false, pingChanOpen: false, } @@ -178,6 +179,14 @@ func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request) return case <-client.keepAliveTimer.C: // Send KeepAlive message + if !client.KeepAliveEnabled { + // Sending keep alives was disabled manually, so we skip this one. + if ws.DebugEnabled { + log.Printf("Skipped sending session_keepalive to client [%s]", client.clientName) + } + continue + } + keepAliveMsg, _ := json.Marshal( KeepaliveMessage{ Metadata: MessageMetadata{ diff --git a/internal/events/websocket/websocket_cmd.go b/internal/events/websocket/websocket_cmd.go index c4a16882..ced3a28a 100644 --- a/internal/events/websocket/websocket_cmd.go +++ b/internal/events/websocket/websocket_cmd.go @@ -3,6 +3,7 @@ package websocket import ( "fmt" "net/rpc" + "strconv" "github.com/fatih/color" "github.com/twitchdev/twitch-cli/internal/events/websocket/mock_server" @@ -14,6 +15,7 @@ type WebsocketCommandParameters struct { Subscription string SubscriptionStatus string CloseReason string + FeatureEnabled bool } func ForwardWebsocketCommand(cmd string, p WebsocketCommandParameters) error { @@ -36,6 +38,7 @@ func ForwardWebsocketCommand(cmd string, p WebsocketCommandParameters) error { variables["SubscriptionID"] = p.Subscription variables["SubscriptionStatus"] = p.SubscriptionStatus variables["CloseReason"] = p.CloseReason + variables["FeatureEnabled"] = strconv.FormatBool(p.FeatureEnabled) args := &rpc_handler.RPCArgs{ RPCName: rpcName, diff --git a/internal/mock_api/endpoints/bits/leaderboard.go b/internal/mock_api/endpoints/bits/leaderboard.go index 67753c2e..95386122 100644 --- a/internal/mock_api/endpoints/bits/leaderboard.go +++ b/internal/mock_api/endpoints/bits/leaderboard.go @@ -124,7 +124,9 @@ func getBitsLeaderboard(w http.ResponseWriter, r *http.Request) { // check if the started_at date is valid and then add it to the start/end range if period != "all" { if startedAt == "" { - startedAt = time.Now().Format(time.RFC3339) + w.Write(mock_errors.GetErrorBytes(http.StatusBadRequest, errors.New("Bad Request"), "invalid value provided for started_at")) + w.WriteHeader(http.StatusBadRequest) + return } sa, err := time.Parse(time.RFC3339, startedAt) @@ -199,12 +201,9 @@ func getBitsLeaderboard(w http.ResponseWriter, r *http.Request) { length := len(bl) apiR := models.APIResponse{ - Data: bl, - Total: &length, - } - - if dateRange.StartedAt != "" { - apiR.DateRange = &dateRange + Data: bl, + DateRange: &dateRange, + Total: &length, } body, _ := json.Marshal(apiR) diff --git a/internal/mock_api/endpoints/channel_points/channel_points_test.go b/internal/mock_api/endpoints/channel_points/channel_points_test.go index 878fe05b..cdffae19 100644 --- a/internal/mock_api/endpoints/channel_points/channel_points_test.go +++ b/internal/mock_api/endpoints/channel_points/channel_points_test.go @@ -86,6 +86,7 @@ func TestRedemption(t *testing.T) { a.Equal(400, resp.StatusCode) q.Set("broadcaster_id", "2") + q.Set("status", "FULFILLED") req.URL.RawQuery = q.Encode() resp, err = http.DefaultClient.Do(req) a.Nil(err) diff --git a/internal/mock_api/endpoints/channel_points/redemptions.go b/internal/mock_api/endpoints/channel_points/redemptions.go index 39b7909a..0f099e7d 100644 --- a/internal/mock_api/endpoints/channel_points/redemptions.go +++ b/internal/mock_api/endpoints/channel_points/redemptions.go @@ -68,6 +68,11 @@ func getRedemptions(w http.ResponseWriter, r *http.Request) { status := r.URL.Query().Get("status") sort := r.URL.Query().Get("sort") + if id == "" && status == "" { + mock_errors.WriteBadRequest(w, "The status query parameter is required if you don't specify the id query parameter.") + return + } + if !userCtx.MatchesBroadcasterIDParam(r) { mock_errors.WriteUnauthorized(w, "Broadcaster ID mismatch") return diff --git a/internal/mock_api/endpoints/charity/campaigns.go b/internal/mock_api/endpoints/charity/campaigns.go index d8fbf891..bcc7261b 100644 --- a/internal/mock_api/endpoints/charity/campaigns.go +++ b/internal/mock_api/endpoints/charity/campaigns.go @@ -42,7 +42,7 @@ type GetCharityCampaignResponse struct { CharityDescription string `json:"charity_description"` CharityLogo string `json:"charity_logo"` CharityWebsite string `json:"charity_website"` - CurrentAmount CharityAmount `json:"current_ammount"` + CurrentAmount CharityAmount `json:"current_amount"` TargetAmount CharityAmount `json:"target_amount"` } diff --git a/internal/mock_api/endpoints/charity/donations.go b/internal/mock_api/endpoints/charity/donations.go index 83d0b76d..439755c1 100644 --- a/internal/mock_api/endpoints/charity/donations.go +++ b/internal/mock_api/endpoints/charity/donations.go @@ -34,11 +34,12 @@ var donationsScopesByMethod = map[string][]string{ type CharityDonations struct{} type GetCharityDonationsResponse struct { - ID string `json:"campaign_id"` - UserID string `json:"user_id"` - UserLogin string `json:"user_login"` - UserName string `json:"user_name"` - TargetAmount CharityAmount `json:"target_amount"` + ID string `json:"id"` + CampaignID string `json:"campaign_id"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + Amount CharityAmount `json:"amount"` } func (e CharityDonations) Path() string { return "/charity/donations" } @@ -98,11 +99,12 @@ func getCharityDonations(w http.ResponseWriter, r *http.Request) { for i := 0; i < first; i++ { d := GetCharityDonationsResponse{ - ID: util.RandomGUID(), - UserID: userCtx.UserID, - UserName: user.DisplayName, - UserLogin: user.UserLogin, - TargetAmount: CharityAmount{ + ID: util.RandomGUID(), + CampaignID: util.RandomGUID(), + UserID: userCtx.UserID, + UserName: user.DisplayName, + UserLogin: user.UserLogin, + Amount: CharityAmount{ Value: rand.Intn(150000-300) + 300, // Between $3 and $1,500 DecimalPlaces: 2, Currency: "USD", diff --git a/internal/mock_api/endpoints/chat/color.go b/internal/mock_api/endpoints/chat/color.go index fb1d10ca..bfba5a21 100644 --- a/internal/mock_api/endpoints/chat/color.go +++ b/internal/mock_api/endpoints/chat/color.go @@ -1,188 +1,190 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package chat - -import ( - "encoding/json" - "log" - "net/http" - "regexp" - "strings" - - "github.com/twitchdev/twitch-cli/internal/database" - "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" - "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" - "github.com/twitchdev/twitch-cli/internal/models" -) - -var colorMethodsSupported = map[string]bool{ - http.MethodGet: true, - http.MethodPost: false, - http.MethodDelete: false, - http.MethodPatch: false, - http.MethodPut: true, -} - -var colorScopesByMethod = map[string][]string{ - http.MethodGet: {}, - http.MethodPost: {}, - http.MethodDelete: {}, - http.MethodPatch: {}, - http.MethodPut: {"user:manage:chat_color"}, -} - -type GetColorRequestBody struct { - UserID string `json:"user_id"` - UserName string `json:"user_name"` - UserLogin string `json:"user_login"` - Color string `json:"color"` -} - -type Color struct{} - -func (e Color) Path() string { return "/chat/color" } - -func (e Color) GetRequiredScopes(method string) []string { - return colorScopesByMethod[method] -} - -func (e Color) ValidMethod(method string) bool { - return colorMethodsSupported[method] -} - -func (e Color) ServeHTTP(w http.ResponseWriter, r *http.Request) { - db = r.Context().Value("db").(database.CLIDatabase) - - switch r.Method { - case http.MethodGet: - getColor(w, r) - break - case http.MethodPut: - putColor(w, r) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -var validHexColorRegexp *regexp.Regexp = regexp.MustCompile("^#[a-fA-F0-9]{6}$") -var validNamedColorsLower = map[string]string{ - "blue": "#0000FF", - "blue_violet": "#8A2BE2", - "cadet_blue": "#5F9EA0", - "chocolate": "#D2691E", - "coral": "#FF7F50", - "dodger_blue": "#1E90FF", - "firebrick": "#B22222", - "golden_rod": "#DAA520", - "green": "#008000", - "hot_pink": "#FF69B4", - "orange_red": "#FF4500", - "red": "#FF0000", - "sea_green": "#2E8B57", - "spring_green": "#00FF7F", - "yellow_green": "#9ACD32", -} - -func getColor(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - userIDs := q["user_id"] - results := []GetColorRequestBody{} - - if len(userIDs) == 0 { - mock_errors.WriteBadRequest(w, "Missing required parameter user_id") - return - } - - if len(userIDs) > 100 { - mock_errors.WriteBadRequest(w, "You may only specify up to 100 user_id query parameters") - return - } - - for _, i := range userIDs { - user := database.User{ - ID: i, - } - u, err := db.NewQuery(r, 100).GetUser(user) - if err != nil { - log.Print(err.Error()) - w.WriteHeader(500) - return - } - if u.ID == "" { - continue - } - - duplicate := false - for _, d := range results { - if d.UserID == u.ID { - duplicate = true - } - } - if duplicate { - continue - } - - results = append(results, GetColorRequestBody{ - UserID: u.ID, - UserName: u.DisplayName, - UserLogin: u.UserLogin, - Color: u.ChatColor, - }) - } - - bytes, _ := json.Marshal(models.APIResponse{ - Data: results, - }) - w.Write(bytes) -} - -func putColor(w http.ResponseWriter, r *http.Request) { - userCtx := r.Context().Value("auth").(authentication.UserAuthentication) - if !userCtx.MatchesUserIDParam(r) { - mock_errors.WriteUnauthorized(w, "User ID does not match token.") - return - } - - userID := r.URL.Query().Get("user_id") - if userID == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter user_id") - return - } - - color := r.URL.Query().Get("color") - if color == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter color") - return - } - - // Users need to input %23 instead of # into their query. We store as # internally, so this needs to be converted - color = strings.ReplaceAll(color, "%23", "#") - - // Check if named color is valid. If so, change the above color variable to the hex so it can pass the regex below. - // This allows us to store color directly into the database without an additional variable. - if namedColorHex, ok := validNamedColorsLower[strings.ToLower(color)]; ok { - color = namedColorHex - } - - validHex := validHexColorRegexp.MatchString(color) - - if !validHex { - mock_errors.WriteBadRequest(w, "The color specified in the color query paramter is not valid") - return - } - - // Store in database, and return no body, just HTTP 200 - u, err := db.NewQuery(r, 100).GetUser(database.User{ID: userID}) - if err != nil { - mock_errors.WriteServerError(w, "Error fetching user: "+err.Error()) - return - } - - u.ChatColor = color - log.Printf("%v", u) - - err = db.NewQuery(r, 100).InsertUser(u, true) - if err != nil { - mock_errors.WriteServerError(w, "Error writing to database: "+err.Error()) - } -} +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package chat + +import ( + "encoding/json" + "log" + "net/http" + "regexp" + "strings" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" + "github.com/twitchdev/twitch-cli/internal/models" +) + +var colorMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: true, +} + +var colorScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {"user:manage:chat_color"}, +} + +type GetColorRequestBody struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + UserLogin string `json:"user_login"` + Color string `json:"color"` +} + +type Color struct{} + +func (e Color) Path() string { return "/chat/color" } + +func (e Color) GetRequiredScopes(method string) []string { + return colorScopesByMethod[method] +} + +func (e Color) ValidMethod(method string) bool { + return colorMethodsSupported[method] +} + +func (e Color) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + getColor(w, r) + break + case http.MethodPut: + putColor(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +var validHexColorRegexp *regexp.Regexp = regexp.MustCompile("^#[a-fA-F0-9]{6}$") +var validNamedColorsLower = map[string]string{ + "blue": "#0000FF", + "blue_violet": "#8A2BE2", + "cadet_blue": "#5F9EA0", + "chocolate": "#D2691E", + "coral": "#FF7F50", + "dodger_blue": "#1E90FF", + "firebrick": "#B22222", + "golden_rod": "#DAA520", + "green": "#008000", + "hot_pink": "#FF69B4", + "orange_red": "#FF4500", + "red": "#FF0000", + "sea_green": "#2E8B57", + "spring_green": "#00FF7F", + "yellow_green": "#9ACD32", +} + +func getColor(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + userIDs := q["user_id"] + results := []GetColorRequestBody{} + + if len(userIDs) == 0 { + mock_errors.WriteBadRequest(w, "Missing required parameter user_id") + return + } + + if len(userIDs) > 100 { + mock_errors.WriteBadRequest(w, "You may only specify up to 100 user_id query parameters") + return + } + + for _, i := range userIDs { + user := database.User{ + ID: i, + } + u, err := db.NewQuery(r, 100).GetUser(user) + if err != nil { + log.Print(err.Error()) + w.WriteHeader(500) + return + } + if u.ID == "" { + continue + } + + duplicate := false + for _, d := range results { + if d.UserID == u.ID { + duplicate = true + } + } + if duplicate { + continue + } + + results = append(results, GetColorRequestBody{ + UserID: u.ID, + UserName: u.DisplayName, + UserLogin: u.UserLogin, + Color: u.ChatColor, + }) + } + + bytes, _ := json.Marshal(models.APIResponse{ + Data: results, + }) + w.Write(bytes) +} + +func putColor(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + if !userCtx.MatchesUserIDParam(r) { + mock_errors.WriteUnauthorized(w, "User ID does not match token.") + return + } + + userID := r.URL.Query().Get("user_id") + if userID == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter user_id") + return + } + + color := r.URL.Query().Get("color") + if color == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter color") + return + } + + // Users need to input %23 instead of # into their query. We store as # internally, so this needs to be converted + color = strings.ReplaceAll(color, "%23", "#") + + // Check if named color is valid. If so, change the above color variable to the hex so it can pass the regex below. + // This allows us to store color directly into the database without an additional variable. + if namedColorHex, ok := validNamedColorsLower[strings.ToLower(color)]; ok { + color = namedColorHex + } + + validHex := validHexColorRegexp.MatchString(color) + + if !validHex { + mock_errors.WriteBadRequest(w, "The color specified in the color query paramter is not valid") + return + } + + // Store in database, and return no body, just HTTP 204 + u, err := db.NewQuery(r, 100).GetUser(database.User{ID: userID}) + if err != nil { + mock_errors.WriteServerError(w, "Error fetching user: "+err.Error()) + return + } + + u.ChatColor = color + log.Printf("%v", u) + + err = db.NewQuery(r, 100).InsertUser(u, true) + if err != nil { + mock_errors.WriteServerError(w, "Error writing to database: "+err.Error()) + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/mock_api/endpoints/chat/shoutouts.go b/internal/mock_api/endpoints/chat/shoutouts.go index e8aae82c..c512f1bd 100644 --- a/internal/mock_api/endpoints/chat/shoutouts.go +++ b/internal/mock_api/endpoints/chat/shoutouts.go @@ -1,133 +1,133 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package chat - -import ( - "net/http" - - "github.com/twitchdev/twitch-cli/internal/database" - "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" - "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" -) - -var shoutoutsMethodsSupported = map[string]bool{ - http.MethodGet: false, - http.MethodPost: true, - http.MethodDelete: false, - http.MethodPatch: false, - http.MethodPut: false, -} - -var shoutoutsScopesByMethod = map[string][]string{ - http.MethodGet: {}, - http.MethodPost: {"moderator:manage:shoutout"}, - http.MethodDelete: {}, - http.MethodPatch: {}, - http.MethodPut: {}, -} - -type PostShoutoutsRequestBody struct { - SlowMode *bool `json:"slow_mode"` - SlowModeWaitTime *int `json:"slow_mode_wait_time"` - FollowerMode *bool `json:"follower_mode"` - FollowerModeDuration *int `json:"follower_mode_duration"` - SubscriberMode *bool `json:"subscriber_mode"` - EmoteMode *bool `json:"emote_mode"` - UniqueChatMode *bool `json:"unique_chat_mode"` - NonModeratorChatDelay *bool `json:"non_moderator_chat_delay"` - NonModeratorChatDelayDuration *int `json:"non_moderator_chat_delay_duration"` -} -type Shoutouts struct{} - -func (e Shoutouts) Path() string { return "/chat/shoutouts" } - -func (e Shoutouts) GetRequiredScopes(method string) []string { - return shoutoutsScopesByMethod[method] -} - -func (e Shoutouts) ValidMethod(method string) bool { - return shoutoutsMethodsSupported[method] -} - -func (e Shoutouts) ServeHTTP(w http.ResponseWriter, r *http.Request) { - db = r.Context().Value("db").(database.CLIDatabase) - - switch r.Method { - case http.MethodPost: - postShoutouts(w, r) - break - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -func postShoutouts(w http.ResponseWriter, r *http.Request) { - userCtx := r.Context().Value("auth").(authentication.UserAuthentication) - if !userCtx.MatchesModeratorIDParam(r) { - mock_errors.WriteUnauthorized(w, "Moderator ID does not match token.") - return - } - - fromBroadcasterId := r.URL.Query().Get("from_broadcaster_id") - if fromBroadcasterId == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter from_broadcaster_id") - return - } - - toBroadcasterId := r.URL.Query().Get("to_broadcaster_id") - if toBroadcasterId == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter to_broadcaster_id") - return - } - - moderatorID := r.URL.Query().Get("moderator_id") - if moderatorID == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter moderator_id") - return - } - - fromBroadcaster, err := db.NewQuery(r, 100).GetUser(database.User{ID: fromBroadcasterId}) - if err != nil { - mock_errors.WriteServerError(w, "error fetching fromBrodcasterId") - return - } - if fromBroadcaster.ID == "" { - mock_errors.WriteBadRequest(w, "Invalid from_broadcaser_id: No broadcaster by that ID exists") - return - } - - toBroadcaster, err := db.NewQuery(r, 100).GetUser(database.User{ID: toBroadcasterId}) - if err != nil { - mock_errors.WriteServerError(w, "error fetching toBrodcasterId") - return - } - if toBroadcaster.ID == "" { - mock_errors.WriteBadRequest(w, "Invalid to_broadcaser_id: No broadcaster by that ID exists") - return - } - - // Verify user is a moderator or is the broadcaster - isModerator := false - if fromBroadcasterId == moderatorID { - isModerator = true - } else { - moderatorListDbr, err := db.NewQuery(r, 1000).GetModeratorsForBroadcaster(fromBroadcasterId) - if err != nil { - mock_errors.WriteServerError(w, err.Error()) - return - } - for _, mod := range moderatorListDbr.Data.([]database.Moderator) { - if mod.UserID == moderatorID { - isModerator = true - } - } - } - if !isModerator { - mock_errors.WriteUnauthorized(w, "The user specified in parameter moderator_id is not one of the broadcaster's moderators") - return - } - - // No connection to chat on here, and no way to GET or PATCH announcements via API - // For the time being, we just ingest it and pretend it worked (HTTP 204) - w.WriteHeader(http.StatusNoContent) -} +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package chat + +import ( + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" +) + +var shoutoutsMethodsSupported = map[string]bool{ + http.MethodGet: false, + http.MethodPost: true, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var shoutoutsScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {"moderator:manage:shoutouts"}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type PostShoutoutsRequestBody struct { + SlowMode *bool `json:"slow_mode"` + SlowModeWaitTime *int `json:"slow_mode_wait_time"` + FollowerMode *bool `json:"follower_mode"` + FollowerModeDuration *int `json:"follower_mode_duration"` + SubscriberMode *bool `json:"subscriber_mode"` + EmoteMode *bool `json:"emote_mode"` + UniqueChatMode *bool `json:"unique_chat_mode"` + NonModeratorChatDelay *bool `json:"non_moderator_chat_delay"` + NonModeratorChatDelayDuration *int `json:"non_moderator_chat_delay_duration"` +} +type Shoutouts struct{} + +func (e Shoutouts) Path() string { return "/chat/shoutouts" } + +func (e Shoutouts) GetRequiredScopes(method string) []string { + return shoutoutsScopesByMethod[method] +} + +func (e Shoutouts) ValidMethod(method string) bool { + return shoutoutsMethodsSupported[method] +} + +func (e Shoutouts) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodPost: + postShoutouts(w, r) + break + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func postShoutouts(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + if !userCtx.MatchesModeratorIDParam(r) { + mock_errors.WriteUnauthorized(w, "Moderator ID does not match token.") + return + } + + fromBroadcasterId := r.URL.Query().Get("from_broadcaster_id") + if fromBroadcasterId == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter from_broadcaster_id") + return + } + + toBroadcasterId := r.URL.Query().Get("to_broadcaster_id") + if toBroadcasterId == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter to_broadcaster_id") + return + } + + moderatorID := r.URL.Query().Get("moderator_id") + if moderatorID == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter moderator_id") + return + } + + fromBroadcaster, err := db.NewQuery(r, 100).GetUser(database.User{ID: fromBroadcasterId}) + if err != nil { + mock_errors.WriteServerError(w, "error fetching fromBrodcasterId") + return + } + if fromBroadcaster.ID == "" { + mock_errors.WriteBadRequest(w, "Invalid from_broadcaser_id: No broadcaster by that ID exists") + return + } + + toBroadcaster, err := db.NewQuery(r, 100).GetUser(database.User{ID: toBroadcasterId}) + if err != nil { + mock_errors.WriteServerError(w, "error fetching toBrodcasterId") + return + } + if toBroadcaster.ID == "" { + mock_errors.WriteBadRequest(w, "Invalid to_broadcaser_id: No broadcaster by that ID exists") + return + } + + // Verify user is a moderator or is the broadcaster + isModerator := false + if fromBroadcasterId == moderatorID { + isModerator = true + } else { + moderatorListDbr, err := db.NewQuery(r, 1000).GetModeratorsForBroadcaster(fromBroadcasterId) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + for _, mod := range moderatorListDbr.Data.([]database.Moderator) { + if mod.UserID == moderatorID { + isModerator = true + } + } + } + if !isModerator { + mock_errors.WriteUnauthorized(w, "The user specified in parameter moderator_id is not one of the broadcaster's moderators") + return + } + + // No connection to chat on here, and no way to GET or PATCH announcements via API + // For the time being, we just ingest it and pretend it worked (HTTP 204) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/mock_api/endpoints/raids/raids.go b/internal/mock_api/endpoints/raids/raids.go index 8d9ef184..30452fe2 100644 --- a/internal/mock_api/endpoints/raids/raids.go +++ b/internal/mock_api/endpoints/raids/raids.go @@ -1,133 +1,135 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package raids - -import ( - "encoding/json" - "math/rand" - "net/http" - "time" - - "github.com/twitchdev/twitch-cli/internal/database" - "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" - "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" - "github.com/twitchdev/twitch-cli/internal/models" - "github.com/twitchdev/twitch-cli/internal/util" -) - -var raidsMethodsSupported = map[string]bool{ - http.MethodGet: false, - http.MethodPost: true, - http.MethodDelete: true, - http.MethodPatch: false, - http.MethodPut: false, -} - -var raidsScopesByMethod = map[string][]string{ - http.MethodGet: {}, - http.MethodPost: {"channel:manage:raids"}, - http.MethodDelete: {"channel:manage:raids"}, - http.MethodPatch: {}, - http.MethodPut: {}, -} - -type GetVIPsResponseBody struct { - CreatedAt string `json:"created_at"` - IsMature bool `json:"is_mature"` -} - -type Raids struct{} - -func (e Raids) Path() string { return "/raids" } - -func (e Raids) GetRequiredScopes(method string) []string { - return raidsScopesByMethod[method] -} - -func (e Raids) ValidMethod(method string) bool { - return raidsMethodsSupported[method] -} - -func (e Raids) ServeHTTP(w http.ResponseWriter, r *http.Request) { - db = r.Context().Value("db").(database.CLIDatabase) - - switch r.Method { - case http.MethodPost: - postRaids(w, r) - break - case http.MethodDelete: - deleteRaids(w, r) - break - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -func postRaids(w http.ResponseWriter, r *http.Request) { - userCtx := r.Context().Value("auth").(authentication.UserAuthentication) - if !userCtx.MatchesSpecifiedIDParam(r, "from_broadcaster_id") { - mock_errors.WriteUnauthorized(w, "from_broadcaster_id does not match token") - return - } - - fromBroadcasterID := r.URL.Query().Get("from_broadcaster_id") - if fromBroadcasterID == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter from_broadcaster_id") - return - } - - toBroadcasterID := r.URL.Query().Get("to_broadcaster_id") - if toBroadcasterID == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter to_broadcaster_id") - return - } - - if fromBroadcasterID == toBroadcasterID { - mock_errors.WriteBadRequest(w, "The IDs on from_broadcaster_id and to_broadcaster_id cannot be the same ID") - return - } - - // Check if user exists - user, err := db.NewQuery(r, 100).GetUser(database.User{ID: toBroadcasterID}) - if err != nil { - mock_errors.WriteServerError(w, "error pulling to_broadcaster_id from user database: "+err.Error()) - return - } - if user.ID == "" { - mock_errors.WriteBadRequest(w, "User specified in to_broadcaster_id doesn't exist") - return - } - - rand.Seed(util.GetTimestamp().UnixNano()) - isMature := rand.Float32() < 0.5 - - bytes, _ := json.Marshal(models.APIResponse{ - Data: GetVIPsResponseBody{ - CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), - IsMature: isMature, - }, - }) - w.Write(bytes) - - // There's no real channel handling in the mock API, so we'll just ingest this and say it happened. - // Right now this means no 409 Conflict handling -} - -func deleteRaids(w http.ResponseWriter, r *http.Request) { - userCtx := r.Context().Value("auth").(authentication.UserAuthentication) - if !userCtx.MatchesBroadcasterIDParam(r) { - mock_errors.WriteUnauthorized(w, "broadcaster_id does not match token") - return - } - - broadcasterID := r.URL.Query().Get("broadcaster_id") - if broadcasterID == "" { - mock_errors.WriteBadRequest(w, "Missing required parameter broadcaster_id") - return - } - - // There's no real channel handling in the mock API, so we'll just ingest this and say it happened. - // Right now this means no 404 Not Found handling - - w.WriteHeader(http.StatusNoContent) -} +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package raids + +import ( + "encoding/json" + "math/rand" + "net/http" + "time" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var raidsMethodsSupported = map[string]bool{ + http.MethodGet: false, + http.MethodPost: true, + http.MethodDelete: true, + http.MethodPatch: false, + http.MethodPut: false, +} + +var raidsScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {"channel:manage:raids"}, + http.MethodDelete: {"channel:manage:raids"}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type GetVIPsResponseBody struct { + CreatedAt string `json:"created_at"` + IsMature bool `json:"is_mature"` +} + +type Raids struct{} + +func (e Raids) Path() string { return "/raids" } + +func (e Raids) GetRequiredScopes(method string) []string { + return raidsScopesByMethod[method] +} + +func (e Raids) ValidMethod(method string) bool { + return raidsMethodsSupported[method] +} + +func (e Raids) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodPost: + postRaids(w, r) + break + case http.MethodDelete: + deleteRaids(w, r) + break + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func postRaids(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + if !userCtx.MatchesSpecifiedIDParam(r, "from_broadcaster_id") { + mock_errors.WriteUnauthorized(w, "from_broadcaster_id does not match token") + return + } + + fromBroadcasterID := r.URL.Query().Get("from_broadcaster_id") + if fromBroadcasterID == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter from_broadcaster_id") + return + } + + toBroadcasterID := r.URL.Query().Get("to_broadcaster_id") + if toBroadcasterID == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter to_broadcaster_id") + return + } + + if fromBroadcasterID == toBroadcasterID { + mock_errors.WriteBadRequest(w, "The IDs on from_broadcaster_id and to_broadcaster_id cannot be the same ID") + return + } + + // Check if user exists + user, err := db.NewQuery(r, 100).GetUser(database.User{ID: toBroadcasterID}) + if err != nil { + mock_errors.WriteServerError(w, "error pulling to_broadcaster_id from user database: "+err.Error()) + return + } + if user.ID == "" { + mock_errors.WriteBadRequest(w, "User specified in to_broadcaster_id doesn't exist") + return + } + + rand.Seed(util.GetTimestamp().UnixNano()) + isMature := rand.Float32() < 0.5 + + bytes, _ := json.Marshal(models.APIResponse{ + Data: []GetVIPsResponseBody{ + { + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + IsMature: isMature, + }, + }, + }) + w.Write(bytes) + + // There's no real channel handling in the mock API, so we'll just ingest this and say it happened. + // Right now this means no 409 Conflict handling +} + +func deleteRaids(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + if !userCtx.MatchesBroadcasterIDParam(r) { + mock_errors.WriteUnauthorized(w, "broadcaster_id does not match token") + return + } + + broadcasterID := r.URL.Query().Get("broadcaster_id") + if broadcasterID == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter broadcaster_id") + return + } + + // There's no real channel handling in the mock API, so we'll just ingest this and say it happened. + // Right now this means no 404 Not Found handling + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/mock_api/endpoints/schedule/scehdule_test.go b/internal/mock_api/endpoints/schedule/schedule_test.go similarity index 96% rename from internal/mock_api/endpoints/schedule/scehdule_test.go rename to internal/mock_api/endpoints/schedule/schedule_test.go index fc3928f3..f726e60f 100644 --- a/internal/mock_api/endpoints/schedule/scehdule_test.go +++ b/internal/mock_api/endpoints/schedule/schedule_test.go @@ -185,16 +185,6 @@ func TestSegment(t *testing.T) { a.Nil(err) a.Equal(400, resp.StatusCode) - body.Timezone = segment.Timezone - body.IsRecurring = nil - b, _ = json.Marshal(body) - req, _ = http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) - q.Set("broadcaster_id", "1") - req.URL.RawQuery = q.Encode() - resp, err = http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(400, resp.StatusCode) - // patch // no id b, _ = json.Marshal(body) @@ -217,6 +207,7 @@ func TestSegment(t *testing.T) { // good request body.Title = "patched_title" + body.Timezone = "America/Los_Angeles" b, _ = json.Marshal(body) req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) q.Set("broadcaster_id", "1") diff --git a/internal/mock_api/endpoints/schedule/segment.go b/internal/mock_api/endpoints/schedule/segment.go index 701adad8..a86a15e8 100644 --- a/internal/mock_api/endpoints/schedule/segment.go +++ b/internal/mock_api/endpoints/schedule/segment.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "github.com/twitchdev/twitch-cli/internal/models" "net/http" "strconv" "time" @@ -95,6 +96,7 @@ func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { mock_errors.WriteBadRequest(w, "Invalid/malformed start_time provided") return } + if body.Timezone == "" { mock_errors.WriteBadRequest(w, "Missing timezone") return @@ -138,7 +140,6 @@ func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { CategoryID: body.CategoryID, Title: body.Title, UserID: userCtx.UserID, - Timezone: "America/Los_Angeles", IsCanceled: &f, } err = db.NewQuery(nil, 100).InsertSchedule(segment) @@ -163,7 +164,6 @@ func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { CategoryID: body.CategoryID, Title: body.Title, UserID: userCtx.UserID, - Timezone: body.Timezone, IsCanceled: &f, } @@ -181,16 +181,15 @@ func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { } b := dbr.Data.(database.Schedule) - // Remove timezone from JSON given in response - for i := range b.Segments { - b.Segments[i].Timezone = "" - } - if b.Vacation.StartTime == "" && b.Vacation.EndTime == "" { b.Vacation = nil } - bytes, _ := json.Marshal(b) + apiResponse := models.APIResponse{ + Data: b, + } + + bytes, _ := json.Marshal(apiResponse) w.Write(bytes) } @@ -261,26 +260,21 @@ func (e ScheduleSegment) patchSegment(w http.ResponseWriter, r *http.Request) { } } - // timezone - tz, err := time.LoadLocation(segment.Timezone) - if err != nil { - mock_errors.WriteServerError(w, err.Error()) - return + // is_canceled + isCanceled := false + if body.IsCanceled != nil { + isCanceled = *body.IsCanceled } + + // timezone if body.Timezone != "" { - tz, err = time.LoadLocation(body.Timezone) + _, err := time.LoadLocation(body.Timezone) if err != nil { mock_errors.WriteBadRequest(w, "Error parsing timezone") return } } - // is_canceled - isCanceled := false - if body.IsCanceled != nil { - isCanceled = *body.IsCanceled - } - // title title := segment.Title if body.Title != "" { @@ -312,7 +306,6 @@ func (e ScheduleSegment) patchSegment(w http.ResponseWriter, r *http.Request) { StartTime: st.UTC().Format(time.RFC3339), EndTime: et.UTC().Format(time.RFC3339), IsCanceled: &isCanceled, - Timezone: tz.String(), Title: title, } @@ -329,15 +322,14 @@ func (e ScheduleSegment) patchSegment(w http.ResponseWriter, r *http.Request) { } b = dbr.Data.(database.Schedule) - // Remove timezone from JSON given in response - for i := range b.Segments { - b.Segments[i].Timezone = "" - } - if b.Vacation.StartTime == "" && b.Vacation.EndTime == "" { b.Vacation = nil } - bytes, _ := json.Marshal(b) + apiResponse := models.APIResponse{ + Data: b, + } + + bytes, _ := json.Marshal(apiResponse) w.Write(bytes) } diff --git a/internal/mock_api/endpoints/subscriptions/subscriptions.go b/internal/mock_api/endpoints/subscriptions/subscriptions.go index da68f91a..d7b51c06 100644 --- a/internal/mock_api/endpoints/subscriptions/subscriptions.go +++ b/internal/mock_api/endpoints/subscriptions/subscriptions.go @@ -78,6 +78,8 @@ func getBroadcasterSubscriptions(w http.ResponseWriter, r *http.Request) { body := models.APIResponse{ Data: dbr.Data, Total: &dbr.Total, + // This would usually be something like tier 1 = 1 pt, tier 2 = 2 pts, tier 3 = 6 pts. For simplicity, return total instead + Points: dbr.Total, } if dbr.Cursor != "" { diff --git a/internal/mock_api/generate/generate.go b/internal/mock_api/generate/generate.go index c81aab71..bc6d7808 100644 --- a/internal/mock_api/generate/generate.go +++ b/internal/mock_api/generate/generate.go @@ -297,7 +297,6 @@ func generateUsers(ctx context.Context, count int) error { CategoryID: &dropsGameID, Title: "Test Title", UserID: broadcaster.ID, - Timezone: "America/Los_Angeles", IsCanceled: &f, } diff --git a/internal/models/api.go b/internal/models/api.go index 4502bb07..3987ae3f 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -11,6 +11,7 @@ type APIResponse struct { Template string `json:"template,omitempty"` Total *int `json:"total,omitempty"` DateRange *BitsLeaderboardDateRange `json:"date_range,omitempty"` + Points int `json:"points,omitempty"` } type APIPagination struct { diff --git a/internal/util/path.go b/internal/util/path.go index 0ed804e7..17b51cb9 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -58,5 +58,10 @@ func GetConfigPath() (string, error) { configPath := filepath.Join(home, ".twitch-cli.env") + // purely for testing purposes- this allows us to run tests without overwriting the user's config + if os.Getenv("GOLANG_TESTING") == "true" { + configPath = filepath.Join(home, ".twitch-cli-test.env") + } + return configPath, nil } diff --git a/test_setup/test_setup.go b/test_setup/test_setup.go index 74a06abf..65c98ba5 100644 --- a/test_setup/test_setup.go +++ b/test_setup/test_setup.go @@ -21,5 +21,6 @@ func SetupTestEnv(t *testing.T) *assert.Assertions { viper.SetConfigType("env") viper.Set("DB_FILENAME", "test-eventCache.db") + t.Setenv("GOLANG_TESTING", "true") return assert }