Skip to content

Commit f950e19

Browse files
Expand VIP token automation with StreamElements tips
1 parent a490447 commit f950e19

File tree

19 files changed

+1279
-178
lines changed

19 files changed

+1279
-178
lines changed
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_streamelements_tips` integer DEFAULT false NOT NULL;
3+
4+
ALTER TABLE `channel_settings`
5+
ADD `streamelements_tip_amount_per_vip_token` real DEFAULT 5 NOT NULL;
6+
7+
ALTER TABLE `channel_settings`
8+
ADD `streamelements_tip_webhook_token` text DEFAULT '' NOT NULL;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE `channel_settings`
2+
ADD `allow_request_path_modifiers` integer DEFAULT false NOT NULL;

src/components/ui/card.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
88
<div
99
ref={ref}
1010
className={cn(
11-
"surface-noise rounded-none border border-(--border) bg-(--panel) shadow-none backdrop-blur-xl",
11+
"surface-noise min-w-0 rounded-none border border-(--border) bg-(--panel) shadow-none backdrop-blur-xl",
1212
className
1313
)}
1414
{...props}
@@ -55,7 +55,11 @@ const CardContent = React.forwardRef<
5555
HTMLDivElement,
5656
React.HTMLAttributes<HTMLDivElement>
5757
>(({ className, ...props }, ref) => (
58-
<div ref={ref} className={cn("px-6 pb-6 pt-0", className)} {...props} />
58+
<div
59+
ref={ref}
60+
className={cn("min-w-0 px-6 pb-6 pt-0", className)}
61+
{...props}
62+
/>
5963
));
6064
CardContent.displayName = "CardContent";
6165

src/components/ui/input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
66
<input
77
ref={ref}
88
className={cn(
9-
"flex h-12 w-full rounded-none border border-(--border) bg-(--panel-soft) px-4 py-3 text-sm text-(--text) shadow-none transition-[border-color,background,box-shadow] placeholder:text-(--muted) focus-visible:border-(--brand) focus-visible:bg-(--bg-elevated) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--brand) focus-visible:ring-offset-2 focus-visible:ring-offset-(--bg) disabled:cursor-not-allowed disabled:opacity-50",
9+
"flex h-12 min-w-0 w-full rounded-none border border-(--border) bg-(--panel-soft) px-4 py-3 text-sm text-(--text) shadow-none transition-[border-color,background,box-shadow] placeholder:text-(--muted) focus-visible:border-(--brand) focus-visible:bg-(--bg-elevated) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--brand) focus-visible:ring-offset-2 focus-visible:ring-offset-(--bg) disabled:cursor-not-allowed disabled:opacity-50",
1010
className
1111
)}
1212
{...props}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const LATEST_MIGRATION_NAME = "0016_channel_owned_official_dlcs.sql";
1+
export const LATEST_MIGRATION_NAME = "0018_request_path_modifiers.sql";

src/lib/db/repositories.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,10 @@ function nextOverlayToken() {
853853
return createId("ovl");
854854
}
855855

856+
function nextStreamElementsTipWebhookToken() {
857+
return createId("setip");
858+
}
859+
856860
export async function ensureOverlayAccessToken(env: AppEnv, channelId: string) {
857861
const existing = await getChannelSettingsByChannelId(env, channelId);
858862

@@ -888,6 +892,28 @@ export async function regenerateOverlayAccessToken(
888892
return token;
889893
}
890894

895+
export async function ensureStreamElementsTipWebhookToken(
896+
env: AppEnv,
897+
channelId: string
898+
) {
899+
const existing = await getChannelSettingsByChannelId(env, channelId);
900+
901+
if (existing?.streamElementsTipWebhookToken) {
902+
return existing.streamElementsTipWebhookToken;
903+
}
904+
905+
const token = nextStreamElementsTipWebhookToken();
906+
await getDb(env)
907+
.update(channelSettings)
908+
.set({
909+
streamElementsTipWebhookToken: token,
910+
updatedAt: Date.now(),
911+
})
912+
.where(eq(channelSettings.channelId, channelId));
913+
914+
return token;
915+
}
916+
891917
export async function getOverlayStateForOwner(
892918
env: AppEnv,
893919
ownerUserId: string
@@ -2488,8 +2514,11 @@ export async function updateSettings(
24882514
autoGrantVipTokensToSubGifters: boolean;
24892515
autoGrantVipTokensToGiftRecipients: boolean;
24902516
autoGrantVipTokensForCheers: boolean;
2517+
autoGrantVipTokensForStreamElementsTips: boolean;
2518+
allowRequestPathModifiers: boolean;
24912519
cheerBitsPerVipToken: number;
24922520
cheerMinimumTokenPercent: 25 | 50 | 75 | 100;
2521+
streamElementsTipAmountPerVipToken: number;
24932522
duplicateWindowSeconds: number;
24942523
showPlaylistPositions: boolean;
24952524
commandPrefix: string;
@@ -2537,8 +2566,13 @@ export async function updateSettings(
25372566
autoGrantVipTokensToGiftRecipients:
25382567
input.autoGrantVipTokensToGiftRecipients,
25392568
autoGrantVipTokensForCheers: input.autoGrantVipTokensForCheers,
2569+
autoGrantVipTokensForStreamElementsTips:
2570+
input.autoGrantVipTokensForStreamElementsTips,
2571+
allowRequestPathModifiers: input.allowRequestPathModifiers,
25402572
cheerBitsPerVipToken: input.cheerBitsPerVipToken,
25412573
cheerMinimumTokenPercent: input.cheerMinimumTokenPercent,
2574+
streamElementsTipAmountPerVipToken:
2575+
input.streamElementsTipAmountPerVipToken,
25422576
duplicateWindowSeconds: input.duplicateWindowSeconds,
25432577
showPlaylistPositions: input.showPlaylistPositions,
25442578
commandPrefix: input.commandPrefix,

src/lib/db/schema.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,33 @@ export const channelSettings = sqliteTable("channel_settings", {
190190
})
191191
.notNull()
192192
.default(false),
193+
autoGrantVipTokensForStreamElementsTips: integer(
194+
"auto_grant_vip_tokens_for_streamelements_tips",
195+
{
196+
mode: "boolean",
197+
}
198+
)
199+
.notNull()
200+
.default(false),
201+
allowRequestPathModifiers: integer("allow_request_path_modifiers", {
202+
mode: "boolean",
203+
})
204+
.notNull()
205+
.default(false),
193206
cheerBitsPerVipToken: integer("cheer_bits_per_vip_token")
194207
.notNull()
195208
.default(200),
196209
cheerMinimumTokenPercent: integer("cheer_minimum_token_percent")
197210
.notNull()
198211
.default(25),
212+
streamElementsTipAmountPerVipToken: real(
213+
"streamelements_tip_amount_per_vip_token"
214+
)
215+
.notNull()
216+
.default(5),
217+
streamElementsTipWebhookToken: text("streamelements_tip_webhook_token")
218+
.notNull()
219+
.default(""),
199220
duplicateWindowSeconds: integer("duplicate_window_seconds")
200221
.notNull()
201222
.default(900),

src/lib/eventsub/chat-message.ts

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ import {
2828
buildHowMessage,
2929
buildSearchMessage,
3030
buildSetlistMessage,
31+
formatPathList,
3132
getActiveRequestLimit,
3233
getArraySetting,
3334
getRateLimitWindow,
3435
getRequiredPathsMatchMode,
3536
getRequiredPathsWarning,
3637
isRequesterAllowed,
3738
isSongAllowed,
39+
songMatchesRequestedPaths,
3840
} from "~/lib/request-policy";
3941
import type { NormalizedChatEvent, ParsedChatCommand } from "~/lib/requests";
4042
import type { SongSearchResult } from "~/lib/song-search/types";
@@ -324,6 +326,11 @@ function getRejectedSongMessage(input: {
324326
}`;
325327
}
326328

