diff --git a/internal/database/init.go b/internal/database/init.go index 1f674472..fbaef7c5 100644 --- a/internal/database/init.go +++ b/internal/database/init.go @@ -10,7 +10,7 @@ import ( "github.com/jmoiron/sqlx" ) -const currentVersion = 4 +const currentVersion = 5 type migrateMap struct { SQL string @@ -37,12 +37,18 @@ var migrateSQL = map[int]migrateMap{ 4: { SQL: ` ALTER TABLE categories ADD COLUMN igdb_id text not null default 0; UPDATE categories SET igdb_id = abs(random() % 100000); ALTER TABLE clips ADD COLUMN vod_offset int default 0; UPDATE clips SET vod_offset = abs(random() % 3000); ALTER TABLE drops_entitlements ADD COLUMN last_updated text default '2023-01-01T04:17:53.325Z'; -CREATE TABLE chat_settings( broadcaster_id text not null primary key, slow_mode boolean not null default 0, slow_mode_wait_time int not null default 10, follower_mode boolean not null default 0, follower_mode_duration int not null default 60, subscriber_mode boolean not null default 0, emote_mode boolean not null default 0, unique_chat_mode boolean not null default 0, non_moderator_chat_delay boolean not null default 0, non_moderator_chat_delay_duration int not null default 10, shieldmode_is_active boolean not null default 0, shieldmode_moderator_id text not null default '', shieldmode_moderator_login text not null default '', shieldmode_moderator_name text not null default '', shieldmode_last_activated text not null default '' ); +CREATE TABLE chat_settings (broadcaster_id text not null primary key, slow_mode boolean not null default 0, slow_mode_wait_time int not null default 10, follower_mode boolean not null default 0, follower_mode_duration int not null default 60, subscriber_mode boolean not null default 0, emote_mode boolean not null default 0, unique_chat_mode boolean not null default 0, non_moderator_chat_delay boolean not null default 0, non_moderator_chat_delay_duration int not null default 10, shieldmode_is_active boolean not null default 0, shieldmode_moderator_id text not null default '', shieldmode_moderator_login text not null default '', shieldmode_moderator_name text not null default '', shieldmode_last_activated text not null default '' ); INSERT INTO chat_settings (broadcaster_id) SELECT id FROM users; ALTER TABLE users ADD COLUMN chat_color text not null default '#9146FF'; CREATE TABLE vips ( broadcaster_id text not null, user_id text not null, created_at text not null default '', primary key (broadcaster_id, user_id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) );`, Message: `Updating database to include API changes since last version. See Twitch CLI changelog for more info.`, }, + 5: { + SQL: ` +ALTER TABLE users ADD COLUMN branded_content boolean not null default false; +ALTER TABLE users ADD COLUMN content_labels text not null default '';`, + Message: `Updating database to include Content Classification Label field.`, + }, } func checkAndUpdate(db sqlx.DB) error { @@ -81,7 +87,7 @@ 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, igdb_id 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, chat_color text not null default '#9146FF', foreign key (category_id) references categories(id) ); +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, chat_color text not null default '#9146FF', branded_content boolean not null default false, content_labels text not null default '', 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) ); diff --git a/internal/database/user.go b/internal/database/user.go index 108bee87..50af24d6 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -13,24 +13,28 @@ import ( ) type User struct { - ID string `db:"id" json:"id" dbs:"u1.id"` - UserLogin string `db:"user_login" json:"login"` - DisplayName string `db:"display_name" json:"display_name"` - Email string `db:"email" json:"email,omitempty"` - UserType string `db:"user_type" json:"type"` - BroadcasterType string `db:"broadcaster_type" json:"broadcaster_type"` - UserDescription string `db:"user_description" json:"description"` - CreatedAt string `db:"created_at" json:"created_at"` - ModifiedAt string `db:"modified_at" json:"-"` - ProfileImageURL string `dbi:"false" json:"profile_image_url" ` - OfflineImageURL string `dbi:"false" json:"offline_image_url" ` - ViewCount int `dbi:"false" json:"view_count"` - CategoryID sql.NullString `db:"category_id" json:"game_id" dbi:"force"` - CategoryName sql.NullString `db:"category_name" json:"game_name" dbi:"false"` - Title string `db:"title" json:"title"` - Language string `db:"stream_language" json:"stream_language"` - Delay int `db:"delay" json:"delay" dbi:"force"` - ChatColor string `db:"chat_color" json:"-"` + ID string `db:"id" json:"id" dbs:"u1.id"` + UserLogin string `db:"user_login" json:"login"` + DisplayName string `db:"display_name" json:"display_name"` + Email string `db:"email" json:"email,omitempty"` + UserType string `db:"user_type" json:"type"` + BroadcasterType string `db:"broadcaster_type" json:"broadcaster_type"` + UserDescription string `db:"user_description" json:"description"` + CreatedAt string `db:"created_at" json:"created_at"` + ModifiedAt string `db:"modified_at" json:"-"` + ProfileImageURL string `dbi:"false" json:"profile_image_url" ` + OfflineImageURL string `dbi:"false" json:"offline_image_url" ` + ViewCount int `dbi:"false" json:"view_count"` + CategoryID sql.NullString `db:"category_id" json:"game_id" dbi:"force"` + CategoryName sql.NullString `db:"category_name" json:"game_name" dbi:"false"` + Title string `db:"title" json:"title"` + Language string `db:"stream_language" json:"stream_language"` + Delay int `db:"delay" json:"delay" dbi:"force"` + ChatColor string `db:"chat_color" json:"-"` + IsBrandedContent bool `db:"branded_content" json:"is_branded_content"` + + // UnparsedCCLs is a comma seperated array (e.g. "Gambling,ViolentGraphic,ProfanityVulgarity") + UnparsedCCLs string `db:"content_labels" json:"-"` } type Follow struct { diff --git a/internal/events/types/stream_change/stream_change_event.go b/internal/events/types/channel_update_v1/channel_update.go similarity index 96% rename from internal/events/types/stream_change/stream_change_event.go rename to internal/events/types/channel_update_v1/channel_update.go index 4488b963..9bbb2241 100644 --- a/internal/events/types/stream_change/stream_change_event.go +++ b/internal/events/types/channel_update_v1/channel_update.go @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package stream_change +package channel_update_v1 import ( "encoding/json" @@ -71,7 +71,7 @@ func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEven StreamLanguage: "en", StreamCategoryID: params.GameID, StreamCategoryName: params.ItemName, - IsMature: false, + IsMature: falsePtr(), }, } event, err = json.Marshal(body) @@ -142,3 +142,8 @@ func (e Event) GetEventSubAlias(t string) string { func (e Event) SubscriptionVersion() string { return "1" } + +func falsePtr() *bool { + f := false + return &f +} diff --git a/internal/events/types/stream_change/stream_change_event_test.go b/internal/events/types/channel_update_v1/channel_update_test.go similarity index 99% rename from internal/events/types/stream_change/stream_change_event_test.go rename to internal/events/types/channel_update_v1/channel_update_test.go index c55ea8b4..500750ff 100644 --- a/internal/events/types/stream_change/stream_change_event_test.go +++ b/internal/events/types/channel_update_v1/channel_update_test.go @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package stream_change +package channel_update_v1 import ( "encoding/json" diff --git a/internal/events/types/channel_update_v2/channel_update.go b/internal/events/types/channel_update_v2/channel_update.go new file mode 100644 index 00000000..77cec03f --- /dev/null +++ b/internal/events/types/channel_update_v2/channel_update.go @@ -0,0 +1,147 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package channel_update_v2 + +import ( + "encoding/json" + "strings" + + "github.com/twitchdev/twitch-cli/internal/events" + "github.com/twitchdev/twitch-cli/internal/models" +) + +var transportsSupported = map[string]bool{ + models.TransportWebhook: true, + models.TransportWebSocket: true, +} + +var triggerSupported = []string{"stream-change"} + +var triggerMapping = map[string]map[string]string{ + models.TransportWebhook: { + "stream-change": "channel.update", + }, + models.TransportWebSocket: { + "stream-change": "channel.update", + }, +} + +type Event struct{} + +func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { + var event []byte + var err error + + if params.Description == "" { + params.Description = "Example title from the CLI!" + } + 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" + } + + switch params.Transport { + case models.TransportWebhook, models.TransportWebSocket: + body := &models.EventsubResponse{ + // make the eventsub response (if supported) + Subscription: models.EventsubSubscription{ + ID: params.ID, + Status: params.SubscriptionStatus, + Type: triggerMapping[params.Transport][params.Trigger], + Version: e.SubscriptionVersion(), + Condition: models.EventsubCondition{ + BroadcasterUserID: params.ToUserID, + }, + Transport: models.EventsubTransport{ + Method: "webhook", + Callback: "null", + }, + Cost: 0, + CreatedAt: params.Timestamp, + }, + Event: models.ChannelUpdateEventSubEvent{ + BroadcasterUserID: params.ToUserID, + BroadcasterUserLogin: params.ToUserName, + BroadcasterUserName: params.ToUserName, + StreamTitle: params.Description, + StreamLanguage: "en", + StreamCategoryID: params.GameID, + StreamCategoryName: params.ItemName, + ContentClassificationLabels: []string{ + "MatureGame", + "ViolentGraphic", + }, + }, + } + event, err = json.Marshal(body) + if err != nil { + return events.MockEventResponse{}, err + } + + // Delete event info if Subscription.Status is not set to "enabled" + if !strings.EqualFold(params.SubscriptionStatus, "enabled") { + var i interface{} + if err := json.Unmarshal([]byte(event), &i); err != nil { + return events.MockEventResponse{}, err + } + if m, ok := i.(map[string]interface{}); ok { + delete(m, "event") // Matches JSON key defined in body variable above + } + + event, err = json.Marshal(i) + 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 (e Event) GetAllTopicsByTransport(transport string) []string { + allTopics := []string{} + for _, topic := range triggerMapping[transport] { + allTopics = append(allTopics, topic) + } + return allTopics +} +func (e Event) GetEventSubAlias(t string) string { + // check for aliases + for trigger, topic := range triggerMapping[models.TransportWebhook] { + if topic == t { + return trigger + } + } + return "" +} + +func (e Event) SubscriptionVersion() string { + return "beta" +} diff --git a/internal/events/types/channel_update_v2/channel_update_test.go b/internal/events/types/channel_update_v2/channel_update_test.go new file mode 100644 index 00000000..87e81dce --- /dev/null +++ b/internal/events/types/channel_update_v2/channel_update_test.go @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package channel_update_v2 + +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.TransportWebhook, + Trigger: "stream-change", + SubscriptionStatus: "enabled", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + + var body models.ChannelUpdateEventSubResponse + err = json.Unmarshal(r.JSON, &body) + a.Nil(err, "Error unmarshalling JSON") + + // write actual tests here (making sure you set appropriate values and the like) for eventsub + a.Equal(toUser, body.Event.BroadcasterUserID, "Expected Stream Channel %v, got %v", toUser, body.Event.BroadcasterUserID) + + // test for changing a title + params = events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: models.TransportWebhook, + Trigger: "stream_change", + SubscriptionStatus: "enabled", + GameID: "1234", + } + + r, err = Event{}.GenerateEvent(params) + a.Nil(err) + + err = json.Unmarshal(r.JSON, &body) + a.Nil(err) + + a.Equal(toUser, body.Event.BroadcasterUserID, "Expected Stream Channel %v, got %v", toUser, body.Event.BroadcasterUserID) + a.Equal("Example title from the CLI!", body.Event.StreamTitle, "Expected new stream title, got %v", body.Event.StreamTitle) + a.Equal("1234", body.Event.StreamCategoryID) +} + +func TestFakeTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + params := events.MockEventParameters{ + FromUserID: fromUser, + ToUserID: toUser, + Transport: "fake_transport", + Trigger: "stream-change", + SubscriptionStatus: "enabled", + } + + r, err := Event{}.GenerateEvent(params) + a.Nil(err) + a.Empty(r) +} +func TestValidTrigger(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTrigger("stream-change") + a.Equal(true, r) + + r = Event{}.ValidTrigger("not_trigger_keyword") + a.Equal(false, r) +} + +func TestValidTransport(t *testing.T) { + a := test_setup.SetupTestEnv(t) + + r := Event{}.ValidTransport(models.TransportWebhook) + 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.TransportWebhook, "stream-change") + a.NotNil(r) +} diff --git a/internal/events/types/types.go b/internal/events/types/types.go index 0cf508e5..00e65d7d 100644 --- a/internal/events/types/types.go +++ b/internal/events/types/types.go @@ -14,6 +14,8 @@ import ( "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/channel_update_v1" + "github.com/twitchdev/twitch-cli/internal/events/types/channel_update_v2" "github.com/twitchdev/twitch-cli/internal/events/types/charity" "github.com/twitchdev/twitch-cli/internal/events/types/cheer" "github.com/twitchdev/twitch-cli/internal/events/types/drop" @@ -29,7 +31,6 @@ import ( "github.com/twitchdev/twitch-cli/internal/events/types/raid" "github.com/twitchdev/twitch-cli/internal/events/types/shield_mode" "github.com/twitchdev/twitch-cli/internal/events/types/shoutout" - "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" @@ -60,7 +61,8 @@ func AllEvents() []events.MockEvent { raid.Event{}, shield_mode.Event{}, shoutout.Event{}, - stream_change.Event{}, + channel_update_v1.Event{}, + channel_update_v2.Event{}, streamup.Event{}, streamdown.Event{}, subscribe.Event{}, diff --git a/internal/mock_api/endpoints/ccl/ccl_test.go b/internal/mock_api/endpoints/ccl/ccl_test.go new file mode 100644 index 00000000..e359aa3c --- /dev/null +++ b/internal/mock_api/endpoints/ccl/ccl_test.go @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package ccl + +import ( + "net/http" + "testing" + + "github.com/twitchdev/twitch-cli/test_setup" + "github.com/twitchdev/twitch-cli/test_setup/test_server" +) + +func TestContentClassificationLabels(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ts := test_server.SetupTestServer(ContentClassificationLabels{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+ContentClassificationLabels{}.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) +} diff --git a/internal/mock_api/endpoints/ccl/content_classification_labels.go b/internal/mock_api/endpoints/ccl/content_classification_labels.go new file mode 100644 index 00000000..94f3ad56 --- /dev/null +++ b/internal/mock_api/endpoints/ccl/content_classification_labels.go @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package ccl + +import ( + "encoding/json" + "net/http" + + "github.com/twitchdev/twitch-cli/internal/models" +) + +var cclMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var cclScopesByMethod = map[string][]string{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type ContentClassificationLabels struct{} + +func (e ContentClassificationLabels) Path() string { return "/content_classification_labels" } + +func (e ContentClassificationLabels) GetRequiredScopes(method string) []string { + return cclScopesByMethod[method] +} + +func (e ContentClassificationLabels) ValidMethod(method string) bool { + return cclMethodsSupported[method] +} + +func (e ContentClassificationLabels) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getContentClassificationLabels(w, r) + break + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} +func getContentClassificationLabels(w http.ResponseWriter, r *http.Request) { + // TODO: locale param + + allCCLs := []models.ContentClassificationLabel{} + for _, ccl := range models.CCL_MAP { + allCCLs = append(allCCLs, ccl) + } + + bytes, _ := json.Marshal( + models.APIResponse{ + Data: allCCLs, + }, + ) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/channels/information.go b/internal/mock_api/endpoints/channels/information.go index 5671fc19..883ce202 100644 --- a/internal/mock_api/endpoints/channels/information.go +++ b/internal/mock_api/endpoints/channels/information.go @@ -5,7 +5,9 @@ package channels import ( "database/sql" "encoding/json" + "fmt" "net/http" + "strings" "github.com/mattn/go-sqlite3" "github.com/twitchdev/twitch-cli/internal/database" @@ -15,15 +17,18 @@ import ( ) type Channel struct { - ID string `db:"id" json:"broadcaster_id"` - UserLogin string `db:"user_login" json:"broadcaster_login"` - DisplayName string `db:"display_name" json:"broadcaster_name"` - 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:"broadcaster_language"` - Delay int `dbi:"false" json:"delay"` - Tags []string `dbi:"false" json:"tags"` + ID string `db:"id" json:"broadcaster_id"` + UserLogin string `db:"user_login" json:"broadcaster_login"` + DisplayName string `db:"display_name" json:"broadcaster_name"` + 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:"broadcaster_language"` + Delay int `dbi:"false" json:"delay"` + Tags []string `dbi:"false" json:"tags"` + BrandedContent bool `dbi:"false" json:"is_branded_content"` + + ContentClassificationLabels []string `dbi:"false" json:"content_classification_labels"` } var informationMethodsSupported = map[string]bool{ @@ -49,6 +54,14 @@ type PatchInformationEndpointRequest struct { BroadcasterLanguage string `json:"broadcaster_language"` Title string `json:"title"` Delay *int `json:"delay"` + BrandedContent *bool `json:"is_branded_content"` + // TODO: tags + ContentClassificationLabels []PatchInformationEndpointRequestLabel `json:"content_classification_labels"` +} + +type PatchInformationEndpointRequestLabel struct { + ID string `json:"id"` + IsEnabled bool `json:"is_enabled"` } func (e InformationEndpoint) Path() string { return "/channels" } @@ -128,6 +141,7 @@ func patchInformation(w http.ResponseWriter, r *http.Request) { return } + // Game ID var gameID = u.CategoryID if params.GameID == "" || params.GameID == "0" { gameID = sql.NullString{} @@ -135,6 +149,7 @@ func patchInformation(w http.ResponseWriter, r *http.Request) { gameID = sql.NullString{String: params.GameID, Valid: true} } + // Delay if params.Delay != nil && u.BroadcasterType != "partner" { mock_errors.WriteBadRequest(w, "Delay is partner only") return @@ -146,12 +161,27 @@ func patchInformation(w http.ResponseWriter, r *http.Request) { } else { delay = *params.Delay } + + isBrandedContent := u.IsBrandedContent + if params.BrandedContent != nil { + isBrandedContent = *params.BrandedContent + } + + cclDbString, err := handleCCLs(u, params) + if err != nil { + mock_errors.WriteForbidden(w, err.Error()) + return + } + + // Write err = db.NewQuery(r, 100).UpdateChannel(broadcasterID, database.User{ - ID: broadcasterID, - Title: params.Title, - Language: params.BroadcasterLanguage, - CategoryID: gameID, - Delay: delay, + ID: broadcasterID, + Title: params.Title, + Language: params.BroadcasterLanguage, + CategoryID: gameID, + Delay: delay, + UnparsedCCLs: cclDbString, + IsBrandedContent: isBrandedContent, }) if err != nil { if database.DatabaseErrorIs(err, sqlite3.ErrConstraintForeignKey) { @@ -168,17 +198,80 @@ func patchInformation(w http.ResponseWriter, r *http.Request) { func convertUsers(users []database.User) []Channel { response := []Channel{} for _, u := range users { + // Convert CCL array into an actual string array + var ccls = []string{} + if u.UnparsedCCLs != "" { + ccls = strings.Split(u.UnparsedCCLs, ",") + } + response = append(response, Channel{ - ID: u.ID, - UserLogin: u.UserLogin, - DisplayName: u.DisplayName, - Title: u.Title, - Language: u.Language, - CategoryID: u.CategoryID.String, - CategoryName: u.CategoryName.String, - Delay: u.Delay, - Tags: []string{"English", "CLI Tag"}, + ID: u.ID, + UserLogin: u.UserLogin, + DisplayName: u.DisplayName, + Title: u.Title, + Language: u.Language, + CategoryID: u.CategoryID.String, + CategoryName: u.CategoryName.String, + Delay: u.Delay, + Tags: []string{"English", "CLI Tag"}, + BrandedContent: u.IsBrandedContent, + + ContentClassificationLabels: ccls, }) } return response } + +func handleCCLs(u database.User, params PatchInformationEndpointRequest) (string, error) { + // Get list of already enabled CCLs + currentCCLsStrings := []string{} + if u.UnparsedCCLs != "" { + currentCCLsStrings = strings.Split(u.UnparsedCCLs, ",") + } + cclsDetailed := []PatchInformationEndpointRequestLabel{} + for _, ccl := range models.CCL_MAP { + newCCL := PatchInformationEndpointRequestLabel{ + ID: ccl.ID, + IsEnabled: false, + } + for _, s := range currentCCLsStrings { + if s == ccl.ID { + newCCL.IsEnabled = true + } + } + cclsDetailed = append(cclsDetailed, newCCL) + } + + // Run through user-provided CCLs + for _, ccl := range params.ContentClassificationLabels { + // Validate CCLs provided by the user + foundCCL, ok := models.CCL_MAP[ccl.ID] + if !ok { + return "", fmt.Errorf("ContentClassificationLabels label provided is not supported") + } + if foundCCL.RestrictedGaming { + return "", fmt.Errorf("User requested gaming CCLs to be added to their channel") + } + + // Update anything mentioned by the user + for i, updatingThisCCL := range cclsDetailed { + if updatingThisCCL.ID == ccl.ID { + updatingThisCCL.IsEnabled = ccl.IsEnabled + cclsDetailed[i] = updatingThisCCL + } + } + } + + // Convert CCL list to CSV for storage + cclDbString := "" + for _, ccl := range cclsDetailed { + if ccl.IsEnabled { + cclDbString += ccl.ID + "," + } + } + if strings.HasSuffix(cclDbString, ",") { + cclDbString = cclDbString[:len(cclDbString)-1] + } + + return cclDbString, nil +} diff --git a/internal/mock_api/endpoints/endpoints.go b/internal/mock_api/endpoints/endpoints.go index be17a503..9b479987 100644 --- a/internal/mock_api/endpoints/endpoints.go +++ b/internal/mock_api/endpoints/endpoints.go @@ -6,6 +6,7 @@ import ( "github.com/twitchdev/twitch-cli/internal/mock_api" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/bits" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/categories" + "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/ccl" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/channel_points" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/channels" "github.com/twitchdev/twitch-cli/internal/mock_api/endpoints/charity" @@ -34,6 +35,7 @@ func All() []mock_api.MockEndpoint { bits.Cheermotes{}, categories.Games{}, categories.TopGames{}, + ccl.ContentClassificationLabels{}, channel_points.Redemption{}, channel_points.Reward{}, channels.CommercialEndpoint{}, diff --git a/internal/models/ccl.go b/internal/models/ccl.go new file mode 100644 index 00000000..eea98153 --- /dev/null +++ b/internal/models/ccl.go @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package models + +type ContentClassificationLabel struct { + Description string `json:"description"` + ID string `json:"id"` + Name string `json:"name"` + RestrictedGaming bool `json:"-"` // Restricts users from applying that CCL via the API. Currently only for MatureGame. +} + +var CCL_MAP = map[string]ContentClassificationLabel{ + "DrugsIntoxication": { + Description: "Excessive tobacco glorification or promotion, any marijuana consumption/use, legal drug and alcohol induced intoxication, discussions of illegal drugs.", + ID: "DrugsIntoxication", + Name: "Drugs, Intoxication, or Excessive Tobacco Use", + RestrictedGaming: false, + }, + "Gambling": { + Description: "Participating in online or in-person gambling, poker or fantasy sports, that involve the exchange of real money.", + ID: "Gambling", + Name: "Gambling", + RestrictedGaming: false, + }, + "MatureGame": { + Description: "Games that are rated Mature or less suitable for a younger audience.", + ID: "MatureGame", + Name: "Mature-rated game", + RestrictedGaming: true, + }, + "ProfanityVulgarity": { + Description: "Prolonged, and repeated use of obscenities, profanities, and vulgarities, especially as a regular part of speech.", + ID: "ProfanityVulgarity", + Name: "Significant Profanity or Vulgarity", + RestrictedGaming: false, + }, + "SexualThemes": { + Description: "Content that focuses on sexualized physical attributes and activities, sexual topics, or experiences.", + ID: "SexualThemes", + Name: "Sexual Themes", + RestrictedGaming: false, + }, + "ViolentGraphic": { + Description: "Simulations and/or depictions of realistic violence, gore, extreme injury, or death.", + ID: "ViolentGraphic", + Name: "Violent and Graphic Depictions", + RestrictedGaming: false, + }, +} diff --git a/internal/models/stream_change.go b/internal/models/channel_update.go similarity index 82% rename from internal/models/stream_change.go rename to internal/models/channel_update.go index 0a4d532b..f504d5d5 100644 --- a/internal/models/stream_change.go +++ b/internal/models/channel_update.go @@ -10,7 +10,12 @@ type ChannelUpdateEventSubEvent struct { StreamLanguage string `json:"language"` StreamCategoryID string `json:"category_id"` StreamCategoryName string `json:"category_name"` - IsMature bool `json:"is_mature"` + + // v1 + IsMature *bool `json:"is_mature,omitempty"` + + // v2 + ContentClassificationLabels []string `json:"content_classification_labels,omitempty"` } type ChannelUpdateEventSubResponse struct {