Add tenant-scoped gift menu settings, catalog-backed triggers, infinite OBS rendering, and guard assets. Normalize legacy and protobuf gift values for blind-box, battery-tier, and transaction-aware matching.
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
/**
|
|
* Same-origin API client and defensive wire-format normalizers.
|
|
*
|
|
* The backend is authoritative and may evolve response wrappers independently
|
|
* from a deployed frontend. Normalizers accept those compatible wrappers while
|
|
* producing strict UI models. Mutations are refused while offline and are never
|
|
* queued for background replay, which avoids applying an old user's action
|
|
* after logout or tenant switching.
|
|
*/
|
|
import type {
|
|
AuthUser,
|
|
ComponentSummary,
|
|
CookieCloudSource,
|
|
GiftEffectSettings,
|
|
GiftCatalogItem,
|
|
GiftMenuItem,
|
|
GiftMenuSettings,
|
|
Invitation,
|
|
OverlaySettings,
|
|
Session,
|
|
SongRequestItem,
|
|
SongRequestPage,
|
|
SongRequestSettings,
|
|
TotpEnrollment,
|
|
} from './types'
|
|
import { normalizeThemeId } from './themes'
|
|
import { normalizeGiftEffectThemeId } from './giftThemes'
|
|
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
|
|
import {
|
|
defaultGiftEffectSettings,
|
|
defaultGiftMenuSettings,
|
|
defaultOverlaySettings,
|
|
defaultSongRequestSettings,
|
|
} from './types'
|
|
import { currentLanguage, hasTranslation, translate } from './i18n'
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number
|
|
readonly code?: string
|
|
readonly messageKey?: string
|
|
readonly fieldErrors?: Record<string, string>
|
|
|
|
constructor(
|
|
status: number,
|
|
message: string,
|
|
code?: string,
|
|
messageKey?: string,
|
|
fieldErrors?: Record<string, string>,
|
|
) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
this.code = code
|
|
this.messageKey = messageKey
|
|
this.fieldErrors = fieldErrors
|
|
}
|
|
}
|
|
|
|
async function parseResponse(response: Response): Promise<unknown> {
|
|
if (response.status === 204) return undefined
|
|
const contentType = response.headers.get('content-type') ?? ''
|
|
if (contentType.includes('application/json')) return response.json()
|
|
const text = await response.text()
|
|
return text ? { message: text } : undefined
|
|
}
|
|
|
|
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const method = (init.method ?? 'GET').toUpperCase()
|
|
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
|
|
throw new ApiError(0, translate('api.offline'), 'offline', 'api.error.offline')
|
|
}
|
|
const headers = new Headers(init.headers)
|
|
if (init.body != null && !headers.has('content-type'))
|
|
headers.set('content-type', 'application/json')
|
|
headers.set('accept', 'application/json')
|
|
headers.set('accept-language', currentLanguage())
|
|
|
|
const response = await fetch(path, {
|
|
...init,
|
|
headers,
|
|
credentials: 'same-origin',
|
|
})
|
|
const payload = await parseResponse(response)
|
|
if (!response.ok) {
|
|
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
|
|
window.dispatchEvent(new Event('lxc:session-expired'))
|
|
}
|
|
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
|
const code = typeof error.code === 'string' ? error.code : undefined
|
|
const messageKey =
|
|
typeof error.messageKey === 'string'
|
|
? error.messageKey
|
|
: typeof error.message_key === 'string'
|
|
? error.message_key
|
|
: code
|
|
? `api.error.${code}`
|
|
: undefined
|
|
const localized = messageKey && hasTranslation(messageKey) ? translate(messageKey) : undefined
|
|
throw new ApiError(
|
|
response.status,
|
|
localized ??
|
|
String(
|
|
error.message ?? error.error ?? translate('api.http_error', { status: response.status }),
|
|
),
|
|
code,
|
|
messageKey,
|
|
error.fieldErrors && typeof error.fieldErrors === 'object'
|
|
? (error.fieldErrors as Record<string, string>)
|
|
: undefined,
|
|
)
|
|
}
|
|
return payload as T
|
|
}
|
|
|
|
export function json(method: string, body?: unknown): RequestInit {
|
|
return {
|
|
method,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
}
|
|
}
|
|
|
|
function object(value: unknown): Record<string, unknown> {
|
|
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {}
|
|
}
|
|
|
|
export function normalizeSession(value: unknown): Session {
|
|
const root = object(value)
|
|
const candidate =
|
|
root.user ??
|
|
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
|
|
? root
|
|
: null)
|
|
const raw = object(candidate)
|
|
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
|
|
const user: AuthUser | null = hasUser
|
|
? {
|
|
id: String(raw.id ?? ''),
|
|
username: String(raw.username ?? ''),
|
|
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
|
|
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
|
|
role: typeof raw.role === 'string' ? raw.role : 'user',
|
|
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
|
|
}
|
|
: null
|
|
return { user, setupRequired: root.setupRequired === true }
|
|
}
|
|
|
|
export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
|
const root = object(value)
|
|
const totp = object(root.totp ?? root.enrollment)
|
|
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
|
|
return {
|
|
enrollmentToken: String(enrollmentToken ?? ''),
|
|
qrSvg:
|
|
typeof totp.qrSvg === 'string'
|
|
? totp.qrSvg
|
|
: typeof root.qrSvg === 'string'
|
|
? root.qrSvg
|
|
: undefined,
|
|
qrDataUrl:
|
|
typeof totp.qrDataUrl === 'string'
|
|
? totp.qrDataUrl
|
|
: typeof totp.qrCodeDataUrl === 'string'
|
|
? totp.qrCodeDataUrl
|
|
: typeof root.qrDataUrl === 'string'
|
|
? root.qrDataUrl
|
|
: undefined,
|
|
otpauthUri:
|
|
typeof totp.otpauthUri === 'string'
|
|
? totp.otpauthUri
|
|
: typeof root.otpauthUri === 'string'
|
|
? root.otpauthUri
|
|
: undefined,
|
|
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
|
|
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
|
|
}
|
|
}
|
|
|
|
export function normalizeRecoveryCodes(value: unknown): string[] {
|
|
const root = object(value)
|
|
const codes = root.recoveryCodes ?? object(root.recovery).codes
|
|
return Array.isArray(codes)
|
|
? codes.filter((code): code is string => typeof code === 'string')
|
|
: []
|
|
}
|
|
|
|
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
|
const root = object(value)
|
|
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
|
|
return list
|
|
.map(entry => {
|
|
const item = object(entry)
|
|
return {
|
|
id: String(item.id ?? ''),
|
|
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
|
|
kind: String(item.kind ?? item.type ?? 'danmaku'),
|
|
name: String(item.name ?? translate('components.danmaku_type')),
|
|
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
|
settings: item.settings as OverlaySettings | undefined,
|
|
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
|
|
}
|
|
})
|
|
.filter(item => item.id)
|
|
}
|
|
|
|
export function normalizeSettings(value: unknown): OverlaySettings {
|
|
const root = object(value)
|
|
const settings = object(root.settings ?? value)
|
|
return {
|
|
...defaultOverlaySettings,
|
|
...(settings as Partial<OverlaySettings>),
|
|
themeId: normalizeThemeId(settings.themeId),
|
|
}
|
|
}
|
|
|
|
export function normalizeSongRequestSettings(value: unknown): SongRequestSettings {
|
|
const root = object(value)
|
|
const settings = object(root.settings ?? value)
|
|
return {
|
|
...defaultSongRequestSettings,
|
|
...(settings as Partial<SongRequestSettings>),
|
|
themeId: normalizeThemeId(settings.themeId),
|
|
}
|
|
}
|
|
|
|
export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings {
|
|
const root = object(value)
|
|
const settings = object(root.settings ?? value)
|
|
const normal = object(settings.normal)
|
|
const high = object(settings.high)
|
|
const featured = object(settings.featured)
|
|
return {
|
|
...defaultGiftEffectSettings,
|
|
...(settings as Partial<GiftEffectSettings>),
|
|
themeId: normalizeGiftEffectThemeId(settings.themeId),
|
|
normal: { ...defaultGiftEffectSettings.normal, ...normal },
|
|
high: { ...defaultGiftEffectSettings.high, ...high },
|
|
featured: { ...defaultGiftEffectSettings.featured, ...featured },
|
|
}
|
|
}
|
|
|
|
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
|
|
const item = object(value)
|
|
const trigger = object(item.trigger)
|
|
if (typeof item.id !== 'string' || typeof item.description !== 'string') return undefined
|
|
if (trigger.kind === 'gift') {
|
|
const giftId = Number(trigger.giftId)
|
|
if (!Number.isSafeInteger(giftId) || giftId <= 0 || typeof trigger.giftName !== 'string')
|
|
return undefined
|
|
return {
|
|
id: item.id,
|
|
description: item.description,
|
|
trigger: {
|
|
kind: 'gift',
|
|
giftId,
|
|
giftName: trigger.giftName,
|
|
unitPrice: Number(trigger.unitPrice ?? 0),
|
|
imageUrl: typeof trigger.imageUrl === 'string' ? trigger.imageUrl : undefined,
|
|
},
|
|
}
|
|
}
|
|
if (
|
|
trigger.kind === 'guard' &&
|
|
['captain', 'admiral', 'governor'].includes(String(trigger.level))
|
|
) {
|
|
return {
|
|
id: item.id,
|
|
description: item.description,
|
|
trigger: {
|
|
kind: 'guard',
|
|
level: trigger.level as 'captain' | 'admiral' | 'governor',
|
|
},
|
|
}
|
|
}
|
|
const amount = Number(trigger.amount)
|
|
if (trigger.kind === 'battery' && Number.isSafeInteger(amount) && amount > 0) {
|
|
return {
|
|
id: item.id,
|
|
description: item.description,
|
|
trigger: { kind: 'battery', amount },
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function normalizeGiftMenuSettings(value: unknown): GiftMenuSettings {
|
|
const root = object(value)
|
|
const settings = object(root.settings ?? value)
|
|
const items = Array.isArray(settings.items)
|
|
? settings.items.map(normalizeGiftMenuItem).filter(item => item !== undefined)
|
|
: []
|
|
return {
|
|
...defaultGiftMenuSettings,
|
|
...(settings as Partial<GiftMenuSettings>),
|
|
themeId: normalizeGiftMenuThemeId(settings.themeId),
|
|
items,
|
|
}
|
|
}
|
|
|
|
export function normalizeGiftCatalog(value: unknown): GiftCatalogItem[] {
|
|
const root = object(value)
|
|
if (!Array.isArray(root.gifts)) return []
|
|
return root.gifts.flatMap(candidate => {
|
|
const gift = object(candidate)
|
|
const id = Number(gift.id)
|
|
if (!Number.isSafeInteger(id) || id <= 0 || typeof gift.name !== 'string') return []
|
|
return [
|
|
{
|
|
id,
|
|
name: gift.name,
|
|
coinType: String(gift.coinType ?? 'gold'),
|
|
batteryValue: Number(gift.batteryValue ?? Number(gift.unitPrice ?? 0) / 100),
|
|
unitPrice: Number(gift.unitPrice ?? 0),
|
|
imageUrl:
|
|
typeof gift.imageUrl === 'string'
|
|
? gift.imageUrl
|
|
: typeof gift.animationUrl === 'string'
|
|
? gift.animationUrl
|
|
: undefined,
|
|
animationUrl: typeof gift.animationUrl === 'string' ? gift.animationUrl : undefined,
|
|
},
|
|
]
|
|
})
|
|
}
|
|
|
|
export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined {
|
|
const item = object(value)
|
|
const requester = object(item.requester)
|
|
if (typeof item.id !== 'string' || typeof item.title !== 'string') return undefined
|
|
const status = String(item.status)
|
|
if (!['current', 'queued', 'completed', 'cancelled'].includes(status)) return undefined
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
requester: {
|
|
uid: String(requester.uid ?? ''),
|
|
name: String(requester.name ?? translate('common.viewer')),
|
|
},
|
|
status: status as SongRequestItem['status'],
|
|
queuePosition: Number(item.queuePosition ?? 0),
|
|
requestedAt: String(item.requestedAt ?? ''),
|
|
startedAt:
|
|
typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined,
|
|
finishedAt:
|
|
typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined,
|
|
averageScore:
|
|
typeof item.averageScore === 'number' || item.averageScore === null
|
|
? item.averageScore
|
|
: undefined,
|
|
ratingCount: Number(item.ratingCount ?? 0),
|
|
}
|
|
}
|
|
|
|
export function normalizeSongRequestPage(value: unknown): SongRequestPage {
|
|
const root = object(value)
|
|
const current = normalizeSongRequestItem(root.current)
|
|
const summary = object(root.summary)
|
|
return {
|
|
revision: Number(root.revision ?? 0),
|
|
current: current ?? null,
|
|
items: (Array.isArray(root.items) ? root.items : [])
|
|
.map(normalizeSongRequestItem)
|
|
.filter((item): item is SongRequestItem => Boolean(item)),
|
|
nextCursor: typeof root.nextCursor === 'number' ? root.nextCursor : null,
|
|
summary: {
|
|
activeCount: Number(summary.activeCount ?? 0),
|
|
queuedCount: Number(summary.queuedCount ?? 0),
|
|
completedCount: Number(summary.completedCount ?? 0),
|
|
cancelledCount: Number(summary.cancelledCount ?? 0),
|
|
ratingCount: Number(summary.ratingCount ?? 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
export function normalizeSource(value: unknown): CookieCloudSource {
|
|
const root = object(value)
|
|
const source = object(root.source ?? root)
|
|
const cookieCloud = object(
|
|
source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud,
|
|
)
|
|
const status = object(source.status)
|
|
return {
|
|
roomId: String(source.roomId ?? root.roomId ?? ''),
|
|
cookieCloud: {
|
|
host: String(cookieCloud.host ?? ''),
|
|
key: '',
|
|
keyConfigured:
|
|
cookieCloud.keyConfigured === true ||
|
|
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
|
passwordConfigured:
|
|
cookieCloud.passwordConfigured === true ||
|
|
cookieCloud.configured === true ||
|
|
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
|
},
|
|
connected:
|
|
typeof source.connected === 'boolean'
|
|
? source.connected
|
|
: typeof status.connected === 'boolean'
|
|
? status.connected
|
|
: undefined,
|
|
detail:
|
|
typeof source.detail === 'string'
|
|
? source.detail
|
|
: typeof status.detail === 'string'
|
|
? status.detail
|
|
: undefined,
|
|
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
|
|
}
|
|
}
|
|
|
|
export function normalizeInvitations(value: unknown): Invitation[] {
|
|
const root = object(value)
|
|
const list = Array.isArray(value)
|
|
? value
|
|
: Array.isArray(root.invitations)
|
|
? root.invitations
|
|
: []
|
|
return list
|
|
.map(entry => {
|
|
const item = object(entry)
|
|
return {
|
|
id: String(item.id ?? ''),
|
|
code: typeof item.code === 'string' ? item.code : undefined,
|
|
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
|
|
roomId: String(item.roomId ?? ''),
|
|
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
|
|
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
|
|
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
|
|
consumedAt:
|
|
typeof item.consumedAt === 'string' || item.consumedAt === null
|
|
? item.consumedAt
|
|
: undefined,
|
|
revokedAt:
|
|
typeof item.revokedAt === 'string' || item.revokedAt === null
|
|
? item.revokedAt
|
|
: undefined,
|
|
}
|
|
})
|
|
.filter(item => item.id)
|
|
}
|
|
|
|
export function errorMessage(error: unknown, fallback = translate('common.failed')): string {
|
|
if (error instanceof ApiError && error.messageKey && hasTranslation(error.messageKey))
|
|
return translate(error.messageKey)
|
|
return error instanceof Error && error.message ? error.message : fallback
|
|
}
|
|
|
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
return true
|
|
} catch {
|
|
// Fall through to the compatibility path used by OBS' embedded browser.
|
|
}
|
|
}
|
|
const input = document.createElement('textarea')
|
|
input.value = text
|
|
input.readOnly = true
|
|
input.style.position = 'fixed'
|
|
input.style.left = '-9999px'
|
|
input.style.opacity = '0'
|
|
document.body.appendChild(input)
|
|
input.focus()
|
|
input.select()
|
|
let copied = false
|
|
try {
|
|
copied = document.execCommand('copy')
|
|
} finally {
|
|
input.remove()
|
|
}
|
|
return copied
|
|
}
|