From d783250c8af3f53f52b4c6b1303841ef230a34ae Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:58:38 -0800 Subject: [PATCH 01/31] Changed .Run(...) to .RunE(...); Fixes #289 --- cmd/configure.go | 2 +- cmd/token.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/token.go b/cmd/token.go index bebb5f45..5133b14b 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -50,7 +50,11 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { 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") } From 68fee14d6f71e9852ff50ce4e083a9fea30d8b78 Mon Sep 17 00:00:00 2001 From: lleadbet <5595530+lleadbet@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:05:54 -0500 Subject: [PATCH 02/31] bind to 0.0.0.0 instead of localhost --- cmd/token.go | 10 ++++++---- docs/token.md | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/cmd/token.go b/cmd/token.go index bebb5f45..a8a350a3 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,7 +48,7 @@ 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.") @@ -76,7 +78,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 +107,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/token.md b/docs/token.md index 4be61388..2588bc4a 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,15 @@ 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 | +| `--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 From ce71f4e29a17d8b0e1bc8f80fbde86f083f2b074 Mon Sep 17 00:00:00 2001 From: lleadbet <5595530+lleadbet@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:19:28 -0500 Subject: [PATCH 03/31] adds missing version parameter to verify-subscription --- cmd/events.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/events.go b/cmd/events.go index ce0cf17d..0217a1f1 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -319,6 +319,7 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` Secret: secret, Timestamp: timestamp, EventID: eventID, + Version: version, }) if err != nil { From 7a7cb4a314dbac224ea116125967167c7249cb65 Mon Sep 17 00:00:00 2001 From: lleadbet <5595530+lleadbet@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:30:20 -0500 Subject: [PATCH 04/31] allows users to pass broadcaster IDs into verify-subscription --- cmd/events.go | 14 ++++++----- docs/event.md | 3 ++- internal/events/verify/subscription_verify.go | 25 +++++++++++-------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/cmd/events.go b/cmd/events.go index ce0cf17d..cc923a21 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -176,6 +176,7 @@ 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.Flags().StringVarP(&toUser, "broadcaster", "b", "", "User ID of the broadcaster for the verification event.") verifyCmd.MarkFlagRequired("forward-address") // websocket flags @@ -313,12 +314,13 @@ 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, }) if err != nil { diff --git a/docs/event.md b/docs/event.md index 828dfbf3..cd3eade7 100644 --- a/docs/event.md +++ b/docs/event.md @@ -5,7 +5,7 @@ - [Trigger](#trigger) - [Retrigger](#retrigger) - [Verify-Subscription](#verify-subscription) - - [Websocket](#websocket) + - [WebSocket](#websocket) ## Description @@ -154,6 +154,7 @@ 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 | | `--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 | 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", From 2d3755dda918a2fb3b423d11e14408f2bc328898 Mon Sep 17 00:00:00 2001 From: lleadbet <5595530+lleadbet@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:59:13 -0500 Subject: [PATCH 05/31] Support for larger drop payloads --- cmd/events.go | 2 +- internal/events/types/drop/drop.go | 53 ++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/cmd/events.go b/cmd/events.go index ce0cf17d..b6de2f55 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -149,7 +149,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.") 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 { From c1aef88989f6cdbb989e22bf3019c85ea5319a99 Mon Sep 17 00:00:00 2001 From: lleadbet <5595530+lleadbet@users.noreply.github.com> Date: Sun, 3 Dec 2023 17:09:08 -0500 Subject: [PATCH 06/31] event configure command --- cmd/events.go | 68 ++++++++++++++++++--- docs/event.md | 24 +++++++- internal/events/configure/configure.go | 58 ++++++++++++++++++ internal/events/configure/configure_test.go | 36 +++++++++++ internal/util/path.go | 5 ++ test_setup/test_setup.go | 1 + 6 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 internal/events/configure/configure.go create mode 100644 internal/events/configure/configure_test.go diff --git a/cmd/events.go b/cmd/events.go index ce0cf17d..81e45cd4 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 @@ -127,16 +129,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 @@ -167,6 +178,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 +188,7 @@ 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.") // websocket flags /// flags for start-server @@ -192,6 +204,10 @@ 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".`) + + // 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 +220,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 +236,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 +284,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 +324,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 +340,8 @@ func verifyCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } + } else { + forwardAddress = defaults.ForwardAddress } if timestamp == "" { @@ -352,3 +397,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/docs/event.md b/docs/event.md index 828dfbf3..4f3c5373 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 | @@ -134,8 +149,10 @@ 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 @@ -144,7 +161,7 @@ twitch event retrigger -i "713f3254-0178-9757-7439-d779400c0999" -F https://loca ## 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** @@ -155,6 +172,7 @@ This command takes the same arguments as [Trigger](#trigger). | Flag | Shorthand | Description | Example | Required? (Y/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 | 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/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 } From 52e55e95c19542dbf675562e502edec18c9d323a Mon Sep 17 00:00:00 2001 From: AaricDev Date: Mon, 4 Dec 2023 01:18:25 +0100 Subject: [PATCH 07/31] Fixed some difference to the original API - Also fixed a little typo on "campaigns.current_amount" --- .../mock_api/endpoints/charity/campaigns.go | 2 +- .../mock_api/endpoints/charity/donations.go | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) 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..568c8771 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"` + TargetAmount CharityAmount `json:"amount"` } func (e CharityDonations) Path() string { return "/charity/donations" } @@ -98,10 +99,11 @@ 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, + ID: util.RandomGUID(), + CampaignID: util.RandomGUID(), + UserID: userCtx.UserID, + UserName: user.DisplayName, + UserLogin: user.UserLogin, TargetAmount: CharityAmount{ Value: rand.Intn(150000-300) + 300, // Between $3 and $1,500 DecimalPlaces: 2, From 19ce495eefc0d9c97c503121545c148d163fc091 Mon Sep 17 00:00:00 2001 From: AaricDev Date: Mon, 4 Dec 2023 01:25:16 +0100 Subject: [PATCH 08/31] Fix name of "TargetAmount" property of a donation. The correct name is just amount --- internal/mock_api/endpoints/charity/donations.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/mock_api/endpoints/charity/donations.go b/internal/mock_api/endpoints/charity/donations.go index 568c8771..439755c1 100644 --- a/internal/mock_api/endpoints/charity/donations.go +++ b/internal/mock_api/endpoints/charity/donations.go @@ -34,12 +34,12 @@ var donationsScopesByMethod = map[string][]string{ type CharityDonations struct{} type GetCharityDonationsResponse struct { - ID string `json:"id"` - CampaignID string `json:"campaign_id"` - UserID string `json:"user_id"` - UserLogin string `json:"user_login"` - UserName string `json:"user_name"` - TargetAmount CharityAmount `json:"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" } @@ -104,7 +104,7 @@ func getCharityDonations(w http.ResponseWriter, r *http.Request) { UserID: userCtx.UserID, UserName: user.DisplayName, UserLogin: user.UserLogin, - TargetAmount: CharityAmount{ + Amount: CharityAmount{ Value: rand.Intn(150000-300) + 300, // Between $3 and $1,500 DecimalPlaces: 2, Currency: "USD", From c8e1bcfa3bcd35b4677ca18a4fc80a4524cbe3a7 Mon Sep 17 00:00:00 2001 From: AaricDev Date: Mon, 4 Dec 2023 16:16:34 +0100 Subject: [PATCH 09/31] Fix scope name for mock-api POST /chat/shoutouts endpoint --- internal/mock_api/endpoints/chat/shoutouts.go | 266 +++++++++--------- 1 file changed, 133 insertions(+), 133 deletions(-) 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) +} From fa7ebe79cf36df147ac15ae4d8ecdeccc8940e93 Mon Sep 17 00:00:00 2001 From: AaricDev Date: Mon, 11 Dec 2023 14:00:26 +0100 Subject: [PATCH 10/31] - Fix statuscode of chatcolor endpoint - Fix typo in clip entity --- internal/database/videos.go | 2 +- internal/mock_api/endpoints/chat/color.go | 378 +++++++++++----------- 2 files changed, 191 insertions(+), 189 deletions(-) diff --git a/internal/database/videos.go b/internal/database/videos.go index 556f2021..4ef92685 100644 --- a/internal/database/videos.go +++ b/internal/database/videos.go @@ -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:"-"` } 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) +} From 891a2b657887dd63e4385f8ed4acc3e28cbbe597 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Mon, 11 Dec 2023 23:43:42 +0100 Subject: [PATCH 11/31] =?UTF-8?q?Fixed=20wrong=20property=20name=20of=20pr?= =?UTF-8?q?edictions=C3=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/predictions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"` From 3c4e668afaae9d3a20972b10debab9c9467272d2 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Mon, 11 Dec 2023 23:44:13 +0100 Subject: [PATCH 12/31] Fixed response for raids. Create endpoint should return an array --- internal/mock_api/endpoints/raids/raids.go | 268 +++++++++++---------- 1 file changed, 135 insertions(+), 133 deletions(-) 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) +} From 6eae45ee32bb0b72d4748ee614826db27324a4d2 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Tue, 12 Dec 2023 20:19:48 +0100 Subject: [PATCH 13/31] Fix response of schedule segment POST/PATCH - Might need some tweaking --- internal/mock_api/endpoints/schedule/segment.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/mock_api/endpoints/schedule/segment.go b/internal/mock_api/endpoints/schedule/segment.go index 701adad8..34143193 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" @@ -190,7 +191,11 @@ func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { b.Vacation = nil } - bytes, _ := json.Marshal(b) + apiResponse := models.APIResponse{ + Data: b, + } + + bytes, _ := json.Marshal(apiResponse) w.Write(bytes) } @@ -338,6 +343,10 @@ func (e ScheduleSegment) patchSegment(w http.ResponseWriter, r *http.Request) { b.Vacation = nil } - bytes, _ := json.Marshal(b) + apiResponse := models.APIResponse{ + Data: b, + } + + bytes, _ := json.Marshal(apiResponse) w.Write(bytes) } From f34a368a2ffc8049171dd74518bffc5934e97f0b Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Wed, 13 Dec 2023 01:28:48 +0100 Subject: [PATCH 14/31] Fix mapped name for language property of videos --- internal/database/videos.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/database/videos.go b/internal/database/videos.go index 4ef92685..6a64c0fa 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"` From 505c45f0921a22c1ffc0575d52190383a29a1481 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Wed, 13 Dec 2023 01:29:29 +0100 Subject: [PATCH 15/31] Remove omitempty parameter from Subscription structs gifterId/Name/Login --- internal/database/subscriptions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From dc6e4316a16a21a1d86244caf441446901397e73 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sat, 16 Dec 2023 11:47:06 +0100 Subject: [PATCH 16/31] Add dateRange always to leaderboard response --- internal/mock_api/endpoints/bits/leaderboard.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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) From 18c1ae5d5a2963ec78b1976beb25409a2d5828b6 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sat, 16 Dec 2023 13:09:21 +0100 Subject: [PATCH 17/31] Update custom reward redemptions response --- internal/database/channel_points_redemptions.go | 8 ++++---- internal/mock_api/endpoints/channel_points/redemptions.go | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) 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/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 From 33234b1ab27abd6c6a961ccfb0d0431f41960797 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 00:17:53 +0100 Subject: [PATCH 18/31] Add Points to possible api response property --- internal/models/api.go | 1 + 1 file changed, 1 insertion(+) 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 { From c76e9ec6ff1ba03298dbbd4fd6059698d9241e5b Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 00:19:39 +0100 Subject: [PATCH 19/31] Return empty string instead of null for Gifter{ID,Name,Login} --- internal/database/subscriptions.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/database/subscriptions.go b/internal/database/subscriptions.go index ae462ba3..324e7498 100644 --- a/internal/database/subscriptions.go +++ b/internal/database/subscriptions.go @@ -9,18 +9,18 @@ import ( ) type Subscription struct { - BroadcasterID string `db:"broadcaster_id" json:"broadcaster_id"` - BroadcasterLogin string `db:"broadcaster_login" json:"broadcaster_login"` - BroadcasterName string `db:"broadcaster_name" json:"broadcaster_name"` - UserID string `db:"user_id" json:"user_id"` - 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"` - 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:"-"` + BroadcasterID string `db:"broadcaster_id" json:"broadcaster_id"` + BroadcasterLogin string `db:"broadcaster_login" json:"broadcaster_login"` + BroadcasterName string `db:"broadcaster_name" json:"broadcaster_name"` + UserID string `db:"user_id" json:"user_id"` + 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 string `db:"gifter_id" json:"gifter_id"` + GifterName string `db:"gifter_name" json:"gifter_name"` + GifterLogin string `db:"gifter_login" json:"gifter_login"` + Tier string `db:"tier" json:"tier"` + CreatedAt string `db:"created_at" json:"-"` // calculated fields PlanName string `json:"plan_name"` } @@ -36,7 +36,7 @@ type SubscriptionInsert struct { func (q *Query) GetSubscriptions(s Subscription) (*DBResponse, error) { r := []Subscription{} - sql := generateSQL("SELECT u1.id as user_id, u1.user_login as user_login, u1.display_name as user_name, u2.id as broadcaster_id, u2.user_login as broadcaster_login, u2.display_name as broadcaster_name, u3.id as gifter_id, u3.user_login as gifter_login, u3.display_name as gifter_name, s.tier as tier, s.is_gift as is_gift FROM subscriptions as s JOIN users u1 ON s.user_id = u1.id JOIN users u2 ON s.broadcaster_id = u2.id LEFT JOIN users u3 ON s.gifter_id = u3.id", s, SEP_AND) + sql := generateSQL("SELECT u1.id as user_id, u1.user_login as user_login, u1.display_name as user_name, u2.id as broadcaster_id, u2.user_login as broadcaster_login, u2.display_name as broadcaster_name, ifnull(u3.id, '') as gifter_id, ifnull(u3.user_login, '') as gifter_login, ifnull(u3.display_name, '') as gifter_name, s.tier as tier, s.is_gift as is_gift FROM subscriptions as s JOIN users u1 ON s.user_id = u1.id JOIN users u2 ON s.broadcaster_id = u2.id LEFT JOIN users u3 ON s.gifter_id = u3.id", s, SEP_AND) sql += " order by s.created_at desc" sql += q.SQL From 2699420c911ee2d5cad8565832e69ecafe081bba Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 00:19:52 +0100 Subject: [PATCH 20/31] Add points to subscription response --- internal/mock_api/endpoints/subscriptions/subscriptions.go | 2 ++ 1 file changed, 2 insertions(+) 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 != "" { From bd2c989dc1a4dbaef29c4b77a02aa5a55262b4f8 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 00:20:05 +0100 Subject: [PATCH 21/31] Fix naming of video offset --- internal/database/videos.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/database/videos.go b/internal/database/videos.go index 6a64c0fa..be6264b8 100644 --- a/internal/database/videos.go +++ b/internal/database/videos.go @@ -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"` } From 8c24ee0e95719573805d76d5089494f86452fa79 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 00:24:23 +0100 Subject: [PATCH 22/31] Remove deprecated timezone property --- internal/database/_schema.sql | 6 +-- internal/database/init.go | 8 +++- internal/database/schedule.go | 1 - .../{scehdule_test.go => schedule_test.go} | 4 -- .../mock_api/endpoints/schedule/segment.go | 37 ------------------- internal/mock_api/generate/generate.go | 1 - 6 files changed, 9 insertions(+), 48 deletions(-) rename internal/mock_api/endpoints/schedule/{scehdule_test.go => schedule_test.go} (98%) 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/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/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/mock_api/endpoints/schedule/scehdule_test.go b/internal/mock_api/endpoints/schedule/schedule_test.go similarity index 98% rename from internal/mock_api/endpoints/schedule/scehdule_test.go rename to internal/mock_api/endpoints/schedule/schedule_test.go index fc3928f3..d6a74381 100644 --- a/internal/mock_api/endpoints/schedule/scehdule_test.go +++ b/internal/mock_api/endpoints/schedule/schedule_test.go @@ -133,7 +133,6 @@ func TestSegment(t *testing.T) { // post tests body := SegmentPatchAndPostBody{ Title: "hello", - Timezone: "America/Los_Angeles", StartTime: time.Now().Format(time.RFC3339), IsRecurring: &tr, Duration: "60", @@ -167,7 +166,6 @@ func TestSegment(t *testing.T) { a.Equal(401, resp.StatusCode) body.Title = "testing" - body.Timezone = "" b, _ = json.Marshal(body) req, _ = http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) q.Set("broadcaster_id", "1") @@ -176,7 +174,6 @@ func TestSegment(t *testing.T) { a.Nil(err) a.Equal(400, resp.StatusCode) - body.Timezone = "test" b, _ = json.Marshal(body) req, _ = http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) q.Set("broadcaster_id", "1") @@ -185,7 +182,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)) diff --git a/internal/mock_api/endpoints/schedule/segment.go b/internal/mock_api/endpoints/schedule/segment.go index 34143193..e048e617 100644 --- a/internal/mock_api/endpoints/schedule/segment.go +++ b/internal/mock_api/endpoints/schedule/segment.go @@ -39,7 +39,6 @@ type ScheduleSegment struct{} type SegmentPatchAndPostBody struct { StartTime string `json:"start_time"` - Timezone string `json:"timezone"` IsRecurring *bool `json:"is_recurring"` Duration string `json:"duration"` CategoryID *string `json:"category_id"` @@ -96,15 +95,6 @@ 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 - } - _, err = time.LoadLocation(body.Timezone) - if err != nil { - mock_errors.WriteBadRequest(w, "Invalid timezone provided") - return - } var isRecurring bool @@ -139,7 +129,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) @@ -164,7 +153,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, } @@ -182,11 +170,6 @@ 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 } @@ -266,20 +249,6 @@ 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 - } - if body.Timezone != "" { - tz, err = time.LoadLocation(body.Timezone) - if err != nil { - mock_errors.WriteBadRequest(w, "Error parsing timezone") - return - } - } - // is_canceled isCanceled := false if body.IsCanceled != nil { @@ -317,7 +286,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, } @@ -334,11 +302,6 @@ 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 } 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, } From cdc13dc6651d82b3f53e568cf8877c174cdbf75c Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 00:45:25 +0100 Subject: [PATCH 23/31] Also delete stream_markers related to the deleted videos --- internal/database/videos.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/database/videos.go b/internal/database/videos.go index be6264b8..180b4499 100644 --- a/internal/database/videos.go +++ b/internal/database/videos.go @@ -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() From 864911acf68fde437f1b21094e993053538539d5 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 17:37:14 +0100 Subject: [PATCH 24/31] Reverted changes for Gifter{Id,Name,Login} --- internal/database/subscriptions.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/database/subscriptions.go b/internal/database/subscriptions.go index 324e7498..ae462ba3 100644 --- a/internal/database/subscriptions.go +++ b/internal/database/subscriptions.go @@ -9,18 +9,18 @@ import ( ) type Subscription struct { - BroadcasterID string `db:"broadcaster_id" json:"broadcaster_id"` - BroadcasterLogin string `db:"broadcaster_login" json:"broadcaster_login"` - BroadcasterName string `db:"broadcaster_name" json:"broadcaster_name"` - UserID string `db:"user_id" json:"user_id"` - 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 string `db:"gifter_id" json:"gifter_id"` - GifterName string `db:"gifter_name" json:"gifter_name"` - GifterLogin string `db:"gifter_login" json:"gifter_login"` - Tier string `db:"tier" json:"tier"` - CreatedAt string `db:"created_at" json:"-"` + BroadcasterID string `db:"broadcaster_id" json:"broadcaster_id"` + BroadcasterLogin string `db:"broadcaster_login" json:"broadcaster_login"` + BroadcasterName string `db:"broadcaster_name" json:"broadcaster_name"` + UserID string `db:"user_id" json:"user_id"` + 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"` + 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 PlanName string `json:"plan_name"` } @@ -36,7 +36,7 @@ type SubscriptionInsert struct { func (q *Query) GetSubscriptions(s Subscription) (*DBResponse, error) { r := []Subscription{} - sql := generateSQL("SELECT u1.id as user_id, u1.user_login as user_login, u1.display_name as user_name, u2.id as broadcaster_id, u2.user_login as broadcaster_login, u2.display_name as broadcaster_name, ifnull(u3.id, '') as gifter_id, ifnull(u3.user_login, '') as gifter_login, ifnull(u3.display_name, '') as gifter_name, s.tier as tier, s.is_gift as is_gift FROM subscriptions as s JOIN users u1 ON s.user_id = u1.id JOIN users u2 ON s.broadcaster_id = u2.id LEFT JOIN users u3 ON s.gifter_id = u3.id", s, SEP_AND) + sql := generateSQL("SELECT u1.id as user_id, u1.user_login as user_login, u1.display_name as user_name, u2.id as broadcaster_id, u2.user_login as broadcaster_login, u2.display_name as broadcaster_name, u3.id as gifter_id, u3.user_login as gifter_login, u3.display_name as gifter_name, s.tier as tier, s.is_gift as is_gift FROM subscriptions as s JOIN users u1 ON s.user_id = u1.id JOIN users u2 ON s.broadcaster_id = u2.id LEFT JOIN users u3 ON s.gifter_id = u3.id", s, SEP_AND) sql += " order by s.created_at desc" sql += q.SQL From 8987158e3ac07b1f43da711cee3eb73f51936c04 Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 17:52:43 +0100 Subject: [PATCH 25/31] Fixed tests failing due to timezone changes - Removed one test that relied on a timezone from the database --- .../channel_points/channel_points_test.go | 1 + .../endpoints/schedule/schedule_test.go | 12 +++-------- .../mock_api/endpoints/schedule/segment.go | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) 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/schedule/schedule_test.go b/internal/mock_api/endpoints/schedule/schedule_test.go index d6a74381..92a744c2 100644 --- a/internal/mock_api/endpoints/schedule/schedule_test.go +++ b/internal/mock_api/endpoints/schedule/schedule_test.go @@ -133,6 +133,7 @@ func TestSegment(t *testing.T) { // post tests body := SegmentPatchAndPostBody{ Title: "hello", + Timezone: "America/Los_Angeles", StartTime: time.Now().Format(time.RFC3339), IsRecurring: &tr, Duration: "60", @@ -166,6 +167,7 @@ func TestSegment(t *testing.T) { a.Equal(401, resp.StatusCode) body.Title = "testing" + body.Timezone = "" b, _ = json.Marshal(body) req, _ = http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) q.Set("broadcaster_id", "1") @@ -174,15 +176,7 @@ func TestSegment(t *testing.T) { a.Nil(err) a.Equal(400, resp.StatusCode) - 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) - - body.IsRecurring = nil + body.Timezone = "test" b, _ = json.Marshal(body) req, _ = http.NewRequest(http.MethodPost, 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 e048e617..a86a15e8 100644 --- a/internal/mock_api/endpoints/schedule/segment.go +++ b/internal/mock_api/endpoints/schedule/segment.go @@ -39,6 +39,7 @@ type ScheduleSegment struct{} type SegmentPatchAndPostBody struct { StartTime string `json:"start_time"` + Timezone string `json:"timezone"` IsRecurring *bool `json:"is_recurring"` Duration string `json:"duration"` CategoryID *string `json:"category_id"` @@ -96,6 +97,16 @@ func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { return } + if body.Timezone == "" { + mock_errors.WriteBadRequest(w, "Missing timezone") + return + } + _, err = time.LoadLocation(body.Timezone) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid timezone provided") + return + } + var isRecurring bool if body.IsRecurring == nil { @@ -255,6 +266,15 @@ func (e ScheduleSegment) patchSegment(w http.ResponseWriter, r *http.Request) { isCanceled = *body.IsCanceled } + // timezone + if body.Timezone != "" { + _, err := time.LoadLocation(body.Timezone) + if err != nil { + mock_errors.WriteBadRequest(w, "Error parsing timezone") + return + } + } + // title title := segment.Title if body.Title != "" { From 0ae40521976220d9affd4d57db9196de744149db Mon Sep 17 00:00:00 2001 From: Aaricdev Date: Sun, 17 Dec 2023 18:00:20 +0100 Subject: [PATCH 26/31] Set the timezone for another test that relied on the removed scenario --- internal/mock_api/endpoints/schedule/schedule_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/mock_api/endpoints/schedule/schedule_test.go b/internal/mock_api/endpoints/schedule/schedule_test.go index 92a744c2..f726e60f 100644 --- a/internal/mock_api/endpoints/schedule/schedule_test.go +++ b/internal/mock_api/endpoints/schedule/schedule_test.go @@ -207,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") From 2a62486abac1fa81758a0c85804ee34e45028cac Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Fri, 29 Dec 2023 00:49:13 -0800 Subject: [PATCH 27/31] Added RPC command to disable keepalive messages for a client; #291 --- cmd/events.go | 26 +++--- .../events/websocket/mock_server/client.go | 1 + .../events/websocket/mock_server/manager.go | 1 + .../websocket/mock_server/rpc_handler.go | 80 +++++++++++++++++-- .../events/websocket/mock_server/server.go | 9 +++ internal/events/websocket/websocket_cmd.go | 3 + 6 files changed, 101 insertions(+), 19 deletions(-) diff --git a/cmd/events.go b/cmd/events.go index 19924ab5..09bd8614 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -53,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{ @@ -108,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", @@ -205,6 +207,7 @@ 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).") @@ -366,7 +369,7 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` Timestamp: timestamp, EventID: eventID, BroadcasterUserID: toUser, - Version: version, + Version: version, }) if err != nil { @@ -393,6 +396,7 @@ func websocketCmdRun(cmd *cobra.Command, args []string) error { Subscription: wsSubscription, SubscriptionStatus: wsStatus, CloseReason: wsReason, + FeatureEnabled: wsFeatureEnabled, }) return err 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, From 41b07aa835ecca72d6e256a8534d63e70238e06e Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Fri, 29 Dec 2023 01:04:43 -0800 Subject: [PATCH 28/31] Updated docs for keepalive websocket RPC --- docs/event.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/event.md b/docs/event.md index c8be09a7..b431237d 100644 --- a/docs/event.md +++ b/docs/event.md @@ -171,7 +171,7 @@ 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 | +| `--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 | @@ -210,6 +210,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** @@ -218,4 +219,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 +twitch event websocket keepalive --session=e411cc1e_a2613d4e --enabled=false ``` \ No newline at end of file From a04c8b9743ae26c1ea5d5b91cb3564900cc29865 Mon Sep 17 00:00:00 2001 From: constantstress <149433905+constantstress@users.noreply.github.com> Date: Sun, 31 Dec 2023 01:51:34 +0300 Subject: [PATCH 29/31] Update token.md Added --validate / -v flag to command line notes table --- docs/token.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/token.md b/docs/token.md index 2588bc4a..41fe4502 100644 --- a/docs/token.md +++ b/docs/token.md @@ -152,6 +152,7 @@ None. | `--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 | From ce41312801427d39a2ddf2c3d2cd0754bb87f703 Mon Sep 17 00:00:00 2001 From: constantstress <149433905+constantstress@users.noreply.github.com> Date: Sun, 31 Dec 2023 03:59:54 +0300 Subject: [PATCH 30/31] Updated examples in event.md Changed golang style comments to sh style comments in examples `//` to `#` --- docs/event.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/event.md b/docs/event.md index b431237d..9a88cb10 100644 --- a/docs/event.md +++ b/docs/event.md @@ -116,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 @@ -156,7 +157,7 @@ None **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 @@ -180,7 +181,7 @@ This command takes the same arguments as [Trigger](#trigger). **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 @@ -220,4 +221,4 @@ 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 keepalive --session=e411cc1e_a2613d4e --enabled=false -``` \ No newline at end of file +``` From e3ee806e2355c8ac409e8888596951fcc0b2f737 Mon Sep 17 00:00:00 2001 From: Barry Carlyon Date: Thu, 4 Jan 2024 17:16:19 +0000 Subject: [PATCH 31/31] Add WinGet instructions to the Readme touch #275 --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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).