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)

OP OpenVibers committed 5 Oct 2026 · 05c59f51a9 · 7 files, +194 −38

* Internal reads for Live's last mirror readers: the site-wide daily series, first chat in a channel, and a channel sound by command

## Changes (file:line)

- `server/chat/internal-reads.js:7,20-24` — header: new routes + site-daily; note the three result schemas are not yet in openvibe-contracts.
- `server/chat/internal-reads.js:40-41` — `MAX_SERIES_DAYS = 400`, `DAY_MS`.
- `server/chat/internal-reads.js:77-78` — `dayStart`/`dayCount` UTC-day helpers.
- `server/chat/internal-reads.js:108,115-134` — `site-daily` kind: validation (required `since`/`until`, no other filters, `until > since`, ≤ 400 UTC days) and the daily aggregation (`substr(timestamp,1,10)`, `COUNT(*)`, `COUNT(DISTINCT CHATTER)`, zeros filled).
- `server/chat/internal-reads.js:223-231` — `firstChat` (reads `stream_first_chats`; PK `(chatter_key, channel_user_id)` already indexes it, so no migration).
- `server/chat/internal-reads.js:333-343` — `soundByCommand` (approved sound, `ORDER BY RANDOM()`, 404 when none).
- `server/chat/internal-reads.js:349,357` — route registration with `serviceAuth.guard`.
- `test/chat-internal-reads.test.js:84,92` — new routes in the per-route capability/loopback/audience test.
- `test/chat-internal-reads.test.js:130-147` — `N1b` site-daily (answer, zero-fill, 400-day bound, input validation).
- `test/chat-internal-reads.test.js:190-206` — `N1c` first-chat (user/anon/ext identities, channel isolation, validation).
- `test/chat-internal-reads.test.js:283-308` — `N5b` by-command (random pick, approval filter, case/whitespace/`!` handling, 404, validation).
- `docs/chat-ingress.md:30,33,40,69-72` — documented all three beside the others.

## Exact request/response shapes for Live

1. **Site-wide daily series** — `POST /internal/chat/stats`, capability `chat.stats.read`, result `chat.site-daily-result@1`
   - Request: `{ kind: 'site-daily', since: <epoch ms>, until: <epoch ms> }` (both required; no other fields; at most 400 UTC days).
   - 200: `{ ok: true, days: [ { day: 'YYYY-MM-DD', messages: <int>, chatters: <int> }, ... ] }` — one row per UTC day in `[since, until)`, oldest first, empty days zero-filled. `messages` = every non-deleted `chat_messages` row (all types); `chatters` = distinct `COALESCE('u:'||user_id, 'a:'||anon_id, source_platform||':'||username)`. Matches Live `HOME_SERIES.messages`/`active`.

2. **First chat in a channel** — `GET /internal/chat/first-chat?channel_id=&identity=`, capability `chat.analysis.read`, result `chat.first-chat-result@1`
   - `channel_id` = channel owner's Live user id (`stream_first_chats.channel_user_id`); `identity` = `user:<id>` | `anon:<anonId>` | `ext:<prefixed username>`, ≤ 160.
   - 200: `{ ok: true, first: true|false }` — `first:true` when the identity has never chatted there (Live `isFirstChatInChannel`).

3. **Channel sound by command** — `GET /internal/chat/sounds/by-command?channel_id=&command=`, capability `chat.sounds.read`, result `chat.sound-result@1`
   - `channel_id` = channel owner's Live user id (`channel_sounds.channel_owner_id`); `command` ≤ 120, trimmed, lowercased, leading `!` stripped.
   - 200: `{ ok: true, sound: { id, channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, media_url, media_asset_id } }` (same projection as `/sounds`; one approved sound at random when several match).
   - 404: `{ ok: false, error: 'Sound not found' }`.

* First chats are recorded in the message save path for every room and identity; site-daily tolerates NULL is_deleted; registered identity documented as user:<id> (review)

Findings 1 and 3 fixed; finding 2 is the doc-only note. Changes, one line each:

- `server/db/database.js:173,181,199` — `saveChatMessage` now derives the welcome key (`firstChatKey`: explicit `first_chat_key`, else chat-only `ext:`/`user:`/`anon:`) and records `stream_first_chats` inside its own transaction, keyed on the resolved channel (`channel_user_id`, else the stream owner), returning `first_chat`; so every save path records it once.
- `server/chat/internal-ingress.js:143-148` — dropped the duplicated key helper/`recordFirstChat`; `first_chat` now comes from the save result (retries still replay the stored answer).
- `server/chat/chat-server.js:924,940,965` — the WebSocket welcome uses that save answer (`firstChat`) instead of its own record check, so it no longer double-writes; channel-only rooms now get recorded.
- `server/chat/internal-reads.js:124` — `site-daily` filters with `COALESCE(is_deleted, 0) = 0`; the other stats kinds keep `is_deleted = 0`.
- `server/chat/internal-reads.js:20` — header note now says the three schemas land in openvibe-contracts 0.103.0 (Contracts change separate).
- `docs/chat-ingress.md:33,17` — first-chat rows document that a registered identity is `user:<user_id>` (never the username), with Live's `user:${line.username}` trap spelled out.
- `docs/chat-ingress.md:72` — the three schemas note now says openvibe-contracts 0.103.0.
- `test/ws-protocol.test.js:358` — new test: a channel-only (`channelUserId`, no stream) WebSocket line flips `isFirstChatInChannel` to false.

Test results (`ov test`, targeted):
- `test/chat-internal-reads.test.js` ✓, `test/chat-ingress.test.js` ✓, `test/ws-protocol.test.js` ✓
- `test/outbox-mirror.test.js test/import.test.js test/pg-schema.test.js` ✓ (3 passed)
- `test/rest.test.js test/message-deleted.test.js test/events-consumer.test.js test/security-idor.test.js` ✓ (4 passed) — all pass.

Changes

ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex af40cc9..27710bf 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -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 DMs
to `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 3945adb..b33d489 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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 */ }
}
ddiff --git a/server/chat/internal-ingress.js b/server/chat/internal-ingress.js
iindex f44dd36..382c577 100644
---- a/server/chat/internal-ingress.js
++++ b/server/chat/internal-ingress.js
@@@ -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) {
ddiff --git a/server/chat/internal-reads.js b/server/chat/internal-reads.js
iindex e05535c..b591c32 100644
---- a/server/chat/internal-reads.js
++++ b/server/chat/internal-reads.js
@@@ -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;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 6ded767..f410600 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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, u
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 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',
ddiff --git a/test/chat-internal-reads.test.js b/test/chat-internal-reads.test.js
iindex c0381ef..d13270a 100644
---- a/test/chat-internal-reads.test.js
++++ b/test/chat-internal-reads.test.js
@@@ -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', async
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 () => {
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') });
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex d9dcea2..4bd36e7 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -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(); });