Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions .github/workflows/release-promotion.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,18 @@ jobs:

echo "✅ Added PR #${PR_NUMBER} link to CHANGELOG.md"

- name: Auto-merge PR and create tag
- name: Enable auto-merge on PR
if: ${{ github.event.inputs.auto_merge == 'true' }}
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
echo "🔄 Enabling auto-merge on PR #${PR_NUMBER}..."
gh pr merge "$PR_NUMBER" --auto --merge --delete-branch=false
echo "✅ Auto-merge enabled - PR will merge when status checks pass"

- name: Wait for PR to merge and create tag
if: ${{ github.event.inputs.auto_merge == 'true' }}
env:
TAG_NAME: ${{ steps.version.outputs.tag }}
Expand All @@ -271,15 +282,26 @@ jobs:
run: |
set -euo pipefail

echo "🔄 Merging PR #${PR_NUMBER}..."

# Wait for any status checks to complete
sleep 5
echo "⏳ Waiting for PR #${PR_NUMBER} to merge..."
TIMEOUT=600
ELAPSED=0
INTERVAL=15

# Merge the PR
gh pr merge "$PR_NUMBER" --merge --delete-branch=false
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
if [ "$STATE" = "MERGED" ]; then
echo "✅ PR #${PR_NUMBER} merged to $TARGET_BRANCH"
break
fi
echo " PR state: $STATE (${ELAPSED}s elapsed, checking again in ${INTERVAL}s)"
sleep "$INTERVAL"
ELAPSED=$((ELAPSED + INTERVAL))
done

echo "✅ PR #${PR_NUMBER} merged to $TARGET_BRANCH"
if [ "$STATE" != "MERGED" ]; then
echo "❌ Timed out waiting for PR to merge after ${TIMEOUT}s"
exit 1
fi

# Fetch latest target branch
git fetch origin "$TARGET_BRANCH"
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

## [1.1.1] - 2026-02-06 ([#56](https://github.com/Its-donkey/kappopher/pull/56))

### Added
- `WithToken(ctx, token)` context override for per-request authentication, enabling concurrent requests with different user tokens through a single client instance

### Changed

### Fixed

## [1.1.0] - 2026-01-20

### Added
Expand Down
48 changes: 48 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,54 @@ func CustomHeadersMiddleware(headers map[string]string) Middleware {
}
```

## Per-Request Token Override

Override the client-level token for individual requests using `WithToken`. This enables concurrent requests with different user tokens through a single client, without creating multiple client instances.

```go
// Override token for a single request
ctx := helix.WithToken(ctx, &helix.Token{AccessToken: "user-token"})
followers, err := client.GetChannelFollowers(ctx, &helix.GetChannelFollowersParams{
BroadcasterID: "12345",
})
```

### Token Resolution Order

1. Per-request token from `WithToken(ctx, token)` (highest priority)
2. Client-level `AuthClient` token
3. Client-level `TokenProvider` (e.g., Extension JWT)

### Concurrent Multi-Token Requests

Fetch data for multiple channels where each requires its own user token:

```go
type ChannelToken struct {
BroadcasterID string
Token *helix.Token
}

channels := []ChannelToken{
{BroadcasterID: "111", Token: &helix.Token{AccessToken: "token-a"}},
{BroadcasterID: "222", Token: &helix.Token{AccessToken: "token-b"}},
{BroadcasterID: "333", Token: &helix.Token{AccessToken: "token-c"}},
}

results := make(chan error, len(channels))
for _, ch := range channels {
go func(ch ChannelToken) {
ctx := helix.WithToken(ctx, ch.Token)
_, err := client.GetChannelFollowers(ctx, &helix.GetChannelFollowersParams{
BroadcasterID: ch.BroadcasterID,
})
results <- err
}(ch)
}
```

This works with all context-aware features including caching (`NoCacheContext`), middleware, and batch operations.

## Low-Level Request Execution

For advanced use cases, you can execute raw requests directly.
Expand Down
23 changes: 23 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,29 @@ auth.SetToken(&helix.Token{
token := auth.GetToken()
```

### WithToken (Per-Request Override)

Override the client-level token for a single request. This is useful when making
concurrent requests that each require a different user token (e.g., fetching
followers for multiple channels where each requires `moderator:read:followers`).

