diff --git a/cmd/events.go b/cmd/events.go index 81e45cd4..19924ab5 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -160,7 +160,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.") @@ -189,6 +189,7 @@ func init() { 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().BoolVarP(&noConfig, "no-config", "D", false, "Disables the use of the configuration, if it exists.") + verifyCmd.Flags().StringVarP(&toUser, "broadcaster", "b", "", "User ID of the broadcaster for the verification event.") // websocket flags /// flags for start-server @@ -358,12 +359,14 @@ https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event` } _, err := verify.VerifyWebhookSubscription(verify.VerifyParameters{ - Event: args[0], - Transport: transport, - ForwardAddress: forwardAddress, - Secret: secret, - Timestamp: timestamp, - EventID: eventID, + Event: args[0], + Transport: transport, + ForwardAddress: forwardAddress, + Secret: secret, + Timestamp: timestamp, + EventID: eventID, + BroadcasterUserID: toUser, + Version: version, }) if err != nil { diff --git a/docs/event.md b/docs/event.md index 4f3c5373..c8be09a7 100644 --- a/docs/event.md +++ b/docs/event.md @@ -171,6 +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 | | `--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 | diff --git a/internal/events/types/drop/drop.go b/internal/events/types/drop/drop.go index afa3bd98..ab8c470d 100644 --- a/internal/events/types/drop/drop.go +++ b/internal/events/types/drop/drop.go @@ -41,6 +41,41 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven case models.TransportWebhook: campaignId := util.RandomGUID() + dropEvents := []models.DropsEntitlementEventSubEvent{ + { + ID: util.RandomGUID(), + Data: models.DropsEntitlementEventSubEventData{ + OrganizationID: params.FromUserID, + CategoryID: params.GameID, + CategoryName: "Special Events", + CampaignID: campaignId, + EntitlementID: util.RandomGUID(), + BenefitID: params.ItemID, + UserID: params.ToUserID, + UserName: params.ToUserName, + UserLogin: params.ToUserName, + CreatedAt: params.Timestamp, + }, + }, + } + + for i := int64(1); i < params.Cost; i++ { + // for the new events, we'll use the entitlement above except generating new users as to avoid conflicting drops + dropEvents = append(dropEvents, models.DropsEntitlementEventSubEvent{ + ID: util.RandomGUID(), + Data: models.DropsEntitlementEventSubEventData{ + OrganizationID: params.FromUserID, + CategoryID: params.GameID, + CategoryName: "Special Events", + CampaignID: campaignId, + EntitlementID: util.RandomGUID(), + BenefitID: params.ItemID, + UserID: util.RandomUserID(), + UserName: params.ToUserName, + UserLogin: params.ToUserName, + CreatedAt: params.Timestamp, + }}) + } body := &models.DropsEntitlementEventSubResponse{ Subscription: models.EventsubSubscription{ ID: params.ID, @@ -59,23 +94,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven Cost: 0, CreatedAt: params.Timestamp, }, - Events: []models.DropsEntitlementEventSubEvent{ - { - ID: util.RandomGUID(), - Data: models.DropsEntitlementEventSubEventData{ - OrganizationID: params.FromUserID, - CategoryID: params.GameID, - CategoryName: "Special Events", - CampaignID: campaignId, - EntitlementID: util.RandomGUID(), - BenefitID: params.ItemID, - UserID: params.ToUserID, - UserName: params.ToUserName, - UserLogin: params.ToUserName, - CreatedAt: params.Timestamp, - }, - }, - }, + Events: dropEvents, } event, err = json.Marshal(body) if err != nil { diff --git a/internal/events/verify/subscription_verify.go b/internal/events/verify/subscription_verify.go index 11b21b8c..647bd8ff 100644 --- a/internal/events/verify/subscription_verify.go +++ b/internal/events/verify/subscription_verify.go @@ -19,13 +19,14 @@ import ( ) type VerifyParameters struct { - Transport string - Timestamp string - Event string - ForwardAddress string - Secret string - EventID string - Version string + Transport string + Timestamp string + Event string + ForwardAddress string + Secret string + EventID string + Version string + BroadcasterUserID string } type VerifyResponse struct { @@ -55,7 +56,11 @@ func VerifyWebhookSubscription(p VerifyParameters) (VerifyResponse, error) { p.EventID = util.RandomGUID() } - body, err := generateWebhookSubscriptionBody(p.Transport, p.EventID, event.GetTopic(p.Transport, p.Event), event.SubscriptionVersion(), challenge, p.ForwardAddress) + if p.BroadcasterUserID == "" { + p.BroadcasterUserID = util.RandomUserID() + } + + body, err := generateWebhookSubscriptionBody(p.Transport, p.EventID, event.GetTopic(p.Transport, p.Event), event.SubscriptionVersion(), p.BroadcasterUserID, challenge, p.ForwardAddress) if err != nil { return VerifyResponse{}, err } @@ -133,7 +138,7 @@ func VerifyWebhookSubscription(p VerifyParameters) (VerifyResponse, error) { return r, nil } -func generateWebhookSubscriptionBody(transport string, eventID string, event string, subscriptionVersion string, challenge string, callback string) (trigger.TriggerResponse, error) { +func generateWebhookSubscriptionBody(transport string, eventID string, event string, subscriptionVersion string, broadcaster string, challenge string, callback string) (trigger.TriggerResponse, error) { var res []byte var err error ts := util.GetTimestamp().Format(time.RFC3339Nano) @@ -147,7 +152,7 @@ func generateWebhookSubscriptionBody(transport string, eventID string, event str Type: event, Version: subscriptionVersion, Condition: models.EventsubCondition{ - BroadcasterUserID: util.RandomUserID(), + BroadcasterUserID: broadcaster, }, Transport: models.EventsubTransport{ Method: "webhook", diff --git a/internal/mock_api/endpoints/charity/campaigns.go b/internal/mock_api/endpoints/charity/campaigns.go index d8fbf891..bcc7261b 100644 --- a/internal/mock_api/endpoints/charity/campaigns.go +++ b/internal/mock_api/endpoints/charity/campaigns.go @@ -42,7 +42,7 @@ type GetCharityCampaignResponse struct { CharityDescription string `json:"charity_description"` CharityLogo string `json:"charity_logo"` CharityWebsite string `json:"charity_website"` - CurrentAmount CharityAmount `json:"current_ammount"` + CurrentAmount CharityAmount `json:"current_amount"` TargetAmount CharityAmount `json:"target_amount"` } diff --git a/internal/mock_api/endpoints/charity/donations.go b/internal/mock_api/endpoints/charity/donations.go index 83d0b76d..439755c1 100644 --- a/internal/mock_api/endpoints/charity/donations.go +++ b/internal/mock_api/endpoints/charity/donations.go @@ -34,11 +34,12 @@ var donationsScopesByMethod = map[string][]string{ type CharityDonations struct{} type GetCharityDonationsResponse struct { - ID string `json:"campaign_id"` - UserID string `json:"user_id"` - UserLogin string `json:"user_login"` - UserName string `json:"user_name"` - TargetAmount CharityAmount `json:"target_amount"` + ID string `json:"id"` + CampaignID string `json:"campaign_id"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + Amount CharityAmount `json:"amount"` } func (e CharityDonations) Path() string { return "/charity/donations" } @@ -98,11 +99,12 @@ func getCharityDonations(w http.ResponseWriter, r *http.Request) { for i := 0; i < first; i++ { d := GetCharityDonationsResponse{ - ID: util.RandomGUID(), - UserID: userCtx.UserID, - UserName: user.DisplayName, - UserLogin: user.UserLogin, - TargetAmount: CharityAmount{ + ID: util.RandomGUID(), + CampaignID: util.RandomGUID(), + UserID: userCtx.UserID, + UserName: user.DisplayName, + UserLogin: user.UserLogin, + Amount: CharityAmount{ Value: rand.Intn(150000-300) + 300, // Between $3 and $1,500 DecimalPlaces: 2, Currency: "USD", diff --git a/internal/mock_api/endpoints/chat/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) +}