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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ Rules (checked by `tests/changelog.test.ts`):
and behind-the-scenes work.
0.1.0 to 0.7.1 were cut after the fact from git history.

## 0.11.0 - 2026-10-20

- Channel point rewards section on the mod page's Stream setup tab (`renderPoints` in `src/admin.ts`): the
broadcaster or owner allows the points permission, adds and deletes rewards; mods read rewards and redemptions.
- `GET /api/admin/:channel` returns `pointsReady`, read from the scopes sealed with the stored broadcaster token.

## 0.10.0 - 2026-10-20

- `!changelog` bot and StreamElements command (`changelog` action, a read action). Its line comes from `LATEST_RELEASE`
Expand Down
25 changes: 25 additions & 0 deletions admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,31 @@ <h3>Counters</h3>
<p class="status" id="counter-status" role="status"></p>
</div>
</section>
<section class="section" id="points" aria-labelledby="points-title" hidden>
<h2 id="points-title">Channel point rewards that add PixFray dollars</h2>
<p class="muted small">A viewer redeems the reward with channel points, the bot adds its dollars to their saved fighter and says their new wallet. A viewer without a saved fighter gets the points back. Twitch gives channel points only to Affiliates and Partners.</p>
<div id="points-connect" class="stack" hidden>
<p id="points-connect-text"></p>
<div class="toolbar"><a class="btn" id="points-login" href="#">Allow channel point rewards</a></div>
</div>
<div class="table-wrap"><table class="data" id="points-table">
<thead><tr><th scope="col">Reward</th><th scope="col" class="num">Cost<span class="wide-only"> (points)</span></th><th scope="col" class="num">Adds ($)</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
<tbody></tbody>
</table></div>
<form id="points-form" class="controls" novalidate hidden>
<div><label for="points-title-input">Reward name</label><input id="points-title-input" maxlength="45" autocomplete="off" required><p class="hint">Up to 45 characters, shown on Twitch</p></div>
<div><label for="points-cost">Cost (channel points)</label><input id="points-cost" type="number" min="1" max="1000000" step="1" inputmode="numeric" required><p class="hint">1 to 1000000</p></div>
<div><label for="points-dollars">Adds ($)</label><input id="points-dollars" type="number" min="1" max="10000" step="1" inputmode="numeric" required><p class="hint">1 to 10000 PixFray dollars</p></div>
<div><button class="btn" id="points-add" type="submit">Add reward</button></div>
</form>
<p class="small muted" id="points-note"></p>
<p class="status" id="points-status" role="status"></p>
<h3>Recent redemptions</h3>
<div class="table-wrap"><table class="data" id="points-redemptions">
<thead><tr><th scope="col">When</th><th scope="col">Viewer</th><th scope="col">Reward</th><th scope="col">What happened</th><th scope="col" class="num">Added ($)</th></tr></thead>
<tbody></tbody>
</table></div>
</section>
<section class="section" id="troubleshoot" aria-labelledby="trouble-title">
<h2 id="trouble-title">If something doesn't work</h2>
<details class="small"><summary>The overlay is blank in OBS</summary>
Expand Down
12 changes: 11 additions & 1 deletion changelog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
<main class="page">
<header class="page-header">
<h1>What's new in PixFray</h1>
<p class="subtitle">What changed for players and streamers, newest first. PixFray is on version <span class="num" id="current-version">0.10.0</span>. Code changes are listed in <code>CHANGELOG.md</code> in the PixFray source.</p>
<p class="subtitle">What changed for players and streamers, newest first. PixFray is on version <span class="num" id="current-version">0.11.0</span>. Code changes are listed in <code>CHANGELOG.md</code> in the PixFray source.</p>
</header>

<div class="with-index changelog">
<nav class="page-index" aria-label="Releases"><ol>
<li><a href="#v0.11.0">0.11.0 Channel point rewards on the mod page</a></li>
<li><a href="#v0.10.0">0.10.0 Changelog in chat</a></li>
<li><a href="#v0.9.0">0.9.0 Fairer duels</a></li>
<li><a href="#v0.8.0">0.8.0 Channel point rewards</a></li>
Expand All @@ -38,6 +39,15 @@ <h1>What's new in PixFray</h1>
</ol></nav>