```go
// Each request uses a different user's token
ctx := helix.WithToken(context.Background(), &helix.Token{
AccessToken: "user-specific-token",
})
followers, err := client.GetChannelFollowers(ctx, &helix.GetChannelFollowersParams{
BroadcasterID: "12345",
})
```

The token resolution order is:
1. Per-request token from `WithToken` context (if set)
2. Client-level `AuthClient` token
3. Client-level `TokenProvider` (e.g., Extension JWT)

See [Batch & Caching Examples](examples/batch-caching.md) for a complete concurrent multi-token example.

## OIDC (OpenID Connect)

Support for Twitch's OIDC implementation for identity verification.
Expand Down
2 changes: 2 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ description: Comprehensive code examples covering all kappopher features.
| Feature | Example |
|---------|---------|
| OAuth Authentication | [Authentication](examples/authentication.md) |
| Per-Request Token Override | [Authentication](examples/authentication.md), [Batch & Caching](examples/batch-caching.md) |
| Get Users/Channels | [Basic](examples/basic.md), [API Usage](examples/api-usage.md) |
| Send Chat Messages | [Chat Bot](examples/chatbot.md), [IRC Client](examples/irc-client.md) |
| Handle Chat Events | [IRC Client](examples/irc-client.md), [EventSub WebSocket](examples/eventsub-websocket.md) |
Expand Down Expand Up @@ -72,6 +73,7 @@ description: Comprehensive code examples covering all kappopher features.

| Use Case | Start Here |
|----------|-----------|
| Concurrent multi-user requests | [Authentication](examples/authentication.md), [Batch & Caching](examples/batch-caching.md) |
| Build a chat bot | [Chat Bot](examples/chatbot.md) or [IRC Client](examples/irc-client.md) |
| Monitor stream events | [EventSub WebSocket](examples/eventsub-websocket.md) |
| Create a dashboard | [Batch & Caching](examples/batch-caching.md) |
Expand Down
65 changes: 65 additions & 0 deletions docs/examples/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,71 @@ func main() {
}
```

## Per-Request Token Override

When making concurrent requests that each require a different user token, use `WithToken` to override the client-level token on a per-request basis. This avoids creating multiple client instances.

**When to use**: Fetching scoped data for multiple channels concurrently (e.g., followers, subscriptions) where each channel requires its own user token.

```go
package main

import (
"context"
"fmt"
"log"
"sync"

"github.com/Its-donkey/kappopher/helix"
)

func main() {
ctx := context.Background()

// One client shared across all requests
authClient := helix.NewAuthClient(helix.AuthConfig{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
})
client := helix.NewClient("your-client-id", authClient)

// Each channel has its own user token (obtained via Authorization Code flow)
type Channel struct {
BroadcasterID string
Token *helix.Token
}

channels := []Channel{
{BroadcasterID: "111", Token: &helix.Token{AccessToken: "user-token-a"}},
{BroadcasterID: "222", Token: &helix.Token{AccessToken: "user-token-b"}},
{BroadcasterID: "333", Token: &helix.Token{AccessToken: "user-token-c"}},
}

// Fetch followers for all channels concurrently
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(ch Channel) {
defer wg.Done()

// Override the client token for this request
reqCtx := helix.WithToken(ctx, ch.Token)

followers, err := client.GetChannelFollowers(reqCtx, &helix.GetChannelFollowersParams{
BroadcasterID: ch.BroadcasterID,
})
if err != nil {
log.Printf("Error fetching followers for %s: %v", ch.BroadcasterID, err)
return
}

fmt.Printf("Channel %s has %d followers\n", ch.BroadcasterID, *followers.Total)
}(ch)
}
wg.Wait()
}
```

## Common Scope Combinations

The library provides pre-defined scope combinations for common use cases. These help you request the right permissions without having to look up individual scope names.
Expand Down
61 changes: 61 additions & 0 deletions docs/examples/batch-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,67 @@ func main() {
}
```

## Concurrent Requests with Different Tokens

When endpoints require user-specific tokens (e.g., `Get Channel Followers` requires `moderator:read:followers`), use `WithToken` to override the client token per-request:

