openvibers/openvibe.chat Commits
Internal reads for Live's last mirror readers: the site-wide daily series, first chat in a channel, and a channel sound by command (#24)
Changes
@@@ -14,7 +14,7 @@ a stream that does not exist is 404: do not retry a 4xx.| Route | Capability | Body and result || --- | --- | --- |--| `POST /internal/chat/messages` | `chat.message.send` | Chat message fields (`stream_id`, `channel_user_id`, `user_id`, `anon_id`, `username`, `message`, `message_type`, `is_global`, `reply_to_id`, `source_platform`, `metadata`), optional `mirror`, `tts:{voice,identity_key,key}`, `auto_delete_at`, and typed chat-frame fields in `frame` (`role`: `admin`/`global_mod`/`streamer`/`user`/`anon`/`external`, `profile_color`: hex, `avatar_url`, `is_ai`/`is_bot`/`filtered`: booleans, `core_username`, `display_name`); the broadcast carries `reply_to_id`, `is_global`, `auto_delete_at` and `metadata` from the body. Returns `{ok,id,stream_id,channel_user_id,first_chat}`: `first_chat` (`chat.send-result` 1.2.0) is true when this line recorded the chatter's first chat in the channel, so Live can welcome them. With no TTS key, Chat uses `m<real message id>`. `dm:{to_user_id}` with `user_id` persists and delivers a direct message (the same `dm` frame as `POST /api/dm`, with the sender looked up by id), returning `{ok,id,conversation_id,to_user_id}`; a blank message is 400. Chat records the first chat in the channel (the welcome check) under `first_chat_key` when given (`user:<id>`, `anon:<anonId>` or `ext:<prefixed username>`), otherwise for a `chat` line only: `ext:<username>` with `source_platform`, else `user:<user_id>`, else `anon:<anon_id>`. |++| `POST /internal/chat/messages` | `chat.message.send` | Chat message fields (`stream_id`, `channel_user_id`, `user_id`, `anon_id`, `username`, `message`, `message_type`, `is_global`, `reply_to_id`, `source_platform`, `metadata`), optional `mirror`, `tts:{voice,identity_key,key}`, `auto_delete_at`, and typed chat-frame fields in `frame` (`role`: `admin`/`global_mod`/`streamer`/`user`/`anon`/`external`, `profile_color`: hex, `avatar_url`, `is_ai`/`is_bot`/`filtered`: booleans, `core_username`, `display_name`); the broadcast carries `reply_to_id`, `is_global`, `auto_delete_at` and `metadata` from the body. Returns `{ok,id,stream_id,channel_user_id,first_chat}`: `first_chat` (`chat.send-result` 1.2.0) is true when this line recorded the chatter's first chat in the channel, so Live can welcome them. With no TTS key, Chat uses `m<real message id>`. `dm:{to_user_id}` with `user_id` persists and delivers a direct message (the same `dm` frame as `POST /api/dm`, with the sender looked up by id), returning `{ok,id,conversation_id,to_user_id}`; a blank message is 400. Chat records the first chat in the channel (the welcome check) under `first_chat_key` when given (`user:<user_id>`, `anon:<anonId>` or `ext:<prefixed username>`), otherwise for a `chat` line only: `ext:<username>` with `source_platform`, else `user:<user_id>`, else `anon:<anon_id>`. || `POST /internal/chat/events` | `chat.event.publish` | `{key,target:{kind,id?},frame:{type,...}}`, returns `{ok:true}`. Target kinds: `stream`, `channel`, `owner-streams`, `user` (positive `id`), `global`, `all` (no `id`). Only the event frame types allowlisted in `internal-ingress.js` are accepted; `dm` and delete frames are refused, and `chat` only as the news card. || `POST /internal/chat/moderation` | `chat.moderation.write` | `{key,action,...}`; actions: `delete-message`, `delete-user-messages`, `delete-anon-messages`, `delete-relay-messages`, `delete-by-range`, `review-pending-ip`, `approve-ip-messages`, `deny-ip-messages`, `relay-hide`, `relay-unhide`, `relay-record`, `tts-voice-override`, `disconnect`, `log`. Deletes return `ids` and broadcast `delete-messages` to every affected surface. || `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |@@@ -27,15 +27,17 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive| Route | Capability | Request and result || --- | --- | --- |--| `POST /internal/chat/stats` | `chat.stats.read` | `{kind:'site'\|'user'\|'stream'\|'channel-top', user_id?, stream_id?, channel_user_id?, since?, until?, limit?}`. `site`/`user`(`user_id`)/`stream`(`stream_id`) → `{messages,chatters}` (+ `sounds`, soundboard plays, for `stream`); `channel-top` (by `stream_id`, `channel_user_id` or all chat; `limit` ≤ 50, default 10) → `{top_chatters:[{user_id,username,display_name,avatar_url,profile_color,count}]}`. |++| `POST /internal/chat/stats` | `chat.stats.read` | `{kind:'site'\|'user'\|'stream'\|'channel-top'\|'site-daily', user_id?, stream_id?, channel_user_id?, since?, until?, limit?}`. `site`/`user`(`user_id`)/`stream`(`stream_id`) → `{messages,chatters}` (+ `sounds`, soundboard plays, for `stream`); `channel-top` (by `stream_id`, `channel_user_id` or all chat; `limit` ≤ 50, default 10) → `{top_chatters:[{user_id,username,display_name,avatar_url,profile_color,count}]}`; `site-daily` (`since` and `until` required, epoch ms, at most 400 UTC days) → `{days:[{day,messages,chatters}]}`, one row per UTC day in `[since,until)` with zero-filled gaps: every non-deleted message across all channels (Live's home `messages` series) and distinct chatters (Live's `active`: `COALESCE(user_id,anon_id,source_platform‖username)`). || `GET /internal/chat/messages` | `chat.messages.read` | Exactly one of `channel_user_id`, `stream_id`, `user_id`, `anon_id`, `username`, `id`; `after_id` (oldest first), `before_id` (newest first; the default), `limit` ≤ 500 (default 100), `types=chat,donation,…`, `tail=1` (no rows) → `{messages,max_id}`; `max_id` is the newest matching id. || `GET /internal/chat/timeline` | `chat.analysis.read` | `channel_user_id` or `stream_id`, `since`, `until` (default now), `bucket_ms` ≥ 1000 (≤ 10 000 buckets) → `{buckets:[{t,count}],max_id}`; buckets start at `since`, oldest first, empty ones left out. |++| `GET /internal/chat/first-chat?channel_id&identity` | `chat.analysis.read` | `{first}`: true when `identity` (`user:<user_id>`, `anon:<anonId>`, `ext:<prefixed username>`; ≤ 160) has never chatted in the channel — Live's `isFirstChatInChannel`, from `stream_first_chats`. `channel_id` is the channel owner's Live user id (`stream_first_chats.channel_user_id`), not `ctx_channels.id`. A registered identity is `user:<user_id>`, the numeric Live user id and **never** the username: Live's own `user:${line.username}` key would read `first:true` forever, so its welcome check must pass `user:${line.user_id}`. || `GET /internal/chat/moderation/pending-ip?channel_id` | `chat.moderation.queue.read` | `{pending_ip}`: the channel's pending rows, oldest first (`limit` ≤ 500, default 50). || `GET /internal/chat/moderation/relay-users?channel_id` | `chat.moderation.queue.read` | `{relay_users}`: hidden or banned relay names on the channel or everywhere, newest first, with `created_by_username` (`limit` ≤ 500, default 100). || `GET /internal/chat/moderation/relay-users/:id` | `chat.moderation.queue.read` | `{relay_user}`, null when unknown. || `GET /internal/chat/moderation/tts-override?identity_key` | `chat.moderation.queue.read` | `{tts_override}` (key trimmed and lowercased), null when none. || `GET /internal/chat/sounds?channel_owner_id` (also `/sounds/count`) | `chat.sounds.read` | `{count}` of the channel's sounds. || `GET /internal/chat/sounds?pending_asset=1` | `chat.sounds.read` | `{sounds}` not yet on Media (`media_asset_id` null), by id; optional `channel_owner_id`, `after_id`, `limit` ≤ 500 (default 100). |++| `GET /internal/chat/sounds/by-command?channel_id&command` | `chat.sounds.read` | `{sound}`: the approved (`is_approved=1`) channel sound `command` plays, one at random when several match, or 404 — Live's `getChannelSoundByCommand`. `command` is trimmed, lowercased and has leading `!` stripped (≤ 120); `channel_id` is the channel owner's Live user id (`channel_sounds.channel_owner_id`); same projection as `/sounds`. || `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. Until Live writes here, the read mirror leaves Live's own asset columns alone for a sound Chat has no asset for. |`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.@@@ -64,7 +66,11 @@ the read API above, Network's `DEFAULT_GRANTS` must add `live chat.stats.read op`live chat.messages.read openvibe.chat`, `live chat.analysis.read openvibe.chat`,`live chat.moderation.queue.read openvibe.chat`, `live chat.sounds.read openvibe.chat` and`live chat.sounds.write openvibe.chat`. Chat's--local proposals are in `docs/capabilities-proposal/`.++local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's home++series, its welcome check and its robot channel sounds use those same capabilities but add result++schemas `chat.site-daily-result@1`, `chat.first-chat-result@1` and `chat.sound-result@1`, which++land in openvibe-contracts 0.103.0 (the Contracts change is separate; Live codes against the++shapes here until that version is pinned).Live J2 (done: Live #20/#21) replaced its bridge writer with a typed client: repoint message producers and persisted DMsto `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption@@@ -919,7 +919,9 @@ class ChatServer {} catch { /* no badge */ }}-- // Save to database++ // Save to database. The save also records the first chat in the resolved channel++ // (channel_user_id, or the stream owner), so a channel-only line counts too.++ let firstChat = false;try {const result = await db.saveChatMessage({stream_id: client.streamId || null,@@@ -935,6 +937,7 @@ class ChatServer {metadata: chatMsg.vip_badge ? { vip_badge: chatMsg.vip_badge } : undefined,});if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);++ firstChat = !!result.first_chat;} catch (err) {// Not saved means not sent: a line everyone saw but history, moderation and replays never// have would be a ghost. Tell the sender and stop here.@@@ -957,22 +960,16 @@ class ChatServer {powerchat: !!client.channelUserId,};-- // Welcome first-time chatters in this streamer's channel-- if (client.streamId) {++ // Welcome first-time chatters in this streamer's channel. The save answered firstChat++ // (it recorded it for the resolved channel), so there is nothing left to check here.++ if (client.streamId && firstChat) {try {-- const stream = await ctx.getStreamById(client.streamId);-- if (stream?.user_id) {-- const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;-- if (await db.isFirstChatInChannel(chatterKey, stream.user_id)) {-- await db.recordFirstChat(chatterKey, stream.user_id);-- const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';-- await this.broadcastToStream(client.streamId, {-- type: 'system',-- message: `Welcome ${welcomeName} to the chat! 👋`,-- timestamp: new Date().toISOString(),-- });-- }-- }++ const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';++ await this.broadcastToStream(client.streamId, {++ type: 'system',++ message: `Welcome ${welcomeName} to the chat! 👋`,++ timestamp: new Date().toISOString(),++ });} catch { /* non-critical */ }}@@@ -130,16 +130,6 @@ function validateMessage(b) {}}--// The keys chat-server's welcome check reads (`user:`/`anon:`) and Live's relay saves (`ext:` +--// the prefixed username it stores relay lines under). Only chat lines use up a welcome.--function firstChatKey(b) {-- if (b.first_chat_key) return b.first_chat_key;-- if ((b.message_type || 'chat') !== 'chat') return null;-- if (b.source_platform) return `ext:${b.username}`;-- if (b.user_id) return `user:${b.user_id}`;-- return b.anon_id ? `anon:${b.anon_id}` : null;--}--async function writeMessage(b) {if (b.dm) {if (await dm.isBlockedEither(b.user_id, b.dm.to_user_id)) { const e = new Error('DM participants are blocked'); e.status = 403; throw e; }@@@ -153,10 +143,9 @@ async function writeMessage(b) {const saved = await db.saveChatMessage({ ...b, is_global: b.is_global ?? (!b.stream_id && !b.channel_user_id) });const id = Number(saved.lastInsertRowid);const channelUserId = b.channel_user_id || (b.stream_id ? (await ctx.getStreamById(b.stream_id))?.user_id : null) || null;-- const chatterKey = firstChatKey(b);-- // first_chat (chat.send-result 1.2.0): this line used up the chatter's welcome in the channel.-- const first_chat = !!(channelUserId && chatterKey) && await db.recordFirstChat(chatterKey, channelUserId);-- return { id, stream_id: b.stream_id || null, channel_user_id: channelUserId, first_chat };++ // first_chat (chat.send-result 1.2.0): the save records the welcome in the resolved++ // channel, so its answer already says whether this line used it up.++ return { id, stream_id: b.stream_id || null, channel_user_id: channelUserId, first_chat: !!saved.first_chat };}async function deliverMessage(chatServer, b, result) {@@@ -4,15 +4,26 @@* openvibe.chat, one capability per route (openvibe-contracts 0.95.0):** POST /internal/chat/stats chat.stats.read chat.stats-request@1 → chat.stats-result@1++ * kind site-daily → chat.site-daily-result@1 { days }* GET /internal/chat/messages chat.messages.read → chat.messages-page@1* GET /internal/chat/timeline chat.analysis.read → chat.timeline-result@1++ * GET /internal/chat/first-chat chat.analysis.read → chat.first-chat-result@1 { first }* GET /internal/chat/moderation/pending-ip chat.moderation.queue.read → chat.moderation-queue-result@1 { pending_ip }* GET /internal/chat/moderation/relay-users chat.moderation.queue.read → { relay_users }* GET /internal/chat/moderation/relay-users/:id chat.moderation.queue.read → { relay_user }* GET /internal/chat/moderation/tts-override chat.moderation.queue.read → { tts_override }* GET /internal/chat/sounds(/count) chat.sounds.read → chat.sounds-result@1 { count } | { sounds }++ * GET /internal/chat/sounds/by-command chat.sounds.read → chat.sound-result@1 { sound }* POST /internal/chat/sounds/asset chat.sounds.write chat.sound-asset-request@1 → chat.ingress-ack@1*++ * The three plan-T3 reads added for Live's home series, its welcome check and its robot channel++ * sounds (chat.site-daily-result@1, chat.first-chat-result@1, chat.sound-result@1) land in++ * openvibe-contracts 0.103.0 (a separate Contracts change); until it is pinned, Live's next PR++ * codes against the shapes documented in docs/chat-ingress.md.++ * In first-chat and sounds/by-command, `channel_id` is the channel owner's Live user id — the value++ * Live passes as channelUserId to isFirstChatInChannel and channel_owner_id to++ * getChannelSoundByCommand (both stream.user_id), not Chat's ctx_channels.id.++ ** Every parameter is validated (unknown ones are 400) and bound; deleted messages are never read.* Times in requests are epoch ms; chat_messages.timestamp is UTC 'YYYY-MM-DD HH:MM:SS' text, so a* window compares text and keeps the timestamp indexes.@@@ -27,6 +38,8 @@ const MESSAGE_TYPES = ['chat', 'system', 'donation', 'command', 'tts', 'channel-const MAX_MESSAGES = 500;const MAX_ROWS = 500;const MAX_BUCKETS = 10_000;++const MAX_SERIES_DAYS = 400;++const DAY_MS = 86_400_000;const bad = (message) => { const e = new Error(message); e.status = 400; throw e; };const id = (v) => Number.isSafeInteger(v) && v > 0;@@@ -62,6 +75,9 @@ function query(req, spec) {function limit(v, dflt, max) { return v == null ? dflt : Math.min(v, max); }++const dayStart = (t) => Math.floor(t / DAY_MS) * DAY_MS;++const dayCount = (since, until) => (dayStart(until - 1) - dayStart(since)) / DAY_MS + 1;++function route(name, fn) {return async (req, res) => {try {@@@ -78,8 +94,8 @@ function route(name, fn) {const CHATTER = `COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)`;const STATS_FIELDS = ['kind', 'user_id', 'stream_id', 'channel_user_id', 'since', 'until', 'limit'];--function statsFilter(b) {-- const where = ['is_deleted = 0'];++function statsFilter(b, deleted = 'is_deleted = 0') {++ const where = [deleted];const params = [];for (const k of ['user_id', 'stream_id', 'channel_user_id']) if (b[k] != null) { where.push(`${k} = ?`); params.push(b[k]); }if (b.since != null) { where.push('timestamp >= ?'); params.push(sqlTime(b.since)); }@@@ -90,14 +106,38 @@ function statsFilter(b) {async function stats(req) {const b = req.body;if (!b || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).some((k) => !STATS_FIELDS.includes(k))) bad('Invalid fields');-- if (!['site', 'user', 'stream', 'channel-top'].includes(b.kind)) bad('Invalid kind');++ if (!['site', 'user', 'stream', 'channel-top', 'site-daily'].includes(b.kind)) bad('Invalid kind');for (const k of ['user_id', 'stream_id', 'channel_user_id']) if (b[k] != null && !id(b[k])) bad(`Invalid ${k}`);for (const k of ['since', 'until']) if (b[k] != null && !ms(b[k])) bad(`Invalid ${k}`);if (b.limit != null && !(Number.isSafeInteger(b.limit) && b.limit >= 1 && b.limit <= 50)) bad('Invalid limit');if (b.kind === 'user' && b.user_id == null) bad('kind user needs user_id');if (b.kind === 'stream' && b.stream_id == null) bad('kind stream needs stream_id');if (b.kind === 'channel-top' && b.user_id != null) bad('kind channel-top takes stream_id or channel_user_id');-- const { where, params } = statsFilter(b);++ if (b.kind === 'site-daily') {++ if (b.since == null || b.until == null) bad('kind site-daily needs since and until');++ if (b.user_id != null || b.stream_id != null || b.channel_user_id != null || b.limit != null) bad('kind site-daily takes since and until only');++ if (b.until <= b.since) bad('until must be after since');++ if (dayCount(b.since, b.until) > MAX_SERIES_DAYS) bad('Invalid window');++ }++ // Live's series reader counts a NULL is_deleted as live (COALESCE(is_deleted,0)=0); the++ // other kinds keep Chat's is_deleted = 0.++ const { where, params } = statsFilter(b, b.kind === 'site-daily' ? 'COALESCE(is_deleted, 0) = 0' : 'is_deleted = 0');++ if (b.kind === 'site-daily') {++ // Live's home series (HOME_SERIES messages/active): every non-deleted row counts, whatever++ // its type; a chatter is a distinct user, anon or relayed name. One row per UTC day in++ // [since, until), zeros filled, so the chart never skips a day.++ const rows = await db.all(++ `SELECT substr(timestamp, 1, 10) AS day, COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters++ FROM chat_messages WHERE ${where} GROUP BY day`, params);++ const by = new Map(rows.map((r) => [String(r.day), r]));++ const days = [];++ for (let t = dayStart(b.since), last = dayStart(b.until - 1); t <= last; t += DAY_MS) {++ const day = new Date(t).toISOString().slice(0, 10);++ const r = by.get(day);++ days.push({ day, messages: r ? Number(r.messages) : 0, chatters: r ? Number(r.chatters) : 0 });++ }++ return { days };++ }if (b.kind !== 'channel-top') {const r = await db.get(`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters,@@@ -182,6 +222,17 @@ async function timeline(req) {};}++/** Live's welcome check: has this identity ever chatted in this channel (stream_first_chats)? */++async function firstChat(req) {++ const q = query(req, { channel_id: 'id', identity: 160 });++ if (q.channel_id == null) bad('channel_id is required');++ // Same key shapes internal-ingress records: user:<id>, anon:<anonId>, ext:<prefixed username>.++ if (!/^(?:user|anon|ext):./.test(String(q.identity || ''))) bad('Invalid identity');++ const row = await db.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',++ [q.identity, q.channel_id]);++ return { first: !row };++}++// ── N4: moderation queues ──function pendingIpRow(r) {return {@@@ -281,10 +332,24 @@ async function soundAsset(req) {return {};}++/** Live's getChannelSoundByCommand: the approved sound a !command plays, picked at random, or 404. */++async function soundByCommand(req) {++ const q = query(req, { channel_id: 'id', command: 120 });++ if (q.channel_id == null) bad('channel_id is required');++ const command = String(q.command || '').trim().toLowerCase().replace(/^!+/, '');++ if (!command) bad('command is required');++ const row = await db.get(++ 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',++ [q.channel_id, command]);++ if (!row) { const e = new Error('Sound not found'); e.status = 404; throw e; }++ return { sound: soundRow(row) };++}++const router = express.Router();router.post('/stats', serviceAuth.guard('chat.stats.read'), route('stats', stats));router.get('/messages', serviceAuth.guard('chat.messages.read'), route('messages', messages));router.get('/timeline', serviceAuth.guard('chat.analysis.read'), route('timeline', timeline));++router.get('/first-chat', serviceAuth.guard('chat.analysis.read'), route('first-chat', firstChat));const queue = serviceAuth.guard('chat.moderation.queue.read');router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));@@@ -292,6 +357,7 @@ router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser))router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));++router.get('/sounds/by-command', serviceAuth.guard('chat.sounds.read'), route('sound-by-command', soundByCommand));router.post('/sounds/asset', serviceAuth.guard('chat.sounds.write'), route('sound-asset', soundAsset));module.exports = router;@@@ -168,7 +168,17 @@ async function _announceDeleted(ids) {// ── Chat messages ─────────────────────────────────────────────--async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {++// The welcome identity a save records, keyed the way the first-chat read checks it: an explicit++// ingress key wins, only chat lines otherwise count, and relayed lines live under ext:<username>.++function firstChatKey({ message_type, source_platform, user_id, anon_id, username, first_chat_key }) {++ if (first_chat_key) return first_chat_key;++ if ((message_type || 'chat') !== 'chat') return null;++ if (source_platform) return `ext:${username}`;++ if (user_id) return `user:${user_id}`;++ return anon_id ? `anon:${anon_id}` : null;++}++++async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, first_chat_key }) {// channel_user_id = the broadcaster's user id — set for all channel/stream// messages so a streamer's chat history survives across sessions AND offline// periods (independent of the live-session stream row's lifetime).@@@ -176,12 +186,17 @@ async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, uif (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));const subject = await subjectFor(user_id);++ // Every save path records the chatter's first chat in the resolved channel here, so a++ // channel-only line (a PowerChat or offline room with no stream) counts too. Only chat lines++ // take a welcome (see firstChatKey); the record joins the row's transaction.++ const chatterKey = firstChatKey({ message_type, source_platform, user_id, anon_id, username, first_chat_key });return await transaction(async () => {const res = await run(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,[stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]);++ res.first_chat = !!(chatterKey && chanUid) && await recordFirstChat(chatterKey, chanUid);await _outbox().enqueue({event_type: 'chat.message.created',visibility: 'public',@@@ -81,6 +81,7 @@ t('each route needs its own capability, a service token for Chat and loopback',['POST', '/stats', 'chat.stats.read', { kind: 'site' }],['GET', `/messages?stream_id=${streamId}`, 'chat.messages.read'],['GET', `/timeline?stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`, 'chat.analysis.read'],++ ['GET', `/first-chat?channel_id=${streamer.id}&identity=user:${viewer.id}`, 'chat.analysis.read'],['GET', `/moderation/pending-ip?channel_id=${channelId}`, 'chat.moderation.queue.read'],['GET', `/moderation/relay-users?channel_id=${channelId}`, 'chat.moderation.queue.read'],['GET', '/moderation/relay-users/1', 'chat.moderation.queue.read'],@@@ -88,6 +89,7 @@ t('each route needs its own capability, a service token for Chat and loopback',['GET', `/sounds?channel_owner_id=${streamer.id}`, 'chat.sounds.read'],['GET', `/sounds/count?channel_owner_id=${streamer.id}`, 'chat.sounds.read'],['GET', '/sounds?pending_asset=1', 'chat.sounds.read'],++ ['GET', `/sounds/by-command?channel_id=${streamer.id}&command=honk`, 'chat.sounds.read', undefined, 404],['POST', '/sounds/asset', 'chat.sounds.write', { id: 999999, media_url: 'https://media.test/x', media_asset_id: 1 }, 404],];for (const [method, path, cap, body, ok = 200] of routes) {@@@ -125,6 +127,26 @@ t('N1 stats: site, user, stream and channel-top over non-deleted lines in the wi{ kind: 'channel-top', user_id: viewer.id }]) await stats(body, 400);});++t('N1b stats site-daily: one row per UTC day over every channel, zeros filled', async () => {++ const series = async (body, status = 200) => {++ const r = await h.http('POST', '/internal/chat/stats', { body, token: token('chat.stats.read') });++ assert.strictEqual(r.status, status, `${JSON.stringify(body)}: ${r.text}`);++ return r.body;++ };++ assert.deepStrictEqual(await series({ kind: 'site-daily', since: T, until: T + 600e3 }),++ { ok: true, days: [{ day: '2026-01-01', messages: 9, chatters: 4 }] });++ // [2025-12-31, 2026-01-02): two UTC days; the empty first day is a zero row, not skipped.++ assert.deepStrictEqual(await series({ kind: 'site-daily', since: Date.UTC(2025, 11, 31), until: Date.UTC(2026, 0, 2) }), {++ ok: true, days: [{ day: '2025-12-31', messages: 0, chatters: 0 }, { day: '2026-01-01', messages: 9, chatters: 4 }],++ });++ assert.strictEqual((await series({ kind: 'site-daily', since: T, until: T + 399 * 86400000 })).days.length, 400);++ for (const body of [{ kind: 'site-daily' }, { kind: 'site-daily', since: T }, { kind: 'site-daily', until: T },++ { kind: 'site-daily', since: T, until: T }, { kind: 'site-daily', since: T + 1, until: T },++ { kind: 'site-daily', since: T, until: T + 400 * 86400000 }, { kind: 'site-daily', since: T, until: T + 600e3, user_id: viewer.id },++ { kind: 'site-daily', since: T, until: T + 600e3, limit: 5 }, { kind: 'site-daily', since: T, until: T + 600e3, extra: 1 }])++ await series(body, 400);++});++t('N2 messages: one filter, a cursor on id, tail → max_id', async () => {const live = ['v1', 'v2', 'o1', 'v3', 'a1', 'r1', 'sb', 'sys'].map((k) => ids[k]);const page = (q, status) => read(`/messages?${q}`, 'chat.messages.read', 'chat.messages-page@1', status);@@@ -165,6 +187,24 @@ t('N3 timeline: buckets from since, oldest first, empty ones left out', async ()check('chat.timeline-result@1', await tl(q, 400));});++t('N1c first chat: whether this identity has ever chatted in the channel', async () => {++ await h.db.run(`INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:123', ?), ('anon:anonX', ?), ('ext:[Twitch] foo', ?)`,++ [streamer.id, streamer.id, streamer.id]);++ const fc = async (q, status = 200) => {++ const r = await h.http('GET', `/internal/chat/first-chat?${q}`, { token: token('chat.analysis.read') });++ assert.strictEqual(r.status, status, `${q}: ${r.text}`);++ return r.body;++ };++ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=user:123`), { ok: true, first: false });++ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=anon:anonX`), { ok: true, first: false });++ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=${encodeURIComponent('ext:[Twitch] foo')}`), { ok: true, first: false });++ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=user:999`), { ok: true, first: true });++ assert.deepStrictEqual(await fc(`channel_id=${other.id}&identity=user:123`), { ok: true, first: true }, 'a channel is its owner');++ for (const q of ['', `channel_id=${streamer.id}`, 'identity=user:1', `channel_id=abc&identity=user:1`, `channel_id=0&identity=user:1`,++ `channel_id=${streamer.id}&identity=123`, `channel_id=${streamer.id}&identity=user:`, `channel_id=${streamer.id}&identity=user:${'a'.repeat(200)}`,++ `channel_id=${streamer.id}&identity=user:1&extra=1`]) await fc(q, 400);++});++t('N4 moderation queues: pending IP messages, hidden relay users, TTS overrides', async () => {const run = (sql, p) => h.db.run(sql, p);const pend = async (channel, status, ms) => Number((await run(@@@ -240,6 +280,33 @@ t('N5 sounds: count, pending assets, and an idempotent asset write-back', asyncfor (const path of ['', '?channel_owner_id=abc', '?pending_asset=2', `?channel_owner_id=${streamer.id}&limit=5`]) await s(path, 400);});++t('N5b sounds by command: the approved sound Live plays, or 404', async () => {++ const sound = async (owner, command, approved = 1, asset = null) => Number((await h.db.run(++ `INSERT INTO channel_sounds (channel_owner_id, command, url, duration_seconds, created_by, created_by_name, is_approved, media_url, media_asset_id) VALUES (?, ?, ?, 2, ?, 'uploader', ?, ?, ?)`,++ [owner, command, `/sounds/${command}.mp3`, owner, approved, asset && `https://media.test/a/${asset}`, asset])).lastInsertRowid);++ const a = await sound(host.id, 'honk', 1, 3);++ const b = await sound(host.id, 'honk');++ await sound(host.id, 'quiet', 0);++ const get = async (q, status = 200) => {++ const r = await h.http('GET', `/internal/chat/sounds/by-command?${q}`, { token: token('chat.sounds.read') });++ assert.strictEqual(r.status, status, `${q}: ${r.text}`);++ return r.body;++ };++ const found = (await get(`channel_id=${host.id}&command=HONK`)).sound;++ assert.ok([a, b].includes(found.id), 'one of the channel\'s approved honks');++ assert.deepStrictEqual(found, {++ id: found.id, channel_owner_id: host.id, command: 'honk', url: '/sounds/honk.mp3', mime: 'audio/mpeg',++ duration_seconds: 2, created_by: host.id, created_by_name: 'uploader',++ media_url: found.id === a ? 'https://media.test/a/3' : null, media_asset_id: found.id === a ? 3 : null,++ });++ assert.ok([a, b].includes((await get(`channel_id=${host.id}&command=%20!HONK%20`)).sound.id), 'trimmed, lowercased, ! stripped');++ assert.deepStrictEqual(await get(`channel_id=${other.id}&command=honk`, 404), { ok: false, error: 'Sound not found' }, 'another owner\'s command');++ assert.deepStrictEqual(await get(`channel_id=${host.id}&command=quiet`, 404), { ok: false, error: 'Sound not found' }, 'unapproved');++ for (const q of ['', `channel_id=${host.id}`, 'command=honk', `channel_id=abc&command=honk`, `channel_id=0&command=honk`,++ `channel_id=${host.id}&command=%20`, `channel_id=${host.id}&command=${'x'.repeat(121)}`, `channel_id=${host.id}&command=honk&extra=1`])++ await get(q, 400);++});++t('the message ingress answers first_chat: true once per chatter and channel', async () => {const send = async (key, extra) => {const r = await h.http('POST', '/internal/chat/messages', { body: { key, stream_id: hostStreamId, message: 'hello host', ...extra }, token: token('chat.message.send') });@@@ -355,4 +355,20 @@ t('messages sent before the connection is ready are handled in order', async ()ws.close();});++t('a channel-only WebSocket line records the first chat for that channel', async () => {++ // An offline/PowerChat channel room has no streamId, only channelUserId. The save itself must++ // record the welcome there too, or Live's first-chat read would answer true forever.++ const carol = h.addUser('carol');++ await h.ctx.sync();++ const ws = await h.ws({ ip: '198.51.100.61', token: carol.token });++ ws.sendJson({ type: 'join', channelUserId: streamer.id, token: carol.token });++ await ws.next((m) => m.type === 'auth');++ const identity = `user:${carol.id}`;++ assert.strictEqual(await h.db.isFirstChatInChannel(identity, streamer.id), true, 'carol has not chatted in the channel');++ ws.sendJson({ type: 'chat', message: 'offline channel line' });++ await ws.next((m) => m.type === 'chat' && m.message === 'offline channel line');++ assert.strictEqual(await h.db.isFirstChatInChannel(identity, streamer.id), false, 'the channel-only save recorded it');++ ws.close();++});++t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });
@@ -14,7 +14,7 @@ a stream that does not exist is 404: do not retry a 4xx.
@@ -14,7 +14,7 @@ a stream that does not exist is 404: do not retry a 4xx.
| Route | Capability | Body and result |
| Route | Capability | Body and result |
| --- | --- | --- |
| --- | --- | --- |
-| `POST /internal/chat/messages` | `chat.message.send` | Chat message fields (`stream_id`, `channel_user_id`, `user_id`, `anon_id`, `username`, `message`, `message_type`, `is_global`, `reply_to_id`, `source_platform`, `metadata`), optional `mirror`, `tts:{voice,identity_key,key}`, `auto_delete_at`, and typed chat-frame fields in `frame` (`role`: `admin`/`global_mod`/`streamer`/`user`/`anon`/`external`, `profile_color`: hex, `avatar_url`, `is_ai`/`is_bot`/`filtered`: booleans, `core_username`, `display_name`); the broadcast carries `reply_to_id`, `is_global`, `auto_delete_at` and `metadata` from the body. Returns `{ok,id,stream_id,channel_user_id,first_chat}`: `first_chat` (`chat.send-result` 1.2.0) is true when this line recorded the chatter's first chat in the channel, so Live can welcome them. With no TTS key, Chat uses `m<real message id>`. `dm:{to_user_id}` with `user_id` persists and delivers a direct message (the same `dm` frame as `POST /api/dm`, with the sender looked up by id), returning `{ok,id,conversation_id,to_user_id}`; a blank message is 400. Chat records the first chat in the channel (the welcome check) under `first_chat_key` when given (`user:<id>`, `anon:<anonId>` or `ext:<prefixed username>`), otherwise for a `chat` line only: `ext:<username>` with `source_platform`, else `user:<user_id>`, else `anon:<anon_id>`. |
+| `POST /internal/chat/messages` | `chat.message.send` | Chat message fields (`stream_id`, `channel_user_id`, `user_id`, `anon_id`, `username`, `message`, `message_type`, `is_global`, `reply_to_id`, `source_platform`, `metadata`), optional `mirror`, `tts:{voice,identity_key,key}`, `auto_delete_at`, and typed chat-frame fields in `frame` (`role`: `admin`/`global_mod`/`streamer`/`user`/`anon`/`external`, `profile_color`: hex, `avatar_url`, `is_ai`/`is_bot`/`filtered`: booleans, `core_username`, `display_name`); the broadcast carries `reply_to_id`, `is_global`, `auto_delete_at` and `metadata` from the body. Returns `{ok,id,stream_id,channel_user_id,first_chat}`: `first_chat` (`chat.send-result` 1.2.0) is true when this line recorded the chatter's first chat in the channel, so Live can welcome them. With no TTS key, Chat uses `m<real message id>`. `dm:{to_user_id}` with `user_id` persists and delivers a direct message (the same `dm` frame as `POST /api/dm`, with the sender looked up by id), returning `{ok,id,conversation_id,to_user_id}`; a blank message is 400. Chat records the first chat in the channel (the welcome check) under `first_chat_key` when given (`user:<user_id>`, `anon:<anonId>` or `ext:<prefixed username>`), otherwise for a `chat` line only: `ext:<username>` with `source_platform`, else `user:<user_id>`, else `anon:<anon_id>`. |
| `POST /internal/chat/events` | `chat.event.publish` | `{key,target:{kind,id?},frame:{type,...}}`, returns `{ok:true}`. Target kinds: `stream`, `channel`, `owner-streams`, `user` (positive `id`), `global`, `all` (no `id`). Only the event frame types allowlisted in `internal-ingress.js` are accepted; `dm` and delete frames are refused, and `chat` only as the news card. |
| `POST /internal/chat/events` | `chat.event.publish` | `{key,target:{kind,id?},frame:{type,...}}`, returns `{ok:true}`. Target kinds: `stream`, `channel`, `owner-streams`, `user` (positive `id`), `global`, `all` (no `id`). Only the event frame types allowlisted in `internal-ingress.js` are accepted; `dm` and delete frames are refused, and `chat` only as the news card. |
| `POST /internal/chat/moderation` | `chat.moderation.write` | `{key,action,...}`; actions: `delete-message`, `delete-user-messages`, `delete-anon-messages`, `delete-relay-messages`, `delete-by-range`, `review-pending-ip`, `approve-ip-messages`, `deny-ip-messages`, `relay-hide`, `relay-unhide`, `relay-record`, `tts-voice-override`, `disconnect`, `log`. Deletes return `ids` and broadcast `delete-messages` to every affected surface. |
| `POST /internal/chat/moderation` | `chat.moderation.write` | `{key,action,...}`; actions: `delete-message`, `delete-user-messages`, `delete-anon-messages`, `delete-relay-messages`, `delete-by-range`, `review-pending-ip`, `approve-ip-messages`, `deny-ip-messages`, `relay-hide`, `relay-unhide`, `relay-record`, `tts-voice-override`, `disconnect`, `log`. Deletes return `ids` and broadcast `delete-messages` to every affected surface. |
| `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |
| `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |
@@ -27,15 +27,17 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
@@ -27,15 +27,17 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
| Route | Capability | Request and result |
| Route | Capability | Request and result |
| --- | --- | --- |
| --- | --- | --- |
-| `POST /internal/chat/stats` | `chat.stats.read` | `{kind:'site'\|'user'\|'stream'\|'channel-top', user_id?, stream_id?, channel_user_id?, since?, until?, limit?}`. `site`/`user`(`user_id`)/`stream`(`stream_id`) → `{messages,chatters}` (+ `sounds`, soundboard plays, for `stream`); `channel-top` (by `stream_id`, `channel_user_id` or all chat; `limit` ≤ 50, default 10) → `{top_chatters:[{user_id,username,display_name,avatar_url,profile_color,count}]}`. |
+| `POST /internal/chat/stats` | `chat.stats.read` | `{kind:'site'\|'user'\|'stream'\|'channel-top'\|'site-daily', user_id?, stream_id?, channel_user_id?, since?, until?, limit?}`. `site`/`user`(`user_id`)/`stream`(`stream_id`) → `{messages,chatters}` (+ `sounds`, soundboard plays, for `stream`); `channel-top` (by `stream_id`, `channel_user_id` or all chat; `limit` ≤ 50, default 10) → `{top_chatters:[{user_id,username,display_name,avatar_url,profile_color,count}]}`; `site-daily` (`since` and `until` required, epoch ms, at most 400 UTC days) → `{days:[{day,messages,chatters}]}`, one row per UTC day in `[since,until)` with zero-filled gaps: every non-deleted message across all channels (Live's home `messages` series) and distinct chatters (Live's `active`: `COALESCE(user_id,anon_id,source_platform‖username)`). |
| `GET /internal/chat/messages` | `chat.messages.read` | Exactly one of `channel_user_id`, `stream_id`, `user_id`, `anon_id`, `username`, `id`; `after_id` (oldest first), `before_id` (newest first; the default), `limit` ≤ 500 (default 100), `types=chat,donation,…`, `tail=1` (no rows) → `{messages,max_id}`; `max_id` is the newest matching id. |
| `GET /internal/chat/messages` | `chat.messages.read` | Exactly one of `channel_user_id`, `stream_id`, `user_id`, `anon_id`, `username`, `id`; `after_id` (oldest first), `before_id` (newest first; the default), `limit` ≤ 500 (default 100), `types=chat,donation,…`, `tail=1` (no rows) → `{messages,max_id}`; `max_id` is the newest matching id. |
| `GET /internal/chat/timeline` | `chat.analysis.read` | `channel_user_id` or `stream_id`, `since`, `until` (default now), `bucket_ms` ≥ 1000 (≤ 10 000 buckets) → `{buckets:[{t,count}],max_id}`; buckets start at `since`, oldest first, empty ones left out. |
| `GET /internal/chat/timeline` | `chat.analysis.read` | `channel_user_id` or `stream_id`, `since`, `until` (default now), `bucket_ms` ≥ 1000 (≤ 10 000 buckets) → `{buckets:[{t,count}],max_id}`; buckets start at `since`, oldest first, empty ones left out. |
+| `GET /internal/chat/first-chat?channel_id&identity` | `chat.analysis.read` | `{first}`: true when `identity` (`user:<user_id>`, `anon:<anonId>`, `ext:<prefixed username>`; ≤ 160) has never chatted in the channel — Live's `isFirstChatInChannel`, from `stream_first_chats`. `channel_id` is the channel owner's Live user id (`stream_first_chats.channel_user_id`), not `ctx_channels.id`. A registered identity is `user:<user_id>`, the numeric Live user id and **never** the username: Live's own `user:${line.username}` key would read `first:true` forever, so its welcome check must pass `user:${line.user_id}`. |
| `GET /internal/chat/moderation/pending-ip?channel_id` | `chat.moderation.queue.read` | `{pending_ip}`: the channel's pending rows, oldest first (`limit` ≤ 500, default 50). |
| `GET /internal/chat/moderation/pending-ip?channel_id` | `chat.moderation.queue.read` | `{pending_ip}`: the channel's pending rows, oldest first (`limit` ≤ 500, default 50). |
| `GET /internal/chat/moderation/relay-users?channel_id` | `chat.moderation.queue.read` | `{relay_users}`: hidden or banned relay names on the channel or everywhere, newest first, with `created_by_username` (`limit` ≤ 500, default 100). |
| `GET /internal/chat/moderation/relay-users?channel_id` | `chat.moderation.queue.read` | `{relay_users}`: hidden or banned relay names on the channel or everywhere, newest first, with `created_by_username` (`limit` ≤ 500, default 100). |
| `GET /internal/chat/moderation/relay-users/:id` | `chat.moderation.queue.read` | `{relay_user}`, null when unknown. |
| `GET /internal/chat/moderation/relay-users/:id` | `chat.moderation.queue.read` | `{relay_user}`, null when unknown. |
| `GET /internal/chat/moderation/tts-override?identity_key` | `chat.moderation.queue.read` | `{tts_override}` (key trimmed and lowercased), null when none. |
| `GET /internal/chat/moderation/tts-override?identity_key` | `chat.moderation.queue.read` | `{tts_override}` (key trimmed and lowercased), null when none. |
| `GET /internal/chat/sounds?channel_owner_id` (also `/sounds/count`) | `chat.sounds.read` | `{count}` of the channel's sounds. |
| `GET /internal/chat/sounds?channel_owner_id` (also `/sounds/count`) | `chat.sounds.read` | `{count}` of the channel's sounds. |
| `GET /internal/chat/sounds?pending_asset=1` | `chat.sounds.read` | `{sounds}` not yet on Media (`media_asset_id` null), by id; optional `channel_owner_id`, `after_id`, `limit` ≤ 500 (default 100). |
| `GET /internal/chat/sounds?pending_asset=1` | `chat.sounds.read` | `{sounds}` not yet on Media (`media_asset_id` null), by id; optional `channel_owner_id`, `after_id`, `limit` ≤ 500 (default 100). |
+| `GET /internal/chat/sounds/by-command?channel_id&command` | `chat.sounds.read` | `{sound}`: the approved (`is_approved=1`) channel sound `command` plays, one at random when several match, or 404 — Live's `getChannelSoundByCommand`. `command` is trimmed, lowercased and has leading `!` stripped (≤ 120); `channel_id` is the channel owner's Live user id (`channel_sounds.channel_owner_id`); same projection as `/sounds`. |
| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. Until Live writes here, the read mirror leaves Live's own asset columns alone for a sound Chat has no asset for. |
| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. Until Live writes here, the read mirror leaves Live's own asset columns alone for a sound Chat has no asset for. |
`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.
`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.
@@ -64,7 +66,11 @@ the read API above, Network's `DEFAULT_GRANTS` must add `live chat.stats.read op
@@ -64,7 +66,11 @@ the read API above, Network's `DEFAULT_GRANTS` must add `live chat.stats.read op
`live chat.messages.read openvibe.chat`, `live chat.analysis.read openvibe.chat`,
`live chat.messages.read openvibe.chat`, `live chat.analysis.read openvibe.chat`,
`live chat.moderation.queue.read openvibe.chat`, `live chat.sounds.read openvibe.chat` and
`live chat.moderation.queue.read openvibe.chat`, `live chat.sounds.read openvibe.chat` and
`live chat.sounds.write openvibe.chat`. Chat's
`live chat.sounds.write openvibe.chat`. Chat's
-local proposals are in `docs/capabilities-proposal/`.
+local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's home
+series, its welcome check and its robot channel sounds use those same capabilities but add result
+schemas `chat.site-daily-result@1`, `chat.first-chat-result@1` and `chat.sound-result@1`, which
+land in openvibe-contracts 0.103.0 (the Contracts change is separate; Live codes against the
+shapes here until that version is pinned).
Live J2 (done: Live #20/#21) replaced its bridge writer with a typed client: repoint message producers and persisted DMs
Live J2 (done: Live #20/#21) replaced its bridge writer with a typed client: repoint message producers and persisted DMs
to `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption
to `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption
@@ -919,7 +919,9 @@ class ChatServer {
@@ -919,7 +919,9 @@ class ChatServer {
} catch { /* no badge */ }
} catch { /* no badge */ }
}
}
- // Save to database
+ // Save to database. The save also records the first chat in the resolved channel
+ // (channel_user_id, or the stream owner), so a channel-only line counts too.
+ let firstChat = false;
try {
try {
const result = await db.saveChatMessage({
const result = await db.saveChatMessage({
stream_id: client.streamId || null,
stream_id: client.streamId || null,
@@ -935,6 +937,7 @@ class ChatServer {
@@ -935,6 +937,7 @@ class ChatServer {
metadata: chatMsg.vip_badge ? { vip_badge: chatMsg.vip_badge } : undefined,
metadata: chatMsg.vip_badge ? { vip_badge: chatMsg.vip_badge } : undefined,
});
});
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
+ firstChat = !!result.first_chat;
} catch (err) {
} catch (err) {
// Not saved means not sent: a line everyone saw but history, moderation and replays never
// Not saved means not sent: a line everyone saw but history, moderation and replays never
// have would be a ghost. Tell the sender and stop here.
// have would be a ghost. Tell the sender and stop here.
@@ -957,22 +960,16 @@ class ChatServer {
@@ -957,22 +960,16 @@ class ChatServer {
powerchat: !!client.channelUserId,
powerchat: !!client.channelUserId,
};
};
- // Welcome first-time chatters in this streamer's channel
- if (client.streamId) {
+ // Welcome first-time chatters in this streamer's channel. The save answered firstChat
+ // (it recorded it for the resolved channel), so there is nothing left to check here.
+ if (client.streamId && firstChat) {
try {
try {
- const stream = await ctx.getStreamById(client.streamId);
- if (stream?.user_id) {
- const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;
- if (await db.isFirstChatInChannel(chatterKey, stream.user_id)) {
- await db.recordFirstChat(chatterKey, stream.user_id);
- const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';
- await this.broadcastToStream(client.streamId, {
- type: 'system',
- message: `Welcome ${welcomeName} to the chat! 👋`,
- timestamp: new Date().toISOString(),
- });
- }
- }
+ const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';
+ await this.broadcastToStream(client.streamId, {
+ type: 'system',
+ message: `Welcome ${welcomeName} to the chat! 👋`,
+ timestamp: new Date().toISOString(),
+ });
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -130,16 +130,6 @@ function validateMessage(b) {
@@ -130,16 +130,6 @@ function validateMessage(b) {
}
}
}
}
-// The keys chat-server's welcome check reads (`user:`/`anon:`) and Live's relay saves (`ext:` +
-// the prefixed username it stores relay lines under). Only chat lines use up a welcome.
-function firstChatKey(b) {
- if (b.first_chat_key) return b.first_chat_key;
- if ((b.message_type || 'chat') !== 'chat') return null;
- if (b.source_platform) return `ext:${b.username}`;
- if (b.user_id) return `user:${b.user_id}`;
- return b.anon_id ? `anon:${b.anon_id}` : null;
-}
-
async function writeMessage(b) {
async function writeMessage(b) {
if (b.dm) {
if (b.dm) {
if (await dm.isBlockedEither(b.user_id, b.dm.to_user_id)) { const e = new Error('DM participants are blocked'); e.status = 403; throw e; }
if (await dm.isBlockedEither(b.user_id, b.dm.to_user_id)) { const e = new Error('DM participants are blocked'); e.status = 403; throw e; }
@@ -153,10 +143,9 @@ async function writeMessage(b) {
@@ -153,10 +143,9 @@ async function writeMessage(b) {
const saved = await db.saveChatMessage({ ...b, is_global: b.is_global ?? (!b.stream_id && !b.channel_user_id) });
const saved = await db.saveChatMessage({ ...b, is_global: b.is_global ?? (!b.stream_id && !b.channel_user_id) });
const id = Number(saved.lastInsertRowid);
const id = Number(saved.lastInsertRowid);
const channelUserId = b.channel_user_id || (b.stream_id ? (await ctx.getStreamById(b.stream_id))?.user_id : null) || null;
const channelUserId = b.channel_user_id || (b.stream_id ? (await ctx.getStreamById(b.stream_id))?.user_id : null) || null;
- const chatterKey = firstChatKey(b);
- // first_chat (chat.send-result 1.2.0): this line used up the chatter's welcome in the channel.
- const first_chat = !!(channelUserId && chatterKey) && await db.recordFirstChat(chatterKey, channelUserId);
- return { id, stream_id: b.stream_id || null, channel_user_id: channelUserId, first_chat };
+ // first_chat (chat.send-result 1.2.0): the save records the welcome in the resolved
+ // channel, so its answer already says whether this line used it up.
+ return { id, stream_id: b.stream_id || null, channel_user_id: channelUserId, first_chat: !!saved.first_chat };
}
}
async function deliverMessage(chatServer, b, result) {
async function deliverMessage(chatServer, b, result) {
@@ -4,15 +4,26 @@
@@ -4,15 +4,26 @@
* openvibe.chat, one capability per route (openvibe-contracts 0.95.0):
* openvibe.chat, one capability per route (openvibe-contracts 0.95.0):
*
*
* POST /internal/chat/stats chat.stats.read chat.stats-request@1 → chat.stats-result@1
* POST /internal/chat/stats chat.stats.read chat.stats-request@1 → chat.stats-result@1
+ * kind site-daily → chat.site-daily-result@1 { days }
* GET /internal/chat/messages chat.messages.read → chat.messages-page@1
* GET /internal/chat/messages chat.messages.read → chat.messages-page@1
* GET /internal/chat/timeline chat.analysis.read → chat.timeline-result@1
* GET /internal/chat/timeline chat.analysis.read → chat.timeline-result@1
+ * GET /internal/chat/first-chat chat.analysis.read → chat.first-chat-result@1 { first }
* GET /internal/chat/moderation/pending-ip chat.moderation.queue.read → chat.moderation-queue-result@1 { pending_ip }
* GET /internal/chat/moderation/pending-ip chat.moderation.queue.read → chat.moderation-queue-result@1 { pending_ip }
* GET /internal/chat/moderation/relay-users chat.moderation.queue.read → { relay_users }
* GET /internal/chat/moderation/relay-users chat.moderation.queue.read → { relay_users }
* GET /internal/chat/moderation/relay-users/:id chat.moderation.queue.read → { relay_user }
* GET /internal/chat/moderation/relay-users/:id chat.moderation.queue.read → { relay_user }
* GET /internal/chat/moderation/tts-override chat.moderation.queue.read → { tts_override }
* GET /internal/chat/moderation/tts-override chat.moderation.queue.read → { tts_override }
* GET /internal/chat/sounds(/count) chat.sounds.read → chat.sounds-result@1 { count } | { sounds }
* GET /internal/chat/sounds(/count) chat.sounds.read → chat.sounds-result@1 { count } | { sounds }
+ * GET /internal/chat/sounds/by-command chat.sounds.read → chat.sound-result@1 { sound }
* POST /internal/chat/sounds/asset chat.sounds.write chat.sound-asset-request@1 → chat.ingress-ack@1
* POST /internal/chat/sounds/asset chat.sounds.write chat.sound-asset-request@1 → chat.ingress-ack@1
*
*
+ * The three plan-T3 reads added for Live's home series, its welcome check and its robot channel
+ * sounds (chat.site-daily-result@1, chat.first-chat-result@1, chat.sound-result@1) land in
+ * openvibe-contracts 0.103.0 (a separate Contracts change); until it is pinned, Live's next PR
+ * codes against the shapes documented in docs/chat-ingress.md.
+ * In first-chat and sounds/by-command, `channel_id` is the channel owner's Live user id — the value
+ * Live passes as channelUserId to isFirstChatInChannel and channel_owner_id to
+ * getChannelSoundByCommand (both stream.user_id), not Chat's ctx_channels.id.
+ *
* Every parameter is validated (unknown ones are 400) and bound; deleted messages are never read.
* Every parameter is validated (unknown ones are 400) and bound; deleted messages are never read.
* Times in requests are epoch ms; chat_messages.timestamp is UTC 'YYYY-MM-DD HH:MM:SS' text, so a
* Times in requests are epoch ms; chat_messages.timestamp is UTC 'YYYY-MM-DD HH:MM:SS' text, so a
* window compares text and keeps the timestamp indexes.
* window compares text and keeps the timestamp indexes.
@@ -27,6 +38,8 @@ const MESSAGE_TYPES = ['chat', 'system', 'donation', 'command', 'tts', 'channel-
@@ -27,6 +38,8 @@ const MESSAGE_TYPES = ['chat', 'system', 'donation', 'command', 'tts', 'channel-
const MAX_MESSAGES = 500;
const MAX_MESSAGES = 500;
const MAX_ROWS = 500;
const MAX_ROWS = 500;
const MAX_BUCKETS = 10_000;
const MAX_BUCKETS = 10_000;
+const MAX_SERIES_DAYS = 400;
+const DAY_MS = 86_400_000;
const bad = (message) => { const e = new Error(message); e.status = 400; throw e; };
const bad = (message) => { const e = new Error(message); e.status = 400; throw e; };
const id = (v) => Number.isSafeInteger(v) && v > 0;
const id = (v) => Number.isSafeInteger(v) && v > 0;
@@ -62,6 +75,9 @@ function query(req, spec) {
@@ -62,6 +75,9 @@ function query(req, spec) {
function limit(v, dflt, max) { return v == null ? dflt : Math.min(v, max); }
function limit(v, dflt, max) { return v == null ? dflt : Math.min(v, max); }
+const dayStart = (t) => Math.floor(t / DAY_MS) * DAY_MS;
+const dayCount = (since, until) => (dayStart(until - 1) - dayStart(since)) / DAY_MS + 1;
+
function route(name, fn) {
function route(name, fn) {
return async (req, res) => {
return async (req, res) => {
try {
try {
@@ -78,8 +94,8 @@ function route(name, fn) {
@@ -78,8 +94,8 @@ function route(name, fn) {
const CHATTER = `COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)`;
const CHATTER = `COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)`;
const STATS_FIELDS = ['kind', 'user_id', 'stream_id', 'channel_user_id', 'since', 'until', 'limit'];
const STATS_FIELDS = ['kind', 'user_id', 'stream_id', 'channel_user_id', 'since', 'until', 'limit'];
-function statsFilter(b) {
- const where = ['is_deleted = 0'];
+function statsFilter(b, deleted = 'is_deleted = 0') {
+ const where = [deleted];
const params = [];
const params = [];
for (const k of ['user_id', 'stream_id', 'channel_user_id']) if (b[k] != null) { where.push(`${k} = ?`); params.push(b[k]); }
for (const k of ['user_id', 'stream_id', 'channel_user_id']) if (b[k] != null) { where.push(`${k} = ?`); params.push(b[k]); }
if (b.since != null) { where.push('timestamp >= ?'); params.push(sqlTime(b.since)); }
if (b.since != null) { where.push('timestamp >= ?'); params.push(sqlTime(b.since)); }
@@ -90,14 +106,38 @@ function statsFilter(b) {
@@ -90,14 +106,38 @@ function statsFilter(b) {
async function stats(req) {
async function stats(req) {
const b = req.body;
const b = req.body;
if (!b || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).some((k) => !STATS_FIELDS.includes(k))) bad('Invalid fields');
if (!b || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).some((k) => !STATS_FIELDS.includes(k))) bad('Invalid fields');
- if (!['site', 'user', 'stream', 'channel-top'].includes(b.kind)) bad('Invalid kind');
+ if (!['site', 'user', 'stream', 'channel-top', 'site-daily'].includes(b.kind)) bad('Invalid kind');
for (const k of ['user_id', 'stream_id', 'channel_user_id']) if (b[k] != null && !id(b[k])) bad(`Invalid ${k}`);
for (const k of ['user_id', 'stream_id', 'channel_user_id']) if (b[k] != null && !id(b[k])) bad(`Invalid ${k}`);
for (const k of ['since', 'until']) if (b[k] != null && !ms(b[k])) bad(`Invalid ${k}`);
for (const k of ['since', 'until']) if (b[k] != null && !ms(b[k])) bad(`Invalid ${k}`);
if (b.limit != null && !(Number.isSafeInteger(b.limit) && b.limit >= 1 && b.limit <= 50)) bad('Invalid limit');
if (b.limit != null && !(Number.isSafeInteger(b.limit) && b.limit >= 1 && b.limit <= 50)) bad('Invalid limit');
if (b.kind === 'user' && b.user_id == null) bad('kind user needs user_id');
if (b.kind === 'user' && b.user_id == null) bad('kind user needs user_id');
if (b.kind === 'stream' && b.stream_id == null) bad('kind stream needs stream_id');
if (b.kind === 'stream' && b.stream_id == null) bad('kind stream needs stream_id');
if (b.kind === 'channel-top' && b.user_id != null) bad('kind channel-top takes stream_id or channel_user_id');
if (b.kind === 'channel-top' && b.user_id != null) bad('kind channel-top takes stream_id or channel_user_id');
- const { where, params } = statsFilter(b);
+ if (b.kind === 'site-daily') {
+ if (b.since == null || b.until == null) bad('kind site-daily needs since and until');
+ if (b.user_id != null || b.stream_id != null || b.channel_user_id != null || b.limit != null) bad('kind site-daily takes since and until only');
+ if (b.until <= b.since) bad('until must be after since');
+ if (dayCount(b.since, b.until) > MAX_SERIES_DAYS) bad('Invalid window');
+ }
+ // Live's series reader counts a NULL is_deleted as live (COALESCE(is_deleted,0)=0); the
+ // other kinds keep Chat's is_deleted = 0.
+ const { where, params } = statsFilter(b, b.kind === 'site-daily' ? 'COALESCE(is_deleted, 0) = 0' : 'is_deleted = 0');
+ if (b.kind === 'site-daily') {
+ // Live's home series (HOME_SERIES messages/active): every non-deleted row counts, whatever
+ // its type; a chatter is a distinct user, anon or relayed name. One row per UTC day in
+ // [since, until), zeros filled, so the chart never skips a day.
+ const rows = await db.all(
+ `SELECT substr(timestamp, 1, 10) AS day, COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters
+ FROM chat_messages WHERE ${where} GROUP BY day`, params);
+ const by = new Map(rows.map((r) => [String(r.day), r]));
+ const days = [];
+ for (let t = dayStart(b.since), last = dayStart(b.until - 1); t <= last; t += DAY_MS) {
+ const day = new Date(t).toISOString().slice(0, 10);
+ const r = by.get(day);
+ days.push({ day, messages: r ? Number(r.messages) : 0, chatters: r ? Number(r.chatters) : 0 });
+ }
+ return { days };
+ }
if (b.kind !== 'channel-top') {
if (b.kind !== 'channel-top') {
const r = await db.get(
const r = await db.get(
`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters,
`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters,
@@ -182,6 +222,17 @@ async function timeline(req) {
@@ -182,6 +222,17 @@ async function timeline(req) {
};
};
}
}
+/** Live's welcome check: has this identity ever chatted in this channel (stream_first_chats)? */
+async function firstChat(req) {
+ const q = query(req, { channel_id: 'id', identity: 160 });
+ if (q.channel_id == null) bad('channel_id is required');
+ // Same key shapes internal-ingress records: user:<id>, anon:<anonId>, ext:<prefixed username>.
+ if (!/^(?:user|anon|ext):./.test(String(q.identity || ''))) bad('Invalid identity');
+ const row = await db.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
+ [q.identity, q.channel_id]);
+ return { first: !row };
+}
+
// ── N4: moderation queues ──
// ── N4: moderation queues ──
function pendingIpRow(r) {
function pendingIpRow(r) {
return {
return {
@@ -281,10 +332,24 @@ async function soundAsset(req) {
@@ -281,10 +332,24 @@ async function soundAsset(req) {
return {};
return {};
}
}
+/** Live's getChannelSoundByCommand: the approved sound a !command plays, picked at random, or 404. */
+async function soundByCommand(req) {
+ const q = query(req, { channel_id: 'id', command: 120 });
+ if (q.channel_id == null) bad('channel_id is required');
+ const command = String(q.command || '').trim().toLowerCase().replace(/^!+/, '');
+ if (!command) bad('command is required');
+ const row = await db.get(
+ 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',
+ [q.channel_id, command]);
+ if (!row) { const e = new Error('Sound not found'); e.status = 404; throw e; }
+ return { sound: soundRow(row) };
+}
+
const router = express.Router();
const router = express.Router();
router.post('/stats', serviceAuth.guard('chat.stats.read'), route('stats', stats));
router.post('/stats', serviceAuth.guard('chat.stats.read'), route('stats', stats));
router.get('/messages', serviceAuth.guard('chat.messages.read'), route('messages', messages));
router.get('/messages', serviceAuth.guard('chat.messages.read'), route('messages', messages));
router.get('/timeline', serviceAuth.guard('chat.analysis.read'), route('timeline', timeline));
router.get('/timeline', serviceAuth.guard('chat.analysis.read'), route('timeline', timeline));
+router.get('/first-chat', serviceAuth.guard('chat.analysis.read'), route('first-chat', firstChat));
const queue = serviceAuth.guard('chat.moderation.queue.read');
const queue = serviceAuth.guard('chat.moderation.queue.read');
router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));
router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));
router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));
router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));
@@ -292,6 +357,7 @@ router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser))
@@ -292,6 +357,7 @@ router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser))
router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));
router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));
router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));
router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));
router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));
router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));
+router.get('/sounds/by-command', serviceAuth.guard('chat.sounds.read'), route('sound-by-command', soundByCommand));
router.post('/sounds/asset', serviceAuth.guard('chat.sounds.write'), route('sound-asset', soundAsset));
router.post('/sounds/asset', serviceAuth.guard('chat.sounds.write'), route('sound-asset', soundAsset));
module.exports = router;
module.exports = router;
@@ -168,7 +168,17 @@ async function _announceDeleted(ids) {
@@ -168,7 +168,17 @@ async function _announceDeleted(ids) {
// ── Chat messages ─────────────────────────────────────────────
// ── Chat messages ─────────────────────────────────────────────
-async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {
+// The welcome identity a save records, keyed the way the first-chat read checks it: an explicit
+// ingress key wins, only chat lines otherwise count, and relayed lines live under ext:<username>.
+function firstChatKey({ message_type, source_platform, user_id, anon_id, username, first_chat_key }) {
+ if (first_chat_key) return first_chat_key;
+ if ((message_type || 'chat') !== 'chat') return null;
+ if (source_platform) return `ext:${username}`;
+ if (user_id) return `user:${user_id}`;
+ return anon_id ? `anon:${anon_id}` : null;
+}
+
+async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, first_chat_key }) {
// channel_user_id = the broadcaster's user id — set for all channel/stream
// channel_user_id = the broadcaster's user id — set for all channel/stream
// messages so a streamer's chat history survives across sessions AND offline
// messages so a streamer's chat history survives across sessions AND offline
// periods (independent of the live-session stream row's lifetime).
// periods (independent of the live-session stream row's lifetime).
@@ -176,12 +186,17 @@ async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, u
@@ -176,12 +186,17 @@ async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, u
if (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }
if (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }
const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
const subject = await subjectFor(user_id);
const subject = await subjectFor(user_id);
+ // Every save path records the chatter's first chat in the resolved channel here, so a
+ // channel-only line (a PowerChat or offline room with no stream) counts too. Only chat lines
+ // take a welcome (see firstChatKey); the record joins the row's transaction.
+ const chatterKey = firstChatKey({ message_type, source_platform, user_id, anon_id, username, first_chat_key });
return await transaction(async () => {
return await transaction(async () => {
const res = await run(
const res = await run(
`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)
`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]
[stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]
);
);
+ res.first_chat = !!(chatterKey && chanUid) && await recordFirstChat(chatterKey, chanUid);
await _outbox().enqueue({
await _outbox().enqueue({
event_type: 'chat.message.created',
event_type: 'chat.message.created',
visibility: 'public',
visibility: 'public',
@@ -81,6 +81,7 @@ t('each route needs its own capability, a service token for Chat and loopback',
@@ -81,6 +81,7 @@ t('each route needs its own capability, a service token for Chat and loopback',
['POST', '/stats', 'chat.stats.read', { kind: 'site' }],
['POST', '/stats', 'chat.stats.read', { kind: 'site' }],
['GET', `/messages?stream_id=${streamId}`, 'chat.messages.read'],
['GET', `/messages?stream_id=${streamId}`, 'chat.messages.read'],
['GET', `/timeline?stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`, 'chat.analysis.read'],
['GET', `/timeline?stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`, 'chat.analysis.read'],
+ ['GET', `/first-chat?channel_id=${streamer.id}&identity=user:${viewer.id}`, 'chat.analysis.read'],
['GET', `/moderation/pending-ip?channel_id=${channelId}`, 'chat.moderation.queue.read'],
['GET', `/moderation/pending-ip?channel_id=${channelId}`, 'chat.moderation.queue.read'],
['GET', `/moderation/relay-users?channel_id=${channelId}`, 'chat.moderation.queue.read'],
['GET', `/moderation/relay-users?channel_id=${channelId}`, 'chat.moderation.queue.read'],
['GET', '/moderation/relay-users/1', 'chat.moderation.queue.read'],
['GET', '/moderation/relay-users/1', 'chat.moderation.queue.read'],
@@ -88,6 +89,7 @@ t('each route needs its own capability, a service token for Chat and loopback',
@@ -88,6 +89,7 @@ t('each route needs its own capability, a service token for Chat and loopback',
['GET', `/sounds?channel_owner_id=${streamer.id}`, 'chat.sounds.read'],
['GET', `/sounds?channel_owner_id=${streamer.id}`, 'chat.sounds.read'],
['GET', `/sounds/count?channel_owner_id=${streamer.id}`, 'chat.sounds.read'],
['GET', `/sounds/count?channel_owner_id=${streamer.id}`, 'chat.sounds.read'],
['GET', '/sounds?pending_asset=1', 'chat.sounds.read'],
['GET', '/sounds?pending_asset=1', 'chat.sounds.read'],
+ ['GET', `/sounds/by-command?channel_id=${streamer.id}&command=honk`, 'chat.sounds.read', undefined, 404],
['POST', '/sounds/asset', 'chat.sounds.write', { id: 999999, media_url: 'https://media.test/x', media_asset_id: 1 }, 404],
['POST', '/sounds/asset', 'chat.sounds.write', { id: 999999, media_url: 'https://media.test/x', media_asset_id: 1 }, 404],
];
];
for (const [method, path, cap, body, ok = 200] of routes) {
for (const [method, path, cap, body, ok = 200] of routes) {
@@ -125,6 +127,26 @@ t('N1 stats: site, user, stream and channel-top over non-deleted lines in the wi
@@ -125,6 +127,26 @@ t('N1 stats: site, user, stream and channel-top over non-deleted lines in the wi
{ kind: 'channel-top', user_id: viewer.id }]) await stats(body, 400);
{ kind: 'channel-top', user_id: viewer.id }]) await stats(body, 400);
});
});
+t('N1b stats site-daily: one row per UTC day over every channel, zeros filled', async () => {
+ const series = async (body, status = 200) => {
+ const r = await h.http('POST', '/internal/chat/stats', { body, token: token('chat.stats.read') });
+ assert.strictEqual(r.status, status, `${JSON.stringify(body)}: ${r.text}`);
+ return r.body;
+ };
+ assert.deepStrictEqual(await series({ kind: 'site-daily', since: T, until: T + 600e3 }),
+ { ok: true, days: [{ day: '2026-01-01', messages: 9, chatters: 4 }] });
+ // [2025-12-31, 2026-01-02): two UTC days; the empty first day is a zero row, not skipped.
+ assert.deepStrictEqual(await series({ kind: 'site-daily', since: Date.UTC(2025, 11, 31), until: Date.UTC(2026, 0, 2) }), {
+ ok: true, days: [{ day: '2025-12-31', messages: 0, chatters: 0 }, { day: '2026-01-01', messages: 9, chatters: 4 }],
+ });
+ assert.strictEqual((await series({ kind: 'site-daily', since: T, until: T + 399 * 86400000 })).days.length, 400);
+ for (const body of [{ kind: 'site-daily' }, { kind: 'site-daily', since: T }, { kind: 'site-daily', until: T },
+ { kind: 'site-daily', since: T, until: T }, { kind: 'site-daily', since: T + 1, until: T },
+ { kind: 'site-daily', since: T, until: T + 400 * 86400000 }, { kind: 'site-daily', since: T, until: T + 600e3, user_id: viewer.id },
+ { kind: 'site-daily', since: T, until: T + 600e3, limit: 5 }, { kind: 'site-daily', since: T, until: T + 600e3, extra: 1 }])
+ await series(body, 400);
+});
+
t('N2 messages: one filter, a cursor on id, tail → max_id', async () => {
t('N2 messages: one filter, a cursor on id, tail → max_id', async () => {
const live = ['v1', 'v2', 'o1', 'v3', 'a1', 'r1', 'sb', 'sys'].map((k) => ids[k]);
const live = ['v1', 'v2', 'o1', 'v3', 'a1', 'r1', 'sb', 'sys'].map((k) => ids[k]);
const page = (q, status) => read(`/messages?${q}`, 'chat.messages.read', 'chat.messages-page@1', status);
const page = (q, status) => read(`/messages?${q}`, 'chat.messages.read', 'chat.messages-page@1', status);
@@ -165,6 +187,24 @@ t('N3 timeline: buckets from since, oldest first, empty ones left out', async ()
@@ -165,6 +187,24 @@ t('N3 timeline: buckets from since, oldest first, empty ones left out', async ()
check('chat.timeline-result@1', await tl(q, 400));
check('chat.timeline-result@1', await tl(q, 400));
});
});
+t('N1c first chat: whether this identity has ever chatted in the channel', async () => {
+ await h.db.run(`INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:123', ?), ('anon:anonX', ?), ('ext:[Twitch] foo', ?)`,
+ [streamer.id, streamer.id, streamer.id]);
+ const fc = async (q, status = 200) => {
+ const r = await h.http('GET', `/internal/chat/first-chat?${q}`, { token: token('chat.analysis.read') });
+ assert.strictEqual(r.status, status, `${q}: ${r.text}`);
+ return r.body;
+ };
+ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=user:123`), { ok: true, first: false });
+ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=anon:anonX`), { ok: true, first: false });
+ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=${encodeURIComponent('ext:[Twitch] foo')}`), { ok: true, first: false });
+ assert.deepStrictEqual(await fc(`channel_id=${streamer.id}&identity=user:999`), { ok: true, first: true });
+ assert.deepStrictEqual(await fc(`channel_id=${other.id}&identity=user:123`), { ok: true, first: true }, 'a channel is its owner');
+ for (const q of ['', `channel_id=${streamer.id}`, 'identity=user:1', `channel_id=abc&identity=user:1`, `channel_id=0&identity=user:1`,
+ `channel_id=${streamer.id}&identity=123`, `channel_id=${streamer.id}&identity=user:`, `channel_id=${streamer.id}&identity=user:${'a'.repeat(200)}`,
+ `channel_id=${streamer.id}&identity=user:1&extra=1`]) await fc(q, 400);
+});
+
t('N4 moderation queues: pending IP messages, hidden relay users, TTS overrides', async () => {
t('N4 moderation queues: pending IP messages, hidden relay users, TTS overrides', async () => {
const run = (sql, p) => h.db.run(sql, p);
const run = (sql, p) => h.db.run(sql, p);
const pend = async (channel, status, ms) => Number((await run(
const pend = async (channel, status, ms) => Number((await run(
@@ -240,6 +280,33 @@ t('N5 sounds: count, pending assets, and an idempotent asset write-back', async
@@ -240,6 +280,33 @@ t('N5 sounds: count, pending assets, and an idempotent asset write-back', async
for (const path of ['', '?channel_owner_id=abc', '?pending_asset=2', `?channel_owner_id=${streamer.id}&limit=5`]) await s(path, 400);
for (const path of ['', '?channel_owner_id=abc', '?pending_asset=2', `?channel_owner_id=${streamer.id}&limit=5`]) await s(path, 400);
});
});
+t('N5b sounds by command: the approved sound Live plays, or 404', async () => {
+ const sound = async (owner, command, approved = 1, asset = null) => Number((await h.db.run(
+ `INSERT INTO channel_sounds (channel_owner_id, command, url, duration_seconds, created_by, created_by_name, is_approved, media_url, media_asset_id) VALUES (?, ?, ?, 2, ?, 'uploader', ?, ?, ?)`,
+ [owner, command, `/sounds/${command}.mp3`, owner, approved, asset && `https://media.test/a/${asset}`, asset])).lastInsertRowid);
+ const a = await sound(host.id, 'honk', 1, 3);
+ const b = await sound(host.id, 'honk');
+ await sound(host.id, 'quiet', 0);
+ const get = async (q, status = 200) => {
+ const r = await h.http('GET', `/internal/chat/sounds/by-command?${q}`, { token: token('chat.sounds.read') });
+ assert.strictEqual(r.status, status, `${q}: ${r.text}`);
+ return r.body;
+ };
+ const found = (await get(`channel_id=${host.id}&command=HONK`)).sound;
+ assert.ok([a, b].includes(found.id), 'one of the channel\'s approved honks');
+ assert.deepStrictEqual(found, {
+ id: found.id, channel_owner_id: host.id, command: 'honk', url: '/sounds/honk.mp3', mime: 'audio/mpeg',
+ duration_seconds: 2, created_by: host.id, created_by_name: 'uploader',
+ media_url: found.id === a ? 'https://media.test/a/3' : null, media_asset_id: found.id === a ? 3 : null,
+ });
+ assert.ok([a, b].includes((await get(`channel_id=${host.id}&command=%20!HONK%20`)).sound.id), 'trimmed, lowercased, ! stripped');
+ assert.deepStrictEqual(await get(`channel_id=${other.id}&command=honk`, 404), { ok: false, error: 'Sound not found' }, 'another owner\'s command');
+ assert.deepStrictEqual(await get(`channel_id=${host.id}&command=quiet`, 404), { ok: false, error: 'Sound not found' }, 'unapproved');
+ for (const q of ['', `channel_id=${host.id}`, 'command=honk', `channel_id=abc&command=honk`, `channel_id=0&command=honk`,
+ `channel_id=${host.id}&command=%20`, `channel_id=${host.id}&command=${'x'.repeat(121)}`, `channel_id=${host.id}&command=honk&extra=1`])
+ await get(q, 400);
+});
+
t('the message ingress answers first_chat: true once per chatter and channel', async () => {
t('the message ingress answers first_chat: true once per chatter and channel', async () => {
const send = async (key, extra) => {
const send = async (key, extra) => {
const r = await h.http('POST', '/internal/chat/messages', { body: { key, stream_id: hostStreamId, message: 'hello host', ...extra }, token: token('chat.message.send') });
const r = await h.http('POST', '/internal/chat/messages', { body: { key, stream_id: hostStreamId, message: 'hello host', ...extra }, token: token('chat.message.send') });
@@ -355,4 +355,20 @@ t('messages sent before the connection is ready are handled in order', async ()
@@ -355,4 +355,20 @@ t('messages sent before the connection is ready are handled in order', async ()
ws.close();
ws.close();
});
});
+t('a channel-only WebSocket line records the first chat for that channel', async () => {
+ // An offline/PowerChat channel room has no streamId, only channelUserId. The save itself must
+ // record the welcome there too, or Live's first-chat read would answer true forever.
+ const carol = h.addUser('carol');
+ await h.ctx.sync();
+ const ws = await h.ws({ ip: '198.51.100.61', token: carol.token });
+ ws.sendJson({ type: 'join', channelUserId: streamer.id, token: carol.token });
+ await ws.next((m) => m.type === 'auth');
+ const identity = `user:${carol.id}`;
+ assert.strictEqual(await h.db.isFirstChatInChannel(identity, streamer.id), true, 'carol has not chatted in the channel');
+ ws.sendJson({ type: 'chat', message: 'offline channel line' });
+ await ws.next((m) => m.type === 'chat' && m.message === 'offline channel line');
+ assert.strictEqual(await h.db.isFirstChatInChannel(identity, streamer.id), false, 'the channel-only save recorded it');
+ ws.close();
+});
+
t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });
t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });