openvibers/openvibe.chat Commits
Chat serves the features built on the six chat tables (plan T3 step 1): Live's read API GET /internal/moderation/channels/:id, /users/:id/channels and /channels/:id/emote-count (chat.moderation.read, loopback, service token; answers validate against Contracts 0.80.0); the emote API at /api/emotes with Live's paths, shapes, limits and messages, bytes to Media with Chat's own token (the declared type must match the bytes); channel moderators and moderation settings under /api/chat/channels/:id; the playAlertSound bridge op resolving the sound from Chat's own settings row. Writes refuse with 503 until the tables' authority flips. test/chat-apis.test.js (13 checks); 42/42.
Changes
@@@ -9,7 +9,7 @@ chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own site (global chsettings; the service manifest records it live since 2026-09-24).**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chatis also served on Live's origin: `https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`,--`/api/tts/`, `/api/sounds` via nginx.++`/api/tts/`, `/api/sounds`, `/api/emotes` via nginx.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -59,6 +59,27 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th- **Staged tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,`chat_ai_summaries`, `chat_timeline_events`): Live writes them until each is handed to Chat, onetable at a time (`table_authority`, register C-04). Runbook: `docs/staged-tables-cutover.md`.++- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can++ stop touching them at the flip:++ - `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),++ `/search`, `/all/:streamId`, `/file/:filename`, `POST /api/emotes`, `PATCH/DELETE /api/emotes/:id`++ — Live's exact paths, bodies, limits and error messages; uploaded bytes go to OpenVibe.Media with++ Chat's own token (`media.object.upload` / `.delete`, namespace `chat`), the row stores++ `media_url` + `media_asset_id`, responses use `media_url`. No local emote files (`/file/…` 404s).++ `ffz`/`bttv`/`7tv` are cached provider proxies on fixed hosts.++ - `GET /api/chat/channels/moderation/mine`, `GET/POST /api/chat/channels/:id/mods`,++ `DELETE /api/chat/channels/:id/mods/:userId`, `GET/PUT /api/chat/channels/:id/moderation`,++ `…/moderation/logs`, `…/moderation/chat-search`,++ `POST …/moderation/messages/:messageId/delete` — owner/admin write, channel mods read.++ - Live's read API for what Chat owns: `GET /internal/moderation/channels/:channelId`,++ `GET /internal/moderation/users/:userId/channels`,++ `GET /internal/moderation/channels/:channelId/emote-count` — loopback + service token,++ capability `chat.moderation.read` (`chat.channel-moderation-result@1`,++ `chat.moderated-channels-result@1`, `chat.emote-count-result@1`). Live caches each answer 30 s.++ - Alert sounds: the bridge op `playAlertSound [streamerId, streamId, kind]` makes Chat resolve the++ channel's own settings row, read the clip and broadcast it, so Live only names the alert.++ - Every write keeps the `table_authority` gate: while Live still writes the table the route++ answers `503 { ok: false, error: 'not yet' }` and never writes.## How it fits the network@@@ -68,6 +89,8 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) || Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) || Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |++| The six staged tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (after the T3 flip; Live until then) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |++| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` || Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` || Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) || A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person |@@@ -363,7 +386,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and| Routes | Per caller || --- | --- |--| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |++| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/emotes`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) || GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 || Chat search, staff and streamer logs, purge preview | 30 / 600 || Log export, `GET /api/chat/me/export` | 5 / 30 |@@@ -379,7 +402,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line andNever limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the bridge, the callhooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,--`/api/sounds/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.++`/api/sounds/file/…`, `/api/emotes/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.### Layout@@@ -387,7 +410,7 @@ hooks and the signed Events deliveries, which carry account deletions and mergesserver/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stopserver/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checksserver/live-context.js the only module that talks to Live (interface in its header)--server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice++server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-soundsserver/auth/ token resolution through Live; the chat subset of Live's permissionsserver/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)server/events/outbox.js events.event-envelope@1 outbox and relay@@@ -396,6 +419,7 @@ server/events/subscriptions.js creates them at boot when missing; list/disable/server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle++server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service tokenserver/db/ schema.sql, database.js (Live's chat functions, same names and arguments)scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-recorddocs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/@@@ -17,7 +17,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0","pg": "^8.23.0",@@@ -1153,9 +1153,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.79.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",++ "version": "0.80.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",++ "integrity": "sha512-7RCNFKxFRey3PquBwyQkSu8Bu2q2Ek0yb248SJWaymcg/g9PrlStFVUDTLMgh8i7BZSaZSngj0nojpwYTLGYDQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0","pg": "^8.23.0",@@@ -98,6 +98,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServeif (events) app.use('/internal/events', events.router);// Live's stream hooks for calls (CALLS_AUTHORITY=chat): stream voice channels (server/calls/internal.js).app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());++ // Live's read API for the chat tables Chat owns (plan T3; capability chat.moderation.read).++ app.use('/internal/moderation', express.json({ limit: '64kb' }), require('./chat/internal-moderation'));app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve@@@ -214,17 +216,22 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServeapp.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));++ app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…app.use('/api/chat/rooms', require('./rooms/routes'));++ // Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.++ app.use('/api/chat/channels', require('./chat/channel-mod-routes'));// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());app.use('/api/chat', require('./chat/routes'));app.use('/api/dm', require('./chat/dm-routes'));app.use('/api/tts', require('./chat/tts-routes'));app.use('/api/sounds', require('./chat/sounds-routes'));++ // Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.++ app.use('/api/emotes', require('./chat/emotes-routes'));// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).const callRoutes = require('./calls/routes');@@@ -9,7 +9,7 @@* { boot, ops: [{ seq, op, args, ref? }] } → { ok, results: [{ seq, ok, result?, error? }] }* op = a ChatServer method Live's modules call (broadcastToStream, forwardToGlobal, sendDm,* synthesizeAndBroadcastTTS, triggerChannelSound, disconnectUser, sendUserUpdate, …), a few-- * bridge-only ops (sendToConn, deployNotice, userChanged, invalidate, broadcastAllRaw), or `db` — one of++ * bridge-only ops (sendToConn, deployNotice, playAlertSound, userChanged, invalidate, broadcastAllRaw), or `db` — one of* the chat-table writes Live's un-moved code still makes (AI viewers, relays, donations,* /api/mod), executed on Chat's database, which is the authority.*@@@ -241,6 +241,13 @@ function createBridge({ chatServer, mirror = null, config = require('../config')}case 'deployNotice':return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });++ case 'playAlertSound': {++ // Live's donation / goal-reached alerts: Live says "play the alert for channel X"++ // (streamerId, streamId, kind), Chat resolves the sound from its own settings row and++ // broadcasts it to the channel room. Replaces Live reading the file + sending base64.++ const [streamerId, streamId, kind] = args;++ return require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');++ }case 'userChanged':ctx.invalidateUser(args[0]);await ctx.ensureUsers([args[0]]).catch(() => {});nnew file mode 100644@@@ -0,0 +1,72 @@++/**++ * OpenVibe.Chat — channel alert sounds (donation / donation-goal-reached).++ *++ * Live's server/monetization/alerts.js used to read the sound file off disk and broadcast it itself++ * as base64 `soundboard-audio`. At the T3 cutover the settings row and the file are Chat's, so Live++ * only says "play the donation|goal alert for channel X" over the bridge (op `playAlertSound`); this++ * module resolves the channel's own channel_moderation_settings row, reads the clip from the shared++ * sounds directory, and broadcasts it to the channel room (every slot + the offline room), gated on++ * the viewer's Chat Sounds toggle exactly as before.++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const db = require('../db/database');++const ctx = require('../live-context');++++const MAX_SOUND_BYTES = 3 * 1024 * 1024;++++function mimeForExt(ext) {++ switch ((ext || '').toLowerCase()) {++ case '.wav': return 'audio/wav';++ case '.ogg': case '.oga': return 'audio/ogg';++ case '.opus': return 'audio/opus';++ case '.webm': return 'audio/webm';++ case '.m4a': case '.mp4': return 'audio/mp4';++ case '.aac': return 'audio/aac';++ case '.flac': return 'audio/flac';++ default: return 'audio/mpeg';++ }++}++++function readSound(diskPath, mimeHint) {++ try {++ if (!diskPath || !fs.existsSync(diskPath)) return null;++ const buf = fs.readFileSync(diskPath);++ if (!buf || !buf.length || buf.length > MAX_SOUND_BYTES) return null;++ return { audio: buf.toString('base64'), mimeType: mimeHint || mimeForExt(path.extname(diskPath)) };++ } catch { return null; }++}++++/**++ * Broadcast a streamer's alert sound to their viewers. kind: 'donation' | 'goal'. The sound comes++ * from Chat's own settings row for the channel (goal falls back to the donation sound). No-op when++ * none is configured. → { played: boolean, source } (never throws: a missing sound is not an error).++ */++async function playAlertSound(chatServer, streamerId, streamId, kind) {++ const source = kind === 'goal' ? 'goal-alert' : 'donation-alert';++ try {++ if (!chatServer || !chatServer.broadcastToChannelRoom) return { played: false, source };++ const channel = await ctx.ensureChannelForUser(streamerId);++ const row = channel ? db.getChannelModerationSettingsRow(channel.id) : null;++ if (!row) return { played: false, source };++ let disk, mime;++ if (kind === 'goal') {++ disk = row.goal_sound_url || row.donation_sound_url;++ mime = row.goal_sound_url ? row.goal_sound_mime : row.donation_sound_mime;++ } else {++ disk = row.donation_sound_url;++ mime = row.donation_sound_mime;++ }++ const snd = readSound(disk, mime);++ if (!snd) return { played: false, source };++ chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });++ return { played: true, source };++ } catch (err) {++ console.warn(`[Alerts] play ${kind} for ${streamerId}: ${err.message}`);++ return { played: false, source };++ }++}++++module.exports = { playAlertSound, mimeForExt };nnew file mode 100644@@@ -0,0 +1,321 @@++/**++ * OpenVibe.Chat — Channel moderator & moderation settings API (moved from OpenVibe.Live++ * server/admin/channel-mod-routes.js, plan T3 step 1). Mounted under /api/chat/channels (Live's++ * /api/channels paths, on Chat's origin).++ *++ * GET /moderation/mine - Channels the user owns/moderates++ * GET /:channelId/mods - List channel mods++ * POST /:channelId/mods - Add channel mod (owner or admin)++ * DELETE /:channelId/mods/:userId - Remove channel mod (owner or admin)++ * GET /:channelId/moderation - Get moderation settings (mod+)++ * PUT /:channelId/moderation - Update settings (mod+; owner for policy)++ * GET /:channelId/moderation/logs - Moderation action log (mod+)++ * GET /:channelId/moderation/chat-search - Search channel chat (mod+)++ * POST /:channelId/moderation/messages/:messageId/delete - Delete a chat message (mod+)++ *++ * Channels, users and moderation policy come through live-context; the moderator and settings rows++ * are Chat's own tables. A write while Live is still the table's writer answers 503++ * { ok: false, error: 'not yet' } and never writes; after the flip a settings or moderator change++ * refreshes Chat's policy cache at once (the db helpers call invalidateChannel).++ */++'use strict';++++const express = require('express');++const db = require('../db/database');++const ctx = require('../live-context');++const { requireAuth } = require('../auth/auth');++const permissions = require('../auth/permissions');++++const router = express.Router();++++/** A staged-table write while Live is still the authority: 503, never a write. */++function notYet(res, err) {++ if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }++ return false;++}++++function parseBoolean(value, fallback) {++ if (value === undefined || value === null) return fallback;++ if (typeof value === 'boolean') return value;++ if (typeof value === 'number') return value !== 0;++ if (typeof value === 'string') return !['0', 'false', 'off', 'no'].includes(value.toLowerCase());++ return fallback;++}++++/** Can this user hand out / take away channel mods? Live's canAssignChannelMods: owner or staff. */++function canAssignMods(user, channel) {++ return permissions.can(user, 'staff.roles.assign') || (!!channel && channel.user_id === user.id);++}++++/** Middleware: require channel access (owner, channel mod, or staff). */++function requireChannelAccess(req, res, next) {++ const channelId = parseInt(req.params.channelId, 10);++ if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });++ const channel = ctx.getChannelById(channelId);++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ if (!permissions.canModerateChannel(req.user, channelId)) {++ return res.status(403).json({ error: 'Channel moderation access required' });++ }++ req.channel = channel;++ next();++}++++// ── My moderation access ─────────────────────────────────────++router.get('/moderation/mine', requireAuth, async (req, res) => {++ try {++ const owned = await ctx.ensureChannelForUser(req.user.id);++ const moderated = db.getChannelsByModerator(req.user.id) || [];++++ const channelMap = new Map();++ if (owned) channelMap.set(owned.id, owned);++ for (const ch of moderated) {++ const id = Number(ch.channel_id ?? ch.id);++ if (!channelMap.has(id)) channelMap.set(id, { id, user_id: ch.owner_user_id ?? ch.user_id, title: ch.title });++ }++++ const channels = [...channelMap.values()].map((channel) => ({++ ...channel,++ moderation_settings: ctx.getChannelModerationSettings(channel.id),++ moderators: db.getChannelModerators(channel.id),++ }));++++ res.json({ channels });++ } catch (err) {++ console.error('[ChannelMod] Failed to load moderation access:', err.message);++ res.status(500).json({ error: 'Failed to load channel moderation access' });++ }++});++++// ── List channel mods ────────────────────────────────────────++router.get('/:channelId/mods', requireAuth, (req, res) => {++ try {++ const channelId = parseInt(req.params.channelId);++ const channel = ctx.getChannelById(channelId);++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ res.json({ moderators: db.getChannelModerators(channelId), channel_id: channelId });++ } catch (err) {++ res.status(500).json({ error: 'Failed to list channel moderators' });++ }++});++++// ── Add channel mod ──────────────────────────────────────────++router.post('/:channelId/mods', requireAuth, async (req, res) => {++ try {++ const channelId = parseInt(req.params.channelId);++ const channel = ctx.getChannelById(channelId);++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ if (!canAssignMods(req.user, channel)) {++ return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });++ }++ const { username } = req.body;++ if (!username) return res.status(400).json({ error: 'Username is required' });++++ const targetUser = await ctx.ensureUserByUsername(username);++ if (!targetUser) return res.status(404).json({ error: 'User not found' });++ if (targetUser.id === channel.user_id) {++ return res.status(400).json({ error: 'Channel owner is already a moderator' });++ }++++ db.addChannelModerator(channelId, targetUser.id, req.user.id);++ db.logModerationAction({++ scope_type: 'channel',++ scope_id: channelId,++ actor_user_id: req.user.id,++ target_user_id: targetUser.id,++ action_type: 'channel_mod_add',++ details: { channel_id: channelId, username: targetUser.username },++ });++ res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });++ } catch (err) {++ if (notYet(res, err)) return;++ res.status(500).json({ error: 'Failed to add channel moderator' });++ }++});++++// ── Remove channel mod ───────────────────────────────────────++router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {++ try {++ const channelId = parseInt(req.params.channelId);++ const userId = parseInt(req.params.userId);++ const channel = ctx.getChannelById(channelId);++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ if (!canAssignMods(req.user, channel)) {++ return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });++ }++ const target = ctx.getUserById(userId);++ db.removeChannelModerator(channelId, userId);++ db.logModerationAction({++ scope_type: 'channel',++ scope_id: channelId,++ actor_user_id: req.user.id,++ target_user_id: userId,++ action_type: 'channel_mod_remove',++ details: { channel_id: channelId, username: (target && target.username) || String(userId) },++ });++ res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });++ } catch (err) {++ if (notYet(res, err)) return;++ res.status(500).json({ error: 'Failed to remove channel moderator' });++ }++});++++// ── Get channel moderation settings ──────────────────────────++router.get('/:channelId/moderation', requireAuth, requireChannelAccess, (req, res) => {++ try {++ res.json({ settings: ctx.getChannelModerationSettings(req.channel.id) });++ } catch (err) {++ res.status(500).json({ error: 'Failed to get moderation settings' });++ }++});++++// ── Update channel moderation settings (Live's parsing, clamps and all) ──++const OWNER_POLICY_KEYS = ['mods_can_edit_about', 'ip_approval_mode', 'uploads_mods_only', 'allow_anonymous', 'sounds_mods_only'];++router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (req, res) => {++ try {++ const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);++ if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];++ const settings = db.upsertChannelModerationSettings(req.channel.id, {++ slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined++ ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,++ followers_only: req.body.followers_only !== undefined ? parseBoolean(req.body.followers_only, false) : undefined,++ sub_only: req.body.sub_only !== undefined ? parseBoolean(req.body.sub_only, false) : undefined,++ emote_only: req.body.emote_only,++ allow_anonymous: req.body.allow_anonymous !== undefined ? parseBoolean(req.body.allow_anonymous, true) : undefined,++ links_allowed: req.body.links_allowed !== undefined ? parseBoolean(req.body.links_allowed, true) : undefined,++ gifs_enabled: req.body.gifs_enabled !== undefined ? parseBoolean(req.body.gifs_enabled, true) : undefined,++ aggressive_filter: req.body.aggressive_filter !== undefined ? parseBoolean(req.body.aggressive_filter, false) : undefined,++ account_age_gate_hours: req.body.account_age_gate_hours !== undefined ? Math.max(0, parseInt(req.body.account_age_gate_hours) || 0) : undefined,++ caps_percentage_limit: req.body.caps_percentage_limit !== undefined ? Math.min(100, Math.max(0, parseInt(req.body.caps_percentage_limit) || 70)) : undefined,++ max_message_length: req.body.max_message_length !== undefined++ ? Math.min(permissions.isAdmin(req.user) ? 6000 : 4000, Math.max(1, parseInt(req.body.max_message_length) || 500)) : undefined,++ tts_max_length: req.body.tts_max_length !== undefined ? Math.min(1200, Math.max(10, parseInt(req.body.tts_max_length) || 200)) : undefined,++ slur_filter_enabled: req.body.slur_filter_enabled !== undefined ? parseBoolean(req.body.slur_filter_enabled, false) : undefined,++ slur_filter_use_builtin: req.body.slur_filter_use_builtin !== undefined ? parseBoolean(req.body.slur_filter_use_builtin, true) : undefined,++ slur_filter_terms: req.body.slur_filter_terms !== undefined ? String(req.body.slur_filter_terms || '').slice(0, 4000) : undefined,++ slur_filter_regexes: req.body.slur_filter_regexes !== undefined ? String(req.body.slur_filter_regexes || '').slice(0, 8000) : undefined,++ slur_filter_nudge_message: req.body.slur_filter_nudge_message !== undefined ? String(req.body.slur_filter_nudge_message || '').slice(0, 800) : undefined,++ slur_filter_disabled_categories: req.body.slur_filter_disabled_categories !== undefined++ ? (() => {++ const VALID_CATS = new Set(['n_word', 'antisemitic', 'homophobic', 'racial']);++ let val = req.body.slur_filter_disabled_categories;++ if (typeof val === 'string') { try { val = JSON.parse(val); } catch { val = []; } }++ if (!Array.isArray(val)) return '[]';++ return JSON.stringify(val.filter((k) => typeof k === 'string' && VALID_CATS.has(k)));++ })() : undefined,++ ip_approval_mode: req.body.ip_approval_mode !== undefined ? parseBoolean(req.body.ip_approval_mode, false) : undefined,++ soundboard_enabled: req.body.soundboard_enabled !== undefined ? parseBoolean(req.body.soundboard_enabled, true) : undefined,++ soundboard_allow_pitch: req.body.soundboard_allow_pitch !== undefined ? parseBoolean(req.body.soundboard_allow_pitch, true) : undefined,++ soundboard_allow_speed: req.body.soundboard_allow_speed !== undefined ? parseBoolean(req.body.soundboard_allow_speed, true) : undefined,++ soundboard_banned_ids: req.body.soundboard_banned_ids !== undefined++ ? (() => {++ const raw = String(req.body.soundboard_banned_ids || '');++ const ids = raw.split(/[\s,\n\r]+/).map((s) => {++ const m = s.trim().match(/101soundboards\.com\/sounds\/(\d+)/i);++ if (m) return m[1];++ if (/^\d{2,}$/.test(s.trim())) return s.trim();++ return null;++ }).filter(Boolean);++ return [...new Set(ids)].slice(0, 200).join(',');++ })() : undefined,++ viewer_auto_delete_enabled: req.body.viewer_auto_delete_enabled !== undefined ? parseBoolean(req.body.viewer_auto_delete_enabled, true) : undefined,++ viewer_delete_all_enabled: req.body.viewer_delete_all_enabled !== undefined ? parseBoolean(req.body.viewer_delete_all_enabled, true) : undefined,++ custom_emotes_enabled: req.body.custom_emotes_enabled !== undefined ? parseBoolean(req.body.custom_emotes_enabled, true) : undefined,++ custom_sounds_enabled: req.body.custom_sounds_enabled !== undefined ? parseBoolean(req.body.custom_sounds_enabled, true) : undefined,++ uploads_mods_only: req.body.uploads_mods_only !== undefined ? parseBoolean(req.body.uploads_mods_only, false) : undefined,++ mods_can_edit_about: req.body.mods_can_edit_about !== undefined ? parseBoolean(req.body.mods_can_edit_about, false) : undefined,++ max_sound_seconds: req.body.max_sound_seconds !== undefined ? Math.min(30, Math.max(1, parseInt(req.body.max_sound_seconds) || 10)) : undefined,++ emote_scale: req.body.emote_scale !== undefined ? Math.min(300, Math.max(50, parseInt(req.body.emote_scale) || 100)) : undefined,++ emote_size_min: req.body.emote_size_min !== undefined ? Math.min(200, Math.max(25, parseInt(req.body.emote_size_min) || 50)) : undefined,++ emote_size_max: req.body.emote_size_max !== undefined ? Math.min(400, Math.max(50, parseInt(req.body.emote_size_max) || 200)) : undefined,++ sounds_mods_only: req.body.sounds_mods_only !== undefined ? (req.body.sounds_mods_only ? 1 : 0) : undefined,++ sound_min_speed: req.body.sound_min_speed !== undefined ? Math.min(1, Math.max(0.1, parseFloat(req.body.sound_min_speed) || 0.5)) : undefined,++ sound_max_speed: req.body.sound_max_speed !== undefined ? Math.min(5, Math.max(1, parseFloat(req.body.sound_max_speed) || 3.0)) : undefined,++ sound_min_pitch_cents: req.body.sound_min_pitch_cents !== undefined ? Math.min(0, Math.max(-2400, parseInt(req.body.sound_min_pitch_cents) || -1200)) : undefined,++ sound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,++ });++++ db.logModerationAction({++ scope_type: 'channel',++ scope_id: req.channel.id,++ actor_user_id: req.user.id,++ action_type: 'channel_settings_update',++ details: settings.value,++ });++ res.json({ settings: settings.value });++ } catch (err) {++ if (notYet(res, err)) return;++ console.error('[ChannelMod] Failed to update moderation settings:', err.message);++ res.status(500).json({ error: 'Failed to update moderation settings' });++ }++});++++// ── View moderation action log ───────────────────────────────++router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, (req, res) => {++ try {++ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);++ res.json({ actions: db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });++ } catch (err) {++ res.status(500).json({ error: 'Failed to load channel moderation log' });++ }++});++++// ── Search channel chat messages ─────────────────────────────++router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, (req, res) => {++ try {++ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);++ const q = (req.query.q || '').trim();++ const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;++ res.json(db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));++ } catch (err) {++ res.status(500).json({ error: 'Failed to search channel chat' });++ }++});++++// ── Delete a channel chat message ────────────────────────────++router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, (req, res) => {++ try {++ const messageId = parseInt(req.params.messageId, 10);++ const message = db.getChatMessageById(messageId);++ if (!message) return res.status(404).json({ error: 'Message not found' });++++ if (!permissions.isGlobalModOrAbove(req.user)) {++ let inScope = false;++ if (message.stream_id) {++ const stream = ctx.getStreamById(message.stream_id);++ inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));++ } else if (message.channel_user_id) {++ inScope = message.channel_user_id === req.channel.user_id;++ }++ if (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });++ }++++ db.deleteChatMessage(messageId, req.user.id);++++ const delPayload = { type: 'delete-messages', ids: [messageId] };++ const chanUid = message.channel_user_id || (message.stream_id ? (ctx.getStreamById(message.stream_id) || {}).user_id || null : null);++ try {++ const chatServer = require('./chat-server');++ if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);++ if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);++ else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);++ else chatServer.broadcastGlobal(delPayload);++ } catch { /* chat server may not be initialized */ }++++ db.logModerationAction({++ scope_type: 'channel',++ scope_id: req.channel.id,++ actor_user_id: req.user.id,++ target_user_id: message.user_id,++ action_type: 'channel_message_delete',++ details: { message_id: messageId, stream_id: message.stream_id },++ });++ res.json({ message: 'Message deleted', ids: [messageId] });++ } catch (err) {++ if (notYet(res, err)) return;++ res.status(500).json({ error: 'Failed to delete message' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,673 @@++/**++ * OpenVibe.Chat — Emote API (moved from OpenVibe.Live server/emotes/routes.js, plan T3 step 1)++ *++ * Same paths, bodies, responses, limits and error messages as Live's /api/emotes, so Live's++ * frontend (public/js/emotes.js, chat.js, channel-uploads.js, obs/chat.html) works unchanged once++ * nginx sends /api/emotes here. The rows live in Chat's own emotes table.++ *++ * GET /api/emotes/global - Global custom emotes++ * GET /api/emotes/channel/:userId - Channel emotes for a streamer++ * GET /api/emotes/mine - My emotes (auth required)++ * POST /api/emotes - Upload a custom emote (auth required)++ * PATCH /api/emotes/:id - Rename / resize an emote (auth required)++ * DELETE /api/emotes/:id - Delete an emote (auth required)++ * GET /api/emotes/file/:filename - Legacy image file (no local files: 404)++ * GET /api/emotes/{ffz,bttv,7tv} - Cached provider proxies++ * GET /api/emotes/defaults - Built-in emote collection++ * GET/PUT /api/emotes/sources - Per-channel emote source preferences (auth)++ * GET /api/emotes/search - Search FFZ emotes++ * GET /api/emotes/all/:streamId - All emotes available in a stream context++ *++ * Uploaded bytes go to OpenVibe.Media with Chat's own service token; the row stores media_url and++ * media_asset_id, and responses use media_url (existing Live rows already carry one). Writes answer++ * 503 { ok: false, error: 'not yet' } while Live is still the table's writer (table_authority live).++ */++'use strict';++++const express = require('express');++const path = require('path');++const multer = require('multer');++const db = require('../db/database');++const ctx = require('../live-context');++const config = require('../config');++const media = require('../media/client');++const { requireAuth, optionalAuth } = require('../auth/auth');++const permissions = require('../auth/permissions');++++const router = express.Router();++++const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/avif': '.avif' };++const ALLOWED_MIME = ['image/png', 'image/gif', 'image/webp', 'image/jpeg', 'image/avif'];++++/** The image type a buffer's first bytes say it is (PNG, GIF, WebP, JPEG, AVIF); null for anything else. */++function sniffImage(buf) {++ if (!buf || buf.length < 12) return null;++ if (buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') return 'image/png';++ if (buf.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';++ if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';++ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';++ if (buf.toString('latin1', 4, 8) === 'ftyp' && /^avi[fs]$/.test(buf.toString('latin1', 8, 12))) return 'image/avif';++ return null;++}++++// Bytes are held in memory only long enough to reach Media; nothing is written to Chat's disk.++const emoteUpload = multer({++ storage: multer.memoryStorage(),++ limits: { fileSize: config.emotes.maxSizeKb * 1024 },++ fileFilter: (req, file, cb) => {++ if (ALLOWED_MIME.includes(file.mimetype)) return cb(null, true);++ cb(new Error('Only PNG, GIF, WebP, AVIF, and JPEG images are allowed'));++ },++});++++// Per-user emote-upload rate limit (sliding window), Live's values: 40 uploads / 15 min per account.++const EMOTE_UPLOAD_WINDOW_MS = 15 * 60 * 1000;++const EMOTE_UPLOAD_MAX = 40;++const _emoteUploadTimes = new Map();++function emoteUploadRateLimited(userId) {++ const now = Date.now();++ const arr = (_emoteUploadTimes.get(userId) || []).filter((t) => now - t < EMOTE_UPLOAD_WINDOW_MS);++ if (arr.length >= EMOTE_UPLOAD_MAX) { _emoteUploadTimes.set(userId, arr); return true; }++ arr.push(now);++ _emoteUploadTimes.set(userId, arr);++ return false;++}++++/** A staged-table write while Live is still the authority: 503, never a write. */++function notYet(res, err) {++ if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }++ return false;++}++const fileUrl = (e) => e.media_url || `/api/emotes/file/${path.basename(e.url)}`;++++// ══════════════════════════════════════════════════════════════++// FFZ / BTTV / 7TV CACHE++// ══════════════════════════════════════════════════════════════++++let ffzCache = { data: null, ts: 0 };++let bttvCache = { data: null, ts: 0 };++let sevenTvCache = { data: null, ts: 0 };++const ffzSearchCache = new Map();++++async function fetchJSON(url) {++ const res = await fetch(url, {++ headers: { 'User-Agent': 'OpenVibe.Chat/1.0 (emote-proxy)' },++ signal: AbortSignal.timeout(8000),++ });++ if (!res.ok) throw new Error(`HTTP ${res.status}`);++ return res.json();++}++++function parseFfzSets(data) {++ const emotes = [];++ const sets = data.sets || {};++ for (const setId of Object.keys(sets)) {++ const set = sets[setId];++ for (const e of (set.emoticons || [])) {++ if (e.hidden) continue;++ if (e.modifier) continue;++ emotes.push({++ id: `ffz-${e.id}`,++ code: e.name,++ url: e.urls['2'] || e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,++ url_1x: e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,++ url_2x: e.urls['2'] || e.urls['1'],++ url_4x: e.urls['4'] || e.urls['2'] || e.urls['1'],++ width: e.width || 28,++ height: e.height || 28,++ animated: false,++ source: 'ffz',++ });++ }++ }++ return emotes;++}++++function parseBttvEmotes(data) {++ return (Array.isArray(data) ? data : []).map((e) => ({++ id: `bttv-${e.id}`,++ code: e.code,++ url: `https://cdn.betterttv.net/emote/${e.id}/2x`,++ url_1x: `https://cdn.betterttv.net/emote/${e.id}/1x`,++ url_2x: `https://cdn.betterttv.net/emote/${e.id}/2x`,++ url_4x: `https://cdn.betterttv.net/emote/${e.id}/3x`,++ width: 28,++ height: 28,++ animated: e.imageType === 'gif' || e.animated === true,++ source: 'bttv',++ }));++}++++function parse7tvEmotes(data) {++ const emotes = data?.emotes || [];++ return emotes.map((e) => {++ const host = e.data?.host;++ const baseUrl = host?.url || '';++ const files = host?.files || [];++ const f2x = files.find((f) => f.name === '2x.webp') || files.find((f) => f.name === '2x.avif') || files.find((f) => f.name === '1x.webp') || files[0];++ const f1x = files.find((f) => f.name === '1x.webp') || files.find((f) => f.name === '1x.avif') || files[0];++ const f4x = files.find((f) => f.name === '4x.webp') || files.find((f) => f.name === '3x.webp') || f2x;++ return {++ id: `7tv-${e.id}`,++ code: e.name,++ url: f2x ? `https:${baseUrl}/${f2x.name}` : '',++ url_1x: f1x ? `https:${baseUrl}/${f1x.name}` : '',++ url_2x: f2x ? `https:${baseUrl}/${f2x.name}` : '',++ url_4x: f4x ? `https:${baseUrl}/${f4x.name}` : '',++ width: f2x?.width || 28,++ height: f2x?.height || 28,++ animated: !!(e.data?.animated),++ source: '7tv',++ };++ }).filter((e) => e.url);++}++++// Live's built-in emote collection (popular community emotes, provider CDN URLs).++const DEFAULT_EMOTES = [++ { id: 'def-lul', code: 'LUL', url: 'https://cdn.frankerfacez.com/emote/38010/2', source: 'defaults', animated: false },++ { id: 'def-lulw', code: 'LULW', url: 'https://cdn.frankerfacez.com/emote/139407/2', source: 'defaults', animated: false },++ { id: 'def-kekw', code: 'KEKW', url: 'https://cdn.frankerfacez.com/emote/381875/2', source: 'defaults', animated: false },++ { id: 'def-omegalul', code: 'OMEGALUL', url: 'https://cdn.frankerfacez.com/emote/128054/2', source: 'defaults', animated: false },++ { id: 'def-pepehands', code: 'PepeHands', url: 'https://cdn.frankerfacez.com/emote/231552/2', source: 'defaults', animated: false },++ { id: 'def-copium', code: 'Copium', url: 'https://cdn.frankerfacez.com/emote/540942/2', source: 'defaults', animated: false },++ { id: 'def-monkas', code: 'monkaS', url: 'https://cdn.frankerfacez.com/emote/130762/2', source: 'defaults', animated: false },++ { id: 'def-ez', code: 'EZ', url: 'https://cdn.frankerfacez.com/emote/425688/2', source: 'defaults', animated: false },++ { id: 'def-pog', code: 'Pog', url: 'https://cdn.frankerfacez.com/emote/210748/2', source: 'defaults', animated: false },++ { id: 'def-poggers', code: 'Poggers', url: 'https://cdn.frankerfacez.com/emote/214681/2', source: 'defaults', animated: false },++ { id: 'def-pogu', code: 'PogU', url: 'https://cdn.betterttv.net/emote/5e4e7a1f08b4447d56a92967/2x', source: 'defaults', animated: false },++ { id: 'def-peped', code: 'PepeD', url: 'https://cdn.betterttv.net/emote/5b1740221c5a6065a7bad4b5/2x', source: 'defaults', animated: true },++ { id: 'def-catjam', code: 'catJAM', url: 'https://cdn.betterttv.net/emote/5f1b0186cf6d2144653d2970/2x', source: 'defaults', animated: true },++ { id: 'def-sadge', code: 'Sadge', url: 'https://cdn.betterttv.net/emote/5e0fa9d40550d42106b8a489/2x', source: 'defaults', animated: false },++ { id: 'def-pepega', code: 'Pepega', url: 'https://cdn.betterttv.net/emote/5aca62163e290877a25481ad/2x', source: 'defaults', animated: false },++ { id: 'def-5head', code: '5Head', url: 'https://cdn.betterttv.net/emote/5d6096974932b21d9c332904/2x', source: 'defaults', animated: false },++ { id: 'def-widehard', code: 'widepeepohappy', url: 'https://cdn.betterttv.net/emote/5e1a76dd8af14b5f1b438c04/2x', source: 'defaults', animated: false },++ { id: 'def-monkaw', code: 'monkaW', url: 'https://cdn.betterttv.net/emote/59ca6551b27c823d5b1fd872/2x', source: 'defaults', animated: false },++ { id: 'def-pepelaugh', code: 'PepeLaugh', url: 'https://cdn.betterttv.net/emote/5c548025009a2e73916b3a37/2x', source: 'defaults', animated: false },++ { id: 'def-modtime', code: 'modCheck', url: 'https://cdn.betterttv.net/emote/5d7eefb7c0652668c9e4d394/2x', source: 'defaults', animated: true },++ { id: 'def-clap', code: 'CLAP', url: 'https://cdn.betterttv.net/emote/55b6f480e66682f576dd94f5/2x', source: 'defaults', animated: false },++ { id: 'def-gg', code: 'GGEZ', url: 'https://cdn.frankerfacez.com/emote/703645/2', source: 'defaults', animated: false },++ { id: 'def-based', code: 'BASED', url: 'https://cdn.frankerfacez.com/emote/768564/2', source: 'defaults', animated: false },++ { id: 'def-peepo', code: 'peepoHappy', url: 'https://cdn.betterttv.net/emote/5a16ee718c22a247ead62d4a/2x', source: 'defaults', animated: false },++ { id: 'def-mods', code: 'MODS', url: 'https://cdn.betterttv.net/emote/603451b77c74605395f3295d/2x', source: 'defaults', animated: true },++].map((e) => ({ ...e, width: 28, height: 28 }));++++// ══════════════════════════════════════════════════════════════++// ROUTES++// ══════════════════════════════════════════════════════════════++++router.get('/global', (req, res) => {++ try {++ const emotes = db.getGlobalEmotes().map((e) => ({++ id: `custom-${e.id}`,++ code: e.code,++ url: fileUrl(e),++ animated: !!e.animated,++ width: e.width,++ height: e.height,++ source: 'custom',++ owner: e.username,++ }));++ res.json({ emotes });++ } catch (err) {++ res.status(500).json({ error: 'Failed to load global emotes' });++ }++});++++router.get('/channel/:userId', (req, res) => {++ try {++ const userId = parseInt(req.params.userId);++ const emotes = db.getChannelEmotes(userId).map((e) => ({++ id: `custom-${e.id}`,++ emote_id: e.id,++ code: e.code,++ url: fileUrl(e),++ animated: !!e.animated,++ width: e.width,++ height: e.height,++ source: 'channel',++ owner: e.username,++ uploader: e.uploader_display_name || e.uploader_username || e.username,++ uploader_id: e.user_id,++ }));++ res.json({ emotes });++ } catch (err) {++ res.status(500).json({ error: 'Failed to load channel emotes' });++ }++});++++router.get('/mine', requireAuth, (req, res) => {++ try {++ const emotes = db.getEmotesByUser(req.user.id).map((e) => ({++ id: e.id,++ code: e.code,++ url: fileUrl(e),++ animated: !!e.animated,++ width: e.width,++ height: e.height,++ is_global: !!e.is_global,++ created_at: e.created_at,++ }));++ res.json({ emotes, count: db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });++ } catch (err) {++ res.status(500).json({ error: 'Failed to load emotes' });++ }++});++++router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {++ try {++ if (!req.file) return res.status(400).json({ error: 'No image file uploaded' });++++ if (emoteUploadRateLimited(req.user.id)) {++ return res.status(429).json({ error: 'Too many emote uploads — please wait a bit before uploading more.' });++ }++++ const code = (req.body.code || '').trim();++ if (!code || code.length < 2 || code.length > 32) {++ return res.status(400).json({ error: 'Emote code must be 2-32 characters' });++ }++ if (!/^[a-zA-Z0-9_]+$/.test(code)) {++ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });++ }++++ // Optional target channel: channel_id = the streamer's user id; stream_id resolves to its owner.++ let channelOwnerId = parseInt(req.body.channel_id) || null;++ if (!channelOwnerId && req.body.stream_id) {++ channelOwnerId = (await ctx.ensureStream(parseInt(req.body.stream_id)))?.user_id || null;++ }++ const isChannelUpload = channelOwnerId && channelOwnerId !== req.user.id;++++ if (isChannelUpload) {++ const channel = await ctx.ensureChannelForUser(channelOwnerId);++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ await ctx.ensurePolicy(channel.id);++ const settings = ctx.getChannelModerationSettings(channel.id);++ const isMod = permissions.canModerateChannel(req.user, channel.id);++ if (!settings.custom_emotes_enabled && !isMod) {++ return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });++ }++ if (settings.uploads_mods_only && !isMod) {++ return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });++ }++ if (db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {++ return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });++ }++ if (db.getChannelEmoteByCode(channelOwnerId, code)) {++ return res.status(409).json({ error: `This channel already has an emote named "${code}".` });++ }++ } else {++ if (db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {++ return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });++ }++ const existing = db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);++ if (existing) return res.status(409).json({ error: `You already have an emote named "${code}"` });++ }++++ // The declared type must match the bytes: Media serves the object with this type.++ if (sniffImage(req.file.buffer) !== req.file.mimetype) return res.status(400).json({ error: 'That file is not the image type it claims to be' });++ const animated = req.file.mimetype === 'image/gif' || req.file.mimetype === 'image/webp';++ const isGlobal = req.body.is_global === 'true' && permissions.can(req.user, 'staff.assets.manage');++++ let sizeMin = 25, sizeMax = 400;++ if (channelOwnerId) {++ const channel = await ctx.ensureChannelForUser(channelOwnerId);++ const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;++ if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }++ }++ const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));++++ const uploaded = await media.uploadObject(req.file.buffer, { filename: req.file.originalname, mimeType: req.file.mimetype });++ const result = db.createEmote({++ user_id: req.user.id,++ code,++ url: uploaded.url,++ animated,++ width: parseInt(req.body.width) || 28,++ height: parseInt(req.body.height) || 28,++ is_global: isGlobal,++ channel_owner_id: isChannelUpload ? channelOwnerId : null,++ size,++ media_url: uploaded.url,++ media_asset_id: uploaded.id,++ });++++ if (isChannelUpload) {++ try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }++ }++ res.json({++ emote: {++ id: result.value.lastInsertRowid,++ code,++ url: uploaded.url,++ animated,++ channel_id: isChannelUpload ? channelOwnerId : undefined,++ },++ });++ } catch (err) {++ if (notYet(res, err)) return;++ console.error('[Emotes] Upload error:', err);++ if (err && /UNIQUE constraint/i.test(err.message || '')) {++ const code = String((req.body && req.body.code) || '').trim();++ return res.status(409).json({ error: `This channel already has an emote named "${code}" — pick a different code for this channel.` });++ }++ res.status(500).json({ error: 'Failed to upload emote' });++ }++});++++// Multer error handler (Live's status codes and messages).++router.use((err, req, res, next) => {++ if (err.code === 'LIMIT_FILE_SIZE') {++ return res.status(400).json({ error: `File too large (max ${config.emotes.maxSizeKb}KB)` });++ }++ if (err.message && err.message.includes('Only')) {++ return res.status(400).json({ error: err.message });++ }++ console.error('[Emotes] Middleware error:', err);++ res.status(500).json({ error: 'Emote upload failed' });++});++++router.patch('/:id', requireAuth, async (req, res) => {++ try {++ const emote = db.getEmoteById(req.params.id);++ if (!emote) return res.status(404).json({ error: 'Emote not found' });++ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');++ if (!allowed) {++ const ownerId = emote.channel_owner_id || emote.user_id;++ const channel = await ctx.ensureChannelForUser(ownerId);++ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ }++ if (!allowed) return res.status(403).json({ error: 'Not your emote' });++++ const patch = {};++ if (req.body.code !== undefined) {++ const code = String(req.body.code || '').trim();++ if (!code || code.length < 2 || code.length > 32) {++ return res.status(400).json({ error: 'Emote code must be 2-32 characters' });++ }++ if (!/^[a-zA-Z0-9_]+$/.test(code)) {++ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });++ }++ const scopeOwner = emote.channel_owner_id || emote.user_id;++ const clash = (db.getChannelEmotes(scopeOwner) || [])++ .find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());++ if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });++ patch.code = code;++ }++ if (req.body.size !== undefined) {++ let sizeMin = 25, sizeMax = 400;++ const ownerId = emote.channel_owner_id || emote.user_id;++ const channel = await ctx.ensureChannelForUser(ownerId);++ const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;++ if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }++ patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));++ }++ if (patch.code === undefined && patch.size === undefined) {++ return res.status(400).json({ error: 'Nothing to change' });++ }++++ db.updateEmote(emote.id, patch);++ if (patch.code && patch.code !== emote.code) {++ try { db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }++ }++ if (emote.channel_owner_id) {++ try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }++ }++ res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });++ } catch (err) {++ if (notYet(res, err)) return;++ res.status(500).json({ error: 'Failed to update emote' });++ }++});++++router.delete('/:id', requireAuth, async (req, res) => {++ try {++ const emote = db.getEmoteById(req.params.id);++ if (!emote) return res.status(404).json({ error: 'Emote not found' });++ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');++ if (!allowed) {++ const ownerId = emote.channel_owner_id || emote.user_id;++ const channel = await ctx.ensureChannelForUser(ownerId);++ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ }++ if (!allowed) return res.status(403).json({ error: 'Not your emote' });++++ db.deleteEmote(emote.id);++ await media.deleteObject(emote.media_asset_id);++ if (emote.channel_owner_id) {++ try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }++ }++ res.json({ message: 'Emote deleted' });++ } catch (err) {++ if (notYet(res, err)) return;++ res.status(500).json({ error: 'Failed to delete emote' });++ }++});++++// Legacy image files: Chat keeps none. Bytes live on OpenVibe.Media now; a row that still lacks a++// media_url has no file here either, so this answers 404 for everything (Live's frontend only uses++// this path for rows without media_url, which are served from the provider/Media URLs instead).++router.get('/file/:filename', (req, res) => {++ res.status(404).json({ error: 'Emote file not found' });++});++++// ── FFZ / BTTV / 7TV (cached proxies) ────────────────────────++router.get('/ffz', async (req, res) => {++ try {++ const now = Date.now();++ if (ffzCache.data && (now - ffzCache.ts) < config.emotes.ffzCacheTtl * 1000) {++ return res.json({ emotes: ffzCache.data, cached: true });++ }++ const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');++ const emotes = parseFfzSets(data);++ ffzCache = { data: emotes, ts: now };++ res.json({ emotes, cached: false });++ } catch (err) {++ if (ffzCache.data) return res.json({ emotes: ffzCache.data, cached: true, stale: true });++ res.status(502).json({ error: 'Failed to fetch FFZ emotes', emotes: [] });++ }++});++++router.get('/bttv', async (req, res) => {++ try {++ const now = Date.now();++ if (bttvCache.data && (now - bttvCache.ts) < config.emotes.bttvCacheTtl * 1000) {++ return res.json({ emotes: bttvCache.data, cached: true });++ }++ const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');++ const emotes = parseBttvEmotes(data);++ bttvCache = { data: emotes, ts: now };++ res.json({ emotes, cached: false });++ } catch (err) {++ if (bttvCache.data) return res.json({ emotes: bttvCache.data, cached: true, stale: true });++ res.status(502).json({ error: 'Failed to fetch BTTV emotes', emotes: [] });++ }++});++++router.get('/7tv', async (req, res) => {++ try {++ const now = Date.now();++ if (sevenTvCache.data && (now - sevenTvCache.ts) < config.emotes.sevenTvCacheTtl * 1000) {++ return res.json({ emotes: sevenTvCache.data, cached: true });++ }++ const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');++ const emotes = parse7tvEmotes(data);++ sevenTvCache = { data: emotes, ts: now };++ res.json({ emotes, cached: false });++ } catch (err) {++ if (sevenTvCache.data) return res.json({ emotes: sevenTvCache.data, cached: true, stale: true });++ res.status(502).json({ error: 'Failed to fetch 7TV emotes', emotes: [] });++ }++});++++router.get('/defaults', (req, res) => {++ res.json({ emotes: DEFAULT_EMOTES });++});++++// ── Emote source preferences (stored on Live's channels row) ──++router.get('/sources', requireAuth, async (req, res) => {++ try {++ const channel = await ctx.ensureChannelForUser(req.user.id);++ res.json({ sources: ctx.emoteSources(channel && channel.emote_sources) });++ } catch (err) {++ res.status(500).json({ error: 'Failed to load emote sources' });++ }++});++++router.put('/sources', requireAuth, async (req, res) => {++ try {++ const sources = {};++ for (const key of ['defaults', 'custom', 'ffz', 'bttv', '7tv']) {++ sources[key] = req.body[key] !== false && req.body[key] !== 'false';++ }++ await ctx.setChannelEmoteSources(req.user.id, sources);++ res.json({ sources, message: 'Emote sources updated' });++ } catch (err) {++ res.status(500).json({ error: 'Failed to update emote sources' });++ }++});++++// ── Search FFZ emotes ────────────────────────────────────────++router.get('/search', async (req, res) => {++ try {++ const q = (req.query.q || '').trim();++ if (!q || q.length < 2) return res.json({ emotes: [] });++++ const cacheKey = q.toLowerCase();++ const cached = ffzSearchCache.get(cacheKey);++ const now = Date.now();++ if (cached && (now - cached.ts) < config.emotes.ffzCacheTtl * 1000) {++ return res.json({ emotes: cached.data, cached: true });++ }++++ const data = await fetchJSON(`https://api.frankerfacez.com/v1/emotes?q=${encodeURIComponent(q)}&per_page=50&sort=count-desc`);++ const emotes = (data.emoticons || []).filter((e) => !e.hidden && !e.modifier).map((e) => ({++ id: `ffz-${e.id}`,++ code: e.name,++ url: e.urls?.['2'] || e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,++ url_1x: e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,++ url_2x: e.urls?.['2'] || e.urls?.['1'],++ animated: false,++ source: 'ffz',++ usage_count: e.usage_count || 0,++ }));++++ ffzSearchCache.set(cacheKey, { data: emotes, ts: now });++ if (ffzSearchCache.size > 200) {++ const oldest = [...ffzSearchCache.entries()].sort((a, b) => a[1].ts - b[1].ts).slice(0, 100);++ for (const [key] of oldest) ffzSearchCache.delete(key);++ }++ res.json({ emotes, cached: false });++ } catch (err) {++ res.status(502).json({ error: 'Search failed', emotes: [] });++ }++});++++// ── All emotes for a stream context ──────────────────────────++router.get('/all/:streamId', optionalAuth, async (req, res) => {++ try {++ const streamId = parseInt(req.params.streamId);++ const stream = streamId ? await ctx.ensureStream(streamId) : null;++ let streamUserId = stream ? stream.user_id : null;++ if (!streamUserId && req.query.channel) {++ try { const u = await ctx.ensureUserByUsername(String(req.query.channel)); if (u) streamUserId = u.id; } catch { /* */ }++ }++++ let sources = { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true };++ let channel = null;++ if (streamUserId) {++ channel = await ctx.ensureChannelForUser(streamUserId);++ if (channel && channel.emote_sources) sources = ctx.emoteSources(channel.emote_sources);++ }++++ const defaults = sources.defaults !== false ? DEFAULT_EMOTES : [];++++ let globalCustom = [];++ let channelEmotes = [];++ if (sources.custom !== false) {++ globalCustom = db.getGlobalEmotes().map((e) => ({++ id: `custom-${e.id}`,++ code: e.code,++ url: fileUrl(e),++ animated: !!e.animated,++ width: e.width,++ height: e.height,++ size: e.size || 100,++ source: 'custom',++ owner: e.username,++ }));++++ if (streamUserId) {++ channelEmotes = db.getChannelEmotes(streamUserId).filter((e) => !e.is_global).map((e) => ({++ id: `custom-${e.id}`,++ emote_id: e.id,++ code: e.code,++ url: fileUrl(e),++ animated: !!e.animated,++ width: e.width,++ height: e.height,++ size: e.size || 100,++ source: 'channel',++ owner: e.username,++ uploader: e.uploader_display_name || e.uploader_username || e.username,++ uploader_id: e.user_id,++ }));++ }++ }++++ let ffzEmotes = [];++ if (sources.ffz !== false) {++ ffzEmotes = ffzCache.data || [];++ if (!ffzEmotes.length) {++ try {++ const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');++ ffzEmotes = parseFfzSets(data);++ ffzCache = { data: ffzEmotes, ts: Date.now() };++ } catch { /* use empty */ }++ }++ }++++ let bttvEmotes = [];++ if (sources.bttv !== false) {++ bttvEmotes = bttvCache.data || [];++ if (!bttvEmotes.length) {++ try {++ const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');++ bttvEmotes = parseBttvEmotes(data);++ bttvCache = { data: bttvEmotes, ts: Date.now() };++ } catch { /* use empty */ }++ }++ }++++ let sevenTvEmotes = [];++ if (sources['7tv'] !== false) {++ sevenTvEmotes = sevenTvCache.data || [];++ if (!sevenTvEmotes.length) {++ try {++ const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');++ sevenTvEmotes = parse7tvEmotes(data);++ sevenTvCache = { data: sevenTvEmotes, ts: Date.now() };++ } catch { /* use empty */ }++ }++ }++++ let emoteScale = 100;++ if (channel) {++ const st = ctx.getChannelModerationSettings(channel.id);++ if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;++ }++++ res.json({++ defaults,++ channel: channelEmotes,++ global: globalCustom,++ ffz: ffzEmotes,++ bttv: bttvEmotes,++ '7tv': sevenTvEmotes,++ sources,++ emote_scale: emoteScale,++ });++ } catch (err) {++ res.status(500).json({ error: 'Failed to load emotes' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,70 @@++/**++ * OpenVibe.Chat — Live's internal read API for the chat tables Chat owns (plan T3).++ *++ * Live keeps only its own features; what it needs from the tables Chat now writes it asks here,++ * loopback only, with a service token for audience openvibe.chat carrying `chat.moderation.read`:++ *++ * GET /internal/moderation/channels/:channelId { ok, settings, moderator_ids }++ * GET /internal/moderation/users/:userId/channels { ok, channels: [{ channel_id, title, owner_user_id, owner_username }] }++ * GET /internal/moderation/channels/:channelId/emote-count { ok, count }++ *++ * The answers are the contracts chat.channel-moderation-result@1, chat.moderated-channels-result@1++ * and chat.emote-count-result@1. `settings` is every channel_moderation_settings column, with++ * Live's defaults when the channel has no row. `channelId` is a channels.id for the settings and++ * moderator reads; for the emote count it is the channel owner's Live user id (the emotes rows are++ * keyed by the streamer), the same value Live's db.countChannelEmotes() takes.++ */++'use strict';++++const express = require('express');++const db = require('../db/database');++const ctx = require('../live-context');++const serviceAuth = require('../net/service-auth');++++const router = express.Router();++const guard = serviceAuth.guard('chat.moderation.read');++++router.get('/channels/:channelId', guard, (req, res) => {++ try {++ const channelId = parseInt(req.params.channelId, 10);++ if (!channelId) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });++ const settings = db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);++ const moderator_ids = db.getChannelModeratorIds(channelId);++ res.json({ ok: true, settings, moderator_ids });++ } catch (err) {++ console.error('[Moderation] channel read:', err.message);++ res.status(500).json({ ok: false, error: 'Failed to load channel moderation' });++ }++});++++router.get('/users/:userId/channels', guard, (req, res) => {++ try {++ const userId = parseInt(req.params.userId, 10);++ if (!userId) return res.status(400).json({ ok: false, error: 'Invalid user ID' });++ const channels = db.getChannelsByModerator(userId).map((c) => ({++ channel_id: Number(c.channel_id),++ title: c.title == null ? null : String(c.title),++ owner_user_id: c.owner_user_id == null ? null : Number(c.owner_user_id),++ owner_username: c.owner_username == null ? null : String(c.owner_username),++ }));++ res.json({ ok: true, channels });++ } catch (err) {++ console.error('[Moderation] moderated channels read:', err.message);++ res.status(500).json({ ok: false, error: 'Failed to load moderated channels' });++ }++});++++router.get('/channels/:channelId/emote-count', guard, (req, res) => {++ try {++ const raw = parseInt(req.params.channelId, 10);++ if (!raw) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });++ // A channels.id resolves to its owner's user id (the emotes' key); a user id is used as it is.++ const ownerId = (ctx.getChannelById(raw) || {}).user_id || raw;++ res.json({ ok: true, count: db.countChannelEmotes(ownerId) });++ } catch (err) {++ console.error('[Moderation] emote count:', err.message);++ res.status(500).json({ ok: false, error: 'Failed to count emotes' });++ }++});++++module.exports = router;@@@ -140,6 +140,26 @@ module.exports = {ringTimeoutMs: int(process.env.CALL_RING_TIMEOUT_MS, 45_000),},++ // Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to++ // OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.++ emotes: {++ maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),++ maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),++ maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),++ maxPerUploaderPerChannel: int(process.env.MAX_EMOTES_PER_UPLOADER_PER_CHANNEL, 30),++ ffzCacheTtl: int(process.env.FFZ_CACHE_TTL, 3600),++ bttvCacheTtl: int(process.env.BTTV_CACHE_TTL, 3600),++ sevenTvCacheTtl: int(process.env.SEVENTV_CACHE_TTL, 3600),++ },++++ // OpenVibe.Media — where emote image bytes live now (Media API v2 objects, namespace `chat`).++ // Chat's own service token (client `chat`) needs media.object.upload and media.object.delete.++ media: {++ baseUrl: strip(process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),++ publicOrigin: strip(process.env.OV_MEDIA_PUBLIC_URL || 'https://openvibe.media'),++ scope: 'media.object.upload media.object.delete',++ },++// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.sounds: {@@@ -118,6 +118,7 @@ const ADDED_COLUMNS = [['emotes', 'media_asset_id', 'INTEGER'],['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too++ ['ctx_channels', 'emote_sources', 'TEXT'], // the channel's emote source switches (T3, Live's channels row)];function initDb({ captureMirror = false } = {}) {@@@ -846,6 +847,152 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {[newCode || '', ownerId, oldCode]);}++// ── The six staged tables: reads for the public APIs and Live's internal read API (T3) ──++// Same SQL and shapes as Live's database.js helpers of the same name; usernames come from the++// ctx_users projection instead of Live's users table.++++function isChannelModerator(userId, channelId) {++ return !!get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);++}++++/** The channel_moderation_settings row as it is, or null when the channel has none (defaults are Live's). */++function getChannelModerationSettingsRow(channelId) {++ return get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;++}++++/** A channel's moderators, with names, oldest first (Live's getChannelModerators). */++function getChannelModerators(channelId) {++ return all(`++ SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,++ u.username, u.display_name, u.avatar_url,++ a.username as added_by_username++ FROM channel_moderators cm++ LEFT JOIN ctx_users u ON cm.user_id = u.id++ LEFT JOIN ctx_users a ON cm.added_by = a.id++ WHERE cm.channel_id = ?++ ORDER BY cm.created_at ASC, cm.id ASC++ `, [channelId]);++}++++/** The channel's moderator user ids, oldest first (Live's policy answer carries the same list). */++function getChannelModeratorIds(channelId) {++ return all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId]).map((r) => r.user_id);++}++++/**++ * The channels a user moderates (Live's getChannelsByModerator). Channel title and owner come from++ * the ctx_channels / ctx_users projections; the extra `id` alias is what the dashboard reads.++ */++function getChannelsByModerator(userId) {++ return all(`++ SELECT cm.channel_id, cm.channel_id AS id, c.title,++ c.user_id AS user_id, c.user_id AS owner_user_id, u.username AS owner_username++ FROM channel_moderators cm++ LEFT JOIN ctx_channels c ON cm.channel_id = c.id++ LEFT JOIN ctx_users u ON c.user_id = u.id++ WHERE cm.user_id = ?++ ORDER BY cm.created_at ASC, cm.id ASC++ `, [userId]);++}++++/** Custom emotes marked global (Live's getGlobalEmotes). */++function getGlobalEmotes() {++ return all(`SELECT e.*, u.username FROM emotes e++ LEFT JOIN ctx_users u ON e.user_id = u.id++ WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code`);++}++++/** A channel's emotes: ones targeted at the owner, plus the owner's own legacy uploads. */++function getChannelEmotes(userId) {++ return all(++ `SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name++ FROM emotes e++ LEFT JOIN ctx_users u ON e.user_id = u.id++ LEFT JOIN ctx_users up ON e.user_id = up.id++ WHERE ((e.channel_owner_id = ?) OR (e.channel_owner_id IS NULL AND e.user_id = ?))++ AND e.is_approved = 1++ ORDER BY code`,++ [userId, userId]++ );++}++++/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */++function countChannelEmotes(ownerId) {++ return get(++ 'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',++ [ownerId, ownerId]++ )?.count || 0;++}++++/** The channel's emote with this code, any uploader (Live's getChannelEmoteByCode). */++function getChannelEmoteByCode(ownerId, code) {++ return get(++ `SELECT * FROM emotes++ WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))++ LIMIT 1`,++ [code, ownerId, ownerId]++ );++}++++/** One emote by id, with its uploader's name (Live's getEmoteById). */++function getEmoteById(id) {++ return get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);++}++++/** A user's own emotes (Live's getEmotesByUser). */++function getEmotesByUser(userId) {++ return all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);++}++++/** Moderation actions with actor/target names (Live's getModerationActions). */++function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {++ const conditions = [];++ const params = [];++ const st = scopeType || scope_type;++ const si = scopeId || scope_id;++ if (st) { conditions.push('ma.scope_type = ?'); params.push(st); }++ if (si) { conditions.push('ma.scope_id = ?'); params.push(si); }++ if (actor_user_id) { conditions.push('ma.actor_user_id = ?'); params.push(actor_user_id); }++ if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }++ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';++ params.push(limit, offset);++ return all(`++ SELECT ma.*, actor.username AS actor_username, target.username AS target_username++ FROM moderation_actions ma++ LEFT JOIN ctx_users actor ON ma.actor_user_id = actor.id++ LEFT JOIN ctx_users target ON ma.target_user_id = target.id++ ${where}++ ORDER BY ma.created_at DESC++ LIMIT ? OFFSET ?++ `, params);++}++++/**++ * A channel's chat messages matching a search (Live's searchChannelChatMessages): one of the++ * channel's streams, or its offline room (channel_user_id), never another channel's room.++ */++function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {++ const conditions = [++ '(cm.channel_user_id = ? OR cm.stream_id IN (SELECT id FROM ctx_streams WHERE channel_id = ? OR user_id = ?))',++ 'cm.is_deleted = 0',++ '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',++ ];++ const params = [channel.user_id, channel.id, channel.user_id];++ if (query) { conditions.push('cm.message LIKE ?'); params.push(`%${query}%`); }++ if (userId) { conditions.push('cm.user_id = ?'); params.push(userId); }++ params.push(limit, offset);++ return {++ messages: all(`++ SELECT cm.*, u.username, u.display_name, u.avatar_url++ FROM chat_messages cm++ LEFT JOIN ctx_users u ON cm.user_id = u.id++ WHERE ${conditions.join(' AND ')}++ ORDER BY cm.timestamp DESC++ LIMIT ? OFFSET ?++ `, params),++ };++}++// ── Staged tables (roadmap C-04) ──────────────────────────────// Who writes each one is table_authority (STAGED_TABLES above). The reads are cached for a second:// a handoff made by another process (scripts/table-authority.js) counts within that.@@@ -1053,14 +1200,15 @@ function setChannelAlertSound(channelId, kind, url, mime) {return out;}--// Emotes (Live's /api/emotes; media_url/media_asset_id from its Media asset-sync).--function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100 }) {++// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's++// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).++function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {_assertChatWrites('emotes');return transaction(() => {const res = run(-- `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,-- [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100))]++ `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]);return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);});@@@ -1296,6 +1444,20 @@ module.exports = {deleteChannelSound,renameChannelSoundCommand,updateChannelSoundEmoteRefs,++ // the six staged tables: reads for the public APIs and Live's internal read API (T3)++ isChannelModerator,++ getChannelModerationSettingsRow,++ getChannelModerators,++ getChannelModeratorIds,++ getChannelsByModerator,++ getGlobalEmotes,++ getChannelEmotes,++ countChannelEmotes,++ getChannelEmoteByCode,++ getEmoteById,++ getEmotesByUser,++ getModerationActions,++ searchChannelChatMessages,// staged tables (C-04): authority, Live's changes, dual-read slices, and the writes once Chat owns themtableAuthority,setTableAuthority,@@@ -362,6 +362,7 @@ CREATE TABLE IF NOT EXISTS ctx_channels (id INTEGER PRIMARY KEY,user_id INTEGER UNIQUE,title TEXT,++ emote_sources TEXT, -- the channel's emote source switches (Live's channels.emote_sources)synced_at INTEGER);@@@ -207,7 +207,19 @@ class Swr {const USER_COLS = ['id', 'username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned', 'ban_reason', 'is_owner', 'created_at', 'subject_id'];const STREAM_COLS = ['id', 'user_id', 'channel_id', 'managed_stream_id', 'title', 'is_live', 'started_at', 'ended_at', 'created_at'];const MS_COLS = ['id', 'user_id', 'slug', 'title', 'sort_order', 'created_at'];--const CHANNEL_COLS = ['id', 'user_id', 'title'];++const CHANNEL_COLS = ['id', 'user_id', 'title', 'emote_sources'];++++// The emote source switches Live keeps on the channels row (emote_sources JSON), every key defaulting++// to true: Live's /api/emotes/sources answers exactly this shape.++const EMOTE_SOURCE_KEYS = ['defaults', 'custom', 'ffz', 'bttv', '7tv'];++function emoteSources(raw) {++ let sources = raw;++ if (typeof raw === 'string') { try { sources = JSON.parse(raw || '{}'); } catch { sources = {}; } }++ if (!sources || typeof sources !== 'object') sources = {};++ const out = {};++ for (const key of EMOTE_SOURCE_KEYS) out[key] = sources[key] === undefined ? true : sources[key] !== false;++ return out;++}function upsertRows(table, cols, rows) {if (!rows || !rows.length) return 0;@@@ -334,6 +346,14 @@ async function createChannel(userId) {return getChannelByUserId(userId);}++/** Write the channel's emote source switches (Live's channels.emote_sources) and refresh the cache. */++async function setChannelEmoteSources(userId, sources) {++ const clean = emoteSources(sources);++ await effect('channel-emote-sources', { user_id: userId, sources: clean });++ db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);++ return clean;++}++// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────// Everyone who wants to know when a channel's settings were read anew (the chat server announces a@@@ -939,6 +959,7 @@ module.exports = {// streams + channelsgetStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,++ emoteSources, setChannelEmoteSources,// policygetChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,reloadPolicy, onChannelSettings, defaultModerationSettings,nnew file mode 100644@@@ -0,0 +1,56 @@++/**++ * OpenVibe.Chat → OpenVibe.Media (object API v2, namespace `chat`).++ *++ * Chat's OWN service token (client `chat`, capability media.object.upload to store an emote's++ * image and media.object.delete to remove it) through openvibe-sdk/media's createObjectsClient.++ * An uploaded object is public, and its public openvibe.media URL is what an emote row stores as++ * media_url (Live's asset-sync did the same onto the shared rows before the T3 takeover).++ */++'use strict';++++const config = require('../config');++++const APP = 'chat';++++let _objects = null;++++function objects() {++ if (_objects) return _objects;++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const { createObjectsClient } = require('openvibe-sdk/media');++ const tokenClient = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.media',++ scope: config.media.scope,++ });++ _objects = createObjectsClient({ app: APP, baseUrl: config.media.baseUrl, tokenClient });++ return _objects;++}++++/** The absolute public URL of an uploaded object (Media returns public_url; url is the fallback). */++function publicUrl(obj) {++ if (!obj) return null;++ if (obj.public_url) return obj.public_url;++ if (obj.url) return /^https?:\/\//i.test(obj.url) ? obj.url : `${config.media.publicOrigin}${String(obj.url).startsWith('/') ? '' : '/'}${obj.url}`;++ return null;++}++++/**++ * Upload image bytes as a public Media object → { id, url }.++ * Shaped for the emote routes: `id` is stored as media_asset_id, `url` as media_url.++ */++async function uploadObject(buffer, { filename, mimeType } = {}) {++ const obj = await objects().upload(buffer, { kind: 'file', visibility: 'public', mimeType: mimeType || 'application/octet-stream', filename });++ return { id: obj.id, url: publicUrl(obj) };++}++++/** Remove a Media object (an emote or sound was deleted). Best-effort: logged, never thrown. */++async function deleteObject(id) {++ if (!id) return false;++ try { return await objects().delete(id); } catch (err) { console.warn(`[Media] delete ${id}: ${err.message}`); return false; }++}++++module.exports = { uploadObject, deleteObject, publicUrl, APP, _setClient(c) { _objects = c; }, _reset() { _objects = null; } };nnew file mode 100644@@@ -0,0 +1,287 @@++'use strict';++/**++ * Plan T3 step 1 — the APIs Chat serves for the six staged tables:++ * - Live's internal read API (/internal/moderation/*, capability chat.moderation.read)++ * - the public emote API (/api/emotes, Live's paths and shapes; bytes on OpenVibe.Media)++ * - channel moderators & moderation settings (/api/chat/channels/:id/…)++ * - the bridge alert-sound op (Live says "play the alert", Chat resolves the sound)++ * The authority-not-flipped refusal (503 { ok:false, error:'not yet' }) is checked for every writer.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { validate } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('chat-apis');++let h, RO, BRIDGE, streamer, other, admin, mod, stranger, channelId, streamId;++const STAGED_TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes'];++const deletedAssets = [];++let assets = { upload: null, delete: null };++++function checkContract(ref, body) {++ const r = validate(ref, body);++ assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);++}++const asChat = (table) => h.db.setTableAuthority(table, 'chat');++++t('boot', async () => {++ // 3 emotes per channel, so both the clash check and the cap can be exercised.++ h = await boot({ env: { MAX_EMOTES_PER_CHANNEL: '3' } });++ RO = h.serviceToken(['chat.moderation.read']);++ BRIDGE = h.serviceToken(['chat.live_bridge.write']);++ streamer = h.addUser('streamer', { role: 'streamer' });++ other = h.addUser('otherstreamer', { role: 'streamer' });++ admin = h.addUser('adminny', { role: 'admin' });++ mod = h.addUser('moddy');++ stranger = h.addUser('stranger');++ channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { channel_id: 0, custom_emotes_enabled: 1 } });++ streamId = h.addStream(streamer.id, channelId);++ await h.ctx.sync();++ // The Media mock: uploads answer a public object, deletes are recorded.++ assets.upload = async (buf, opts) => ({ id: 777, public_url: `https://openvibe.media/chat/${(opts && opts.filename) || 'emote.png'}` });++ assets.delete = async (id) => { deletedAssets.push(id); return true; };++ require('../server/media/client')._setClient({ upload: assets.upload, delete: assets.delete });++});++++// ── Internal read API ────────────────────────────────────────++t('internal read API: loopback + service token (401 / 403 / forwarded)', async () => {++ let r = await h.http('GET', `/internal/moderation/channels/${channelId}`);++ assert.strictEqual(r.status, 401, r.text);++ r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: BRIDGE, headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.strictEqual(r.status, 403, 'a forwarded request is refused (loopback only)');++ r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: BRIDGE });++ assert.strictEqual(r.status, 403, 'wrong capability');++ r = await h.http('GET', `/api/internal/moderation/channels/${channelId}`, { token: RO });++ assert.strictEqual(r.status, 404, 'not a public path');++});++++t('internal read API: settings + moderator_ids validate, defaults for a channel with no row', async () => {++ const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });++ assert.strictEqual(r.status, 200, r.text);++ checkContract('chat.channel-moderation-result', r.body);++ assert.strictEqual(r.body.ok, true);++ assert.strictEqual(r.body.settings.channel_id, channelId);++ // Live's defaults when the channel has no settings row.++ const d = await h.http('GET', '/internal/moderation/channels/999999', { token: RO });++ checkContract('chat.channel-moderation-result', d.body);++ assert.strictEqual(d.body.settings.channel_id, 999999);++ assert.strictEqual(d.body.settings.max_message_length, 500);++ assert.strictEqual(d.body.settings.allow_anonymous, 1);++ assert.deepStrictEqual(d.body.moderator_ids, []);++});++++t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {++ asChat('channel_moderators');++ h.db.addChannelModerator(channelId, mod.id, streamer.id);++ h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);++ h.db.addChannelModerator(channelId, stranger.id, streamer.id);++ h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);++ const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });++ checkContract('chat.channel-moderation-result', r.body);++ assert.deepStrictEqual(r.body.moderator_ids, [mod.id, stranger.id]);++++ // other moderates nothing; mod moderates one channel (title/owner from the projection).++ let c = await h.http('GET', `/internal/moderation/users/${mod.id}/channels`, { token: RO });++ checkContract('chat.moderated-channels-result', c.body);++ assert.strictEqual(c.body.channels.length, 1);++ assert.strictEqual(c.body.channels[0].channel_id, channelId);++ assert.strictEqual(c.body.channels[0].owner_user_id, streamer.id);++ assert.strictEqual(c.body.channels[0].owner_username, 'streamer');++ assert.strictEqual(c.body.channels[0].title, 'Channel');++ c = await h.http('GET', `/internal/moderation/users/${other.id}/channels`, { token: RO });++ checkContract('chat.moderated-channels-result', c.body);++ assert.deepStrictEqual(c.body.channels, []);++});++++t('internal read API: emote count validates; channels.id resolves to its owner', async () => {++ const r = await h.http('GET', `/internal/moderation/channels/${channelId}/emote-count`, { token: RO });++ assert.strictEqual(r.status, 200, r.text);++ checkContract('chat.emote-count-result', r.body);++ assert.strictEqual(r.body.count, 0);++});++++// ── Emote API ────────────────────────────────────────────────++const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);++async function upload(userId, token, fields = {}, bytes = PNG, type = 'image/png') {++ const form = new FormData();++ form.append('image', new Blob([bytes], { type }), 'emote.png');++ for (const [k, v] of Object.entries(fields)) form.append(k, String(v));++ const res = await fetch(`${h.base}/api/emotes`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });++ return { status: res.status, body: await res.json().catch(() => null) };++}++++t('emote API: the authority-not-flipped refusal (503 not yet, nothing written)', async () => {++ const r = await upload(streamer.id, streamer.token, { code: 'refused' });++ assert.strictEqual(r.status, 503, JSON.stringify(r.body));++ assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes').n, 0);++});++++t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap', async () => {++ asChat('emotes');++ // Public list shapes need no auth.++ let r = await h.http('GET', '/api/emotes/defaults');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.emotes.length, 25);++++ const up = await upload(streamer.id, streamer.token, { code: 'hype', size: 150 });++ assert.strictEqual(up.status, 200, JSON.stringify(up.body));++ assert.strictEqual(up.body.emote.code, 'hype');++ assert.strictEqual(up.body.emote.url, 'https://openvibe.media/chat/emote.png');++ const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);++ assert.strictEqual(row.media_asset_id, 777);++ assert.strictEqual(row.media_url, 'https://openvibe.media/chat/emote.png');++ assert.strictEqual(row.size, 150);++++ // Channel list uses media_url.++ const ch = await h.http('GET', `/api/emotes/channel/${streamer.id}`);++ assert.strictEqual(ch.status, 200);++ assert.strictEqual(ch.body.emotes[0].url, 'https://openvibe.media/chat/emote.png');++ assert.strictEqual(ch.body.emotes[0].source, 'channel');++++ // mine: authenticated, count/max.++ const mine = await h.http('GET', '/api/emotes/mine', { token: streamer.token });++ assert.strictEqual(mine.status, 200);++ assert.deepStrictEqual([mine.body.count, mine.body.max], [1, 3]);++++ // Clash within the channel.++ const lie = await upload(streamer.id, streamer.token, { code: 'fakeimg' }, Buffer.from('<html><script>x</script></html>'), 'image/png');++ assert.strictEqual(lie.status, 400, 'bytes that are not the declared image type are refused');++ const clash = await upload(streamer.id, streamer.token, { code: 'hype' });++ assert.strictEqual(clash.status, 409, JSON.stringify(clash.body));++++ // Cap: 3 per channel (already 1), the third is refused.++ assert.strictEqual((await upload(streamer.id, streamer.token, { code: 'two' })).status, 200);++ assert.strictEqual((await upload(streamer.id, streamer.token, { code: 'three' })).status, 200);++ const full = await upload(streamer.id, streamer.token, { code: 'four' });++ assert.strictEqual(full.status, 400, JSON.stringify(full.body));++ assert.match(full.body.error, /full/);++++ // Code validation + bad type.++ const bad = await upload(streamer.id, streamer.token, { code: 'x' });++ assert.strictEqual(bad.status, 400);++ const r2 = await h.http('GET', '/api/emotes/file/whatever.png');++ assert.strictEqual(r2.status, 404);++});++++t('emote API: rename/resize, delete removes the Media object', async () => {++ const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);++ let r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: streamer.token, body: { code: 'hype2', size: 300 } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.code, r.body.size], ['hype2', 200], 'size clamped to the channel max (200)');++++ r = await h.http('DELETE', `/api/emotes/${row.id}`, { token: streamer.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id]).n, 0);++ assert.deepStrictEqual(deletedAssets, [777], 'the Media object is removed');++});++++t('emote API: not-mine is refused, sources round-trip through the Live-context effect', async () => {++ const row = h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);++ const r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: other.token, body: { code: 'nope' } });++ assert.strictEqual(r.status, 403, r.text);++++ const s = await h.http('GET', '/api/emotes/sources', { token: streamer.token });++ assert.strictEqual(s.status, 200);++ assert.deepStrictEqual(s.body.sources, { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true });++ const p = await h.http('PUT', '/api/emotes/sources', { token: streamer.token, body: { ffz: false } });++ assert.strictEqual(p.status, 200, p.text);++ assert.strictEqual(p.body.sources.ffz, false);++ assert.ok(h.live.effects.some((e) => e.name === 'channel-emote-sources'), 'the write reaches Live');++ const s2 = await h.http('GET', '/api/emotes/sources', { token: streamer.token });++ assert.strictEqual(s2.body.sources.ffz, false);++});++++// ── Moderators & moderation settings ─────────────────────────++t('moderators: owner and admin add/remove, a stranger is refused', async () => {++ asChat('channel_moderators');++ let r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: stranger.token, body: { username: 'moddy' } });++ assert.strictEqual(r.status, 403, r.text);++ r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'moddy' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.body.moderators.some((m) => m.user_id === mod.id));++ r = await h.http('DELETE', `/api/chat/channels/${channelId}/mods/${mod.id}`, { token: admin.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.body.moderators.some((m) => m.user_id === mod.id));++ r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'nobodyhere' } });++ assert.strictEqual(r.status, 404, r.text);++});++++t('moderators: list is public to any signed-in reader; moderation settings are mod-only', async () => {++ const list = await h.http('GET', `/api/chat/channels/${channelId}/mods`, { token: other.token });++ assert.strictEqual(list.status, 200, list.text);++ assert.strictEqual(list.body.channel_id, channelId);++++ let s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: other.token });++ assert.strictEqual(s.status, 403, s.text);++ s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token });++ assert.strictEqual(s.status, 200, s.text);++ assert.strictEqual(s.body.settings.custom_emotes_enabled, 1);++ const mine = await h.http('GET', '/api/chat/channels/moderation/mine', { token: streamer.token });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.body.channels.some((c) => c.id === channelId));++});++++t('moderation settings: owner writes, a mod cannot change policy keys, authority gate first', async () => {++ // While Live writes the settings, the write refuses.++ h.db.setTableAuthority('channel_moderation_settings', 'live');++ let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 5 } });++ assert.strictEqual(r.status, 503, r.text);++ assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });++++ asChat('channel_moderation_settings');++ asChat('channel_moderators');++ h.db.addChannelModerator(channelId, mod.id, streamer.id);++ r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.settings.slow_mode_seconds, 12);++ assert.strictEqual(r.body.settings.ip_approval_mode, 1);++++ // A mod passing an owner-policy key: ignored, not refused.++ r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: mod.token, body: { slow_mode_seconds: 3, allow_anonymous: 0 } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.settings.slow_mode_seconds, 3);++ assert.strictEqual(r.body.settings.allow_anonymous, 1, 'owner policy stayed');++++ const s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: mod.token });++ assert.strictEqual(s.status, 200, s.text);++++ const logs = await h.http('GET', `/api/chat/channels/${channelId}/moderation/logs`, { token: streamer.token });++ assert.strictEqual(logs.status, 200, logs.text);++ assert.ok(Array.isArray(logs.body.actions));++ const search = await h.http('GET', `/api/chat/channels/${channelId}/moderation/chat-search?q=hi`, { token: streamer.token });++ assert.strictEqual(search.status, 200, search.text);++ assert.ok(Array.isArray(search.body.messages));++});++++// ── Alert-sound op ───────────────────────────────────────────++t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {++ asChat('channel_moderation_settings');++ const snd = path.join(h.tmp, 'sounds', 'donation.mp3');++ fs.writeFileSync(snd, 'ID3alert');++ h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');++++ const ws = await h.ws({ ip: '198.51.100.7', stream: streamId });++ ws.sendJson({ type: 'join', streamId, channelUserId: streamer.id, anonId: null });++ await ws.next((m) => m.type === 'auth' || m.type === 'connected');++++ const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-alerts', ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });++ let r = await calls([{ op: 'playAlertSound', args: [streamer.id, streamId, 'donation'] }]);++ assert.strictEqual(r.status, 200, r.text);++ const res = r.body.results[0];++ assert.strictEqual(res.ok, true, JSON.stringify(res));++ assert.deepStrictEqual(res.result, { played: true, source: 'donation-alert' });++ const frame = await ws.next((m) => m.type === 'soundboard-audio');++ assert.strictEqual(frame.source, 'donation-alert');++ assert.strictEqual(Buffer.from(frame.audio, 'base64').toString(), 'ID3alert');++ ws.close();++++ // No goal sound set: goal falls back to the donation sound; a channel with none does not play.++ r = await calls([{ op: 'playAlertSound', args: [streamer.id, streamId, 'goal'] }]);++ assert.deepStrictEqual(r.body.results[0].result, { played: true, source: 'goal-alert' });++ r = await calls([{ op: 'playAlertSound', args: [other.id, streamId, 'donation'] }]);++ assert.deepStrictEqual(r.body.results[0].result, { played: false, source: 'donation-alert' });++});++++t.run(async () => { if (h) await h.close(); });@@@ -117,6 +117,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations','server/prefs/network-modules.js': 'Network user modules (configured)','server/chat/routes.js': 'GIF providers (fixed hosts)',++ 'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)','server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)','server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',};
@@ -9,7 +9,7 @@ chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own site (global ch
@@ -9,7 +9,7 @@ chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own site (global ch
settings; the service manifest records it live since 2026-09-24).
settings; the service manifest records it live since 2026-09-24).
**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat
**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat
is also served on Live's origin: `https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`,
is also served on Live's origin: `https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`,
-`/api/tts/`, `/api/sounds` via nginx.
+`/api/tts/`, `/api/sounds`, `/api/emotes` via nginx.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -59,6 +59,27 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -59,6 +59,27 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
- **Staged tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
- **Staged tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
`chat_ai_summaries`, `chat_timeline_events`): Live writes them until each is handed to Chat, one
`chat_ai_summaries`, `chat_timeline_events`): Live writes them until each is handed to Chat, one
table at a time (`table_authority`, register C-04). Runbook: `docs/staged-tables-cutover.md`.
table at a time (`table_authority`, register C-04). Runbook: `docs/staged-tables-cutover.md`.
+- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can
+ stop touching them at the flip:
+ - `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),
+ `/search`, `/all/:streamId`, `/file/:filename`, `POST /api/emotes`, `PATCH/DELETE /api/emotes/:id`
+ — Live's exact paths, bodies, limits and error messages; uploaded bytes go to OpenVibe.Media with
+ Chat's own token (`media.object.upload` / `.delete`, namespace `chat`), the row stores
+ `media_url` + `media_asset_id`, responses use `media_url`. No local emote files (`/file/…` 404s).
+ `ffz`/`bttv`/`7tv` are cached provider proxies on fixed hosts.
+ - `GET /api/chat/channels/moderation/mine`, `GET/POST /api/chat/channels/:id/mods`,
+ `DELETE /api/chat/channels/:id/mods/:userId`, `GET/PUT /api/chat/channels/:id/moderation`,
+ `…/moderation/logs`, `…/moderation/chat-search`,
+ `POST …/moderation/messages/:messageId/delete` — owner/admin write, channel mods read.
+ - Live's read API for what Chat owns: `GET /internal/moderation/channels/:channelId`,
+ `GET /internal/moderation/users/:userId/channels`,
+ `GET /internal/moderation/channels/:channelId/emote-count` — loopback + service token,
+ capability `chat.moderation.read` (`chat.channel-moderation-result@1`,
+ `chat.moderated-channels-result@1`, `chat.emote-count-result@1`). Live caches each answer 30 s.
+ - Alert sounds: the bridge op `playAlertSound [streamerId, streamId, kind]` makes Chat resolve the
+ channel's own settings row, read the clip and broadcast it, so Live only names the alert.
+ - Every write keeps the `table_authority` gate: while Live still writes the table the route
+ answers `503 { ok: false, error: 'not yet' }` and never writes.
## How it fits the network
## How it fits the network
@@ -68,6 +89,8 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -68,6 +89,8 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
+| The six staged tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (after the T3 flip; Live until then) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |
+| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
| A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person |
| A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person |
@@ -363,7 +386,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
@@ -363,7 +386,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
| Routes | Per caller |
| Routes | Per caller |
| --- | --- |
| --- | --- |
-| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |
+| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/emotes`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |
| GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 |
| GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 |
| Chat search, staff and streamer logs, purge preview | 30 / 600 |
| Chat search, staff and streamer logs, purge preview | 30 / 600 |
| Log export, `GET /api/chat/me/export` | 5 / 30 |
| Log export, `GET /api/chat/me/export` | 5 / 30 |
@@ -379,7 +402,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
@@ -379,7 +402,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the bridge, the call
Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the bridge, the call
hooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and
hooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and
`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,
`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,
-`/api/sounds/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.
+`/api/sounds/file/…`, `/api/emotes/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.
### Layout
### Layout
@@ -387,7 +410,7 @@ hooks and the signed Events deliveries, which carry account deletions and merges
@@ -387,7 +410,7 @@ hooks and the signed Events deliveries, which carry account deletions and merges
server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop
server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop
server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks
server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks
server/live-context.js the only module that talks to Live (interface in its header)
server/live-context.js the only module that talks to Live (interface in its header)
-server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice
+server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-sounds
server/auth/ token resolution through Live; the chat subset of Live's permissions
server/auth/ token resolution through Live; the chat subset of Live's permissions
server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)
server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)
server/events/outbox.js events.event-envelope@1 outbox and relay
server/events/outbox.js events.event-envelope@1 outbox and relay
@@ -396,6 +419,7 @@ server/events/subscriptions.js creates them at boot when missing; list/disable/
@@ -396,6 +419,7 @@ server/events/subscriptions.js creates them at boot when missing; list/disable/
server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
+server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-record
scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-record
docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1153,9 +1153,9 @@
@@ -1153,9 +1153,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.79.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
+ "version": "0.80.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
+ "integrity": "sha512-7RCNFKxFRey3PquBwyQkSu8Bu2q2Ek0yb248SJWaymcg/g9PrlStFVUDTLMgh8i7BZSaZSngj0nojpwYTLGYDQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -98,6 +98,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -98,6 +98,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
if (events) app.use('/internal/events', events.router);
if (events) app.use('/internal/events', events.router);
// Live's stream hooks for calls (CALLS_AUTHORITY=chat): stream voice channels (server/calls/internal.js).
// Live's stream hooks for calls (CALLS_AUTHORITY=chat): stream voice channels (server/calls/internal.js).
app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());
app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());
+ // Live's read API for the chat tables Chat owns (plan T3; capability chat.moderation.read).
+ app.use('/internal/moderation', express.json({ limit: '64kb' }), require('./chat/internal-moderation'));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
@@ -214,17 +216,22 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -214,17 +216,22 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));
app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));
app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));
app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));
app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
+ app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));
// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat/rooms', require('./rooms/routes'));
+ // Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
+ app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());
app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());
app.use('/api/chat', require('./chat/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
+ // Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.
+ app.use('/api/emotes', require('./chat/emotes-routes'));
// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.
// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.
// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).
// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).
const callRoutes = require('./calls/routes');
const callRoutes = require('./calls/routes');
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* { boot, ops: [{ seq, op, args, ref? }] } → { ok, results: [{ seq, ok, result?, error? }] }
* { boot, ops: [{ seq, op, args, ref? }] } → { ok, results: [{ seq, ok, result?, error? }] }
* op = a ChatServer method Live's modules call (broadcastToStream, forwardToGlobal, sendDm,
* op = a ChatServer method Live's modules call (broadcastToStream, forwardToGlobal, sendDm,
* synthesizeAndBroadcastTTS, triggerChannelSound, disconnectUser, sendUserUpdate, …), a few
* synthesizeAndBroadcastTTS, triggerChannelSound, disconnectUser, sendUserUpdate, …), a few
- * bridge-only ops (sendToConn, deployNotice, userChanged, invalidate, broadcastAllRaw), or `db` — one of
+ * bridge-only ops (sendToConn, deployNotice, playAlertSound, userChanged, invalidate, broadcastAllRaw), or `db` — one of
* the chat-table writes Live's un-moved code still makes (AI viewers, relays, donations,
* the chat-table writes Live's un-moved code still makes (AI viewers, relays, donations,
* /api/mod), executed on Chat's database, which is the authority.
* /api/mod), executed on Chat's database, which is the authority.
*
*
@@ -241,6 +241,13 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -241,6 +241,13 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
}
}
case 'deployNotice':
case 'deployNotice':
return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });
return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });
+ case 'playAlertSound': {
+ // Live's donation / goal-reached alerts: Live says "play the alert for channel X"
+ // (streamerId, streamId, kind), Chat resolves the sound from its own settings row and
+ // broadcasts it to the channel room. Replaces Live reading the file + sending base64.
+ const [streamerId, streamId, kind] = args;
+ return require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');
+ }
case 'userChanged':
case 'userChanged':
ctx.invalidateUser(args[0]);
ctx.invalidateUser(args[0]);
await ctx.ensureUsers([args[0]]).catch(() => {});
await ctx.ensureUsers([args[0]]).catch(() => {});
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+/**
+ * OpenVibe.Chat — channel alert sounds (donation / donation-goal-reached).
+ *
+ * Live's server/monetization/alerts.js used to read the sound file off disk and broadcast it itself
+ * as base64 `soundboard-audio`. At the T3 cutover the settings row and the file are Chat's, so Live
+ * only says "play the donation|goal alert for channel X" over the bridge (op `playAlertSound`); this
+ * module resolves the channel's own channel_moderation_settings row, reads the clip from the shared
+ * sounds directory, and broadcasts it to the channel room (every slot + the offline room), gated on
+ * the viewer's Chat Sounds toggle exactly as before.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const db = require('../db/database');
+const ctx = require('../live-context');
+
+const MAX_SOUND_BYTES = 3 * 1024 * 1024;
+
+function mimeForExt(ext) {
+ switch ((ext || '').toLowerCase()) {
+ case '.wav': return 'audio/wav';
+ case '.ogg': case '.oga': return 'audio/ogg';
+ case '.opus': return 'audio/opus';
+ case '.webm': return 'audio/webm';
+ case '.m4a': case '.mp4': return 'audio/mp4';
+ case '.aac': return 'audio/aac';
+ case '.flac': return 'audio/flac';
+ default: return 'audio/mpeg';
+ }
+}
+
+function readSound(diskPath, mimeHint) {
+ try {
+ if (!diskPath || !fs.existsSync(diskPath)) return null;
+ const buf = fs.readFileSync(diskPath);
+ if (!buf || !buf.length || buf.length > MAX_SOUND_BYTES) return null;
+ return { audio: buf.toString('base64'), mimeType: mimeHint || mimeForExt(path.extname(diskPath)) };
+ } catch { return null; }
+}
+
+/**
+ * Broadcast a streamer's alert sound to their viewers. kind: 'donation' | 'goal'. The sound comes
+ * from Chat's own settings row for the channel (goal falls back to the donation sound). No-op when
+ * none is configured. → { played: boolean, source } (never throws: a missing sound is not an error).
+ */
+async function playAlertSound(chatServer, streamerId, streamId, kind) {
+ const source = kind === 'goal' ? 'goal-alert' : 'donation-alert';
+ try {
+ if (!chatServer || !chatServer.broadcastToChannelRoom) return { played: false, source };
+ const channel = await ctx.ensureChannelForUser(streamerId);
+ const row = channel ? db.getChannelModerationSettingsRow(channel.id) : null;
+ if (!row) return { played: false, source };
+ let disk, mime;
+ if (kind === 'goal') {
+ disk = row.goal_sound_url || row.donation_sound_url;
+ mime = row.goal_sound_url ? row.goal_sound_mime : row.donation_sound_mime;
+ } else {
+ disk = row.donation_sound_url;
+ mime = row.donation_sound_mime;
+ }
+ const snd = readSound(disk, mime);
+ if (!snd) return { played: false, source };
+ chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });
+ return { played: true, source };
+ } catch (err) {
+ console.warn(`[Alerts] play ${kind} for ${streamerId}: ${err.message}`);
+ return { played: false, source };
+ }
+}
+
+module.exports = { playAlertSound, mimeForExt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,321 @@
@@ -0,0 +1,321 @@
+/**
+ * OpenVibe.Chat — Channel moderator & moderation settings API (moved from OpenVibe.Live
+ * server/admin/channel-mod-routes.js, plan T3 step 1). Mounted under /api/chat/channels (Live's
+ * /api/channels paths, on Chat's origin).
+ *
+ * GET /moderation/mine - Channels the user owns/moderates
+ * GET /:channelId/mods - List channel mods
+ * POST /:channelId/mods - Add channel mod (owner or admin)
+ * DELETE /:channelId/mods/:userId - Remove channel mod (owner or admin)
+ * GET /:channelId/moderation - Get moderation settings (mod+)
+ * PUT /:channelId/moderation - Update settings (mod+; owner for policy)
+ * GET /:channelId/moderation/logs - Moderation action log (mod+)
+ * GET /:channelId/moderation/chat-search - Search channel chat (mod+)
+ * POST /:channelId/moderation/messages/:messageId/delete - Delete a chat message (mod+)
+ *
+ * Channels, users and moderation policy come through live-context; the moderator and settings rows
+ * are Chat's own tables. A write while Live is still the table's writer answers 503
+ * { ok: false, error: 'not yet' } and never writes; after the flip a settings or moderator change
+ * refreshes Chat's policy cache at once (the db helpers call invalidateChannel).
+ */
+'use strict';
+
+const express = require('express');
+const db = require('../db/database');
+const ctx = require('../live-context');
+const { requireAuth } = require('../auth/auth');
+const permissions = require('../auth/permissions');
+
+const router = express.Router();
+
+/** A staged-table write while Live is still the authority: 503, never a write. */
+function notYet(res, err) {
+ if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }
+ return false;
+}
+
+function parseBoolean(value, fallback) {
+ if (value === undefined || value === null) return fallback;
+ if (typeof value === 'boolean') return value;
+ if (typeof value === 'number') return value !== 0;
+ if (typeof value === 'string') return !['0', 'false', 'off', 'no'].includes(value.toLowerCase());
+ return fallback;
+}
+
+/** Can this user hand out / take away channel mods? Live's canAssignChannelMods: owner or staff. */
+function canAssignMods(user, channel) {
+ return permissions.can(user, 'staff.roles.assign') || (!!channel && channel.user_id === user.id);
+}
+
+/** Middleware: require channel access (owner, channel mod, or staff). */
+function requireChannelAccess(req, res, next) {
+ const channelId = parseInt(req.params.channelId, 10);
+ if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
+ const channel = ctx.getChannelById(channelId);
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ if (!permissions.canModerateChannel(req.user, channelId)) {
+ return res.status(403).json({ error: 'Channel moderation access required' });
+ }
+ req.channel = channel;
+ next();
+}
+
+// ── My moderation access ─────────────────────────────────────
+router.get('/moderation/mine', requireAuth, async (req, res) => {
+ try {
+ const owned = await ctx.ensureChannelForUser(req.user.id);
+ const moderated = db.getChannelsByModerator(req.user.id) || [];
+
+ const channelMap = new Map();
+ if (owned) channelMap.set(owned.id, owned);
+ for (const ch of moderated) {
+ const id = Number(ch.channel_id ?? ch.id);
+ if (!channelMap.has(id)) channelMap.set(id, { id, user_id: ch.owner_user_id ?? ch.user_id, title: ch.title });
+ }
+
+ const channels = [...channelMap.values()].map((channel) => ({
+ ...channel,
+ moderation_settings: ctx.getChannelModerationSettings(channel.id),
+ moderators: db.getChannelModerators(channel.id),
+ }));
+
+ res.json({ channels });
+ } catch (err) {
+ console.error('[ChannelMod] Failed to load moderation access:', err.message);
+ res.status(500).json({ error: 'Failed to load channel moderation access' });
+ }
+});
+
+// ── List channel mods ────────────────────────────────────────
+router.get('/:channelId/mods', requireAuth, (req, res) => {
+ try {
+ const channelId = parseInt(req.params.channelId);
+ const channel = ctx.getChannelById(channelId);
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ res.json({ moderators: db.getChannelModerators(channelId), channel_id: channelId });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to list channel moderators' });
+ }
+});
+
+// ── Add channel mod ──────────────────────────────────────────
+router.post('/:channelId/mods', requireAuth, async (req, res) => {
+ try {
+ const channelId = parseInt(req.params.channelId);
+ const channel = ctx.getChannelById(channelId);
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ if (!canAssignMods(req.user, channel)) {
+ return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });
+ }
+ const { username } = req.body;
+ if (!username) return res.status(400).json({ error: 'Username is required' });
+
+ const targetUser = await ctx.ensureUserByUsername(username);
+ if (!targetUser) return res.status(404).json({ error: 'User not found' });
+ if (targetUser.id === channel.user_id) {
+ return res.status(400).json({ error: 'Channel owner is already a moderator' });
+ }
+
+ db.addChannelModerator(channelId, targetUser.id, req.user.id);
+ db.logModerationAction({
+ scope_type: 'channel',
+ scope_id: channelId,
+ actor_user_id: req.user.id,
+ target_user_id: targetUser.id,
+ action_type: 'channel_mod_add',
+ details: { channel_id: channelId, username: targetUser.username },
+ });
+ res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ res.status(500).json({ error: 'Failed to add channel moderator' });
+ }
+});
+
+// ── Remove channel mod ───────────────────────────────────────
+router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
+ try {
+ const channelId = parseInt(req.params.channelId);
+ const userId = parseInt(req.params.userId);
+ const channel = ctx.getChannelById(channelId);
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ if (!canAssignMods(req.user, channel)) {
+ return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });
+ }
+ const target = ctx.getUserById(userId);
+ db.removeChannelModerator(channelId, userId);
+ db.logModerationAction({
+ scope_type: 'channel',
+ scope_id: channelId,
+ actor_user_id: req.user.id,
+ target_user_id: userId,
+ action_type: 'channel_mod_remove',
+ details: { channel_id: channelId, username: (target && target.username) || String(userId) },
+ });
+ res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ res.status(500).json({ error: 'Failed to remove channel moderator' });
+ }
+});
+
+// ── Get channel moderation settings ──────────────────────────
+router.get('/:channelId/moderation', requireAuth, requireChannelAccess, (req, res) => {
+ try {
+ res.json({ settings: ctx.getChannelModerationSettings(req.channel.id) });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to get moderation settings' });
+ }
+});
+
+// ── Update channel moderation settings (Live's parsing, clamps and all) ──
+const OWNER_POLICY_KEYS = ['mods_can_edit_about', 'ip_approval_mode', 'uploads_mods_only', 'allow_anonymous', 'sounds_mods_only'];
+router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (req, res) => {
+ try {
+ const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);
+ if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
+ const settings = db.upsertChannelModerationSettings(req.channel.id, {
+ slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
+ ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
+ followers_only: req.body.followers_only !== undefined ? parseBoolean(req.body.followers_only, false) : undefined,
+ sub_only: req.body.sub_only !== undefined ? parseBoolean(req.body.sub_only, false) : undefined,
+ emote_only: req.body.emote_only,
+ allow_anonymous: req.body.allow_anonymous !== undefined ? parseBoolean(req.body.allow_anonymous, true) : undefined,
+ links_allowed: req.body.links_allowed !== undefined ? parseBoolean(req.body.links_allowed, true) : undefined,
+ gifs_enabled: req.body.gifs_enabled !== undefined ? parseBoolean(req.body.gifs_enabled, true) : undefined,
+ aggressive_filter: req.body.aggressive_filter !== undefined ? parseBoolean(req.body.aggressive_filter, false) : undefined,
+ account_age_gate_hours: req.body.account_age_gate_hours !== undefined ? Math.max(0, parseInt(req.body.account_age_gate_hours) || 0) : undefined,
+ caps_percentage_limit: req.body.caps_percentage_limit !== undefined ? Math.min(100, Math.max(0, parseInt(req.body.caps_percentage_limit) || 70)) : undefined,
+ max_message_length: req.body.max_message_length !== undefined
+ ? Math.min(permissions.isAdmin(req.user) ? 6000 : 4000, Math.max(1, parseInt(req.body.max_message_length) || 500)) : undefined,
+ tts_max_length: req.body.tts_max_length !== undefined ? Math.min(1200, Math.max(10, parseInt(req.body.tts_max_length) || 200)) : undefined,
+ slur_filter_enabled: req.body.slur_filter_enabled !== undefined ? parseBoolean(req.body.slur_filter_enabled, false) : undefined,
+ slur_filter_use_builtin: req.body.slur_filter_use_builtin !== undefined ? parseBoolean(req.body.slur_filter_use_builtin, true) : undefined,
+ slur_filter_terms: req.body.slur_filter_terms !== undefined ? String(req.body.slur_filter_terms || '').slice(0, 4000) : undefined,
+ slur_filter_regexes: req.body.slur_filter_regexes !== undefined ? String(req.body.slur_filter_regexes || '').slice(0, 8000) : undefined,
+ slur_filter_nudge_message: req.body.slur_filter_nudge_message !== undefined ? String(req.body.slur_filter_nudge_message || '').slice(0, 800) : undefined,
+ slur_filter_disabled_categories: req.body.slur_filter_disabled_categories !== undefined
+ ? (() => {
+ const VALID_CATS = new Set(['n_word', 'antisemitic', 'homophobic', 'racial']);
+ let val = req.body.slur_filter_disabled_categories;
+ if (typeof val === 'string') { try { val = JSON.parse(val); } catch { val = []; } }
+ if (!Array.isArray(val)) return '[]';
+ return JSON.stringify(val.filter((k) => typeof k === 'string' && VALID_CATS.has(k)));
+ })() : undefined,
+ ip_approval_mode: req.body.ip_approval_mode !== undefined ? parseBoolean(req.body.ip_approval_mode, false) : undefined,
+ soundboard_enabled: req.body.soundboard_enabled !== undefined ? parseBoolean(req.body.soundboard_enabled, true) : undefined,
+ soundboard_allow_pitch: req.body.soundboard_allow_pitch !== undefined ? parseBoolean(req.body.soundboard_allow_pitch, true) : undefined,
+ soundboard_allow_speed: req.body.soundboard_allow_speed !== undefined ? parseBoolean(req.body.soundboard_allow_speed, true) : undefined,
+ soundboard_banned_ids: req.body.soundboard_banned_ids !== undefined
+ ? (() => {
+ const raw = String(req.body.soundboard_banned_ids || '');
+ const ids = raw.split(/[\s,\n\r]+/).map((s) => {
+ const m = s.trim().match(/101soundboards\.com\/sounds\/(\d+)/i);
+ if (m) return m[1];
+ if (/^\d{2,}$/.test(s.trim())) return s.trim();
+ return null;
+ }).filter(Boolean);
+ return [...new Set(ids)].slice(0, 200).join(',');
+ })() : undefined,
+ viewer_auto_delete_enabled: req.body.viewer_auto_delete_enabled !== undefined ? parseBoolean(req.body.viewer_auto_delete_enabled, true) : undefined,
+ viewer_delete_all_enabled: req.body.viewer_delete_all_enabled !== undefined ? parseBoolean(req.body.viewer_delete_all_enabled, true) : undefined,
+ custom_emotes_enabled: req.body.custom_emotes_enabled !== undefined ? parseBoolean(req.body.custom_emotes_enabled, true) : undefined,
+ custom_sounds_enabled: req.body.custom_sounds_enabled !== undefined ? parseBoolean(req.body.custom_sounds_enabled, true) : undefined,
+ uploads_mods_only: req.body.uploads_mods_only !== undefined ? parseBoolean(req.body.uploads_mods_only, false) : undefined,
+ mods_can_edit_about: req.body.mods_can_edit_about !== undefined ? parseBoolean(req.body.mods_can_edit_about, false) : undefined,
+ max_sound_seconds: req.body.max_sound_seconds !== undefined ? Math.min(30, Math.max(1, parseInt(req.body.max_sound_seconds) || 10)) : undefined,
+ emote_scale: req.body.emote_scale !== undefined ? Math.min(300, Math.max(50, parseInt(req.body.emote_scale) || 100)) : undefined,
+ emote_size_min: req.body.emote_size_min !== undefined ? Math.min(200, Math.max(25, parseInt(req.body.emote_size_min) || 50)) : undefined,
+ emote_size_max: req.body.emote_size_max !== undefined ? Math.min(400, Math.max(50, parseInt(req.body.emote_size_max) || 200)) : undefined,
+ sounds_mods_only: req.body.sounds_mods_only !== undefined ? (req.body.sounds_mods_only ? 1 : 0) : undefined,
+ sound_min_speed: req.body.sound_min_speed !== undefined ? Math.min(1, Math.max(0.1, parseFloat(req.body.sound_min_speed) || 0.5)) : undefined,
+ sound_max_speed: req.body.sound_max_speed !== undefined ? Math.min(5, Math.max(1, parseFloat(req.body.sound_max_speed) || 3.0)) : undefined,
+ sound_min_pitch_cents: req.body.sound_min_pitch_cents !== undefined ? Math.min(0, Math.max(-2400, parseInt(req.body.sound_min_pitch_cents) || -1200)) : undefined,
+ sound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,
+ });
+
+ db.logModerationAction({
+ scope_type: 'channel',
+ scope_id: req.channel.id,
+ actor_user_id: req.user.id,
+ action_type: 'channel_settings_update',
+ details: settings.value,
+ });
+ res.json({ settings: settings.value });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ console.error('[ChannelMod] Failed to update moderation settings:', err.message);
+ res.status(500).json({ error: 'Failed to update moderation settings' });
+ }
+});
+
+// ── View moderation action log ───────────────────────────────
+router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, (req, res) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
+ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ res.json({ actions: db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load channel moderation log' });
+ }
+});
+
+// ── Search channel chat messages ─────────────────────────────
+router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, (req, res) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
+ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ const q = (req.query.q || '').trim();
+ const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;
+ res.json(db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to search channel chat' });
+ }
+});
+
+// ── Delete a channel chat message ────────────────────────────
+router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, (req, res) => {
+ try {
+ const messageId = parseInt(req.params.messageId, 10);
+ const message = db.getChatMessageById(messageId);
+ if (!message) return res.status(404).json({ error: 'Message not found' });
+
+ if (!permissions.isGlobalModOrAbove(req.user)) {
+ let inScope = false;
+ if (message.stream_id) {
+ const stream = ctx.getStreamById(message.stream_id);
+ inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));
+ } else if (message.channel_user_id) {
+ inScope = message.channel_user_id === req.channel.user_id;
+ }
+ if (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });
+ }
+
+ db.deleteChatMessage(messageId, req.user.id);
+
+ const delPayload = { type: 'delete-messages', ids: [messageId] };
+ const chanUid = message.channel_user_id || (message.stream_id ? (ctx.getStreamById(message.stream_id) || {}).user_id || null : null);
+ try {
+ const chatServer = require('./chat-server');
+ if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
+ if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
+ else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);
+ else chatServer.broadcastGlobal(delPayload);
+ } catch { /* chat server may not be initialized */ }
+
+ db.logModerationAction({
+ scope_type: 'channel',
+ scope_id: req.channel.id,
+ actor_user_id: req.user.id,
+ target_user_id: message.user_id,
+ action_type: 'channel_message_delete',
+ details: { message_id: messageId, stream_id: message.stream_id },
+ });
+ res.json({ message: 'Message deleted', ids: [messageId] });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ res.status(500).json({ error: 'Failed to delete message' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,673 @@
@@ -0,0 +1,673 @@
+/**
+ * OpenVibe.Chat — Emote API (moved from OpenVibe.Live server/emotes/routes.js, plan T3 step 1)
+ *
+ * Same paths, bodies, responses, limits and error messages as Live's /api/emotes, so Live's
+ * frontend (public/js/emotes.js, chat.js, channel-uploads.js, obs/chat.html) works unchanged once
+ * nginx sends /api/emotes here. The rows live in Chat's own emotes table.
+ *
+ * GET /api/emotes/global - Global custom emotes
+ * GET /api/emotes/channel/:userId - Channel emotes for a streamer
+ * GET /api/emotes/mine - My emotes (auth required)
+ * POST /api/emotes - Upload a custom emote (auth required)
+ * PATCH /api/emotes/:id - Rename / resize an emote (auth required)
+ * DELETE /api/emotes/:id - Delete an emote (auth required)
+ * GET /api/emotes/file/:filename - Legacy image file (no local files: 404)
+ * GET /api/emotes/{ffz,bttv,7tv} - Cached provider proxies
+ * GET /api/emotes/defaults - Built-in emote collection
+ * GET/PUT /api/emotes/sources - Per-channel emote source preferences (auth)
+ * GET /api/emotes/search - Search FFZ emotes
+ * GET /api/emotes/all/:streamId - All emotes available in a stream context
+ *
+ * Uploaded bytes go to OpenVibe.Media with Chat's own service token; the row stores media_url and
+ * media_asset_id, and responses use media_url (existing Live rows already carry one). Writes answer
+ * 503 { ok: false, error: 'not yet' } while Live is still the table's writer (table_authority live).
+ */
+'use strict';
+
+const express = require('express');
+const path = require('path');
+const multer = require('multer');
+const db = require('../db/database');
+const ctx = require('../live-context');
+const config = require('../config');
+const media = require('../media/client');
+const { requireAuth, optionalAuth } = require('../auth/auth');
+const permissions = require('../auth/permissions');
+
+const router = express.Router();
+
+const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/avif': '.avif' };
+const ALLOWED_MIME = ['image/png', 'image/gif', 'image/webp', 'image/jpeg', 'image/avif'];
+
+/** The image type a buffer's first bytes say it is (PNG, GIF, WebP, JPEG, AVIF); null for anything else. */
+function sniffImage(buf) {
+ if (!buf || buf.length < 12) return null;
+ if (buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') return 'image/png';
+ if (buf.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';
+ if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
+ if (buf.toString('latin1', 4, 8) === 'ftyp' && /^avi[fs]$/.test(buf.toString('latin1', 8, 12))) return 'image/avif';
+ return null;
+}
+
+// Bytes are held in memory only long enough to reach Media; nothing is written to Chat's disk.
+const emoteUpload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: config.emotes.maxSizeKb * 1024 },
+ fileFilter: (req, file, cb) => {
+ if (ALLOWED_MIME.includes(file.mimetype)) return cb(null, true);
+ cb(new Error('Only PNG, GIF, WebP, AVIF, and JPEG images are allowed'));
+ },
+});
+
+// Per-user emote-upload rate limit (sliding window), Live's values: 40 uploads / 15 min per account.
+const EMOTE_UPLOAD_WINDOW_MS = 15 * 60 * 1000;
+const EMOTE_UPLOAD_MAX = 40;
+const _emoteUploadTimes = new Map();
+function emoteUploadRateLimited(userId) {
+ const now = Date.now();
+ const arr = (_emoteUploadTimes.get(userId) || []).filter((t) => now - t < EMOTE_UPLOAD_WINDOW_MS);
+ if (arr.length >= EMOTE_UPLOAD_MAX) { _emoteUploadTimes.set(userId, arr); return true; }
+ arr.push(now);
+ _emoteUploadTimes.set(userId, arr);
+ return false;
+}
+
+/** A staged-table write while Live is still the authority: 503, never a write. */
+function notYet(res, err) {
+ if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }
+ return false;
+}
+const fileUrl = (e) => e.media_url || `/api/emotes/file/${path.basename(e.url)}`;
+
+// ══════════════════════════════════════════════════════════════
+// FFZ / BTTV / 7TV CACHE
+// ══════════════════════════════════════════════════════════════
+
+let ffzCache = { data: null, ts: 0 };
+let bttvCache = { data: null, ts: 0 };
+let sevenTvCache = { data: null, ts: 0 };
+const ffzSearchCache = new Map();
+
+async function fetchJSON(url) {
+ const res = await fetch(url, {
+ headers: { 'User-Agent': 'OpenVibe.Chat/1.0 (emote-proxy)' },
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+function parseFfzSets(data) {
+ const emotes = [];
+ const sets = data.sets || {};
+ for (const setId of Object.keys(sets)) {
+ const set = sets[setId];
+ for (const e of (set.emoticons || [])) {
+ if (e.hidden) continue;
+ if (e.modifier) continue;
+ emotes.push({
+ id: `ffz-${e.id}`,
+ code: e.name,
+ url: e.urls['2'] || e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
+ url_1x: e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
+ url_2x: e.urls['2'] || e.urls['1'],
+ url_4x: e.urls['4'] || e.urls['2'] || e.urls['1'],
+ width: e.width || 28,
+ height: e.height || 28,
+ animated: false,
+ source: 'ffz',
+ });
+ }
+ }
+ return emotes;
+}
+
+function parseBttvEmotes(data) {
+ return (Array.isArray(data) ? data : []).map((e) => ({
+ id: `bttv-${e.id}`,
+ code: e.code,
+ url: `https://cdn.betterttv.net/emote/${e.id}/2x`,
+ url_1x: `https://cdn.betterttv.net/emote/${e.id}/1x`,
+ url_2x: `https://cdn.betterttv.net/emote/${e.id}/2x`,
+ url_4x: `https://cdn.betterttv.net/emote/${e.id}/3x`,
+ width: 28,
+ height: 28,
+ animated: e.imageType === 'gif' || e.animated === true,
+ source: 'bttv',
+ }));
+}
+
+function parse7tvEmotes(data) {
+ const emotes = data?.emotes || [];
+ return emotes.map((e) => {
+ const host = e.data?.host;
+ const baseUrl = host?.url || '';
+ const files = host?.files || [];
+ const f2x = files.find((f) => f.name === '2x.webp') || files.find((f) => f.name === '2x.avif') || files.find((f) => f.name === '1x.webp') || files[0];
+ const f1x = files.find((f) => f.name === '1x.webp') || files.find((f) => f.name === '1x.avif') || files[0];
+ const f4x = files.find((f) => f.name === '4x.webp') || files.find((f) => f.name === '3x.webp') || f2x;
+ return {
+ id: `7tv-${e.id}`,
+ code: e.name,
+ url: f2x ? `https:${baseUrl}/${f2x.name}` : '',
+ url_1x: f1x ? `https:${baseUrl}/${f1x.name}` : '',
+ url_2x: f2x ? `https:${baseUrl}/${f2x.name}` : '',
+ url_4x: f4x ? `https:${baseUrl}/${f4x.name}` : '',
+ width: f2x?.width || 28,
+ height: f2x?.height || 28,
+ animated: !!(e.data?.animated),
+ source: '7tv',
+ };
+ }).filter((e) => e.url);
+}
+
+// Live's built-in emote collection (popular community emotes, provider CDN URLs).
+const DEFAULT_EMOTES = [
+ { id: 'def-lul', code: 'LUL', url: 'https://cdn.frankerfacez.com/emote/38010/2', source: 'defaults', animated: false },
+ { id: 'def-lulw', code: 'LULW', url: 'https://cdn.frankerfacez.com/emote/139407/2', source: 'defaults', animated: false },
+ { id: 'def-kekw', code: 'KEKW', url: 'https://cdn.frankerfacez.com/emote/381875/2', source: 'defaults', animated: false },
+ { id: 'def-omegalul', code: 'OMEGALUL', url: 'https://cdn.frankerfacez.com/emote/128054/2', source: 'defaults', animated: false },
+ { id: 'def-pepehands', code: 'PepeHands', url: 'https://cdn.frankerfacez.com/emote/231552/2', source: 'defaults', animated: false },
+ { id: 'def-copium', code: 'Copium', url: 'https://cdn.frankerfacez.com/emote/540942/2', source: 'defaults', animated: false },
+ { id: 'def-monkas', code: 'monkaS', url: 'https://cdn.frankerfacez.com/emote/130762/2', source: 'defaults', animated: false },
+ { id: 'def-ez', code: 'EZ', url: 'https://cdn.frankerfacez.com/emote/425688/2', source: 'defaults', animated: false },
+ { id: 'def-pog', code: 'Pog', url: 'https://cdn.frankerfacez.com/emote/210748/2', source: 'defaults', animated: false },
+ { id: 'def-poggers', code: 'Poggers', url: 'https://cdn.frankerfacez.com/emote/214681/2', source: 'defaults', animated: false },
+ { id: 'def-pogu', code: 'PogU', url: 'https://cdn.betterttv.net/emote/5e4e7a1f08b4447d56a92967/2x', source: 'defaults', animated: false },
+ { id: 'def-peped', code: 'PepeD', url: 'https://cdn.betterttv.net/emote/5b1740221c5a6065a7bad4b5/2x', source: 'defaults', animated: true },
+ { id: 'def-catjam', code: 'catJAM', url: 'https://cdn.betterttv.net/emote/5f1b0186cf6d2144653d2970/2x', source: 'defaults', animated: true },
+ { id: 'def-sadge', code: 'Sadge', url: 'https://cdn.betterttv.net/emote/5e0fa9d40550d42106b8a489/2x', source: 'defaults', animated: false },
+ { id: 'def-pepega', code: 'Pepega', url: 'https://cdn.betterttv.net/emote/5aca62163e290877a25481ad/2x', source: 'defaults', animated: false },
+ { id: 'def-5head', code: '5Head', url: 'https://cdn.betterttv.net/emote/5d6096974932b21d9c332904/2x', source: 'defaults', animated: false },
+ { id: 'def-widehard', code: 'widepeepohappy', url: 'https://cdn.betterttv.net/emote/5e1a76dd8af14b5f1b438c04/2x', source: 'defaults', animated: false },
+ { id: 'def-monkaw', code: 'monkaW', url: 'https://cdn.betterttv.net/emote/59ca6551b27c823d5b1fd872/2x', source: 'defaults', animated: false },
+ { id: 'def-pepelaugh', code: 'PepeLaugh', url: 'https://cdn.betterttv.net/emote/5c548025009a2e73916b3a37/2x', source: 'defaults', animated: false },
+ { id: 'def-modtime', code: 'modCheck', url: 'https://cdn.betterttv.net/emote/5d7eefb7c0652668c9e4d394/2x', source: 'defaults', animated: true },
+ { id: 'def-clap', code: 'CLAP', url: 'https://cdn.betterttv.net/emote/55b6f480e66682f576dd94f5/2x', source: 'defaults', animated: false },
+ { id: 'def-gg', code: 'GGEZ', url: 'https://cdn.frankerfacez.com/emote/703645/2', source: 'defaults', animated: false },
+ { id: 'def-based', code: 'BASED', url: 'https://cdn.frankerfacez.com/emote/768564/2', source: 'defaults', animated: false },
+ { id: 'def-peepo', code: 'peepoHappy', url: 'https://cdn.betterttv.net/emote/5a16ee718c22a247ead62d4a/2x', source: 'defaults', animated: false },
+ { id: 'def-mods', code: 'MODS', url: 'https://cdn.betterttv.net/emote/603451b77c74605395f3295d/2x', source: 'defaults', animated: true },
+].map((e) => ({ ...e, width: 28, height: 28 }));
+
+// ══════════════════════════════════════════════════════════════
+// ROUTES
+// ══════════════════════════════════════════════════════════════
+
+router.get('/global', (req, res) => {
+ try {
+ const emotes = db.getGlobalEmotes().map((e) => ({
+ id: `custom-${e.id}`,
+ code: e.code,
+ url: fileUrl(e),
+ animated: !!e.animated,
+ width: e.width,
+ height: e.height,
+ source: 'custom',
+ owner: e.username,
+ }));
+ res.json({ emotes });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load global emotes' });
+ }
+});
+
+router.get('/channel/:userId', (req, res) => {
+ try {
+ const userId = parseInt(req.params.userId);
+ const emotes = db.getChannelEmotes(userId).map((e) => ({
+ id: `custom-${e.id}`,
+ emote_id: e.id,
+ code: e.code,
+ url: fileUrl(e),
+ animated: !!e.animated,
+ width: e.width,
+ height: e.height,
+ source: 'channel',
+ owner: e.username,
+ uploader: e.uploader_display_name || e.uploader_username || e.username,
+ uploader_id: e.user_id,
+ }));
+ res.json({ emotes });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load channel emotes' });
+ }
+});
+
+router.get('/mine', requireAuth, (req, res) => {
+ try {
+ const emotes = db.getEmotesByUser(req.user.id).map((e) => ({
+ id: e.id,
+ code: e.code,
+ url: fileUrl(e),
+ animated: !!e.animated,
+ width: e.width,
+ height: e.height,
+ is_global: !!e.is_global,
+ created_at: e.created_at,
+ }));
+ res.json({ emotes, count: db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load emotes' });
+ }
+});
+
+router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: 'No image file uploaded' });
+
+ if (emoteUploadRateLimited(req.user.id)) {
+ return res.status(429).json({ error: 'Too many emote uploads — please wait a bit before uploading more.' });
+ }
+
+ const code = (req.body.code || '').trim();
+ if (!code || code.length < 2 || code.length > 32) {
+ return res.status(400).json({ error: 'Emote code must be 2-32 characters' });
+ }
+ if (!/^[a-zA-Z0-9_]+$/.test(code)) {
+ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
+ }
+
+ // Optional target channel: channel_id = the streamer's user id; stream_id resolves to its owner.
+ let channelOwnerId = parseInt(req.body.channel_id) || null;
+ if (!channelOwnerId && req.body.stream_id) {
+ channelOwnerId = (await ctx.ensureStream(parseInt(req.body.stream_id)))?.user_id || null;
+ }
+ const isChannelUpload = channelOwnerId && channelOwnerId !== req.user.id;
+
+ if (isChannelUpload) {
+ const channel = await ctx.ensureChannelForUser(channelOwnerId);
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ await ctx.ensurePolicy(channel.id);
+ const settings = ctx.getChannelModerationSettings(channel.id);
+ const isMod = permissions.canModerateChannel(req.user, channel.id);
+ if (!settings.custom_emotes_enabled && !isMod) {
+ return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
+ }
+ if (settings.uploads_mods_only && !isMod) {
+ return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });
+ }
+ if (db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {
+ return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });
+ }
+ if (db.getChannelEmoteByCode(channelOwnerId, code)) {
+ return res.status(409).json({ error: `This channel already has an emote named "${code}".` });
+ }
+ } else {
+ if (db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {
+ return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });
+ }
+ const existing = db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);
+ if (existing) return res.status(409).json({ error: `You already have an emote named "${code}"` });
+ }
+
+ // The declared type must match the bytes: Media serves the object with this type.
+ if (sniffImage(req.file.buffer) !== req.file.mimetype) return res.status(400).json({ error: 'That file is not the image type it claims to be' });
+ const animated = req.file.mimetype === 'image/gif' || req.file.mimetype === 'image/webp';
+ const isGlobal = req.body.is_global === 'true' && permissions.can(req.user, 'staff.assets.manage');
+
+ let sizeMin = 25, sizeMax = 400;
+ if (channelOwnerId) {
+ const channel = await ctx.ensureChannelForUser(channelOwnerId);
+ const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;
+ if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
+ }
+ const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
+
+ const uploaded = await media.uploadObject(req.file.buffer, { filename: req.file.originalname, mimeType: req.file.mimetype });
+ const result = db.createEmote({
+ user_id: req.user.id,
+ code,
+ url: uploaded.url,
+ animated,
+ width: parseInt(req.body.width) || 28,
+ height: parseInt(req.body.height) || 28,
+ is_global: isGlobal,
+ channel_owner_id: isChannelUpload ? channelOwnerId : null,
+ size,
+ media_url: uploaded.url,
+ media_asset_id: uploaded.id,
+ });
+
+ if (isChannelUpload) {
+ try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }
+ }
+ res.json({
+ emote: {
+ id: result.value.lastInsertRowid,
+ code,
+ url: uploaded.url,
+ animated,
+ channel_id: isChannelUpload ? channelOwnerId : undefined,
+ },
+ });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ console.error('[Emotes] Upload error:', err);
+ if (err && /UNIQUE constraint/i.test(err.message || '')) {
+ const code = String((req.body && req.body.code) || '').trim();
+ return res.status(409).json({ error: `This channel already has an emote named "${code}" — pick a different code for this channel.` });
+ }
+ res.status(500).json({ error: 'Failed to upload emote' });
+ }
+});
+
+// Multer error handler (Live's status codes and messages).
+router.use((err, req, res, next) => {
+ if (err.code === 'LIMIT_FILE_SIZE') {
+ return res.status(400).json({ error: `File too large (max ${config.emotes.maxSizeKb}KB)` });
+ }
+ if (err.message && err.message.includes('Only')) {
+ return res.status(400).json({ error: err.message });
+ }
+ console.error('[Emotes] Middleware error:', err);
+ res.status(500).json({ error: 'Emote upload failed' });
+});
+
+router.patch('/:id', requireAuth, async (req, res) => {
+ try {
+ const emote = db.getEmoteById(req.params.id);
+ if (!emote) return res.status(404).json({ error: 'Emote not found' });
+ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
+ if (!allowed) {
+ const ownerId = emote.channel_owner_id || emote.user_id;
+ const channel = await ctx.ensureChannelForUser(ownerId);
+ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ }
+ if (!allowed) return res.status(403).json({ error: 'Not your emote' });
+
+ const patch = {};
+ if (req.body.code !== undefined) {
+ const code = String(req.body.code || '').trim();
+ if (!code || code.length < 2 || code.length > 32) {
+ return res.status(400).json({ error: 'Emote code must be 2-32 characters' });
+ }
+ if (!/^[a-zA-Z0-9_]+$/.test(code)) {
+ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
+ }
+ const scopeOwner = emote.channel_owner_id || emote.user_id;
+ const clash = (db.getChannelEmotes(scopeOwner) || [])
+ .find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());
+ if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });
+ patch.code = code;
+ }
+ if (req.body.size !== undefined) {
+ let sizeMin = 25, sizeMax = 400;
+ const ownerId = emote.channel_owner_id || emote.user_id;
+ const channel = await ctx.ensureChannelForUser(ownerId);
+ const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;
+ if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
+ patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
+ }
+ if (patch.code === undefined && patch.size === undefined) {
+ return res.status(400).json({ error: 'Nothing to change' });
+ }
+
+ db.updateEmote(emote.id, patch);
+ if (patch.code && patch.code !== emote.code) {
+ try { db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }
+ }
+ if (emote.channel_owner_id) {
+ try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
+ }
+ res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ res.status(500).json({ error: 'Failed to update emote' });
+ }
+});
+
+router.delete('/:id', requireAuth, async (req, res) => {
+ try {
+ const emote = db.getEmoteById(req.params.id);
+ if (!emote) return res.status(404).json({ error: 'Emote not found' });
+ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
+ if (!allowed) {
+ const ownerId = emote.channel_owner_id || emote.user_id;
+ const channel = await ctx.ensureChannelForUser(ownerId);
+ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ }
+ if (!allowed) return res.status(403).json({ error: 'Not your emote' });
+
+ db.deleteEmote(emote.id);
+ await media.deleteObject(emote.media_asset_id);
+ if (emote.channel_owner_id) {
+ try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
+ }
+ res.json({ message: 'Emote deleted' });
+ } catch (err) {
+ if (notYet(res, err)) return;
+ res.status(500).json({ error: 'Failed to delete emote' });
+ }
+});
+
+// Legacy image files: Chat keeps none. Bytes live on OpenVibe.Media now; a row that still lacks a
+// media_url has no file here either, so this answers 404 for everything (Live's frontend only uses
+// this path for rows without media_url, which are served from the provider/Media URLs instead).
+router.get('/file/:filename', (req, res) => {
+ res.status(404).json({ error: 'Emote file not found' });
+});
+
+// ── FFZ / BTTV / 7TV (cached proxies) ────────────────────────
+router.get('/ffz', async (req, res) => {
+ try {
+ const now = Date.now();
+ if (ffzCache.data && (now - ffzCache.ts) < config.emotes.ffzCacheTtl * 1000) {
+ return res.json({ emotes: ffzCache.data, cached: true });
+ }
+ const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
+ const emotes = parseFfzSets(data);
+ ffzCache = { data: emotes, ts: now };
+ res.json({ emotes, cached: false });
+ } catch (err) {
+ if (ffzCache.data) return res.json({ emotes: ffzCache.data, cached: true, stale: true });
+ res.status(502).json({ error: 'Failed to fetch FFZ emotes', emotes: [] });
+ }
+});
+
+router.get('/bttv', async (req, res) => {
+ try {
+ const now = Date.now();
+ if (bttvCache.data && (now - bttvCache.ts) < config.emotes.bttvCacheTtl * 1000) {
+ return res.json({ emotes: bttvCache.data, cached: true });
+ }
+ const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
+ const emotes = parseBttvEmotes(data);
+ bttvCache = { data: emotes, ts: now };
+ res.json({ emotes, cached: false });
+ } catch (err) {
+ if (bttvCache.data) return res.json({ emotes: bttvCache.data, cached: true, stale: true });
+ res.status(502).json({ error: 'Failed to fetch BTTV emotes', emotes: [] });
+ }
+});
+
+router.get('/7tv', async (req, res) => {
+ try {
+ const now = Date.now();
+ if (sevenTvCache.data && (now - sevenTvCache.ts) < config.emotes.sevenTvCacheTtl * 1000) {
+ return res.json({ emotes: sevenTvCache.data, cached: true });
+ }
+ const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');
+ const emotes = parse7tvEmotes(data);
+ sevenTvCache = { data: emotes, ts: now };
+ res.json({ emotes, cached: false });
+ } catch (err) {
+ if (sevenTvCache.data) return res.json({ emotes: sevenTvCache.data, cached: true, stale: true });
+ res.status(502).json({ error: 'Failed to fetch 7TV emotes', emotes: [] });
+ }
+});
+
+router.get('/defaults', (req, res) => {
+ res.json({ emotes: DEFAULT_EMOTES });
+});
+
+// ── Emote source preferences (stored on Live's channels row) ──
+router.get('/sources', requireAuth, async (req, res) => {
+ try {
+ const channel = await ctx.ensureChannelForUser(req.user.id);
+ res.json({ sources: ctx.emoteSources(channel && channel.emote_sources) });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load emote sources' });
+ }
+});
+
+router.put('/sources', requireAuth, async (req, res) => {
+ try {
+ const sources = {};
+ for (const key of ['defaults', 'custom', 'ffz', 'bttv', '7tv']) {
+ sources[key] = req.body[key] !== false && req.body[key] !== 'false';
+ }
+ await ctx.setChannelEmoteSources(req.user.id, sources);
+ res.json({ sources, message: 'Emote sources updated' });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to update emote sources' });
+ }
+});
+
+// ── Search FFZ emotes ────────────────────────────────────────
+router.get('/search', async (req, res) => {
+ try {
+ const q = (req.query.q || '').trim();
+ if (!q || q.length < 2) return res.json({ emotes: [] });
+
+ const cacheKey = q.toLowerCase();
+ const cached = ffzSearchCache.get(cacheKey);
+ const now = Date.now();
+ if (cached && (now - cached.ts) < config.emotes.ffzCacheTtl * 1000) {
+ return res.json({ emotes: cached.data, cached: true });
+ }
+
+ const data = await fetchJSON(`https://api.frankerfacez.com/v1/emotes?q=${encodeURIComponent(q)}&per_page=50&sort=count-desc`);
+ const emotes = (data.emoticons || []).filter((e) => !e.hidden && !e.modifier).map((e) => ({
+ id: `ffz-${e.id}`,
+ code: e.name,
+ url: e.urls?.['2'] || e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
+ url_1x: e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
+ url_2x: e.urls?.['2'] || e.urls?.['1'],
+ animated: false,
+ source: 'ffz',
+ usage_count: e.usage_count || 0,
+ }));
+
+ ffzSearchCache.set(cacheKey, { data: emotes, ts: now });
+ if (ffzSearchCache.size > 200) {
+ const oldest = [...ffzSearchCache.entries()].sort((a, b) => a[1].ts - b[1].ts).slice(0, 100);
+ for (const [key] of oldest) ffzSearchCache.delete(key);
+ }
+ res.json({ emotes, cached: false });
+ } catch (err) {
+ res.status(502).json({ error: 'Search failed', emotes: [] });
+ }
+});
+
+// ── All emotes for a stream context ──────────────────────────
+router.get('/all/:streamId', optionalAuth, async (req, res) => {
+ try {
+ const streamId = parseInt(req.params.streamId);
+ const stream = streamId ? await ctx.ensureStream(streamId) : null;
+ let streamUserId = stream ? stream.user_id : null;
+ if (!streamUserId && req.query.channel) {
+ try { const u = await ctx.ensureUserByUsername(String(req.query.channel)); if (u) streamUserId = u.id; } catch { /* */ }
+ }
+
+ let sources = { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true };
+ let channel = null;
+ if (streamUserId) {
+ channel = await ctx.ensureChannelForUser(streamUserId);
+ if (channel && channel.emote_sources) sources = ctx.emoteSources(channel.emote_sources);
+ }
+
+ const defaults = sources.defaults !== false ? DEFAULT_EMOTES : [];
+
+ let globalCustom = [];
+ let channelEmotes = [];
+ if (sources.custom !== false) {
+ globalCustom = db.getGlobalEmotes().map((e) => ({
+ id: `custom-${e.id}`,
+ code: e.code,
+ url: fileUrl(e),
+ animated: !!e.animated,
+ width: e.width,
+ height: e.height,
+ size: e.size || 100,
+ source: 'custom',
+ owner: e.username,
+ }));
+
+ if (streamUserId) {
+ channelEmotes = db.getChannelEmotes(streamUserId).filter((e) => !e.is_global).map((e) => ({
+ id: `custom-${e.id}`,
+ emote_id: e.id,
+ code: e.code,
+ url: fileUrl(e),
+ animated: !!e.animated,
+ width: e.width,
+ height: e.height,
+ size: e.size || 100,
+ source: 'channel',
+ owner: e.username,
+ uploader: e.uploader_display_name || e.uploader_username || e.username,
+ uploader_id: e.user_id,
+ }));
+ }
+ }
+
+ let ffzEmotes = [];
+ if (sources.ffz !== false) {
+ ffzEmotes = ffzCache.data || [];
+ if (!ffzEmotes.length) {
+ try {
+ const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
+ ffzEmotes = parseFfzSets(data);
+ ffzCache = { data: ffzEmotes, ts: Date.now() };
+ } catch { /* use empty */ }
+ }
+ }
+
+ let bttvEmotes = [];
+ if (sources.bttv !== false) {
+ bttvEmotes = bttvCache.data || [];
+ if (!bttvEmotes.length) {
+ try {
+ const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
+ bttvEmotes = parseBttvEmotes(data);
+ bttvCache = { data: bttvEmotes, ts: Date.now() };
+ } catch { /* use empty */ }
+ }
+ }
+
+ let sevenTvEmotes = [];
+ if (sources['7tv'] !== false) {
+ sevenTvEmotes = sevenTvCache.data || [];
+ if (!sevenTvEmotes.length) {
+ try {
+ const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');
+ sevenTvEmotes = parse7tvEmotes(data);
+ sevenTvCache = { data: sevenTvEmotes, ts: Date.now() };
+ } catch { /* use empty */ }
+ }
+ }
+
+ let emoteScale = 100;
+ if (channel) {
+ const st = ctx.getChannelModerationSettings(channel.id);
+ if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;
+ }
+
+ res.json({
+ defaults,
+ channel: channelEmotes,
+ global: globalCustom,
+ ffz: ffzEmotes,
+ bttv: bttvEmotes,
+ '7tv': sevenTvEmotes,
+ sources,
+ emote_scale: emoteScale,
+ });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load emotes' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+/**
+ * OpenVibe.Chat — Live's internal read API for the chat tables Chat owns (plan T3).
+ *
+ * Live keeps only its own features; what it needs from the tables Chat now writes it asks here,
+ * loopback only, with a service token for audience openvibe.chat carrying `chat.moderation.read`:
+ *
+ * GET /internal/moderation/channels/:channelId { ok, settings, moderator_ids }
+ * GET /internal/moderation/users/:userId/channels { ok, channels: [{ channel_id, title, owner_user_id, owner_username }] }
+ * GET /internal/moderation/channels/:channelId/emote-count { ok, count }
+ *
+ * The answers are the contracts chat.channel-moderation-result@1, chat.moderated-channels-result@1
+ * and chat.emote-count-result@1. `settings` is every channel_moderation_settings column, with
+ * Live's defaults when the channel has no row. `channelId` is a channels.id for the settings and
+ * moderator reads; for the emote count it is the channel owner's Live user id (the emotes rows are
+ * keyed by the streamer), the same value Live's db.countChannelEmotes() takes.
+ */
+'use strict';
+
+const express = require('express');
+const db = require('../db/database');
+const ctx = require('../live-context');
+const serviceAuth = require('../net/service-auth');
+
+const router = express.Router();
+const guard = serviceAuth.guard('chat.moderation.read');
+
+router.get('/channels/:channelId', guard, (req, res) => {
+ try {
+ const channelId = parseInt(req.params.channelId, 10);
+ if (!channelId) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
+ const settings = db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);
+ const moderator_ids = db.getChannelModeratorIds(channelId);
+ res.json({ ok: true, settings, moderator_ids });
+ } catch (err) {
+ console.error('[Moderation] channel read:', err.message);
+ res.status(500).json({ ok: false, error: 'Failed to load channel moderation' });
+ }
+});
+
+router.get('/users/:userId/channels', guard, (req, res) => {
+ try {
+ const userId = parseInt(req.params.userId, 10);
+ if (!userId) return res.status(400).json({ ok: false, error: 'Invalid user ID' });
+ const channels = db.getChannelsByModerator(userId).map((c) => ({
+ channel_id: Number(c.channel_id),
+ title: c.title == null ? null : String(c.title),
+ owner_user_id: c.owner_user_id == null ? null : Number(c.owner_user_id),
+ owner_username: c.owner_username == null ? null : String(c.owner_username),
+ }));
+ res.json({ ok: true, channels });
+ } catch (err) {
+ console.error('[Moderation] moderated channels read:', err.message);
+ res.status(500).json({ ok: false, error: 'Failed to load moderated channels' });
+ }
+});
+
+router.get('/channels/:channelId/emote-count', guard, (req, res) => {
+ try {
+ const raw = parseInt(req.params.channelId, 10);
+ if (!raw) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
+ // A channels.id resolves to its owner's user id (the emotes' key); a user id is used as it is.
+ const ownerId = (ctx.getChannelById(raw) || {}).user_id || raw;
+ res.json({ ok: true, count: db.countChannelEmotes(ownerId) });
+ } catch (err) {
+ console.error('[Moderation] emote count:', err.message);
+ res.status(500).json({ ok: false, error: 'Failed to count emotes' });
+ }
+});
+
+module.exports = router;
@@ -140,6 +140,26 @@ module.exports = {
@@ -140,6 +140,26 @@ module.exports = {
ringTimeoutMs: int(process.env.CALL_RING_TIMEOUT_MS, 45_000),
ringTimeoutMs: int(process.env.CALL_RING_TIMEOUT_MS, 45_000),
},
},
+ // Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
+ // OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
+ emotes: {
+ maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
+ maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
+ maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
+ maxPerUploaderPerChannel: int(process.env.MAX_EMOTES_PER_UPLOADER_PER_CHANNEL, 30),
+ ffzCacheTtl: int(process.env.FFZ_CACHE_TTL, 3600),
+ bttvCacheTtl: int(process.env.BTTV_CACHE_TTL, 3600),
+ sevenTvCacheTtl: int(process.env.SEVENTV_CACHE_TTL, 3600),
+ },
+
+ // OpenVibe.Media — where emote image bytes live now (Media API v2 objects, namespace `chat`).
+ // Chat's own service token (client `chat`) needs media.object.upload and media.object.delete.
+ media: {
+ baseUrl: strip(process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
+ publicOrigin: strip(process.env.OV_MEDIA_PUBLIC_URL || 'https://openvibe.media'),
+ scope: 'media.object.upload media.object.delete',
+ },
+
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.
// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.
sounds: {
sounds: {
@@ -118,6 +118,7 @@ const ADDED_COLUMNS = [
@@ -118,6 +118,7 @@ const ADDED_COLUMNS = [
['emotes', 'media_asset_id', 'INTEGER'],
['emotes', 'media_asset_id', 'INTEGER'],
['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too
['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too
+ ['ctx_channels', 'emote_sources', 'TEXT'], // the channel's emote source switches (T3, Live's channels row)
];
];
function initDb({ captureMirror = false } = {}) {
function initDb({ captureMirror = false } = {}) {
@@ -846,6 +847,152 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
@@ -846,6 +847,152 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
[newCode || '', ownerId, oldCode]);
[newCode || '', ownerId, oldCode]);
}
}
+// ── The six staged tables: reads for the public APIs and Live's internal read API (T3) ──
+// Same SQL and shapes as Live's database.js helpers of the same name; usernames come from the
+// ctx_users projection instead of Live's users table.
+
+function isChannelModerator(userId, channelId) {
+ return !!get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);
+}
+
+/** The channel_moderation_settings row as it is, or null when the channel has none (defaults are Live's). */
+function getChannelModerationSettingsRow(channelId) {
+ return get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;
+}
+
+/** A channel's moderators, with names, oldest first (Live's getChannelModerators). */
+function getChannelModerators(channelId) {
+ return all(`
+ SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,
+ u.username, u.display_name, u.avatar_url,
+ a.username as added_by_username
+ FROM channel_moderators cm
+ LEFT JOIN ctx_users u ON cm.user_id = u.id
+ LEFT JOIN ctx_users a ON cm.added_by = a.id
+ WHERE cm.channel_id = ?
+ ORDER BY cm.created_at ASC, cm.id ASC
+ `, [channelId]);
+}
+
+/** The channel's moderator user ids, oldest first (Live's policy answer carries the same list). */
+function getChannelModeratorIds(channelId) {
+ return all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId]).map((r) => r.user_id);
+}
+
+/**
+ * The channels a user moderates (Live's getChannelsByModerator). Channel title and owner come from
+ * the ctx_channels / ctx_users projections; the extra `id` alias is what the dashboard reads.
+ */
+function getChannelsByModerator(userId) {
+ return all(`
+ SELECT cm.channel_id, cm.channel_id AS id, c.title,
+ c.user_id AS user_id, c.user_id AS owner_user_id, u.username AS owner_username
+ FROM channel_moderators cm
+ LEFT JOIN ctx_channels c ON cm.channel_id = c.id
+ LEFT JOIN ctx_users u ON c.user_id = u.id
+ WHERE cm.user_id = ?
+ ORDER BY cm.created_at ASC, cm.id ASC
+ `, [userId]);
+}
+
+/** Custom emotes marked global (Live's getGlobalEmotes). */
+function getGlobalEmotes() {
+ return all(`SELECT e.*, u.username FROM emotes e
+ LEFT JOIN ctx_users u ON e.user_id = u.id
+ WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code`);
+}
+
+/** A channel's emotes: ones targeted at the owner, plus the owner's own legacy uploads. */
+function getChannelEmotes(userId) {
+ return all(
+ `SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name
+ FROM emotes e
+ LEFT JOIN ctx_users u ON e.user_id = u.id
+ LEFT JOIN ctx_users up ON e.user_id = up.id
+ WHERE ((e.channel_owner_id = ?) OR (e.channel_owner_id IS NULL AND e.user_id = ?))
+ AND e.is_approved = 1
+ ORDER BY code`,
+ [userId, userId]
+ );
+}
+
+/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */
+function countChannelEmotes(ownerId) {
+ return get(
+ 'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',
+ [ownerId, ownerId]
+ )?.count || 0;
+}
+
+/** The channel's emote with this code, any uploader (Live's getChannelEmoteByCode). */
+function getChannelEmoteByCode(ownerId, code) {
+ return get(
+ `SELECT * FROM emotes
+ WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
+ LIMIT 1`,
+ [code, ownerId, ownerId]
+ );
+}
+
+/** One emote by id, with its uploader's name (Live's getEmoteById). */
+function getEmoteById(id) {
+ return get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);
+}
+
+/** A user's own emotes (Live's getEmotesByUser). */
+function getEmotesByUser(userId) {
+ return all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);
+}
+
+/** Moderation actions with actor/target names (Live's getModerationActions). */
+function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {
+ const conditions = [];
+ const params = [];
+ const st = scopeType || scope_type;
+ const si = scopeId || scope_id;
+ if (st) { conditions.push('ma.scope_type = ?'); params.push(st); }
+ if (si) { conditions.push('ma.scope_id = ?'); params.push(si); }
+ if (actor_user_id) { conditions.push('ma.actor_user_id = ?'); params.push(actor_user_id); }
+ if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
+ params.push(limit, offset);
+ return all(`
+ SELECT ma.*, actor.username AS actor_username, target.username AS target_username
+ FROM moderation_actions ma
+ LEFT JOIN ctx_users actor ON ma.actor_user_id = actor.id
+ LEFT JOIN ctx_users target ON ma.target_user_id = target.id
+ ${where}
+ ORDER BY ma.created_at DESC
+ LIMIT ? OFFSET ?
+ `, params);
+}
+
+/**
+ * A channel's chat messages matching a search (Live's searchChannelChatMessages): one of the
+ * channel's streams, or its offline room (channel_user_id), never another channel's room.
+ */
+function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {
+ const conditions = [
+ '(cm.channel_user_id = ? OR cm.stream_id IN (SELECT id FROM ctx_streams WHERE channel_id = ? OR user_id = ?))',
+ 'cm.is_deleted = 0',
+ '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',
+ ];
+ const params = [channel.user_id, channel.id, channel.user_id];
+ if (query) { conditions.push('cm.message LIKE ?'); params.push(`%${query}%`); }
+ if (userId) { conditions.push('cm.user_id = ?'); params.push(userId); }
+ params.push(limit, offset);
+ return {
+ messages: all(`
+ SELECT cm.*, u.username, u.display_name, u.avatar_url
+ FROM chat_messages cm
+ LEFT JOIN ctx_users u ON cm.user_id = u.id
+ WHERE ${conditions.join(' AND ')}
+ ORDER BY cm.timestamp DESC
+ LIMIT ? OFFSET ?
+ `, params),
+ };
+}
+
// ── Staged tables (roadmap C-04) ──────────────────────────────
// ── Staged tables (roadmap C-04) ──────────────────────────────
// Who writes each one is table_authority (STAGED_TABLES above). The reads are cached for a second:
// Who writes each one is table_authority (STAGED_TABLES above). The reads are cached for a second:
// a handoff made by another process (scripts/table-authority.js) counts within that.
// a handoff made by another process (scripts/table-authority.js) counts within that.
@@ -1053,14 +1200,15 @@ function setChannelAlertSound(channelId, kind, url, mime) {
@@ -1053,14 +1200,15 @@ function setChannelAlertSound(channelId, kind, url, mime) {
return out;
return out;
}
}
-// Emotes (Live's /api/emotes; media_url/media_asset_id from its Media asset-sync).
-function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100 }) {
+// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's
+// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).
+function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {
_assertChatWrites('emotes');
_assertChatWrites('emotes');
return transaction(() => {
return transaction(() => {
const res = run(
const res = run(
- `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100))]
+ `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]
);
);
return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
});
});
@@ -1296,6 +1444,20 @@ module.exports = {
@@ -1296,6 +1444,20 @@ module.exports = {
deleteChannelSound,
deleteChannelSound,
renameChannelSoundCommand,
renameChannelSoundCommand,
updateChannelSoundEmoteRefs,
updateChannelSoundEmoteRefs,
+ // the six staged tables: reads for the public APIs and Live's internal read API (T3)
+ isChannelModerator,
+ getChannelModerationSettingsRow,
+ getChannelModerators,
+ getChannelModeratorIds,
+ getChannelsByModerator,
+ getGlobalEmotes,
+ getChannelEmotes,
+ countChannelEmotes,
+ getChannelEmoteByCode,
+ getEmoteById,
+ getEmotesByUser,
+ getModerationActions,
+ searchChannelChatMessages,
// staged tables (C-04): authority, Live's changes, dual-read slices, and the writes once Chat owns them
// staged tables (C-04): authority, Live's changes, dual-read slices, and the writes once Chat owns them
tableAuthority,
tableAuthority,
setTableAuthority,
setTableAuthority,
@@ -362,6 +362,7 @@ CREATE TABLE IF NOT EXISTS ctx_channels (
@@ -362,6 +362,7 @@ CREATE TABLE IF NOT EXISTS ctx_channels (
id INTEGER PRIMARY KEY,
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE,
user_id INTEGER UNIQUE,
title TEXT,
title TEXT,
+ emote_sources TEXT, -- the channel's emote source switches (Live's channels.emote_sources)
synced_at INTEGER
synced_at INTEGER
);
);
@@ -207,7 +207,19 @@ class Swr {
@@ -207,7 +207,19 @@ class Swr {
const USER_COLS = ['id', 'username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned', 'ban_reason', 'is_owner', 'created_at', 'subject_id'];
const USER_COLS = ['id', 'username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned', 'ban_reason', 'is_owner', 'created_at', 'subject_id'];
const STREAM_COLS = ['id', 'user_id', 'channel_id', 'managed_stream_id', 'title', 'is_live', 'started_at', 'ended_at', 'created_at'];
const STREAM_COLS = ['id', 'user_id', 'channel_id', 'managed_stream_id', 'title', 'is_live', 'started_at', 'ended_at', 'created_at'];
const MS_COLS = ['id', 'user_id', 'slug', 'title', 'sort_order', 'created_at'];
const MS_COLS = ['id', 'user_id', 'slug', 'title', 'sort_order', 'created_at'];
-const CHANNEL_COLS = ['id', 'user_id', 'title'];
+const CHANNEL_COLS = ['id', 'user_id', 'title', 'emote_sources'];
+
+// The emote source switches Live keeps on the channels row (emote_sources JSON), every key defaulting
+// to true: Live's /api/emotes/sources answers exactly this shape.
+const EMOTE_SOURCE_KEYS = ['defaults', 'custom', 'ffz', 'bttv', '7tv'];
+function emoteSources(raw) {
+ let sources = raw;
+ if (typeof raw === 'string') { try { sources = JSON.parse(raw || '{}'); } catch { sources = {}; } }
+ if (!sources || typeof sources !== 'object') sources = {};
+ const out = {};
+ for (const key of EMOTE_SOURCE_KEYS) out[key] = sources[key] === undefined ? true : sources[key] !== false;
+ return out;
+}
function upsertRows(table, cols, rows) {
function upsertRows(table, cols, rows) {
if (!rows || !rows.length) return 0;
if (!rows || !rows.length) return 0;
@@ -334,6 +346,14 @@ async function createChannel(userId) {
@@ -334,6 +346,14 @@ async function createChannel(userId) {
return getChannelByUserId(userId);
return getChannelByUserId(userId);
}
}
+/** Write the channel's emote source switches (Live's channels.emote_sources) and refresh the cache. */
+async function setChannelEmoteSources(userId, sources) {
+ const clean = emoteSources(sources);
+ await effect('channel-emote-sources', { user_id: userId, sources: clean });
+ db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);
+ return clean;
+}
+
// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
// Everyone who wants to know when a channel's settings were read anew (the chat server announces a
// Everyone who wants to know when a channel's settings were read anew (the chat server announces a
@@ -939,6 +959,7 @@ module.exports = {
@@ -939,6 +959,7 @@ module.exports = {
// streams + channels
// streams + channels
getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
+ emoteSources, setChannelEmoteSources,
// policy
// policy
getChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,
getChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,
reloadPolicy, onChannelSettings, defaultModerationSettings,
reloadPolicy, onChannelSettings, defaultModerationSettings,
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+/**
+ * OpenVibe.Chat → OpenVibe.Media (object API v2, namespace `chat`).
+ *
+ * Chat's OWN service token (client `chat`, capability media.object.upload to store an emote's
+ * image and media.object.delete to remove it) through openvibe-sdk/media's createObjectsClient.
+ * An uploaded object is public, and its public openvibe.media URL is what an emote row stores as
+ * media_url (Live's asset-sync did the same onto the shared rows before the T3 takeover).
+ */
+'use strict';
+
+const config = require('../config');
+
+const APP = 'chat';
+
+let _objects = null;
+
+function objects() {
+ if (_objects) return _objects;
+ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const { createObjectsClient } = require('openvibe-sdk/media');
+ const tokenClient = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.media',
+ scope: config.media.scope,
+ });
+ _objects = createObjectsClient({ app: APP, baseUrl: config.media.baseUrl, tokenClient });
+ return _objects;
+}
+
+/** The absolute public URL of an uploaded object (Media returns public_url; url is the fallback). */
+function publicUrl(obj) {
+ if (!obj) return null;
+ if (obj.public_url) return obj.public_url;
+ if (obj.url) return /^https?:\/\//i.test(obj.url) ? obj.url : `${config.media.publicOrigin}${String(obj.url).startsWith('/') ? '' : '/'}${obj.url}`;
+ return null;
+}
+
+/**
+ * Upload image bytes as a public Media object → { id, url }.
+ * Shaped for the emote routes: `id` is stored as media_asset_id, `url` as media_url.
+ */
+async function uploadObject(buffer, { filename, mimeType } = {}) {
+ const obj = await objects().upload(buffer, { kind: 'file', visibility: 'public', mimeType: mimeType || 'application/octet-stream', filename });
+ return { id: obj.id, url: publicUrl(obj) };
+}
+
+/** Remove a Media object (an emote or sound was deleted). Best-effort: logged, never thrown. */
+async function deleteObject(id) {
+ if (!id) return false;
+ try { return await objects().delete(id); } catch (err) { console.warn(`[Media] delete ${id}: ${err.message}`); return false; }
+}
+
+module.exports = { uploadObject, deleteObject, publicUrl, APP, _setClient(c) { _objects = c; }, _reset() { _objects = null; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+'use strict';
+/**
+ * Plan T3 step 1 — the APIs Chat serves for the six staged tables:
+ * - Live's internal read API (/internal/moderation/*, capability chat.moderation.read)
+ * - the public emote API (/api/emotes, Live's paths and shapes; bytes on OpenVibe.Media)
+ * - channel moderators & moderation settings (/api/chat/channels/:id/…)
+ * - the bridge alert-sound op (Live says "play the alert", Chat resolves the sound)
+ * The authority-not-flipped refusal (503 { ok:false, error:'not yet' }) is checked for every writer.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { validate } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('chat-apis');
+let h, RO, BRIDGE, streamer, other, admin, mod, stranger, channelId, streamId;
+const STAGED_TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes'];
+const deletedAssets = [];
+let assets = { upload: null, delete: null };
+
+function checkContract(ref, body) {
+ const r = validate(ref, body);
+ assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);
+}
+const asChat = (table) => h.db.setTableAuthority(table, 'chat');
+
+t('boot', async () => {
+ // 3 emotes per channel, so both the clash check and the cap can be exercised.
+ h = await boot({ env: { MAX_EMOTES_PER_CHANNEL: '3' } });
+ RO = h.serviceToken(['chat.moderation.read']);
+ BRIDGE = h.serviceToken(['chat.live_bridge.write']);
+ streamer = h.addUser('streamer', { role: 'streamer' });
+ other = h.addUser('otherstreamer', { role: 'streamer' });
+ admin = h.addUser('adminny', { role: 'admin' });
+ mod = h.addUser('moddy');
+ stranger = h.addUser('stranger');
+ channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { channel_id: 0, custom_emotes_enabled: 1 } });
+ streamId = h.addStream(streamer.id, channelId);
+ await h.ctx.sync();
+ // The Media mock: uploads answer a public object, deletes are recorded.
+ assets.upload = async (buf, opts) => ({ id: 777, public_url: `https://openvibe.media/chat/${(opts && opts.filename) || 'emote.png'}` });
+ assets.delete = async (id) => { deletedAssets.push(id); return true; };
+ require('../server/media/client')._setClient({ upload: assets.upload, delete: assets.delete });
+});
+
+// ── Internal read API ────────────────────────────────────────
+t('internal read API: loopback + service token (401 / 403 / forwarded)', async () => {
+ let r = await h.http('GET', `/internal/moderation/channels/${channelId}`);
+ assert.strictEqual(r.status, 401, r.text);
+ r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: BRIDGE, headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.strictEqual(r.status, 403, 'a forwarded request is refused (loopback only)');
+ r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: BRIDGE });
+ assert.strictEqual(r.status, 403, 'wrong capability');
+ r = await h.http('GET', `/api/internal/moderation/channels/${channelId}`, { token: RO });
+ assert.strictEqual(r.status, 404, 'not a public path');
+});
+
+t('internal read API: settings + moderator_ids validate, defaults for a channel with no row', async () => {
+ const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });
+ assert.strictEqual(r.status, 200, r.text);
+ checkContract('chat.channel-moderation-result', r.body);
+ assert.strictEqual(r.body.ok, true);
+ assert.strictEqual(r.body.settings.channel_id, channelId);
+ // Live's defaults when the channel has no settings row.
+ const d = await h.http('GET', '/internal/moderation/channels/999999', { token: RO });
+ checkContract('chat.channel-moderation-result', d.body);
+ assert.strictEqual(d.body.settings.channel_id, 999999);
+ assert.strictEqual(d.body.settings.max_message_length, 500);
+ assert.strictEqual(d.body.settings.allow_anonymous, 1);
+ assert.deepStrictEqual(d.body.moderator_ids, []);
+});
+
+t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {
+ asChat('channel_moderators');
+ h.db.addChannelModerator(channelId, mod.id, streamer.id);
+ h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);
+ h.db.addChannelModerator(channelId, stranger.id, streamer.id);
+ h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);
+ const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });
+ checkContract('chat.channel-moderation-result', r.body);
+ assert.deepStrictEqual(r.body.moderator_ids, [mod.id, stranger.id]);
+
+ // other moderates nothing; mod moderates one channel (title/owner from the projection).
+ let c = await h.http('GET', `/internal/moderation/users/${mod.id}/channels`, { token: RO });
+ checkContract('chat.moderated-channels-result', c.body);
+ assert.strictEqual(c.body.channels.length, 1);
+ assert.strictEqual(c.body.channels[0].channel_id, channelId);
+ assert.strictEqual(c.body.channels[0].owner_user_id, streamer.id);
+ assert.strictEqual(c.body.channels[0].owner_username, 'streamer');
+ assert.strictEqual(c.body.channels[0].title, 'Channel');
+ c = await h.http('GET', `/internal/moderation/users/${other.id}/channels`, { token: RO });
+ checkContract('chat.moderated-channels-result', c.body);
+ assert.deepStrictEqual(c.body.channels, []);
+});
+
+t('internal read API: emote count validates; channels.id resolves to its owner', async () => {
+ const r = await h.http('GET', `/internal/moderation/channels/${channelId}/emote-count`, { token: RO });
+ assert.strictEqual(r.status, 200, r.text);
+ checkContract('chat.emote-count-result', r.body);
+ assert.strictEqual(r.body.count, 0);
+});
+
+// ── Emote API ────────────────────────────────────────────────
+const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
+async function upload(userId, token, fields = {}, bytes = PNG, type = 'image/png') {
+ const form = new FormData();
+ form.append('image', new Blob([bytes], { type }), 'emote.png');
+ for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
+ const res = await fetch(`${h.base}/api/emotes`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
+ return { status: res.status, body: await res.json().catch(() => null) };
+}
+
+t('emote API: the authority-not-flipped refusal (503 not yet, nothing written)', async () => {
+ const r = await upload(streamer.id, streamer.token, { code: 'refused' });
+ assert.strictEqual(r.status, 503, JSON.stringify(r.body));
+ assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });
+ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes').n, 0);
+});
+
+t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap', async () => {
+ asChat('emotes');
+ // Public list shapes need no auth.
+ let r = await h.http('GET', '/api/emotes/defaults');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.emotes.length, 25);
+
+ const up = await upload(streamer.id, streamer.token, { code: 'hype', size: 150 });
+ assert.strictEqual(up.status, 200, JSON.stringify(up.body));
+ assert.strictEqual(up.body.emote.code, 'hype');
+ assert.strictEqual(up.body.emote.url, 'https://openvibe.media/chat/emote.png');
+ const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
+ assert.strictEqual(row.media_asset_id, 777);
+ assert.strictEqual(row.media_url, 'https://openvibe.media/chat/emote.png');
+ assert.strictEqual(row.size, 150);
+
+ // Channel list uses media_url.
+ const ch = await h.http('GET', `/api/emotes/channel/${streamer.id}`);
+ assert.strictEqual(ch.status, 200);
+ assert.strictEqual(ch.body.emotes[0].url, 'https://openvibe.media/chat/emote.png');
+ assert.strictEqual(ch.body.emotes[0].source, 'channel');
+
+ // mine: authenticated, count/max.
+ const mine = await h.http('GET', '/api/emotes/mine', { token: streamer.token });
+ assert.strictEqual(mine.status, 200);
+ assert.deepStrictEqual([mine.body.count, mine.body.max], [1, 3]);
+
+ // Clash within the channel.
+ const lie = await upload(streamer.id, streamer.token, { code: 'fakeimg' }, Buffer.from('<html><script>x</script></html>'), 'image/png');
+ assert.strictEqual(lie.status, 400, 'bytes that are not the declared image type are refused');
+ const clash = await upload(streamer.id, streamer.token, { code: 'hype' });
+ assert.strictEqual(clash.status, 409, JSON.stringify(clash.body));
+
+ // Cap: 3 per channel (already 1), the third is refused.
+ assert.strictEqual((await upload(streamer.id, streamer.token, { code: 'two' })).status, 200);
+ assert.strictEqual((await upload(streamer.id, streamer.token, { code: 'three' })).status, 200);
+ const full = await upload(streamer.id, streamer.token, { code: 'four' });
+ assert.strictEqual(full.status, 400, JSON.stringify(full.body));
+ assert.match(full.body.error, /full/);
+
+ // Code validation + bad type.
+ const bad = await upload(streamer.id, streamer.token, { code: 'x' });
+ assert.strictEqual(bad.status, 400);
+ const r2 = await h.http('GET', '/api/emotes/file/whatever.png');
+ assert.strictEqual(r2.status, 404);
+});
+
+t('emote API: rename/resize, delete removes the Media object', async () => {
+ const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
+ let r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: streamer.token, body: { code: 'hype2', size: 300 } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.code, r.body.size], ['hype2', 200], 'size clamped to the channel max (200)');
+
+ r = await h.http('DELETE', `/api/emotes/${row.id}`, { token: streamer.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id]).n, 0);
+ assert.deepStrictEqual(deletedAssets, [777], 'the Media object is removed');
+});
+
+t('emote API: not-mine is refused, sources round-trip through the Live-context effect', async () => {
+ const row = h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);
+ const r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: other.token, body: { code: 'nope' } });
+ assert.strictEqual(r.status, 403, r.text);
+
+ const s = await h.http('GET', '/api/emotes/sources', { token: streamer.token });
+ assert.strictEqual(s.status, 200);
+ assert.deepStrictEqual(s.body.sources, { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true });
+ const p = await h.http('PUT', '/api/emotes/sources', { token: streamer.token, body: { ffz: false } });
+ assert.strictEqual(p.status, 200, p.text);
+ assert.strictEqual(p.body.sources.ffz, false);
+ assert.ok(h.live.effects.some((e) => e.name === 'channel-emote-sources'), 'the write reaches Live');
+ const s2 = await h.http('GET', '/api/emotes/sources', { token: streamer.token });
+ assert.strictEqual(s2.body.sources.ffz, false);
+});
+
+// ── Moderators & moderation settings ─────────────────────────
+t('moderators: owner and admin add/remove, a stranger is refused', async () => {
+ asChat('channel_moderators');
+ let r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: stranger.token, body: { username: 'moddy' } });
+ assert.strictEqual(r.status, 403, r.text);
+ r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'moddy' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.body.moderators.some((m) => m.user_id === mod.id));
+ r = await h.http('DELETE', `/api/chat/channels/${channelId}/mods/${mod.id}`, { token: admin.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.body.moderators.some((m) => m.user_id === mod.id));
+ r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'nobodyhere' } });
+ assert.strictEqual(r.status, 404, r.text);
+});
+
+t('moderators: list is public to any signed-in reader; moderation settings are mod-only', async () => {
+ const list = await h.http('GET', `/api/chat/channels/${channelId}/mods`, { token: other.token });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.strictEqual(list.body.channel_id, channelId);
+
+ let s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: other.token });
+ assert.strictEqual(s.status, 403, s.text);
+ s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token });
+ assert.strictEqual(s.status, 200, s.text);
+ assert.strictEqual(s.body.settings.custom_emotes_enabled, 1);
+ const mine = await h.http('GET', '/api/chat/channels/moderation/mine', { token: streamer.token });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.body.channels.some((c) => c.id === channelId));
+});
+
+t('moderation settings: owner writes, a mod cannot change policy keys, authority gate first', async () => {
+ // While Live writes the settings, the write refuses.
+ h.db.setTableAuthority('channel_moderation_settings', 'live');
+ let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 5 } });
+ assert.strictEqual(r.status, 503, r.text);
+ assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });
+
+ asChat('channel_moderation_settings');
+ asChat('channel_moderators');
+ h.db.addChannelModerator(channelId, mod.id, streamer.id);
+ r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.settings.slow_mode_seconds, 12);
+ assert.strictEqual(r.body.settings.ip_approval_mode, 1);
+
+ // A mod passing an owner-policy key: ignored, not refused.
+ r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: mod.token, body: { slow_mode_seconds: 3, allow_anonymous: 0 } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.settings.slow_mode_seconds, 3);
+ assert.strictEqual(r.body.settings.allow_anonymous, 1, 'owner policy stayed');
+
+ const s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: mod.token });
+ assert.strictEqual(s.status, 200, s.text);
+
+ const logs = await h.http('GET', `/api/chat/channels/${channelId}/moderation/logs`, { token: streamer.token });
+ assert.strictEqual(logs.status, 200, logs.text);
+ assert.ok(Array.isArray(logs.body.actions));
+ const search = await h.http('GET', `/api/chat/channels/${channelId}/moderation/chat-search?q=hi`, { token: streamer.token });
+ assert.strictEqual(search.status, 200, search.text);
+ assert.ok(Array.isArray(search.body.messages));
+});
+
+// ── Alert-sound op ───────────────────────────────────────────
+t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {
+ asChat('channel_moderation_settings');
+ const snd = path.join(h.tmp, 'sounds', 'donation.mp3');
+ fs.writeFileSync(snd, 'ID3alert');
+ h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');
+
+ const ws = await h.ws({ ip: '198.51.100.7', stream: streamId });
+ ws.sendJson({ type: 'join', streamId, channelUserId: streamer.id, anonId: null });
+ await ws.next((m) => m.type === 'auth' || m.type === 'connected');
+
+ const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-alerts', ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });
+ let r = await calls([{ op: 'playAlertSound', args: [streamer.id, streamId, 'donation'] }]);
+ assert.strictEqual(r.status, 200, r.text);
+ const res = r.body.results[0];
+ assert.strictEqual(res.ok, true, JSON.stringify(res));
+ assert.deepStrictEqual(res.result, { played: true, source: 'donation-alert' });
+ const frame = await ws.next((m) => m.type === 'soundboard-audio');
+ assert.strictEqual(frame.source, 'donation-alert');
+ assert.strictEqual(Buffer.from(frame.audio, 'base64').toString(), 'ID3alert');
+ ws.close();
+
+ // No goal sound set: goal falls back to the donation sound; a channel with none does not play.
+ r = await calls([{ op: 'playAlertSound', args: [streamer.id, streamId, 'goal'] }]);
+ assert.deepStrictEqual(r.body.results[0].result, { played: true, source: 'goal-alert' });
+ r = await calls([{ op: 'playAlertSound', args: [other.id, streamId, 'donation'] }]);
+ assert.deepStrictEqual(r.body.results[0].result, { played: false, source: 'donation-alert' });
+});
+
+t.run(async () => { if (h) await h.close(); });
@@ -117,6 +117,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -117,6 +117,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
+ 'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
};
};