diff --git a/.gitignore b/.gitignore index 05722bde..6ca677ff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ twitch-cli *.html # Editor configs -.vsvode/ +.vscode/ .idea/ # junk files diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 1f29e122..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "cSpell.words": [ - "SPDX" - ] -} \ No newline at end of file diff --git a/README.md b/README.md index e9fe8a17..ef65c73e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The CLI currently supports the following products: - [api](./docs/api.md) - [configure](./docs/configure.md) - [event](docs/event.md) +- [mock-api](docs/mock-api.md) - [token](docs/token.md) - [version](docs/version.md) diff --git a/cmd/events.go b/cmd/events.go index 5d5a2e2c..5c127742 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -28,6 +28,7 @@ var ( cost int64 count int description string + gameID string ) var eventCmd = &cobra.Command{ @@ -90,6 +91,7 @@ func init() { 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 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.") // retrigger flags retriggerCmd.Flags().StringVarP(&forwardAddress, "forward-address", "F", "", "Forward address for mock event.") @@ -134,6 +136,7 @@ func triggerCmdRun(cmd *cobra.Command, args []string) { Cost: cost, Description: description, ItemName: itemName, + GameID: gameID, }) if err != nil { diff --git a/docs/event.md b/docs/event.md index 07921cf6..64098141 100644 --- a/docs/event.md +++ b/docs/event.md @@ -16,50 +16,64 @@ Used to either create or send mock events for use with local webhooks testing. **Args** -| Argument | Description | -|-----------------------|------------------------------------------------------------------------------------------------------------| -| `subscribe` | A standard subscription event. Triggers a basic tier 1 sub. | -| `unsubscribe` | A standard unsubscribe event. Triggers a basic tier 1 sub. | -| `gift` | A gifted subscription event. Triggers a basic tier 1 sub. | -| `cheer` | Only usable with the `eventsub` transport, shows Cheers from chat. | -| `transaction` | Bits in Extensions transactions events. | -| `add-reward` | Channel Points EventSub event for a Custom Reward being added. | -| `update-reward` | Channel Points EventSub event for a Custom Reward being updated. | -| `remove-reward` | Channel Points EventSub event for a Custom Reward being removed. | -| `add-redemption` | Channel Points EventSub event for a redemption being performed. | -| `update-redemption` | Channel Points EventSub event for a redemption being updated. | -| `raid` | Channel Raid event with a random viewer count. | -| `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | -| `stream-change` | Stream Changed event. | -| `streamup` | Stream online event. | -| `streamdown` | Sstream offline event. | -| `add-moderator` | Channel moderator add event. | -| `remove-moderator` | Channel moderator removal event. | -| `ban` | Channel ban event. | -| `unban` | Channel unban event. | -| `hype-train-begin` | Channel hype train start event. | -| `hype-train-progress` | Channel hype train progress event. | -| `hype-train-end` | Channel hype train end event. | +| Argument | Description | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `add-moderator` | Channel moderator add event. | +| `add-redemption` | Channel Points EventSub event for a redemption being performed. | +| `add-reward` | Channel Points EventSub event for a Custom Reward being added. | +| `ban` | Channel ban event. | +| `channel-gift` | Channel gifting event; not to be confused with the `gift` event. This event is a description of the number of gifts given by a user. | +| `cheer` | Only usable with the `eventsub` transport | +| `drop` | Drops Entitlement event. | +| `gift` | A gifted subscription event. Triggers a basic tier 1 sub. | +| `grant` | Authorization grant event. | +| `hype-train-begin` | Channel hype train start event. | +| `hype-train-end` | Channel hype train end event. | +| `hype-train-progress` | Channel hype train progress event. | +| `poll-begin` | Channel poll begin event. | +| `poll-end` | Channel poll end event. | +| `poll-progress` | Channel poll progress event. | +| `prediction-begin` | Channel prediction begin event. | +| `prediction-end` | Channel prediction end event. | +| `prediction-lock` | Channel prediction lock event. | +| `prediction-progress` | Channel prediction progress event. | +| `raid` | Channel Raid event with a random viewer count. | +| `remove-moderator` | Channel moderator removal event. | +| `remove-reward` | Channel Points EventSub event for a Custom Reward being removed. | +| `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | +| `stream-change` | Stream Changed event. | +| `streamdown` | Sstream offline event. | +| `streamup` | Stream online event. | +| `subscribe-message` | Subscription Message event. | +| `subscribe` | A standard subscription event. Triggers a basic tier 1 sub. | +| `transaction` | Bits in Extensions transactions events. | +| `unban` | Channel unban event. | +| `unsubscribe` | A standard unsubscribe event. Triggers a basic tier 1 sub. | +| `update-redemption` | Channel Points EventSub event for a redemption being updated. | +| `update-reward` | Channel Points EventSub event for a Custom Reward being updated. | + **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 | -| `--transport` | `-T` | The method used to send events. Default is eventsub, but can send using websub. | `-T websub` | N | -| `--to-user` | `-t` | Denotes the receiver's TUID of the event, usually the broadcaster. | `-t 44635596` | N | -| `--from-user` | `-f` | Denotes the sender's TUID of the event, for example the user that follows another user or the subscriber to a broadcaster. | `-f 44635596` | N | -| `--gift-user` | `-g` | Used only for subcription-based events, denotes the gifting user ID | `-g 44635596` | N | -| `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC. | `-s testsecret` | N | -| `--count` | `-c` | Count of events to fire. This can be used to simulate an influx of subscriptions. | `-c 100` | N | -| `--anonymous` | `-a` | If the event is anonymous. Only applies to `gift` and `cheer` events. | `-a` | N | -| `--status` | `-S` | Status of the event object, currently applies to channel points redemptions. | `-S fulfilled` | 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 | -| `--cost` | `-C` | Amount of bits or channel points redeemed/used in the event. | `-C 250` | N | -| `--description` | `-d` | Title the stream should be updated/started with. | `-d Awesome new title!` | N | + Flag | Shorthand | Description | Example | Required? (Y/N) +---------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|----------------- + `--forward-address` | `-F` | Web server address for where to send mock events. | `-F https://localhost:8080` | N + `--transport` | `-T` | The method used to send events. Default is `eventsub`, but can send using `websub`. | `-T websub` | N + `--to-user` | `-t` | Denotes the receiver's TUID of the event, usually the broadcaster. | `-t 44635596` | N + `--from-user` | `-f` | Denotes the sender's TUID of the event, for example the user that follows another user or the subscriber to a broadcaster. | `-f 44635596` | N + `--gift-user` | `-g` | Used only for subcription-based events, denotes the gifting user ID | `-g 44635596` | N + `--secret` | `-s` | Webhook secret. If defined, signs all forwarded events with the SHA256 HMAC. | `-s testsecret` | N + `--count` | `-c` | Count of events to fire. This can be used to simulate an influx of events. | `-c 100` | N + `--anonymous` | `-a` | If the event is anonymous. Only applies to `gift` and `cheer` events. | `-a` | N + `--status` | `-S` | Status of the event object, currently applies to channel points redemptions. | `-S fulfilled` | 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 + `--cost` | `-C` | Amount of bits or channel points redeemed/used in the event. | `-C 250` | N + `--description` | `-d` | Title the stream should be updated/started with. | `-d Awesome new title!` | N + `--game-id` | `-G` | Game ID for Drop or other relevant events. | `-G 1234` | N + **Examples** @@ -110,30 +124,42 @@ Allows you to test if your webserver responds to subscription requests properly. **Args** -| Argument | Description | -|-----------------------|------------------------------------------------------------------------------------------------------------| -| `subscribe` | A standard subscription event. Triggers a basic tier 1 sub. | -| `unsubscribe` | A standard unsubscribe event. Triggers a basic tier 1 sub. | -| `gift` | A gifted subscription event. Triggers a basic tier 1 sub. | -| `cheer` | Only usable with the `eventsub` transport, shows Cheers from chat. | -| `transaction` | Bits in Extensions transactions events. | -| `add-reward` | Channel Points EventSub event for a Custom Reward being added. | -| `update-reward` | Channel Points EventSub event for a Custom Reward being updated. | -| `remove-reward` | Channel Points EventSub event for a Custom Reward being removed. | -| `add-redemption` | Channel Points EventSub event for a redemption being performed. | -| `update-redemption` | Channel Points EventSub event for a redemption being updated. | -| `raid` | Channel Raid event with a random viewer count. | -| `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | -| `stream-change` | Stream Changed event. | -| `streamup` | Stream online event. | -| `streamdown` | Sstream offline event. | -| `add-moderator` | Channel moderator add event. | -| `remove-moderator` | Channel moderator removal event. | -| `ban` | Channel ban event. | -| `unban` | Channel unban event. | -| `hype-train-begin` | Channel hype train start event. | -| `hype-train-progress` | Channel hype train progress event. | -| `hype-train-end` | Channel hype train end event. | +| Argument | Description | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `add-moderator` | Channel moderator add event. | +| `add-redemption` | Channel Points EventSub event for a redemption being performed. | +| `add-reward` | Channel Points EventSub event for a Custom Reward being added. | +| `ban` | Channel ban event. | +| `channel-gift` | Channel gifting event; not to be confused with the `gift` event. This event is a description of the number of gifts given by a user. | +| `cheer` | Only usable with the `eventsub` transport | +| `drop` | Drops Entitlement event. | +| `gift` | A gifted subscription event. Triggers a basic tier 1 sub. | +| `grant` | Authorization grant event. | +| `hype-train-begin` | Channel hype train start event. | +| `hype-train-end` | Channel hype train end event. | +| `hype-train-progress` | Channel hype train progress event. | +| `poll-begin` | Channel poll begin event. | +| `poll-end` | Channel poll end event. | +| `poll-progress` | Channel poll progress event. | +| `prediction-begin` | Channel prediction begin event. | +| `prediction-end` | Channel prediction end event. | +| `prediction-lock` | Channel prediction lock event. | +| `prediction-progress` | Channel prediction progress event. | +| `raid` | Channel Raid event with a random viewer count. | +| `remove-moderator` | Channel moderator removal event. | +| `remove-reward` | Channel Points EventSub event for a Custom Reward being removed. | +| `revoke` | User authorization revoke event. Uses local Client as set in `twitch configure` or generates one randomly. | +| `stream-change` | Stream Changed event. | +| `streamdown` | Sstream offline event. | +| `streamup` | Stream online event. | +| `subscribe-message` | Subscription Message event. | +| `subscribe` | A standard subscription event. Triggers a basic tier 1 sub. | +| `transaction` | Bits in Extensions transactions events. | +| `unban` | Channel unban event. | +| `unsubscribe` | A standard unsubscribe event. Triggers a basic tier 1 sub. | +| `update-redemption` | Channel Points EventSub event for a redemption being updated. | +| `update-reward` | Channel Points EventSub event for a Custom Reward being updated. | + **Flags** diff --git a/docs/mock-api.md b/docs/mock-api.md index 33edae58..3b444d97 100644 --- a/docs/mock-api.md +++ b/docs/mock-api.md @@ -20,8 +20,9 @@ The `mock-api` product has two primary functions. The first is to generate so-ca * Teams * Users -The second is the actual server used to mock the endpoints. In the next iteration, you will be able to edit these and add further ones manually (for example, making a user with specific attributes), but for the beta we won't be providing this functionality as the current `generate` feature will make all of these (and more), +The second is the actual server used to mock the endpoints. In the next iteration, you will be able to edit these and add further ones manually (for example, making a user with specific attributes), but for the beta we won't be providing this functionality as the current `generate` feature will make all of these (and more). +As of the 1.1 release, this product is in an **open beta** and any bugs should be filed via GitHub Issues. Given the breadth of the tool, it is likely you may run across oddities; please fill out an issue if that is the case. ## generate @@ -40,18 +41,12 @@ None. ## start -The `start` function starts a new mock server for use with testing functionality. Currently, this replicates a large majority of the current API endpoints on the new API, but are ommitting: - -* GET /analytics/extensions -* GET /analytics/games -* GET /extensions/transactions -* POST /eventsub/subscriptions -* DELETE /eventsub/subscriptions -* GET /eventsub/subscriptions -* GET /users/extensions/list -* GET /users/extensions -* PUT /users/extensions -* GET /webhooks/subscriptions +The `start` function starts a new mock server for use with testing functionality. Currently, this replicates a large majority of the current API endpoints on the new API, but are omitting: + +* Extensions endpoints +* Code entitlement endpoints +* Websub endpoints +* EventSub endpoints For many of these, we are exploring how to better integrate this with existing features (for example, allowing events to be triggered on unit creation or otherwise), and for others, the value is minimal compared to the docs. All other endpoints should be currently supported, however it is possible to be out of date- if so, [please raise an issue](https://github.com/twitchdev/twitch-cli/issues). diff --git a/internal/api/api.go b/internal/api/api.go index 18de4ab5..6c61fe00 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -56,9 +56,13 @@ func NewRequest(method string, path string, queryParameters []string, body []byt } q := u.Query() - for _, param := range queryParameters { - value := strings.Split(param, "=") - q.Add(value[0], value[1]) + for _, paramStr := range queryParameters { + var value string + param := strings.Split(paramStr, "=") + if len(param) == 2 { + value = param[1] + } + q.Add(param[0], value) } if cursor != "" { @@ -97,13 +101,19 @@ func NewRequest(method string, path string, queryParameters []string, body []byt return } + data.Template = apiResponse.Template + if resp.StatusCode > 299 || resp.StatusCode < 200 { data = apiResponse break } d := data.Data.([]interface{}) - data.Data = append(d, apiResponse.Data) + if strings.Contains(path, "schedule") || apiResponse.Data == nil { + data.Data = append(d, apiResponse.Data) + } else { + data.Data = append(d, apiResponse.Data.([]interface{})...) + } if apiResponse.Pagination == nil || *&apiResponse.Pagination.Cursor == "" { break diff --git a/internal/database/_schema.sql b/internal/database/_schema.sql index b10b2e0c..b79aeabc 100644 --- a/internal/database/_schema.sql +++ b/internal/database/_schema.sql @@ -204,7 +204,8 @@ create table drops_entitlements( benefit_id text not null, timestamp text not null, user_id text not null, - game_id text not null, + game_id text not null, + status text not null default 'CLAIMED', foreign key (user_id) references users(id), foreign key (game_id) references categories(id) ); @@ -289,3 +290,17 @@ create table clips ( 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) +); \ No newline at end of file diff --git a/internal/database/categories.go b/internal/database/categories.go index f2c0de4f..02b2cf29 100644 --- a/internal/database/categories.go +++ b/internal/database/categories.go @@ -9,7 +9,7 @@ import ( type Category struct { ID string `db:"id" json:"id"` Name string `db:"category_name" json:"name"` - BoxartURL string `json:"boxart_url"` + BoxartURL string `json:"box_art_url"` ViewerCount int `db:"vc" json:"-"` } diff --git a/internal/database/drops.go b/internal/database/drops.go index dffbd5ef..fab3c70a 100644 --- a/internal/database/drops.go +++ b/internal/database/drops.go @@ -10,6 +10,7 @@ type DropsEntitlement struct { BenefitID string `db:"benefit_id" json:"benefit_id"` GameID string `db:"game_id" json:"game_id"` Timestamp string `db:"timestamp" json:"timestamp"` + Status string `db:"status" json:"fulfillment_status"` } func (q *Query) GetDropsEntitlements(de DropsEntitlement) (*DBResponse, error) { @@ -51,3 +52,8 @@ func (q *Query) InsertDropsEntitlement(d DropsEntitlement) error { _, err := q.DB.NamedExec(stmt, d) return err } + +func (q *Query) UpdateDropsEntitlement(d DropsEntitlement) error { + _, err := q.DB.NamedExec(generateUpdateSQL("drops_entitlements", []string{"id"}, d), d) + return err +} diff --git a/internal/database/init.go b/internal/database/init.go index 04967c2d..2ad5a40e 100644 --- a/internal/database/init.go +++ b/internal/database/init.go @@ -10,7 +10,7 @@ import ( "github.com/jmoiron/sqlx" ) -const currentVersion = 2 +const currentVersion = 3 type migrateMap struct { SQL string @@ -30,6 +30,10 @@ var migrateSQL = map[int]migrateMap{ SQL: `create table categories( id text not null primary key, category_name text not null ); create table users( id text not null primary key, user_login text not null, display_name text not null, email text not null, user_type text, broadcaster_type text, user_description text, created_at text not null, category_id text, modified_at text, stream_language text not null default 'en', title text not null default '', delay int not null default 0, foreign key (category_id) references categories(id) ); create table follows ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table blocks ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table bans ( broadcaster_id text not null, user_id text not null, created_at text not null, expires_at text, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table ban_events ( id text not null primary key, event_timestamp text not null, event_type text not null, event_version text not null default '1.0', broadcaster_id text not null, user_id text not null, expires_at text, foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table moderators ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table moderator_actions ( id text not null primary key, event_timestamp text not null, event_type text not null, event_version text not null default '1.0', broadcaster_id text not null, user_id text not null, foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table editors ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table channel_points_rewards( id text not null primary key, broadcaster_id text not null, reward_image text, background_color text, is_enabled boolean not null default false, cost int not null default 0, title text not null, reward_prompt text, is_user_input_required boolean default false, stream_max_enabled boolean default false, stream_max_count int default 0, stream_user_max_enabled boolean default false, stream_user_max_count int default 0, global_cooldown_enabled boolean default false, global_cooldown_seconds int default 0, is_paused boolean default false, is_in_stock boolean default true, should_redemptions_skip_queue boolean default false, redemptions_redeemed_current_stream int, cooldown_expires_at text, foreign key (broadcaster_id) references users(id) ); create table channel_points_redemptions( id text not null primary key, reward_id text not null, broadcaster_id text not null, user_id text not null, user_input text, redemption_status text not null, redeemed_at text, foreign key (reward_id) references channel_points_rewards(id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table streams( id text not null primary key, broadcaster_id id text not null, stream_type text not null default 'live', viewer_count int not null, started_at text not null, is_mature boolean not null default false, foreign key (broadcaster_id) references users(id) ); create table tags( id text not null primary key, is_auto boolean not null default false, tag_name text not null ); create table stream_tags( user_id text not null, tag_id text not null, primary key(user_id, tag_id), foreign key(user_id) references users(id), foreign key(tag_id) references tags(id) ); create table teams( id text not null primary key, background_image_url text, banner text, created_at text not null, updated_at text, info text, thumbnail_url text, team_name text, team_display_name text ); create table team_members( team_id text not null, user_id text not null, primary key (team_id, user_id) foreign key (team_id) references teams(id), foreign key (user_id) references users(id) ); create table videos( id text not null primary key, stream_id text, broadcaster_id text not null, title text not null, video_description text not null, created_at text not null, published_at text, viewable text not null, view_count int not null default 0, duration text not null, video_language text not null default 'en', category_id text, type text default 'archive', foreign key (stream_id) references streams(id), foreign key (broadcaster_id) references users(id), foreign key (category_id) references categories(id) ); create table stream_markers( id text not null primary key, video_id text not null, position_seconds int not null, created_at text not null, description text not null, broadcaster_id text not null, foreign key (broadcaster_id) references users(id), foreign key (video_id) references videos(id) ); create table video_muted_segments ( video_id text not null, video_offset int not null, duration int not null, primary key (video_id, video_offset), foreign key (video_id) references videos(id) ); create table subscriptions ( broadcaster_id text not null, user_id text not null, is_gift boolean not null default false, gifter_id text, tier text not null default '1000', created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id), foreign key (gifter_id) references users(id) ); create table drops_entitlements( id text not null primary key, benefit_id text not null, timestamp text not null, user_id text not null, game_id text not null, foreign key (user_id) references users(id), foreign key (game_id) references categories(id) ); create table clients ( id text not null primary key, secret text not null, is_extension boolean default false, name text not null ); create table authorizations ( id integer not null primary key AUTOINCREMENT, client_id text not null, user_id text, token text not null unique, expires_at text not null, scopes text, foreign key (client_id) references clients(id) ); create table polls ( id text not null primary key, broadcaster_id text not null, title text not null, bits_voting_enabled boolean default false, bits_per_vote int default 10, channel_points_voting_enabled boolean default false, channel_points_per_vote int default 10, status text not null, duration int not null, started_at text not null, ended_at text, foreign key (broadcaster_id) references users(id) ); create table poll_choices ( id text not null primary key, title text not null, votes int not null default 0, channel_points_votes int not null default 0, bits_votes int not null default 0, poll_id text not null, foreign key (poll_id) references polls(id) ); create table predictions ( id text not null primary key, broadcaster_id text not null, title text not null, winning_outcome_id text, prediction_window int, status text not null, created_at text not null, ended_at text, locked_at text, foreign key (broadcaster_id) references users(id) ); 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, foreign key (broadcaster_id) references users(id), foreign key (creator_id) references users(id) ); `, Message: "Adding mock API tables.", }, + 3: { + SQL: `alter table drops_entitlements add column status text not null default 'CLAIMED'; 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));`, + Message: ``, + }, } func checkAndUpdate(db sqlx.DB) error { @@ -65,7 +69,7 @@ func checkAndUpdate(db sqlx.DB) error { } func initDatabase(db sqlx.DB) error { - createSQL := `create table events( id text not null primary key, event text not null, json text not null, from_user text not null, to_user text not null, transport text not null, timestamp text not null); create table categories( id text not null primary key, category_name text not null ); create table users( id text not null primary key, user_login text not null, display_name text not null, email text not null, user_type text, broadcaster_type text, user_description text, created_at text not null, category_id text, modified_at text, stream_language text not null default 'en', title text not null default '', delay int not null default 0, foreign key (category_id) references categories(id) ); create table follows ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table blocks ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table bans ( broadcaster_id text not null, user_id text not null, created_at text not null, expires_at text, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table ban_events ( id text not null primary key, event_timestamp text not null, event_type text not null, event_version text not null default '1.0', broadcaster_id text not null, user_id text not null, expires_at text, foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table moderators ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table moderator_actions ( id text not null primary key, event_timestamp text not null, event_type text not null, event_version text not null default '1.0', broadcaster_id text not null, user_id text not null, foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table editors ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table channel_points_rewards( id text not null primary key, broadcaster_id text not null, reward_image text, background_color text, is_enabled boolean not null default false, cost int not null default 0, title text not null, reward_prompt text, is_user_input_required boolean default false, stream_max_enabled boolean default false, stream_max_count int default 0, stream_user_max_enabled boolean default false, stream_user_max_count int default 0, global_cooldown_enabled boolean default false, global_cooldown_seconds int default 0, is_paused boolean default false, is_in_stock boolean default true, should_redemptions_skip_queue boolean default false, redemptions_redeemed_current_stream int, cooldown_expires_at text, foreign key (broadcaster_id) references users(id) ); create table channel_points_redemptions( id text not null primary key, reward_id text not null, broadcaster_id text not null, user_id text not null, user_input text, redemption_status text not null, redeemed_at text, foreign key (reward_id) references channel_points_rewards(id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table streams( id text not null primary key, broadcaster_id id text not null, stream_type text not null default 'live', viewer_count int not null, started_at text not null, is_mature boolean not null default false, foreign key (broadcaster_id) references users(id) ); create table tags( id text not null primary key, is_auto boolean not null default false, tag_name text not null ); create table stream_tags( user_id text not null, tag_id text not null, primary key(user_id, tag_id), foreign key(user_id) references users(id), foreign key(tag_id) references tags(id) ); create table teams( id text not null primary key, background_image_url text, banner text, created_at text not null, updated_at text, info text, thumbnail_url text, team_name text, team_display_name text ); create table team_members( team_id text not null, user_id text not null, primary key (team_id, user_id) foreign key (team_id) references teams(id), foreign key (user_id) references users(id) ); create table videos( id text not null primary key, stream_id text, broadcaster_id text not null, title text not null, video_description text not null, created_at text not null, published_at text, viewable text not null, view_count int not null default 0, duration text not null, video_language text not null default 'en', category_id text, type text default 'archive', foreign key (stream_id) references streams(id), foreign key (broadcaster_id) references users(id), foreign key (category_id) references categories(id) ); create table stream_markers( id text not null primary key, video_id text not null, position_seconds int not null, created_at text not null, description text not null, broadcaster_id text not null, foreign key (broadcaster_id) references users(id), foreign key (video_id) references videos(id) ); create table video_muted_segments ( video_id text not null, video_offset int not null, duration int not null, primary key (video_id, video_offset), foreign key (video_id) references videos(id) ); create table subscriptions ( broadcaster_id text not null, user_id text not null, is_gift boolean not null default false, gifter_id text, tier text not null default '1000', created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id), foreign key (gifter_id) references users(id) ); create table drops_entitlements( id text not null primary key, benefit_id text not null, timestamp text not null, user_id text not null, game_id text not null, foreign key (user_id) references users(id), foreign key (game_id) references categories(id) ); create table clients ( id text not null primary key, secret text not null, is_extension boolean default false, name text not null ); create table authorizations ( id integer not null primary key AUTOINCREMENT, client_id text not null, user_id text, token text not null unique, expires_at text not null, scopes text, foreign key (client_id) references clients(id) ); create table polls ( id text not null primary key, broadcaster_id text not null, title text not null, bits_voting_enabled boolean default false, bits_per_vote int default 10, channel_points_voting_enabled boolean default false, channel_points_per_vote int default 10, status text not null, duration int not null, started_at text not null, ended_at text, foreign key (broadcaster_id) references users(id) ); create table poll_choices ( id text not null primary key, title text not null, votes int not null default 0, channel_points_votes int not null default 0, bits_votes int not null default 0, poll_id text not null, foreign key (poll_id) references polls(id) ); create table predictions ( id text not null primary key, broadcaster_id text not null, title text not null, winning_outcome_id text, prediction_window int, status text not null, created_at text not null, ended_at text, locked_at text, foreign key (broadcaster_id) references users(id) ); 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, foreign key (broadcaster_id) references users(id), foreign key (creator_id) references users(id) ); ` + createSQL := `create table events( id text not null primary key, event text not null, json text not null, from_user text not null, to_user text not null, transport text not null, timestamp text not null); create table categories( id text not null primary key, category_name text not null ); create table users( id text not null primary key, user_login text not null, display_name text not null, email text not null, user_type text, broadcaster_type text, user_description text, created_at text not null, category_id text, modified_at text, stream_language text not null default 'en', title text not null default '', delay int not null default 0, foreign key (category_id) references categories(id) ); create table follows ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table blocks ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table bans ( broadcaster_id text not null, user_id text not null, created_at text not null, expires_at text, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table ban_events ( id text not null primary key, event_timestamp text not null, event_type text not null, event_version text not null default '1.0', broadcaster_id text not null, user_id text not null, expires_at text, foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table moderators ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table moderator_actions ( id text not null primary key, event_timestamp text not null, event_type text not null, event_version text not null default '1.0', broadcaster_id text not null, user_id text not null, foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table editors ( broadcaster_id text not null, user_id text not null, created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table channel_points_rewards( id text not null primary key, broadcaster_id text not null, reward_image text, background_color text, is_enabled boolean not null default false, cost int not null default 0, title text not null, reward_prompt text, is_user_input_required boolean default false, stream_max_enabled boolean default false, stream_max_count int default 0, stream_user_max_enabled boolean default false, stream_user_max_count int default 0, global_cooldown_enabled boolean default false, global_cooldown_seconds int default 0, is_paused boolean default false, is_in_stock boolean default true, should_redemptions_skip_queue boolean default false, redemptions_redeemed_current_stream int, cooldown_expires_at text, foreign key (broadcaster_id) references users(id) ); create table channel_points_redemptions( id text not null primary key, reward_id text not null, broadcaster_id text not null, user_id text not null, user_input text, redemption_status text not null, redeemed_at text, foreign key (reward_id) references channel_points_rewards(id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table streams( id text not null primary key, broadcaster_id id text not null, stream_type text not null default 'live', viewer_count int not null, started_at text not null, is_mature boolean not null default false, foreign key (broadcaster_id) references users(id) ); create table tags( id text not null primary key, is_auto boolean not null default false, tag_name text not null ); create table stream_tags( user_id text not null, tag_id text not null, primary key(user_id, tag_id), foreign key(user_id) references users(id), foreign key(tag_id) references tags(id) ); create table teams( id text not null primary key, background_image_url text, banner text, created_at text not null, updated_at text, info text, thumbnail_url text, team_name text, team_display_name text ); create table team_members( team_id text not null, user_id text not null, primary key (team_id, user_id) foreign key (team_id) references teams(id), foreign key (user_id) references users(id) ); create table videos( id text not null primary key, stream_id text, broadcaster_id text not null, title text not null, video_description text not null, created_at text not null, published_at text, viewable text not null, view_count int not null default 0, duration text not null, video_language text not null default 'en', category_id text, type text default 'archive', foreign key (stream_id) references streams(id), foreign key (broadcaster_id) references users(id), foreign key (category_id) references categories(id) ); create table stream_markers( id text not null primary key, video_id text not null, position_seconds int not null, created_at text not null, description text not null, broadcaster_id text not null, foreign key (broadcaster_id) references users(id), foreign key (video_id) references videos(id) ); create table video_muted_segments ( video_id text not null, video_offset int not null, duration int not null, primary key (video_id, video_offset), foreign key (video_id) references videos(id) ); create table subscriptions ( broadcaster_id text not null, user_id text not null, is_gift boolean not null default false, gifter_id text, tier text not null default '1000', created_at text not null, primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id), foreign key (gifter_id) references users(id) ); create table drops_entitlements( id text not null primary key, benefit_id text not null, timestamp text not null, user_id text not null, game_id text not null, status text not null default 'CLAIMED', foreign key (user_id) references users(id), foreign key (game_id) references categories(id) ); create table clients ( id text not null primary key, secret text not null, is_extension boolean default false, name text not null ); create table authorizations ( id integer not null primary key AUTOINCREMENT, client_id text not null, user_id text, token text not null unique, expires_at text not null, scopes text, foreign key (client_id) references clients(id) ); create table polls ( id text not null primary key, broadcaster_id text not null, title text not null, bits_voting_enabled boolean default false, bits_per_vote int default 10, channel_points_voting_enabled boolean default false, channel_points_per_vote int default 10, status text not null, duration int not null, started_at text not null, ended_at text, foreign key (broadcaster_id) references users(id) ); create table poll_choices ( id text not null primary key, title text not null, votes int not null default 0, channel_points_votes int not null default 0, bits_votes int not null default 0, poll_id text not null, foreign key (poll_id) references polls(id) ); create table predictions ( id text not null primary key, broadcaster_id text not null, title text not null, winning_outcome_id text, prediction_window int, status text not null, created_at text not null, ended_at text, locked_at text, foreign key (broadcaster_id) references users(id) ); 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, 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));` for i := 1; i <= 5; i++ { tx := db.MustBegin() tx.Exec(createSQL) diff --git a/internal/database/moderation.go b/internal/database/moderation.go index e6374ec0..531694d8 100644 --- a/internal/database/moderation.go +++ b/internal/database/moderation.go @@ -26,7 +26,7 @@ type ModeratorAction struct { ID string `db:"id" json:"id"` EventType string `db:"event_type" json:"event_type"` EventTimestamp string `db:"event_timestamp" json:"event_timestamp"` - EventVersion string `db:"event_version" json:"event_version"` + EventVersion string `db:"event_version" json:"version"` ModeratorActionEvent `json:"event_data"` } @@ -40,27 +40,35 @@ type ModeratorActionEvent struct { } type BanActionEvent 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"` - ExpiresAt *string `db:"expires_at" json:"expires_at"` + 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"` + ExpiresAt *string `db:"expires_at" json:"expires_at"` + Reason string `json:"reason"` + ModeratorID string `json:"moderator_id"` + ModeratorUserLogin string `json:"moderator_login"` + ModeratorUserName string `json:"moderator_name"` } type BanEvent struct { ID string `db:"id" json:"id"` EventType string `db:"event_type" json:"event_type"` EventTimestamp string `db:"event_timestamp" json:"event_timestamp"` - EventVersion string `db:"event_version" json:"event_version"` + EventVersion string `db:"event_version" json:"version"` BanActionEvent `json:"event_data"` } type Ban struct { - UserID string `db:"user_id" json:"user_id"` - UserLogin string `db:"user_login" json:"user_login"` - UserName string `db:"user_name" json:"user_name"` - ExpiresAt *string `db:"expires_at" json:"expires_at"` + UserID string `db:"user_id" json:"user_id"` + UserLogin string `db:"user_login" json:"user_login"` + UserName string `db:"user_name" json:"user_name"` + ExpiresAt *string `db:"expires_at" json:"expires_at"` + Reason string `json:"reason"` + ModeratorID string `json:"moderator_id"` + ModeratorUserLogin string `json:"moderator_login"` + ModeratorUserName string `json:"moderator_name"` } var es = "" @@ -105,7 +113,7 @@ func (q *Query) AddModerator(p UserRequestParams) error { tx := q.DB.MustBegin() tx.NamedExec(stmt, p) - tx.NamedExec(`INSERT INTO moderator_actions VALUES(:id, :event_type, :event_timestamp, :event_version, :broadcaster_id, :user_id)`, ma) + tx.NamedExec(`INSERT INTO moderator_actions VALUES(:id, :event_timestamp, :event_type, :event_version, :broadcaster_id, :user_id)`, ma) return tx.Commit() } @@ -148,7 +156,7 @@ func (q *Query) RemoveModerator(broadcaster string, user string) error { tx := q.DB.MustBegin() tx.Exec(`delete from moderators where broadcaster_id=$1 and user_id=$2`, broadcaster, user) - tx.NamedExec(`INSERT INTO moderator_actions VALUES(:id, :event_type, :event_timestamp, :event_version, :broadcaster_id, :user_id)`, ma) + tx.NamedExec(`INSERT INTO moderator_actions VALUES(:id, :event_timestamp, :event_type, :event_version, :broadcaster_id, :user_id)`, ma) return tx.Commit() } @@ -170,7 +178,7 @@ func (q *Query) InsertBan(p UserRequestParams) error { tx := q.DB.MustBegin() tx.NamedExec(stmt, p) - tx.NamedExec(`INSERT INTO ban_events VALUES(:id, :event_type, :event_timestamp, :event_version, :broadcaster_id, :user_id, :expires_at)`, ma) + tx.NamedExec(`INSERT INTO ban_events VALUES(:id, :event_timestamp, :event_type, :event_version, :broadcaster_id, :user_id, :expires_at)`, ma) return tx.Commit() } @@ -192,6 +200,7 @@ func (q *Query) GetBans(p UserRequestParams) (*DBResponse, error) { if b.ExpiresAt == nil { b.ExpiresAt = &es } + b.Reason = "CLI ban" r = append(r, b) } dbr := DBResponse{ @@ -225,9 +234,19 @@ func (q *Query) GetBanEvents(p UserRequestParams) (*DBResponse, error) { return nil, err } es := "" + if b.ExpiresAt == nil { b.ExpiresAt = &es } + // shim for https://github.com/twitchdev/twitch-cli/issues/83 + _, err = time.Parse(time.RFC3339, b.EventTimestamp) + if err != nil { + ts := b.EventType + b.EventType = b.EventTimestamp + b.EventTimestamp = ts + } + + b.Reason = "CLI ban" r = append(r, b) } dbr := DBResponse{ @@ -260,6 +279,14 @@ func (q *Query) GetModeratorEvents(p UserRequestParams) (*DBResponse, error) { log.Print(err) return nil, err } + // shim for https://github.com/twitchdev/twitch-cli/issues/83 + _, err = time.Parse(time.RFC3339, ma.EventTimestamp) + if err != nil { + ts := ma.EventType + ma.EventType = ma.EventTimestamp + ma.EventTimestamp = ts + } + r = append(r, ma) } dbr := DBResponse{ diff --git a/internal/database/schedule.go b/internal/database/schedule.go new file mode 100644 index 00000000..10667838 --- /dev/null +++ b/internal/database/schedule.go @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package database + +import ( + "database/sql" + "errors" + "time" +) + +type Schedule struct { + Segments []ScheduleSegment `json:"segments"` + UserID string `db:"broadcaster_id" json:"broadcaster_id"` + UserLogin string `db:"broadcaster_login" json:"broadcaster_login" dbi:"false"` + UserName string `db:"broadcaster_name" json:"broadcaster_name" dbi:"false"` + Vacation *ScheduleVacation `json:"vacation"` +} + +type ScheduleSegment struct { + ID string `db:"id" json:"id" dbs:"s.id"` + Title string `db:"title" json:"title"` + StartTime string `db:"starttime" json:"start_time"` + EndTime string `db:"endtime" json:"end_time"` + IsRecurring bool `db:"is_recurring" json:"is_recurring"` + IsVacation bool `db:"is_vacation" json:"-"` + Category *SegmentCategory `json:"category"` + UserID string `db:"broadcaster_id" json:"-"` + Timezone string `db:"timezone" json:"timezone"` + CategoryID *string `db:"category_id" json:"-"` + CategoryName *string `db:"category_name" dbi:"false" json:"-"` + IsCanceled *bool `db:"is_canceled" json:"-"` + CanceledUntil *string `json:"canceled_until"` +} +type ScheduleVacation struct { + ID string `db:"id" json:"-"` + StartTime string `db:"starttime" json:"start_time"` + EndTime string `db:"endtime" json:"end_time"` +} + +type SegmentCategory struct { + ID *string `db:"category_id" json:"id" dbs:"category_id"` + CategoryName *string `db:"category_name" json:"name" dbi:"false"` +} + +func (q *Query) GetSchedule(p ScheduleSegment, startTime time.Time) (*DBResponse, error) { + r := Schedule{} + + u, err := q.GetUser(User{ID: p.UserID}) + if err != nil { + return nil, err + } + r.UserID = u.ID + r.UserLogin = u.UserLogin + r.UserName = u.DisplayName + + sql := generateSQL("select s.*, c.category_name from stream_schedule s left join categories c on s.category_id = c.id", p, SEP_AND) + p.StartTime = startTime.Format(time.RFC3339) + sql += " and datetime(starttime) >= datetime(:starttime) " + q.SQL + rows, err := q.DB.NamedQuery(sql, p) + if err != nil { + return nil, err + } + + for rows.Next() { + var s ScheduleSegment + err := rows.StructScan(&s) + if err != nil { + return nil, err + } + if s.CategoryID != nil { + s.Category = &SegmentCategory{ + ID: s.CategoryID, + CategoryName: s.CategoryName, + } + } + if s.IsVacation { + r.Vacation = &ScheduleVacation{ + StartTime: s.StartTime, + EndTime: s.EndTime, + } + } else { + r.Segments = append(r.Segments, s) + } + } + v, err := q.GetVacations(ScheduleSegment{UserID: p.UserID}) + if err != nil { + return nil, err + } + r.Vacation = &v + dbr := DBResponse{ + Data: r, + Limit: q.Limit, + Total: len(r.Segments), + } + + if len(r.Segments) != q.Limit { + q.PaginationCursor = "" + } + + dbr.Cursor = q.PaginationCursor + + return &dbr, err +} + +func (q *Query) InsertSchedule(p ScheduleSegment) error { + tx := q.DB.MustBegin() + _, err := tx.NamedExec(generateInsertSQL("stream_schedule", "id", p, false), p) + if err != nil { + return err + } + return tx.Commit() +} + +func (q *Query) DeleteSegment(id string, broadcasterID string) error { + _, err := q.DB.Exec("delete from stream_schedule where id=$1 and broadcaster_id=$2", id, broadcasterID) + return err +} + +func (q *Query) UpdateSegment(p ScheduleSegment) error { + _, err := q.DB.NamedExec(generateUpdateSQL("stream_schedule", []string{"id"}, p), p) + return err +} + +func (q *Query) GetVacations(p ScheduleSegment) (ScheduleVacation, error) { + v := ScheduleVacation{} + err := q.DB.Get(&v, "select id,starttime,endtime from stream_schedule where is_vacation=true and datetime(endtime) > datetime('now') and broadcaster_id= $1 limit 1", p.UserID) + if errors.As(err, &sql.ErrNoRows) { + return v, nil + } + return v, err +} diff --git a/internal/database/streams.go b/internal/database/streams.go index b4ecbde3..b92312b7 100644 --- a/internal/database/streams.go +++ b/internal/database/streams.go @@ -13,7 +13,7 @@ type Stream struct { UserID string `db:"broadcaster_id" json:"user_id"` UserLogin string `db:"broadcaster_login" json:"user_login" dbi:"false"` UserName string `db:"broadcaster_name" json:"user_name" dbi:"false"` - StreamType string `db:"stream_type" json:"stream_type"` + StreamType string `db:"stream_type" json:"type"` ViewerCount int `db:"viewer_count" json:"viewer_count"` StartedAt string `db:"started_at" json:"started_at"` IsMature bool `db:"is_mature" json:"is_mature"` @@ -24,7 +24,9 @@ type Stream struct { CategoryName sql.NullString `db:"category_name" json:"-" dbi:"false"` RealCategoryName string `json:"game_name"` Title string `db:"title" json:"title" dbi:"false"` - Language string `db:"stream_language" json:"stream_language" dbi:"false"` + Language string `db:"stream_language" json:"language" dbi:"false"` + // calculated fields + ThumbnailURL string `json:"thumbnail_url"` } type StreamTag struct { @@ -83,6 +85,7 @@ func (q *Query) GetStream(s Stream) (*DBResponse, error) { if s.CategoryName.Valid { s.RealCategoryName = s.CategoryName.String } + s.ThumbnailURL = fmt.Sprintf("https://static-cdn.jtvnw.net/previews-ttv/live_user_%v-{width}x{height}.jpg", s.UserLogin) r = append(r, s) } diff --git a/internal/database/subscriptions.go b/internal/database/subscriptions.go index 046273a0..f73b2df6 100644 --- a/internal/database/subscriptions.go +++ b/internal/database/subscriptions.go @@ -4,6 +4,7 @@ package database import ( "database/sql" + "fmt" "log" ) @@ -20,6 +21,8 @@ type Subscription struct { GifterLogin *sql.NullString `db:"gifter_login" json:"gifter_login,omitempty"` Tier string `db:"tier" json:"tier"` CreatedAt string `db:"created_at" json:"-"` + // calculated fields + PlanName string `json:"plan_name"` } type SubscriptionInsert struct { @@ -50,6 +53,15 @@ func (q *Query) GetSubscriptions(s Subscription) (*DBResponse, error) { log.Print(err) return nil, err } + plan := fmt.Sprintf("Channel Subscription (%v)", s.BroadcasterLogin) + switch s.Tier { + case "2000": + plan = plan + ": $9.99 Sub" + case "3000": + plan = plan + ": $24.99 Sub" + default: + } + s.PlanName = plan r = append(r, s) } diff --git a/internal/database/user.go b/internal/database/user.go index b372137d..1118d48e 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -72,6 +72,8 @@ type SearchChannel struct { TagIDs []string `json:"tag_ids" dbi:"false"` IsLive bool `json:"is_live" db:"is_live"` StartedAt *string `db:"started_at" json:"started_at"` + // calculated fields + ThumbNailURL string `json:"thumbnail_url"` } func (q *Query) GetUser(u User) (User, error) { @@ -327,6 +329,7 @@ func (q *Query) SearchChannels(query string, live_only bool) (*DBResponse, error r[i].StartedAt = &emptyString } r[i].TagIDs = st + r[i].ThumbNailURL = "https://static-cdn.jtvnw.net/jtv_user_pictures/3f13ab61-ec78-4fe6-8481-8682cb3b0ac2-channel_offline_image-300x300.png" } dbr := DBResponse{ diff --git a/internal/events/event.go b/internal/events/event.go index 33a64edb..901a0b40 100644 --- a/internal/events/event.go +++ b/internal/events/event.go @@ -20,6 +20,7 @@ type MockEventParameters struct { Cost int64 IsPermanent bool Description string + GameID string } type MockEventResponse struct { diff --git a/internal/events/trigger/trigger_event.go b/internal/events/trigger/trigger_event.go index 20271cf7..3735fab5 100644 --- a/internal/events/trigger/trigger_event.go +++ b/internal/events/trigger/trigger_event.go @@ -4,6 +4,7 @@ package trigger import ( "fmt" + "log" "time" "github.com/twitchdev/twitch-cli/internal/database" @@ -29,6 +30,7 @@ type TriggerParameters struct { Count int Description string ItemName string + GameID string } type TriggerResponse struct { @@ -52,6 +54,9 @@ func Fire(p TriggerParameters) (string, error) { p.FromUser = util.RandomUserID() } + if p.GameID == "" { + p.GameID = fmt.Sprint(util.RandomInt(10 * 1000)) + } eventParamaters := events.MockEventParameters{ ID: util.RandomGUID(), Trigger: p.Event, @@ -66,6 +71,7 @@ func Fire(p TriggerParameters) (string, error) { ItemID: p.ItemID, Description: p.Description, ItemName: p.ItemName, + GameID: p.GameID, } e, err := types.GetByTriggerAndTransport(p.Event, p.Transport) @@ -111,7 +117,7 @@ func Fire(p TriggerParameters) (string, error) { } defer resp.Body.Close() - println(fmt.Sprintf(`[%v] Request Sent`, resp.StatusCode)) + log.Println(fmt.Sprintf(`[%v] Request Sent`, resp.StatusCode)) } return string(resp.JSON), nil diff --git a/internal/events/types/authorization_revoke/authorization_revoke.go b/internal/events/types/authorization/authorization.go similarity index 91% rename from internal/events/types/authorization_revoke/authorization_revoke.go rename to internal/events/types/authorization/authorization.go index 09b32f39..8e5d7dbb 100644 --- a/internal/events/types/authorization_revoke/authorization_revoke.go +++ b/internal/events/types/authorization/authorization.go @@ -1,10 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package authorization_revoke +package authorization import ( "encoding/json" - "errors" "time" "github.com/spf13/viper" @@ -18,11 +17,12 @@ var transportsSupported = map[string]bool{ models.TransportEventSub: true, } -var triggerSupported = []string{"revoke"} +var triggerSupported = []string{"revoke", "grant"} var triggerMapping = map[string]map[string]string{ models.TransportEventSub: { "revoke": "user.authorization.revoke", + "grant": "user.authorization.grant", }, } @@ -66,8 +66,6 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven if err != nil { return events.MockEventResponse{}, err } - case models.TransportWebSub: - return events.MockEventResponse{}, errors.New("Websub is unsupported for authorization revoke events") default: return events.MockEventResponse{}, nil } diff --git a/internal/events/types/authorization_revoke/authorization_revoke_test.go b/internal/events/types/authorization/authorization_test.go similarity index 80% rename from internal/events/types/authorization_revoke/authorization_revoke_test.go rename to internal/events/types/authorization/authorization_test.go index a26a7f11..eeaa4627 100644 --- a/internal/events/types/authorization_revoke/authorization_revoke_test.go +++ b/internal/events/types/authorization/authorization_test.go @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package authorization_revoke +package authorization import ( "encoding/json" @@ -29,7 +29,7 @@ func TestEventSub(t *testing.T) { r, err := Event{}.GenerateEvent(params) a.Nil(err) - var body models.AuthorizationRevokeEventSubResponse // replace with actual value + var body models.AuthorizationRevokeEventSubResponse err = json.Unmarshal(r.JSON, &body) a.Nil(err) @@ -37,21 +37,6 @@ func TestEventSub(t *testing.T) { a.Equal(body.Event.ClientID, body.Subscription.Condition.ClientID) a.Equal("1234", body.Event.ClientID) } -func TestWebSub(t *testing.T) { - a := test_setup.SetupTestEnv(t) - - params := *&events.MockEventParameters{ - FromUserID: fromUser, - ToUserID: toUser, - Transport: models.TransportWebSub, - Trigger: "revoke", - } - - _, err := Event{}.GenerateEvent(params) - a.NotNil(err) - - // write tests here for websub -} func TestFakeTransport(t *testing.T) { a := test_setup.SetupTestEnv(t) diff --git a/internal/events/types/drop/drop.go b/internal/events/types/drop/drop.go new file mode 100644 index 00000000..b6dca9c6 --- /dev/null +++ b/internal/events/types/drop/drop.go @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package drop + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var transportsSupported = map[string]bool{ + models.TransportWebSub: false, + models.TransportEventSub: true, +} + +var triggerSupported = []string{"drop"} + +var triggerMapping = map[string]map[string]string{ + models.TransportEventSub: { + "drop": "drop.entitlement.grant", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + + if params.ItemID == "" { + params.ItemID = util.RandomGUID() + } + + if params.Description == "" { + params.Description = fmt.Sprintf("%v", util.RandomInt(1000)) + } + switch params.Transport { + case models.TransportEventSub: + body := &models.DropsEntitlementEventSubResponse{ + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: "enabled", + Type: triggerMapping[params.Transport][params.Trigger], + Version: "1", + Condition: models.EventsubCondition{ + OrganizationID: params.FromUserID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 0, + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + Events: []models.DropsEntitlementEventSubEvent{ + { + ID: util.RandomGUID(), + Data: models.DropsEntitlementEventSubEventData{ + OrganizationID: params.FromUserID, + CategoryID: params.GameID, + CategoryName: "", + CampaignID: util.RandomGUID(), + EntitlementID: util.RandomGUID(), + BenefitID: params.ItemID, + UserID: params.ToUserID, + UserName: params.ToUserName, + UserLogin: params.ToUserName, + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + }, + }, + } + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + default: + return events.MockEventResponse{}, nil + } + + return events.MockEventResponse{ + ID: params.ID, + JSON: event, + FromUser: params.FromUserID, + ToUser: params.ToUserID, + }, nil +} + +func (e Event) ValidTransport(t string) bool { + return transportsSupported[t] +} + +func (e Event) ValidTrigger(t string) bool { + for _, ts := range triggerSupported { + if ts == t { + return true + } + } + return false +} +func (e Event) GetTopic(transport string, trigger string) string { + return triggerMapping[transport][trigger] +} diff --git a/internal/events/types/drop/drop_test.go b/internal/events/types/drop/drop_test.go new file mode 100644 index 00000000..fb0431f2 --- /dev/null +++ b/internal/events/types/drop/drop_test.go @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package drop + +import ( + "encoding/json" + "testing" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/test_setup" +) + +var fromUser = "1234" +var toUser = "4567" + +func TestEventSub(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "drop", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.DropsEntitlementEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + a.Len(body.Events, 1) +} + +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "drop", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("drop") + a.Equal(true, r) + + r = Event{}.ValidTrigger("notdrop") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportEventSub) + a.Equal(true, r) + + r = Event{}.ValidTransport("noteventsub") + a.Equal(false, r) +} +func TestGetTopic(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.GetTopic(models.TransportEventSub, "drop") + a.NotNil(r) +} diff --git a/internal/events/types/extension_transaction/transaction_event.go b/internal/events/types/extension_transaction/transaction_event.go index cebb8bc1..0538be39 100644 --- a/internal/events/types/extension_transaction/transaction_event.go +++ b/internal/events/types/extension_transaction/transaction_event.go @@ -44,6 +44,15 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven if params.Cost == 0 { params.Cost = 100 } + + if params.ItemID == "" { + params.ItemID = "testItemSku" + } + + if params.ItemName == "" { + params.ItemName = "Test Trigger Item from CLI" + } + switch params.Transport { case models.TransportEventSub: body := &models.TransactionEventSubResponse{ @@ -72,8 +81,8 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven UserLogin: "testUser", UserID: params.FromUserID, Product: models.TransactionEventSubProduct{ - Name: "Test Trigger Item from CLI", - Sku: "testItemSku", + Name: params.ItemName, + Sku: params.ItemID, Bits: params.Cost, InDevelopment: true, }, diff --git a/internal/events/types/gift/channel_gift.go b/internal/events/types/gift/channel_gift.go new file mode 100644 index 00000000..b01d9ee7 --- /dev/null +++ b/internal/events/types/gift/channel_gift.go @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package gift + +import ( + "encoding/json" + "time" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var transportsSupported = map[string]bool{ + models.TransportWebSub: false, + models.TransportEventSub: true, +} + +var triggerSupported = []string{"channel-gift"} + +var triggerMapping = map[string]map[string]string{ + models.TransportEventSub: { + "channel-gift": "channel.subscription.gift", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + + if params.Cost <= 0 { + params.Cost = 5 + } + + total := int(util.RandomInt(200) + params.Cost) + + if params.IsAnonymous { + params.FromUserID = "274598607" + params.FromUserName = "ananonymousgifter" + } + + switch params.Transport { + case models.TransportEventSub: + body := &models.GiftEventSubResponse{ + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: "enabled", + Type: triggerMapping[params.Transport][params.Trigger], + Version: "1", + Condition: models.EventsubCondition{ + BroadcasterUserID: params.ToUserID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 0, + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + Event: models.GiftEventSubEvent{ + UserID: params.FromUserID, + UserLogin: params.FromUserName, + UserName: params.FromUserName, + BroadcasterUserID: params.ToUserID, + BroadcasterUserLogin: params.ToUserName, + BroadcasterUserName: params.ToUserName, + Tier: "1000", + Total: int(params.Cost), + CumulativeTotal: &total, + IsAnonymous: params.IsAnonymous, + }, + } + if params.IsAnonymous { + body.Event.CumulativeTotal = nil + } + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + default: + return events.MockEventResponse{}, nil + } + + return events.MockEventResponse{ + ID: params.ID, + JSON: event, + FromUser: params.FromUserID, + ToUser: params.ToUserID, + }, nil +} + +func (e Event) ValidTransport(t string) bool { + return transportsSupported[t] +} + +func (e Event) ValidTrigger(t string) bool { + for _, ts := range triggerSupported { + if ts == t { + return true + } + } + return false +} +func (e Event) GetTopic(transport string, trigger string) string { + return triggerMapping[transport][trigger] +} diff --git a/internal/events/types/gift/channel_gift_test.go b/internal/events/types/gift/channel_gift_test.go new file mode 100644 index 00000000..3aa40b28 --- /dev/null +++ b/internal/events/types/gift/channel_gift_test.go @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package gift + +import ( + "encoding/json" + "testing" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/test_setup" +) + +var fromUser = "1234" +var toUser = "4567" + +func TestEventSub(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "channel-gift", + Cost: 0, + IsAnonymous: true, + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.GiftEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + a.GreaterOrEqual(body.Event.Total, 1) + a.Nil(body.Event.CumulativeTotal) + a.NotEmpty(body.Event.UserID) +} + +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "unsubscribe", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("channel-gift") + a.Equal(true, r) + + r = Event{}.ValidTrigger("notgift") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportEventSub) + a.Equal(true, r) + + r = Event{}.ValidTransport("noteventsub") + a.Equal(false, r) +} +func TestGetTopic(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.GetTopic(models.TransportEventSub, "channel-gift") + a.NotNil(r) +} diff --git a/internal/events/types/hype_train/hype_train_event.go b/internal/events/types/hype_train/hype_train_event.go index c98e639e..3c6e51c3 100644 --- a/internal/events/types/hype_train/hype_train_event.go +++ b/internal/events/types/hype_train/hype_train_event.go @@ -39,6 +39,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven lastType := util.RandomType() //Local variables which will be used for the trigger params below + localLevel := util.RandomInt(4) + 1 localTotal := util.RandomInt(10 * 100) localGoal := util.RandomInt(10*100*100) + localTotal localProgress := (localTotal / localGoal) @@ -62,6 +63,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), }, Event: models.HypeTrainEventSubEvent{ + ID: params.ID, BroadcasterUserID: params.ToUserID, BroadcasterUserLogin: params.ToUserName, BroadcasterUserName: params.ToUserName, @@ -92,11 +94,20 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven UserLoginWhoMadeContribution: "cli_user2", }, StartedAtTimestamp: util.GetTimestamp().Format(time.RFC3339Nano), - ExpiresAtTimestamp: util.GetTimestamp().Format(time.RFC3339Nano), + ExpiresAtTimestamp: util.GetTimestamp().Add(5 * time.Minute).Format(time.RFC3339Nano), }, } - if triggerMapping[params.Transport][params.Trigger] == "hype-train-end " { + if params.Trigger == "hype-train-progress" { + body.Event.Level = localLevel + } + if params.Trigger == "hype-train-end" { body.Event.CooldownEndsAtTimestamp = util.GetTimestamp().Add(1 * time.Hour).Format(time.RFC3339Nano) + body.Event.EndedAtTimestamp = util.GetTimestamp().Format(time.RFC3339Nano) + body.Event.ExpiresAtTimestamp = "" + body.Event.Goal = 0 + body.Event.Level = localLevel + body.Event.Progress = 0 + body.Event.StartedAtTimestamp = util.GetTimestamp().Add(5 * -time.Minute).Format(time.RFC3339Nano) } event, err = json.Marshal(body) if err != nil { diff --git a/internal/events/types/poll/poll.go b/internal/events/types/poll/poll.go new file mode 100644 index 00000000..5fa6e84a --- /dev/null +++ b/internal/events/types/poll/poll.go @@ -0,0 +1,132 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package poll + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var transportsSupported = map[string]bool{ + models.TransportWebSub: false, + models.TransportEventSub: true, +} + +var triggerSupported = []string{"poll-begin", "poll-progress", "poll-end"} + +var triggerMapping = map[string]map[string]string{ + models.TransportEventSub: { + "poll-begin": "channel.poll.begin", + "poll-progress": "channel.poll.progress", + "poll-end": "channel.poll.end", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + + if params.Description == "" { + params.Description = "Pineapple on pizza?" + } + + switch params.Transport { + case models.TransportEventSub: + choices := []models.PollEventSubEventChoice{} + for i := 1; i < 5; i++ { + c := models.PollEventSubEventChoice{ + ID: util.RandomGUID(), + Title: fmt.Sprintf("Yes but choice %v", i), + } + if params.Trigger != "poll-begin" { + c.BitsVotes = intPointer(int(util.RandomInt(10))) + c.ChannelPointsVotes = intPointer(int(util.RandomInt(10))) + c.Votes = intPointer(*c.BitsVotes + *c.ChannelPointsVotes + int(util.RandomInt(10))) + } + choices = append(choices, c) + } + + body := &models.PollEventSubResponse{ + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: "enabled", + Type: triggerMapping[params.Transport][params.Trigger], + Version: "1", + Condition: models.EventsubCondition{ + BroadcasterUserID: params.ToUserID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 0, + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + Event: models.PollEventSubEvent{ + ID: util.RandomGUID(), + BroadcasterUserID: params.ToUserID, + BroadcasterUserLogin: params.ToUserName, + BroadcasterUserName: params.ToUserName, + Title: params.Description, + Choices: choices, + BitsVoting: models.PollEventSubEventGoodVoting{ + IsEnabled: true, + AmountPerVote: 10, + }, + ChannelPointsVoting: models.PollEventSubEventGoodVoting{ + IsEnabled: true, + AmountPerVote: 500, + }, + StartedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + } + + if params.Trigger == "poll-end" { + body.Event.EndedAt = util.GetTimestamp().Add(time.Minute * 15).Format(time.RFC3339Nano) + body.Event.Status = "completed" + } else { + body.Event.EndsAt = util.GetTimestamp().Add(time.Minute * 15).Format(time.RFC3339Nano) + } + + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + default: + return events.MockEventResponse{}, nil + } + + return events.MockEventResponse{ + ID: params.ID, + JSON: event, + FromUser: params.FromUserID, + ToUser: params.ToUserID, + }, nil +} + +func (e Event) ValidTransport(t string) bool { + return transportsSupported[t] +} + +func (e Event) ValidTrigger(t string) bool { + for _, ts := range triggerSupported { + if ts == t { + return true + } + } + return false +} +func (e Event) GetTopic(transport string, trigger string) string { + return triggerMapping[transport][trigger] +} + +func intPointer(i int) *int { + return &i +} diff --git a/internal/events/types/poll/poll_test.go b/internal/events/types/poll/poll_test.go new file mode 100644 index 00000000..c74601e0 --- /dev/null +++ b/internal/events/types/poll/poll_test.go @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package poll + +import ( + "encoding/json" + "testing" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/test_setup" +) + +var fromUser = "1234" +var toUser = "4567" + +func TestEventSub(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "poll-begin", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.PollEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + a.NotEmpty(body.Event.EndsAt) + a.Empty(body.Event.EndedAt) + + params = *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "poll-progress", + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + body = models.PollEventSubResponse{} + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + a.NotEmpty(body.Event.EndsAt) + a.Empty(body.Event.EndedAt) + + params = *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "poll-end", + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + body = models.PollEventSubResponse{} + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + a.Empty(body.Event.EndsAt) + a.NotEmpty(body.Event.EndedAt) +} + +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "poll-begin", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("poll-begin") + a.Equal(true, r) + + r = Event{}.ValidTrigger("notgift") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportEventSub) + a.Equal(true, r) + + r = Event{}.ValidTransport("noteventsub") + a.Equal(false, r) +} +func TestGetTopic(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.GetTopic(models.TransportEventSub, "poll-begin") + a.NotNil(r) +} diff --git a/internal/events/types/prediction/prediction.go b/internal/events/types/prediction/prediction.go new file mode 100644 index 00000000..5c024c7f --- /dev/null +++ b/internal/events/types/prediction/prediction.go @@ -0,0 +1,153 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package prediction + +import ( + "encoding/json" + "time" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var transportsSupported = map[string]bool{ + models.TransportWebSub: false, + models.TransportEventSub: true, +} + +var triggerSupported = []string{"prediction-begin", "prediction-progress", "prediction-end"} + +var triggerMapping = map[string]map[string]string{ + models.TransportEventSub: { + "prediction-begin": "channel.prediction.begin", + "prediction-progress": "channel.prediction.progress", + "prediction-lock": "channel.prediction.lock", + "prediction-end": "channel.prediction.end", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + if params.Description == "" { + params.Description = "Will the developer finish this program?" + } + + switch params.Transport { + case models.TransportEventSub: + var outcomes []models.PredictionEventSubEventOutcomes + for i := 0; i < 2; i++ { + color := "blue" + title := "yes" + + if i == 1 { + color = "pink" + title = "no" + } + + o := models.PredictionEventSubEventOutcomes{ + ID: util.RandomGUID(), + Title: title, + Color: color, + } + + if params.Trigger != "prediction-begin" { + tp := []models.PredictionEventSubEventTopPredictors{} + + for j := 0; j < int(util.RandomInt(10))+1; j++ { + t := models.PredictionEventSubEventTopPredictors{ + UserID: util.RandomUserID(), + UserLogin: "testLogin", + UserName: "testLogin", + ChannelPointsUsed: int(util.RandomInt(10*1000)) + 100, + } + if params.Trigger == "prediction-lock" || params.Trigger == "prediction-end" { + if i == 0 { + t.ChannelPointsWon = intPointer(t.ChannelPointsUsed * 2) + } else { + t.ChannelPointsWon = intPointer(0) + } + } + tp = append(tp, t) + o.TopPredictors = &tp + } + } + + outcomes = append(outcomes, o) + } + + body := &models.PredictionEventSubResponse{ + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: "enabled", + Type: triggerMapping[params.Transport][params.Trigger], + Version: "1", + Condition: models.EventsubCondition{ + BroadcasterUserID: params.ToUserID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 0, + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + Event: models.PredictionEventSubEvent{ + ID: util.RandomGUID(), + BroadcasterUserID: params.ToUserID, + BroadcasterUserLogin: params.ToUserName, + BroadcasterUserName: params.ToUserName, + Title: params.Description, + Outcomes: outcomes, + StartedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + } + + if params.Trigger == "prediction-begin" || params.Trigger == "prediction-progress" { + body.Event.LocksAt = util.GetTimestamp().Add(time.Minute * 10).Format(time.RFC3339Nano) + } else if params.Trigger == "prediction-lock" { + body.Event.LockedAt = util.GetTimestamp().Add(time.Minute * 10).Format(time.RFC3339Nano) + } else if params.Trigger == "prediction-end" { + body.Event.WinningOutcomeID = outcomes[0].ID + body.Event.EndedAt = util.GetTimestamp().Add(time.Minute * 10).Format(time.RFC3339Nano) + body.Event.Status = "resolved" + } + + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + default: + return events.MockEventResponse{}, nil + } + + return events.MockEventResponse{ + ID: params.ID, + JSON: event, + FromUser: params.FromUserID, + ToUser: params.ToUserID, + }, nil +} + +func (e Event) ValidTransport(t string) bool { + return transportsSupported[t] +} + +func (e Event) ValidTrigger(t string) bool { + for _, ts := range triggerSupported { + if ts == t { + return true + } + } + return false +} +func (e Event) GetTopic(transport string, trigger string) string { + return triggerMapping[transport][trigger] +} + +func intPointer(i int) *int { + return &i +} diff --git a/internal/events/types/prediction/prediction_test.go b/internal/events/types/prediction/prediction_test.go new file mode 100644 index 00000000..a66c0897 --- /dev/null +++ b/internal/events/types/prediction/prediction_test.go @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package prediction + +import ( + "encoding/json" + "testing" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/test_setup" +) + +var fromUser = "1234" +var toUser = "4567" + +func TestEventSub(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "prediction-begin", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.PredictionEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + params = *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "prediction-progress", + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + body = models.PredictionEventSubResponse{} + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + params = *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "prediction-lock", + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + body = models.PredictionEventSubResponse{} + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + params = *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "prediction-end", + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + body = models.PredictionEventSubResponse{} + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) +} + +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "unsubscribe", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("prediction-begin") + a.Equal(true, r) + + r = Event{}.ValidTrigger("notgift") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportEventSub) + a.Equal(true, r) + + r = Event{}.ValidTransport("noteventsub") + a.Equal(false, r) +} +func TestGetTopic(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.GetTopic(models.TransportEventSub, "prediction-begin") + a.NotNil(r) +} diff --git a/internal/events/types/raid/raid_test.go b/internal/events/types/raid/raid_test.go index ca67f0c7..dab555b5 100644 --- a/internal/events/types/raid/raid_test.go +++ b/internal/events/types/raid/raid_test.go @@ -27,7 +27,7 @@ func TestEventSub(t *testing.T) { r, err := Event{}.GenerateEvent(params) a.Nil(err) - var body models.SubEventSubResponse // replace with actual value + var body models.SubEventSubResponse err = json.Unmarshal(r.JSON, &body) a.Nil(err) diff --git a/internal/events/types/stream_change/stream_change_event.go b/internal/events/types/stream_change/stream_change_event.go index ef94d1fc..ffe645a8 100644 --- a/internal/events/types/stream_change/stream_change_event.go +++ b/internal/events/types/stream_change/stream_change_event.go @@ -37,8 +37,10 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven if params.Description == "" { params.Description = "Example title from the CLI!" } - if params.ItemID == "" { - params.ItemID = "509658" + if params.ItemID == "" && params.GameID == "" { + params.GameID = "509658" + } else if params.ItemID != "" && params.GameID == "" { + params.GameID = params.ItemID } if params.ItemName == "" { params.ItemName = "Just Chatting" @@ -69,7 +71,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven BroadcasterUserName: params.ToUserName, StreamTitle: params.Description, StreamLanguage: "en", - StreamCategoryID: params.ItemID, + StreamCategoryID: params.GameID, StreamCategoryName: params.ItemName, IsMature: false, }, @@ -86,8 +88,8 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven BroadcasterUserID: params.ToUserID, BroadcasterUserLogin: params.ToUserName, BroadcasterUserName: params.ToUserName, - StreamCategoryID: params.ItemID, - StreamCategoryName: "Just Chatting", + StreamCategoryID: params.GameID, + StreamCategoryName: params.ItemName, StreamType: "live", StreamTitle: params.Description, StreamViewerCount: 9848, diff --git a/internal/events/types/stream_change/stream_change_event_test.go b/internal/events/types/stream_change/stream_change_event_test.go index bda3cabf..11e1c0be 100644 --- a/internal/events/types/stream_change/stream_change_event_test.go +++ b/internal/events/types/stream_change/stream_change_event_test.go @@ -40,7 +40,7 @@ func TestEventSub(t *testing.T) { ToUserID: toUser, Transport: models.TransportEventSub, Trigger: "stream_change", - ItemID: "1234", + GameID: "1234", } r, err = Event{}.GenerateEvent(params) @@ -65,7 +65,7 @@ func TestWebSubStreamChange(t *testing.T) { Transport: models.TransportWebSub, Trigger: "stream-change", Description: newStreamTitle, - ItemID: "1234", + GameID: "1234", } r, err := Event{}.GenerateEvent(params) diff --git a/internal/events/types/subscribe/sub_event.go b/internal/events/types/subscribe/sub_event.go index 8f783f93..3fd79b6f 100644 --- a/internal/events/types/subscribe/sub_event.go +++ b/internal/events/types/subscribe/sub_event.go @@ -16,18 +16,20 @@ var transportsSupported = map[string]bool{ models.TransportEventSub: true, } -var triggerSupported = []string{"subscribe", "gift", "unsubscribe"} +var triggerSupported = []string{"subscribe", "gift", "unsubscribe", "subscribe-end"} var triggerMapping = map[string]map[string]string{ models.TransportWebSub: { - "subscribe": "subscriptions.subscribe", - "unsubscribe": "subscriptions.unsubscribe", - "gift": "subscriptions.subscribe", + "subscribe": "subscriptions.subscribe", + "unsubscribe": "subscriptions.unsubscribe", + "gift": "subscriptions.subscribe", + "subscribe-end": "", }, models.TransportEventSub: { - "subscribe": "channel.subscribe", - "unsubscribe": "channel.unsubscribe", - "gift": "channel.subscribe", + "subscribe": "channel.subscribe", + "unsubscribe": "channel.unsubscribe", + "gift": "channel.subscribe", + "subscribe-end": "channel.subscription.end", }, } diff --git a/internal/events/types/subscription_message/subscription_message.go b/internal/events/types/subscription_message/subscription_message.go new file mode 100644 index 00000000..6bcac31d --- /dev/null +++ b/internal/events/types/subscription_message/subscription_message.go @@ -0,0 +1,113 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package subscription_message + +import ( + "encoding/json" + "time" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var transportsSupported = map[string]bool{ + models.TransportWebSub: false, + models.TransportEventSub: true, +} + +var triggerSupported = []string{"subscribe-message"} + +var triggerMapping = map[string]map[string]string{ + models.TransportEventSub: { + "subscribe-message": "channel.subscription.message", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + + if params.Cost == 0 { + params.Cost = util.RandomInt(120) + 1 + } + + switch params.Transport { + case models.TransportEventSub: + body := &models.SubscribeMessageEventSubResponse{ + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: "enabled", + Type: triggerMapping[params.Transport][params.Trigger], + Version: "1", + Condition: models.EventsubCondition{ + BroadcasterUserID: params.ToUserID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 0, + CreatedAt: util.GetTimestamp().Format(time.RFC3339Nano), + }, + Event: models.SubscribeMessageEventSubEvent{ + UserID: params.FromUserID, + UserLogin: params.FromUserName, + UserName: params.FromUserName, + BroadcasterUserID: params.ToUserID, + BroadcasterUserLogin: params.ToUserName, + BroadcasterUserName: params.ToUserName, + Tier: "1000", + Message: models.SubscribeMessageEventSubMessage{ + Text: "Hello from the Twitch CLI! twitchdevLeek", + Emotes: []models.SubscribeMessageEventSubMessageEmote{ + { + Begin: 26, + End: 39, + ID: "304456816", + }, + }, + }, + CumulativeMonths: int(params.Cost) + int(util.RandomInt(10)), + DurationMonths: 1, + }, + } + + if !params.IsAnonymous { + streak := int(params.Cost) + body.Event.StreakMonths = &streak + } + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + + default: + return events.MockEventResponse{}, nil + } + + return events.MockEventResponse{ + ID: params.ID, + JSON: event, + FromUser: params.FromUserID, + ToUser: params.ToUserID, + }, nil +} + +func (e Event) ValidTransport(t string) bool { + return transportsSupported[t] +} + +func (e Event) ValidTrigger(t string) bool { + for _, ts := range triggerSupported { + if ts == t { + return true + } + } + return false +} +func (e Event) GetTopic(transport string, trigger string) string { + return triggerMapping[transport][trigger] +} diff --git a/internal/events/types/subscription_message/subscription_message_test.go b/internal/events/types/subscription_message/subscription_message_test.go new file mode 100644 index 00000000..4a14fc1a --- /dev/null +++ b/internal/events/types/subscription_message/subscription_message_test.go @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package subscription_message + +import ( + "encoding/json" + "testing" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/test_setup" +) + +var fromUser = "1234" +var toUser = "4567" + +func TestEventSub(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ten := 10 + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "subscribe-message", + Cost: int64(ten), + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.SubscribeMessageEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + a.Equal(&ten, body.Event.StreakMonths) + a.GreaterOrEqual(body.Event.CumulativeMonths, 10) + + params = *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportEventSub, + Trigger: "subscribe-message", + Cost: int64(ten), + IsAnonymous: true, + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + a.Nil(body.Event.StreakMonths) + a.GreaterOrEqual(body.Event.CumulativeMonths, 10) +} + +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := *&events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "subscribe-message", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("subscribe-message") + a.Equal(true, r) + + r = Event{}.ValidTrigger("notmessage") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportEventSub) + a.Equal(true, r) + + r = Event{}.ValidTransport("noteventsub") + a.Equal(false, r) +} +func TestGetTopic(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.GetTopic(models.TransportEventSub, "subscribe-message") + a.NotNil(r) +} diff --git a/internal/events/types/types.go b/internal/events/types/types.go index 49676ad4..ff61a213 100644 --- a/internal/events/types/types.go +++ b/internal/events/types/types.go @@ -6,38 +6,48 @@ import ( "errors" "github.com/twitchdev/twitch-cli/internal/events" - "github.com/twitchdev/twitch-cli/internal/events/types/authorization_revoke" + "github.com/twitchdev/twitch-cli/internal/events/types/authorization" + "github.com/twitchdev/twitch-cli/internal/events/types/ban" "github.com/twitchdev/twitch-cli/internal/events/types/channel_points_redemption" "github.com/twitchdev/twitch-cli/internal/events/types/channel_points_reward" "github.com/twitchdev/twitch-cli/internal/events/types/cheer" + "github.com/twitchdev/twitch-cli/internal/events/types/drop" "github.com/twitchdev/twitch-cli/internal/events/types/extension_transaction" "github.com/twitchdev/twitch-cli/internal/events/types/follow" + "github.com/twitchdev/twitch-cli/internal/events/types/gift" "github.com/twitchdev/twitch-cli/internal/events/types/hype_train" "github.com/twitchdev/twitch-cli/internal/events/types/moderator_change" + "github.com/twitchdev/twitch-cli/internal/events/types/poll" + "github.com/twitchdev/twitch-cli/internal/events/types/prediction" "github.com/twitchdev/twitch-cli/internal/events/types/raid" "github.com/twitchdev/twitch-cli/internal/events/types/stream_change" "github.com/twitchdev/twitch-cli/internal/events/types/streamdown" "github.com/twitchdev/twitch-cli/internal/events/types/streamup" "github.com/twitchdev/twitch-cli/internal/events/types/subscribe" - "github.com/twitchdev/twitch-cli/internal/events/types/ban" + "github.com/twitchdev/twitch-cli/internal/events/types/subscription_message" ) func All() []events.MockEvent { return []events.MockEvent{ - authorization_revoke.Event{}, + authorization.Event{}, + ban.Event{}, channel_points_redemption.Event{}, channel_points_reward.Event{}, cheer.Event{}, + drop.Event{}, extension_transaction.Event{}, follow.Event{}, + gift.Event{}, hype_train.Event{}, + moderator_change.Event{}, + poll.Event{}, + prediction.Event{}, raid.Event{}, - subscribe.Event{}, stream_change.Event{}, streamup.Event{}, streamdown.Event{}, - moderator_change.Event{}, - ban.Event{}, + subscribe.Event{}, + subscription_message.Event{}, } } diff --git a/internal/mock_api/endpoints/channels/information.go b/internal/mock_api/endpoints/channels/information.go index 1b5346d2..69e0b6c6 100644 --- a/internal/mock_api/endpoints/channels/information.go +++ b/internal/mock_api/endpoints/channels/information.go @@ -22,7 +22,7 @@ type Channel struct { CategoryID string `db:"category_id" json:"game_id"` CategoryName string `db:"category_name" json:"game_name" dbi:"false"` Title string `db:"title" json:"title"` - Language string `db:"stream_language" json:"stream_language"` + Language string `db:"stream_language" json:"broadcaster_language"` Delay int `dbi:"false" json:"delay"` } diff --git a/internal/mock_api/endpoints/chat/channel_emotes.go b/internal/mock_api/endpoints/chat/channel_emotes.go new file mode 100644 index 00000000..1decc21b --- /dev/null +++ b/internal/mock_api/endpoints/chat/channel_emotes.go @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package chat + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "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 channelEmotesMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var channelEmotesScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type ChannelEmotes struct{} + +func (e ChannelEmotes) Path() string { return "/chat/emotes/channel" } + +func (e ChannelEmotes) GetRequiredScopes(method string) []string { + return channelEmotesScopesByMethod[method] +} + +func (e ChannelEmotes) ValidMethod(method string) bool { + return channelEmotesMethodsSupported[method] +} + +func (e ChannelEmotes) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + getChannelEmotes(w, r) + break + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} +func getChannelEmotes(w http.ResponseWriter, r *http.Request) { + emotes := []EmotesResponse{} + broadcaster := r.URL.Query().Get("broadcaster_id") + if broadcaster == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter broadcaster_id") + return + } + + setID := fmt.Sprint(util.RandomInt(10 * 1000)) + ownerID := util.RandomUserID() + for _, v := range defaultEmoteTypes { + emoteType := v + for i := 0; i < 5; i++ { + id := util.RandomInt(10 * 1000) + name := util.RandomGUID() + er := EmotesResponse{ + ID: fmt.Sprint(id), + Name: name, + Images: EmotesImages{ + ImageURL1X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/1.0", id), + ImageURL2X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/2.0", id), + ImageURL4X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/4.0", id), + }, + EmoteType: &emoteType, + EmoteSetID: &setID, + OwnerID: &ownerID, + } + if emoteType == "subscription" { + thousand := "1000" + er.Tier = &thousand + } else { + es := "" + er.Tier = &es + } + + emotes = append(emotes, er) + } + } + + bytes, _ := json.Marshal(models.APIResponse{Data: emotes}) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/chat/chat_test.go b/internal/mock_api/endpoints/chat/chat_test.go index 5aa23728..d5284d50 100644 --- a/internal/mock_api/endpoints/chat/chat_test.go +++ b/internal/mock_api/endpoints/chat/chat_test.go @@ -41,3 +41,54 @@ func TestChannelBadges(t *testing.T) { a.Nil(err) a.Equal(200, resp.StatusCode) } + +func TestGlobalEmotes(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ts := test_server.SetupTestServer(GlobalEmotes{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+GlobalEmotes{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} + +func TestChannelEmotes(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ts := test_server.SetupTestServer(ChannelEmotes{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+ChannelEmotes{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} + +func TestEmoteSets(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ts := test_server.SetupTestServer(EmoteSets{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+EmoteSets{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("emote_set_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} diff --git a/internal/mock_api/endpoints/chat/emote_set.go b/internal/mock_api/endpoints/chat/emote_set.go new file mode 100644 index 00000000..9d0a32dd --- /dev/null +++ b/internal/mock_api/endpoints/chat/emote_set.go @@ -0,0 +1,93 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package chat + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "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 emoteSetsMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var emoteSetsScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type EmoteSets struct{} + +func (e EmoteSets) Path() string { return "/chat/emotes/set" } + +func (e EmoteSets) GetRequiredScopes(method string) []string { + return emoteSetsScopesByMethod[method] +} + +func (e EmoteSets) ValidMethod(method string) bool { + return emoteSetsMethodsSupported[method] +} + +func (e EmoteSets) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + getEmoteSets(w, r) + break + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} +func getEmoteSets(w http.ResponseWriter, r *http.Request) { + emotes := []EmotesResponse{} + setID := r.URL.Query().Get("emote_set_id") + if setID == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter emote_set_id") + return + } + + for _, v := range defaultEmoteTypes { + emoteType := v + for i := 0; i < 5; i++ { + id := util.RandomInt(10 * 1000) + name := util.RandomGUID() + er := EmotesResponse{ + ID: fmt.Sprint(id), + Name: name, + Images: EmotesImages{ + ImageURL1X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/1.0", id), + ImageURL2X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/2.0", id), + ImageURL4X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/4.0", id), + }, + EmoteType: &emoteType, + EmoteSetID: &setID, + } + if emoteType == "subscription" { + thousand := "1000" + er.Tier = &thousand + } else { + es := "" + er.Tier = &es + } + + emotes = append(emotes, er) + } + } + + bytes, _ := json.Marshal(models.APIResponse{Data: emotes}) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/chat/global_emotes.go b/internal/mock_api/endpoints/chat/global_emotes.go new file mode 100644 index 00000000..2dceedad --- /dev/null +++ b/internal/mock_api/endpoints/chat/global_emotes.go @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package chat + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/models" + "github.com/twitchdev/twitch-cli/internal/util" +) + +var globalEmotesMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var globalEmotesScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type GlobalEmotes struct{} + +func (e GlobalEmotes) Path() string { return "/chat/emotes/global" } + +func (e GlobalEmotes) GetRequiredScopes(method string) []string { + return globalEmotesScopesByMethod[method] +} + +func (e GlobalEmotes) ValidMethod(method string) bool { + return globalEmotesMethodsSupported[method] +} + +func (e GlobalEmotes) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + getGlobalEmotes(w, r) + break + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func getGlobalEmotes(w http.ResponseWriter, r *http.Request) { + emotes := []EmotesResponse{} + + for i := 0; i < 100; i++ { + id := util.RandomInt(10 * 1000) + name := util.RandomGUID() + emotes = append(emotes, EmotesResponse{ + ID: fmt.Sprintf("%v", id), + Name: name, + Images: EmotesImages{ + ImageURL1X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/1.0", id), + ImageURL2X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/2.0", id), + ImageURL4X: fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v1/%v/4.0", id), + }, + }) + } + + bytes, _ := json.Marshal(models.APIResponse{Data: emotes}) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/chat/shared.go b/internal/mock_api/endpoints/chat/shared.go index 0552b074..c81b6cbd 100644 --- a/internal/mock_api/endpoints/chat/shared.go +++ b/internal/mock_api/endpoints/chat/shared.go @@ -5,6 +5,7 @@ package chat import "github.com/twitchdev/twitch-cli/internal/database" var db database.CLIDatabase +var defaultEmoteTypes = []string{"subscription", "bitstier", "follower"} type BadgesResponse struct { SetID string `json:"set_id"` @@ -17,3 +18,19 @@ type BadgesVersion struct { ImageURL2X string `json:"image_url_2x"` ImageURL4X string `json:"image_url_4x"` } + +type EmotesResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Images EmotesImages `json:"images"` + Tier *string `json:"tier,omitempty"` + EmoteType *string `json:"emote_type,omitempty"` + EmoteSetID *string `json:"emote_set_id,omitempty"` + OwnerID *string `json:"owner_id,omitempty"` +} + +type EmotesImages struct { + ImageURL1X string `json:"url_1x"` + ImageURL2X string `json:"url_2x"` + ImageURL4X string `json:"url_4x"` +} diff --git a/internal/mock_api/endpoints/clips/clips.go b/internal/mock_api/endpoints/clips/clips.go index e5c51071..9af8d60e 100644 --- a/internal/mock_api/endpoints/clips/clips.go +++ b/internal/mock_api/endpoints/clips/clips.go @@ -87,8 +87,7 @@ func getClips(w http.ResponseWriter, r *http.Request) { dbr, err := db.NewQuery(r, 100).GetClips(database.Clip{ID: id, BroadcasterID: broadcasterID, GameID: gameID}, startedAt, endedAt) if err != nil { - println(err.Error()) - mock_errors.WriteServerError(w, "Error fetching clips") + mock_errors.WriteServerError(w, err.Error()) return } @@ -150,8 +149,7 @@ func postClips(w http.ResponseWriter, r *http.Request) { err = db.NewQuery(r, 100).InsertClip(clip) if err != nil { - println(err.Error()) - mock_errors.WriteServerError(w, "Error creating clip for user") + mock_errors.WriteServerError(w, err.Error()) return } diff --git a/internal/mock_api/endpoints/drops/drops_test.go b/internal/mock_api/endpoints/drops/drops_test.go index aff4805c..69d3edfe 100644 --- a/internal/mock_api/endpoints/drops/drops_test.go +++ b/internal/mock_api/endpoints/drops/drops_test.go @@ -3,13 +3,48 @@ package drops import ( + "bytes" + "encoding/json" + "log" "net/http" + "os" "testing" + "time" + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/util" "github.com/twitchdev/twitch-cli/test_setup" "github.com/twitchdev/twitch-cli/test_setup/test_server" ) +var entitlement database.DropsEntitlement + +func TestMain(m *testing.M) { + test_setup.SetupTestEnv(&testing.T{}) + + db, err := database.NewConnection() + if err != nil { + log.Fatal(err) + } + e := database.DropsEntitlement{ + ID: util.RandomGUID(), + UserID: "1", + BenefitID: "1234", + GameID: "1", + Timestamp: util.GetTimestamp().Format(time.RFC3339), + Status: "CLAIMED", + } + + err = db.NewQuery(nil, 100).InsertDropsEntitlement(e) + if err != nil { + log.Fatal(err) + } + entitlement = e + + db.DB.Close() + + os.Exit(m.Run()) +} func TestDropsEntitlements(t *testing.T) { a := test_setup.SetupTestEnv(t) ts := test_server.SetupTestServer(DropsEntitlements{}) @@ -21,4 +56,33 @@ func TestDropsEntitlements(t *testing.T) { resp, err := http.DefaultClient.Do(req) a.Nil(err) a.Equal(200, resp.StatusCode) + + // patch + // patch tests + body := PatchEntitlementsBody{ + FulfillmentStatus: "FULFILLED", + EntitlementIDs: []string{ + entitlement.ID, + "potato", + }, + } + + b, _ := json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+DropsEntitlements{}.Path(), bytes.NewBuffer(b)) + q = req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.NotNil(resp) + a.Equal(200, resp.StatusCode) + + body.FulfillmentStatus = "potato" + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+DropsEntitlements{}.Path(), bytes.NewBuffer(b)) + q = req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.NotNil(resp) + a.Equal(400, resp.StatusCode) } diff --git a/internal/mock_api/endpoints/drops/entitlements.go b/internal/mock_api/endpoints/drops/entitlements.go index cdb9b619..6fdcf79a 100644 --- a/internal/mock_api/endpoints/drops/entitlements.go +++ b/internal/mock_api/endpoints/drops/entitlements.go @@ -16,7 +16,7 @@ var dropsEntitlementsMethodsSupported = map[string]bool{ http.MethodGet: true, http.MethodPost: false, http.MethodDelete: false, - http.MethodPatch: false, + http.MethodPatch: true, http.MethodPut: false, } @@ -30,6 +30,16 @@ var dropsEntitlementsScopesByMethod = map[string][]string{ type DropsEntitlements struct{} +type PatchEntitlementsBody struct { + FulfillmentStatus string `json:"fulfillment_status"` + EntitlementIDs []string `json:"entitlement_ids"` +} + +type PatchEntitlementsResponse struct { + Status string `json:"status"` + IDs []string `json:"ids"` +} + func (e DropsEntitlements) Path() string { return "/entitlements/drops" } func (e DropsEntitlements) GetRequiredScopes(method string) []string { @@ -46,7 +56,8 @@ func (e DropsEntitlements) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: getEntitlements(w, r) - break + case http.MethodPatch: + patchEntitlements(w, r) default: w.WriteHeader(http.StatusMethodNotAllowed) } @@ -86,3 +97,69 @@ func getEntitlements(w http.ResponseWriter, r *http.Request) { bytes, err := json.Marshal(apiResponse) w.Write(bytes) } + +func patchEntitlements(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + + var body PatchEntitlementsBody + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid body") + return + } + + if body.FulfillmentStatus != "CLAIMED" && body.FulfillmentStatus != "FULFILLED" { + mock_errors.WriteBadRequest(w, "fulfillment_status must be one of CLAIMED or FULFILLED") + return + } + + if len(body.EntitlementIDs) == 0 || len(body.EntitlementIDs) > 100 { + mock_errors.WriteBadRequest(w, "entitlement_ids must be at least 1 and at most 100") + return + } + s := PatchEntitlementsResponse{ + Status: "SUCCESS", + } + ua := PatchEntitlementsResponse{Status: "UNAUTHORIZED"} + fail := PatchEntitlementsResponse{Status: "UPDATE_FAILED"} + notFound := PatchEntitlementsResponse{Status: "NOT_FOUND"} + for _, e := range body.EntitlementIDs { + dbr, err := db.NewQuery(nil, 100).GetDropsEntitlements(database.DropsEntitlement{ID: e}) + if err != nil { + fail.IDs = append(fail.IDs, e) + continue + } + entitlement := dbr.Data.([]database.DropsEntitlement) + if len(entitlement) == 0 { + notFound.IDs = append(notFound.IDs, e) + continue + } + + if userCtx.UserID != "" && userCtx.UserID != entitlement[0].UserID { + ua.IDs = append(ua.IDs, e) + continue + } + + err = db.NewQuery(nil, 100).UpdateDropsEntitlement(database.DropsEntitlement{ID: e, UserID: entitlement[0].UserID, Status: body.FulfillmentStatus}) + if err != nil { + fail.IDs = append(fail.IDs, e) + continue + } + s.IDs = append(s.IDs, e) + } + all := []PatchEntitlementsResponse{ + s, + ua, + fail, + notFound, + } + resp := []PatchEntitlementsResponse{} + for _, r := range all { + if len(r.IDs) != 0 { + resp = append(resp, r) + } + } + + bytes, _ := json.Marshal(resp) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/endpoints.go b/internal/mock_api/endpoints/endpoints.go index 548ccd9b..257f2536 100644 --- a/internal/mock_api/endpoints/endpoints.go +++ b/internal/mock_api/endpoints/endpoints.go @@ -15,6 +15,7 @@ import ( "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/moderation" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/polls" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/predictions" + "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/schedule" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/search" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/streams" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/subscriptions" @@ -35,7 +36,10 @@ func All() []mock_api.MockEndpoint { channels.Editors{}, channels.InformationEndpoint{}, chat.ChannelBadges{}, + chat.ChannelEmotes{}, + chat.EmoteSets{}, chat.GlobalBadges{}, + chat.GlobalEmotes{}, clips.Clips{}, drops.DropsEntitlements{}, hype_train.HypeTrainEvents{}, @@ -47,6 +51,10 @@ func All() []mock_api.MockEndpoint { moderation.Moderators{}, polls.Polls{}, predictions.Predictions{}, + schedule.Schedule{}, + schedule.ScheduleICal{}, + schedule.ScheduleSegment{}, + schedule.ScheduleSettings{}, search.SearchCategories{}, search.SearchChannels{}, streams.AllTags{}, diff --git a/internal/mock_api/endpoints/moderation/banned.go b/internal/mock_api/endpoints/moderation/banned.go index 2afcf3e8..36cc76c5 100644 --- a/internal/mock_api/endpoints/moderation/banned.go +++ b/internal/mock_api/endpoints/moderation/banned.go @@ -61,6 +61,12 @@ func getBans(w http.ResponseWriter, r *http.Request) { mock_errors.WriteUnauthorized(w, "broadcaster_id does not match token") return } + broadcaster, err := db.NewQuery(r, 100).GetUser(database.User{ID: userCtx.UserID}) + if err != nil { + mock_errors.WriteServerError(w, "error fetching broadcaster") + return + } + bans := []database.Ban{} userIDs := r.URL.Query()["user_id"] if len(userIDs) > 0 { @@ -81,6 +87,11 @@ func getBans(w http.ResponseWriter, r *http.Request) { } bans = append(bans, dbr.Data.([]database.Ban)...) } + for i := range bans { + bans[i].ModeratorID = broadcaster.ID + bans[i].ModeratorUserLogin = broadcaster.UserLogin + bans[i].ModeratorUserName = broadcaster.DisplayName + } apiResponse := models.APIResponse{Data: bans} if dbr.Cursor != "" { apiResponse.Pagination = &models.APIPagination{Cursor: dbr.Cursor} diff --git a/internal/mock_api/endpoints/moderation/banned_events.go b/internal/mock_api/endpoints/moderation/banned_events.go index 3f16d658..16f14e77 100644 --- a/internal/mock_api/endpoints/moderation/banned_events.go +++ b/internal/mock_api/endpoints/moderation/banned_events.go @@ -60,6 +60,13 @@ func getBanEvents(w http.ResponseWriter, r *http.Request) { mock_errors.WriteUnauthorized(w, "broadcaster_id does not match token") return } + + broadcaster, err := db.NewQuery(r, 100).GetUser(database.User{ID: userCtx.UserID}) + if err != nil { + mock_errors.WriteServerError(w, "error fetching broadcaster") + return + } + bans := []database.BanEvent{} userIDs := r.URL.Query()["user_id"] if len(userIDs) > 0 { @@ -80,6 +87,11 @@ func getBanEvents(w http.ResponseWriter, r *http.Request) { } bans = append(bans, dbr.Data.([]database.BanEvent)...) } + for i := range bans { + bans[i].ModeratorID = broadcaster.ID + bans[i].ModeratorUserLogin = broadcaster.UserLogin + bans[i].ModeratorUserName = broadcaster.DisplayName + } apiResponse := models.APIResponse{Data: bans} if dbr.Cursor != "" { apiResponse.Pagination = &models.APIPagination{Cursor: dbr.Cursor} diff --git a/internal/mock_api/endpoints/polls/polls.go b/internal/mock_api/endpoints/polls/polls.go index 8b3fd41d..bacb8000 100644 --- a/internal/mock_api/endpoints/polls/polls.go +++ b/internal/mock_api/endpoints/polls/polls.go @@ -227,8 +227,7 @@ func patchPolls(w http.ResponseWriter, r *http.Request) { dbr, err := db.NewQuery(r, 100).GetPolls(database.Poll{BroadcasterID: userCtx.UserID, ID: body.ID}) if err != nil { - println(err.Error()) - mock_errors.WriteServerError(w, "error fetching polls") + mock_errors.WriteServerError(w, err.Error()) return } diff --git a/internal/mock_api/endpoints/schedule/ical.go b/internal/mock_api/endpoints/schedule/ical.go new file mode 100644 index 00000000..59d0713e --- /dev/null +++ b/internal/mock_api/endpoints/schedule/ical.go @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package schedule + +import ( + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" +) + +var scheduleICalMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var scheduleICalScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type ScheduleICal struct{} + +func (e ScheduleICal) Path() string { return "/schedule/icalendar" } + +func (e ScheduleICal) GetRequiredScopes(method string) []string { + return scheduleICalScopesByMethod[method] +} + +func (e ScheduleICal) ValidMethod(method string) bool { + return scheduleICalMethodsSupported[method] +} + +func (e ScheduleICal) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + e.getIcal(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// stubbed with fake data for now, since .ics generation libraries are far and few between for golang +// and it's just useful for mock data +func (e ScheduleICal) getIcal(w http.ResponseWriter, r *http.Request) { + broadcaster := r.URL.Query().Get("broadcaster_id") + if broadcaster == "" { + mock_errors.WriteBadRequest(w, "Missing required paramater broadaster_id") + return + } + + body := + `BEGIN:VCALENDAR +PRODID:-//twitch.tv//StreamSchedule//1.0 +VERSION:2.0 +CALSCALE:GREGORIAN +REFRESH-INTERVAL;VALUE=DURATION:PT1H +NAME:TwitchDev +BEGIN:VEVENT +UID:e4acc724-371f-402c-81ca-23ada79759d4 +DTSTAMP:20210323T040131Z +DTSTART;TZID=/America/New_York:20210701T140000 +DTEND;TZID=/America/New_York:20210701T150000 +SUMMARY:TwitchDev Monthly Update // July 1, 2021 +DESCRIPTION:Science & Technology. +CATEGORIES:Science & Technology +END:VEVENT +END:VCALENDAR` + w.Header().Set("Content-Type", "text/calendar") + w.Write([]byte(body)) +} diff --git a/internal/mock_api/endpoints/schedule/scehdule_test.go b/internal/mock_api/endpoints/schedule/scehdule_test.go new file mode 100644 index 00000000..5f9b54cf --- /dev/null +++ b/internal/mock_api/endpoints/schedule/scehdule_test.go @@ -0,0 +1,332 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package schedule + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "os" + "testing" + "time" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/util" + "github.com/twitchdev/twitch-cli/test_setup" + "github.com/twitchdev/twitch-cli/test_setup/test_server" +) + +type RewardResponse struct { + Data []database.ChannelPointsReward `json:"data"` +} + +var ( + segment database.ScheduleSegment +) + +func TestMain(m *testing.M) { + test_setup.SetupTestEnv(&testing.T{}) + + db, err := database.NewConnection() + if err != nil { + log.Fatal(err) + } + f := false + s := database.ScheduleSegment{ + ID: util.RandomGUID(), + UserID: "1", + Title: "from_unit_tests", + IsRecurring: true, + IsVacation: false, + StartTime: time.Now().UTC().Format(time.RFC3339), + EndTime: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339), + IsCanceled: &f, + } + err = db.NewQuery(nil, 100).InsertSchedule(s) + if err != nil { + log.Fatal(err) + } + + _, err = db.NewQuery(nil, 100).GetSchedule(database.ScheduleSegment{UserID: "1"}, time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)) + + segment = s + db.DB.Close() + + os.Exit(m.Run()) +} +func TestSchedule(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + ts := test_server.SetupTestServer(Schedule{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+Schedule{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + q.Set("broadcaster_id", "2") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + q.Set("broadcaster_id", "1") + q.Set("id", segment.ID) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + q.Set("utc_offset", "60") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + q.Set("start_time", "test") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("start_time", segment.StartTime) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} + +func TestICal(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + ts := test_server.SetupTestServer(ScheduleICal{}) + req, _ := http.NewRequest(http.MethodGet, ts.URL+Schedule{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} + +func TestSegment(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + ts := test_server.SetupTestServer(ScheduleSegment{}) + tr := true + + // post tests + body := SegmentPatchAndPostBody{ + Title: "hello", + Timezone: "America/Los_Angeles", + StartTime: time.Now().Format(time.RFC3339), + IsRecurring: &tr, + Duration: "60", + } + + b, _ := json.Marshal(body) + req, _ := http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) + q := req.URL.Query() + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.NotNil(resp) + a.Equal(200, resp.StatusCode) + + body.Title = "" + 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(200, resp.StatusCode) + + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) + q.Set("broadcaster_id", "2") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + 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") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + 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") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + body.Timezone = segment.Timezone + body.IsRecurring = nil + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPost, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + // patch + // no id + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) + q.Set("broadcaster_id", "1") + q.Del("id") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + //mismatch bid and token + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) + q.Set("broadcaster_id", "2") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(401, resp.StatusCode) + + // good request + body.Title = "patched_title" + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSegment{}.Path(), bytes.NewBuffer(b)) + q.Set("broadcaster_id", "1") + q.Set("id", segment.ID) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + // delete + req, _ = http.NewRequest(http.MethodDelete, ts.URL+ScheduleSegment{}.Path(), nil) + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(204, resp.StatusCode) + + q.Set("id", segment.ID) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(204, resp.StatusCode) + + q.Set("broadcaster_id", "2") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(401, resp.StatusCode) +} + +func TestSettings(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + ts := test_server.SetupTestServer(ScheduleSettings{}) + tr := true + f := false + + // patch tests + body := PatchSettingsBody{ + Timezone: "America/Los_Angeles", + VacationStartTime: time.Now().Format(time.RFC3339), + VacationEndTime: segment.EndTime, + IsVacationEnabled: &f, + } + + b, _ := json.Marshal(body) + req, _ := http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.Path(), bytes.NewBuffer(b)) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.NotNil(resp) + a.Equal(401, resp.StatusCode) + + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.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(204, resp.StatusCode) + + body.IsVacationEnabled = &tr + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.Path(), bytes.NewBuffer(b)) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(204, resp.StatusCode) + + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.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.IsVacationEnabled = &f + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.Path(), bytes.NewBuffer(b)) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(204, resp.StatusCode) + + body.IsVacationEnabled = &tr + body.VacationStartTime = "123" + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.Path(), bytes.NewBuffer(b)) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + body.VacationStartTime = segment.StartTime + body.VacationEndTime = "123" + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.Path(), bytes.NewBuffer(b)) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + body.VacationEndTime = segment.EndTime + body.Timezone = "1" + b, _ = json.Marshal(body) + req, _ = http.NewRequest(http.MethodPatch, ts.URL+ScheduleSettings{}.Path(), bytes.NewBuffer(b)) + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) +} diff --git a/internal/mock_api/endpoints/schedule/schedule.go b/internal/mock_api/endpoints/schedule/schedule.go new file mode 100644 index 00000000..63254015 --- /dev/null +++ b/internal/mock_api/endpoints/schedule/schedule.go @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package schedule + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "time" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" + "github.com/twitchdev/twitch-cli/internal/models" +) + +var scheduleMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var scheduleScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type Schedule struct{} + +func (e Schedule) Path() string { return "/schedule" } + +func (e Schedule) GetRequiredScopes(method string) []string { + return scheduleScopesByMethod[method] +} + +func (e Schedule) ValidMethod(method string) bool { + return scheduleMethodsSupported[method] +} + +func (e Schedule) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + e.getSchedule(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (e Schedule) getSchedule(w http.ResponseWriter, r *http.Request) { + broadcasterID := r.URL.Query().Get("broadcaster_id") + queryTime := r.URL.Query().Get("start_time") + offset := r.URL.Query().Get("utc_offset") + ids := r.URL.Query()["id"] + schedule := database.Schedule{} + startTime := time.Now().UTC() + apiResponse := models.APIResponse{} + + if broadcasterID == "" { + mock_errors.WriteBadRequest(w, "Required parameter broadcaster_id is missing") + return + } + + if queryTime != "" { + st, err := time.Parse(time.RFC3339, queryTime) + if err != nil { + mock_errors.WriteBadRequest(w, "Parameter start_time is in an invalid format") + return + } + startTime = st.UTC() + } + + if offset != "" { + o, err := strconv.Atoi(offset) + if err != nil { + mock_errors.WriteBadRequest(w, "Error decoding parameter offset") + return + } + tz := time.FixedZone("", o*60) + startTime = startTime.In(tz) + } + + segments := []database.ScheduleSegment{} + if len(ids) > 0 { + if len(ids) > 100 { + mock_errors.WriteBadRequest(w, "Parameter id may only have a maximum of 100 values") + return + } + for _, id := range ids { + dbr, err := db.NewQuery(r, 25).GetSchedule(database.ScheduleSegment{ID: id, UserID: broadcasterID}, startTime) + if err != nil { + log.Print(err) + mock_errors.WriteServerError(w, err.Error()) + return + } + response := dbr.Data.(database.Schedule) + schedule = response + segments = append(segments, response.Segments...) + } + schedule.Segments = segments + apiResponse = models.APIResponse{ + Data: schedule, + } + } else { + dbr, err := db.NewQuery(r, 25).GetSchedule(database.ScheduleSegment{UserID: broadcasterID}, startTime) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + response := dbr.Data.(database.Schedule) + segments = append(segments, response.Segments...) + schedule = response + schedule.Segments = segments + apiResponse = models.APIResponse{ + Data: schedule, + } + + if len(schedule.Segments) == dbr.Limit { + apiResponse.Pagination = &models.APIPagination{ + Cursor: dbr.Cursor, + } + } + } + + bytes, _ := json.Marshal(apiResponse) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/schedule/segment.go b/internal/mock_api/endpoints/schedule/segment.go new file mode 100644 index 00000000..0b423c00 --- /dev/null +++ b/internal/mock_api/endpoints/schedule/segment.go @@ -0,0 +1,319 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package schedule + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strconv" + "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/util" +) + +var scheduleSegmentMethodsSupported = map[string]bool{ + http.MethodGet: false, + http.MethodPost: true, + http.MethodDelete: true, + http.MethodPatch: true, + http.MethodPut: false, +} + +var scheduleSegmentScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {"channel:manage:schedule"}, + http.MethodDelete: {"channel:manage:schedule"}, + http.MethodPatch: {"channel:manage:schedule"}, + http.MethodPut: {}, +} + +var f = false + +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"` + Title string `json:"title"` + IsCanceled *bool `json:"is_canceled"` +} + +func (e ScheduleSegment) Path() string { return "/schedule/segment" } + +func (e ScheduleSegment) GetRequiredScopes(method string) []string { + return scheduleSegmentScopesByMethod[method] +} + +func (e ScheduleSegment) ValidMethod(method string) bool { + return scheduleSegmentMethodsSupported[method] +} + +func (e ScheduleSegment) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodPost: + e.postSegment(w, r) + case http.MethodDelete: + e.deleteSegment(w, r) + case http.MethodPatch: + e.patchSegment(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (e ScheduleSegment) postSegment(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + duration := 240 + + if !userCtx.MatchesBroadcasterIDParam(r) { + mock_errors.WriteUnauthorized(w, "User token does not match broadcaster_id parameter") + return + } + var body SegmentPatchAndPostBody + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + mock_errors.WriteBadRequest(w, "Error parsing body") + return + } + + if body.StartTime == "" { + mock_errors.WriteBadRequest(w, "Missing start_time") + return + } + st, err := time.Parse(time.RFC3339, body.StartTime) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid timezone 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 + } + if body.IsRecurring == nil { + mock_errors.WriteBadRequest(w, "Missing is_recurring") + return + } + + if len(body.Title) > 140 { + mock_errors.WriteBadRequest(w, "Title must be less than 140 characters") + return + } + + if body.Duration != "" { + duration, err = strconv.Atoi(body.Duration) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid duration provided") + return + } + } + et := st.Add(time.Duration(duration) * time.Minute) + + segmentID := util.RandomGUID() + eventID := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf("%v\\%v", segmentID, st))) + segment := database.ScheduleSegment{ + ID: eventID, + StartTime: st.UTC().Format(time.RFC3339), + EndTime: et.UTC().Format(time.RFC3339), + IsRecurring: *body.IsRecurring, + IsVacation: false, + CategoryID: body.CategoryID, + Title: body.Title, + UserID: userCtx.UserID, + Timezone: "America/Los_Angeles", + IsCanceled: &f, + } + err = db.NewQuery(nil, 100).InsertSchedule(segment) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + if *body.IsRecurring { + // just a years worth of recurring events; mock data + for i := 0; i < 52; i++ { + weekAdd := (i + 1) * 7 * 24 + startTime := time.Now().Add(time.Duration(weekAdd) * time.Hour).UTC() + endTime := time.Now().Add(time.Duration(weekAdd) * time.Hour).UTC() + eventID := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf("%v\\%v", segmentID, startTime))) + + s := database.ScheduleSegment{ + ID: eventID, + StartTime: startTime.Format(time.RFC3339), + EndTime: endTime.Format(time.RFC3339), + IsRecurring: *body.IsRecurring, + IsVacation: false, + CategoryID: body.CategoryID, + Title: body.Title, + UserID: userCtx.UserID, + Timezone: body.Timezone, + IsCanceled: &f, + } + + err := db.NewQuery(nil, 100).InsertSchedule(s) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + } + } + dbr, err := db.NewQuery(nil, 100).GetSchedule(database.ScheduleSegment{ID: eventID}, time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + b := dbr.Data.(database.Schedule) + bytes, _ := json.Marshal(b) + w.Write(bytes) +} + +func (e ScheduleSegment) deleteSegment(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + id := r.URL.Query().Get("id") + if !userCtx.MatchesBroadcasterIDParam(r) { + mock_errors.WriteUnauthorized(w, "User token does not match broadcaster_id parameter") + return + } + + if id == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter id") + return + } + + err := db.NewQuery(nil, 100).DeleteSegment(id, userCtx.UserID) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (e ScheduleSegment) patchSegment(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + id := r.URL.Query().Get("id") + if !userCtx.MatchesBroadcasterIDParam(r) { + mock_errors.WriteUnauthorized(w, "User token does not match broadcaster_id parameter") + return + } + if id == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter id") + return + } + + dbr, err := db.NewQuery(nil, 100).GetSchedule(database.ScheduleSegment{ID: id, UserID: userCtx.UserID}, time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + b := dbr.Data.(database.Schedule) + + if len(b.Segments) == 0 { + mock_errors.WriteBadRequest(w, "Invalid ID requested") + return + } + segment := b.Segments[0] + + var body SegmentPatchAndPostBody + err = json.NewDecoder(r.Body).Decode(&body) + if err != nil { + mock_errors.WriteBadRequest(w, "Error parsing body") + return + } + + // start_time + st, err := time.Parse(time.RFC3339, segment.StartTime) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + if body.StartTime != "" { + st, err = time.Parse(time.RFC3339, body.StartTime) + if err != nil { + mock_errors.WriteBadRequest(w, "Error parsing start_time") + return + } + } + + // 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 { + isCanceled = *body.IsCanceled + } + + // title + title := segment.Title + if body.Title != "" { + if len(body.Title) > 140 { + mock_errors.WriteBadRequest(w, "Title must be less than 140 characters") + return + } + title = body.Title + } + + // duration + et, err := time.Parse(time.RFC3339, segment.EndTime) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + if body.Duration != "" { + duration, err := strconv.Atoi(body.Duration) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid duration provided") + return + } + + et = st.Add(time.Duration(duration) * time.Minute) + } + + s := database.ScheduleSegment{ + ID: segment.ID, + StartTime: st.UTC().Format(time.RFC3339), + EndTime: et.UTC().Format(time.RFC3339), + IsCanceled: &isCanceled, + Timezone: tz.String(), + Title: title, + } + + err = db.NewQuery(r, 20).UpdateSegment(s) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + + dbr, err = db.NewQuery(nil, 100).GetSchedule(database.ScheduleSegment{ID: segment.ID}, time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + b = dbr.Data.(database.Schedule) + bytes, _ := json.Marshal(b) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/schedule/settings.go b/internal/mock_api/endpoints/schedule/settings.go new file mode 100644 index 00000000..a41a82c2 --- /dev/null +++ b/internal/mock_api/endpoints/schedule/settings.go @@ -0,0 +1,140 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package schedule + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "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/util" +) + +var scheduleSettingsMethodsSupported = map[string]bool{ + http.MethodGet: false, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: true, + http.MethodPut: false, +} + +var scheduleSettingsScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {"channel:manage:schedule"}, + http.MethodPut: {}, +} + +type ScheduleSettings struct{} + +type PatchSettingsBody struct { + IsVacationEnabled *bool `json:"is_vacation_enabled"` + VacationStartTime string `json:"vacation_start_time"` + VacationEndTime string `json:"vacation_end_time"` + Timezone string `json:"timezone"` +} + +func (e ScheduleSettings) Path() string { return "/schedule/settings" } + +func (e ScheduleSettings) GetRequiredScopes(method string) []string { + return scheduleSettingsScopesByMethod[method] +} + +func (e ScheduleSettings) ValidMethod(method string) bool { + return scheduleSettingsMethodsSupported[method] +} + +func (e ScheduleSettings) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodPatch: + e.patchSchedule(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (e ScheduleSettings) patchSchedule(w http.ResponseWriter, r *http.Request) { + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + if !userCtx.MatchesBroadcasterIDParam(r) { + mock_errors.WriteUnauthorized(w, "User token does not match broadcaster_id parameter") + return + } + + vacation, err := db.NewQuery(r, 100).GetVacations(database.ScheduleSegment{UserID: userCtx.UserID}) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + + var body PatchSettingsBody + err = json.NewDecoder(r.Body).Decode(&body) + + if body.IsVacationEnabled == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + if *body.IsVacationEnabled == false { + if vacation.ID != "" { + err := db.NewQuery(r, 100).DeleteSegment(vacation.ID, userCtx.UserID) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + } + w.WriteHeader(http.StatusNoContent) + return + } + + if vacation.ID != "" && *body.IsVacationEnabled == true { + mock_errors.WriteBadRequest(w, "Existing vacation already exists") + return + } + + if body.Timezone == "" || body.VacationStartTime == "" || body.VacationEndTime == "" { + mock_errors.WriteBadRequest(w, "Missing required parameter") + return + } + + _, err = time.LoadLocation(body.Timezone) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid timezone requested") + return + } + + st, err := time.Parse(time.RFC3339, body.VacationStartTime) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid vacation_start_time requested") + return + } + + et, err := time.Parse(time.RFC3339, body.VacationEndTime) + if err != nil { + mock_errors.WriteBadRequest(w, "Invalid vacation_end_time requested") + return + } + f := false + err = db.NewQuery(r, 100).InsertSchedule(database.ScheduleSegment{ + ID: base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf("%v\\%v", util.RandomGUID(), st))), + StartTime: st.UTC().Format(time.RFC3339), + EndTime: et.UTC().Format(time.RFC3339), + IsVacation: true, + IsRecurring: false, + IsCanceled: &f, + UserID: userCtx.UserID, + }) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/mock_api/endpoints/schedule/shared.go b/internal/mock_api/endpoints/schedule/shared.go new file mode 100644 index 00000000..4ba602dd --- /dev/null +++ b/internal/mock_api/endpoints/schedule/shared.go @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package schedule + +import "github.com/twitchdev/twitch-cli/internal/database" + +var db database.CLIDatabase diff --git a/internal/mock_api/endpoints/search/channels.go b/internal/mock_api/endpoints/search/channels.go index 827606d8..809bf2d2 100644 --- a/internal/mock_api/endpoints/search/channels.go +++ b/internal/mock_api/endpoints/search/channels.go @@ -64,7 +64,6 @@ func searchChannels(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("live_only") != "" { live_only, _ = strconv.ParseBool(r.URL.Query().Get("live_only")) } - println(live_only) dbr, err := db.NewQuery(r, 100).SearchChannels(query, live_only) if err != nil { log.Print(err) diff --git a/internal/mock_api/endpoints/streams/all_tags.go b/internal/mock_api/endpoints/streams/all_tags.go index 635361ff..bbd962de 100644 --- a/internal/mock_api/endpoints/streams/all_tags.go +++ b/internal/mock_api/endpoints/streams/all_tags.go @@ -63,7 +63,6 @@ func getAllTags(w http.ResponseWriter, r *http.Request) { if len(tagIDs) > 0 { for _, id := range tagIDs { - println(id) t := database.Tag{ID: id} dbr, err := db.NewQuery(r, 100).GetTags(t) if err != nil { diff --git a/internal/mock_api/endpoints/streams/markers.go b/internal/mock_api/endpoints/streams/markers.go index 7fad7065..988b65be 100644 --- a/internal/mock_api/endpoints/streams/markers.go +++ b/internal/mock_api/endpoints/streams/markers.go @@ -89,8 +89,7 @@ func getMarkers(w http.ResponseWriter, r *http.Request) { dbr, err := db.NewQuery(r, 100).GetStreamMarkers(database.StreamMarker{BroadcasterID: userID, VideoID: videoID}) if err != nil { - println(err.Error()) - mock_errors.WriteServerError(w, "error fetching markers") + mock_errors.WriteServerError(w, err.Error()) return } markerResponse := dbr.Data.([]database.StreamMarkerUser) @@ -145,8 +144,7 @@ func postMarkers(w http.ResponseWriter, r *http.Request) { err = db.NewQuery(r, 100).InsertStreamMarker(sm) if err != nil { - println(err.Error()) - mock_errors.WriteServerError(w, "error inserting marker") + mock_errors.WriteServerError(w, err.Error()) return } diff --git a/internal/mock_api/endpoints/streams/stream_tags.go b/internal/mock_api/endpoints/streams/stream_tags.go index 7a24e15b..d677937e 100644 --- a/internal/mock_api/endpoints/streams/stream_tags.go +++ b/internal/mock_api/endpoints/streams/stream_tags.go @@ -7,6 +7,7 @@ import ( "log" "net/http" + "github.com/mattn/go-sqlite3" "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" @@ -106,15 +107,18 @@ func putStreamTags(w http.ResponseWriter, r *http.Request) { err = db.NewQuery(r, 100).DeleteAllStreamTags(userCtx.UserID) if err != nil { - println(err.Error()) - mock_errors.WriteBadRequest(w, "error removing stream tags") + log.Print(err) + mock_errors.WriteServerError(w, err.Error()) return } for _, tag := range body.TagIDs { err = db.NewQuery(r, 100).InsertStreamTag(database.StreamTag{UserID: userCtx.UserID, TagID: tag}) if err != nil { - println(err.Error()) - mock_errors.WriteBadRequest(w, "error adding stream tag") + if database.DatabaseErrorIs(err, sqlite3.ErrConstraintForeignKey) { + mock_errors.WriteBadRequest(w, "invalid tag provided") + return + } + mock_errors.WriteServerError(w, err.Error()) return } } diff --git a/internal/mock_api/generate/generate.go b/internal/mock_api/generate/generate.go index cffa8dec..79ed7e44 100644 --- a/internal/mock_api/generate/generate.go +++ b/internal/mock_api/generate/generate.go @@ -5,6 +5,7 @@ package generate import ( "context" "database/sql" + "encoding/base64" "fmt" "log" "strings" @@ -24,6 +25,8 @@ type UserInfo struct { Type string } +var f = false + func Generate(userCount int) error { db, err := database.NewConnection() if err != nil { @@ -183,6 +186,7 @@ func generateUsers(ctx context.Context, count int) error { GameID: dropsGameID, UserID: broadcaster.ID, Timestamp: util.GetTimestamp().Format(time.RFC3339Nano), + Status: "CLAIMED", } err = db.NewQuery(nil, 1000).InsertDropsEntitlement(entitlement) if err != nil { @@ -232,7 +236,7 @@ func generateUsers(ctx context.Context, count int) error { }, { ID: util.RandomGUID(), - Title: "Choice1", + Title: "Choice2", Color: "PINK", Users: 0, ChannelPoints: 0, @@ -245,6 +249,35 @@ func generateUsers(ctx context.Context, count int) error { log.Print(err.Error()) } + // create fake schedule event + segmentID := util.RandomGUID() + + // just a years worth of recurring events; mock data + for i := 0; i < 52; i++ { + weekAdd := (i + 1) * 7 * 24 + startTime := time.Now().Add(time.Duration(weekAdd) * time.Hour).UTC() + endTime := time.Now().Add(time.Duration(weekAdd) * time.Hour).UTC() + eventID := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf("%v\\%v", segmentID, startTime))) + + segment := database.ScheduleSegment{ + ID: eventID, + StartTime: startTime.Format(time.RFC3339), + EndTime: endTime.Format(time.RFC3339), + IsRecurring: true, + IsVacation: false, + CategoryID: &dropsGameID, + Title: "Test Title", + UserID: broadcaster.ID, + Timezone: "America/Los_Angeles", + IsCanceled: &f, + } + + err := db.NewQuery(nil, 100).InsertSchedule(segment) + if err != nil { + log.Print(err.Error()) + } + } + for j, user := range users { // create a seed used for the below determination on if a user should follow one another- this simply simulates a social mesh userSeed := util.RandomInt(100 * 100) @@ -509,6 +542,10 @@ func generateAuthorization(ctx context.Context, c database.AuthenticationClient, if err != nil { return err } - log.Printf("Created authorization for user %v with token %v", userID, auth.Token) + if userID != "" { + log.Printf("Created authorization for user %v with token %v", userID, auth.Token) + } else { + log.Printf("Created authorization with token %v", auth.Token) + } return nil } diff --git a/internal/mock_api/generate/generate_tools.go b/internal/mock_api/generate/generate_tools.go index 3e6fd62e..7eefd8a9 100644 --- a/internal/mock_api/generate/generate_tools.go +++ b/internal/mock_api/generate/generate_tools.go @@ -30,6 +30,8 @@ var usernamePossibilities = []string{ "Skateboard", "Egg", "Lion", + "Isaac", + "Jill", } func generateUsername() string { diff --git a/internal/mock_api/mock_server/server.go b/internal/mock_api/mock_server/server.go index 819cc745..7bf2c748 100644 --- a/internal/mock_api/mock_server/server.go +++ b/internal/mock_api/mock_server/server.go @@ -81,6 +81,11 @@ func StartServer(port int) { func RegisterHandlers(m *http.ServeMux) { // all mock endpoints live in the /mock/ namespace for _, e := range endpoints.All() { + // no auth requirements on this endpoint, so just add it manually + if e.Path() == "/schedule/icalendar" { + m.Handle(MOCK_NAMESPACE+e.Path(), loggerMiddleware(e)) + continue + } m.Handle(MOCK_NAMESPACE+e.Path(), loggerMiddleware(authentication.AuthenticationMiddleware(e))) } for _, e := range mock_units.All() { @@ -96,6 +101,7 @@ func loggerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("%v %v", r.Method, r.URL.Path) w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) }) } diff --git a/internal/mock_auth/mock_auth.go b/internal/mock_auth/mock_auth.go index d63f0b62..f3c91289 100644 --- a/internal/mock_auth/mock_auth.go +++ b/internal/mock_auth/mock_auth.go @@ -39,6 +39,7 @@ var validScopesByTokenType = map[string]map[string]bool{ "channel:read:polls": true, "channel:read:predictions": true, "channel:read:redemptions": true, + "channel:manage:schedule": true, "channel:read:stream_key": true, "channel:read:subscriptions": true, "clips:edit": true, diff --git a/internal/models/api.go b/internal/models/api.go index 7d464b34..732a79b8 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -8,6 +8,7 @@ type APIResponse struct { Error string `json:"error,omitempty"` Status int `json:"status,omitempty"` Message string `json:"message,omitempty"` + Template string `json:"template,omitempty"` Total *int `json:"total,omitempty"` DateRange *BitsLeaderboardDateRange `json:"date_range,omitempty"` } diff --git a/internal/models/drops.go b/internal/models/drops.go index 79bd2a04..65d6d93a 100644 --- a/internal/models/drops.go +++ b/internal/models/drops.go @@ -2,17 +2,24 @@ // SPDX-License-Identifier: Apache-2.0 package models -type DropsEntitlementsData struct { - ID string `json:"id"` - BenefitID string `json:"benefit_id"` - Timestamp string `json:"timestamp"` - UserID string `json:"user_id"` - GameID string `json:"game_id"` +type DropsEntitlementEventSubResponse struct { + Subscription EventsubSubscription `json:"subscription"` + Events []DropsEntitlementEventSubEvent `json:"events"` } -type DropsEntitlementsResponse struct { - Pagination struct { - Cursor string `json:"cursor"` - } `json:"pagination"` - Data []DropsEntitlementsData `json:"data"` +type DropsEntitlementEventSubEvent struct { + ID string `json:"id"` + Data DropsEntitlementEventSubEventData `json:"data"` +} +type DropsEntitlementEventSubEventData struct { + EntitlementID string `json:"entitlement_id"` + BenefitID string `json:"benefit_id"` + CampaignID string `json:"campaign_id"` + OrganizationID string `json:"organization_id"` + CreatedAt string `json:"created_at"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + UserLogin string `json:"user_login"` + CategoryID string `json:"category_id"` + CategoryName string `json:"category_name"` } diff --git a/internal/models/eventsub.go b/internal/models/eventsub.go index 7c88d736..ad30fd60 100644 --- a/internal/models/eventsub.go +++ b/internal/models/eventsub.go @@ -24,6 +24,7 @@ type EventsubCondition struct { FromBroadcasterUserID string `json:"from_broadcaster_user_id,omitempty"` ClientID string `json:"client_id,omitempty"` ExtensionClientID string `json:"extension_client_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` } type EventsubResponse struct { diff --git a/internal/models/hype_train.go b/internal/models/hype_train.go index 3ba1c24b..a13540c6 100644 --- a/internal/models/hype_train.go +++ b/internal/models/hype_train.go @@ -42,6 +42,7 @@ type HypeTrainEventSubResponse struct { } type HypeTrainEventSubEvent struct { + ID string `json:"id"` BroadcasterUserID string `json:"broadcaster_user_id"` BroadcasterUserLogin string `json:"broadcaster_user_login"` BroadcasterUserName string `json:"broadcaster_user_name"` diff --git a/internal/models/poll.go b/internal/models/poll.go new file mode 100644 index 00000000..bef9b143 --- /dev/null +++ b/internal/models/poll.go @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package models + +type PollEventSubResponse struct { + Subscription EventsubSubscription `json:"subscription"` + Event PollEventSubEvent `json:"event"` +} + +type PollEventSubEvent struct { + ID string `json:"id"` + BroadcasterUserID string `json:"broadcaster_user_id"` + BroadcasterUserLogin string `json:"broadcaster_user_login"` + BroadcasterUserName string `json:"broadcaster_user_name"` + Title string `json:"title"` + Choices []PollEventSubEventChoice `json:"choices"` + BitsVoting PollEventSubEventGoodVoting `json:"bits_voting"` + ChannelPointsVoting PollEventSubEventGoodVoting `json:"channel_points_voting"` + Status string `json:"status,omitempty"` + StartedAt string `json:"started_at"` + EndsAt string `json:"ends_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` +} + +type PollEventSubEventChoice struct { + ID string `json:"id"` + Title string `json:"title"` + BitsVotes *int `json:"bits_votes,omitempty"` + ChannelPointsVotes *int `json:"channel_points_votes,omitempty"` + Votes *int `json:"votes,omitempty"` +} + +type PollEventSubEventGoodVoting struct { + IsEnabled bool `json:"is_enabled"` + AmountPerVote int `json:"amount_per_vote"` +} diff --git a/internal/models/prediction.go b/internal/models/prediction.go new file mode 100644 index 00000000..ca9c38e1 --- /dev/null +++ b/internal/models/prediction.go @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package models + +type PredictionEventSubResponse struct { + Subscription EventsubSubscription `json:"subscription"` + Event PredictionEventSubEvent `json:"event"` +} + +type PredictionEventSubEvent struct { + ID string `json:"id"` + BroadcasterUserID string `json:"broadcaster_user_id"` + BroadcasterUserLogin string `json:"broadcaster_user_login"` + BroadcasterUserName string `json:"broadcaster_user_name"` + Title string `json:"title"` + WinningOutcomeID string `json:"winning_outcome_id,omitempty"` + Outcomes []PredictionEventSubEventOutcomes `json:"outcomes"` + StartedAt string `json:"started_at"` + LocksAt string `json:"locks_at,omitempty"` + LockedAt string `json:"locked_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + Status string `json:"status,omitempty"` +} + +type PredictionEventSubEventOutcomes struct { + ID string `json:"id"` + Title string `json:"title"` + Color string `json:"color"` + Users *int `json:"users,omitempty"` + ChannelPoints *int `json:"channel_points,omitempty"` + TopPredictors *[]PredictionEventSubEventTopPredictors `json:"top_predictors,omitempty"` +} + +type PredictionEventSubEventTopPredictors struct { + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + ChannelPointsWon *int `json:"channel_points_won"` + ChannelPointsUsed int `json:"channel_points_used"` +} diff --git a/internal/models/subs.go b/internal/models/subs.go index b5d48a01..c2303314 100644 --- a/internal/models/subs.go +++ b/internal/models/subs.go @@ -41,3 +41,51 @@ type SubEventSubEvent struct { Tier string `json:"tier"` IsGift bool `json:"is_gift"` } + +type GiftEventSubResponse struct { + Subscription EventsubSubscription `json:"subscription"` + Event GiftEventSubEvent `json:"event"` +} + +type GiftEventSubEvent struct { + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + BroadcasterUserID string `json:"broadcaster_user_id"` + BroadcasterUserLogin string `json:"broadcaster_user_login"` + BroadcasterUserName string `json:"broadcaster_user_name"` + Tier string `json:"tier"` + Total int `json:"total"` + IsAnonymous bool `json:"is_anonymous"` + CumulativeTotal *int `json:"cumulative_total"` +} + +type SubscribeMessageEventSubResponse struct { + Subscription EventsubSubscription `json:"subscription"` + Event SubscribeMessageEventSubEvent `json:"event"` +} + +type SubscribeMessageEventSubEvent struct { + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + BroadcasterUserID string `json:"broadcaster_user_id"` + BroadcasterUserLogin string `json:"broadcaster_user_login"` + BroadcasterUserName string `json:"broadcaster_user_name"` + Tier string `json:"tier"` + Message SubscribeMessageEventSubMessage `json:"message"` + CumulativeMonths int `json:"cumulative_months"` + StreakMonths *int `json:"streak_months"` + DurationMonths int `json:"duration_months"` +} + +type SubscribeMessageEventSubMessage struct { + Text string `json:"text"` + Emotes []SubscribeMessageEventSubMessageEmote `json:"emotes"` +} + +type SubscribeMessageEventSubMessageEmote struct { + Begin int `json:"begin"` + End int `json:"end"` + ID string `json:"id"` +}