Skip to content

Commit 6377573

Browse files
Merge pull request #69 from Jamesllllllllll/codex/streamelements-tip-instructions
Add native Twitch VIP rewards and i18n updates
2 parents 34d6d4d + 98c4403 commit 6377573

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2672
-110
lines changed

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ ADMIN_TWITCH_USER_IDS=
2626
# Production should keep its own production bot username in deployed env/secrets.
2727
TWITCH_BOT_USERNAME=requestbot
2828
# Broadcaster OAuth scopes used by the main app login.
29-
# These should include the channel permissions needed for bot replies, chatter lookups, gifted-sub automation, and cheer automation.
30-
TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read
29+
# These should include the channel permissions needed for bot replies, chatter lookups, gifted-sub automation, cheer automation, and native channel point rewards.
30+
TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ It runs on TanStack Start, Cloudflare Workers, D1, Durable Objects, Queues, KV,
3131

3232
- Twitch panel extension with playlist viewing, viewer request actions, and owner/moderator controls for play-now, reorder, remove, request-type changes, and other queue actions
3333
- Shared bot-account OAuth, per-channel bot opt-in, and live-aware EventSub subscription management
34-
- VIP token tracking with manual grants plus automatic rewards for new subs, shared resub messages, gifted subs, gift recipients, cheers, raids, and StreamElements tips
34+
- VIP token tracking with manual grants plus automatic rewards for new subs, shared resub messages, gifted subs, gift recipients, cheers, app-owned channel point rewards, raids, and StreamElements tips
3535

3636
### Platform And Quality
3737

@@ -95,7 +95,7 @@ TWITCH_EVENTSUB_SECRET=local-dev-eventsub-secret
9595
TWITCH_EXTENSION_SECRET=
9696
SESSION_SECRET=local-dev-session-secret
9797
TWITCH_BOT_USERNAME=requestbot
98-
TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read
98+
TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions
9999
ADMIN_TWITCH_USER_IDS=
100100
VITE_ALLOWED_HOSTS=
101101
```
@@ -108,7 +108,7 @@ For basic local development, set:
108108
- `TWITCH_EVENTSUB_SECRET`
109109
- `SESSION_SECRET`
110110
- `TWITCH_BOT_USERNAME`
111-
- `TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read`
111+
- `TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions`
112112
- `VITE_ALLOWED_HOSTS=` if you need extra Vite hostnames
113113
- `VITE_TWITCH_EXTENSION_API_BASE_URL=` if you want the standalone extension build to call a different app origin
114114

@@ -135,7 +135,9 @@ If you build the panel as a standalone Twitch extension artifact, set `VITE_TWIT
135135

136136
`ADMIN_TWITCH_USER_IDS` should contain the Twitch user ID for the admin account that is allowed to connect the shared bot account and access admin pages.
137137

138-
Broadcaster connections need `channel:bot`, `channel:read:subscriptions`, and `bits:read` in `TWITCH_SCOPES`. If the connected Twitch account is missing those permissions, reconnect Twitch from the app so bot replies and VIP token automation can use them.
138+
Broadcaster connections need `channel:bot`, `channel:read:subscriptions`, `bits:read`, and `channel:manage:redemptions` in `TWITCH_SCOPES`. If the connected Twitch account is missing those permissions, reconnect Twitch from the app so bot replies, VIP token automation, and app-owned channel point rewards can use them.
139+
140+
App-owned channel point rewards also require a Twitch Affiliate or Partner channel. Twitch rejects custom reward create/update calls for channels that do not have channel points.
139141

140142
Sentry stays off locally unless you explicitly set a DSN:
141143

@@ -568,7 +570,7 @@ gh secret set CLOUDFLARE_D1_DATABASE_ID
568570
gh secret set CLOUDFLARE_SESSION_KV_ID
569571
gh secret set APP_URL --body "https://your-app-host"
570572
gh variable set TWITCH_BOT_USERNAME --body "your_bot_username"
571-
gh variable set TWITCH_SCOPES --body "openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read"
573+
gh variable set TWITCH_SCOPES --body "openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions"
572574
```
573575

574576
For the full deploy and GitHub workflow details, use [docs/deployment-workflow.md](/docs/deployment-workflow.md).

docs/bot-operations.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ TWITCH_EVENTSUB_SECRET=...
4040
SESSION_SECRET=...
4141
ADMIN_TWITCH_USER_IDS=your_main_twitch_user_id
4242
TWITCH_BOT_USERNAME=Pants_Bot_
43-
TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot
43+
TWITCH_SCOPES=openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions
4444
```
4545

