From b7407a5d1aa7df2ed761c0d42d38588baccdaef0 Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:18:04 +0700 Subject: [PATCH 1/2] Channel point rewards section on the mod page (0.11.0) The broadcaster or owner allows the points permission, then adds and deletes up to 5 rewards on the Stream setup tab; mods see the rewards and the latest redemptions. GET /api/admin returns pointsReady from the stored token's scopes. --- CHANGELOG.md | 6 ++ admin/index.html | 25 ++++++ changelog/index.html | 12 ++- docs/CONTRACTS.md | 2 +- docs/DUELS.md | 1 + docs/STREAMER_SETUP.md | 23 +++++ package.json | 2 +- public/dashboard.css | 11 ++- server/release.ts | 2 +- server/worker.ts | 8 ++ src/admin.ts | 181 +++++++++++++++++++++++++++++++++++++ src/parse.ts | 20 +++++ tests/hardening.test.ts | 16 ++++ tests/ui.ts | 195 ++++++++++++++++++++++++++++++++++++++++ 14 files changed, 497 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fddf67..3aee103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/admin/index.html b/admin/index.html index 246d1a5..76448ad 100644 --- a/admin/index.html +++ b/admin/index.html @@ -291,6 +291,31 @@

Counters

+

If something doesn't work

The overlay is blank in OBS diff --git a/changelog/index.html b/changelog/index.html index 08e41f9..3c7e91d 100644 --- a/changelog/index.html +++ b/changelog/index.html @@ -18,11 +18,12 @@
+
+

0.11.0 Channel point rewards on the mod page

+

+
    +
  • New 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.
  • +
  • New The same section lists the latest redemptions and what happened to each, so mods can see who got dollars and who was refunded.
  • +
+
+

0.10.0 Changelog in chat

diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index bf391ce..f0bf50c 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -162,7 +162,7 @@ channel of that account only, so nobody can turn on someone else's channel. - `fighters:` = `{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:` = `{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:` = 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:` record exists) and `modsLapsed` (`modsconnected:` exists but `broadcaster:` 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 diff --git a/docs/DUELS.md b/docs/DUELS.md index 6b6f67b..a59309c 100644 --- a/docs/DUELS.md +++ b/docs/DUELS.md @@ -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 diff --git a/docs/STREAMER_SETUP.md b/docs/STREAMER_SETUP.md index 3a68285..9142377 100644 --- a/docs/STREAMER_SETUP.md +++ b/docs/STREAMER_SETUP.md @@ -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 diff --git a/package.json b/package.json index c3c4068..94a24f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mini-chat", - "version": "0.10.0", + "version": "0.11.0", "private": true, "license": "MIT", "type": "module", diff --git a/public/dashboard.css b/public/dashboard.css index 48c7bdb..7a3e280 100644 --- a/public/dashboard.css +++ b/public/dashboard.css @@ -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; diff --git a/server/release.ts b/server/release.ts index 803e803..aa1ea16 100644 --- a/server/release.ts +++ b/server/release.ts @@ -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" }; diff --git a/server/worker.ts b/server/worker.ts index 244e2c5..c64d1a1 100644 --- a/server/worker.ts +++ b/server/worker.ts @@ -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"; @@ -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), @@ -808,6 +815,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi modsReady, modsLapsed, pointsLogin: pointsLoginUrl(channel), + pointsReady, channelState: state, }); } diff --git a/src/admin.ts b/src/admin.ts index c528152..db4aeda 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -30,6 +30,7 @@ import type { BotStatus, ChatStatus, LeaderRow as Fighter, + PointsPanel, Se, Session, } from "./parse.ts"; @@ -437,6 +438,24 @@ async function init() { url.searchParams.delete("mods"); history.replaceState(null, "", url.pathname + url.search + url.hash); } + // Back from "Allow channel point rewards" (/auth/login?channel=&connect=points) + const points = new URLSearchParams(location.search).get("points"); + const POINTS: Record = { + connected: "Channel point rewards are allowed. Add a reward below.", + denied: "The Twitch permission was cancelled, so PixFray can't make channel point rewards.", + }; + // without ?points= this looks up "null", which isn't a key + if (POINTS[points as string]) { + // as above; element type from the page HTML + setStatus( + $("#points-status") as HTMLParagraphElement, + POINTS[points as string], + points === "connected" ? "ok" : "error", + ); + const url = new URL(location.href); + url.searchParams.delete("points"); + history.replaceState(null, "", url.pathname + url.search + url.hash); + } // Back from "Add the PixFray bot" (/auth/login?connect=bot) or sign-up with channel:bot: connect chat through the bot now. if (new URLSearchParams(location.search).get("bot") === "allowed") { const url = new URL(location.href); @@ -812,6 +831,7 @@ function renderAll() { renderChecklist(); renderCheckinTest(); renderBotCommands(); + renderPoints(); renderPower(); } // Where chat comes from and whether it works. StreamElements counts as working only once a command has @@ -2713,6 +2733,167 @@ async function commandAction(action: string, payload: Record, s else button.disabled = false; }); +// ---------- channel point rewards (server/points.ts; the broadcaster or the owner adds and deletes, mods read) ---------- +const POINT_OUTCOMES: Record = { + granted: "Dollars added", + refunded: "No saved fighter, points refunded", + no_fighter: "No saved fighter, not refunded", + ignored: "Not a PixFray reward", +}; +const POINT_ERRORS: Record = { + invalid_title: "Name the reward with 1 to 45 characters", + invalid_cost: "Pick a cost from 1 to 1000000 channel points", + invalid_dollars: "Pick 1 to 10000 dollars", + points_max_rewards: "A channel can have 5 PixFray rewards. Delete one first", + points_not_connected: "Twitch needs the channel point permission again. Use Allow channel point rewards", + reward_not_found: "That reward is already gone", +}; +let shownPoints = ""; +function renderPoints() { + // S.admin!: called from renderAll and pointsAction, after load() + const a = S.admin!, + p: PointsPanel | undefined = a.points, + // element type from the page HTML + section = $("#points") as HTMLElement; + section.hidden = !p; + if (!p) return; + const rewards = Array.isArray(p.rewards) ? p.rewards : [], + redemptions = Array.isArray(p.redemptions) ? p.redemptions : [], + max = p.limits?.maxRewards || 5, + owner = mayBroadcast(), + ready = !!a.pointsReady; + // element type from the page HTML + ($("#points-connect") as HTMLDivElement).hidden = ready || !owner; + // element type from the page HTML + ($("#points-connect-text") as HTMLParagraphElement).textContent = rewards.length + ? "Twitch no longer lets PixFray manage these rewards. Allow it again so redemptions add dollars and refunds work." + : "First let PixFray make rewards on your channel. Twitch asks for one more permission, then you come back here."; + // element type from the page HTML + ($("#points-login") as HTMLAnchorElement).href = a.pointsLogin || "#"; + // element type from the page HTML + ($("#points-form") as HTMLFormElement).hidden = !(owner && ready); + // element type from the page HTML + ($("#points-add") as HTMLButtonElement).disabled = rewards.length >= max; + // element type from the page HTML + ($("#points-note") as HTMLParagraphElement).textContent = !owner + ? "Only " + CHANNEL + " can add or delete rewards." + (ready ? "" : " Channel point rewards aren't allowed yet.") + : ready && rewards.length >= max + ? max + " of " + max + " rewards. Delete one to add another." + : ""; + const key = JSON.stringify([p, owner]); + if (key === shownPoints) return; + shownPoints = key; + // element type from the page HTML + ($("#points-table tbody") as HTMLTableSectionElement).replaceChildren( + ...(rewards.length + ? rewards.map((r) => + h( + "tr", + {}, + h("td", {}, r.title), + h("td", { class: "num" }, r.cost.toLocaleString("en-US")), + h("td", { class: "num" }, "$" + r.dollars.toLocaleString("en-US")), + h( + "td", + {}, + owner + ? h( + "button", + { + class: "btn btn-small btn-danger", + type: "button", + "data-reward": r.rewardId, + "data-title": r.title, + "aria-label": "Delete " + r.title, + }, + "Delete", + ) + : "", + ), + ), + ) + : [h("tr", {}, h("td", { colspan: "4", class: "muted" }, "No rewards yet."))]), + ); + const titles = new Map(rewards.map((r) => [r.rewardId, r.title])); + // element type from the page HTML + ($("#points-redemptions tbody") as HTMLTableSectionElement).replaceChildren( + ...(redemptions.length + ? redemptions.map((x) => { + // marked: 0 until Twitch answers, -1 when PixFray leaves it alone, else Twitch's HTTP status (1: no answer) + const unmarked = x.marked > 0 && (x.marked < 200 || x.marked > 299); + return h( + "tr", + {}, + h("td", {}, dateTime(x.at)), + h("td", {}, x.user), + h("td", {}, titles.get(x.rewardId) || "A deleted reward"), + h( + "td", + {}, + (POINT_OUTCOMES[x.outcome] || x.outcome) + + (unmarked ? ". Twitch's request queue wasn't updated, so check it there" : ""), + ), + h("td", { class: "num" }, x.dollars ? "$" + x.dollars.toLocaleString("en-US") : "0"), + ); + }) + : [h("tr", {}, h("td", { colspan: "5", class: "muted" }, "No redemptions yet."))]), + ); +} +async function pointsAction(action: string, payload: Record) { + // element type from the page HTML + const status = $("#points-status") as HTMLParagraphElement; + setStatus(status, "Working…"); + const r = await api("/api/admin/" + CHANNEL, { method: "POST", body: { action, payload } }); + if (!r.ok) { + setStatus(status, "That didn't work: " + (POINT_ERRORS[r.data?.reason] || errorText(r)) + ".", "error"); + if (r.data?.reason === "points_not_connected") await load(); + return false; + } + // S.admin!: the section shows only after load() + S.admin!.points = { rewards: r.data.rewards, redemptions: r.data.redemptions, limits: r.data.limits }; + renderPoints(); + return true; +} +// element type from the page HTML +($("#points-form") as HTMLFormElement).addEventListener("submit", async (e) => { + e.preventDefault(); + // element type from the page HTML + const status = $("#points-status") as HTMLParagraphElement, + // element type from the page HTML + title = ($("#points-title-input") as HTMLInputElement).value.trim(), + // element type from the page HTML + cost = Number(($("#points-cost") as HTMLInputElement).value), + // element type from the page HTML + dollars = Number(($("#points-dollars") as HTMLInputElement).value), + // element type from the page HTML + button = $("#points-add") as HTMLButtonElement; + if (!title || title.length > 45) return setStatus(status, POINT_ERRORS.invalid_title + ".", "error"); + if (!Number.isInteger(cost) || cost < 1 || cost > 1e6) + return setStatus(status, POINT_ERRORS.invalid_cost + ".", "error"); + if (!Number.isInteger(dollars) || dollars < 1 || dollars > 1e4) + return setStatus(status, POINT_ERRORS.invalid_dollars + ".", "error"); + button.disabled = true; + const ok = await pointsAction("createPointReward", { title, cost, dollars }); + button.disabled = false; + renderPoints(); + if (!ok) return; + // element type from the page HTML + ($("#points-form") as HTMLFormElement).reset(); + setStatus(status, "Added " + title + ". It shows under channel points on Twitch now.", "ok"); +}); +// element type from the page HTML +($("#points-table") as HTMLTableElement).addEventListener("click", async (e) => { + // clicks come from elements in the table; the delete carriers are buttons + const del = (e.target as Element).closest("[data-reward]"); + if (!del || !confirm("Delete " + del.dataset.title + "? It disappears from Twitch too. Dollars already added stay.")) + return; + del.disabled = true; + if (await pointsAction("deletePointReward", { rewardId: del.dataset.reward })) + // element type from the page HTML + setStatus($("#points-status") as HTMLParagraphElement, "Deleted " + del.dataset.title + ".", "ok"); + else del.disabled = false; +}); + // ---------- turning PixFray off (signed-up channels; the broadcaster or the owner, and only the owner undoes the owner's off) ---------- const mayBroadcast = () => !!(S.access?.broadcaster || S.access?.owner); const mayPower = () => diff --git a/src/parse.ts b/src/parse.ts index 3d06674..4536c3a 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -440,6 +440,20 @@ export type BotCommands = { commands: { name: string; reply: string }[]; counters: { name: string; value: number }[]; }; +/** Channel point rewards and the newest redemptions (server/points.ts pointsView). */ +export type PointsPanel = { + rewards: { rewardId: string; title: string; cost: number; dollars: number; status?: string; createdAt?: number }[]; + redemptions: { + id: string; + at: number; + rewardId: string; + user: string; + outcome: string; + dollars: number; + marked: number; + }[]; + limits: { titleMax: number; costMax: number; dollarsMax: number; maxRewards: number }; +}; /** GET /api/admin/:channel: the room's state plus the mod page's panels. */ export type Admin = { revision: number; @@ -461,6 +475,9 @@ export type Admin = { checkinTestAllowed?: boolean; checkinTest?: { until: number; by?: string } | null; botCommands?: BotCommands; + points?: PointsPanel; + pointsLogin?: string; + pointsReady?: boolean; channelState?: string; }; const ADMIN: Spec = { @@ -485,6 +502,9 @@ const ADMIN: Spec = { customUsage: opt(isObject), checkinTest: opt(orNull(isObject)), botCommands: opt(isObject), + points: opt(isObject), + pointsLogin: opt(str), + pointsReady: opt(bool), }; /** The room's lists, with each duel and player that doesn't read dropped. */ function roomLists(value: T): T { diff --git a/tests/hardening.test.ts b/tests/hardening.test.ts index a62ec94..103a8b1 100644 --- a/tests/hardening.test.ts +++ b/tests/hardening.test.ts @@ -563,6 +563,22 @@ test("admin snapshot: modsLapsed is true only when mod access was connected and assert.deepEqual([s.modsReady, s.modsLapsed], [false, true], "and lapsed again once it expires"); }); +test("admin snapshot: pointsReady is true only when the stored token has channel:manage:redemptions", async () => { + const f = environment(), + cookie = await ownerCookie(f); + let s = await snapshot(f, cookie); + assert.deepEqual([s.pointsReady, s.pointsLogin], [false, "/auth/login?channel=nesszerra&connect=points"], "no token"); + f.entries.set("broadcaster:nesszerra", await broadcasterToken(f, { scopes: ["moderation:read", "channel:bot"] })); + s = await snapshot(f, cookie); + assert.equal(s.pointsReady, false, "mod access only"); + f.entries.set( + "broadcaster:nesszerra", + await broadcasterToken(f, { scopes: ["moderation:read", "channel:bot", "channel:manage:redemptions"] }), + ); + s = await snapshot(f, cookie); + assert.equal(s.pointsReady, true, "after the points sign-in"); +}); + test("admin snapshot: a token seen without a marker gets one, and viewing keeps a stale token alive", async () => { const f = environment(), cookie = await ownerCookie(f); diff --git a/tests/ui.ts b/tests/ui.ts index a980837..b76ba67 100644 --- a/tests/ui.ts +++ b/tests/ui.ts @@ -2169,6 +2169,201 @@ try { await context.close(); } } + // 7d. Channel point rewards on a signed-up channel: the broadcaster allows them first, then adds and deletes rewards + // (five at most); a moderator reads the rewards and redemptions without controls. + { + const newstreamer = { id: "5505", login: "newstreamer", displayName: "NewStreamer" }, + ch = "newstreamer", + login = "/auth/login?channel=newstreamer&connect=points"; + const cases = [ + { + name: "broadcaster, not allowed yet", + session: newstreamer, + access: { owner: false, broadcaster: true, moderator: false, canManage: true }, + ready: false, + }, + { + name: "broadcaster, allowed", + session: newstreamer, + access: { owner: false, broadcaster: true, moderator: false, canManage: true }, + ready: true, + }, + { name: "moderator", session: mod, access: { owner: false, moderator: true, canManage: true }, ready: true }, + ]; + for (const c of cases) + for (const s of sizes) { + const { context, page } = await newPage(s); + const posts: Loose[] = []; // Loose: the POST bodies, read by field below + const limits = { titleMax: 45, costMax: 1000000, dollarsMax: 10000, maxRewards: 5 }; + let rewards = [ + { + rewardId: "rw1", + title: "PixFray $100", + cost: 500, + dollars: 100, + status: "enabled", + createdAt: now - 3600000, + }, + ]; + const redemptions = [ + { id: "rd2", at: now - 60000, rewardId: "rw1", user: "alice", outcome: "granted", dollars: 100, marked: 200 }, + { id: "rd1", at: now - 120000, rewardId: "gone", user: "bob", outcome: "refunded", dollars: 0, marked: 401 }, + ]; + await page.route("**/api/session", (r) => + json(r, { + user: c.session, + owner: false, + configured: true, + channels: ["nesszerra"], + productionEnabled: false, + }), + ); + await page.route("**/api/access/" + ch, (r) => json(r, c.access)); + await page.route("**/api/leaderboard/" + ch + "*", (r) => json(r, [])); + await page.route("**/api/assets/" + ch, (r) => + json(r, { + items: [], + usage: { count: 0, limit: 8, bytes: 0 }, + limits: { maxFrames: 24, frameSize: 128, maxAtlasBytes: 1572864, maxCharacters: 8 }, + }), + ); + await page.route("**/api/admin/" + ch, (r) => { + if (r.request().method() === "POST") { + const body = r.request().postDataJSON(); + posts.push(body); + if (body.action === "createPointReward" && body.payload.title === "Taken") + return json( + r, + { ok: false, reason: "points_rejected", error: "Twitch refused the reward: duplicate" }, + 400, + ); + if (body.action === "createPointReward") + rewards = [ + ...rewards, + { rewardId: "rw" + (rewards.length + 2), status: "enabled", createdAt: now, ...body.payload }, + ]; + if (body.action === "deletePointReward") + rewards = rewards.filter((x) => x.rewardId !== body.payload.rewardId); + return json(r, { ok: true, rewards, redemptions, limits }); + } + return json(r, { + type: "snapshot", + channel: ch, + revision: 1, + paused: false, + chat: { connected: true, lastSeen: now, status: "enabled" }, + config, + configVersion: 1, + round: 1, + players: [], + duels: [], + events: [], + chatStatus: { + connected: true, + source: "twitch", + status: "enabled", + subscriptionId: "sub-bot", + createdAt: now, + }, + history: [{ version: 1, config, actorId: "system", at: now, note: "" }], + customUsage: { count: 0, limit: 8, bytes: 0 }, + streamelements: null, + overlays: 1, + modsReady: true, + channelState: "on", + chatBot: { login: "pixfray", debug: false }, + botCommands: { commands: [], counters: [], max: 50 }, + botStatus: null, + points: { rewards, redemptions, limits }, + pointsLogin: login, + pointsReady: c.ready, + access: c.access, + }); + }); + await page.goto(base + "/admin/?channel=" + ch + "#chat"); + await page.waitForSelector("#points:not([hidden])"); + assert.deepEqual(await page.locator("#points-table tbody tr").first().locator("td").allTextContents(), [ + "PixFray $100", + "500", + "$100", + c.name === "moderator" ? "" : "Delete", + ]); + assert.deepEqual( + await page.locator("#points-redemptions tbody td:nth-child(4)").allTextContents(), + [ + "Dollars added", + "No saved fighter, points refunded. Twitch's request queue wasn't updated, so check it there", + ], + c.name + ": outcomes, and a refund Twitch refused", + ); + assert.equal( + await page.locator("#points-redemptions tbody td:nth-child(3)").nth(1).textContent(), + "A deleted reward", + ); + if (c.name === "moderator") { + assert.equal(await page.locator("#points-form").isHidden(), true); + assert.equal(await page.locator("#points-connect").isHidden(), true); + assert.equal(await page.locator("#points-note").textContent(), "Only newstreamer can add or delete rewards."); + } else if (!c.ready) { + assert.equal(await page.locator("#points-form").isHidden(), true, "no form before the permission"); + assert.equal(await page.locator("#points-login").getAttribute("href"), login); + assert.match( + (await page.locator("#points-connect-text").textContent())!, // element text, never null + /^Twitch no longer lets PixFray manage these rewards\./, + ); + } else { + assert.equal(await page.locator("#points-connect").isHidden(), true); + await page.click("#points-add"); + assert.equal( + await page.locator("#points-status").textContent(), + "Name the reward with 1 to 45 characters.", + "checked before sending", + ); + await page.fill("#points-title-input", "Taken"); + await page.fill("#points-cost", "100"); + await page.fill("#points-dollars", "10"); + await page.click("#points-add"); + await page.waitForFunction( + () => /duplicate/.test(document.querySelector("#points-status")!.textContent), // the page has this element + ); + for (const n of [2, 3, 4, 5]) { + await page.fill("#points-title-input", "Reward " + n); + await page.fill("#points-cost", String(n * 100)); + await page.fill("#points-dollars", String(n * 10)); + await page.click("#points-add"); + await page.waitForFunction( + (t) => document.querySelector("#points-status")!.textContent.startsWith("Added " + t), // the page has this element + "Reward " + n, + ); + } + assert.deepEqual(posts.at(-1), { + action: "createPointReward", + payload: { title: "Reward 5", cost: 500, dollars: 50 }, + }); + assert.equal(await page.locator("#points-table tbody tr").count(), 5); + assert.equal(await page.locator("#points-add").isDisabled(), true, "five rewards is the limit"); + assert.equal(await page.locator("#points-note").textContent(), "5 of 5 rewards. Delete one to add another."); + await noOverflow(page, "channel points " + s.name); + for (const t of ["#points-table", "#points-redemptions"]) + assert.ok( + await page + .locator(t) + .locator("xpath=..") + .evaluate((n) => n.scrollWidth <= n.clientWidth + 1), + t + " fits without sideways scrolling at " + s.name, + ); + await page.locator("#points").screenshot({ path: shots + "/admin-points-" + s.name + ".png" }); + page.once("dialog", (d) => d.accept()); + await page.click('[data-reward="rw1"]'); + await page.waitForFunction( + () => document.querySelector("#points-status")!.textContent === "Deleted PixFray $100.", // the page has this element + ); + assert.deepEqual(posts.at(-1), { action: "deletePointReward", payload: { rewardId: "rw1" } }); + assert.equal(await page.locator("#points-add").isDisabled(), false, "room for one more"); + } + await context.close(); + } + } // 8. /start: open sign-up, and each reason Twitch sign-in can send someone back, at 1280/390. for (const s of sizes) { const { context, page } = await newPage(s); From 5c51dac30ea9194dc9793aa03fd64d78b551c3ec Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:19:13 +0700 Subject: [PATCH 2/2] Only the broadcaster gets Allow channel point rewards Twitch grants the points scope only to the broadcaster's own account, so the site owner on another channel gets a note instead of a sign-in that ends in wrong_account. --- src/admin.ts | 11 +++++++---- tests/ui.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/admin.ts b/src/admin.ts index db4aeda..b6adba7 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -2763,7 +2763,8 @@ function renderPoints() { owner = mayBroadcast(), ready = !!a.pointsReady; // element type from the page HTML - ($("#points-connect") as HTMLDivElement).hidden = ready || !owner; + // only the broadcaster's own Twitch account can grant the permission, so the site owner elsewhere gets the note instead + ($("#points-connect") as HTMLDivElement).hidden = ready || !broadcasterHere(); // element type from the page HTML ($("#points-connect-text") as HTMLParagraphElement).textContent = rewards.length ? "Twitch no longer lets PixFray manage these rewards. Allow it again so redemptions add dollars and refunds work." @@ -2777,9 +2778,11 @@ function renderPoints() { // element type from the page HTML ($("#points-note") as HTMLParagraphElement).textContent = !owner ? "Only " + CHANNEL + " can add or delete rewards." + (ready ? "" : " Channel point rewards aren't allowed yet.") - : ready && rewards.length >= max - ? max + " of " + max + " rewards. Delete one to add another." - : ""; + : !ready && !broadcasterHere() + ? CHANNEL + " has to allow channel point rewards, signed in to Twitch as themself." + : ready && rewards.length >= max + ? max + " of " + max + " rewards. Delete one to add another." + : ""; const key = JSON.stringify([p, owner]); if (key === shownPoints) return; shownPoints = key; diff --git a/tests/ui.ts b/tests/ui.ts index b76ba67..c8e1d29 100644 --- a/tests/ui.ts +++ b/tests/ui.ts @@ -2189,6 +2189,12 @@ try { ready: true, }, { name: "moderator", session: mod, access: { owner: false, moderator: true, canManage: true }, ready: true }, + { + name: "site owner, not allowed yet", + session: { id: "9009", login: "nesszerra", displayName: "nesszerra" }, + access: { owner: true, moderator: false, canManage: true }, + ready: false, + }, ]; for (const c of cases) for (const s of sizes) { @@ -2304,6 +2310,17 @@ try { assert.equal(await page.locator("#points-form").isHidden(), true); assert.equal(await page.locator("#points-connect").isHidden(), true); assert.equal(await page.locator("#points-note").textContent(), "Only newstreamer can add or delete rewards."); + } else if (c.name === "site owner, not allowed yet") { + assert.equal( + await page.locator("#points-connect").isHidden(), + true, + "Twitch takes only the broadcaster's account", + ); + assert.equal(await page.locator("#points-form").isHidden(), true); + assert.equal( + await page.locator("#points-note").textContent(), + "newstreamer has to allow channel point rewards, signed in to Twitch as themself.", + ); } else if (!c.ready) { assert.equal(await page.locator("#points-form").isHidden(), true, "no form before the permission"); assert.equal(await page.locator("#points-login").getAttribute("href"), login);