flybrain/services/bridge/src/redemptions.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

245 lines
9.3 KiB
TypeScript

/**
* Sugar Channel Points redemption handling (`docs/design/stage-bridge.md` B3), behind
* `FEATURE_REDEMPTIONS`.
*
* 1. Idempotent reward creation: look up "Sugar" by title first, create only if missing, so
* re-running startup never creates a duplicate reward.
* 2. On `channel.channel_points_custom_reward_redemption.add` (wired in `src/eventsub.ts`),
* `POST /stimulate` on flysim with `source: 'points'`. Accepted -> `FULFILLED`. Rejected,
* failed, or timed out -> `CANCELED` (refunds the viewer's points).
* 3. Every redemption is persisted as a pending intent before the sim call and resolved after,
* via `IntentStore`. On startup, anything still `pending` (a crash mid-flight) is force-
* resolved to `CANCELED` so nothing stays pending forever.
*
* `RedemptionApi` is a small interface so tests exercise the state machine with a mock, never a
* real `@twurple/api` client.
*/
import { readFile } from 'node:fs/promises';
import type { ApiClient } from '@twurple/api';
import { atomicWriteJson } from './atomic-file';
import { validateDisplayName } from './names';
import type { Send } from './chat';
import type { SimClient } from './sim';
export interface RedemptionReward {
id: string;
title: string;
}
export type RedemptionStatus = 'FULFILLED' | 'CANCELED';
/** Small interface over the Twitch Helix calls redemption handling needs. Never import twurple in tests. */
export interface RedemptionApi {
findRewardByTitle(title: string): Promise<RedemptionReward | null>;
createReward(title: string): Promise<RedemptionReward>;
updateRedemptionStatus(rewardId: string, redemptionIds: string[], status: RedemptionStatus): Promise<void>;
}
export interface RedemptionIntent {
redemptionId: string;
rewardId: string;
displayName: string;
status: 'pending' | 'fulfilled' | 'canceled';
createdAtMs: number;
}
export interface IntentStore {
load(): Promise<RedemptionIntent[]>;
save(intents: RedemptionIntent[]): Promise<void>;
}
/** Reads/writes the intent log as JSON at a fixed path (`BridgeConfig.redemptionStateFile`). */
export class FileIntentStore implements IntentStore {
constructor(private readonly path: string) {}
async load(): Promise<RedemptionIntent[]> {
try {
const raw = await readFile(this.path, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as RedemptionIntent[]) : [];
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw cause;
}
}
async save(intents: RedemptionIntent[]): Promise<void> {
await atomicWriteJson(this.path, intents);
}
}
/** An in-memory store, for tests. */
export class MemoryIntentStore implements IntentStore {
private intents: RedemptionIntent[] = [];
async load(): Promise<RedemptionIntent[]> {
return this.intents.map((intent) => ({ ...intent }));
}
async save(intents: RedemptionIntent[]): Promise<void> {
this.intents = intents.map((intent) => ({ ...intent }));
}
}
/**
* Real Twitch Helix implementation of `RedemptionApi`. `is_user_input_required: false` (no free
* text to render on screen), a global cooldown and a per-stream max, per B3 item 1.
*/
export class TwurpleRedemptionApi implements RedemptionApi {
constructor(
private readonly apiClient: ApiClient,
private readonly broadcasterUserId: string,
private readonly options: { costPoints: number; globalCooldownSeconds: number; maxPerStream: number | null } = {
costPoints: 200,
globalCooldownSeconds: 60,
maxPerStream: null,
},
) {}
async findRewardByTitle(title: string): Promise<RedemptionReward | null> {
const rewards = await this.apiClient.channelPoints.getCustomRewards(this.broadcasterUserId, true);
const match = rewards.find((reward) => reward.title === title);
return match ? { id: match.id, title: match.title } : null;
}
async createReward(title: string): Promise<RedemptionReward> {
const reward = await this.apiClient.channelPoints.createCustomReward(this.broadcasterUserId, {
title,
cost: this.options.costPoints,
userInputRequired: false,
globalCooldown: this.options.globalCooldownSeconds,
maxRedemptionsPerStream: this.options.maxPerStream,
isEnabled: true,
});
return { id: reward.id, title: reward.title };
}
async updateRedemptionStatus(rewardId: string, redemptionIds: string[], status: RedemptionStatus): Promise<void> {
await this.apiClient.channelPoints.updateRedemptionStatusByIds(
this.broadcasterUserId,
rewardId,
redemptionIds,
status,
);
}
}
/**
* Whether an error is Twitch refusing the Channel Points API because the channel is not Affiliate
* or Partner yet.
*
* Channel Points are **not** available on a plain account, whatever the token's scopes say: a
* brand-new channel can be granted `channel:read:redemptions` and `channel:manage:redemptions`,
* pass the startup scope assertion, and still get
*
* GET channel_points/custom_rewards -> 403
* {"error":"Forbidden","status":403,"message":"The broadcaster must have partner or affiliate status."}
*
* on the first call. Measured on `<twitch-channel>` (`broadcaster_type: ""`) on 2026-09-16, where
* it took the whole bridge down in a 10 s restart loop and the channel had no chat bot at all.
*
* Duck-typed on purpose (`_statusCode`/`_body`, the fields twurple's `HttpStatusCodeError`
* carries) so this module stays twurple-free and testable without a Twitch client. The message
* match is required, not just the 403: a 403 with any other body is a real failure — a revoked
* token, a scope the assertion did not cover — and must not be swallowed.
*/
export function isChannelPointsAffiliateRefusal(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const candidate = error as { _statusCode?: unknown; _body?: unknown; message?: unknown };
if (candidate._statusCode !== 403) return false;
const text = `${String(candidate._body ?? '')} ${String(candidate.message ?? '')}`;
return /partner or affiliate status/i.test(text);
}
const MAX_STATUS_UPDATE_ATTEMPTS = 3;
export interface RedemptionManagerOptions {
sim: SimClient;
api: RedemptionApi;
store: IntentStore;
send: Send;
rewardTitle: string;
/** Injectable for tests; defaults to `console.error`. */
logError?: (message: string) => void;
}
export class RedemptionManager {
private rewardId: string | null = null;
private readonly intents = new Map<string, RedemptionIntent>();
constructor(private readonly options: RedemptionManagerOptions) {}
/** Idempotent reward creation, then replay any intents left pending from a previous run. */
async start(): Promise<RedemptionReward> {
const existing = await this.options.api.findRewardByTitle(this.options.rewardTitle);
const reward = existing ?? (await this.options.api.createReward(this.options.rewardTitle));
this.rewardId = reward.id;
const loaded = await this.options.store.load();
for (const intent of loaded) this.intents.set(intent.redemptionId, intent);
const pending = loaded.filter((intent) => intent.status === 'pending');
for (const intent of pending) {
// A crash between accepting the redemption and resolving it. Refund by default: we cannot
// safely re-fire a sim stimulation after an unknown-length restart gap.
await this.resolve(intent, 'CANCELED');
}
return reward;
}
get currentRewardId(): string | null {
return this.rewardId;
}
/** Handle one `channel.channel_points_custom_reward_redemption.add` event. */
async handleRedemptionAdd(redemptionId: string, rewardId: string, displayName: string): Promise<RedemptionStatus> {
const intent: RedemptionIntent = {
redemptionId,
rewardId,
displayName,
status: 'pending',
createdAtMs: Date.now(),
};
this.intents.set(redemptionId, intent);
await this.persist();
const by = validateDisplayName(displayName);
const result = await this.options.sim.stimulate({ by, source: 'points' });
if (result.ok) {
await this.resolve(intent, 'FULFILLED');
await this.options.send('sugarAccepted', { by });
return 'FULFILLED';
}
await this.resolve(intent, 'CANCELED');
await this.options.send('sugarDisabled', {});
return 'CANCELED';
}
private async resolve(intent: RedemptionIntent, status: RedemptionStatus): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_STATUS_UPDATE_ATTEMPTS; attempt++) {
try {
await this.options.api.updateRedemptionStatus(intent.rewardId, [intent.redemptionId], status);
intent.status = status === 'FULFILLED' ? 'fulfilled' : 'canceled';
this.intents.set(intent.redemptionId, intent);
await this.persist();
return;
} catch (cause) {
lastError = cause;
}
}
// Every attempt failed. Leave the intent `pending` in the store (already persisted) so the
// next startup's replay tries again rather than silently losing the refund.
const log = this.options.logError ?? ((message: string) => console.error(message));
log(
`redemption ${intent.redemptionId}: failed to set status ${status} after ${MAX_STATUS_UPDATE_ATTEMPTS} attempts: ${String(lastError)}`,
);
}
private async persist(): Promise<void> {
await this.options.store.save([...this.intents.values()]);
}
}