4646
For local development, `TWITCH_BOT_USERNAME` should usually be your dedicated test bot account. Production should keep its own bot username in deployed env or secrets. The app enforces that the connected bot login matches `TWITCH_BOT_USERNAME`, so changing bot accounts locally requires changing local `.env` first.
4747

48-
`TWITCH_SCOPES` belongs to the broadcaster login flow, not the bot login flow. It should include `channel:bot` so chat replies can use Twitch's bot badge path. If the connected broadcaster account is missing that permission, reconnect Twitch.
48+
`TWITCH_SCOPES` belongs to the broadcaster login flow, not the bot login flow. It should include `channel:bot` so chat replies can use Twitch's bot badge path, plus `channel:read:subscriptions`, `bits:read`, and `channel:manage:redemptions` for VIP token automation and app-owned channel point rewards. If the connected broadcaster account is missing those permissions, reconnect Twitch.
49+
50+
App-owned channel point rewards only work on Twitch Affiliate or Partner channels. If you test this flow on a channel without channel points, Twitch rejects the reward API calls and request-bot leaves the rest of the bot active.
4951

5052
2. Make sure your Twitch developer application has both redirect URIs registered:
5153

docs/deployment-workflow.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,11 @@ Use these Twitch values:
9393
- `TWITCH_EXTENSION_CLIENT_ID`: Twitch Extension client ID for the panel extension
9494
- `TWITCH_EXTENSION_SECRET`: base64 shared secret from the Twitch Extensions developer console
9595

96-
The checked-in default broadcaster scope is:
97-
98-
```text
99-
openid user:read:moderated_channels channel:bot
100-
```
96+
The checked-in default broadcaster scope is:
97+
98+
```text
99+
openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions
100+
```
101101

102102
Notes:
103103

