diff --git a/apps/desktop/src-sidecar/go.mod b/apps/desktop/src-sidecar/go.mod index 5d46266..d3cb47c 100644 --- a/apps/desktop/src-sidecar/go.mod +++ b/apps/desktop/src-sidecar/go.mod @@ -6,9 +6,14 @@ require ( github.com/coder/websocket v1.8.14 github.com/rs/zerolog v1.35.0 golang.org/x/sys v0.43.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 ) require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect ) diff --git a/apps/desktop/src-sidecar/go.sum b/apps/desktop/src-sidecar/go.sum index 37fde94..0896281 100644 --- a/apps/desktop/src-sidecar/go.sum +++ b/apps/desktop/src-sidecar/go.sum @@ -1,11 +1,47 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/apps/desktop/src-sidecar/internal/control/control.go b/apps/desktop/src-sidecar/internal/control/control.go index f35c29e..e731150 100644 --- a/apps/desktop/src-sidecar/internal/control/control.go +++ b/apps/desktop/src-sidecar/internal/control/control.go @@ -1,7 +1,7 @@ package control -// Platform tag bytes prepended to each message written to the out channel. -// The Rust host reads the first byte to dispatch to the correct parser. +// Platform tag bytes prepended to each ring-buffer payload so the Rust host +// can dispatch to the correct parser without inspecting the JSON body. const ( TagTwitch byte = 0x01 TagKick byte = 0x02 @@ -54,6 +54,11 @@ type Command struct { DurationSeconds int `json:"duration_seconds,omitempty"` Reason string `json:"reason,omitempty"` MessageID string `json:"message_id,omitempty"` + + // YouTube fields + VideoID string `json:"video_id,omitempty"` + LiveChatID string `json:"live_chat_id,omitempty"` + APIKey string `json:"api_key,omitempty"` } // Message is a notification the sidecar writes to stdout for the Rust host. diff --git a/apps/desktop/src-sidecar/internal/sidecar/sidecar.go b/apps/desktop/src-sidecar/internal/sidecar/sidecar.go index 406e64a..a6be5ab 100644 --- a/apps/desktop/src-sidecar/internal/sidecar/sidecar.go +++ b/apps/desktop/src-sidecar/internal/sidecar/sidecar.go @@ -26,6 +26,7 @@ import ( "github.com/ImpulseB23/Prismoid/sidecar/internal/kick" "github.com/ImpulseB23/Prismoid/sidecar/internal/ringbuf" "github.com/ImpulseB23/Prismoid/sidecar/internal/twitch" + "github.com/ImpulseB23/Prismoid/sidecar/internal/youtube" ) const ( @@ -271,6 +272,10 @@ func DispatchCommand(ctx context.Context, cmd control.Command, clients map[strin HandleTwitchConnect(ctx, cmd, clients, out, notify, logger) case "twitch_disconnect": HandleTwitchDisconnect(cmd, clients, logger) + case "youtube_connect": + HandleYouTubeConnect(ctx, cmd, clients, out, notify, logger) + case "youtube_disconnect": + HandleYouTubeDisconnect(cmd, clients, logger) case "kick_connect": HandleKickConnect(ctx, cmd, clients, out, logger) case "kick_disconnect": @@ -505,6 +510,62 @@ func HandleTwitchDisconnect(cmd control.Command, clients map[string]context.Canc logger.Info().Str("broadcaster", cmd.BroadcasterID).Msg("twitch client disconnected") } +// HandleYouTubeConnect spawns a YouTube gRPC streamList client for the given +// live chat ID. The client writes tagged JSON payloads to `out`, which the +// writer goroutine drains into the ring buffer. +func HandleYouTubeConnect(ctx context.Context, cmd control.Command, clients map[string]context.CancelFunc, out chan<- []byte, notify twitch.Notify, logger zerolog.Logger) { + chatID := cmd.LiveChatID + if chatID == "" { + logger.Warn().Msg("youtube_connect missing live_chat_id") + return + } + + key := "yt:" + chatID + if _, exists := clients[key]; exists { + logger.Warn().Str("chat_id", chatID).Msg("already connected to youtube chat, ignoring") + return + } + + clientCtx, clientCancel := context.WithCancel(ctx) + + client := &youtube.Client{ + LiveChatID: chatID, + APIKey: cmd.APIKey, + AccessToken: cmd.Token, + Out: out, + Log: logger.With().Str("youtube_chat", chatID).Logger(), + Notify: notify, + } + + clients[key] = clientCancel + + go func() { + if err := client.Run(clientCtx); err != nil && !errors.Is(err, context.Canceled) { + logger.Error().Err(err).Str("chat_id", chatID).Msg("youtube client exited") + } + }() + + logger.Info().Str("chat_id", chatID).Msg("youtube client started") +} + +// HandleYouTubeDisconnect cancels and removes a previously-connected YouTube client. +func HandleYouTubeDisconnect(cmd control.Command, clients map[string]context.CancelFunc, logger zerolog.Logger) { + chatID := cmd.LiveChatID + if chatID == "" { + logger.Warn().Msg("youtube_disconnect missing live_chat_id") + return + } + key := "yt:" + chatID + cancelFn, exists := clients[key] + if !exists { + logger.Warn().Str("chat_id", chatID).Msg("no active youtube connection to disconnect") + return + } + cancelFn() + delete(clients, key) + logger.Info().Str("chat_id", chatID).Msg("youtube client disconnected") +} + // HandleKickConnect spawns a Kick Pusher WebSocket client for the chatroom in // cmd if there isn't already one running. Uses the chatroom ID as the client // registry key (prefixed with "kick:" to avoid collisions with Twitch IDs). diff --git a/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go b/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go index d38bccb..08d626e 100644 --- a/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go +++ b/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go @@ -1042,3 +1042,112 @@ func TestDispatchCommand_RoutesKickDisconnect(t *testing.T) { t.Fatal("expected kick_disconnect to cancel client") } } + +func TestHandleYouTubeConnect_AddsClient(t *testing.T) { + clients := make(map[string]context.CancelFunc) + out := make(chan []byte, 1) + cmd := control.Command{ + Cmd: "youtube_connect", + LiveChatID: "abc", + APIKey: "key", + } + + HandleYouTubeConnect(context.Background(), cmd, clients, out, func(string, any) {}, zerolog.Nop()) + + if _, ok := clients["yt:abc"]; !ok { + t.Fatal("expected youtube client to be registered") + } + clients["yt:abc"]() +} + +func TestHandleYouTubeConnect_RejectsMissingChatID(t *testing.T) { + clients := make(map[string]context.CancelFunc) + out := make(chan []byte, 1) + cmd := control.Command{Cmd: "youtube_connect"} + + HandleYouTubeConnect(context.Background(), cmd, clients, out, func(string, any) {}, zerolog.Nop()) + + if len(clients) != 0 { + t.Fatalf("expected no clients for missing chat_id, got %d", len(clients)) + } +} + +func TestHandleYouTubeConnect_RejectsDuplicate(t *testing.T) { + clients := make(map[string]context.CancelFunc) + out := make(chan []byte, 1) + + var cancelled atomic.Bool + clients["yt:abc"] = func() { cancelled.Store(true) } + + cmd := control.Command{Cmd: "youtube_connect", LiveChatID: "abc", APIKey: "key"} + HandleYouTubeConnect(context.Background(), cmd, clients, out, func(string, any) {}, zerolog.Nop()) + + if cancelled.Load() { + t.Fatal("existing youtube client cancel was overwritten") + } + if len(clients) != 1 { + t.Fatalf("expected 1 client, got %d", len(clients)) + } +} + +func TestHandleYouTubeDisconnect_CancelsAndRemoves(t *testing.T) { + clients := make(map[string]context.CancelFunc) + var cancelled atomic.Bool + clients["yt:abc"] = func() { cancelled.Store(true) } + + cmd := control.Command{Cmd: "youtube_disconnect", LiveChatID: "abc"} + HandleYouTubeDisconnect(cmd, clients, zerolog.Nop()) + + if !cancelled.Load() { + t.Fatal("expected youtube client to be cancelled") + } + if _, ok := clients["yt:abc"]; ok { + t.Fatal("expected youtube client to be removed from registry") + } +} + +func TestHandleYouTubeDisconnect_NoOpForUnknown(t *testing.T) { + clients := make(map[string]context.CancelFunc) + cmd := control.Command{Cmd: "youtube_disconnect", LiveChatID: "missing"} + + HandleYouTubeDisconnect(cmd, clients, zerolog.Nop()) +} + +func TestHandleYouTubeDisconnect_RejectsMissingChatID(t *testing.T) { + clients := make(map[string]context.CancelFunc) + cmd := control.Command{Cmd: "youtube_disconnect"} + + HandleYouTubeDisconnect(cmd, clients, zerolog.Nop()) + + if len(clients) != 0 { + t.Fatalf("expected no client mutations, got %d", len(clients)) + } +} + +func TestDispatchCommand_RoutesYouTubeConnect(t *testing.T) { + clients := make(map[string]context.CancelFunc) + out := make(chan []byte, 1) + cmd := control.Command{Cmd: "youtube_connect", LiveChatID: "xyz", APIKey: "k"} + + DispatchCommand(context.Background(), cmd, clients, out, func(string, any) {}, zerolog.Nop()) + + if _, ok := clients["yt:xyz"]; !ok { + t.Fatal("expected youtube_connect to register client") + } + clients["yt:xyz"]() +} + +func TestDispatchCommand_RoutesYouTubeDisconnect(t *testing.T) { + var cancelled atomic.Bool + clients := map[string]context.CancelFunc{ + "yt:xyz": func() { cancelled.Store(true) }, + } + out := make(chan []byte, 1) + cmd := control.Command{Cmd: "youtube_disconnect", LiveChatID: "xyz"} + + DispatchCommand(context.Background(), cmd, clients, out, func(string, any) {}, zerolog.Nop()) + + if !cancelled.Load() { + t.Fatal("expected youtube_disconnect to cancel client") + } +} diff --git a/apps/desktop/src-sidecar/internal/twitch/eventsub.go b/apps/desktop/src-sidecar/internal/twitch/eventsub.go index d78f91f..a8583d6 100644 --- a/apps/desktop/src-sidecar/internal/twitch/eventsub.go +++ b/apps/desktop/src-sidecar/internal/twitch/eventsub.go @@ -173,6 +173,7 @@ func (c *Client) listenLoop(ctx context.Context, conn *websocket.Conn, keepalive tagged := make([]byte, 1+len(data)) tagged[0] = control.TagTwitch copy(tagged[1:], data) + select { case c.Out <- tagged: default: diff --git a/apps/desktop/src-sidecar/internal/youtube/client.go b/apps/desktop/src-sidecar/internal/youtube/client.go new file mode 100644 index 0000000..fa9c9d0 --- /dev/null +++ b/apps/desktop/src-sidecar/internal/youtube/client.go @@ -0,0 +1,189 @@ +package youtube + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/ImpulseB23/Prismoid/sidecar/internal/backoff" + "github.com/ImpulseB23/Prismoid/sidecar/internal/control" + pb "github.com/ImpulseB23/Prismoid/sidecar/internal/youtube/ytpb" +) + +const defaultTarget = "dns:///youtube.googleapis.com:443" + +// Notify is called on control-plane events that the Rust host should know +// about (auth errors, stream ended). The caller wires this to stdout JSON. +type Notify = func(msgType string, payload any) + +// Client streams YouTube live chat messages via the gRPC streamList API and +// writes tagged JSON payloads to a shared channel. The sidecar owns the +// channel and a single writer goroutine drains it into the ring buffer. +type Client struct { + LiveChatID string + APIKey string // one of APIKey or AccessToken + AccessToken string + + Target string // override for testing; "" uses default + // DialOpts replaces the default dial options entirely when set. Tests + // inject grpc.WithTransportCredentials(insecure.NewCredentials()) here. + DialOpts []grpc.DialOption + + Out chan<- []byte + Log zerolog.Logger + Notify Notify +} + +// ErrMissingCredentials is returned when neither APIKey nor AccessToken is set. +var ErrMissingCredentials = errors.New("youtube: APIKey or AccessToken required") + +// Run connects to the YouTube gRPC streamList endpoint and reads messages +// until ctx is cancelled. Reconnects automatically with exponential backoff. +func (c *Client) Run(ctx context.Context) error { + if c.APIKey == "" && c.AccessToken == "" { + if c.Notify != nil { + c.Notify("auth_error", "youtube: missing api key or access token") + } + return ErrMissingCredentials + } + + bo := backoff.New(1*time.Second, 30*time.Second) + + var pageToken string + for { + nextToken, err := c.connectAndStream(ctx, pageToken) + if err == nil || errors.Is(err, context.Canceled) { + return err + } + + // FAILED_PRECONDITION(9) = chat disabled or ended, NOT_FOUND(5) = bad chat ID. + // Both are permanent; don't retry. + if s, ok := status.FromError(err); ok { + switch s.Code() { + case codes.FailedPrecondition, codes.NotFound: + c.Log.Warn().Str("code", s.Code().String()).Str("msg", s.Message()).Msg("permanent error, stopping") + if c.Notify != nil { + c.Notify("youtube_error", map[string]string{ + "code": s.Code().String(), + "message": s.Message(), + }) + } + return err + case codes.PermissionDenied: + c.Log.Error().Msg("permission denied, check API key / OAuth token") + if c.Notify != nil { + c.Notify("auth_error", "youtube permission denied") + } + return err + } + } + + if nextToken != "" { + pageToken = nextToken + } + + c.Log.Warn().Err(err).Msg("youtube stream disconnected, reconnecting") + + delay := bo.Next() + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } +} + +func (c *Client) target() string { + if c.Target != "" { + return c.Target + } + return defaultTarget +} + +func (c *Client) authMetadata() metadata.MD { + if c.AccessToken != "" { + return metadata.Pairs("authorization", "Bearer "+c.AccessToken) + } + return metadata.Pairs("x-goog-api-key", c.APIKey) +} + +func (c *Client) connectAndStream(ctx context.Context, pageToken string) (string, error) { + opts := []grpc.DialOption{grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))} + if len(c.DialOpts) > 0 { + opts = c.DialOpts + } + + conn, err := grpc.NewClient(c.target(), opts...) + if err != nil { + return pageToken, err + } + defer func() { _ = conn.Close() }() + + stub := pb.NewV3DataLiveChatMessageServiceClient(conn) + + req := &pb.LiveChatMessageListRequest{ + LiveChatId: &c.LiveChatID, + Part: []string{"snippet", "authorDetails"}, + } + if pageToken != "" { + req.PageToken = &pageToken + } + + md := c.authMetadata() + stream, err := stub.StreamList(metadata.NewOutgoingContext(ctx, md), req) + if err != nil { + return pageToken, err + } + + c.Log.Info().Str("chat_id", c.LiveChatID).Msg("connected to youtube streamList") + + return c.recvLoop(stream, pageToken) +} + +var marshaler = protojson.MarshalOptions{UseProtoNames: true} + +func (c *Client) recvLoop(stream pb.V3DataLiveChatMessageService_StreamListClient, lastToken string) (string, error) { + for { + resp, err := stream.Recv() + if err != nil { + return lastToken, err + } + + if t := resp.GetNextPageToken(); t != "" { + lastToken = t + } + + if resp.GetOfflineAt() != "" { + c.Log.Info().Str("offline_at", resp.GetOfflineAt()).Msg("stream went offline") + if c.Notify != nil { + c.Notify("youtube_offline", resp.GetOfflineAt()) + } + } + + for _, item := range resp.GetItems() { + js, err := marshaler.Marshal(item) + if err != nil { + c.Log.Warn().Err(err).Msg("failed to marshal chat message to json") + continue + } + + tagged := make([]byte, 1+len(js)) + tagged[0] = control.TagYouTube + copy(tagged[1:], js) + + select { + case c.Out <- tagged: + default: + c.Log.Warn().Msg("output channel full, dropping message") + } + } + } +} diff --git a/apps/desktop/src-sidecar/internal/youtube/client_test.go b/apps/desktop/src-sidecar/internal/youtube/client_test.go new file mode 100644 index 0000000..96eece8 --- /dev/null +++ b/apps/desktop/src-sidecar/internal/youtube/client_test.go @@ -0,0 +1,345 @@ +package youtube + +import ( + "context" + "errors" + "net" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + "github.com/ImpulseB23/Prismoid/sidecar/internal/control" + pb "github.com/ImpulseB23/Prismoid/sidecar/internal/youtube/ytpb" +) + +type fakeStreamListServer struct { + pb.UnimplementedV3DataLiveChatMessageServiceServer + responses []*pb.LiveChatMessageListResponse + sendErr error +} + +func (f *fakeStreamListServer) StreamList(_ *pb.LiveChatMessageListRequest, stream pb.V3DataLiveChatMessageService_StreamListServer) error { + for _, resp := range f.responses { + if err := stream.Send(resp); err != nil { + return err + } + } + if f.sendErr != nil { + return f.sendErr + } + <-stream.Context().Done() + return stream.Context().Err() +} + +func startFakeServer(t *testing.T, srvImpl pb.V3DataLiveChatMessageServiceServer) string { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := grpc.NewServer() + pb.RegisterV3DataLiveChatMessageServiceServer(srv, srvImpl) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { + srv.Stop() + _ = lis.Close() + }) + return lis.Addr().String() +} + +func newTestClient(addr string, out chan []byte) *Client { + return &Client{ + LiveChatID: "chat-123", + APIKey: "test-key", + Target: "dns:///" + addr, + DialOpts: []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, + Out: out, + Log: zerolog.Nop(), + } +} + +func TestClientReceivesTextMessages(t *testing.T) { + msgType := pb.LiveChatMessageSnippet_TypeWrapper_TEXT_MESSAGE_EVENT + publishedAt := "2024-06-15T12:30:00Z" + msgText := "hello from test" + displayName := "TestUser" + channelID := "UC_test" + + resp := &pb.LiveChatMessageListResponse{ + Items: []*pb.LiveChatMessage{ + { + Id: proto.String("msg-1"), + Snippet: &pb.LiveChatMessageSnippet{ + Type: &msgType, + PublishedAt: &publishedAt, + DisplayedContent: &pb.LiveChatMessageSnippet_TextMessageDetails{ + TextMessageDetails: &pb.LiveChatTextMessageDetails{ + MessageText: &msgText, + }, + }, + }, + AuthorDetails: &pb.LiveChatMessageAuthorDetails{ + ChannelId: &channelID, + DisplayName: &displayName, + }, + }, + }, + } + + addr := startFakeServer(t, &fakeStreamListServer{responses: []*pb.LiveChatMessageListResponse{resp}}) + + out := make(chan []byte, 16) + client := newTestClient(addr, out) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { done <- client.Run(ctx) }() + + select { + case data := <-out: + cancel() + if len(data) < 2 { + t.Fatal("message too short") + } + if data[0] != control.TagYouTube { + t.Fatalf("expected tag 0x03, got 0x%02x", data[0]) + } + body := string(data[1:]) + if !strings.Contains(body, "msg-1") { + t.Errorf("expected message id msg-1 in json: %s", body) + } + if !strings.Contains(body, "hello from test") { + t.Errorf("expected message text in json: %s", body) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for message") + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("client.Run did not exit after cancel") + } +} + +func TestClientStopsOnNotFound(t *testing.T) { + srv := &fakeStreamListServer{sendErr: status.Error(codes.NotFound, "chat not found")} + addr := startFakeServer(t, srv) + + out := make(chan []byte, 1) + client := newTestClient(addr, out) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := client.Run(ctx) + if err == nil { + t.Fatal("expected NotFound error, got nil") + } + s, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got %T: %v", err, err) + } + if s.Code() != codes.NotFound { + t.Fatalf("expected NotFound, got %s", s.Code()) + } +} + +func TestClientRequiresCredentials(t *testing.T) { + out := make(chan []byte, 1) + client := &Client{ + LiveChatID: "chat-123", + Out: out, + Log: zerolog.Nop(), + } + err := client.Run(context.Background()) + if !errors.Is(err, ErrMissingCredentials) { + t.Fatalf("expected ErrMissingCredentials, got %v", err) + } +} + +func TestClientRequiresCredentials_NotifiesAuthError(t *testing.T) { + out := make(chan []byte, 1) + var got string + client := &Client{ + LiveChatID: "chat-123", + Out: out, + Log: zerolog.Nop(), + Notify: func(msgType string, _ any) { + got = msgType + }, + } + _ = client.Run(context.Background()) + if got != "auth_error" { + t.Fatalf("expected auth_error notification, got %q", got) + } +} + +func TestClientStopsOnFailedPrecondition_NotifiesError(t *testing.T) { + srv := &fakeStreamListServer{sendErr: status.Error(codes.FailedPrecondition, "chat ended")} + addr := startFakeServer(t, srv) + + out := make(chan []byte, 1) + client := newTestClient(addr, out) + var notifyType string + client.Notify = func(msgType string, _ any) { notifyType = msgType } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := client.Run(ctx) + if err == nil { + t.Fatal("expected error") + } + if notifyType != "youtube_error" { + t.Fatalf("expected youtube_error notification, got %q", notifyType) + } +} + +func TestClientStopsOnPermissionDenied_NotifiesAuthError(t *testing.T) { + srv := &fakeStreamListServer{sendErr: status.Error(codes.PermissionDenied, "denied")} + addr := startFakeServer(t, srv) + + out := make(chan []byte, 1) + client := newTestClient(addr, out) + var notifyType string + client.Notify = func(msgType string, _ any) { notifyType = msgType } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := client.Run(ctx) + if err == nil { + t.Fatal("expected error") + } + if notifyType != "auth_error" { + t.Fatalf("expected auth_error notification, got %q", notifyType) + } +} + +func TestClientNotifiesOnOffline(t *testing.T) { + offlineAt := "2024-06-15T13:00:00Z" + resp := &pb.LiveChatMessageListResponse{ + OfflineAt: &offlineAt, + } + addr := startFakeServer(t, &fakeStreamListServer{responses: []*pb.LiveChatMessageListResponse{resp}}) + + out := make(chan []byte, 1) + client := newTestClient(addr, out) + + notified := make(chan string, 1) + client.Notify = func(msgType string, _ any) { + if msgType == "youtube_offline" { + select { + case notified <- msgType: + default: + } + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go func() { _ = client.Run(ctx) }() + + select { + case <-notified: + case <-ctx.Done(): + t.Fatal("timed out waiting for offline notification") + } +} + +func TestClientUsesAccessTokenAuth(t *testing.T) { + msgType := pb.LiveChatMessageSnippet_TypeWrapper_TEXT_MESSAGE_EVENT + publishedAt := "2024-06-15T12:30:00Z" + resp := &pb.LiveChatMessageListResponse{ + Items: []*pb.LiveChatMessage{{ + Id: proto.String("msg-tok"), + Snippet: &pb.LiveChatMessageSnippet{ + Type: &msgType, + PublishedAt: &publishedAt, + DisplayedContent: &pb.LiveChatMessageSnippet_TextMessageDetails{ + TextMessageDetails: &pb.LiveChatTextMessageDetails{ + MessageText: proto.String("ok"), + }, + }, + }, + AuthorDetails: &pb.LiveChatMessageAuthorDetails{ + ChannelId: proto.String("UC_x"), + DisplayName: proto.String("X"), + }, + }}, + } + addr := startFakeServer(t, &fakeStreamListServer{responses: []*pb.LiveChatMessageListResponse{resp}}) + + out := make(chan []byte, 1) + client := &Client{ + LiveChatID: "chat-123", + AccessToken: "oauth-token", + Target: "dns:///" + addr, + DialOpts: []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, + Out: out, + Log: zerolog.Nop(), + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + go func() { _ = client.Run(ctx) }() + + select { + case <-out: + case <-ctx.Done(): + t.Fatal("timed out waiting for message via access token auth") + } +} + +func TestClientDropsMessagesWhenChannelFull(t *testing.T) { + msgType := pb.LiveChatMessageSnippet_TypeWrapper_TEXT_MESSAGE_EVENT + publishedAt := "2024-06-15T12:30:00Z" + mkMsg := func(id string) *pb.LiveChatMessage { + return &pb.LiveChatMessage{ + Id: proto.String(id), + Snippet: &pb.LiveChatMessageSnippet{ + Type: &msgType, + PublishedAt: &publishedAt, + DisplayedContent: &pb.LiveChatMessageSnippet_TextMessageDetails{ + TextMessageDetails: &pb.LiveChatTextMessageDetails{MessageText: proto.String("x")}, + }, + }, + AuthorDetails: &pb.LiveChatMessageAuthorDetails{ + ChannelId: proto.String("UC"), DisplayName: proto.String("U"), + }, + } + } + nextToken := "page-2" + resp := &pb.LiveChatMessageListResponse{ + NextPageToken: &nextToken, + Items: []*pb.LiveChatMessage{mkMsg("a"), mkMsg("b"), mkMsg("c")}, + } + addr := startFakeServer(t, &fakeStreamListServer{responses: []*pb.LiveChatMessageListResponse{resp}}) + + out := make(chan []byte, 1) + client := newTestClient(addr, out) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { _ = client.Run(ctx); close(done) }() + + select { + case <-out: + case <-ctx.Done(): + t.Fatal("expected at least one message") + } + cancel() + <-done +} diff --git a/apps/desktop/src-sidecar/internal/youtube/ytpb/stream_list.pb.go b/apps/desktop/src-sidecar/internal/youtube/ytpb/stream_list.pb.go new file mode 100644 index 0000000..524b46f --- /dev/null +++ b/apps/desktop/src-sidecar/internal/youtube/ytpb/stream_list.pb.go @@ -0,0 +1,2370 @@ +// YouTube Live Chat gRPC streaming API. +// Source: https://developers.google.com/youtube/v3/live/streaming-live-chat +// Licensed under Apache 2.0. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.4 +// source: stream_list.proto + +package ytpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type LiveChatMessageSnippet_TypeWrapper_Type int32 + +const ( + LiveChatMessageSnippet_TypeWrapper_INVALID_TYPE LiveChatMessageSnippet_TypeWrapper_Type = 0 + LiveChatMessageSnippet_TypeWrapper_TEXT_MESSAGE_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 1 + LiveChatMessageSnippet_TypeWrapper_TOMBSTONE LiveChatMessageSnippet_TypeWrapper_Type = 2 + LiveChatMessageSnippet_TypeWrapper_FAN_FUNDING_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 3 + LiveChatMessageSnippet_TypeWrapper_CHAT_ENDED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 4 + LiveChatMessageSnippet_TypeWrapper_SPONSOR_ONLY_MODE_STARTED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 5 + LiveChatMessageSnippet_TypeWrapper_SPONSOR_ONLY_MODE_ENDED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 6 + LiveChatMessageSnippet_TypeWrapper_NEW_SPONSOR_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 7 + LiveChatMessageSnippet_TypeWrapper_MEMBER_MILESTONE_CHAT_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 17 + LiveChatMessageSnippet_TypeWrapper_MEMBERSHIP_GIFTING_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 18 + LiveChatMessageSnippet_TypeWrapper_GIFT_MEMBERSHIP_RECEIVED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 19 + LiveChatMessageSnippet_TypeWrapper_MESSAGE_DELETED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 8 + LiveChatMessageSnippet_TypeWrapper_MESSAGE_RETRACTED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 9 + LiveChatMessageSnippet_TypeWrapper_USER_BANNED_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 10 + LiveChatMessageSnippet_TypeWrapper_SUPER_CHAT_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 15 + LiveChatMessageSnippet_TypeWrapper_SUPER_STICKER_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 16 + LiveChatMessageSnippet_TypeWrapper_POLL_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 20 + LiveChatMessageSnippet_TypeWrapper_GIFT_EVENT LiveChatMessageSnippet_TypeWrapper_Type = 21 +) + +// Enum value maps for LiveChatMessageSnippet_TypeWrapper_Type. +var ( + LiveChatMessageSnippet_TypeWrapper_Type_name = map[int32]string{ + 0: "INVALID_TYPE", + 1: "TEXT_MESSAGE_EVENT", + 2: "TOMBSTONE", + 3: "FAN_FUNDING_EVENT", + 4: "CHAT_ENDED_EVENT", + 5: "SPONSOR_ONLY_MODE_STARTED_EVENT", + 6: "SPONSOR_ONLY_MODE_ENDED_EVENT", + 7: "NEW_SPONSOR_EVENT", + 17: "MEMBER_MILESTONE_CHAT_EVENT", + 18: "MEMBERSHIP_GIFTING_EVENT", + 19: "GIFT_MEMBERSHIP_RECEIVED_EVENT", + 8: "MESSAGE_DELETED_EVENT", + 9: "MESSAGE_RETRACTED_EVENT", + 10: "USER_BANNED_EVENT", + 15: "SUPER_CHAT_EVENT", + 16: "SUPER_STICKER_EVENT", + 20: "POLL_EVENT", + 21: "GIFT_EVENT", + } + LiveChatMessageSnippet_TypeWrapper_Type_value = map[string]int32{ + "INVALID_TYPE": 0, + "TEXT_MESSAGE_EVENT": 1, + "TOMBSTONE": 2, + "FAN_FUNDING_EVENT": 3, + "CHAT_ENDED_EVENT": 4, + "SPONSOR_ONLY_MODE_STARTED_EVENT": 5, + "SPONSOR_ONLY_MODE_ENDED_EVENT": 6, + "NEW_SPONSOR_EVENT": 7, + "MEMBER_MILESTONE_CHAT_EVENT": 17, + "MEMBERSHIP_GIFTING_EVENT": 18, + "GIFT_MEMBERSHIP_RECEIVED_EVENT": 19, + "MESSAGE_DELETED_EVENT": 8, + "MESSAGE_RETRACTED_EVENT": 9, + "USER_BANNED_EVENT": 10, + "SUPER_CHAT_EVENT": 15, + "SUPER_STICKER_EVENT": 16, + "POLL_EVENT": 20, + "GIFT_EVENT": 21, + } +) + +func (x LiveChatMessageSnippet_TypeWrapper_Type) Enum() *LiveChatMessageSnippet_TypeWrapper_Type { + p := new(LiveChatMessageSnippet_TypeWrapper_Type) + *p = x + return p +} + +func (x LiveChatMessageSnippet_TypeWrapper_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (LiveChatMessageSnippet_TypeWrapper_Type) Descriptor() protoreflect.EnumDescriptor { + return file_stream_list_proto_enumTypes[0].Descriptor() +} + +func (LiveChatMessageSnippet_TypeWrapper_Type) Type() protoreflect.EnumType { + return &file_stream_list_proto_enumTypes[0] +} + +func (x LiveChatMessageSnippet_TypeWrapper_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Do not use. +func (x *LiveChatMessageSnippet_TypeWrapper_Type) UnmarshalJSON(b []byte) error { + num, err := protoimpl.X.UnmarshalJSONEnum(x.Descriptor(), b) + if err != nil { + return err + } + *x = LiveChatMessageSnippet_TypeWrapper_Type(num) + return nil +} + +// Deprecated: Use LiveChatMessageSnippet_TypeWrapper_Type.Descriptor instead. +func (LiveChatMessageSnippet_TypeWrapper_Type) EnumDescriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{4, 0, 0} +} + +type LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType int32 + +const ( + LiveChatUserBannedMessageDetails_BanTypeWrapper_PERMANENT LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType = 1 + LiveChatUserBannedMessageDetails_BanTypeWrapper_TEMPORARY LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType = 2 +) + +// Enum value maps for LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType. +var ( + LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType_name = map[int32]string{ + 1: "PERMANENT", + 2: "TEMPORARY", + } + LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType_value = map[string]int32{ + "PERMANENT": 1, + "TEMPORARY": 2, + } +) + +func (x LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) Enum() *LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType { + p := new(LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) + *p = x + return p +} + +func (x LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) Descriptor() protoreflect.EnumDescriptor { + return file_stream_list_proto_enumTypes[1].Descriptor() +} + +func (LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) Type() protoreflect.EnumType { + return &file_stream_list_proto_enumTypes[1] +} + +func (x LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Do not use. +func (x *LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) UnmarshalJSON(b []byte) error { + num, err := protoimpl.X.UnmarshalJSONEnum(x.Descriptor(), b) + if err != nil { + return err + } + *x = LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType(num) + return nil +} + +// Deprecated: Use LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType.Descriptor instead. +func (LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType) EnumDescriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{8, 0, 0} +} + +type LiveChatPollDetails_PollStatusWrapper_PollStatus int32 + +const ( + LiveChatPollDetails_PollStatusWrapper_UNKNOWN LiveChatPollDetails_PollStatusWrapper_PollStatus = 0 + LiveChatPollDetails_PollStatusWrapper_ACTIVE LiveChatPollDetails_PollStatusWrapper_PollStatus = 1 + LiveChatPollDetails_PollStatusWrapper_CLOSED LiveChatPollDetails_PollStatusWrapper_PollStatus = 2 +) + +// Enum value maps for LiveChatPollDetails_PollStatusWrapper_PollStatus. +var ( + LiveChatPollDetails_PollStatusWrapper_PollStatus_name = map[int32]string{ + 0: "UNKNOWN", + 1: "ACTIVE", + 2: "CLOSED", + } + LiveChatPollDetails_PollStatusWrapper_PollStatus_value = map[string]int32{ + "UNKNOWN": 0, + "ACTIVE": 1, + "CLOSED": 2, + } +) + +func (x LiveChatPollDetails_PollStatusWrapper_PollStatus) Enum() *LiveChatPollDetails_PollStatusWrapper_PollStatus { + p := new(LiveChatPollDetails_PollStatusWrapper_PollStatus) + *p = x + return p +} + +func (x LiveChatPollDetails_PollStatusWrapper_PollStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (LiveChatPollDetails_PollStatusWrapper_PollStatus) Descriptor() protoreflect.EnumDescriptor { + return file_stream_list_proto_enumTypes[2].Descriptor() +} + +func (LiveChatPollDetails_PollStatusWrapper_PollStatus) Type() protoreflect.EnumType { + return &file_stream_list_proto_enumTypes[2] +} + +func (x LiveChatPollDetails_PollStatusWrapper_PollStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Do not use. +func (x *LiveChatPollDetails_PollStatusWrapper_PollStatus) UnmarshalJSON(b []byte) error { + num, err := protoimpl.X.UnmarshalJSONEnum(x.Descriptor(), b) + if err != nil { + return err + } + *x = LiveChatPollDetails_PollStatusWrapper_PollStatus(num) + return nil +} + +// Deprecated: Use LiveChatPollDetails_PollStatusWrapper_PollStatus.Descriptor instead. +func (LiveChatPollDetails_PollStatusWrapper_PollStatus) EnumDescriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{16, 1, 0} +} + +type LiveChatMessageListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + LiveChatId *string `protobuf:"bytes,1,opt,name=live_chat_id,json=liveChatId" json:"live_chat_id,omitempty"` + Hl *string `protobuf:"bytes,2,opt,name=hl" json:"hl,omitempty"` + ProfileImageSize *uint32 `protobuf:"varint,3,opt,name=profile_image_size,json=profileImageSize" json:"profile_image_size,omitempty"` + MaxResults *uint32 `protobuf:"varint,98,opt,name=max_results,json=maxResults" json:"max_results,omitempty"` + PageToken *string `protobuf:"bytes,99,opt,name=page_token,json=pageToken" json:"page_token,omitempty"` + Part []string `protobuf:"bytes,100,rep,name=part" json:"part,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageListRequest) Reset() { + *x = LiveChatMessageListRequest{} + mi := &file_stream_list_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageListRequest) ProtoMessage() {} + +func (x *LiveChatMessageListRequest) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageListRequest.ProtoReflect.Descriptor instead. +func (*LiveChatMessageListRequest) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{0} +} + +func (x *LiveChatMessageListRequest) GetLiveChatId() string { + if x != nil && x.LiveChatId != nil { + return *x.LiveChatId + } + return "" +} + +func (x *LiveChatMessageListRequest) GetHl() string { + if x != nil && x.Hl != nil { + return *x.Hl + } + return "" +} + +func (x *LiveChatMessageListRequest) GetProfileImageSize() uint32 { + if x != nil && x.ProfileImageSize != nil { + return *x.ProfileImageSize + } + return 0 +} + +func (x *LiveChatMessageListRequest) GetMaxResults() uint32 { + if x != nil && x.MaxResults != nil { + return *x.MaxResults + } + return 0 +} + +func (x *LiveChatMessageListRequest) GetPageToken() string { + if x != nil && x.PageToken != nil { + return *x.PageToken + } + return "" +} + +func (x *LiveChatMessageListRequest) GetPart() []string { + if x != nil { + return x.Part + } + return nil +} + +type LiveChatMessageListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind *string `protobuf:"bytes,200,opt,name=kind" json:"kind,omitempty"` + Etag *string `protobuf:"bytes,201,opt,name=etag" json:"etag,omitempty"` + OfflineAt *string `protobuf:"bytes,2,opt,name=offline_at,json=offlineAt" json:"offline_at,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,1004,opt,name=page_info,json=pageInfo" json:"page_info,omitempty"` + NextPageToken *string `protobuf:"bytes,100602,opt,name=next_page_token,json=nextPageToken" json:"next_page_token,omitempty"` + Items []*LiveChatMessage `protobuf:"bytes,1007,rep,name=items" json:"items,omitempty"` + ActivePollItem *LiveChatMessage `protobuf:"bytes,1008,opt,name=active_poll_item,json=activePollItem" json:"active_poll_item,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageListResponse) Reset() { + *x = LiveChatMessageListResponse{} + mi := &file_stream_list_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageListResponse) ProtoMessage() {} + +func (x *LiveChatMessageListResponse) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageListResponse.ProtoReflect.Descriptor instead. +func (*LiveChatMessageListResponse) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{1} +} + +func (x *LiveChatMessageListResponse) GetKind() string { + if x != nil && x.Kind != nil { + return *x.Kind + } + return "" +} + +func (x *LiveChatMessageListResponse) GetEtag() string { + if x != nil && x.Etag != nil { + return *x.Etag + } + return "" +} + +func (x *LiveChatMessageListResponse) GetOfflineAt() string { + if x != nil && x.OfflineAt != nil { + return *x.OfflineAt + } + return "" +} + +func (x *LiveChatMessageListResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +func (x *LiveChatMessageListResponse) GetNextPageToken() string { + if x != nil && x.NextPageToken != nil { + return *x.NextPageToken + } + return "" +} + +func (x *LiveChatMessageListResponse) GetItems() []*LiveChatMessage { + if x != nil { + return x.Items + } + return nil +} + +func (x *LiveChatMessageListResponse) GetActivePollItem() *LiveChatMessage { + if x != nil { + return x.ActivePollItem + } + return nil +} + +type LiveChatMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind *string `protobuf:"bytes,200,opt,name=kind" json:"kind,omitempty"` + Etag *string `protobuf:"bytes,201,opt,name=etag" json:"etag,omitempty"` + Id *string `protobuf:"bytes,101,opt,name=id" json:"id,omitempty"` + Snippet *LiveChatMessageSnippet `protobuf:"bytes,2,opt,name=snippet" json:"snippet,omitempty"` + AuthorDetails *LiveChatMessageAuthorDetails `protobuf:"bytes,3,opt,name=author_details,json=authorDetails" json:"author_details,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessage) Reset() { + *x = LiveChatMessage{} + mi := &file_stream_list_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessage) ProtoMessage() {} + +func (x *LiveChatMessage) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessage.ProtoReflect.Descriptor instead. +func (*LiveChatMessage) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{2} +} + +func (x *LiveChatMessage) GetKind() string { + if x != nil && x.Kind != nil { + return *x.Kind + } + return "" +} + +func (x *LiveChatMessage) GetEtag() string { + if x != nil && x.Etag != nil { + return *x.Etag + } + return "" +} + +func (x *LiveChatMessage) GetId() string { + if x != nil && x.Id != nil { + return *x.Id + } + return "" +} + +func (x *LiveChatMessage) GetSnippet() *LiveChatMessageSnippet { + if x != nil { + return x.Snippet + } + return nil +} + +func (x *LiveChatMessage) GetAuthorDetails() *LiveChatMessageAuthorDetails { + if x != nil { + return x.AuthorDetails + } + return nil +} + +type LiveChatMessageAuthorDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + ChannelId *string `protobuf:"bytes,10101,opt,name=channel_id,json=channelId" json:"channel_id,omitempty"` + ChannelUrl *string `protobuf:"bytes,102,opt,name=channel_url,json=channelUrl" json:"channel_url,omitempty"` + DisplayName *string `protobuf:"bytes,103,opt,name=display_name,json=displayName" json:"display_name,omitempty"` + ProfileImageUrl *string `protobuf:"bytes,104,opt,name=profile_image_url,json=profileImageUrl" json:"profile_image_url,omitempty"` + IsVerified *bool `protobuf:"varint,4,opt,name=is_verified,json=isVerified" json:"is_verified,omitempty"` + IsChatOwner *bool `protobuf:"varint,5,opt,name=is_chat_owner,json=isChatOwner" json:"is_chat_owner,omitempty"` + IsChatSponsor *bool `protobuf:"varint,6,opt,name=is_chat_sponsor,json=isChatSponsor" json:"is_chat_sponsor,omitempty"` + IsChatModerator *bool `protobuf:"varint,7,opt,name=is_chat_moderator,json=isChatModerator" json:"is_chat_moderator,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageAuthorDetails) Reset() { + *x = LiveChatMessageAuthorDetails{} + mi := &file_stream_list_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageAuthorDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageAuthorDetails) ProtoMessage() {} + +func (x *LiveChatMessageAuthorDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageAuthorDetails.ProtoReflect.Descriptor instead. +func (*LiveChatMessageAuthorDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{3} +} + +func (x *LiveChatMessageAuthorDetails) GetChannelId() string { + if x != nil && x.ChannelId != nil { + return *x.ChannelId + } + return "" +} + +func (x *LiveChatMessageAuthorDetails) GetChannelUrl() string { + if x != nil && x.ChannelUrl != nil { + return *x.ChannelUrl + } + return "" +} + +func (x *LiveChatMessageAuthorDetails) GetDisplayName() string { + if x != nil && x.DisplayName != nil { + return *x.DisplayName + } + return "" +} + +func (x *LiveChatMessageAuthorDetails) GetProfileImageUrl() string { + if x != nil && x.ProfileImageUrl != nil { + return *x.ProfileImageUrl + } + return "" +} + +func (x *LiveChatMessageAuthorDetails) GetIsVerified() bool { + if x != nil && x.IsVerified != nil { + return *x.IsVerified + } + return false +} + +func (x *LiveChatMessageAuthorDetails) GetIsChatOwner() bool { + if x != nil && x.IsChatOwner != nil { + return *x.IsChatOwner + } + return false +} + +func (x *LiveChatMessageAuthorDetails) GetIsChatSponsor() bool { + if x != nil && x.IsChatSponsor != nil { + return *x.IsChatSponsor + } + return false +} + +func (x *LiveChatMessageAuthorDetails) GetIsChatModerator() bool { + if x != nil && x.IsChatModerator != nil { + return *x.IsChatModerator + } + return false +} + +type LiveChatMessageSnippet struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type *LiveChatMessageSnippet_TypeWrapper_Type `protobuf:"varint,1,opt,name=type,enum=youtube.api.v3.LiveChatMessageSnippet_TypeWrapper_Type" json:"type,omitempty"` + LiveChatId *string `protobuf:"bytes,201,opt,name=live_chat_id,json=liveChatId" json:"live_chat_id,omitempty"` + AuthorChannelId *string `protobuf:"bytes,301,opt,name=author_channel_id,json=authorChannelId" json:"author_channel_id,omitempty"` + PublishedAt *string `protobuf:"bytes,4,opt,name=published_at,json=publishedAt" json:"published_at,omitempty"` + HasDisplayContent *bool `protobuf:"varint,17,opt,name=has_display_content,json=hasDisplayContent" json:"has_display_content,omitempty"` + DisplayMessage *string `protobuf:"bytes,16,opt,name=display_message,json=displayMessage" json:"display_message,omitempty"` + // Types that are valid to be assigned to DisplayedContent: + // + // *LiveChatMessageSnippet_TextMessageDetails + // *LiveChatMessageSnippet_MessageDeletedDetails + // *LiveChatMessageSnippet_MessageRetractedDetails + // *LiveChatMessageSnippet_UserBannedDetails + // *LiveChatMessageSnippet_SuperChatDetails + // *LiveChatMessageSnippet_SuperStickerDetails + // *LiveChatMessageSnippet_NewSponsorDetails + // *LiveChatMessageSnippet_MemberMilestoneChatDetails + // *LiveChatMessageSnippet_MembershipGiftingDetails + // *LiveChatMessageSnippet_GiftMembershipReceivedDetails + // *LiveChatMessageSnippet_PollDetails + // *LiveChatMessageSnippet_GiftDetails + DisplayedContent isLiveChatMessageSnippet_DisplayedContent `protobuf_oneof:"displayed_content"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageSnippet) Reset() { + *x = LiveChatMessageSnippet{} + mi := &file_stream_list_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageSnippet) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageSnippet) ProtoMessage() {} + +func (x *LiveChatMessageSnippet) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageSnippet.ProtoReflect.Descriptor instead. +func (*LiveChatMessageSnippet) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{4} +} + +func (x *LiveChatMessageSnippet) GetType() LiveChatMessageSnippet_TypeWrapper_Type { + if x != nil && x.Type != nil { + return *x.Type + } + return LiveChatMessageSnippet_TypeWrapper_INVALID_TYPE +} + +func (x *LiveChatMessageSnippet) GetLiveChatId() string { + if x != nil && x.LiveChatId != nil { + return *x.LiveChatId + } + return "" +} + +func (x *LiveChatMessageSnippet) GetAuthorChannelId() string { + if x != nil && x.AuthorChannelId != nil { + return *x.AuthorChannelId + } + return "" +} + +func (x *LiveChatMessageSnippet) GetPublishedAt() string { + if x != nil && x.PublishedAt != nil { + return *x.PublishedAt + } + return "" +} + +func (x *LiveChatMessageSnippet) GetHasDisplayContent() bool { + if x != nil && x.HasDisplayContent != nil { + return *x.HasDisplayContent + } + return false +} + +func (x *LiveChatMessageSnippet) GetDisplayMessage() string { + if x != nil && x.DisplayMessage != nil { + return *x.DisplayMessage + } + return "" +} + +func (x *LiveChatMessageSnippet) GetDisplayedContent() isLiveChatMessageSnippet_DisplayedContent { + if x != nil { + return x.DisplayedContent + } + return nil +} + +func (x *LiveChatMessageSnippet) GetTextMessageDetails() *LiveChatTextMessageDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_TextMessageDetails); ok { + return x.TextMessageDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetMessageDeletedDetails() *LiveChatMessageDeletedDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_MessageDeletedDetails); ok { + return x.MessageDeletedDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetMessageRetractedDetails() *LiveChatMessageRetractedDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_MessageRetractedDetails); ok { + return x.MessageRetractedDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetUserBannedDetails() *LiveChatUserBannedMessageDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_UserBannedDetails); ok { + return x.UserBannedDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetSuperChatDetails() *LiveChatSuperChatDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_SuperChatDetails); ok { + return x.SuperChatDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetSuperStickerDetails() *LiveChatSuperStickerDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_SuperStickerDetails); ok { + return x.SuperStickerDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetNewSponsorDetails() *LiveChatNewSponsorDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_NewSponsorDetails); ok { + return x.NewSponsorDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetMemberMilestoneChatDetails() *LiveChatMemberMilestoneChatDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_MemberMilestoneChatDetails); ok { + return x.MemberMilestoneChatDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetMembershipGiftingDetails() *LiveChatMembershipGiftingDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_MembershipGiftingDetails); ok { + return x.MembershipGiftingDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetGiftMembershipReceivedDetails() *LiveChatGiftMembershipReceivedDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_GiftMembershipReceivedDetails); ok { + return x.GiftMembershipReceivedDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetPollDetails() *LiveChatPollDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_PollDetails); ok { + return x.PollDetails + } + } + return nil +} + +func (x *LiveChatMessageSnippet) GetGiftDetails() *LiveChatGiftDetails { + if x != nil { + if x, ok := x.DisplayedContent.(*LiveChatMessageSnippet_GiftDetails); ok { + return x.GiftDetails + } + } + return nil +} + +type isLiveChatMessageSnippet_DisplayedContent interface { + isLiveChatMessageSnippet_DisplayedContent() +} + +type LiveChatMessageSnippet_TextMessageDetails struct { + TextMessageDetails *LiveChatTextMessageDetails `protobuf:"bytes,19,opt,name=text_message_details,json=textMessageDetails,oneof"` +} + +type LiveChatMessageSnippet_MessageDeletedDetails struct { + MessageDeletedDetails *LiveChatMessageDeletedDetails `protobuf:"bytes,20,opt,name=message_deleted_details,json=messageDeletedDetails,oneof"` +} + +type LiveChatMessageSnippet_MessageRetractedDetails struct { + MessageRetractedDetails *LiveChatMessageRetractedDetails `protobuf:"bytes,21,opt,name=message_retracted_details,json=messageRetractedDetails,oneof"` +} + +type LiveChatMessageSnippet_UserBannedDetails struct { + UserBannedDetails *LiveChatUserBannedMessageDetails `protobuf:"bytes,22,opt,name=user_banned_details,json=userBannedDetails,oneof"` +} + +type LiveChatMessageSnippet_SuperChatDetails struct { + SuperChatDetails *LiveChatSuperChatDetails `protobuf:"bytes,27,opt,name=super_chat_details,json=superChatDetails,oneof"` +} + +type LiveChatMessageSnippet_SuperStickerDetails struct { + SuperStickerDetails *LiveChatSuperStickerDetails `protobuf:"bytes,28,opt,name=super_sticker_details,json=superStickerDetails,oneof"` +} + +type LiveChatMessageSnippet_NewSponsorDetails struct { + NewSponsorDetails *LiveChatNewSponsorDetails `protobuf:"bytes,29,opt,name=new_sponsor_details,json=newSponsorDetails,oneof"` +} + +type LiveChatMessageSnippet_MemberMilestoneChatDetails struct { + MemberMilestoneChatDetails *LiveChatMemberMilestoneChatDetails `protobuf:"bytes,30,opt,name=member_milestone_chat_details,json=memberMilestoneChatDetails,oneof"` +} + +type LiveChatMessageSnippet_MembershipGiftingDetails struct { + MembershipGiftingDetails *LiveChatMembershipGiftingDetails `protobuf:"bytes,31,opt,name=membership_gifting_details,json=membershipGiftingDetails,oneof"` +} + +type LiveChatMessageSnippet_GiftMembershipReceivedDetails struct { + GiftMembershipReceivedDetails *LiveChatGiftMembershipReceivedDetails `protobuf:"bytes,32,opt,name=gift_membership_received_details,json=giftMembershipReceivedDetails,oneof"` +} + +type LiveChatMessageSnippet_PollDetails struct { + PollDetails *LiveChatPollDetails `protobuf:"bytes,33,opt,name=poll_details,json=pollDetails,oneof"` +} + +type LiveChatMessageSnippet_GiftDetails struct { + GiftDetails *LiveChatGiftDetails `protobuf:"bytes,34,opt,name=gift_details,json=giftDetails,oneof"` +} + +func (*LiveChatMessageSnippet_TextMessageDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_MessageDeletedDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_MessageRetractedDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_UserBannedDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_SuperChatDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_SuperStickerDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_NewSponsorDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_MemberMilestoneChatDetails) isLiveChatMessageSnippet_DisplayedContent() { +} + +func (*LiveChatMessageSnippet_MembershipGiftingDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_GiftMembershipReceivedDetails) isLiveChatMessageSnippet_DisplayedContent() { +} + +func (*LiveChatMessageSnippet_PollDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +func (*LiveChatMessageSnippet_GiftDetails) isLiveChatMessageSnippet_DisplayedContent() {} + +type LiveChatTextMessageDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + MessageText *string `protobuf:"bytes,1,opt,name=message_text,json=messageText" json:"message_text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatTextMessageDetails) Reset() { + *x = LiveChatTextMessageDetails{} + mi := &file_stream_list_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatTextMessageDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatTextMessageDetails) ProtoMessage() {} + +func (x *LiveChatTextMessageDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatTextMessageDetails.ProtoReflect.Descriptor instead. +func (*LiveChatTextMessageDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{5} +} + +func (x *LiveChatTextMessageDetails) GetMessageText() string { + if x != nil && x.MessageText != nil { + return *x.MessageText + } + return "" +} + +type LiveChatMessageDeletedDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeletedMessageId *string `protobuf:"bytes,101,opt,name=deleted_message_id,json=deletedMessageId" json:"deleted_message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageDeletedDetails) Reset() { + *x = LiveChatMessageDeletedDetails{} + mi := &file_stream_list_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageDeletedDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageDeletedDetails) ProtoMessage() {} + +func (x *LiveChatMessageDeletedDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageDeletedDetails.ProtoReflect.Descriptor instead. +func (*LiveChatMessageDeletedDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{6} +} + +func (x *LiveChatMessageDeletedDetails) GetDeletedMessageId() string { + if x != nil && x.DeletedMessageId != nil { + return *x.DeletedMessageId + } + return "" +} + +type LiveChatMessageRetractedDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + RetractedMessageId *string `protobuf:"bytes,201,opt,name=retracted_message_id,json=retractedMessageId" json:"retracted_message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageRetractedDetails) Reset() { + *x = LiveChatMessageRetractedDetails{} + mi := &file_stream_list_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageRetractedDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageRetractedDetails) ProtoMessage() {} + +func (x *LiveChatMessageRetractedDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageRetractedDetails.ProtoReflect.Descriptor instead. +func (*LiveChatMessageRetractedDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{7} +} + +func (x *LiveChatMessageRetractedDetails) GetRetractedMessageId() string { + if x != nil && x.RetractedMessageId != nil { + return *x.RetractedMessageId + } + return "" +} + +type LiveChatUserBannedMessageDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + BannedUserDetails *ChannelProfileDetails `protobuf:"bytes,1,opt,name=banned_user_details,json=bannedUserDetails" json:"banned_user_details,omitempty"` + BanType *LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType `protobuf:"varint,2,opt,name=ban_type,json=banType,enum=youtube.api.v3.LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType" json:"ban_type,omitempty"` + BanDurationSeconds *uint64 `protobuf:"varint,4,opt,name=ban_duration_seconds,json=banDurationSeconds" json:"ban_duration_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatUserBannedMessageDetails) Reset() { + *x = LiveChatUserBannedMessageDetails{} + mi := &file_stream_list_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatUserBannedMessageDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatUserBannedMessageDetails) ProtoMessage() {} + +func (x *LiveChatUserBannedMessageDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatUserBannedMessageDetails.ProtoReflect.Descriptor instead. +func (*LiveChatUserBannedMessageDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{8} +} + +func (x *LiveChatUserBannedMessageDetails) GetBannedUserDetails() *ChannelProfileDetails { + if x != nil { + return x.BannedUserDetails + } + return nil +} + +func (x *LiveChatUserBannedMessageDetails) GetBanType() LiveChatUserBannedMessageDetails_BanTypeWrapper_BanType { + if x != nil && x.BanType != nil { + return *x.BanType + } + return LiveChatUserBannedMessageDetails_BanTypeWrapper_PERMANENT +} + +func (x *LiveChatUserBannedMessageDetails) GetBanDurationSeconds() uint64 { + if x != nil && x.BanDurationSeconds != nil { + return *x.BanDurationSeconds + } + return 0 +} + +type LiveChatSuperChatDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + AmountMicros *uint64 `protobuf:"varint,1,opt,name=amount_micros,json=amountMicros" json:"amount_micros,omitempty"` + Currency *string `protobuf:"bytes,2,opt,name=currency" json:"currency,omitempty"` + AmountDisplayString *string `protobuf:"bytes,3,opt,name=amount_display_string,json=amountDisplayString" json:"amount_display_string,omitempty"` + UserComment *string `protobuf:"bytes,4,opt,name=user_comment,json=userComment" json:"user_comment,omitempty"` + Tier *uint32 `protobuf:"varint,5,opt,name=tier" json:"tier,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatSuperChatDetails) Reset() { + *x = LiveChatSuperChatDetails{} + mi := &file_stream_list_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatSuperChatDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatSuperChatDetails) ProtoMessage() {} + +func (x *LiveChatSuperChatDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatSuperChatDetails.ProtoReflect.Descriptor instead. +func (*LiveChatSuperChatDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{9} +} + +func (x *LiveChatSuperChatDetails) GetAmountMicros() uint64 { + if x != nil && x.AmountMicros != nil { + return *x.AmountMicros + } + return 0 +} + +func (x *LiveChatSuperChatDetails) GetCurrency() string { + if x != nil && x.Currency != nil { + return *x.Currency + } + return "" +} + +func (x *LiveChatSuperChatDetails) GetAmountDisplayString() string { + if x != nil && x.AmountDisplayString != nil { + return *x.AmountDisplayString + } + return "" +} + +func (x *LiveChatSuperChatDetails) GetUserComment() string { + if x != nil && x.UserComment != nil { + return *x.UserComment + } + return "" +} + +func (x *LiveChatSuperChatDetails) GetTier() uint32 { + if x != nil && x.Tier != nil { + return *x.Tier + } + return 0 +} + +type LiveChatSuperStickerDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + AmountMicros *uint64 `protobuf:"varint,1,opt,name=amount_micros,json=amountMicros" json:"amount_micros,omitempty"` + Currency *string `protobuf:"bytes,2,opt,name=currency" json:"currency,omitempty"` + AmountDisplayString *string `protobuf:"bytes,3,opt,name=amount_display_string,json=amountDisplayString" json:"amount_display_string,omitempty"` + Tier *uint32 `protobuf:"varint,4,opt,name=tier" json:"tier,omitempty"` + SuperStickerMetadata *SuperStickerMetadata `protobuf:"bytes,5,opt,name=super_sticker_metadata,json=superStickerMetadata" json:"super_sticker_metadata,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatSuperStickerDetails) Reset() { + *x = LiveChatSuperStickerDetails{} + mi := &file_stream_list_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatSuperStickerDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatSuperStickerDetails) ProtoMessage() {} + +func (x *LiveChatSuperStickerDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatSuperStickerDetails.ProtoReflect.Descriptor instead. +func (*LiveChatSuperStickerDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{10} +} + +func (x *LiveChatSuperStickerDetails) GetAmountMicros() uint64 { + if x != nil && x.AmountMicros != nil { + return *x.AmountMicros + } + return 0 +} + +func (x *LiveChatSuperStickerDetails) GetCurrency() string { + if x != nil && x.Currency != nil { + return *x.Currency + } + return "" +} + +func (x *LiveChatSuperStickerDetails) GetAmountDisplayString() string { + if x != nil && x.AmountDisplayString != nil { + return *x.AmountDisplayString + } + return "" +} + +func (x *LiveChatSuperStickerDetails) GetTier() uint32 { + if x != nil && x.Tier != nil { + return *x.Tier + } + return 0 +} + +func (x *LiveChatSuperStickerDetails) GetSuperStickerMetadata() *SuperStickerMetadata { + if x != nil { + return x.SuperStickerMetadata + } + return nil +} + +type LiveChatFanFundingEventDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + AmountMicros *uint64 `protobuf:"varint,1,opt,name=amount_micros,json=amountMicros" json:"amount_micros,omitempty"` + Currency *string `protobuf:"bytes,2,opt,name=currency" json:"currency,omitempty"` + AmountDisplayString *string `protobuf:"bytes,3,opt,name=amount_display_string,json=amountDisplayString" json:"amount_display_string,omitempty"` + UserComment *string `protobuf:"bytes,4,opt,name=user_comment,json=userComment" json:"user_comment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatFanFundingEventDetails) Reset() { + *x = LiveChatFanFundingEventDetails{} + mi := &file_stream_list_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatFanFundingEventDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatFanFundingEventDetails) ProtoMessage() {} + +func (x *LiveChatFanFundingEventDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatFanFundingEventDetails.ProtoReflect.Descriptor instead. +func (*LiveChatFanFundingEventDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{11} +} + +func (x *LiveChatFanFundingEventDetails) GetAmountMicros() uint64 { + if x != nil && x.AmountMicros != nil { + return *x.AmountMicros + } + return 0 +} + +func (x *LiveChatFanFundingEventDetails) GetCurrency() string { + if x != nil && x.Currency != nil { + return *x.Currency + } + return "" +} + +func (x *LiveChatFanFundingEventDetails) GetAmountDisplayString() string { + if x != nil && x.AmountDisplayString != nil { + return *x.AmountDisplayString + } + return "" +} + +func (x *LiveChatFanFundingEventDetails) GetUserComment() string { + if x != nil && x.UserComment != nil { + return *x.UserComment + } + return "" +} + +type LiveChatNewSponsorDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + MemberLevelName *string `protobuf:"bytes,1,opt,name=member_level_name,json=memberLevelName" json:"member_level_name,omitempty"` + IsUpgrade *bool `protobuf:"varint,2,opt,name=is_upgrade,json=isUpgrade" json:"is_upgrade,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatNewSponsorDetails) Reset() { + *x = LiveChatNewSponsorDetails{} + mi := &file_stream_list_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatNewSponsorDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatNewSponsorDetails) ProtoMessage() {} + +func (x *LiveChatNewSponsorDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatNewSponsorDetails.ProtoReflect.Descriptor instead. +func (*LiveChatNewSponsorDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{12} +} + +func (x *LiveChatNewSponsorDetails) GetMemberLevelName() string { + if x != nil && x.MemberLevelName != nil { + return *x.MemberLevelName + } + return "" +} + +func (x *LiveChatNewSponsorDetails) GetIsUpgrade() bool { + if x != nil && x.IsUpgrade != nil { + return *x.IsUpgrade + } + return false +} + +type LiveChatMemberMilestoneChatDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + MemberLevelName *string `protobuf:"bytes,1,opt,name=member_level_name,json=memberLevelName" json:"member_level_name,omitempty"` + MemberMonth *uint32 `protobuf:"varint,2,opt,name=member_month,json=memberMonth" json:"member_month,omitempty"` + UserComment *string `protobuf:"bytes,3,opt,name=user_comment,json=userComment" json:"user_comment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMemberMilestoneChatDetails) Reset() { + *x = LiveChatMemberMilestoneChatDetails{} + mi := &file_stream_list_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMemberMilestoneChatDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMemberMilestoneChatDetails) ProtoMessage() {} + +func (x *LiveChatMemberMilestoneChatDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMemberMilestoneChatDetails.ProtoReflect.Descriptor instead. +func (*LiveChatMemberMilestoneChatDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{13} +} + +func (x *LiveChatMemberMilestoneChatDetails) GetMemberLevelName() string { + if x != nil && x.MemberLevelName != nil { + return *x.MemberLevelName + } + return "" +} + +func (x *LiveChatMemberMilestoneChatDetails) GetMemberMonth() uint32 { + if x != nil && x.MemberMonth != nil { + return *x.MemberMonth + } + return 0 +} + +func (x *LiveChatMemberMilestoneChatDetails) GetUserComment() string { + if x != nil && x.UserComment != nil { + return *x.UserComment + } + return "" +} + +type LiveChatMembershipGiftingDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + GiftMembershipsCount *int32 `protobuf:"varint,1,opt,name=gift_memberships_count,json=giftMembershipsCount" json:"gift_memberships_count,omitempty"` + GiftMembershipsLevelName *string `protobuf:"bytes,2,opt,name=gift_memberships_level_name,json=giftMembershipsLevelName" json:"gift_memberships_level_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMembershipGiftingDetails) Reset() { + *x = LiveChatMembershipGiftingDetails{} + mi := &file_stream_list_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMembershipGiftingDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMembershipGiftingDetails) ProtoMessage() {} + +func (x *LiveChatMembershipGiftingDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMembershipGiftingDetails.ProtoReflect.Descriptor instead. +func (*LiveChatMembershipGiftingDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{14} +} + +func (x *LiveChatMembershipGiftingDetails) GetGiftMembershipsCount() int32 { + if x != nil && x.GiftMembershipsCount != nil { + return *x.GiftMembershipsCount + } + return 0 +} + +func (x *LiveChatMembershipGiftingDetails) GetGiftMembershipsLevelName() string { + if x != nil && x.GiftMembershipsLevelName != nil { + return *x.GiftMembershipsLevelName + } + return "" +} + +type LiveChatGiftMembershipReceivedDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + MemberLevelName *string `protobuf:"bytes,1,opt,name=member_level_name,json=memberLevelName" json:"member_level_name,omitempty"` + GifterChannelId *string `protobuf:"bytes,2,opt,name=gifter_channel_id,json=gifterChannelId" json:"gifter_channel_id,omitempty"` + AssociatedMembershipGiftingMessageId *string `protobuf:"bytes,3,opt,name=associated_membership_gifting_message_id,json=associatedMembershipGiftingMessageId" json:"associated_membership_gifting_message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatGiftMembershipReceivedDetails) Reset() { + *x = LiveChatGiftMembershipReceivedDetails{} + mi := &file_stream_list_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatGiftMembershipReceivedDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatGiftMembershipReceivedDetails) ProtoMessage() {} + +func (x *LiveChatGiftMembershipReceivedDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatGiftMembershipReceivedDetails.ProtoReflect.Descriptor instead. +func (*LiveChatGiftMembershipReceivedDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{15} +} + +func (x *LiveChatGiftMembershipReceivedDetails) GetMemberLevelName() string { + if x != nil && x.MemberLevelName != nil { + return *x.MemberLevelName + } + return "" +} + +func (x *LiveChatGiftMembershipReceivedDetails) GetGifterChannelId() string { + if x != nil && x.GifterChannelId != nil { + return *x.GifterChannelId + } + return "" +} + +func (x *LiveChatGiftMembershipReceivedDetails) GetAssociatedMembershipGiftingMessageId() string { + if x != nil && x.AssociatedMembershipGiftingMessageId != nil { + return *x.AssociatedMembershipGiftingMessageId + } + return "" +} + +type LiveChatPollDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *LiveChatPollDetails_PollMetadata `protobuf:"bytes,1,opt,name=metadata" json:"metadata,omitempty"` + Status *LiveChatPollDetails_PollStatusWrapper_PollStatus `protobuf:"varint,2,opt,name=status,enum=youtube.api.v3.LiveChatPollDetails_PollStatusWrapper_PollStatus" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatPollDetails) Reset() { + *x = LiveChatPollDetails{} + mi := &file_stream_list_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatPollDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatPollDetails) ProtoMessage() {} + +func (x *LiveChatPollDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatPollDetails.ProtoReflect.Descriptor instead. +func (*LiveChatPollDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{16} +} + +func (x *LiveChatPollDetails) GetMetadata() *LiveChatPollDetails_PollMetadata { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *LiveChatPollDetails) GetStatus() LiveChatPollDetails_PollStatusWrapper_PollStatus { + if x != nil && x.Status != nil { + return *x.Status + } + return LiveChatPollDetails_PollStatusWrapper_UNKNOWN +} + +type LiveChatGiftDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + GiftName *string `protobuf:"bytes,1,opt,name=gift_name,json=giftName" json:"gift_name,omitempty"` + GiftDuration *durationpb.Duration `protobuf:"bytes,2,opt,name=gift_duration,json=giftDuration" json:"gift_duration,omitempty"` + JewelsAmount *int32 `protobuf:"varint,3,opt,name=jewels_amount,json=jewelsAmount" json:"jewels_amount,omitempty"` + GiftUrl *string `protobuf:"bytes,4,opt,name=gift_url,json=giftUrl" json:"gift_url,omitempty"` + AltText *string `protobuf:"bytes,5,opt,name=alt_text,json=altText" json:"alt_text,omitempty"` + Language *string `protobuf:"bytes,6,opt,name=language" json:"language,omitempty"` + HasVisualEffect *bool `protobuf:"varint,7,opt,name=has_visual_effect,json=hasVisualEffect" json:"has_visual_effect,omitempty"` + ComboCount *int32 `protobuf:"varint,8,opt,name=combo_count,json=comboCount" json:"combo_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatGiftDetails) Reset() { + *x = LiveChatGiftDetails{} + mi := &file_stream_list_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatGiftDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatGiftDetails) ProtoMessage() {} + +func (x *LiveChatGiftDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatGiftDetails.ProtoReflect.Descriptor instead. +func (*LiveChatGiftDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{17} +} + +func (x *LiveChatGiftDetails) GetGiftName() string { + if x != nil && x.GiftName != nil { + return *x.GiftName + } + return "" +} + +func (x *LiveChatGiftDetails) GetGiftDuration() *durationpb.Duration { + if x != nil { + return x.GiftDuration + } + return nil +} + +func (x *LiveChatGiftDetails) GetJewelsAmount() int32 { + if x != nil && x.JewelsAmount != nil { + return *x.JewelsAmount + } + return 0 +} + +func (x *LiveChatGiftDetails) GetGiftUrl() string { + if x != nil && x.GiftUrl != nil { + return *x.GiftUrl + } + return "" +} + +func (x *LiveChatGiftDetails) GetAltText() string { + if x != nil && x.AltText != nil { + return *x.AltText + } + return "" +} + +func (x *LiveChatGiftDetails) GetLanguage() string { + if x != nil && x.Language != nil { + return *x.Language + } + return "" +} + +func (x *LiveChatGiftDetails) GetHasVisualEffect() bool { + if x != nil && x.HasVisualEffect != nil { + return *x.HasVisualEffect + } + return false +} + +func (x *LiveChatGiftDetails) GetComboCount() int32 { + if x != nil && x.ComboCount != nil { + return *x.ComboCount + } + return 0 +} + +type SuperStickerMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + StickerId *string `protobuf:"bytes,1,opt,name=sticker_id,json=stickerId" json:"sticker_id,omitempty"` + AltText *string `protobuf:"bytes,2,opt,name=alt_text,json=altText" json:"alt_text,omitempty"` + AltTextLanguage *string `protobuf:"bytes,3,opt,name=alt_text_language,json=altTextLanguage" json:"alt_text_language,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SuperStickerMetadata) Reset() { + *x = SuperStickerMetadata{} + mi := &file_stream_list_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SuperStickerMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SuperStickerMetadata) ProtoMessage() {} + +func (x *SuperStickerMetadata) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SuperStickerMetadata.ProtoReflect.Descriptor instead. +func (*SuperStickerMetadata) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{18} +} + +func (x *SuperStickerMetadata) GetStickerId() string { + if x != nil && x.StickerId != nil { + return *x.StickerId + } + return "" +} + +func (x *SuperStickerMetadata) GetAltText() string { + if x != nil && x.AltText != nil { + return *x.AltText + } + return "" +} + +func (x *SuperStickerMetadata) GetAltTextLanguage() string { + if x != nil && x.AltTextLanguage != nil { + return *x.AltTextLanguage + } + return "" +} + +type ChannelProfileDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + ChannelId *string `protobuf:"bytes,101,opt,name=channel_id,json=channelId" json:"channel_id,omitempty"` + ChannelUrl *string `protobuf:"bytes,2,opt,name=channel_url,json=channelUrl" json:"channel_url,omitempty"` + DisplayName *string `protobuf:"bytes,3,opt,name=display_name,json=displayName" json:"display_name,omitempty"` + ProfileImageUrl *string `protobuf:"bytes,4,opt,name=profile_image_url,json=profileImageUrl" json:"profile_image_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelProfileDetails) Reset() { + *x = ChannelProfileDetails{} + mi := &file_stream_list_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelProfileDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelProfileDetails) ProtoMessage() {} + +func (x *ChannelProfileDetails) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelProfileDetails.ProtoReflect.Descriptor instead. +func (*ChannelProfileDetails) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{19} +} + +func (x *ChannelProfileDetails) GetChannelId() string { + if x != nil && x.ChannelId != nil { + return *x.ChannelId + } + return "" +} + +func (x *ChannelProfileDetails) GetChannelUrl() string { + if x != nil && x.ChannelUrl != nil { + return *x.ChannelUrl + } + return "" +} + +func (x *ChannelProfileDetails) GetDisplayName() string { + if x != nil && x.DisplayName != nil { + return *x.DisplayName + } + return "" +} + +func (x *ChannelProfileDetails) GetProfileImageUrl() string { + if x != nil && x.ProfileImageUrl != nil { + return *x.ProfileImageUrl + } + return "" +} + +type PageInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + TotalResults *int32 `protobuf:"varint,1,opt,name=total_results,json=totalResults" json:"total_results,omitempty"` + ResultsPerPage *int32 `protobuf:"varint,2,opt,name=results_per_page,json=resultsPerPage" json:"results_per_page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PageInfo) Reset() { + *x = PageInfo{} + mi := &file_stream_list_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PageInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PageInfo) ProtoMessage() {} + +func (x *PageInfo) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PageInfo.ProtoReflect.Descriptor instead. +func (*PageInfo) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{20} +} + +func (x *PageInfo) GetTotalResults() int32 { + if x != nil && x.TotalResults != nil { + return *x.TotalResults + } + return 0 +} + +func (x *PageInfo) GetResultsPerPage() int32 { + if x != nil && x.ResultsPerPage != nil { + return *x.ResultsPerPage + } + return 0 +} + +type LiveChatMessageSnippet_TypeWrapper struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatMessageSnippet_TypeWrapper) Reset() { + *x = LiveChatMessageSnippet_TypeWrapper{} + mi := &file_stream_list_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatMessageSnippet_TypeWrapper) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatMessageSnippet_TypeWrapper) ProtoMessage() {} + +func (x *LiveChatMessageSnippet_TypeWrapper) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatMessageSnippet_TypeWrapper.ProtoReflect.Descriptor instead. +func (*LiveChatMessageSnippet_TypeWrapper) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{4, 0} +} + +type LiveChatUserBannedMessageDetails_BanTypeWrapper struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatUserBannedMessageDetails_BanTypeWrapper) Reset() { + *x = LiveChatUserBannedMessageDetails_BanTypeWrapper{} + mi := &file_stream_list_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatUserBannedMessageDetails_BanTypeWrapper) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatUserBannedMessageDetails_BanTypeWrapper) ProtoMessage() {} + +func (x *LiveChatUserBannedMessageDetails_BanTypeWrapper) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatUserBannedMessageDetails_BanTypeWrapper.ProtoReflect.Descriptor instead. +func (*LiveChatUserBannedMessageDetails_BanTypeWrapper) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{8, 0} +} + +type LiveChatPollDetails_PollMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + QuestionText *string `protobuf:"bytes,1,opt,name=question_text,json=questionText" json:"question_text,omitempty"` + Options []*LiveChatPollDetails_PollMetadata_PollOption `protobuf:"bytes,2,rep,name=options" json:"options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatPollDetails_PollMetadata) Reset() { + *x = LiveChatPollDetails_PollMetadata{} + mi := &file_stream_list_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatPollDetails_PollMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatPollDetails_PollMetadata) ProtoMessage() {} + +func (x *LiveChatPollDetails_PollMetadata) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatPollDetails_PollMetadata.ProtoReflect.Descriptor instead. +func (*LiveChatPollDetails_PollMetadata) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{16, 0} +} + +func (x *LiveChatPollDetails_PollMetadata) GetQuestionText() string { + if x != nil && x.QuestionText != nil { + return *x.QuestionText + } + return "" +} + +func (x *LiveChatPollDetails_PollMetadata) GetOptions() []*LiveChatPollDetails_PollMetadata_PollOption { + if x != nil { + return x.Options + } + return nil +} + +type LiveChatPollDetails_PollStatusWrapper struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatPollDetails_PollStatusWrapper) Reset() { + *x = LiveChatPollDetails_PollStatusWrapper{} + mi := &file_stream_list_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatPollDetails_PollStatusWrapper) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatPollDetails_PollStatusWrapper) ProtoMessage() {} + +func (x *LiveChatPollDetails_PollStatusWrapper) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatPollDetails_PollStatusWrapper.ProtoReflect.Descriptor instead. +func (*LiveChatPollDetails_PollStatusWrapper) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{16, 1} +} + +type LiveChatPollDetails_PollMetadata_PollOption struct { + state protoimpl.MessageState `protogen:"open.v1"` + OptionText *string `protobuf:"bytes,1,opt,name=option_text,json=optionText" json:"option_text,omitempty"` + Tally *int64 `protobuf:"varint,2,opt,name=tally" json:"tally,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LiveChatPollDetails_PollMetadata_PollOption) Reset() { + *x = LiveChatPollDetails_PollMetadata_PollOption{} + mi := &file_stream_list_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LiveChatPollDetails_PollMetadata_PollOption) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LiveChatPollDetails_PollMetadata_PollOption) ProtoMessage() {} + +func (x *LiveChatPollDetails_PollMetadata_PollOption) ProtoReflect() protoreflect.Message { + mi := &file_stream_list_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LiveChatPollDetails_PollMetadata_PollOption.ProtoReflect.Descriptor instead. +func (*LiveChatPollDetails_PollMetadata_PollOption) Descriptor() ([]byte, []int) { + return file_stream_list_proto_rawDescGZIP(), []int{16, 0, 0} +} + +func (x *LiveChatPollDetails_PollMetadata_PollOption) GetOptionText() string { + if x != nil && x.OptionText != nil { + return *x.OptionText + } + return "" +} + +func (x *LiveChatPollDetails_PollMetadata_PollOption) GetTally() int64 { + if x != nil && x.Tally != nil { + return *x.Tally + } + return 0 +} + +var File_stream_list_proto protoreflect.FileDescriptor + +const file_stream_list_proto_rawDesc = "" + + "\n" + + "\x11stream_list.proto\x12\x0eyoutube.api.v3\x1a\x1egoogle/protobuf/duration.proto\"\xd0\x01\n" + + "\x1aLiveChatMessageListRequest\x12 \n" + + "\flive_chat_id\x18\x01 \x01(\tR\n" + + "liveChatId\x12\x0e\n" + + "\x02hl\x18\x02 \x01(\tR\x02hl\x12,\n" + + "\x12profile_image_size\x18\x03 \x01(\rR\x10profileImageSize\x12\x1f\n" + + "\vmax_results\x18b \x01(\rR\n" + + "maxResults\x12\x1d\n" + + "\n" + + "page_token\x18c \x01(\tR\tpageToken\x12\x12\n" + + "\x04part\x18d \x03(\tR\x04part\"\xcc\x02\n" + + "\x1bLiveChatMessageListResponse\x12\x13\n" + + "\x04kind\x18\xc8\x01 \x01(\tR\x04kind\x12\x13\n" + + "\x04etag\x18\xc9\x01 \x01(\tR\x04etag\x12\x1d\n" + + "\n" + + "offline_at\x18\x02 \x01(\tR\tofflineAt\x126\n" + + "\tpage_info\x18\xec\a \x01(\v2\x18.youtube.api.v3.PageInfoR\bpageInfo\x12(\n" + + "\x0fnext_page_token\x18\xfa\x91\x06 \x01(\tR\rnextPageToken\x126\n" + + "\x05items\x18\xef\a \x03(\v2\x1f.youtube.api.v3.LiveChatMessageR\x05items\x12J\n" + + "\x10active_poll_item\x18\xf0\a \x01(\v2\x1f.youtube.api.v3.LiveChatMessageR\x0eactivePollItem\"\xe2\x01\n" + + "\x0fLiveChatMessage\x12\x13\n" + + "\x04kind\x18\xc8\x01 \x01(\tR\x04kind\x12\x13\n" + + "\x04etag\x18\xc9\x01 \x01(\tR\x04etag\x12\x0e\n" + + "\x02id\x18e \x01(\tR\x02id\x12@\n" + + "\asnippet\x18\x02 \x01(\v2&.youtube.api.v3.LiveChatMessageSnippetR\asnippet\x12S\n" + + "\x0eauthor_details\x18\x03 \x01(\v2,.youtube.api.v3.LiveChatMessageAuthorDetailsR\rauthorDetails\"\xc7\x02\n" + + "\x1cLiveChatMessageAuthorDetails\x12\x1e\n" + + "\n" + + "channel_id\x18\xf5N \x01(\tR\tchannelId\x12\x1f\n" + + "\vchannel_url\x18f \x01(\tR\n" + + "channelUrl\x12!\n" + + "\fdisplay_name\x18g \x01(\tR\vdisplayName\x12*\n" + + "\x11profile_image_url\x18h \x01(\tR\x0fprofileImageUrl\x12\x1f\n" + + "\vis_verified\x18\x04 \x01(\bR\n" + + "isVerified\x12\"\n" + + "\ris_chat_owner\x18\x05 \x01(\bR\visChatOwner\x12&\n" + + "\x0fis_chat_sponsor\x18\x06 \x01(\bR\risChatSponsor\x12*\n" + + "\x11is_chat_moderator\x18\a \x01(\bR\x0fisChatModerator\"\xdd\x0f\n" + + "\x16LiveChatMessageSnippet\x12K\n" + + "\x04type\x18\x01 \x01(\x0e27.youtube.api.v3.LiveChatMessageSnippet.TypeWrapper.TypeR\x04type\x12!\n" + + "\flive_chat_id\x18\xc9\x01 \x01(\tR\n" + + "liveChatId\x12+\n" + + "\x11author_channel_id\x18\xad\x02 \x01(\tR\x0fauthorChannelId\x12!\n" + + "\fpublished_at\x18\x04 \x01(\tR\vpublishedAt\x12.\n" + + "\x13has_display_content\x18\x11 \x01(\bR\x11hasDisplayContent\x12'\n" + + "\x0fdisplay_message\x18\x10 \x01(\tR\x0edisplayMessage\x12^\n" + + "\x14text_message_details\x18\x13 \x01(\v2*.youtube.api.v3.LiveChatTextMessageDetailsH\x00R\x12textMessageDetails\x12g\n" + + "\x17message_deleted_details\x18\x14 \x01(\v2-.youtube.api.v3.LiveChatMessageDeletedDetailsH\x00R\x15messageDeletedDetails\x12m\n" + + "\x19message_retracted_details\x18\x15 \x01(\v2/.youtube.api.v3.LiveChatMessageRetractedDetailsH\x00R\x17messageRetractedDetails\x12b\n" + + "\x13user_banned_details\x18\x16 \x01(\v20.youtube.api.v3.LiveChatUserBannedMessageDetailsH\x00R\x11userBannedDetails\x12X\n" + + "\x12super_chat_details\x18\x1b \x01(\v2(.youtube.api.v3.LiveChatSuperChatDetailsH\x00R\x10superChatDetails\x12a\n" + + "\x15super_sticker_details\x18\x1c \x01(\v2+.youtube.api.v3.LiveChatSuperStickerDetailsH\x00R\x13superStickerDetails\x12[\n" + + "\x13new_sponsor_details\x18\x1d \x01(\v2).youtube.api.v3.LiveChatNewSponsorDetailsH\x00R\x11newSponsorDetails\x12w\n" + + "\x1dmember_milestone_chat_details\x18\x1e \x01(\v22.youtube.api.v3.LiveChatMemberMilestoneChatDetailsH\x00R\x1amemberMilestoneChatDetails\x12p\n" + + "\x1amembership_gifting_details\x18\x1f \x01(\v20.youtube.api.v3.LiveChatMembershipGiftingDetailsH\x00R\x18membershipGiftingDetails\x12\x80\x01\n" + + " gift_membership_received_details\x18 \x01(\v25.youtube.api.v3.LiveChatGiftMembershipReceivedDetailsH\x00R\x1dgiftMembershipReceivedDetails\x12H\n" + + "\fpoll_details\x18! \x01(\v2#.youtube.api.v3.LiveChatPollDetailsH\x00R\vpollDetails\x12H\n" + + "\fgift_details\x18\" \x01(\v2#.youtube.api.v3.LiveChatGiftDetailsH\x00R\vgiftDetails\x1a\xdc\x03\n" + + "\vTypeWrapper\"\xcc\x03\n" + + "\x04Type\x12\x10\n" + + "\fINVALID_TYPE\x10\x00\x12\x16\n" + + "\x12TEXT_MESSAGE_EVENT\x10\x01\x12\r\n" + + "\tTOMBSTONE\x10\x02\x12\x15\n" + + "\x11FAN_FUNDING_EVENT\x10\x03\x12\x14\n" + + "\x10CHAT_ENDED_EVENT\x10\x04\x12#\n" + + "\x1fSPONSOR_ONLY_MODE_STARTED_EVENT\x10\x05\x12!\n" + + "\x1dSPONSOR_ONLY_MODE_ENDED_EVENT\x10\x06\x12\x15\n" + + "\x11NEW_SPONSOR_EVENT\x10\a\x12\x1f\n" + + "\x1bMEMBER_MILESTONE_CHAT_EVENT\x10\x11\x12\x1c\n" + + "\x18MEMBERSHIP_GIFTING_EVENT\x10\x12\x12\"\n" + + "\x1eGIFT_MEMBERSHIP_RECEIVED_EVENT\x10\x13\x12\x19\n" + + "\x15MESSAGE_DELETED_EVENT\x10\b\x12\x1b\n" + + "\x17MESSAGE_RETRACTED_EVENT\x10\t\x12\x15\n" + + "\x11USER_BANNED_EVENT\x10\n" + + "\x12\x14\n" + + "\x10SUPER_CHAT_EVENT\x10\x0f\x12\x17\n" + + "\x13SUPER_STICKER_EVENT\x10\x10\x12\x0e\n" + + "\n" + + "POLL_EVENT\x10\x14\x12\x0e\n" + + "\n" + + "GIFT_EVENT\x10\x15B\x13\n" + + "\x11displayed_content\"?\n" + + "\x1aLiveChatTextMessageDetails\x12!\n" + + "\fmessage_text\x18\x01 \x01(\tR\vmessageText\"M\n" + + "\x1dLiveChatMessageDeletedDetails\x12,\n" + + "\x12deleted_message_id\x18e \x01(\tR\x10deletedMessageId\"T\n" + + "\x1fLiveChatMessageRetractedDetails\x121\n" + + "\x14retracted_message_id\x18\xc9\x01 \x01(\tR\x12retractedMessageId\"\xca\x02\n" + + " LiveChatUserBannedMessageDetails\x12U\n" + + "\x13banned_user_details\x18\x01 \x01(\v2%.youtube.api.v3.ChannelProfileDetailsR\x11bannedUserDetails\x12b\n" + + "\bban_type\x18\x02 \x01(\x0e2G.youtube.api.v3.LiveChatUserBannedMessageDetails.BanTypeWrapper.BanTypeR\abanType\x120\n" + + "\x14ban_duration_seconds\x18\x04 \x01(\x04R\x12banDurationSeconds\x1a9\n" + + "\x0eBanTypeWrapper\"'\n" + + "\aBanType\x12\r\n" + + "\tPERMANENT\x10\x01\x12\r\n" + + "\tTEMPORARY\x10\x02\"\xc6\x01\n" + + "\x18LiveChatSuperChatDetails\x12#\n" + + "\ramount_micros\x18\x01 \x01(\x04R\famountMicros\x12\x1a\n" + + "\bcurrency\x18\x02 \x01(\tR\bcurrency\x122\n" + + "\x15amount_display_string\x18\x03 \x01(\tR\x13amountDisplayString\x12!\n" + + "\fuser_comment\x18\x04 \x01(\tR\vuserComment\x12\x12\n" + + "\x04tier\x18\x05 \x01(\rR\x04tier\"\x82\x02\n" + + "\x1bLiveChatSuperStickerDetails\x12#\n" + + "\ramount_micros\x18\x01 \x01(\x04R\famountMicros\x12\x1a\n" + + "\bcurrency\x18\x02 \x01(\tR\bcurrency\x122\n" + + "\x15amount_display_string\x18\x03 \x01(\tR\x13amountDisplayString\x12\x12\n" + + "\x04tier\x18\x04 \x01(\rR\x04tier\x12Z\n" + + "\x16super_sticker_metadata\x18\x05 \x01(\v2$.youtube.api.v3.SuperStickerMetadataR\x14superStickerMetadata\"\xb8\x01\n" + + "\x1eLiveChatFanFundingEventDetails\x12#\n" + + "\ramount_micros\x18\x01 \x01(\x04R\famountMicros\x12\x1a\n" + + "\bcurrency\x18\x02 \x01(\tR\bcurrency\x122\n" + + "\x15amount_display_string\x18\x03 \x01(\tR\x13amountDisplayString\x12!\n" + + "\fuser_comment\x18\x04 \x01(\tR\vuserComment\"f\n" + + "\x19LiveChatNewSponsorDetails\x12*\n" + + "\x11member_level_name\x18\x01 \x01(\tR\x0fmemberLevelName\x12\x1d\n" + + "\n" + + "is_upgrade\x18\x02 \x01(\bR\tisUpgrade\"\x96\x01\n" + + "\"LiveChatMemberMilestoneChatDetails\x12*\n" + + "\x11member_level_name\x18\x01 \x01(\tR\x0fmemberLevelName\x12!\n" + + "\fmember_month\x18\x02 \x01(\rR\vmemberMonth\x12!\n" + + "\fuser_comment\x18\x03 \x01(\tR\vuserComment\"\x97\x01\n" + + " LiveChatMembershipGiftingDetails\x124\n" + + "\x16gift_memberships_count\x18\x01 \x01(\x05R\x14giftMembershipsCount\x12=\n" + + "\x1bgift_memberships_level_name\x18\x02 \x01(\tR\x18giftMembershipsLevelName\"\xd7\x01\n" + + "%LiveChatGiftMembershipReceivedDetails\x12*\n" + + "\x11member_level_name\x18\x01 \x01(\tR\x0fmemberLevelName\x12*\n" + + "\x11gifter_channel_id\x18\x02 \x01(\tR\x0fgifterChannelId\x12V\n" + + "(associated_membership_gifting_message_id\x18\x03 \x01(\tR$associatedMembershipGiftingMessageId\"\xd7\x03\n" + + "\x13LiveChatPollDetails\x12L\n" + + "\bmetadata\x18\x01 \x01(\v20.youtube.api.v3.LiveChatPollDetails.PollMetadataR\bmetadata\x12X\n" + + "\x06status\x18\x02 \x01(\x0e2@.youtube.api.v3.LiveChatPollDetails.PollStatusWrapper.PollStatusR\x06status\x1a\xcf\x01\n" + + "\fPollMetadata\x12#\n" + + "\rquestion_text\x18\x01 \x01(\tR\fquestionText\x12U\n" + + "\aoptions\x18\x02 \x03(\v2;.youtube.api.v3.LiveChatPollDetails.PollMetadata.PollOptionR\aoptions\x1aC\n" + + "\n" + + "PollOption\x12\x1f\n" + + "\voption_text\x18\x01 \x01(\tR\n" + + "optionText\x12\x14\n" + + "\x05tally\x18\x02 \x01(\x03R\x05tally\x1aF\n" + + "\x11PollStatusWrapper\"1\n" + + "\n" + + "PollStatus\x12\v\n" + + "\aUNKNOWN\x10\x00\x12\n" + + "\n" + + "\x06ACTIVE\x10\x01\x12\n" + + "\n" + + "\x06CLOSED\x10\x02\"\xb6\x02\n" + + "\x13LiveChatGiftDetails\x12\x1b\n" + + "\tgift_name\x18\x01 \x01(\tR\bgiftName\x12>\n" + + "\rgift_duration\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\fgiftDuration\x12#\n" + + "\rjewels_amount\x18\x03 \x01(\x05R\fjewelsAmount\x12\x19\n" + + "\bgift_url\x18\x04 \x01(\tR\agiftUrl\x12\x19\n" + + "\balt_text\x18\x05 \x01(\tR\aaltText\x12\x1a\n" + + "\blanguage\x18\x06 \x01(\tR\blanguage\x12*\n" + + "\x11has_visual_effect\x18\a \x01(\bR\x0fhasVisualEffect\x12\x1f\n" + + "\vcombo_count\x18\b \x01(\x05R\n" + + "comboCount\"|\n" + + "\x14SuperStickerMetadata\x12\x1d\n" + + "\n" + + "sticker_id\x18\x01 \x01(\tR\tstickerId\x12\x19\n" + + "\balt_text\x18\x02 \x01(\tR\aaltText\x12*\n" + + "\x11alt_text_language\x18\x03 \x01(\tR\x0faltTextLanguage\"\xa6\x01\n" + + "\x15ChannelProfileDetails\x12\x1d\n" + + "\n" + + "channel_id\x18e \x01(\tR\tchannelId\x12\x1f\n" + + "\vchannel_url\x18\x02 \x01(\tR\n" + + "channelUrl\x12!\n" + + "\fdisplay_name\x18\x03 \x01(\tR\vdisplayName\x12*\n" + + "\x11profile_image_url\x18\x04 \x01(\tR\x0fprofileImageUrl\"Y\n" + + "\bPageInfo\x12#\n" + + "\rtotal_results\x18\x01 \x01(\x05R\ftotalResults\x12(\n" + + "\x10results_per_page\x18\x02 \x01(\x05R\x0eresultsPerPage2\x89\x01\n" + + "\x1cV3DataLiveChatMessageService\x12i\n" + + "\n" + + "StreamList\x12*.youtube.api.v3.LiveChatMessageListRequest\x1a+.youtube.api.v3.LiveChatMessageListResponse\"\x000\x01B>Z youtube.api.v3.PageInfo + 5, // 1: youtube.api.v3.LiveChatMessageListResponse.items:type_name -> youtube.api.v3.LiveChatMessage + 5, // 2: youtube.api.v3.LiveChatMessageListResponse.active_poll_item:type_name -> youtube.api.v3.LiveChatMessage + 7, // 3: youtube.api.v3.LiveChatMessage.snippet:type_name -> youtube.api.v3.LiveChatMessageSnippet + 6, // 4: youtube.api.v3.LiveChatMessage.author_details:type_name -> youtube.api.v3.LiveChatMessageAuthorDetails + 0, // 5: youtube.api.v3.LiveChatMessageSnippet.type:type_name -> youtube.api.v3.LiveChatMessageSnippet.TypeWrapper.Type + 8, // 6: youtube.api.v3.LiveChatMessageSnippet.text_message_details:type_name -> youtube.api.v3.LiveChatTextMessageDetails + 9, // 7: youtube.api.v3.LiveChatMessageSnippet.message_deleted_details:type_name -> youtube.api.v3.LiveChatMessageDeletedDetails + 10, // 8: youtube.api.v3.LiveChatMessageSnippet.message_retracted_details:type_name -> youtube.api.v3.LiveChatMessageRetractedDetails + 11, // 9: youtube.api.v3.LiveChatMessageSnippet.user_banned_details:type_name -> youtube.api.v3.LiveChatUserBannedMessageDetails + 12, // 10: youtube.api.v3.LiveChatMessageSnippet.super_chat_details:type_name -> youtube.api.v3.LiveChatSuperChatDetails + 13, // 11: youtube.api.v3.LiveChatMessageSnippet.super_sticker_details:type_name -> youtube.api.v3.LiveChatSuperStickerDetails + 15, // 12: youtube.api.v3.LiveChatMessageSnippet.new_sponsor_details:type_name -> youtube.api.v3.LiveChatNewSponsorDetails + 16, // 13: youtube.api.v3.LiveChatMessageSnippet.member_milestone_chat_details:type_name -> youtube.api.v3.LiveChatMemberMilestoneChatDetails + 17, // 14: youtube.api.v3.LiveChatMessageSnippet.membership_gifting_details:type_name -> youtube.api.v3.LiveChatMembershipGiftingDetails + 18, // 15: youtube.api.v3.LiveChatMessageSnippet.gift_membership_received_details:type_name -> youtube.api.v3.LiveChatGiftMembershipReceivedDetails + 19, // 16: youtube.api.v3.LiveChatMessageSnippet.poll_details:type_name -> youtube.api.v3.LiveChatPollDetails + 20, // 17: youtube.api.v3.LiveChatMessageSnippet.gift_details:type_name -> youtube.api.v3.LiveChatGiftDetails + 22, // 18: youtube.api.v3.LiveChatUserBannedMessageDetails.banned_user_details:type_name -> youtube.api.v3.ChannelProfileDetails + 1, // 19: youtube.api.v3.LiveChatUserBannedMessageDetails.ban_type:type_name -> youtube.api.v3.LiveChatUserBannedMessageDetails.BanTypeWrapper.BanType + 21, // 20: youtube.api.v3.LiveChatSuperStickerDetails.super_sticker_metadata:type_name -> youtube.api.v3.SuperStickerMetadata + 26, // 21: youtube.api.v3.LiveChatPollDetails.metadata:type_name -> youtube.api.v3.LiveChatPollDetails.PollMetadata + 2, // 22: youtube.api.v3.LiveChatPollDetails.status:type_name -> youtube.api.v3.LiveChatPollDetails.PollStatusWrapper.PollStatus + 29, // 23: youtube.api.v3.LiveChatGiftDetails.gift_duration:type_name -> google.protobuf.Duration + 28, // 24: youtube.api.v3.LiveChatPollDetails.PollMetadata.options:type_name -> youtube.api.v3.LiveChatPollDetails.PollMetadata.PollOption + 3, // 25: youtube.api.v3.V3DataLiveChatMessageService.StreamList:input_type -> youtube.api.v3.LiveChatMessageListRequest + 4, // 26: youtube.api.v3.V3DataLiveChatMessageService.StreamList:output_type -> youtube.api.v3.LiveChatMessageListResponse + 26, // [26:27] is the sub-list for method output_type + 25, // [25:26] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_stream_list_proto_init() } +func file_stream_list_proto_init() { + if File_stream_list_proto != nil { + return + } + file_stream_list_proto_msgTypes[4].OneofWrappers = []any{ + (*LiveChatMessageSnippet_TextMessageDetails)(nil), + (*LiveChatMessageSnippet_MessageDeletedDetails)(nil), + (*LiveChatMessageSnippet_MessageRetractedDetails)(nil), + (*LiveChatMessageSnippet_UserBannedDetails)(nil), + (*LiveChatMessageSnippet_SuperChatDetails)(nil), + (*LiveChatMessageSnippet_SuperStickerDetails)(nil), + (*LiveChatMessageSnippet_NewSponsorDetails)(nil), + (*LiveChatMessageSnippet_MemberMilestoneChatDetails)(nil), + (*LiveChatMessageSnippet_MembershipGiftingDetails)(nil), + (*LiveChatMessageSnippet_GiftMembershipReceivedDetails)(nil), + (*LiveChatMessageSnippet_PollDetails)(nil), + (*LiveChatMessageSnippet_GiftDetails)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_stream_list_proto_rawDesc), len(file_stream_list_proto_rawDesc)), + NumEnums: 3, + NumMessages: 26, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stream_list_proto_goTypes, + DependencyIndexes: file_stream_list_proto_depIdxs, + EnumInfos: file_stream_list_proto_enumTypes, + MessageInfos: file_stream_list_proto_msgTypes, + }.Build() + File_stream_list_proto = out.File + file_stream_list_proto_goTypes = nil + file_stream_list_proto_depIdxs = nil +} diff --git a/apps/desktop/src-sidecar/internal/youtube/ytpb/stream_list_grpc.pb.go b/apps/desktop/src-sidecar/internal/youtube/ytpb/stream_list_grpc.pb.go new file mode 100644 index 0000000..498ebd3 --- /dev/null +++ b/apps/desktop/src-sidecar/internal/youtube/ytpb/stream_list_grpc.pb.go @@ -0,0 +1,129 @@ +// YouTube Live Chat gRPC streaming API. +// Source: https://developers.google.com/youtube/v3/live/streaming-live-chat +// Licensed under Apache 2.0. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.4 +// source: stream_list.proto + +package ytpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + V3DataLiveChatMessageService_StreamList_FullMethodName = "/youtube.api.v3.V3DataLiveChatMessageService/StreamList" +) + +// V3DataLiveChatMessageServiceClient is the client API for V3DataLiveChatMessageService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type V3DataLiveChatMessageServiceClient interface { + StreamList(ctx context.Context, in *LiveChatMessageListRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LiveChatMessageListResponse], error) +} + +type v3DataLiveChatMessageServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewV3DataLiveChatMessageServiceClient(cc grpc.ClientConnInterface) V3DataLiveChatMessageServiceClient { + return &v3DataLiveChatMessageServiceClient{cc} +} + +func (c *v3DataLiveChatMessageServiceClient) StreamList(ctx context.Context, in *LiveChatMessageListRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LiveChatMessageListResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &V3DataLiveChatMessageService_ServiceDesc.Streams[0], V3DataLiveChatMessageService_StreamList_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[LiveChatMessageListRequest, LiveChatMessageListResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type V3DataLiveChatMessageService_StreamListClient = grpc.ServerStreamingClient[LiveChatMessageListResponse] + +// V3DataLiveChatMessageServiceServer is the server API for V3DataLiveChatMessageService service. +// All implementations must embed UnimplementedV3DataLiveChatMessageServiceServer +// for forward compatibility. +type V3DataLiveChatMessageServiceServer interface { + StreamList(*LiveChatMessageListRequest, grpc.ServerStreamingServer[LiveChatMessageListResponse]) error + mustEmbedUnimplementedV3DataLiveChatMessageServiceServer() +} + +// UnimplementedV3DataLiveChatMessageServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedV3DataLiveChatMessageServiceServer struct{} + +func (UnimplementedV3DataLiveChatMessageServiceServer) StreamList(*LiveChatMessageListRequest, grpc.ServerStreamingServer[LiveChatMessageListResponse]) error { + return status.Error(codes.Unimplemented, "method StreamList not implemented") +} +func (UnimplementedV3DataLiveChatMessageServiceServer) mustEmbedUnimplementedV3DataLiveChatMessageServiceServer() { +} +func (UnimplementedV3DataLiveChatMessageServiceServer) testEmbeddedByValue() {} + +// UnsafeV3DataLiveChatMessageServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to V3DataLiveChatMessageServiceServer will +// result in compilation errors. +type UnsafeV3DataLiveChatMessageServiceServer interface { + mustEmbedUnimplementedV3DataLiveChatMessageServiceServer() +} + +func RegisterV3DataLiveChatMessageServiceServer(s grpc.ServiceRegistrar, srv V3DataLiveChatMessageServiceServer) { + // If the following call panics, it indicates UnimplementedV3DataLiveChatMessageServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&V3DataLiveChatMessageService_ServiceDesc, srv) +} + +func _V3DataLiveChatMessageService_StreamList_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(LiveChatMessageListRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(V3DataLiveChatMessageServiceServer).StreamList(m, &grpc.GenericServerStream[LiveChatMessageListRequest, LiveChatMessageListResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type V3DataLiveChatMessageService_StreamListServer = grpc.ServerStreamingServer[LiveChatMessageListResponse] + +// V3DataLiveChatMessageService_ServiceDesc is the grpc.ServiceDesc for V3DataLiveChatMessageService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var V3DataLiveChatMessageService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "youtube.api.v3.V3DataLiveChatMessageService", + HandlerType: (*V3DataLiveChatMessageServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamList", + Handler: _V3DataLiveChatMessageService_StreamList_Handler, + ServerStreams: true, + }, + }, + Metadata: "stream_list.proto", +} diff --git a/apps/desktop/src-sidecar/proto/stream_list.proto b/apps/desktop/src-sidecar/proto/stream_list.proto new file mode 100644 index 0000000..62c1478 --- /dev/null +++ b/apps/desktop/src-sidecar/proto/stream_list.proto @@ -0,0 +1,221 @@ +// YouTube Live Chat gRPC streaming API. +// Source: https://developers.google.com/youtube/v3/live/streaming-live-chat +// Licensed under Apache 2.0. +syntax = "proto2"; + +package youtube.api.v3; + +option go_package = "github.com/ImpulseB23/Prismoid/sidecar/internal/youtube/ytpb"; + +import "google/protobuf/duration.proto"; + +service V3DataLiveChatMessageService { + rpc StreamList(LiveChatMessageListRequest) + returns (stream LiveChatMessageListResponse) {} +} + +message LiveChatMessageListRequest { + optional string live_chat_id = 1; + optional string hl = 2; + optional uint32 profile_image_size = 3; + optional uint32 max_results = 98; + optional string page_token = 99; + repeated string part = 100; +} + +message LiveChatMessageListResponse { + optional string kind = 200; + optional string etag = 201; + optional string offline_at = 2; + optional PageInfo page_info = 1004; + optional string next_page_token = 100602; + repeated LiveChatMessage items = 1007; + optional LiveChatMessage active_poll_item = 1008; +} + +message LiveChatMessage { + optional string kind = 200; + optional string etag = 201; + optional string id = 101; + optional LiveChatMessageSnippet snippet = 2; + optional LiveChatMessageAuthorDetails author_details = 3; +} + +message LiveChatMessageAuthorDetails { + optional string channel_id = 10101; + optional string channel_url = 102; + optional string display_name = 103; + optional string profile_image_url = 104; + optional bool is_verified = 4; + optional bool is_chat_owner = 5; + optional bool is_chat_sponsor = 6; + optional bool is_chat_moderator = 7; +} + +message LiveChatMessageSnippet { + message TypeWrapper { + enum Type { + INVALID_TYPE = 0; + TEXT_MESSAGE_EVENT = 1; + TOMBSTONE = 2; + FAN_FUNDING_EVENT = 3; + CHAT_ENDED_EVENT = 4; + SPONSOR_ONLY_MODE_STARTED_EVENT = 5; + SPONSOR_ONLY_MODE_ENDED_EVENT = 6; + NEW_SPONSOR_EVENT = 7; + MEMBER_MILESTONE_CHAT_EVENT = 17; + MEMBERSHIP_GIFTING_EVENT = 18; + GIFT_MEMBERSHIP_RECEIVED_EVENT = 19; + MESSAGE_DELETED_EVENT = 8; + MESSAGE_RETRACTED_EVENT = 9; + USER_BANNED_EVENT = 10; + SUPER_CHAT_EVENT = 15; + SUPER_STICKER_EVENT = 16; + POLL_EVENT = 20; + GIFT_EVENT = 21; + } + } + + optional TypeWrapper.Type type = 1; + optional string live_chat_id = 201; + optional string author_channel_id = 301; + optional string published_at = 4; + optional bool has_display_content = 17; + optional string display_message = 16; + + oneof displayed_content { + LiveChatTextMessageDetails text_message_details = 19; + LiveChatMessageDeletedDetails message_deleted_details = 20; + LiveChatMessageRetractedDetails message_retracted_details = 21; + LiveChatUserBannedMessageDetails user_banned_details = 22; + LiveChatSuperChatDetails super_chat_details = 27; + LiveChatSuperStickerDetails super_sticker_details = 28; + LiveChatNewSponsorDetails new_sponsor_details = 29; + LiveChatMemberMilestoneChatDetails member_milestone_chat_details = 30; + LiveChatMembershipGiftingDetails membership_gifting_details = 31; + LiveChatGiftMembershipReceivedDetails gift_membership_received_details = 32; + LiveChatPollDetails poll_details = 33; + LiveChatGiftDetails gift_details = 34; + } +} + +message LiveChatTextMessageDetails { + optional string message_text = 1; +} + +message LiveChatMessageDeletedDetails { + optional string deleted_message_id = 101; +} + +message LiveChatMessageRetractedDetails { + optional string retracted_message_id = 201; +} + +message LiveChatUserBannedMessageDetails { + message BanTypeWrapper { + enum BanType { + PERMANENT = 1; + TEMPORARY = 2; + } + } + + optional ChannelProfileDetails banned_user_details = 1; + optional BanTypeWrapper.BanType ban_type = 2; + optional uint64 ban_duration_seconds = 4; +} + +message LiveChatSuperChatDetails { + optional uint64 amount_micros = 1; + optional string currency = 2; + optional string amount_display_string = 3; + optional string user_comment = 4; + optional uint32 tier = 5; +} + +message LiveChatSuperStickerDetails { + optional uint64 amount_micros = 1; + optional string currency = 2; + optional string amount_display_string = 3; + optional uint32 tier = 4; + optional SuperStickerMetadata super_sticker_metadata = 5; +} + +message LiveChatFanFundingEventDetails { + optional uint64 amount_micros = 1; + optional string currency = 2; + optional string amount_display_string = 3; + optional string user_comment = 4; +} + +message LiveChatNewSponsorDetails { + optional string member_level_name = 1; + optional bool is_upgrade = 2; +} + +message LiveChatMemberMilestoneChatDetails { + optional string member_level_name = 1; + optional uint32 member_month = 2; + optional string user_comment = 3; +} + +message LiveChatMembershipGiftingDetails { + optional int32 gift_memberships_count = 1; + optional string gift_memberships_level_name = 2; +} + +message LiveChatGiftMembershipReceivedDetails { + optional string member_level_name = 1; + optional string gifter_channel_id = 2; + optional string associated_membership_gifting_message_id = 3; +} + +message LiveChatPollDetails { + message PollMetadata { + message PollOption { + optional string option_text = 1; + optional int64 tally = 2; + } + optional string question_text = 1; + repeated PollOption options = 2; + } + + message PollStatusWrapper { + enum PollStatus { + UNKNOWN = 0; + ACTIVE = 1; + CLOSED = 2; + } + } + + optional PollMetadata metadata = 1; + optional PollStatusWrapper.PollStatus status = 2; +} + +message LiveChatGiftDetails { + optional string gift_name = 1; + optional google.protobuf.Duration gift_duration = 2; + optional int32 jewels_amount = 3; + optional string gift_url = 4; + optional string alt_text = 5; + optional string language = 6; + optional bool has_visual_effect = 7; + optional int32 combo_count = 8; +} + +message SuperStickerMetadata { + optional string sticker_id = 1; + optional string alt_text = 2; + optional string alt_text_language = 3; +} + +message ChannelProfileDetails { + optional string channel_id = 101; + optional string channel_url = 2; + optional string display_name = 3; + optional string profile_image_url = 4; +} + +message PageInfo { + optional int32 total_results = 1; + optional int32 results_per_page = 2; +} diff --git a/apps/desktop/src-tauri/src/host.rs b/apps/desktop/src-tauri/src/host.rs index 7abc5c3..e07f013 100644 --- a/apps/desktop/src-tauri/src/host.rs +++ b/apps/desktop/src-tauri/src/host.rs @@ -12,12 +12,15 @@ use std::time::Duration; use serde::Serialize; use crate::emote_index::{EmoteBundle, EmoteIndex}; -use crate::message::{parse_kick_event, parse_twitch_envelope, UnifiedMessage}; +use crate::message::{ + parse_kick_event, parse_twitch_envelope, parse_youtube_message, UnifiedMessage, +}; use crate::ringbuf::RawHandle; /// Platform tag bytes prepended by the Go sidecar. Must match control.go. const TAG_TWITCH: u8 = 0x01; const TAG_KICK: u8 = 0x02; +const TAG_YOUTUBE: u8 = 0x03; /// Timeout for [`ringbuf::RingBufReader::wait_for_signal`] in the host drain /// loop. In the happy path the sidecar signals the auto-reset event after @@ -45,6 +48,10 @@ pub struct TwitchCreds { /// The caller is responsible for clearing the scratch between drain ticks; /// this function only appends. /// +/// Each payload is prefixed with a 1-byte platform tag (0x01 = Twitch, +/// 0x03 = YouTube). The tag determines which parser is invoked on the +/// remaining bytes. +/// /// Each successful parse is scanned for emotes against `emote_index`. The /// scan is cheap when the index is empty (no automaton, early return) so /// passing a fresh index is fine in tests and during the gap before @@ -66,6 +73,7 @@ pub fn parse_batch(raw: &[Vec], batch: &mut Vec, emote_index let outcome = std::panic::catch_unwind(|| match tag { TAG_TWITCH => parse_twitch_envelope(data), TAG_KICK => parse_kick_event(data), + TAG_YOUTUBE => parse_youtube_message(data), _ => { tracing::warn!(tag, "unknown platform tag, dropping message"); Ok(None) @@ -133,6 +141,32 @@ pub fn build_twitch_connect_line(creds: &TwitchCreds) -> serde_json::Result serde_json::Result> { + #[derive(Serialize)] + struct ConnectCmd<'a> { + cmd: &'a str, + api_key: &'a str, + live_chat_id: &'a str, + } + let cmd = ConnectCmd { + cmd: "youtube_connect", + api_key: &creds.api_key, + live_chat_id: &creds.live_chat_id, + }; + let mut bytes = serde_json::to_vec(&cmd)?; + bytes.push(b'\n'); + Ok(bytes) +} + /// Serializes a `kick_connect` control command line for the sidecar. #[allow(dead_code)] pub fn build_kick_connect_line(chatroom_id: i64) -> serde_json::Result> { @@ -259,6 +293,7 @@ pub fn parse_sidecar_event(bytes: &[u8]) -> SidecarEvent { #[cfg(test)] mod tests { use super::*; + use crate::message::Platform; #[test] fn bootstrap_line_has_expected_fields_and_newline() { @@ -290,10 +325,23 @@ mod tests { assert_eq!(parsed["user_id"], "uid"); } + fn tag_twitch(json: &[u8]) -> Vec { + let mut v = Vec::with_capacity(1 + json.len()); + v.push(TAG_TWITCH); + v.extend_from_slice(json); + v + } + + fn tag_youtube(json: &[u8]) -> Vec { + let mut v = Vec::with_capacity(1 + json.len()); + v.push(TAG_YOUTUBE); + v.extend_from_slice(json); + v + } + #[test] fn parse_batch_filters_non_chat_and_parse_errors() { - let viewer = { - let json = br##"{ + let viewer = tag_twitch(br##"{ "metadata": {"message_id":"m","message_type":"notification","message_timestamp":"2023-11-06T18:11:47.492Z"}, "payload": { "subscription": {"type":"channel.chat.message"}, @@ -302,22 +350,9 @@ mod tests { "message_id":"mid","message":{"text":"hi"} } } - }"##; - let mut tagged = vec![TAG_TWITCH]; - tagged.extend_from_slice(json); - tagged - }; - let keepalive = { - let json = br##"{"metadata":{"message_id":"ka","message_type":"session_keepalive","message_timestamp":"2023-11-06T18:11:49.000Z"},"payload":{}}"##; - let mut tagged = vec![TAG_TWITCH]; - tagged.extend_from_slice(json); - tagged - }; - let junk = { - let mut tagged = vec![TAG_TWITCH]; - tagged.extend_from_slice(b"not json"); - tagged - }; + }"##); + let keepalive = tag_twitch(br##"{"metadata":{"message_id":"ka","message_type":"session_keepalive","message_timestamp":"2023-11-06T18:11:49.000Z"},"payload":{}}"##); + let junk = tag_twitch(b"not json"); let raw = vec![viewer, keepalive, junk]; let mut batch = Vec::new(); @@ -338,11 +373,7 @@ mod tests { #[test] fn parse_batch_appends_to_existing_scratch() { - // Verifies that parse_batch appends rather than clearing. The drain - // loop owns clearing between ticks; this lets the loop avoid any - // allocation churn on the hot path. - let viewer = { - let json = br##"{ + let viewer = tag_twitch(br##"{ "metadata": {"message_id":"m","message_type":"notification","message_timestamp":"2023-11-06T18:11:47.492Z"}, "payload": { "subscription": {"type":"channel.chat.message"}, @@ -351,11 +382,7 @@ mod tests { "message_id":"mid","message":{"text":"second"} } } - }"##; - let mut tagged = vec![TAG_TWITCH]; - tagged.extend_from_slice(json); - tagged - }; + }"##); let mut batch = Vec::new(); let idx = EmoteIndex::new(); @@ -373,8 +400,7 @@ mod tests { fn parse_batch_attaches_emote_spans_from_index() { use crate::emote_index::{EmoteMeta, Provider}; - let viewer = { - let json = br##"{ + let viewer = tag_twitch(br##"{ "metadata": {"message_id":"m","message_type":"notification","message_timestamp":"2023-11-06T18:11:47.492Z"}, "payload": { "subscription": {"type":"channel.chat.message"}, @@ -383,11 +409,7 @@ mod tests { "message_id":"mid","message":{"text":"hello Kappa world"} } } - }"##; - let mut tagged = vec![TAG_TWITCH]; - tagged.extend_from_slice(json); - tagged - }; + }"##); let idx = EmoteIndex::new(); idx.load([EmoteMeta { @@ -413,6 +435,33 @@ mod tests { assert_eq!(span.emote.code.as_ref(), "Kappa"); } + #[test] + fn parse_batch_routes_youtube_messages() { + let yt_msg = tag_youtube(br##"{"id":"yt-1","snippet":{"type":"TEXT_MESSAGE_EVENT","published_at":"2024-01-01T00:00:00Z","display_message":"hello from yt","text_message_details":{"message_text":"hello from yt"}},"author_details":{"channel_id":"UC123","display_name":"YTUser","is_chat_owner":false,"is_chat_moderator":false,"is_chat_sponsor":false}}"##); + + let mut batch = Vec::new(); + let idx = EmoteIndex::new(); + parse_batch(std::slice::from_ref(&yt_msg), &mut batch, &idx); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].message_text, "hello from yt"); + assert!(matches!(batch[0].platform, Platform::YouTube)); + assert_eq!(batch[0].display_name, "YTUser"); + } + + #[test] + fn parse_batch_mixed_platforms() { + let twitch = tag_twitch(br##"{"metadata":{"message_id":"m","message_type":"notification","message_timestamp":"2023-11-06T18:11:47.492Z"},"payload":{"subscription":{"type":"channel.chat.message"},"event":{"chatter_user_id":"1","chatter_user_login":"u","chatter_user_name":"U","message_id":"mid","message":{"text":"from twitch"}}}}"##); + let yt = tag_youtube(br##"{"id":"yt-1","snippet":{"type":"TEXT_MESSAGE_EVENT","published_at":"2024-01-01T00:00:00Z","text_message_details":{"message_text":"from youtube"}},"author_details":{"channel_id":"UC1","display_name":"YT"}}"##); + + let raw = vec![twitch, yt]; + let mut batch = Vec::new(); + let idx = EmoteIndex::new(); + parse_batch(&raw, &mut batch, &idx); + assert_eq!(batch.len(), 2); + assert!(matches!(batch[0].platform, Platform::Twitch)); + assert!(matches!(batch[1].platform, Platform::YouTube)); + } + #[test] fn parse_batch_handles_kick_messages() { let kick_msg = { @@ -431,10 +480,19 @@ mod tests { } #[test] - fn parse_batch_skips_empty_and_unknown_tags() { - let empty: Vec = vec![]; - let unknown = vec![0xFF, b'{', b'}']; - let raw = vec![empty, unknown]; + fn parse_batch_skips_empty_payloads() { + let raw = vec![vec![]]; + let mut batch = Vec::new(); + let idx = EmoteIndex::new(); + parse_batch(&raw, &mut batch, &idx); + assert!(batch.is_empty()); + } + + #[test] + fn parse_batch_unknown_tag_dropped() { + let mut payload = vec![0xFF]; + payload.extend_from_slice(b"{}"); + let raw = vec![payload]; let mut batch = Vec::new(); let idx = EmoteIndex::new(); parse_batch(&raw, &mut batch, &idx); diff --git a/apps/desktop/src-tauri/src/message.rs b/apps/desktop/src-tauri/src/message.rs index f8fd57b..b7b393e 100644 --- a/apps/desktop/src-tauri/src/message.rs +++ b/apps/desktop/src-tauri/src/message.rs @@ -8,9 +8,6 @@ use crate::emote_index::EmoteSpan; #[derive(Debug, Clone, Serialize)] pub enum Platform { Twitch, - // Constructed by platform parsers landing in follow-up tickets. Kept here - // so the frontend's discriminated union stays the single source of truth. - #[allow(dead_code)] YouTube, Kick, } @@ -206,6 +203,125 @@ pub fn parse_twitch_envelope(bytes: &[u8]) -> Result, Par })) } +// --- YouTube protojson deserialization types (private, narrow) --- + +#[derive(Debug, Deserialize)] +struct YouTubeMessage { + #[serde(default)] + id: Option, + #[serde(default)] + snippet: Option, + #[serde(default)] + author_details: Option, +} + +#[derive(Debug, Deserialize)] +struct YouTubeSnippet { + #[serde(default, rename = "type")] + msg_type: Option, + #[serde(default)] + published_at: Option, + #[serde(default)] + display_message: Option, + #[serde(default)] + text_message_details: Option, +} + +#[derive(Debug, Deserialize)] +struct YouTubeTextDetails { + #[serde(default)] + message_text: Option, +} + +#[derive(Debug, Deserialize)] +struct YouTubeAuthorDetails { + #[serde(default)] + channel_id: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + is_chat_owner: Option, + #[serde(default)] + is_chat_moderator: Option, + #[serde(default)] + is_chat_sponsor: Option, +} + +/// Parses a protojson-serialized YouTube `LiveChatMessage` into a +/// [`UnifiedMessage`]. Returns `Ok(None)` for non-text message types +/// (super chats, bans, etc. are future work). +pub fn parse_youtube_message(bytes: &[u8]) -> Result, ParseError> { + let msg: YouTubeMessage = serde_json::from_slice(bytes)?; + + let snippet = match msg.snippet { + Some(s) => s, + None => return Ok(None), + }; + + // Only handle text messages for now + let msg_type = snippet.msg_type.as_deref().unwrap_or(""); + if msg_type != "TEXT_MESSAGE_EVENT" { + return Ok(None); + } + + let text = match snippet + .text_message_details + .and_then(|d| d.message_text) + .or(snippet.display_message) + .filter(|s| !s.is_empty()) + { + Some(t) => t, + None => return Ok(None), + }; + + let author = match msg.author_details { + Some(a) => a, + None => return Ok(None), + }; + + let channel_id = match author.channel_id.filter(|s| !s.is_empty()) { + Some(c) => c, + None => return Ok(None), + }; + let display_name = match author.display_name.filter(|s| !s.is_empty()) { + Some(d) => d, + None => return Ok(None), + }; + let id = match msg.id.filter(|s| !s.is_empty()) { + Some(i) => i, + None => return Ok(None), + }; + + let is_broadcaster = author.is_chat_owner.unwrap_or(false); + let is_mod = is_broadcaster || author.is_chat_moderator.unwrap_or(false); + let is_subscriber = author.is_chat_sponsor.unwrap_or(false); + + let timestamp = match snippet.published_at.as_deref() { + Some(s) => chrono::DateTime::parse_from_rfc3339(s) + .map_err(ParseError::Timestamp)? + .timestamp_millis(), + None => chrono::Utc::now().timestamp_millis(), + }; + + Ok(Some(UnifiedMessage { + id, + platform: Platform::YouTube, + timestamp, + arrival_time: chrono::Utc::now().timestamp_millis(), + username: channel_id.clone(), + display_name, + platform_user_id: channel_id, + message_text: text, + badges: Vec::new(), + is_mod, + is_subscriber, + is_broadcaster, + color: None, + reply_to: None, + emote_spans: Vec::new(), + })) +} + // --- Kick Pusher deserialization types (private, narrow) --- #[derive(Debug, Deserialize)] @@ -528,6 +644,208 @@ mod tests { assert!(std::error::Error::source(&err).is_some()); } + // --- YouTube parser tests --- + + const YT_TEXT_MSG: &[u8] = br##"{ + "id": "yt-msg-1", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "live_chat_id": "chat123", + "author_channel_id": "UC_abc", + "published_at": "2024-06-15T12:30:00Z", + "has_display_content": true, + "display_message": "hello youtube", + "text_message_details": { + "message_text": "hello youtube" + } + }, + "author_details": { + "channel_id": "UC_abc", + "display_name": "TestViewer", + "is_verified": false, + "is_chat_owner": false, + "is_chat_sponsor": false, + "is_chat_moderator": false + } + }"##; + + const YT_OWNER_MSG: &[u8] = br##"{ + "id": "yt-msg-2", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "2024-06-15T12:31:00Z", + "text_message_details": { "message_text": "welcome all" } + }, + "author_details": { + "channel_id": "UC_owner", + "display_name": "Streamer", + "is_chat_owner": true, + "is_chat_moderator": false, + "is_chat_sponsor": false + } + }"##; + + const YT_MOD_MSG: &[u8] = br##"{ + "id": "yt-msg-3", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "2024-06-15T12:32:00Z", + "text_message_details": { "message_text": "calm down chat" } + }, + "author_details": { + "channel_id": "UC_mod", + "display_name": "ModUser", + "is_chat_owner": false, + "is_chat_moderator": true, + "is_chat_sponsor": true + } + }"##; + + const YT_SUPER_CHAT: &[u8] = br##"{ + "id": "yt-sc-1", + "snippet": { + "type": "SUPER_CHAT_EVENT", + "published_at": "2024-06-15T12:33:00Z", + "display_message": "$5.00", + "super_chat_details": { "amount_micros": 5000000, "currency": "USD" } + }, + "author_details": { + "channel_id": "UC_donor", + "display_name": "BigDonor" + } + }"##; + + #[test] + fn parses_youtube_text_message() { + let msg = parse_youtube_message(YT_TEXT_MSG).unwrap().unwrap(); + assert_eq!(msg.id, "yt-msg-1"); + assert!(matches!(msg.platform, Platform::YouTube)); + assert_eq!(msg.display_name, "TestViewer"); + assert_eq!(msg.platform_user_id, "UC_abc"); + assert_eq!(msg.message_text, "hello youtube"); + assert!(!msg.is_mod); + assert!(!msg.is_subscriber); + assert!(!msg.is_broadcaster); + assert!(msg.color.is_none()); + assert!(msg.timestamp > 0); + } + + #[test] + fn youtube_owner_implies_mod() { + let msg = parse_youtube_message(YT_OWNER_MSG).unwrap().unwrap(); + assert!(msg.is_broadcaster); + assert!(msg.is_mod); + assert_eq!(msg.display_name, "Streamer"); + } + + #[test] + fn youtube_moderator_flags() { + let msg = parse_youtube_message(YT_MOD_MSG).unwrap().unwrap(); + assert!(msg.is_mod); + assert!(msg.is_subscriber); // is_chat_sponsor maps to subscriber + assert!(!msg.is_broadcaster); + } + + #[test] + fn youtube_non_text_returns_none() { + let result = parse_youtube_message(YT_SUPER_CHAT).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn youtube_malformed_json_returns_err() { + let err = parse_youtube_message(b"not json").unwrap_err(); + assert!(matches!(err, ParseError::Json(_))); + } + + #[test] + fn youtube_missing_snippet_returns_none() { + let msg = br#"{"id":"x","author_details":{"channel_id":"c","display_name":"d"}}"#; + assert!(parse_youtube_message(msg).unwrap().is_none()); + } + + #[test] + fn youtube_missing_text_returns_none() { + let msg = br##"{ + "id": "yt-empty", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "2024-06-15T12:30:00Z" + }, + "author_details": { "channel_id": "c", "display_name": "d" } + }"##; + assert!(parse_youtube_message(msg).unwrap().is_none()); + } + + #[test] + fn youtube_empty_text_returns_none() { + let msg = br##"{ + "id": "yt-empty", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "2024-06-15T12:30:00Z", + "text_message_details": { "message_text": "" } + }, + "author_details": { "channel_id": "c", "display_name": "d" } + }"##; + assert!(parse_youtube_message(msg).unwrap().is_none()); + } + + #[test] + fn youtube_missing_author_returns_none() { + let msg = br##"{ + "id": "yt-1", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "2024-06-15T12:30:00Z", + "text_message_details": { "message_text": "hi" } + } + }"##; + assert!(parse_youtube_message(msg).unwrap().is_none()); + } + + #[test] + fn youtube_missing_id_returns_none() { + let msg = br##"{ + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "2024-06-15T12:30:00Z", + "text_message_details": { "message_text": "hi" } + }, + "author_details": { "channel_id": "c", "display_name": "d" } + }"##; + assert!(parse_youtube_message(msg).unwrap().is_none()); + } + + #[test] + fn youtube_bad_timestamp_returns_err() { + let msg = br##"{ + "id": "yt-1", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "published_at": "not a date", + "text_message_details": { "message_text": "hi" } + }, + "author_details": { "channel_id": "c", "display_name": "d" } + }"##; + let err = parse_youtube_message(msg).unwrap_err(); + assert!(matches!(err, ParseError::Timestamp(_))); + } + + #[test] + fn youtube_missing_timestamp_uses_now() { + let msg = br##"{ + "id": "yt-1", + "snippet": { + "type": "TEXT_MESSAGE_EVENT", + "text_message_details": { "message_text": "hi" } + }, + "author_details": { "channel_id": "c", "display_name": "d" } + }"##; + let parsed = parse_youtube_message(msg).unwrap().unwrap(); + assert!(parsed.timestamp > 0); + } + // --- Kick parser tests --- const KICK_CHAT_EVENT: &[u8] = br##"{ diff --git a/codecov.yml b/codecov.yml index 6695172..db222ee 100644 --- a/codecov.yml +++ b/codecov.yml @@ -28,6 +28,7 @@ ignore: - "apps/desktop/node_modules/" - "apps/desktop/dist/" - "apps/desktop/src-sidecar/cmd/" # entry-point shims; logic lives in internal/sidecar + - "apps/desktop/src-sidecar/internal/youtube/ytpb/" # generated protobuf/grpc bindings - "apps/desktop/src-tauri/src/lib.rs" # tauri entry + setup wiring; logic lives in host.rs - "apps/desktop/src-tauri/src/main.rs" # 3-line binary shim - "apps/desktop/src-tauri/src/twitch_auth/commands.rs" # thin #[tauri::command] adapters; logic lives in auth_state.rs diff --git a/docs/platform-apis.md b/docs/platform-apis.md index 407f2d8..52f4827 100644 --- a/docs/platform-apis.md +++ b/docs/platform-apis.md @@ -55,9 +55,13 @@ Google OAuth 2.0. YouTube Live Streaming API scope. Single app-level Google Clou ### Chat (read) -gRPC `liveChatMessages.streamList` - server-streaming RPC. Not REST polling. This keeps quota usage low and latency minimal. +gRPC `liveChatMessages.streamList` (service `V3DataLiveChatMessageService` at `youtube.googleapis.com:443`) - server-streaming RPC. Not REST polling. This keeps quota usage low and latency minimal. -The Go sidecar maintains the gRPC stream and writes raw protobuf bytes to the ring buffer. +Requires a `liveChatId` obtained from `videos.list` (field `liveStreamingDetails.activeLiveChatId`). Supports API key auth for read-only access and OAuth 2.0 Bearer tokens for authenticated access. + +The Go sidecar maintains the gRPC stream, marshals each `LiveChatMessage` to JSON via `protojson`, prepends the `0x03` platform tag, and writes to the ring buffer. The Rust host dispatches tagged payloads to `parse_youtube_message()`. + +Proto definition: `apps/desktop/src-sidecar/proto/stream_list.proto` (from Google's official streaming-live-chat docs). ### Chat (write)