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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file.
- Broadcaster login now also requests the Twitch permissions needed for gifted-sub and cheer-based VIP token automation.
- The app header and settings pages now surface Twitch reauthorization more clearly when a reconnect is required.
- VIP token balances now support fractional values, including partial token grants and clearer balance handling when a viewer has less than one full VIP token remaining.
- Existing requests can now be converted between regular and VIP from chat and from the playlist manager without creating duplicate playlist entries.
- Local-development guidance now strongly separates production bot/broadcaster usage from local testing and explains the risks of cross-environment chat handling.
- The moderation dashboard now supports faster Twitch username search with debouncing, in-chat prioritization, and clearer saved-state feedback for VIP tokens.
- Search results now show newer song versions first, and public search includes a dedicated `!edit` copy command.
Expand All @@ -39,6 +40,7 @@ All notable changes to this project will be documented in this file.
- Duplicate EventSub deliveries for cheers and gifted-sub automation no longer double-grant VIP tokens.
- Twitch reply handling now distinguishes between accepted API requests and messages that Twitch actually sent to chat.
- Bot/account status screens now show the real connected bot identity instead of only the configured bot name.
- VIP request upgrade and downgrade replies now clearly state when a token was used or refunded, and dashboard-triggered request kind changes follow the same token logic and bot reply flow as chat commands.
- Production deployment config regeneration now stays in sync after remote migrations.

## [0.1.0] - 2026-03-18
Expand Down
2 changes: 2 additions & 0 deletions docs/testing-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ These are the first automated checks worth investing in:
- dashboard settings save flow shows success or warning messages
- moderation page can grant or remove VIP tokens
- playlist page reflects live updates and management actions
- playlist page can convert a request between regular and VIP and keep token usage in sync

#### Manual smoke tests

Expand All @@ -46,6 +47,7 @@ Keep these as manual checks, not core CI blockers:
- real EventSub subscription creation
- real chat reply delivery
- real tunnel or public callback behavior
- real `!vip` upgrade / downgrade behavior and dashboard playlist VIP toggles against a live channel

### What not to over-invest in

Expand Down
172 changes: 148 additions & 24 deletions src/lib/eventsub/chat-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,15 @@ export interface EventSubChatState {
outcome: string;
createdAt: number;
}>;
items: unknown[];
items: Array<{
id: string;
songId: string | null;
status: string;
requestKind: string | null;
requestedByTwitchUserId: string | null;
requestedByLogin: string | null;
requestedByDisplayName: string | null;
}>;
}

export interface VipTokenBalance {
Expand Down Expand Up @@ -176,6 +184,14 @@ export interface EventSubChatDependencies {
kind: "regular" | "vip" | "all";
}
): Promise<PlaylistMutationResult>;
changeRequestKind(
env: AppEnv,
input: {
channelId: string;
itemId: string;
requestKind: "regular" | "vip";
}
): Promise<PlaylistMutationResult>;
createRequestLog(
env: AppEnv,
input: Record<string, unknown>
Expand Down Expand Up @@ -225,6 +241,25 @@ function buildCandidateMatchesJson(results: SongSearchResult[]) {
);
}

function getRequesterMatchingActiveItem(input: {
items: EventSubChatState["items"];
requesterTwitchUserId: string;
matchedSongId: string | null;
}) {
if (!input.matchedSongId) {
return null;
}

return (
input.items.find(
(item) =>
item.songId === input.matchedSongId &&
item.requestedByTwitchUserId === input.requesterTwitchUserId &&
(item.status === "queued" || item.status === "current")
) ?? null
);
}

