openvibers/openvibe.chat Commits
Internal read of DM block state for Live's call invites (GET /internal/chat/dm/block-state, chat.messages.read) (#27)
Changes
@@@ -29,6 +29,7 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive| --- | --- | --- || `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/dm/block-state?a&b` | `chat.messages.read` | `{blocked}`: true when either user blocked the other, in Chat's `dm_blocks` or as a Network platform block (`server/chat/dm.js` `isBlockedEither`, the check the DM and call routes use). `a` and `b` are positive Live user ids and must differ; Live asks this before sending a call invite. || `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). |@@@ -65,8 +66,10 @@ hooks at `/internal/calls/stream-channel`): the bridge is gone, but each still gthe read API above, Network's `DEFAULT_GRANTS` must add `live chat.stats.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.sounds.write openvibe.chat`. Chat's--local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's home++`live chat.sounds.write openvibe.chat`. The++`GET /internal/chat/dm/block-state` route must also be added to `chat.messages.read`'s `implementedBy`++route list in OpenVibe.Contracts (a Contracts follow-up; the capability and Live's grant are already in++place). Chat's local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's homeseries, its welcome check and its robot channel sounds use those same capabilities but add resultschemas `chat.site-daily-result@1`, `chat.first-chat-result@1` and `chat.sound-result@1`, whichland in openvibe-contracts 0.103.0 (the Contracts change is separate; Live codes against the@@@ -6,6 +6,7 @@* 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/dm/block-state chat.messages.read → { blocked }* 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 }@@@ -32,6 +33,7 @@const express = require('express');const db = require('../db/database');++const dm = require('./dm');const serviceAuth = require('../net/service-auth');const MESSAGE_TYPES = ['chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip'];@@@ -199,6 +201,15 @@ async function messages(req) {return { messages: rows.map(messageRow), max_id };}++// ── N2b: DM block state ──++/** Live's call-invite check: has either user blocked the other, in Chat or on the network? */++async function dmBlockState(req) {++ const q = query(req, { a: 'id', b: 'id' });++ if (q.a == null || q.b == null) bad('a and b are required');++ if (q.a === q.b) bad('a and b must differ');++ return { blocked: await dm.isBlockedEither(q.a, q.b) };++}++// ── N3: timeline ──async function timeline(req) {const q = query(req, { channel_user_id: 'id', stream_id: 'id', since: 'ms', until: 'ms', bucket_ms: 'id' });@@@ -348,6 +359,7 @@ async function soundByCommand(req) {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('/dm/block-state', serviceAuth.guard('chat.messages.read'), route('dm-block-state', dmBlockState));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');@@@ -80,6 +80,7 @@ t('each route needs its own capability, a service token for Chat and loopback',const routes = [['POST', '/stats', 'chat.stats.read', { kind: 'site' }],['GET', `/messages?stream_id=${streamId}`, 'chat.messages.read'],++ ['GET', `/dm/block-state?a=${viewer.id}&b=${other.id}`, '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'],@@@ -172,6 +173,22 @@ t('N2 messages: one filter, a cursor on id, tail → max_id', async () => {check('chat.messages-page@1', await page(q, 400));});++t('N2b dm block state: whether either user blocked the other', async () => {++ const state = async (q, status = 200) => {++ const r = await h.http('GET', `/internal/chat/dm/block-state?${q}`, { token: token('chat.messages.read') });++ assert.strictEqual(r.status, status, `${q}: ${r.text}`);++ return r.body;++ };++ assert.deepStrictEqual(await state(`a=${host.id}&b=${streamer.id}`), { ok: true, blocked: false });++ await h.db.run(`INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)`,++ [host.id, streamer.id, await h.db.subjectFor(host.id)]);++ assert.deepStrictEqual(await state(`a=${host.id}&b=${streamer.id}`), { ok: true, blocked: true }, 'a blocked b');++ assert.deepStrictEqual(await state(`a=${streamer.id}&b=${host.id}`), { ok: true, blocked: true }, 'b blocked a');++ assert.deepStrictEqual(await state(`a=${viewer.id}&b=${other.id}`), { ok: true, blocked: false }, 'an unblocked pair');++ for (const q of ['', `a=${viewer.id}`, `b=${viewer.id}`, `a=abc&b=${viewer.id}`, `a=0&b=${viewer.id}`, `a=-1&b=${viewer.id}`,++ `a=${viewer.id}&b=${viewer.id}`, `a=${viewer.id}&b=${other.id}&extra=1`]) await state(q, 400);++});++t('N3 timeline: buckets from since, oldest first, empty ones left out', async () => {const tl = (q, status) => read(`/timeline?${q}`, 'chat.analysis.read', 'chat.timeline-result@1', status);assert.deepStrictEqual(await tl(`stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`), {
@@ -29,6 +29,7 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
@@ -29,6 +29,7 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
| --- | --- | --- |
| --- | --- | --- |
| `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)`). |
| `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/dm/block-state?a&b` | `chat.messages.read` | `{blocked}`: true when either user blocked the other, in Chat's `dm_blocks` or as a Network platform block (`server/chat/dm.js` `isBlockedEither`, the check the DM and call routes use). `a` and `b` are positive Live user ids and must differ; Live asks this before sending a call invite. |
| `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/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). |
@@ -65,8 +66,10 @@ hooks at `/internal/calls/stream-channel`): the bridge is gone, but each still g
@@ -65,8 +66,10 @@ hooks at `/internal/calls/stream-channel`): the bridge is gone, but each still g
the read API above, Network's `DEFAULT_GRANTS` must add `live chat.stats.read openvibe.chat`,
the read API above, Network's `DEFAULT_GRANTS` must add `live chat.stats.read openvibe.chat`,
`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
-local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's home
+`live chat.sounds.write openvibe.chat`. The
+`GET /internal/chat/dm/block-state` route must also be added to `chat.messages.read`'s `implementedBy`
+route list in OpenVibe.Contracts (a Contracts follow-up; the capability and Live's grant are already in
+place). Chat's 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
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
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
land in openvibe-contracts 0.103.0 (the Contracts change is separate; Live codes against the
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* 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 }
* 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/dm/block-state chat.messages.read → { blocked }
* 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/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 }
@@ -32,6 +33,7 @@
@@ -32,6 +33,7 @@
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
+const dm = require('./dm');
const serviceAuth = require('../net/service-auth');
const serviceAuth = require('../net/service-auth');
const MESSAGE_TYPES = ['chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip'];
const MESSAGE_TYPES = ['chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip'];
@@ -199,6 +201,15 @@ async function messages(req) {
@@ -199,6 +201,15 @@ async function messages(req) {
return { messages: rows.map(messageRow), max_id };
return { messages: rows.map(messageRow), max_id };
}
}
+// ── N2b: DM block state ──
+/** Live's call-invite check: has either user blocked the other, in Chat or on the network? */
+async function dmBlockState(req) {
+ const q = query(req, { a: 'id', b: 'id' });
+ if (q.a == null || q.b == null) bad('a and b are required');
+ if (q.a === q.b) bad('a and b must differ');
+ return { blocked: await dm.isBlockedEither(q.a, q.b) };
+}
+
// ── N3: timeline ──
// ── N3: timeline ──
async function timeline(req) {
async function timeline(req) {
const q = query(req, { channel_user_id: 'id', stream_id: 'id', since: 'ms', until: 'ms', bucket_ms: 'id' });
const q = query(req, { channel_user_id: 'id', stream_id: 'id', since: 'ms', until: 'ms', bucket_ms: 'id' });
@@ -348,6 +359,7 @@ async function soundByCommand(req) {
@@ -348,6 +359,7 @@ async function soundByCommand(req) {
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('/dm/block-state', serviceAuth.guard('chat.messages.read'), route('dm-block-state', dmBlockState));
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));
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');
@@ -80,6 +80,7 @@ t('each route needs its own capability, a service token for Chat and loopback',
@@ -80,6 +80,7 @@ t('each route needs its own capability, a service token for Chat and loopback',
const routes = [
const routes = [
['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', `/dm/block-state?a=${viewer.id}&b=${other.id}`, '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', `/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'],
@@ -172,6 +173,22 @@ t('N2 messages: one filter, a cursor on id, tail → max_id', async () => {
@@ -172,6 +173,22 @@ t('N2 messages: one filter, a cursor on id, tail → max_id', async () => {
check('chat.messages-page@1', await page(q, 400));
check('chat.messages-page@1', await page(q, 400));
});
});
+t('N2b dm block state: whether either user blocked the other', async () => {
+ const state = async (q, status = 200) => {
+ const r = await h.http('GET', `/internal/chat/dm/block-state?${q}`, { token: token('chat.messages.read') });
+ assert.strictEqual(r.status, status, `${q}: ${r.text}`);
+ return r.body;
+ };
+ assert.deepStrictEqual(await state(`a=${host.id}&b=${streamer.id}`), { ok: true, blocked: false });
+ await h.db.run(`INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)`,
+ [host.id, streamer.id, await h.db.subjectFor(host.id)]);
+ assert.deepStrictEqual(await state(`a=${host.id}&b=${streamer.id}`), { ok: true, blocked: true }, 'a blocked b');
+ assert.deepStrictEqual(await state(`a=${streamer.id}&b=${host.id}`), { ok: true, blocked: true }, 'b blocked a');
+ assert.deepStrictEqual(await state(`a=${viewer.id}&b=${other.id}`), { ok: true, blocked: false }, 'an unblocked pair');
+ for (const q of ['', `a=${viewer.id}`, `b=${viewer.id}`, `a=abc&b=${viewer.id}`, `a=0&b=${viewer.id}`, `a=-1&b=${viewer.id}`,
+ `a=${viewer.id}&b=${viewer.id}`, `a=${viewer.id}&b=${other.id}&extra=1`]) await state(q, 400);
+});
+
t('N3 timeline: buckets from since, oldest first, empty ones left out', async () => {
t('N3 timeline: buckets from since, oldest first, empty ones left out', async () => {
const tl = (q, status) => read(`/timeline?${q}`, 'chat.analysis.read', 'chat.timeline-result@1', status);
const tl = (q, status) => read(`/timeline?${q}`, 'chat.analysis.read', 'chat.timeline-result@1', status);
assert.deepStrictEqual(await tl(`stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`), {
assert.deepStrictEqual(await tl(`stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`), {