@@ -315,7 +315,7 @@ gh secret set CLOUDFLARE_D1_DATABASE_ID
315315
gh secret set CLOUDFLARE_SESSION_KV_ID
316316
gh secret set APP_URL --body "https://your-production-url.example"
317317
gh variable set TWITCH_BOT_USERNAME --body "your_bot_username"
318-
gh variable set TWITCH_SCOPES --body "openid user:read:moderated_channels channel:bot"
318+
gh variable set TWITCH_SCOPES --body "openid user:read:moderated_channels moderator:read:chatters channel:bot channel:read:subscriptions bits:read channel:manage:redemptions"
319319
gh variable set SENTRY_ENVIRONMENT --body "production"
320320
```
321321

docs/local-development.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ For local bot testing, set `TWITCH_BOT_USERNAME` in `.env` to your dedicated tes
7777

7878
Keep the production bot username only in production secrets or deployed env. Do not point local development at the production bot account unless you intentionally want local testing to use the live bot identity.
7979

80-
`TWITCH_SCOPES` applies to the broadcaster's main app login, not the shared bot login. It needs `channel:bot` so bot replies can use Twitch's bot badge path, and it needs `moderator:read:chatters` for the chatter-first VIP lookup flow.
80+
`TWITCH_SCOPES` applies to the broadcaster's main app login, not the shared bot login. It needs `channel:bot` so bot replies can use Twitch's bot badge path, `moderator:read:chatters` for the chatter-first VIP lookup flow, `channel:read:subscriptions` and `bits:read` for VIP token automation, and `channel:manage:redemptions` for the native channel point reward flow.
81+
82+
If you want to test the native channel point reward flow locally, use a Twitch Affiliate or Partner channel. Twitch does not allow custom rewards on channels without channel points.
8183

8284
### Important local testing warning
8385

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
ALTER TABLE `channel_settings`
2+
ADD `auto_grant_vip_tokens_for_channel_point_rewards` integer DEFAULT false NOT NULL;
3+
4+
ALTER TABLE `channel_settings`
5+
ADD `channel_point_reward_cost` integer DEFAULT 1000 NOT NULL;
6+
7+
ALTER TABLE `channel_settings`
8+
ADD `twitch_channel_point_reward_id` text DEFAULT '' NOT NULL;

src/extension/panel/app.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ import {
5151
SelectContent,
5252
SelectItem,
5353
SelectTrigger,
54-
SelectValue,
5554
} from "~/components/ui/select";
5655
import { Skeleton } from "~/components/ui/skeleton";
5756
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
@@ -82,7 +81,11 @@ import {
8281
mockModeratorViewerProfile,
8382
type PanelDemoPlaylist,
8483
} from "./demo";
85-
import { readPanelStoredLocale, resolveExtensionPanelLocale } from "./locale";
84+
import {
85+
persistPanelStoredLocale,
86+
readPanelStoredLocale,
87+
resolveExtensionPanelLocale,
88+
} from "./locale";
8689
import {
8790
getTwitchExtensionHelper,
8891
loadTwitchExtensionHelper,
@@ -630,6 +633,11 @@ function ExtensionPanelAppContent(props: {
630633
props.onResolvedLocaleChange,
631634
]);
632635

636+
async function setPanelLocale(nextLocale: AppLocale) {
637+
persistPanelStoredLocale(nextLocale);
638+
await setLocale(nextLocale);
639+
}
640+
633641
function showTransientNotice(
634642
tone: TransientPanelNotice["tone"],
635643
message: string
@@ -1229,7 +1237,7 @@ function ExtensionPanelAppContent(props: {
12291237
</div>
12301238
<PanelLanguageSelect
12311239
locale={locale}
1232-
onLocaleChange={setLocale}
1240+
onLocaleChange={setPanelLocale}
12331241
isSavingLocale={isSavingLocale}
12341242
/>
12351243
</div>
@@ -3954,8 +3962,8 @@ function PanelRequestsStatusBar({
39543962
<div
39553963
className={
39563964
requestsEnabled
3957-
? "border-b border-(--border-strong) bg-emerald-950/80 px-3 py-1.5 text-center text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100"
3958-
: "border-b border-(--border-strong) bg-rose-950/60 px-3 py-1.5 text-center text-[10px] font-semibold uppercase tracking-[0.16em] text-rose-100"
3965+
? "border-b border-(--border-strong) bg-emerald-950/80 px-3 py-[3px] text-center text-[9px] leading-[1em] font-semibold uppercase tracking-[0.16em] text-emerald-100"
3966+
: "border-b border-(--border-strong) bg-rose-950/60 px-3 py-[3px] text-center text-[9px] leading-[1em] font-semibold uppercase tracking-[0.16em] text-rose-100"
39593967
}
39603968
>
39613969
{t("requests.status", {
@@ -4001,12 +4009,19 @@ function getPanelPlaylistFooterLabel(
40014009
: t("footer.openPlaylist");
40024010
}
40034011

4012+
function getPanelLocaleShortLabel(locale: AppLocale) {
4013+
return locale === "pt-BR" ? "PT" : locale.slice(0, 2).toUpperCase();
4014+
}
4015+
40044016
function PanelLanguageSelect(props: {
40054017
locale: AppLocale;
40064018
onLocaleChange: (locale: AppLocale) => Promise<void>;
40074019
isSavingLocale: boolean;
40084020
}) {
40094021
const { t } = useLocaleTranslation("common");
4022+
const selectedOption =
4023+
localeOptions.find((option) => option.value === props.locale) ?? null;
4024+
const selectedLabel = getPanelLocaleShortLabel(props.locale);
40104025

40114026
return (
40124027
<Select
@@ -4015,15 +4030,22 @@ function PanelLanguageSelect(props: {
40154030
disabled={props.isSavingLocale}
40164031
>
40174032
<SelectTrigger
4018-
aria-label={t("language.label")}
4019-
className="h-7 min-w-[7.75rem] gap-2 px-2 text-[11px] shadow-none"
4033+
aria-label={`${t("language.label")}: ${selectedOption?.nativeLabel ?? selectedLabel}`}
4034+
title={selectedOption?.nativeLabel ?? selectedLabel}
4035+
className="h-6 w-11 min-w-0 shrink-0 gap-1 self-start border-(--border-strong) bg-(--panel-soft) px-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] shadow-none [&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-55"
40204036
>
4021-
<SelectValue />
4037+
<span className="text-center">{selectedLabel}</span>
40224038
</SelectTrigger>
4023-
<SelectContent>
4039+
<SelectContent align="end" className="min-w-[3.75rem]">
40244040
{localeOptions.map((option) => (
4025-
<SelectItem key={option.value} value={option.value}>
4026-
{option.nativeLabel}
4041+
<SelectItem
4042+
key={option.value}
4043+
value={option.value}
4044+
textValue={option.nativeLabel}
4045+
title={option.nativeLabel}
4046+
className="py-1.5 pl-8 pr-2 text-[10px] font-semibold uppercase tracking-[0.18em]"
4047+
>
4048+
{getPanelLocaleShortLabel(option.value)}
40274049
</SelectItem>
40284050
))}
40294051
</SelectContent>
@@ -4034,10 +4056,15 @@ function PanelLanguageSelect(props: {
40344056
function PreviewPanelLanguageSelect() {
40354057
const { locale, setLocale, isSavingLocale } = useAppLocale();
40364058

4059+
async function setPreviewPanelLocale(nextLocale: AppLocale) {
4060+
persistPanelStoredLocale(nextLocale);
4061+
await setLocale(nextLocale);
4062+
}
4063+
40374064
return (
40384065
<PanelLanguageSelect
40394066
locale={locale}
4040-
onLocaleChange={setLocale}
4067+
onLocaleChange={setPreviewPanelLocale}
40414068
isSavingLocale={isSavingLocale}
40424069
/>
40434070
);

src/extension/panel/locale.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import {
22
readExplicitDeviceLocale,
33
readExplicitLocaleCookie,
44
} from "~/lib/i18n/detect";
5-
import { defaultLocale, normalizeLocale } from "~/lib/i18n/locales";
5+
import {
6+
type AppLocale,
7+
defaultLocale,
8+
normalizeLocale,
9+
} from "~/lib/i18n/locales";
10+
11+
const panelLocaleStorageKey = "request-bot:extension-panel-locale";
612

713
export function resolveExtensionPanelLocale(input?: {
814
search?: string | null;
@@ -16,8 +22,8 @@ export function resolveExtensionPanelLocale(input?: {
1622
const params = new URLSearchParams(input?.search ?? "");
1723

1824
return (
19-
normalizeLocale(input?.viewerPreferredLocale) ??
2025
normalizeLocale(input?.storedLocale) ??
26+
normalizeLocale(input?.viewerPreferredLocale) ??
2127
normalizeLocale(input?.cookieLocale) ??
2228
normalizeLocale(params.get("locale")) ??
2329
normalizeLocale(params.get("language")) ??
@@ -28,6 +34,34 @@ export function resolveExtensionPanelLocale(input?: {
2834
);
2935
}
3036

37+
function readPanelExplicitLocale() {
38+
if (typeof window === "undefined") {
39+
return null;
40+
}
41+
42+
try {
43+
return normalizeLocale(window.localStorage.getItem(panelLocaleStorageKey));
44+
} catch {
45+
return null;
46+
}
47+
}
48+
3149
export function readPanelStoredLocale() {
32-
return readExplicitDeviceLocale() ?? readExplicitLocaleCookie();
50+
return (
51+
readPanelExplicitLocale() ??
52+
readExplicitDeviceLocale() ??
53+
readExplicitLocaleCookie()
54+
);
55+
}
56+
57+
export function persistPanelStoredLocale(locale: AppLocale) {
58+
if (typeof window === "undefined") {
59+
return;
60+
}
61+
62+
try {
63+
window.localStorage.setItem(panelLocaleStorageKey, locale);
64+
} catch {
65+
// Ignore local storage failures in restricted browser contexts.
66+
}
3367
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const LATEST_MIGRATION_NAME = "0023_channel_default_locale.sql";
1+
export const LATEST_MIGRATION_NAME = "0024_channel_point_reward_vip_tokens.sql";

src/lib/db/repositories.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
refreshAccessToken,
1111
TwitchApiError,
1212
} from "~/lib/twitch/api";
13+
import { channelPointRewardManageScope } from "~/lib/twitch/channel-point-rewards";
1314
import {
1415
createId,
1516
decodeHtmlEntities,
@@ -930,6 +931,20 @@ export async function ensureStreamElementsTipWebhookToken(
930931
return token;
931932
}
932933

934+
export async function setTwitchChannelPointRewardId(
935+
env: AppEnv,
936+
channelId: string,
937+
rewardId: string | null
938+
) {
939+
await getDb(env)
940+
.update(channelSettings)
941+
.set({
942+
twitchChannelPointRewardId: rewardId ?? "",
943+
updatedAt: Date.now(),
944+
})
945+
.where(eq(channelSettings.channelId, channelId));
946+
}
947+
933948
export async function getOverlayStateForOwner(
934949
env: AppEnv,
935950
ownerUserId: string
@@ -2748,10 +2763,12 @@ export async function updateSettings(
27482763
autoGrantVipTokensToSubGifters: boolean;
27492764
autoGrantVipTokensToGiftRecipients: boolean;
27502765
autoGrantVipTokensForCheers: boolean;
2766+
autoGrantVipTokensForChannelPointRewards: boolean;
27512767
autoGrantVipTokensForRaiders: boolean;
27522768
autoGrantVipTokensForStreamElementsTips: boolean;
27532769
allowRequestPathModifiers: boolean;
27542770
cheerBitsPerVipToken: number;
2771+
channelPointRewardCost: number;
27552772
cheerMinimumTokenPercent: 25 | 50 | 75 | 100;
27562773
raidMinimumViewerCount: number;
27572774
streamElementsTipAmountPerVipToken: number;
@@ -2805,11 +2822,14 @@ export async function updateSettings(
28052822
autoGrantVipTokensToGiftRecipients:
28062823
input.autoGrantVipTokensToGiftRecipients,
28072824
autoGrantVipTokensForCheers: input.autoGrantVipTokensForCheers,
2825+
autoGrantVipTokensForChannelPointRewards:
2826+
input.autoGrantVipTokensForChannelPointRewards,
28082827
autoGrantVipTokensForRaiders: input.autoGrantVipTokensForRaiders,
28092828
autoGrantVipTokensForStreamElementsTips:
28102829
input.autoGrantVipTokensForStreamElementsTips,
28112830
allowRequestPathModifiers: input.allowRequestPathModifiers,
28122831
cheerBitsPerVipToken: input.cheerBitsPerVipToken,
2832+
channelPointRewardCost: input.channelPointRewardCost,
28132833
cheerMinimumTokenPercent: input.cheerMinimumTokenPercent,
28142834
raidMinimumViewerCount: input.raidMinimumViewerCount,
28152835
streamElementsTipAmountPerVipToken:
@@ -3650,15 +3670,22 @@ export async function getViewerState(env: AppEnv, userId: string) {
36503670
env,
36513671
userId
36523672
);
3673+
const requiredBroadcasterScopes = [
3674+
"user:read:moderated_channels",
3675+
"moderator:read:chatters",
3676+
"channel:bot",
3677+
"channel:read:subscriptions",
3678+
"bits:read",
3679+
...(settings?.autoGrantVipTokensForChannelPointRewards
3680+
? [channelPointRewardManageScope]
3681+
: []),
3682+
];
36533683
const needsBroadcasterScopeReconnect =
36543684
!broadcasterAuthorization ||
3655-
!hasRequiredAuthorizationScopes(broadcasterAuthorization.scopes, [
3656-
"user:read:moderated_channels",
3657-
"moderator:read:chatters",
3658-
"channel:bot",
3659-
"channel:read:subscriptions",
3660-
"bits:read",
3661-
]);
3685+
!hasRequiredAuthorizationScopes(
3686+
broadcasterAuthorization.scopes,
3687+
requiredBroadcasterScopes
3688+
);
36623689
const moderatedChannelsState = broadcasterAuthorization
36633690
? await getModeratedChannelViewerState(env, broadcasterAuthorization)
36643691
: {

0 commit comments

Comments
 (0)