```go
package main

import (
"context"
"fmt"
"log"
"sync"

"github.com/Its-donkey/kappopher/helix"
)

func main() {
ctx := context.Background()

authClient := helix.NewAuthClient(helix.AuthConfig{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
})
client := helix.NewClient("your-client-id", authClient)

// Each channel's user token (obtained via Authorization Code flow)
type ChannelToken struct {
BroadcasterID string
Token *helix.Token
}

channels := []ChannelToken{
{BroadcasterID: "111", Token: &helix.Token{AccessToken: "token-a"}},
{BroadcasterID: "222", Token: &helix.Token{AccessToken: "token-b"}},
{BroadcasterID: "333", Token: &helix.Token{AccessToken: "token-c"}},
}

var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(ch ChannelToken) {
defer wg.Done()

// Override token for this request only
reqCtx := helix.WithToken(ctx, ch.Token)
followers, err := client.GetChannelFollowers(reqCtx, &helix.GetChannelFollowersParams{
BroadcasterID: ch.BroadcasterID,
})
if err != nil {
log.Printf("Error: %v", err)
return
}
fmt.Printf("Channel %s: %d followers\n", ch.BroadcasterID, *followers.Total)
}(ch)
}
wg.Wait()
}
```

The client's rate limiter, cache, and middleware are shared across all requests regardless of which token is used. When caching is enabled, requests with different tokens produce different cache keys automatically.

## Complete Example: Efficient Multi-Channel Dashboard

```go
Expand Down
11 changes: 11 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ batcher := helix.NewBatcher(client, helix.BatchConfig{
results := batcher.GetUsers(ctx, userIDs)
```

### Can I use different tokens for different requests?

Yes. Use `WithToken` to override the client-level token on a per-request basis:

```go
ctx := helix.WithToken(ctx, &helix.Token{AccessToken: "other-user-token"})
followers, err := client.GetChannelFollowers(ctx, params)
```

This is useful when making concurrent requests that each require a different user token (e.g., fetching followers for multiple channels). See the [Authentication Examples](examples/authentication.md) for a complete example.

---

## Troubleshooting
Expand Down
4 changes: 4 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ err := authClient.RevokeToken(ctx, token.AccessToken)
// Auto-refresh (starts background goroutine)
cancel := authClient.AutoRefresh(ctx)
defer cancel()

// Per-request token override (for concurrent multi-user requests)
ctx := helix.WithToken(ctx, &helix.Token{AccessToken: "other-user-token"})
followers, err := client.GetChannelFollowers(ctx, params)
```

## Creating the Helix Client
Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ description: Solutions to common issues when using Kappopher.
- Use the correct Client ID that matches the token
- Generate a new token with your Client ID

**Note:** When using `WithToken` for per-request token overrides, all tokens must belong to the same Client ID as the client. Twitch requires the `Client-Id` header and `Authorization` token to match.

---

## EventSub Issues
Expand Down
24 changes: 22 additions & 2 deletions helix/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ type TokenProvider interface {
GetToken() *Token
}

// tokenContextKey is the context key for per-request token overrides.
type tokenContextKey struct{}

// WithToken returns a context that overrides the client-level token for a
// single request. This is useful when making concurrent requests that each
// require a different user token.
func WithToken(ctx context.Context, token *Token) context.Context {
return context.WithValue(ctx, tokenContextKey{}, token)
}

// tokenFromContext retrieves a per-request token override from context.
func tokenFromContext(ctx context.Context) *Token {
if token, ok := ctx.Value(tokenContextKey{}).(*Token); ok {
return token
}
return nil
}

// Client is a Twitch Helix API client.
type Client struct {
clientID string
Expand Down Expand Up @@ -382,9 +400,11 @@ func (c *Client) doOnceWithResponse(ctx context.Context, req *Request, result in
}
}

// Set authorization
// Set authorization (per-request context override takes precedence)
var token *Token
if c.authClient != nil {
if ctxToken := tokenFromContext(ctx); ctxToken != nil {
token = ctxToken
} else if c.authClient != nil {
token = c.authClient.GetToken()
} else if c.tokenProvider != nil {
token = c.tokenProvider.GetToken()
Expand Down
Loading