329+
function getRequestedPathMismatchMessage(requestedPaths: string[]) {
330+
const formattedPaths = formatPathList(requestedPaths);
331+
return `That song does not include the requested path${requestedPaths.length === 1 ? "" : "s"}: ${formattedPaths}.`;
332+
}
333+
327334
function extractRequestedSourceSongId(query: string | undefined) {
328335
const match = /^song:(\d+)$/i.exec((query ?? "").trim());
329336
if (!match) {
@@ -408,8 +415,9 @@ function getSongAllowance(input: {
408415
state: EventSubChatState;
409416
requesterContext: Parameters<typeof isRequesterAllowed>[1];
410417
allowBlacklistOverride: boolean;
418+
requestedPaths: string[];
411419
}) {
412-
return isSongAllowed({
420+
const policyAllowance = isSongAllowed({
413421
song: input.song,
414422
settings: input.state.settings,
415423
blacklistArtists: input.state.blacklistArtists,
@@ -420,6 +428,26 @@ function getSongAllowance(input: {
420428
requester: input.requesterContext,
421429
allowBlacklistOverride: input.allowBlacklistOverride,
422430
});
431+
432+
if (!policyAllowance.allowed) {
433+
return policyAllowance;
434+
}
435+
436+
if (
437+
input.requestedPaths.length > 0 &&
438+
!songMatchesRequestedPaths({
439+
song: input.song,
440+
requestedPaths: input.requestedPaths,
441+
})
442+
) {
443+
return {
444+
allowed: false,
445+
reason: getRequestedPathMismatchMessage(input.requestedPaths),
446+
reasonCode: "requested_paths_not_matched",
447+
};
448+
}
449+
450+
return policyAllowance;
423451
}
424452

425453
async function resolveChatRandomMatch(input: {
@@ -429,6 +457,7 @@ async function resolveChatRandomMatch(input: {
429457
state: EventSubChatState;
430458
requesterContext: Parameters<typeof isRequesterAllowed>[1];
431459
allowBlacklistOverride: boolean;
460+
requestedPaths: string[];
432461
}) {
433462
const baseSearchInput = buildCatalogSearchInput({
434463
query: input.query,
@@ -477,6 +506,7 @@ async function resolveChatRandomMatch(input: {
477506
state: input.state,
478507
requesterContext: input.requesterContext,
479508
allowBlacklistOverride: input.allowBlacklistOverride,
509+
requestedPaths: input.requestedPaths,
480510
});
481511
if (songAllowed.allowed) {
482512
return { firstMatch: candidate, firstRejectedMatch: undefined };
@@ -505,6 +535,7 @@ async function resolveChatRandomMatch(input: {
505535
state: input.state,
506536
requesterContext: input.requesterContext,
507537
allowBlacklistOverride: input.allowBlacklistOverride,
538+
requestedPaths: input.requestedPaths,
508539
}).allowed
509540
);
510541

@@ -530,6 +561,7 @@ async function resolveChatRandomMatch(input: {
530561
state: input.state,
531562
requesterContext: input.requesterContext,
532563
allowBlacklistOverride: input.allowBlacklistOverride,
564+
requestedPaths: input.requestedPaths,
533565
});
534566
if (!songAllowed.allowed && !firstRejectedMatch) {
535567
firstRejectedMatch = {
@@ -555,6 +587,7 @@ async function resolveChatChoiceAvailability(input: {
555587
state: EventSubChatState;
556588
requesterContext: Parameters<typeof isRequesterAllowed>[1];
557589
allowBlacklistOverride: boolean;
590+
requestedPaths: string[];
558591
}) {
559592
const filteredSearch = await input.deps.searchSongs(
560593
input.env,
@@ -596,6 +629,7 @@ async function resolveChatChoiceAvailability(input: {
596629
state: input.state,
597630
requesterContext: input.requesterContext,
598631
allowBlacklistOverride: input.allowBlacklistOverride,
632+
requestedPaths: input.requestedPaths,
599633
});
600634

601635
if (songAllowed.allowed) {
@@ -957,6 +991,7 @@ export async function processEventSubChatMessage(input: {
957991
commandPrefix: state.settings.commandPrefix,
958992
appUrl: env.APP_URL,
959993
channelSlug: channel.slug,
994+
allowRequestPathModifiers: state.settings.allowRequestPathModifiers,
960995
}),
961996
});
962997
return { body: "Accepted", status: 202 };
@@ -1153,10 +1188,13 @@ export async function processEventSubChatMessage(input: {
11531188
return { body: "Rejected", status: 202 };
11541189
}
11551190

1156-
const parsedRequest = parseRequestModifiers(parsed.query?.trim() ?? "");
1191+
const parsedRequest = parseRequestModifiers(parsed.query?.trim() ?? "", {
1192+
allowPathModifiers: state.settings.allowRequestPathModifiers,
1193+
});
11571194
const requestMode = parsedRequest.mode;
11581195
const normalizedQuery = parsedRequest.query;
11591196
const unmatchedQuery = normalizedQuery;
1197+
const requestedPaths = parsedRequest.requestedPaths;
11601198

11611199
if (!normalizedQuery) {
11621200
await deps.createRequestLog(env, {
@@ -1201,6 +1239,7 @@ export async function processEventSubChatMessage(input: {
12011239
state,
12021240
requesterContext,
12031241
allowBlacklistOverride,
1242+
requestedPaths,
12041243
});
12051244

12061245
if (
@@ -1217,6 +1256,7 @@ export async function processEventSubChatMessage(input: {
12171256
state,
12181257
requesterContext,
12191258
allowBlacklistOverride,
1259+
requestedPaths,
12201260
});
12211261
firstMatch = randomMatch.firstMatch;
12221262
firstRejectedMatch = randomMatch.firstRejectedMatch;
@@ -1233,29 +1273,27 @@ export async function processEventSubChatMessage(input: {
12331273
});
12341274
candidateMatchesJson = buildCandidateMatchesJson(search.results);
12351275
for (const result of search.results) {
1236-
const songAllowed = isSongAllowed({
1276+
const effectiveSongAllowance = getSongAllowance({
12371277
song: result,
1238-
settings: state.settings,
1239-
blacklistArtists: state.blacklistArtists,
1240-
blacklistCharters: state.blacklistCharters,
1241-
blacklistSongs: state.blacklistSongs,
1242-
blacklistSongGroups: state.blacklistSongGroups,
1243-
setlistArtists: state.setlistArtists,
1244-
requester: requesterContext,
1278+
state,
1279+
requesterContext,
12451280
allowBlacklistOverride,
1281+
requestedPaths,
12461282
});
12471283

1248-
if (songAllowed.allowed) {
1284+
if (effectiveSongAllowance.allowed) {
12491285
firstMatch = result;
12501286
break;
12511287
}
12521288

12531289
if (!firstRejectedMatch) {
12541290
firstRejectedMatch = {
12551291
song: result,
1256-
reason: songAllowed.reason,
1292+
reason: effectiveSongAllowance.reason,
12571293
reasonCode:
1258-
"reasonCode" in songAllowed ? songAllowed.reasonCode : undefined,
1294+
"reasonCode" in effectiveSongAllowance
1295+
? effectiveSongAllowance.reasonCode
1296+
: undefined,
12591297
};
12601298
}
12611299
}
@@ -1285,6 +1323,28 @@ export async function processEventSubChatMessage(input: {
12851323
return { body: "Lookup failed", status: 202 };
12861324
}
12871325

1326+
if (firstMatch) {
1327+
const firstMatchAllowance = getSongAllowance({
1328+
song: firstMatch,
1329+
state,
1330+
requesterContext,
1331+
allowBlacklistOverride,
1332+
requestedPaths,
1333+
});
1334+
1335+
if (!firstMatchAllowance.allowed) {
1336+
firstRejectedMatch ??= {
1337+
song: firstMatch,
1338+
reason: firstMatchAllowance.reason,
1339+
reasonCode:
1340+
"reasonCode" in firstMatchAllowance
1341+
? firstMatchAllowance.reasonCode
1342+
: undefined,
1343+
};
1344+
firstMatch = null;
1345+
}
1346+
}
1347+
12881348
let warningCode: string | undefined;
12891349
let warningMessage: string | undefined;
12901350

@@ -1370,16 +1430,12 @@ export async function processEventSubChatMessage(input: {
13701430
}
13711431

13721432
if (firstMatch) {
1373-
const songAllowed = isSongAllowed({
1433+
const songAllowed = getSongAllowance({
13741434
song: firstMatch,
1375-
settings: state.settings,
1376-
blacklistArtists: state.blacklistArtists,
1377-
blacklistCharters: state.blacklistCharters,
1378-
blacklistSongs: state.blacklistSongs,
1379-
blacklistSongGroups: state.blacklistSongGroups,
1380-
setlistArtists: state.setlistArtists,
1381-
requester: requesterContext,
1435+
state,
1436+
requesterContext,
13821437
allowBlacklistOverride,
1438+
requestedPaths,
13831439
});
13841440

13851441
if (!songAllowed.allowed) {

0 commit comments

Comments
 (0)