feat: add twitch graphql streamer resolution
This commit is contained in:
+84
-1
@@ -5,6 +5,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -15,7 +16,9 @@ import (
|
||||
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/config"
|
||||
"parasocial/internal/gql"
|
||||
"parasocial/internal/tui"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
|
||||
// Run loads the application configuration and starts the terminal UI.
|
||||
@@ -39,7 +42,7 @@ func Run(ctx context.Context) error {
|
||||
|
||||
program := tea.NewProgram(
|
||||
tui.New(tui.Options{
|
||||
Streamers: cfg.Streamers,
|
||||
Streamers: twitch.LoadingStreamerEntries(cfg.Streamers),
|
||||
AuthState: state,
|
||||
StartAuth: func(ch chan<- tui.AuthUpdate) {
|
||||
go func() {
|
||||
@@ -60,6 +63,27 @@ func Run(ctx context.Context) error {
|
||||
ch <- tui.AuthUpdate{State: state, Done: true}
|
||||
}()
|
||||
},
|
||||
StartResolve: func(state *auth.State, ch chan<- tui.StreamerUpdate) {
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
service, err := newTwitchService(httpClient, state)
|
||||
if err != nil {
|
||||
ch <- tui.StreamerUpdate{Err: err, Done: true}
|
||||
return
|
||||
}
|
||||
if err := resolveStreamerEntries(ctx, service, cfg.Streamers, func(update tui.StreamerUpdate) {
|
||||
ch <- update
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
ch <- tui.StreamerUpdate{Err: err, Done: true}
|
||||
return
|
||||
}
|
||||
ch <- tui.StreamerUpdate{Done: true}
|
||||
}()
|
||||
},
|
||||
}),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithInput(os.Stdin),
|
||||
@@ -68,3 +92,62 @@ func Run(ctx context.Context) error {
|
||||
_, err = program.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
// streamerService captures the Twitch lookups the app needs during UI resolution.
|
||||
type streamerService interface {
|
||||
CurrentUser(context.Context) (*twitch.Viewer, error)
|
||||
ResolveStreamer(context.Context, string) (*twitch.Channel, error)
|
||||
}
|
||||
|
||||
// newTwitchService builds a Twitch service from the authenticated session state.
|
||||
func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) {
|
||||
client := &gql.Client{
|
||||
HTTPClient: httpClient,
|
||||
Session: gql.Session{
|
||||
AccessToken: state.AccessToken,
|
||||
ClientID: state.ClientID,
|
||||
DeviceID: state.DeviceID,
|
||||
UserAgent: auth.TVUserAgent(),
|
||||
},
|
||||
}
|
||||
if err := client.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("configure graphql session: %w", err)
|
||||
}
|
||||
return &twitch.Service{GQL: client}, nil
|
||||
}
|
||||
|
||||
// resolveStreamerEntries streams viewer and streamer resolution results into the TUI.
|
||||
func resolveStreamerEntries(ctx context.Context, service streamerService, logins []string, send func(tui.StreamerUpdate)) error {
|
||||
viewer, err := service.CurrentUser(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current user: %w", err)
|
||||
}
|
||||
send(tui.StreamerUpdate{Viewer: viewer})
|
||||
|
||||
for index, login := range logins {
|
||||
entry := twitch.StreamerEntry{
|
||||
ConfigLogin: login,
|
||||
Status: twitch.StreamerLoading,
|
||||
}
|
||||
|
||||
channel, err := service.ResolveStreamer(ctx, login)
|
||||
switch {
|
||||
case err == nil:
|
||||
entry.Login = channel.Login
|
||||
entry.ChannelID = channel.ID
|
||||
entry.Status = twitch.StreamerReady
|
||||
case errors.Is(err, context.Canceled):
|
||||
return err
|
||||
default:
|
||||
entry.Status = twitch.StreamerError
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
|
||||
send(tui.StreamerUpdate{
|
||||
Index: index,
|
||||
Entry: &entry,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// app_test.go covers the app-layer orchestration around Twitch viewer and streamer resolution.
|
||||
// It exercises the background resolution loop independently of Bubble Tea startup
|
||||
// so the app wiring can be validated without requiring a full interactive session.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"parasocial/internal/tui"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
|
||||
// fakeStreamerService is a test double for the app's Twitch resolution dependency.
|
||||
type fakeStreamerService struct {
|
||||
viewer *twitch.Viewer
|
||||
viewerErr error
|
||||
channels map[string]*twitch.Channel
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
// CurrentUser returns the configured fake viewer or viewer error.
|
||||
func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) {
|
||||
if f.viewerErr != nil {
|
||||
return nil, f.viewerErr
|
||||
}
|
||||
return f.viewer, nil
|
||||
}
|
||||
|
||||
// ResolveStreamer returns the configured fake channel or resolution error.
|
||||
func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) {
|
||||
if err, ok := f.errs[login]; ok {
|
||||
return nil, err
|
||||
}
|
||||
return f.channels[login], nil
|
||||
}
|
||||
|
||||
// TestResolveStreamerEntries verifies that app resolution emits viewer, success, and error updates.
|
||||
func TestResolveStreamerEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := &fakeStreamerService{
|
||||
viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
||||
channels: map[string]*twitch.Channel{
|
||||
"alpha": {ID: "1", Login: "alpha_live"},
|
||||
},
|
||||
errs: map[string]error{
|
||||
"beta": errors.New("lookup failed"),
|
||||
},
|
||||
}
|
||||
|
||||
var updates []tui.StreamerUpdate
|
||||
err := resolveStreamerEntries(context.Background(), service, []string{"alpha", "beta"}, func(update tui.StreamerUpdate) {
|
||||
updates = append(updates, update)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 3 {
|
||||
t.Fatalf("len(updates) = %d", len(updates))
|
||||
}
|
||||
if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" {
|
||||
t.Fatalf("viewer update = %#v", updates[0])
|
||||
}
|
||||
if updates[1].Entry == nil || updates[1].Entry.Status != twitch.StreamerReady || updates[1].Entry.Login != "alpha_live" {
|
||||
t.Fatalf("alpha update = %#v", updates[1])
|
||||
}
|
||||
if updates[2].Entry == nil || updates[2].Entry.Status != twitch.StreamerError {
|
||||
t.Fatalf("beta update = %#v", updates[2])
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,33 @@ func TestSaveStatePersistsCookieEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStateAllowsZeroExpiresIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cookies.json")
|
||||
state := &State{
|
||||
AccessToken: "token",
|
||||
TokenType: "bearer",
|
||||
Scopes: RequiredScopes,
|
||||
Login: "viewer",
|
||||
UserID: "123",
|
||||
ClientID: ClientID,
|
||||
ExpiresIn: 0,
|
||||
DeviceID: "device",
|
||||
}
|
||||
if err := SaveState(path, state); err != nil {
|
||||
t.Fatalf("SaveState() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadState(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.ExpiresIn != 0 {
|
||||
t.Fatalf("ExpiresIn = %d, want 0", loaded.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStateMissingFileReturnsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -107,8 +107,6 @@ func (s *State) validate() error {
|
||||
return fmt.Errorf("missing client_id")
|
||||
case len(s.Scopes) == 0:
|
||||
return fmt.Errorf("missing scopes")
|
||||
case s.ExpiresIn <= 0:
|
||||
return fmt.Errorf("expires_in must be greater than zero")
|
||||
case s.DeviceID == "":
|
||||
return fmt.Errorf("missing device_id")
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// client.go defines the authenticated Twitch GraphQL transport layer.
|
||||
// It owns request encoding, session header wiring, and response decoding
|
||||
// so higher-level Twitch services can issue typed operations without repeating HTTP logic.
|
||||
package gql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DefaultEndpoint = "https://gql.twitch.tv/gql"
|
||||
|
||||
// Session carries the authenticated Twitch headers needed for GraphQL requests.
|
||||
type Session struct {
|
||||
AccessToken string
|
||||
ClientID string
|
||||
DeviceID string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Client posts authenticated GraphQL requests to Twitch.
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
Endpoint string
|
||||
Session Session
|
||||
}
|
||||
|
||||
// Error is one entry in a GraphQL errors list.
|
||||
type Error struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// StatusError reports a non-200 HTTP response together with its response body.
|
||||
type StatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error formats the HTTP status failure in a way that preserves the response body.
|
||||
func (e *StatusError) Error() string {
|
||||
body := strings.TrimSpace(e.Body)
|
||||
if body == "" {
|
||||
return fmt.Sprintf("graphql status %d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("graphql status %d: %s", e.StatusCode, body)
|
||||
}
|
||||
|
||||
// Do executes one Twitch GraphQL request and decodes its data payload into out.
|
||||
func (c *Client) Do(ctx context.Context, request Request, out any) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
if err := json.NewEncoder(&body).Encode(request); err != nil {
|
||||
return fmt.Errorf("encode graphql request %s: %w", request.operationLabel(), err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), &body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "OAuth "+c.Session.AccessToken)
|
||||
req.Header.Set("Client-Id", c.Session.ClientID)
|
||||
req.Header.Set("User-Agent", c.Session.UserAgent)
|
||||
req.Header.Set("X-Device-Id", c.Session.DeviceID)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post graphql %s: %w", request.operationLabel(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read graphql %s response: %w", request.operationLabel(), err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &StatusError{StatusCode: resp.StatusCode, Body: string(respBody)}
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []Error `json:"errors,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &envelope); err != nil {
|
||||
return fmt.Errorf("parse graphql %s response: %w", request.operationLabel(), err)
|
||||
}
|
||||
if len(envelope.Errors) > 0 {
|
||||
return fmt.Errorf("graphql %s returned errors: %s", request.operationLabel(), formatErrors(envelope.Errors))
|
||||
}
|
||||
if len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) {
|
||||
return fmt.Errorf("graphql %s response missing data", request.operationLabel())
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("decode graphql %s data: %w", request.operationLabel(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatErrors joins GraphQL error messages into one readable string.
|
||||
func formatErrors(errs []Error) string {
|
||||
parts := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
if err.Message != "" {
|
||||
parts = append(parts, err.Message)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "unknown error"
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Validate rejects incomplete session configuration before any request is sent.
|
||||
func (c *Client) Validate() error {
|
||||
switch {
|
||||
case c.Session.AccessToken == "":
|
||||
return errors.New("missing access token")
|
||||
case c.Session.ClientID == "":
|
||||
return errors.New("missing client id")
|
||||
case c.Session.DeviceID == "":
|
||||
return errors.New("missing device id")
|
||||
case c.Session.UserAgent == "":
|
||||
return errors.New("missing user agent")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// httpClient returns the configured HTTP client or the default client.
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// endpoint returns the configured GraphQL endpoint or the default Twitch endpoint.
|
||||
func (c *Client) endpoint() string {
|
||||
if c.Endpoint != "" {
|
||||
return c.Endpoint
|
||||
}
|
||||
return DefaultEndpoint
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package gql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoSendsHeadersAndDecodesData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "OAuth token" {
|
||||
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("Client-Id") != "client" {
|
||||
t.Fatalf("Client-Id = %q", r.Header.Get("Client-Id"))
|
||||
}
|
||||
if r.Header.Get("X-Device-Id") != "device" {
|
||||
t.Fatalf("X-Device-Id = %q", r.Header.Get("X-Device-Id"))
|
||||
}
|
||||
if r.Header.Get("User-Agent") != "agent" {
|
||||
t.Fatalf("User-Agent = %q", r.Header.Get("User-Agent"))
|
||||
}
|
||||
var req Request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.OperationName != "GetIDFromLogin" {
|
||||
t.Fatalf("operation = %q", req.OperationName)
|
||||
}
|
||||
if req.Variables["login"] != "streamer" {
|
||||
t.Fatalf("login variable = %#v", req.Variables["login"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"ok":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
HTTPClient: server.Client(),
|
||||
Endpoint: server.URL,
|
||||
Session: Session{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
DeviceID: "device",
|
||||
UserAgent: "agent",
|
||||
},
|
||||
}
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := client.Do(context.Background(), GetIDFromLogin("streamer"), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.OK {
|
||||
t.Fatal("expected decoded data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSupportsInlineQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req Request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.OperationName != "CurrentUser" {
|
||||
t.Fatalf("operation = %q", req.OperationName)
|
||||
}
|
||||
if !strings.Contains(req.Query, "currentUser") {
|
||||
t.Fatalf("query = %q", req.Query)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"currentUser":{"id":"1","login":"viewer"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
HTTPClient: server.Client(),
|
||||
Endpoint: server.URL,
|
||||
Session: Session{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
DeviceID: "device",
|
||||
UserAgent: "agent",
|
||||
},
|
||||
}
|
||||
var out struct {
|
||||
CurrentUser struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"currentUser"`
|
||||
}
|
||||
if err := client.Do(context.Background(), CurrentUser(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.CurrentUser.Login != "viewer" {
|
||||
t.Fatalf("login = %q", out.CurrentUser.Login)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSurfacesGraphQLErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errors":[{"message":"bad auth"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
HTTPClient: server.Client(),
|
||||
Endpoint: server.URL,
|
||||
Session: Session{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
DeviceID: "device",
|
||||
UserAgent: "agent",
|
||||
},
|
||||
}
|
||||
if err := client.Do(context.Background(), CurrentUser(), &struct{}{}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedOperationHashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := GetIDFromLogin("streamer")
|
||||
if req.OperationName != "GetIDFromLogin" {
|
||||
t.Fatalf("operation = %q", req.OperationName)
|
||||
}
|
||||
if req.Extensions.PersistedQuery == nil {
|
||||
t.Fatal("expected persisted query metadata")
|
||||
}
|
||||
if got := req.Extensions.PersistedQuery.SHA256Hash; got != "94e82a7b1e3c21e186daa73ee2afc4b8f23bade1fbbff6fe8ac133f50a2f58ca" {
|
||||
t.Fatalf("hash = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// operations.go defines the minimal Twitch GraphQL operations used by the app.
|
||||
// It builds either persisted-query or inline query requests for viewer identity
|
||||
// and streamer login resolution without exposing raw payload assembly to callers.
|
||||
package gql
|
||||
|
||||
// persistedQuery stores the persisted-query metadata Twitch expects.
|
||||
type persistedQuery struct {
|
||||
Version int `json:"version"`
|
||||
SHA256Hash string `json:"sha256Hash"`
|
||||
}
|
||||
|
||||
// extensions holds the GraphQL extensions block for one request.
|
||||
type extensions struct {
|
||||
PersistedQuery *persistedQuery `json:"persistedQuery,omitempty"`
|
||||
}
|
||||
|
||||
// Request is one Twitch GraphQL operation.
|
||||
type Request struct {
|
||||
OperationName string `json:"operationName,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
Extensions extensions `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// operation builds a persisted-query request with the supplied variables.
|
||||
func operation(name, hash string, variables map[string]any) Request {
|
||||
return Request{
|
||||
OperationName: name,
|
||||
Variables: variables,
|
||||
Extensions: extensions{
|
||||
PersistedQuery: &persistedQuery{
|
||||
Version: 1,
|
||||
SHA256Hash: hash,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// queryOperation builds an inline-query request with the supplied variables.
|
||||
func queryOperation(name, query string, variables map[string]any) Request {
|
||||
return Request{
|
||||
OperationName: name,
|
||||
Query: query,
|
||||
Variables: variables,
|
||||
}
|
||||
}
|
||||
|
||||
// operationLabel returns a readable operation name for logs and errors.
|
||||
func (r Request) operationLabel() string {
|
||||
if r.OperationName != "" {
|
||||
return r.OperationName
|
||||
}
|
||||
if r.Query != "" {
|
||||
return "anonymous"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// CurrentUser fetches the canonical identity for the authenticated viewer.
|
||||
func CurrentUser() Request {
|
||||
return queryOperation("CurrentUser", "query CurrentUser { currentUser { id login } }", nil)
|
||||
}
|
||||
|
||||
// GetIDFromLogin resolves one login into a Twitch user record using Twitch's persisted-query hash.
|
||||
func GetIDFromLogin(login string) Request {
|
||||
return operation("GetIDFromLogin", "94e82a7b1e3c21e186daa73ee2afc4b8f23bade1fbbff6fe8ac133f50a2f58ca", map[string]any{
|
||||
"login": login,
|
||||
})
|
||||
}
|
||||
+107
-8
@@ -11,6 +11,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
@@ -20,8 +21,12 @@ const (
|
||||
streamerView
|
||||
)
|
||||
|
||||
// StartAuthFunc begins the asynchronous Twitch login flow for the model.
|
||||
type StartAuthFunc func(chan<- AuthUpdate)
|
||||
|
||||
// StartResolveFunc begins the asynchronous viewer and streamer resolution flow for the model.
|
||||
type StartResolveFunc func(*auth.State, chan<- StreamerUpdate)
|
||||
|
||||
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
|
||||
type AuthUpdate struct {
|
||||
Line string
|
||||
@@ -30,11 +35,21 @@ type AuthUpdate struct {
|
||||
Done bool
|
||||
}
|
||||
|
||||
// Options configures the initial streamer list, auth state, and auth starter hook.
|
||||
// StreamerUpdate carries one streamer resolution update into the TUI.
|
||||
type StreamerUpdate struct {
|
||||
Viewer *twitch.Viewer
|
||||
Entry *twitch.StreamerEntry
|
||||
Index int
|
||||
Err error
|
||||
Done bool
|
||||
}
|
||||
|
||||
// Options configures the initial streamer list, auth state, and worker starter hooks.
|
||||
type Options struct {
|
||||
Streamers []string
|
||||
Streamers []twitch.StreamerEntry
|
||||
AuthState *auth.State
|
||||
StartAuth StartAuthFunc
|
||||
StartResolve StartResolveFunc
|
||||
}
|
||||
|
||||
// authStartedMsg hands the model the channel that will stream auth updates.
|
||||
@@ -42,23 +57,33 @@ type authStartedMsg struct {
|
||||
Updates <-chan AuthUpdate
|
||||
}
|
||||
|
||||
// streamerStartedMsg hands the model the channel that will stream streamer updates.
|
||||
type streamerStartedMsg struct {
|
||||
Updates <-chan StreamerUpdate
|
||||
}
|
||||
|
||||
// Model is the terminal UI for auth and streamer display.
|
||||
type Model struct {
|
||||
streamers []string
|
||||
streamers []twitch.StreamerEntry
|
||||
authState *auth.State
|
||||
startAuth StartAuthFunc
|
||||
startResolve StartResolveFunc
|
||||
authUpdates <-chan AuthUpdate
|
||||
streamerUpdates <-chan StreamerUpdate
|
||||
authLogs []string
|
||||
authErr error
|
||||
resolveErr error
|
||||
viewer *twitch.Viewer
|
||||
mode viewMode
|
||||
}
|
||||
|
||||
// New returns a Bubble Tea model for auth and streamer display.
|
||||
func New(options Options) Model {
|
||||
model := Model{
|
||||
streamers: append([]string(nil), options.Streamers...),
|
||||
streamers: append([]twitch.StreamerEntry(nil), options.Streamers...),
|
||||
authState: options.AuthState,
|
||||
startAuth: options.StartAuth,
|
||||
startResolve: options.StartResolve,
|
||||
authLogs: []string{},
|
||||
mode: authView,
|
||||
}
|
||||
@@ -73,7 +98,7 @@ func (m Model) Init() tea.Cmd {
|
||||
if m.mode == authView {
|
||||
return startAuthSession(m.startAuth)
|
||||
}
|
||||
return nil
|
||||
return startStreamerResolution(m.startResolve, m.authState)
|
||||
}
|
||||
|
||||
// Update applies auth progress events and handles the global quit keys.
|
||||
@@ -82,6 +107,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case authStartedMsg:
|
||||
m.authUpdates = msg.Updates
|
||||
return m, waitForAuthUpdate(msg.Updates)
|
||||
case streamerStartedMsg:
|
||||
m.streamerUpdates = msg.Updates
|
||||
return m, waitForStreamerUpdate(msg.Updates)
|
||||
case AuthUpdate:
|
||||
if msg.Line != "" {
|
||||
m.authLogs = append(m.authLogs, msg.Line)
|
||||
@@ -95,12 +123,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.authState = msg.State
|
||||
m.authErr = nil
|
||||
m.mode = streamerView
|
||||
m.viewer = nil
|
||||
m.resolveErr = nil
|
||||
m.streamers = loadingEntries(m.streamers)
|
||||
return m, startStreamerResolution(m.startResolve, m.authState)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.authUpdates != nil {
|
||||
return m, waitForAuthUpdate(m.authUpdates)
|
||||
}
|
||||
case StreamerUpdate:
|
||||
if msg.Viewer != nil {
|
||||
m.viewer = msg.Viewer
|
||||
m.resolveErr = nil
|
||||
}
|
||||
if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) {
|
||||
m.streamers[msg.Index] = *msg.Entry
|
||||
}
|
||||
if msg.Err != nil {
|
||||
m.resolveErr = msg.Err
|
||||
}
|
||||
if msg.Done {
|
||||
return m, nil
|
||||
}
|
||||
if m.streamerUpdates != nil {
|
||||
return m, waitForStreamerUpdate(m.streamerUpdates)
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
@@ -131,12 +180,27 @@ func (m Model) View() string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
if m.authState != nil && m.authState.Login != "" {
|
||||
fmt.Fprintf(&builder, "Logged in as %s\n\n", m.authState.Login)
|
||||
switch {
|
||||
case m.viewer != nil:
|
||||
fmt.Fprintf(&builder, "Logged in as %s (%s)\n\n", m.viewer.Login, m.viewer.ID)
|
||||
case m.authState != nil && m.authState.Login != "":
|
||||
fmt.Fprintf(&builder, "Logged in as %s\n", m.authState.Login)
|
||||
if m.resolveErr != nil {
|
||||
fmt.Fprintf(&builder, "Viewer lookup failed: %v\n\n", m.resolveErr)
|
||||
} else {
|
||||
builder.WriteString("Resolving viewer identity...\n\n")
|
||||
}
|
||||
}
|
||||
builder.WriteString("Streamers\n")
|
||||
for i, streamer := range m.streamers {
|
||||
fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer)
|
||||
switch streamer.Status {
|
||||
case twitch.StreamerReady:
|
||||
fmt.Fprintf(&builder, "%d. %s (%s)\n", i+1, streamer.Login, streamer.ChannelID)
|
||||
case twitch.StreamerError:
|
||||
fmt.Fprintf(&builder, "%d. %s [error: %s]\n", i+1, streamer.ConfigLogin, streamer.Error)
|
||||
default:
|
||||
fmt.Fprintf(&builder, "%d. %s [loading]\n", i+1, streamer.ConfigLogin)
|
||||
}
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
return builder.String()
|
||||
@@ -154,6 +218,21 @@ func startAuthSession(start StartAuthFunc) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// startStreamerResolution starts the background streamer resolver and returns its update channel.
|
||||
func startStreamerResolution(start StartResolveFunc, state *auth.State) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if start == nil {
|
||||
return StreamerUpdate{Err: errors.New("streamer resolution start function is nil"), Done: true}
|
||||
}
|
||||
if state == nil {
|
||||
return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true}
|
||||
}
|
||||
updates := make(chan StreamerUpdate, 32)
|
||||
start(state, updates)
|
||||
return streamerStartedMsg{Updates: updates}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForAuthUpdate blocks until the next auth update is available from the worker.
|
||||
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
@@ -164,3 +243,23 @@ func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
||||
return update
|
||||
}
|
||||
}
|
||||
|
||||
// waitForStreamerUpdate blocks until the next streamer update is available from the worker.
|
||||
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
update, ok := <-updates
|
||||
if !ok {
|
||||
return StreamerUpdate{Done: true}
|
||||
}
|
||||
return update
|
||||
}
|
||||
}
|
||||
|
||||
// loadingEntries resets the UI rows back to their initial loading state.
|
||||
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
||||
logins := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
logins = append(logins, entry.ConfigLogin)
|
||||
}
|
||||
return twitch.LoadingStreamerEntries(logins)
|
||||
}
|
||||
|
||||
@@ -4,23 +4,28 @@ import (
|
||||
"testing"
|
||||
|
||||
"parasocial/internal/auth"
|
||||
"parasocial/internal/twitch"
|
||||
)
|
||||
|
||||
func TestViewDisplaysStreamers(t *testing.T) {
|
||||
model := New(Options{
|
||||
Streamers: []string{"alpha", "beta"},
|
||||
Streamers: []twitch.StreamerEntry{
|
||||
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady},
|
||||
{ConfigLogin: "beta", Status: twitch.StreamerLoading},
|
||||
},
|
||||
})
|
||||
model.mode = streamerView
|
||||
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
||||
|
||||
got := model.View()
|
||||
want := "Streamers\n1. alpha\n2. beta\n\n"
|
||||
want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live (1)\n2. beta [loading]\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthUpdateAppendsLogLine(t *testing.T) {
|
||||
model := New(Options{Streamers: []string{"alpha"}})
|
||||
model := New(Options{Streamers: twitch.LoadingStreamerEntries([]string{"alpha"})})
|
||||
|
||||
updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
|
||||
if cmd != nil {
|
||||
@@ -35,17 +40,88 @@ func TestAuthUpdateAppendsLogLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSuccessSwitchesToStreamerView(t *testing.T) {
|
||||
model := New(Options{Streamers: []string{"alpha"}})
|
||||
func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
|
||||
var started *auth.State
|
||||
model := New(Options{
|
||||
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
|
||||
StartResolve: func(state *auth.State, ch chan<- StreamerUpdate) {
|
||||
started = state
|
||||
close(ch)
|
||||
},
|
||||
})
|
||||
|
||||
updated, _ := model.Update(AuthUpdate{
|
||||
State: &auth.State{Login: "viewer"},
|
||||
state := &auth.State{Login: "viewer", UserID: "7"}
|
||||
|
||||
updated, cmd := model.Update(AuthUpdate{
|
||||
State: state,
|
||||
Done: true,
|
||||
})
|
||||
if cmd == nil {
|
||||
t.Fatal("expected streamer resolution command")
|
||||
}
|
||||
if _, ok := cmd().(streamerStartedMsg); !ok {
|
||||
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
|
||||
}
|
||||
if started != state {
|
||||
t.Fatalf("started state = %#v, want %#v", started, state)
|
||||
}
|
||||
|
||||
next := updated.(Model)
|
||||
got := next.View()
|
||||
want := "Logged in as viewer\n\nStreamers\n1. alpha\n\n"
|
||||
want := "Logged in as viewer\nResolving viewer identity...\n\nStreamers\n1. alpha [loading]\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitStartsResolutionWhenAlreadyAuthenticated(t *testing.T) {
|
||||
var started *auth.State
|
||||
state := &auth.State{Login: "viewer", UserID: "7"}
|
||||
model := New(Options{
|
||||
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
|
||||
AuthState: state,
|
||||
StartResolve: func(got *auth.State, ch chan<- StreamerUpdate) {
|
||||
started = got
|
||||
close(ch)
|
||||
},
|
||||
})
|
||||
|
||||
cmd := model.Init()
|
||||
if cmd == nil {
|
||||
t.Fatal("expected init command")
|
||||
}
|
||||
if _, ok := cmd().(streamerStartedMsg); !ok {
|
||||
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
|
||||
}
|
||||
if started != state {
|
||||
t.Fatalf("started state = %#v, want %#v", started, state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamerUpdateAppliesEntry(t *testing.T) {
|
||||
model := New(Options{
|
||||
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
|
||||
AuthState: &auth.State{Login: "viewer"},
|
||||
})
|
||||
model.mode = streamerView
|
||||
|
||||
updated, cmd := model.Update(StreamerUpdate{
|
||||
Viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
||||
Index: 0,
|
||||
Entry: &twitch.StreamerEntry{
|
||||
ConfigLogin: "alpha",
|
||||
Login: "alpha_live",
|
||||
ChannelID: "1",
|
||||
Status: twitch.StreamerReady,
|
||||
},
|
||||
})
|
||||
if cmd != nil {
|
||||
t.Fatal("expected nil cmd when no update channel is attached")
|
||||
}
|
||||
|
||||
next := updated.(Model)
|
||||
got := next.View()
|
||||
want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live (1)\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("View() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// service.go defines the Twitch domain lookups the rewritten app currently needs.
|
||||
// It wraps the lower-level GraphQL client with small typed methods for the viewer
|
||||
// identity and configured streamer resolution used to populate the terminal UI.
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"parasocial/internal/gql"
|
||||
)
|
||||
|
||||
// GQLClient is the minimal GraphQL interface the Twitch service depends on.
|
||||
type GQLClient interface {
|
||||
Do(context.Context, gql.Request, any) error
|
||||
}
|
||||
|
||||
// Service exposes the Twitch lookups the current app needs.
|
||||
type Service struct {
|
||||
GQL GQLClient
|
||||
}
|
||||
|
||||
// Viewer is the authenticated Twitch account.
|
||||
type Viewer struct {
|
||||
ID string
|
||||
Login string
|
||||
}
|
||||
|
||||
// Channel is the resolved Twitch channel identity for one streamer login.
|
||||
type Channel struct {
|
||||
ID string
|
||||
Login string
|
||||
}
|
||||
|
||||
// StreamerStatus describes one row's resolution state in the UI.
|
||||
type StreamerStatus string
|
||||
|
||||
const (
|
||||
StreamerLoading StreamerStatus = "loading"
|
||||
StreamerReady StreamerStatus = "ready"
|
||||
StreamerError StreamerStatus = "error"
|
||||
)
|
||||
|
||||
// StreamerEntry is the UI-facing state for one configured streamer.
|
||||
type StreamerEntry struct {
|
||||
ConfigLogin string
|
||||
Login string
|
||||
ChannelID string
|
||||
Status StreamerStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// ErrStreamerNotFound is returned when Twitch has no channel for the requested login.
|
||||
var ErrStreamerNotFound = errors.New("streamer does not exist")
|
||||
|
||||
// LoadingStreamerEntries seeds UI state from the normalized config logins.
|
||||
func LoadingStreamerEntries(logins []string) []StreamerEntry {
|
||||
entries := make([]StreamerEntry, 0, len(logins))
|
||||
for _, login := range logins {
|
||||
entries = append(entries, StreamerEntry{
|
||||
ConfigLogin: login,
|
||||
Status: StreamerLoading,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// CurrentUser resolves the authenticated viewer through Twitch GraphQL.
|
||||
func (s *Service) CurrentUser(ctx context.Context) (*Viewer, error) {
|
||||
var data struct {
|
||||
CurrentUser *struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"currentUser"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.CurrentUser(), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.CurrentUser == nil || data.CurrentUser.ID == "" || data.CurrentUser.Login == "" {
|
||||
return nil, fmt.Errorf("current user response missing id or login")
|
||||
}
|
||||
return &Viewer{
|
||||
ID: data.CurrentUser.ID,
|
||||
Login: data.CurrentUser.Login,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveStreamer resolves a configured streamer login into canonical Twitch identity.
|
||||
func (s *Service) ResolveStreamer(ctx context.Context, login string) (*Channel, error) {
|
||||
var data struct {
|
||||
User *struct {
|
||||
ID string `json:"id"`
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.GetIDFromLogin(login), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.User == nil || data.User.ID == "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrStreamerNotFound, login)
|
||||
}
|
||||
resolvedLogin := data.User.Login
|
||||
if resolvedLogin == "" {
|
||||
resolvedLogin = login
|
||||
}
|
||||
return &Channel{ID: data.User.ID, Login: resolvedLogin}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"parasocial/internal/gql"
|
||||
)
|
||||
|
||||
type fakeGQL struct {
|
||||
requests []gql.Request
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeGQL) Do(_ context.Context, request gql.Request, out any) error {
|
||||
f.requests = append(f.requests, request)
|
||||
return json.Unmarshal([]byte(f.data[request.OperationName]), out)
|
||||
}
|
||||
|
||||
func TestCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"CurrentUser": `{"currentUser":{"id":"7","login":"viewer"}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
viewer, err := service.CurrentUser(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if viewer.ID != "7" || viewer.Login != "viewer" {
|
||||
t.Fatalf("viewer = %#v", viewer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStreamer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"GetIDFromLogin": `{"user":{"id":"123","login":"streamer"}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
channel, err := service.ResolveStreamer(context.Background(), "streamer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if channel.ID != "123" || channel.Login != "streamer" {
|
||||
t.Fatalf("channel = %#v", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStreamerNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"GetIDFromLogin": `{"user":null}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
_, err := service.ResolveStreamer(context.Background(), "missing")
|
||||
if !errors.Is(err, ErrStreamerNotFound) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user