<div>
<section class="release" id="v0.11.0" aria-labelledby="v0.11.0-title">
<h2 id="v0.11.0-title"><span class="num">0.11.0</span> Channel point rewards on the mod page</h2>
<p class="meta"><time datetime="2026-10-20">20 October 2026</time></p>
<ul>
<li><span class="badge">New</span> Broadcasters set up channel point rewards on the mod page's Stream setup tab: allow the permission once, then add up to 5 rewards with their cost and the dollars they add, or delete them. Twitch offers channel points to Affiliates and Partners.</li>
<li><span class="badge">New</span> The same section lists the latest redemptions and what happened to each, so mods can see who got dollars and who was refunded.</li>
</ul>
</section>

<section class="release" id="v0.10.0" aria-labelledby="v0.10.0-title">
<h2 id="v0.10.0-title"><span class="num">0.10.0</span> Changelog in chat</h2>
<p class="meta"><time datetime="2026-10-20">20 October 2026</time></p>
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ channel of that account only, so nobody can turn on someone else's channel.
- `fighters:<userId>` = `{channels:[login]}`, the channels where the viewer saved a fighter, newest save first, at most 8. A successful `POST /api/profile/:channel` moves the channel to the front; kept 90 days from the last save. Read by `GET /api/profile/:channel/others`.
- `modsconnected:<login>` = `{at}`. Written when mod access is connected (`connect=mods`, a signup that grants `moderation:read`, or `connect=1`), and also by an admin page view that finds a stored token without one. Expires after 20 years (the AuthStore accepts expiries up to 21 years for `channel:`, `modsconnected:` and `bot:` keys, 100 days for everything else).
- `broadcaster:<login>` = the sealed Twitch token plus `touched` (ms). It is kept 90 days from its last use: every save refreshes the expiry, the hourly token validation re-saves it, and an admin page view re-saves it when `touched` is more than 7 days old. A 401 from Twitch renews the access token with the stored refresh token. If the record expires or the refresh fails, mod checks stop (`modsReady:false`).
- `GET /api/admin/:channel` adds `points` (`{rewards:[{rewardId, title, cost, dollars, subscriptionId, status, createdAt}], redemptions:[{id, at, rewardId, user, outcome, dollars, marked}] (the latest 20), limits}`) and `pointsLogin` (the points sign-in link). A reward's `status` is the subscription status Twitch answered when it was made (usually `webhook_callback_verification_pending`) until a revocation replaces it, so a pending status is not a fault.
- `GET /api/admin/:channel` adds `points` (`{rewards:[{rewardId, title, cost, dollars, subscriptionId, status, createdAt}], redemptions:[{id, at, rewardId, user, outcome, dollars, marked}] (the latest 20), limits}`) `pointsLogin` (the points sign-in link) and `pointsReady` (true when the stored broadcaster token holds `channel:manage:redemptions`; the mod page shows **Allow channel point rewards** to the broadcaster or owner until it is). A reward's `status` is the subscription status Twitch answered when it was made (usually `webhook_callback_verification_pending`) until a revocation replaces it, so a pending status is not a fault.
- `GET /api/admin/:channel` adds `modsReady` (the `broadcaster:<login>` record exists) and `modsLapsed` (`modsconnected:<login>` exists but `broadcaster:<login>` is gone). They are never both true. `modsLapsed` makes the admin page show "Expired" and "Reconnect mod access" instead of "Connect mod access" in Stream setup.
- `OWNER_TWITCH_ID` is a text binding, nesszerra's public Twitch id `445610108`, declared for prod and test in `cloudflare.config.ts`. When it is set, `isOwner` compares the session user's id to it and ignores the `owner:nesszerra` record (which expires 90 days after the last sign-in). It is not declared when `cf dev` runs with `MINI_LOCAL_TEST=1` (the test server), where seeded sessions use owner id 900001 and the `owner:nesszerra` record decides. A plain `cf dev` without that flag does declare it.
- An overlay on a paused or unknown channel draws nothing and reloads every 5 minutes. One that was
Expand Down
1 change: 1 addition & 0 deletions docs/DUELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ The duel is rolled at once, the moment the challenge is accepted.
5 finished duels, at most $100 per stream, and only while the stream is live.
- On a channel that has PixFray channel point rewards, redeeming one adds its dollars to your saved fighter and
the bot says your new wallet. Without a saved fighter the redemption is refunded and the bot links the fighter page.
The broadcaster sets these rewards up on the mod page (docs/STREAMER_SETUP.md).
- Dollars show only to mods and the owner on the website.