function getRejectedSongMessage(input: {
login: string;
reason?: string;
Expand Down Expand Up @@ -604,28 +639,6 @@ export async function processEventSubChatMessage(input: {
const effectiveActiveCount = isEditCommand
? Math.max(0, existingActiveCount - existingActiveCount)
: existingActiveCount;
if (Number.isFinite(activeLimit)) {
if (effectiveActiveCount >= activeLimit) {
const message = `You already have ${activeLimit} active request${activeLimit === 1 ? "" : "s"} in the playlist.`;
await deps.createRequestLog(env, {
channelId: channel.id,
twitchMessageId: event.messageId,
twitchUserId: requesterIdentity.twitchUserId,
requesterLogin: requesterIdentity.login,
requesterDisplayName: requesterIdentity.displayName,
rawMessage: event.rawMessage,
normalizedQuery: parsed.query,
outcome: "rejected",
outcomeReason: "active_request_limit",
});
await deps.sendChatReply(env, {
channelId: channel.id,
broadcasterUserId: channel.twitchChannelId,
message,
});
return { body: "Rejected", status: 202 };
}
}

const timeWindow = getRateLimitWindow(state.settings, requesterContext);
if (timeWindow) {
Expand Down Expand Up @@ -857,8 +870,40 @@ export async function processEventSubChatMessage(input: {
}
}

const existingMatchingRequest = getRequesterMatchingActiveItem({
items: state.items,
requesterTwitchUserId: requesterIdentity.twitchUserId,
matchedSongId: firstMatch?.id ?? null,
});

if (
Number.isFinite(activeLimit) &&
effectiveActiveCount >= activeLimit &&
!existingMatchingRequest
) {
const message = `You already have ${activeLimit} active request${activeLimit === 1 ? "" : "s"} in the playlist.`;
await deps.createRequestLog(env, {
channelId: channel.id,
twitchMessageId: event.messageId,
twitchUserId: requesterIdentity.twitchUserId,
requesterLogin: requesterIdentity.login,
requesterDisplayName: requesterIdentity.displayName,
rawMessage: event.rawMessage,
normalizedQuery: parsed.query,
outcome: "rejected",
outcomeReason: "active_request_limit",
});
await deps.sendChatReply(env, {
channelId: channel.id,
broadcasterUserId: channel.twitchChannelId,
message,
});
return { body: "Rejected", status: 202 };
}

if (
firstMatch &&
!existingMatchingRequest &&
state.settings.duplicateWindowSeconds > 0 &&
state.logs.some(
(log) =>
Expand Down Expand Up @@ -890,7 +935,10 @@ export async function processEventSubChatMessage(input: {
return { body: "Rejected", status: 202 };
}

if (state.items.length >= state.settings.maxQueueSize) {
if (
state.items.length >= state.settings.maxQueueSize &&
!existingMatchingRequest
) {
const effectiveQueueCount = isEditCommand
? Math.max(0, state.items.length - existingActiveCount)
: state.items.length;
Expand Down Expand Up @@ -921,6 +969,69 @@ export async function processEventSubChatMessage(input: {
}

try {
if (
existingMatchingRequest &&
existingMatchingRequest.requestKind !== (isVipCommand ? "vip" : "regular")
) {
await deps.changeRequestKind(env, {
channelId: channel.id,
itemId: existingMatchingRequest.id,
requestKind: isVipCommand ? "vip" : "regular",
});

await deps.createRequestLog(env, {
channelId: channel.id,
twitchMessageId: event.messageId,
twitchUserId: requesterIdentity.twitchUserId,
requesterLogin: requesterIdentity.login,
requesterDisplayName: requesterIdentity.displayName,
rawMessage: event.rawMessage,
normalizedQuery: parsed.query,
matchedSongId: firstMatch?.id ?? null,
matchedSongTitle: firstMatch?.title ?? null,
matchedSongArtist: firstMatch?.artist ?? null,
outcome: "accepted",
outcomeReason: isVipCommand
? "vip_request_upgrade"
: "vip_request_downgrade",
});

if (isVipCommand) {
if (canAutoGrantVipToken) {
await deps.grantVipToken(env, {
channelId: channel.id,
login: requesterIdentity.login,
displayName: requesterIdentity.displayName,
twitchUserId: requesterIdentity.twitchUserId,
autoSubscriberGrant: true,
});
}

await deps.consumeVipToken(env, {
channelId: channel.id,
login: requesterIdentity.login,
displayName: requesterIdentity.displayName,
twitchUserId: requesterIdentity.twitchUserId,
});
} else {
await deps.grantVipToken(env, {
channelId: channel.id,
login: requesterIdentity.login,
displayName: requesterIdentity.displayName,
twitchUserId: requesterIdentity.twitchUserId,
});
}

await deps.sendChatReply(env, {
channelId: channel.id,
broadcasterUserId: channel.twitchChannelId,
message: isVipCommand
? `${mention(requesterIdentity.login)} your existing request "${firstMatch ? formatSongForReply(firstMatch) : unmatchedQuery}" is now a VIP request${existingMatchingRequest.status === "current" ? "." : " and will play next."} 1 VIP token was used.`
: `${mention(requesterIdentity.login)} your existing VIP request "${firstMatch ? formatSongForReply(firstMatch) : unmatchedQuery}" is now a regular request again. 1 VIP token was refunded.`,
});
return { body: "Accepted", status: 202 };
}

if (isEditCommand && existingActiveCount > 0) {
await deps.removeRequestsFromPlaylist(env, {
channelId: channel.id,
Expand Down Expand Up @@ -1148,6 +1259,19 @@ export function createEventSubChatDependencies(): EventSubChatDependencies {
);
return (await response.json()) as PlaylistMutationResult;
},
changeRequestKind: async (env, input) => {
const response = await callBackend(env, "/internal/playlist/mutate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
action: "changeRequestKind",
...input,
}),
});
return (await response.json()) as PlaylistMutationResult;
},
createRequestLog: async (env, input) =>
createRequestLog(env, input as never),
createAuditLog: async (env, input) => createAuditLog(env, input as never),
Expand Down
10 changes: 10 additions & 0 deletions src/lib/playlist/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface RemoveRequestsInput {
kind: "regular" | "vip" | "all";
}

export interface ChangeRequestKindInput {
channelId: string;
itemId: string;
actorUserId: string | null;
requestKind: "regular" | "vip";
}

export interface MarkPlayedInput {
channelId: string;
itemId: string;
Expand Down Expand Up @@ -126,6 +133,9 @@ export interface PlaylistMutationResult {

export interface PlaylistCoordinator {
addRequest(input: AddRequestInput): Promise<PlaylistMutationResult>;
changeRequestKind(
input: ChangeRequestKindInput
): Promise<PlaylistMutationResult>;
removeRequests(input: RemoveRequestsInput): Promise<PlaylistMutationResult>;
markPlayed(input: MarkPlayedInput): Promise<PlaylistMutationResult>;
restorePlayed(input: RestorePlayedInput): Promise<PlaylistMutationResult>;
Expand Down
5 changes: 5 additions & 0 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ export const playlistMutationSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("restorePlayed"), playedSongId: z.string() }),
z.object({ action: z.literal("setCurrent"), itemId: z.string() }),
z.object({ action: z.literal("deleteItem"), itemId: z.string() }),
z.object({
action: z.literal("changeRequestKind"),
itemId: z.string(),
requestKind: z.enum(["regular", "vip"]),
}),
z.object({
action: z.literal("chooseVersion"),
itemId: z.string(),
Expand Down
28 changes: 5 additions & 23 deletions src/routes/$slug/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,28 +164,6 @@ function PublicChannelPage() {
const vipAutomationSummary = getVipAutomationSummary(
data?.playlist.settings ?? {}
);
const publicSearchResultFilter = useMemo(
() => (song: SearchSong) =>
showBlacklisted ||
getBlacklistReasons(
{
songCatalogSourceId: song.sourceId ?? null,
songArtist: song.artist ?? null,
songCreator: song.creator ?? null,
},
{
artists: data?.playlist.blacklistArtists ?? [],
charters: data?.playlist.blacklistCharters ?? [],
songs: data?.playlist.blacklistSongs ?? [],
}
).length === 0,
[
data?.playlist.blacklistArtists,
data?.playlist.blacklistCharters,
data?.playlist.blacklistSongs,
showBlacklisted,
]
);
const publicSearchResultState = useMemo(
() =>
(song: SearchSong): SearchSongResultState => {
Expand Down Expand Up @@ -285,8 +263,12 @@ function PublicChannelPage() {
title="Search to add a song"
description="Copy the request command and use it in Twitch chat."
placeholder={`Search songs for ${channelDisplayName}`}
resultFilter={publicSearchResultFilter}
extraSearchParams={{
channelSlug: slug,
showBlacklisted,
}}
resultState={publicSearchResultState}
useTotalForSummary
advancedFiltersContent={
<div className="inline-flex flex-wrap items-center gap-3 rounded-full border border-(--border) bg-(--panel) px-4 py-2.5">
<Checkbox
Expand Down
Loading
Loading