From 1bdd1a409cc5a08aae045b9e81b0f56949bb853c Mon Sep 17 00:00:00 2001 From: OpenWaygate <27287694+OpenWaygate@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:09:31 +0800 Subject: [PATCH] :sparkles: Add abuseReport resource package and CLI subcommand --- cmd/abuseReport/BUILD.bazel | 21 +++ cmd/abuseReport/abuseReport.go | 43 ++++++ cmd/abuseReport/insert.go | 125 ++++++++++++++++ pkg/abuseReport/BUILD.bazel | 22 +++ pkg/abuseReport/abuseReport.go | 118 +++++++++++++++ pkg/abuseReport/abuseReport_test.go | 222 ++++++++++++++++++++++++++++ 6 files changed, 551 insertions(+) create mode 100644 cmd/abuseReport/BUILD.bazel create mode 100644 cmd/abuseReport/abuseReport.go create mode 100644 cmd/abuseReport/insert.go create mode 100644 pkg/abuseReport/BUILD.bazel create mode 100644 pkg/abuseReport/abuseReport.go create mode 100644 pkg/abuseReport/abuseReport_test.go diff --git a/cmd/abuseReport/BUILD.bazel b/cmd/abuseReport/BUILD.bazel new file mode 100644 index 0000000..84dd9c2 --- /dev/null +++ b/cmd/abuseReport/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "abuseReport", + srcs = [ + "abuseReport.go", + "insert.go", + ], + importpath = "github.com/eat-pray-ai/yutu/cmd/abuseReport", + visibility = ["//visibility:public"], + deps = [ + "//cmd", + "//pkg", + "//pkg/abuseReport", + "//pkg/utils", + "@com_github_eat_pray_ai_cobra_mcp//:cobra-mcp", + "@com_github_google_jsonschema_go//jsonschema", + "@com_github_modelcontextprotocol_go_sdk//mcp", + "@com_github_spf13_cobra//:cobra", + ], +) diff --git a/cmd/abuseReport/abuseReport.go b/cmd/abuseReport/abuseReport.go new file mode 100644 index 0000000..09469a3 --- /dev/null +++ b/cmd/abuseReport/abuseReport.go @@ -0,0 +1,43 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package abuseReport + +import ( + "github.com/eat-pray-ai/yutu/cmd" + "github.com/spf13/cobra" +) + +const ( + short = "Manage YouTube abuse reports" + long = "Manage YouTube abuse reports. Use this tool to report abusive content on YouTube." + abuseTypesUsage = "Abuse type IDs (e.g., spam, harassment)" + descriptionUsage = "Description of the abuse" + subjectIdUsage = "ID of the subject being reported" + subjectTypeUsage = "Type ID of the subject (e.g., video, comment, channel)" + subjectUrlUsage = "URL of the subject being reported" + relatedEntityUsage = "ID of a related entity" +) + +var ( + abuseTypes []string + description string + subjectId string + subjectTypeId string + subjectUrl string + relatedEntityId string + parts []string +) + +var abuseReportCmd = &cobra.Command{ + Use: "abuseReport", + Short: short, + Long: long, + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +func init() { + cmd.RootCmd.AddCommand(abuseReportCmd) +} diff --git a/cmd/abuseReport/insert.go b/cmd/abuseReport/insert.go new file mode 100644 index 0000000..8971a85 --- /dev/null +++ b/cmd/abuseReport/insert.go @@ -0,0 +1,125 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package abuseReport + +import ( + "encoding/json" + "io" + + cobramcp "github.com/eat-pray-ai/cobra-mcp" + "github.com/eat-pray-ai/yutu/cmd" + "github.com/eat-pray-ai/yutu/pkg" + "github.com/eat-pray-ai/yutu/pkg/abuseReport" + "github.com/eat-pray-ai/yutu/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +const ( + insertTool = "abuseReport-insert" + insertShort = "Insert an abuse report" + insertLong = "Insert an abuse report. Use this tool to report abusive content on YouTube such as spam, harassment, or violent content." + insertExample = `# Report a video as spam +yutu abuseReport insert --abuseTypes spam --subjectId VIDEO_ID --subjectTypeId video --description "This video is spam" +# Report a comment as harassment +yutu abuseReport insert --abuseTypes harassment --subjectId COMMENT_ID --subjectTypeId comment --description "Harassment in comment"` +) + +var insertInSchema = &jsonschema.Schema{ + Type: "object", + Required: []string{"abuse_types", "subject_id", "subject_type_id"}, + Properties: map[string]*jsonschema.Schema{ + "abuse_types": { + Type: "array", Description: abuseTypesUsage, + Items: &jsonschema.Schema{Type: "string"}, + }, + "description": {Type: "string", Description: descriptionUsage}, + "subject_id": {Type: "string", Description: subjectIdUsage}, + "subject_type_id": {Type: "string", Description: subjectTypeUsage}, + "subject_url": {Type: "string", Description: subjectUrlUsage}, + "related_entity_id": {Type: "string", Description: relatedEntityUsage}, + "parts": { + Type: "array", Description: "Parts to include in the response", + Items: &jsonschema.Schema{Type: "string"}, Default: json.RawMessage(`["snippet"]`), + }, + "output": { + Type: "string", Enum: []any{"json", "yaml", "silent"}, + Description: pkg.SilentUsage, Default: json.RawMessage(`"yaml"`), + }, + }, +} + +func init() { + mcp.AddTool( + cmd.Server, &mcp.Tool{ + Name: insertTool, Title: insertShort, Description: insertLong, + InputSchema: insertInSchema, Annotations: &mcp.ToolAnnotations{ + DestructiveHint: new(false), + IdempotentHint: false, + OpenWorldHint: new(true), + ReadOnlyHint: false, + }, + }, cobramcp.GenToolHandler( + insertTool, func(input abuseReport.AbuseReport, writer io.Writer) error { + return input.Insert(writer) + }, + ), + ) + abuseReportCmd.AddCommand(insertCmd) + + insertCmd.Flags().StringSliceVarP( + &abuseTypes, "abuseTypes", "a", []string{}, abuseTypesUsage, + ) + insertCmd.Flags().StringVarP( + &description, "description", "d", "", descriptionUsage, + ) + insertCmd.Flags().StringVarP(&subjectId, "subjectId", "s", "", subjectIdUsage) + insertCmd.Flags().StringVarP( + &subjectTypeId, "subjectTypeId", "t", "", subjectTypeUsage, + ) + insertCmd.Flags().StringVarP( + &subjectUrl, "subjectUrl", "u", "", subjectUrlUsage, + ) + insertCmd.Flags().StringVarP( + &relatedEntityId, "relatedEntityId", "r", "", relatedEntityUsage, + ) + insertCmd.Flags().StringSliceVarP( + &parts, "parts", "p", []string{"snippet"}, "Parts to include", + ) + insertCmd.Flags().StringP("output", "o", "", pkg.SilentUsage) + + _ = insertCmd.MarkFlagRequired("abuseTypes") + _ = insertCmd.MarkFlagRequired("subjectId") + _ = insertCmd.MarkFlagRequired("subjectTypeId") + cmd.AddMutationFlags(insertCmd) +} + +var insertCmd = &cobra.Command{ + Use: "insert", + Short: insertShort, + Long: insertLong, + Example: insertExample, + Run: func(c *cobra.Command, args []string) { + output, _ := c.Flags().GetString("output") + err := cmd.Confirm( + c, "Would report abuse for %s %s", subjectTypeId, subjectId, + ) + if err != nil { + utils.HandleCmdError(err, c) + return + } + input := abuseReport.NewAbuseReport( + abuseReport.WithAbuseTypes(abuseTypes), + abuseReport.WithDescription(description), + abuseReport.WithSubjectId(subjectId), + abuseReport.WithSubjectTypeId(subjectTypeId), + abuseReport.WithSubjectUrl(subjectUrl), + abuseReport.WithRelatedEntityId(relatedEntityId), + abuseReport.WithParts(parts), + abuseReport.WithOutput(output), + ) + utils.HandleCmdError(input.Insert(c.OutOrStdout()), c) + }, +} diff --git a/pkg/abuseReport/BUILD.bazel b/pkg/abuseReport/BUILD.bazel new file mode 100644 index 0000000..69aa267 --- /dev/null +++ b/pkg/abuseReport/BUILD.bazel @@ -0,0 +1,22 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "abuseReport", + srcs = ["abuseReport.go"], + importpath = "github.com/eat-pray-ai/yutu/pkg/abuseReport", + visibility = ["//visibility:public"], + deps = [ + "//pkg/common", + "@org_golang_google_api//youtube/v3:youtube", + ], +) + +go_test( + name = "abuseReport_test", + srcs = ["abuseReport_test.go"], + embed = [":abuseReport"], + deps = [ + "//pkg/common", + "@org_golang_google_api//youtube/v3:youtube", + ], +) diff --git a/pkg/abuseReport/abuseReport.go b/pkg/abuseReport/abuseReport.go new file mode 100644 index 0000000..42b44cb --- /dev/null +++ b/pkg/abuseReport/abuseReport.go @@ -0,0 +1,118 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package abuseReport + +import ( + "errors" + "io" + + "github.com/eat-pray-ai/yutu/pkg/common" + "google.golang.org/api/youtube/v3" +) + +var errInsertAbuseReport = errors.New("failed to insert abuse report") + +type AbuseReport struct { + common.Fields + AbuseTypes []string `yaml:"abuse_types" json:"abuse_types,omitempty"` + Description string `yaml:"description" json:"description,omitempty"` + SubjectId string `yaml:"subject_id" json:"subject_id,omitempty"` + SubjectTypeId string `yaml:"subject_type_id" json:"subject_type_id,omitempty"` + SubjectUrl string `yaml:"subject_url" json:"subject_url,omitempty"` + RelatedEntityId string `yaml:"related_entity_id" json:"related_entity_id,omitempty"` +} + +type IAbuseReport interface { + Insert(io.Writer) error +} + +type Option func(*AbuseReport) + +func NewAbuseReport(opts ...Option) IAbuseReport { + r := &AbuseReport{Fields: common.Fields{}} + for _, opt := range opts { + opt(r) + } + return r +} + +func (r *AbuseReport) Insert(writer io.Writer) error { + if err := r.EnsureService(); err != nil { + return err + } + + var abuseTypes []*youtube.AbuseType + for _, id := range r.AbuseTypes { + abuseTypes = append(abuseTypes, &youtube.AbuseType{Id: id}) + } + + report := &youtube.AbuseReport{ + AbuseTypes: abuseTypes, + Description: r.Description, + Subject: &youtube.Entity{ + Id: r.SubjectId, + TypeId: r.SubjectTypeId, + Url: r.SubjectUrl, + }, + } + + if r.RelatedEntityId != "" { + report.RelatedEntities = []*youtube.RelatedEntity{ + {Entity: &youtube.Entity{Id: r.RelatedEntityId}}, + } + } + + call := r.Service.AbuseReports.Insert(r.Parts, report) + res, err := call.Do() + if err != nil { + return errors.Join(errInsertAbuseReport, err) + } + + common.PrintResult( + r.Output, res, writer, "Abuse report submitted\n", + ) + return nil +} + +func WithAbuseTypes(abuseTypes []string) Option { + return func(r *AbuseReport) { + r.AbuseTypes = abuseTypes + } +} + +func WithDescription(description string) Option { + return func(r *AbuseReport) { + r.Description = description + } +} + +func WithSubjectId(subjectId string) Option { + return func(r *AbuseReport) { + r.SubjectId = subjectId + } +} + +func WithSubjectTypeId(subjectTypeId string) Option { + return func(r *AbuseReport) { + r.SubjectTypeId = subjectTypeId + } +} + +func WithSubjectUrl(subjectUrl string) Option { + return func(r *AbuseReport) { + r.SubjectUrl = subjectUrl + } +} + +func WithRelatedEntityId(relatedEntityId string) Option { + return func(r *AbuseReport) { + r.RelatedEntityId = relatedEntityId + } +} + +var ( + WithParts = common.WithParts[*AbuseReport] + WithOutput = common.WithOutput[*AbuseReport] + WithService = common.WithService[*AbuseReport] +) diff --git a/pkg/abuseReport/abuseReport_test.go b/pkg/abuseReport/abuseReport_test.go new file mode 100644 index 0000000..d46699c --- /dev/null +++ b/pkg/abuseReport/abuseReport_test.go @@ -0,0 +1,222 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package abuseReport + +import ( + "bytes" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/eat-pray-ai/yutu/pkg/common" + "google.golang.org/api/youtube/v3" +) + +func TestNewAbuseReport(t *testing.T) { + type args struct { + opts []Option + } + + svc := &youtube.Service{} + + tests := []struct { + name string + args args + want IAbuseReport + }{ + { + name: "with all options", + args: args{ + opts: []Option{ + WithAbuseTypes([]string{"spam", "harassment"}), + WithDescription("This video contains spam"), + WithSubjectId("video123"), + WithSubjectTypeId("video"), + WithSubjectUrl("https://youtube.com/watch?v=video123"), + WithRelatedEntityId("channel456"), + WithParts([]string{"snippet"}), + WithOutput("json"), + WithService(svc), + }, + }, + want: &AbuseReport{ + Fields: common.Fields{ + Service: svc, + Parts: []string{"snippet"}, + Output: "json", + }, + AbuseTypes: []string{"spam", "harassment"}, + Description: "This video contains spam", + SubjectId: "video123", + SubjectTypeId: "video", + SubjectUrl: "https://youtube.com/watch?v=video123", + RelatedEntityId: "channel456", + }, + }, + { + name: "with no options", + args: args{ + opts: []Option{}, + }, + want: &AbuseReport{Fields: common.Fields{}}, + }, + { + name: "with empty string values", + args: args{ + opts: []Option{ + WithDescription(""), + WithSubjectId(""), + WithSubjectTypeId(""), + }, + }, + want: &AbuseReport{ + Fields: common.Fields{}, + Description: "", + SubjectId: "", + SubjectTypeId: "", + }, + }, + { + name: "with partial options", + args: args{ + opts: []Option{ + WithAbuseTypes([]string{"violence"}), + WithSubjectId("video789"), + WithDescription("Violent content"), + }, + }, + want: &AbuseReport{ + Fields: common.Fields{}, + AbuseTypes: []string{"violence"}, + SubjectId: "video789", + Description: "Violent content", + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + if got := NewAbuseReport(tt.args.opts...); !reflect.DeepEqual( + got, tt.want, + ) { + t.Errorf("%s\nNewAbuseReport() = %v\nwant %v", tt.name, got, tt.want) + } + }, + ) + } +} + +func TestAbuseReport_Insert(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "insert abuse report", + opts: []Option{ + WithAbuseTypes([]string{"spam"}), + WithDescription("This is spam"), + WithSubjectId("video123"), + WithSubjectTypeId("video"), + WithParts([]string{"snippet"}), + }, + verify: func(r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + + defer func() { _ = r.Body.Close() }() + var body struct { + AbuseTypes []struct { + Id string `json:"id"` + } `json:"abuseTypes"` + Description string `json:"description"` + Subject struct { + Id string `json:"id"` + TypeId string `json:"typeId"` + } `json:"subject"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if len(body.AbuseTypes) != 1 || body.AbuseTypes[0].Id != "spam" { + t.Errorf("expected abuseTypes=[{id:spam}], got %v", body.AbuseTypes) + } + if body.Description != "This is spam" { + t.Errorf("expected description=This is spam, got %s", body.Description) + } + if body.Subject.Id != "video123" { + t.Errorf("expected subject.id=video123, got %s", body.Subject.Id) + } + if body.Subject.TypeId != "video" { + t.Errorf("expected subject.typeId=video, got %s", body.Subject.TypeId) + } + }, + wantErr: false, + }, + { + name: "insert abuse report with related entity", + opts: []Option{ + WithAbuseTypes([]string{"harassment"}), + WithSubjectId("comment456"), + WithSubjectTypeId("comment"), + WithRelatedEntityId("channel789"), + WithParts([]string{"snippet"}), + }, + verify: func(r *http.Request) { + defer func() { _ = r.Body.Close() }() + var body struct { + RelatedEntities []struct { + Entity struct { + Id string `json:"id"` + } `json:"entity"` + } `json:"relatedEntities"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if len(body.RelatedEntities) != 1 || body.RelatedEntities[0].Entity.Id != "channel789" { + t.Errorf( + "expected relatedEntities=[{entity:{id:channel789}}], got %v", + body.RelatedEntities, + ) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + a := NewAbuseReport(opts...) + var buf bytes.Buffer + if err := a.Insert(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "AbuseReport.Insert() error = %v, wantErr %v", err, tt.wantErr, + ) + } + }, + ) + } +}