## Ranked and unrated duels
Expand Down
23 changes: 23 additions & 0 deletions docs/STREAMER_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ Characters tab. Approving puts the viewer in their sprite; **Turn down** drops t
approved sprite can be removed later. Each viewer can send 6 a day; the AI redraw is limited to
3 per viewer and 60 per channel a day.

## 6. Channel point rewards (optional)

Channel point rewards let viewers spend their Twitch channel points on PixFray dollars for their
saved fighter. Twitch offers channel points only to Affiliates and Partners; on other channels
adding a reward fails and the page says why.

1. On the mod page's **Stream setup** tab, find **Channel point rewards that add PixFray dollars**.
2. Click **Allow channel point rewards** and approve the permission while signed in to Twitch as
yourself. You come back to the mod page.
3. Fill in the reward's name (up to 45 characters), its cost in channel points (1 to 1,000,000) and
the dollars it adds (1 to 10,000), then click **Add reward**. It shows under channel points on
Twitch right away.

A channel can have 5 PixFray rewards; delete one to add another. **Delete** removes the reward from
Twitch too; dollars already added stay. Only you (or the site owner) can add or delete rewards.
Moderators see the rewards and the recent redemptions without the controls.

Each redemption adds the dollars and the bot says the viewer's new wallet. A viewer without a saved
fighter gets the points back and the bot links the fighter page. **Recent redemptions** lists the
latest 20. If a line says Twitch's request queue wasn't updated, the redemption is still pending in
Twitch's queue: refund or complete it there. If Twitch stops letting PixFray manage the rewards,
the page asks you to allow them again.

## Channels that use StreamElements

Channels that set PixFray up before the bot, and sites without a bot (`bot` unset in
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mini-chat",
"version": "0.10.0",
"version": "0.11.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
11 changes: 8 additions & 3 deletions public/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -1559,15 +1559,20 @@ html:has(.config-bar.dirty) {
/* mod tables: keep who, state and the row's button on screen; the detail columns are on wider screens */
table#duels :is(th, td):nth-child(n + 3):nth-child(-n + 5),
table#players :is(th, td):nth-child(n + 3):nth-child(-n + 6),
table#ranks :is(th, td):nth-child(n + 4):nth-child(-n + 5) {
table#ranks :is(th, td):nth-child(n + 4):nth-child(-n + 5),
table#points-redemptions :is(th, td):nth-child(odd):not(:last-child) {
display: none;
}
.table-wrap > table:is(#duels, #players, #ranks) {
.table-wrap > table:is(#duels, #players, #ranks, #points-table, #points-redemptions) {
width: 100%;
}
.table-wrap > table:is(#duels, #players, #ranks) td:first-child {
.table-wrap > table:is(#duels, #players, #ranks, #points-table, #points-redemptions) td:first-child {
min-width: 0;
}
#points-table td:first-child,
#points-redemptions td {
white-space: normal;
}
.stat .value {
font-size: var(--text-xl);
overflow-wrap: anywhere;
Expand Down
2 changes: 1 addition & 1 deletion server/release.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// The newest release on the /changelog/ page (changelog/index.html), for the !changelog chat line.
// tests/changelog.test.ts keeps it equal to the page's newest release.
export const LATEST_RELEASE = { version: "0.10.0", name: "Changelog in chat" };
export const LATEST_RELEASE = { version: "0.11.0", name: "Channel point rewards on the mod page" };
8 changes: 8 additions & 0 deletions server/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
CHANNELS,
touchBroadcaster,
markModsConnected,
unseal,
POINTS_SCOPE,
} from "./auth.ts";
import { handleDeveloper, logWorkerError } from "./developer.ts";
import { backupChannels } from "./backups.ts";
Expand Down Expand Up @@ -792,6 +794,11 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi
]);
const modsReady = !!broadcaster,
modsLapsed = !modsReady && !!marker;
// pointsReady: the stored token carries channel:manage:redemptions (the points sign-in), read from the scopes sealed
// with it; renewals keep them. Twitch can still refuse later, which createPointReward answers with the sign-in link.
const pointsReady = broadcaster
? !!(await unseal<{ scopes?: string[] }>(env, broadcaster).catch(() => null))?.scopes?.includes(POINTS_SCOPE)
: false;
if (broadcaster)
await Promise.all([
touchBroadcaster(env, channel, broadcaster),
Expand All @@ -808,6 +815,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi
modsReady,
modsLapsed,
pointsLogin: pointsLoginUrl(channel),
pointsReady,
channelState: state,
});
}
Expand Down
Loading
Loading