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)

OP OpenVibers committed 5 Oct 2026 · 1b4464ac07 · 3 files, +34 −2

Added the internal DM block-state read for Live call invites.

**Changes**
- `server/chat/internal-reads.js:9` — route listed in the file's header route table.
- `server/chat/internal-reads.js:36` — `const dm = require('./dm')` to reuse Chat's existing either-direction check.
- `server/chat/internal-reads.js:204-211` — `dmBlockState(req)`: validates `a`/`b` (via the shared `query()` spec, so unknown params/malformed ids are 400), requires both, requires `a !== b`, then answers `{ blocked: await dm.isBlockedEither(a, b) }` — the same `isBlockedEither` (dm_blocks both directions, plus Network platform blocks) that `dm-routes.js` calls.
- `server/chat/internal-reads.js:362` — `GET /dm/block-state` guarded by `serviceAuth.guard('chat.messages.read')`.
- `test/chat-internal-reads.test.js:83` — new route added to the generic per-route capability/loopback/audience guard test (capability refused: 401 no token, 403 wrong cap, 403 non-loopback, 401 wrong audience).
- `test/chat-internal-reads.test.js:176-189` — behaviour test: not blocked, a→b blocked, b→a blocked, unrelated pair not blocked, and validation 400s (missing a/b, `abc`, `0`, `-1`, `a == b`, unknown `extra`).
- `docs/chat-ingress.md:32` — documented beside the other reads.
- `docs/chat-ingress.md:69-71` — note that `GET /internal/chat/dm/block-state` must be added to `chat.messages.read`'s `implementedBy` route list in OpenVibe.Contracts (Contracts follow-up; **I did not edit Contracts**). Network already grants the capability.

**Request/response shape**
- Request: `GET /internal/chat/dm/block-state?a=<user id>&b=<user id>` — both positive integer Live user ids, `a != b`; any other/malformed param → `400 {ok:false,error}`.
- Response: `200 { ok: true, blocked: true|false }` — true when either user blocked the other.

**Tests**
`nice -n 15 ov test --no-cache test/chat-internal-reads.test.js` → **1 passed in 6 s** (`test/chat-internal-reads.test.js 5.5 s (named)`), including the new `N2b` test and the extended capability test. No commit/push.

Changes

ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex 167925f..8f8b1f7 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -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 g
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.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
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
ddiff --git a/server/chat/internal-reads.js b/server/chat/internal-reads.js
iindex b591c32..139a451 100644
---- a/server/chat/internal-reads.js
++++ b/server/chat/internal-reads.js
@@@ -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');
ddiff --git a/test/chat-internal-reads.test.js b/test/chat-internal-reads.test.js
iindex d13270a..413b732 100644
---- a/test/chat-internal-reads.test.js
++++ b/test/chat-internal-reads.test.js
@@@ -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`), {