openvibers/openvibe.chat Commits
Internal per-ticket conversations for OpenVibe.Help (chat.ticket.write): create on first message, append, read back, each service its own (#26)
Changes
@@@ -41,6 +41,24 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive| `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. For a sound Chat has no Media asset for, Live's own asset columns stay as its asset sync left them (the read mirror that used to preserve them was retired on 2026-10-05). |++### Per-ticket conversation (OpenVibe.Help)++++OpenVibe.Help reads and answers a ticket through one conversation per (ticket id, calling++service), guarded by `chat.ticket.write` (`server/chat/internal-tickets.js`,++`chat.ticket-conversation@1`, openvibe-contracts 0.103.0; the tables are++`migrations/0007_ticket_conversations.sql`). The calling service is the service token's principal++(`svc:help` → `help`): two services on one ticket get two conversations and a service can only++ever read or write its own — an app, node or agent token is refused. Bodies are the author's plain++text (1–6000 characters, not blank), stored and answered as sent and never rendered as HTML. The++contract has no idempotency key, so a retried POST appends another message; `created_at` is the++author's clock and Chat orders by its own message id (newest first by default, `after_id` pages++oldest first).++++| Route | Capability | Request and result |++| --- | --- | --- |++| `POST /internal/chat/tickets/:ticket_id/messages` | `chat.ticket.write` | `{author_kind:'person'\|'agent'\|'staff', author, body, created_at}` with `author` the author's subject (≤ 200) and `created_at` an RFC 3339 date-time; creates the (ticket, service) conversation on first use and appends the message. Returns `{ok:true, ticket_id, service, message:{author_kind, author, body, created_at}}` (the contract's conversation shape). |++| `GET /internal/chat/tickets/:ticket_id` | `chat.ticket.write` | The calling service's own conversation, paged: `{ok:true, ticket_id, service, created_at, messages:[{id, author_kind, author, body, created_at}], max_id}`; `after_id` (oldest first), `before_id` (newest first, the default), `limit` ≤ 500 (default 100); `max_id` is the conversation's newest message id. A service with no conversation for the ticket gets 404 — another service's is never readable. |++`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.An `alert` targets its `streamerId` channel; a `channel-sound` targets its `streamId` stream.@@@ -72,8 +90,7 @@ route list in OpenVibe.Contracts (a Contracts follow-up; the capability and Liveplace). 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`, which--land in openvibe-contracts 0.103.0 (the Contracts change is separate; Live codes against the--shapes here until that version is pinned).++land in openvibe-contracts 0.103.0, now 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, redemptionnnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++-- OpenVibe.Help's per-ticket conversation (capability chat.ticket.write,++-- chat.ticket-conversation@1, added in openvibe-contracts 0.100.0). One conversation per++-- (ticket id, calling service); two services on one ticket get two conversations and neither++-- can read the other's. Messages carry the author's kind, subject and body (plain text, never++-- HTML) and the author's own created_at; Chat orders by its own id (recorded_at is when it++-- arrived). Service tokens are loopback-only and never granted to an app or an agent.++CREATE TABLE IF NOT EXISTS ticket_conversations (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ ticket_id text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ UNIQUE (ticket_id, service)++);++CREATE TABLE IF NOT EXISTS ticket_messages (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ conversation_id bigint NOT NULL REFERENCES ticket_conversations(id) ON DELETE CASCADE,++ author_kind text COLLATE "C" NOT NULL CHECK (author_kind IN ('person', 'agent', 'staff')),++ author text COLLATE "C" NOT NULL,++ body text COLLATE "C" NOT NULL, -- the author's text, as sent; Chat never renders it++ created_at text COLLATE "C" NOT NULL, -- the author's clock (contract), not Chat's ordering++ recorded_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++CREATE INDEX IF NOT EXISTS idx_ticket_messages_conversation ON ticket_messages(conversation_id, id);@@@ -16,7 +16,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0","pg": "^8.23.0",@@@ -1294,9 +1294,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.96.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",-- "integrity": "sha512-QEXfNYNkK6n49sXokgbP2FTKaIEawRJOS7Xij9g20fBFSqiVv14voZ60LqECPl1VMZedoSGuYLa8doVlnSxH0w==",++ "version": "0.103.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",++ "integrity": "sha512-kTsKojjjtCmJr5uXDIM+ocnhLF5CiAMBGbRSVGmvhfeBGHs5jxldGJc+ozw+S5W2bOQp8/BkT2PVI/fr8eNirQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0","pg": "^8.23.0",@@@ -104,6 +104,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));// Live's reads of the chat tables Chat owns, in place of its mirrored copy (plan T3 J4b, server/chat/internal-reads.js).app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));++ // OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).++ app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over// the raw body, so this router reads the body itself (server/events/consumer.js).if (events) app.use('/internal/events', events.router);@@@ -1,7 +1,7 @@/*** OpenVibe.Chat — the internal read API Live calls instead of reading its mirrored copy of Chat's* tables (plan T3 J4b, design §1d N1–N5). Loopback only, a service token for audience-- * openvibe.chat, one capability per route (openvibe-contracts 0.95.0):++ * openvibe.chat, one capability per route (openvibe-contracts 0.103.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 }@@@ -18,9 +18,8 @@* 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.++ * sounds (chat.site-daily-result@1, chat.first-chat-result@1, chat.sound-result@1) are in++ * openvibe-contracts 0.103.0, now pinned.* 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.nnew file mode 100644@@@ -0,0 +1,135 @@++/**++ * OpenVibe.Help's per-ticket conversation over Chat's service ingress (capability++ * chat.ticket.write, chat.ticket-conversation@1, openvibe-contracts 0.103.0). Loopback only, a++ * service token for audience openvibe.chat. A conversation is keyed by the ticket id and the++ * calling service, taken from the token's principal (`svc:help` → `help`), so two services on one++ * ticket never see each other's messages and a service only ever reads its own conversation.++ *++ * POST /internal/chat/tickets/:ticket_id/messages create the conversation on first use, append one message++ * GET /internal/chat/tickets/:ticket_id the calling service's conversation and its messages, paged++ *++ * The message body is the author's plain text (1–6000 characters, not blank): stored and returned++ * as sent, never rendered as HTML. The contract carries no idempotency key, so a retried POST++ * appends another message; `created_at` is the author's clock and Chat keeps its own ordering by++ * message id (newest first by default, `after_id` pages oldest first).++ */++'use strict';++++const express = require('express');++const db = require('../db/database');++const serviceAuth = require('../net/service-auth');++++const MAX_TICKET_ID = 200;++const MAX_AUTHOR = 200;++const MAX_BODY = 6000;++const MAX_CREATED_AT = 40;++const MAX_MESSAGES = 500;++const AUTHOR_KINDS = ['person', 'agent', 'staff'];++const SERVICE = /^svc:([a-z][a-z0-9-]{1,39})$/;++const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;++const TICKET_ID = /^[^\s/\u0000]{1,200}$/; // PostgreSQL text refuses NUL: a 400 here, not a 503 later++++const bad = (message) => { const e = new Error(message); e.status = 400; throw e; };++const notFound = (message) => { const e = new Error(message); e.status = 404; throw e; };++const denied = (message) => { const e = new Error(message); e.status = 403; throw e; };++const obj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);++const int = (v) => Number.isSafeInteger(v) && v > 0;++const plain = (v, max) => typeof v === 'string' && v.length >= 1 && v.length <= max && !v.includes('\u0000');++++function fields(v, allowed) {++ if (!obj(v) || Object.keys(v).some((k) => !allowed.includes(k))) bad('Invalid fields');++}++++/** The calling service from its token principal: svc:help -> help. Apps, nodes and agents are refused. */++function callingService(req) {++ const m = SERVICE.exec(String((req.principal && req.principal.sub) || ''));++ if (!m) denied('A service token is required');++ return m[1];++}++++function ticketId(req) {++ const raw = String(req.params.ticket_id || '');++ if (!TICKET_ID.test(raw) || raw.length > MAX_TICKET_ID) bad('Invalid ticket id');++ return raw;++}++++function validateMessage(b) {++ fields(b, ['author_kind', 'author', 'body', 'created_at']);++ if (!AUTHOR_KINDS.includes(b.author_kind)) bad('Invalid author kind');++ if (!plain(b.author, MAX_AUTHOR)) bad('Invalid author');++ if (!plain(b.body, MAX_BODY) || !b.body.trim()) bad('Invalid body');++ if (!plain(b.created_at, MAX_CREATED_AT) || !DATE_TIME.test(b.created_at) || !Number.isFinite(Date.parse(b.created_at))) bad('Invalid created_at');++}++++/** Query string → validated positive ids; anything not in `spec` is 400. */++function query(req, spec) {++ const out = {};++ for (const [k, raw] of Object.entries(req.query)) {++ if (!spec[k] || typeof raw !== 'string' || !/^\d{1,16}$/.test(raw)) bad(`Invalid parameter ${k}`);++ const v = Number(raw);++ if (!int(v)) bad(`Invalid ${k}`);++ out[k] = v;++ }++ return out;++}++++function route(name, fn) {++ return async (req, res) => {++ try {++ res.json({ ok: true, ...await fn(req) });++ } catch (err) {++ if (err.status) return res.status(err.status).json({ ok: false, error: err.message });++ console.error(`[ChatTickets] ${name}:`, err);++ res.status(503).json({ ok: false, error: 'Chat ticket conversation unavailable' });++ }++ };++}++++/** The (ticket, service) conversation, created on first use; a concurrent create resolves to one row. */++async function conversationFor(ticket, service) {++ const existing = await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);++ if (existing) return existing;++ await db.run('INSERT INTO ticket_conversations (ticket_id, service) VALUES (?, ?) ON CONFLICT DO NOTHING', [ticket, service]);++ return await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);++}++++async function postMessage(req) {++ const ticket = ticketId(req);++ const service = callingService(req);++ validateMessage(req.body);++ const b = req.body;++ await db.transaction(async () => {++ const conv = await conversationFor(ticket, service);++ await db.run(++ 'INSERT INTO ticket_messages (conversation_id, author_kind, author, body, created_at) VALUES (?, ?, ?, ?, ?)',++ [conv.id, b.author_kind, b.author, b.body, b.created_at]);++ });++ return { ticket_id: ticket, service, message: { author_kind: b.author_kind, author: b.author, body: b.body, created_at: b.created_at } };++}++++const messageRow = (r) => ({ id: Number(r.id), author_kind: String(r.author_kind), author: String(r.author), body: String(r.body), created_at: String(r.created_at) });++++async function getConversation(req) {++ const ticket = ticketId(req);++ const service = callingService(req);++ const q = query(req, { after_id: true, before_id: true, limit: true });++ const conv = await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);++ if (!conv) notFound('Ticket conversation not found');++ const where = ['conversation_id = ?'];++ const params = [conv.id];++ if (q.after_id != null) { where.push('id > ?'); params.push(q.after_id); }++ if (q.before_id != null) { where.push('id < ?'); params.push(q.before_id); }++ const max = await db.get('SELECT MAX(id) AS max_id FROM ticket_messages WHERE conversation_id = ?', [conv.id]);++ // after_id pages forward (oldest first); otherwise newest first, back from before_id.++ const order = q.after_id != null ? 'ASC' : 'DESC';++ const rows = await db.all(++ `SELECT id, author_kind, author, body, created_at FROM ticket_messages WHERE ${where.join(' AND ')} ORDER BY id ${order} LIMIT ?`,++ [...params, Math.min(q.limit || 100, MAX_MESSAGES)]);++ return { ticket_id: ticket, service, created_at: String(conv.created_at), messages: rows.map(messageRow), max_id: max && max.max_id != null ? Number(max.max_id) : null };++}++++const router = express.Router();++router.post('/tickets/:ticket_id/messages', serviceAuth.guard('chat.ticket.write'), route('ticket-message', postMessage));++router.get('/tickets/:ticket_id', serviceAuth.guard('chat.ticket.write'), route('ticket-conversation', getConversation));++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++/**++ * OpenVibe.Help's per-ticket conversation (server/chat/internal-tickets.js, capability++ * chat.ticket.write, chat.ticket-conversation@1 added in openvibe-contracts 0.100.0): a++ * conversation is keyed by (ticket id, calling service) and a service only ever reads its own.++ */++++const assert = require('assert');++const path = require('path');++const { validate } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const quiet = { log() {}, info() {}, warn() {}, error() {} };++++const t = suite('chat-internal-tickets');++let h;++const TICKET = 'tkt_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const HELP = (cap = 'chat.ticket.write') => h.serviceToken([cap], { sub: 'svc:help' });++const AI = (cap = 'chat.ticket.write') => h.serviceToken([cap], { sub: 'svc:ai' });++const msg = (author_kind, author, body, created_at = '2026-10-05T09:00:00Z') => ({ author_kind, author, body, created_at });++++// chat.ticket-conversation@1 arrives with openvibe-contracts 0.100.0. Until the pin reaches it an++// older package cannot validate it: the run says so instead of failing (shapes are asserted too).++function check(ref, body) {++ let r;++ try { r = validate(ref, body); } catch (err) {++ if (/unknown contract|is v\d/.test(err.message)) return body;++ throw err;++ }++ assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);++ return body;++}++async function post(token, ticket, body) {++ const r = await h.http('POST', `/internal/chat/tickets/${encodeURIComponent(ticket)}/messages`, { token, body });++ assert.strictEqual(r.status, 200, `${JSON.stringify(body)}: ${r.text}`);++ return r.body;++}++async function get(token, ticket, qs = '', status = 200) {++ const r = await h.http('GET', `/internal/chat/tickets/${encodeURIComponent(ticket)}${qs}`, { token });++ assert.strictEqual(r.status, status, `${ticket}${qs}: ${r.text}`);++ return r.body;++}++++t('migrate the final schema and boot', async () => {++ // The shared harness migrates with the N-1 window, which holds 0005 (a contract migration) and++ // with it the later 0006/0007. The ticket tables arrive in 0007, so apply the final schema++ // first, exactly as test/pg-schema.test.js does.++ const owner = globalThis.__ovChatTestOwnerDb && globalThis.__ovChatTestOwnerDb();++ if (!owner) throw new Error('run this file through test/run.js (test/helpers/pg-preload.mjs provides the test database)');++ await owner.migrate({ dir: MIGRATIONS, windowDays: 0, log: quiet });++ h = await boot();++});++++t('the route needs its capability, a service token for Chat and loopback', async () => {++ const url = `/internal/chat/tickets/${TICKET}/messages`;++ const body = msg('person', 'user:usr_01', 'hello');++ assert.strictEqual((await h.http('POST', url, { body })).status, 401, 'no token');++ assert.strictEqual((await h.http('POST', url, { body, token: h.serviceToken(['chat.messages.read'], { sub: 'svc:help' }) })).status, 403, 'wrong capability');++ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`, { token: h.serviceToken(['chat.messages.read'], { sub: 'svc:help' }) })).status, 403, 'wrong capability (GET)');++ assert.strictEqual((await h.http('POST', url, { body, token: h.serviceToken(['chat.ticket.write'], { sub: 'svc:help', aud: 'openvibe.live' }) })).status, 401, 'wrong audience');++ assert.strictEqual((await h.http('POST', url, { body, token: HELP(), headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'from outside');++ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`)).status, 401, 'GET no token');++});++++t('an app token is refused: only a first-party service token', async () => {++ const app = h.serviceToken(['chat.ticket.write'], { sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });++ const body = msg('person', 'user:usr_01', 'hello');++ assert.strictEqual((await h.http('POST', `/internal/chat/tickets/${TICKET}/messages`, { token: app, body })).status, 403);++ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`, { token: app })).status, 403);++});++++t('create, append and read back: the conversation is (ticket, service)', async () => {++ const first = msg('person', 'user:usr_01', 'My export from yesterday is missing the last day.');++ const created = await post(HELP(), TICKET, first);++ // The contract describes {ticket_id, service, message}; Chat's answer adds its usual ok.++ check('chat.ticket-conversation@1', { ticket_id: created.ticket_id, service: created.service, message: created.message });++ assert.deepStrictEqual(created, { ok: true, ticket_id: TICKET, service: 'help', message: first });++++ await post(HELP(), TICKET, msg('agent', 'agent:agt_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'Checking the export job.', '2026-10-05T09:01:30Z'));++ await post(HELP(), TICKET, msg('staff', 'staff:sam', 'The last hour moves into the next day file.', '2026-10-05T09:02:00Z'));++++ const page = await get(HELP(), TICKET);++ assert.strictEqual(page.ticket_id, TICKET);++ assert.strictEqual(page.service, 'help');++ assert.match(page.created_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, 'conversation created_at');++ assert.strictEqual(page.messages.length, 3);++ assert.deepStrictEqual(page.messages.map((m) => m.author_kind), ['staff', 'agent', 'person'], 'newest first');++ assert.deepStrictEqual(page.messages[0], {++ id: page.max_id, author_kind: 'staff', author: 'staff:sam',++ body: 'The last hour moves into the next day file.', created_at: '2026-10-05T09:02:00Z',++ });++ assert.strictEqual(page.max_id, Math.max(...page.messages.map((m) => m.id)));++++ // after_id pages forward (oldest first); before_id and limit page back.++ const oldest = page.messages[page.messages.length - 1].id;++ const forward = await get(HELP(), TICKET, `?after_id=${oldest}`);++ assert.deepStrictEqual(forward.messages.map((m) => m.author_kind), ['agent', 'staff']);++ assert.strictEqual(forward.max_id, page.max_id);++ assert.deepStrictEqual((await get(HELP(), TICKET, '?limit=2')).messages.map((m) => m.author_kind), ['staff', 'agent']);++ assert.deepStrictEqual((await get(HELP(), TICKET, `?before_id=${page.max_id}`)).messages.map((m) => m.author_kind), ['agent', 'person']);++ assert.deepStrictEqual(await get(HELP(), TICKET, `?after_id=${page.max_id}`), { ok: true, ticket_id: TICKET, service: 'help', created_at: page.created_at, messages: [], max_id: page.max_id });++ // limit is clamped to 500, not refused: 100000 reads the whole conversation.++ assert.strictEqual((await get(HELP(), TICKET, '?limit=100000')).messages.length, 3);++});++++t('another service gets its own conversation and never reads help\'s', async () => {++ await post(AI(), TICKET, msg('agent', 'agent:agt_ai', 'AI here, on the same ticket.'));++++ const ai = await get(AI(), TICKET);++ assert.strictEqual(ai.service, 'ai');++ assert.deepStrictEqual(ai.messages.map((m) => m.author), ['agent:agt_ai'], 'only ai\'s own message');++++ const help = await get(HELP(), TICKET);++ assert.strictEqual(help.messages.length, 3, 'help\'s conversation did not grow');++ assert.ok(help.messages.every((m) => m.author !== 'agent:agt_ai'), 'ai\'s message is not in help\'s conversation');++++ // A ticket only ai has: help is 404 (it has no conversation), and posting as ai never makes one for help.++ assert.deepStrictEqual(await get(HELP(), 'tkt_ai_only', '', 404), { ok: false, error: 'Ticket conversation not found' });++ await post(AI(), 'tkt_ai_only', msg('staff', 'staff:sam', 'Only ai has this ticket.'));++ assert.strictEqual((await get(HELP(), 'tkt_ai_only', '', 404)).ok, false);++ assert.strictEqual((await get(AI(), 'tkt_ai_only')).messages.length, 1);++++ // An unknown ticket has no conversation at all.++ assert.deepStrictEqual(await get(HELP(), 'tkt_nobody', '', 404), { ok: false, error: 'Ticket conversation not found' });++});++++t('validation: author_kind, author, body bounds, created_at, ticket id and query', async () => {++ const url = `/internal/chat/tickets/${TICKET}/messages`;++ const write = (body) => h.http('POST', url, { token: HELP(), body });++ const long = 'x'.repeat(6000);++ const bad = [++ msg('bot', 'user:usr_01', 'not a kind'),++ msg('person', '', 'empty author'),++ msg('person', 'a'.repeat(201), 'long author'),++ msg('person', 'user:usr_01', ''),++ msg('person', 'user:usr_01', ' '),++ msg('person', 'user:usr_01', 'x'.repeat(6001)),++ msg('person', 'user:usr_01', 'nul \u0000 byte'),++ msg('person', 'user:\u0000', 'nul in author'),++ { ...msg('person', 'user:usr_01', 'x'), extra: 1 },++ { author_kind: 'person', author: 'user:usr_01', body: 'x' },++ { ...msg('person', 'user:usr_01', 'x'), created_at: 'yesterday' },++ [1],++ ];++ for (const body of bad) assert.strictEqual((await write(body)).status, 400, JSON.stringify(body));++++ // 6000 characters is the contract's maximum: accepted and returned unchanged, never as HTML.++ await post(HELP(), 'tkt_bounds', msg('person', 'user:usr_01', long));++ assert.strictEqual((await get(HELP(), 'tkt_bounds')).messages[0].body, long);++++ // The ticket id comes from the path: blank, whitespace, a slash or over 200 characters is 400.++ for (const ticket of ['%20', 'a%20b', 'a%2Fb', 'a'.repeat(201)]) {++ const r = await h.http('POST', `/internal/chat/tickets/${ticket}/messages`, { token: HELP(), body: msg('person', 'user:usr_01', 'x') });++ assert.strictEqual(r.status, 400, ticket);++ }++++ for (const q of ['?limit=0', '?limit=-1', '?limit=abc', '?limit=', '?after_id=0', '?before_id=0', '?unknown=1']) {++ const r = await h.http('GET', `/internal/chat/tickets/${TICKET}${q}`, { token: HELP() });++ assert.strictEqual(r.status, 400, q);++ }++});++++t('the contract has no idempotency key, so a retry appends another message', async () => {++ const body = msg('person', 'user:usr_dup', 'sent twice');++ await post(HELP(), 'tkt_dup', body);++ await post(HELP(), 'tkt_dup', body);++ assert.deepStrictEqual((await get(HELP(), 'tkt_dup')).messages.map((m) => m.body), ['sent twice', 'sent twice']);++});++++t.run(async () => {++ if (h) await h.close();++});@@@ -27,7 +27,7 @@ const TABLES = ['ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'live_mirror_outbox','chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases','chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'token_revocations',-- 'network_blocks', 'account_data_events',++ 'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages',];const INDEXES = ['idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
@@ -41,6 +41,24 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
@@ -41,6 +41,24 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
| `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`. |
| `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. For a sound Chat has no Media asset for, Live's own asset columns stay as its asset sync left them (the read mirror that used to preserve them was retired on 2026-10-05). |
| `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. For a sound Chat has no Media asset for, Live's own asset columns stay as its asset sync left them (the read mirror that used to preserve them was retired on 2026-10-05). |
+### Per-ticket conversation (OpenVibe.Help)
+
+OpenVibe.Help reads and answers a ticket through one conversation per (ticket id, calling
+service), guarded by `chat.ticket.write` (`server/chat/internal-tickets.js`,
+`chat.ticket-conversation@1`, openvibe-contracts 0.103.0; the tables are
+`migrations/0007_ticket_conversations.sql`). The calling service is the service token's principal
+(`svc:help` → `help`): two services on one ticket get two conversations and a service can only
+ever read or write its own — an app, node or agent token is refused. Bodies are the author's plain
+text (1–6000 characters, not blank), stored and answered as sent and never rendered as HTML. The
+contract has no idempotency key, so a retried POST appends another message; `created_at` is the
+author's clock and Chat orders by its own message id (newest first by default, `after_id` pages
+oldest first).
+
+| Route | Capability | Request and result |
+| --- | --- | --- |
+| `POST /internal/chat/tickets/:ticket_id/messages` | `chat.ticket.write` | `{author_kind:'person'\|'agent'\|'staff', author, body, created_at}` with `author` the author's subject (≤ 200) and `created_at` an RFC 3339 date-time; creates the (ticket, service) conversation on first use and appends the message. Returns `{ok:true, ticket_id, service, message:{author_kind, author, body, created_at}}` (the contract's conversation shape). |
+| `GET /internal/chat/tickets/:ticket_id` | `chat.ticket.write` | The calling service's own conversation, paged: `{ok:true, ticket_id, service, created_at, messages:[{id, author_kind, author, body, created_at}], max_id}`; `after_id` (oldest first), `before_id` (newest first, the default), `limit` ≤ 500 (default 100); `max_id` is the conversation's newest message id. A service with no conversation for the ticket gets 404 — another service's is never readable. |
+
`/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.
`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.
`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.
An `alert` targets its `streamerId` channel; a `channel-sound` targets its `streamId` stream.
An `alert` targets its `streamerId` channel; a `channel-sound` targets its `streamId` stream.
@@ -72,8 +90,7 @@ route list in OpenVibe.Contracts (a Contracts follow-up; the capability and Live
@@ -72,8 +90,7 @@ route list in OpenVibe.Contracts (a Contracts follow-up; the capability and Live
place). Chat's local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's home
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
-shapes here until that version is pinned).
+land in openvibe-contracts 0.103.0, now 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
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+-- OpenVibe.Help's per-ticket conversation (capability chat.ticket.write,
+-- chat.ticket-conversation@1, added in openvibe-contracts 0.100.0). One conversation per
+-- (ticket id, calling service); two services on one ticket get two conversations and neither
+-- can read the other's. Messages carry the author's kind, subject and body (plain text, never
+-- HTML) and the author's own created_at; Chat orders by its own id (recorded_at is when it
+-- arrived). Service tokens are loopback-only and never granted to an app or an agent.
+CREATE TABLE IF NOT EXISTS ticket_conversations (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ ticket_id text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ UNIQUE (ticket_id, service)
+);
+CREATE TABLE IF NOT EXISTS ticket_messages (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ conversation_id bigint NOT NULL REFERENCES ticket_conversations(id) ON DELETE CASCADE,
+ author_kind text COLLATE "C" NOT NULL CHECK (author_kind IN ('person', 'agent', 'staff')),
+ author text COLLATE "C" NOT NULL,
+ body text COLLATE "C" NOT NULL, -- the author's text, as sent; Chat never renders it
+ created_at text COLLATE "C" NOT NULL, -- the author's clock (contract), not Chat's ordering
+ recorded_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+CREATE INDEX IF NOT EXISTS idx_ticket_messages_conversation ON ticket_messages(conversation_id, id);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1294,9 +1294,9 @@
@@ -1294,9 +1294,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.96.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
- "integrity": "sha512-QEXfNYNkK6n49sXokgbP2FTKaIEawRJOS7Xij9g20fBFSqiVv14voZ60LqECPl1VMZedoSGuYLa8doVlnSxH0w==",
+ "version": "0.103.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
+ "integrity": "sha512-kTsKojjjtCmJr5uXDIM+ocnhLF5CiAMBGbRSVGmvhfeBGHs5jxldGJc+ozw+S5W2bOQp8/BkT2PVI/fr8eNirQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -104,6 +104,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -104,6 +104,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
// Live's reads of the chat tables Chat owns, in place of its mirrored copy (plan T3 J4b, server/chat/internal-reads.js).
// Live's reads of the chat tables Chat owns, in place of its mirrored copy (plan T3 J4b, server/chat/internal-reads.js).
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));
+ // OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).
+ app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));
// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over
// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over
// the raw body, so this router reads the body itself (server/events/consumer.js).
// the raw body, so this router reads the body itself (server/events/consumer.js).
if (events) app.use('/internal/events', events.router);
if (events) app.use('/internal/events', events.router);
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* OpenVibe.Chat — the internal read API Live calls instead of reading its mirrored copy of Chat's
* OpenVibe.Chat — the internal read API Live calls instead of reading its mirrored copy of Chat's
* tables (plan T3 J4b, design §1d N1–N5). Loopback only, a service token for audience
* tables (plan T3 J4b, design §1d N1–N5). Loopback only, a service token for audience
- * openvibe.chat, one capability per route (openvibe-contracts 0.95.0):
+ * openvibe.chat, one capability per route (openvibe-contracts 0.103.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 }
* kind site-daily → chat.site-daily-result@1 { days }
@@ -18,9 +18,8 @@
@@ -18,9 +18,8 @@
* 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
* 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.
+ * sounds (chat.site-daily-result@1, chat.first-chat-result@1, chat.sound-result@1) are in
+ * openvibe-contracts 0.103.0, now pinned.
* In first-chat and sounds/by-command, `channel_id` is the channel owner's Live user id — the value
* 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
* Live passes as channelUserId to isFirstChatInChannel and channel_owner_id to
* getChannelSoundByCommand (both stream.user_id), not Chat's ctx_channels.id.
* getChannelSoundByCommand (both stream.user_id), not Chat's ctx_channels.id.
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+/**
+ * OpenVibe.Help's per-ticket conversation over Chat's service ingress (capability
+ * chat.ticket.write, chat.ticket-conversation@1, openvibe-contracts 0.103.0). Loopback only, a
+ * service token for audience openvibe.chat. A conversation is keyed by the ticket id and the
+ * calling service, taken from the token's principal (`svc:help` → `help`), so two services on one
+ * ticket never see each other's messages and a service only ever reads its own conversation.
+ *
+ * POST /internal/chat/tickets/:ticket_id/messages create the conversation on first use, append one message
+ * GET /internal/chat/tickets/:ticket_id the calling service's conversation and its messages, paged
+ *
+ * The message body is the author's plain text (1–6000 characters, not blank): stored and returned
+ * as sent, never rendered as HTML. The contract carries no idempotency key, so a retried POST
+ * appends another message; `created_at` is the author's clock and Chat keeps its own ordering by
+ * message id (newest first by default, `after_id` pages oldest first).
+ */
+'use strict';
+
+const express = require('express');
+const db = require('../db/database');
+const serviceAuth = require('../net/service-auth');
+
+const MAX_TICKET_ID = 200;
+const MAX_AUTHOR = 200;
+const MAX_BODY = 6000;
+const MAX_CREATED_AT = 40;
+const MAX_MESSAGES = 500;
+const AUTHOR_KINDS = ['person', 'agent', 'staff'];
+const SERVICE = /^svc:([a-z][a-z0-9-]{1,39})$/;
+const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
+const TICKET_ID = /^[^\s/\u0000]{1,200}$/; // PostgreSQL text refuses NUL: a 400 here, not a 503 later
+
+const bad = (message) => { const e = new Error(message); e.status = 400; throw e; };
+const notFound = (message) => { const e = new Error(message); e.status = 404; throw e; };
+const denied = (message) => { const e = new Error(message); e.status = 403; throw e; };
+const obj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+const int = (v) => Number.isSafeInteger(v) && v > 0;
+const plain = (v, max) => typeof v === 'string' && v.length >= 1 && v.length <= max && !v.includes('\u0000');
+
+function fields(v, allowed) {
+ if (!obj(v) || Object.keys(v).some((k) => !allowed.includes(k))) bad('Invalid fields');
+}
+
+/** The calling service from its token principal: svc:help -> help. Apps, nodes and agents are refused. */
+function callingService(req) {
+ const m = SERVICE.exec(String((req.principal && req.principal.sub) || ''));
+ if (!m) denied('A service token is required');
+ return m[1];
+}
+
+function ticketId(req) {
+ const raw = String(req.params.ticket_id || '');
+ if (!TICKET_ID.test(raw) || raw.length > MAX_TICKET_ID) bad('Invalid ticket id');
+ return raw;
+}
+
+function validateMessage(b) {
+ fields(b, ['author_kind', 'author', 'body', 'created_at']);
+ if (!AUTHOR_KINDS.includes(b.author_kind)) bad('Invalid author kind');
+ if (!plain(b.author, MAX_AUTHOR)) bad('Invalid author');
+ if (!plain(b.body, MAX_BODY) || !b.body.trim()) bad('Invalid body');
+ if (!plain(b.created_at, MAX_CREATED_AT) || !DATE_TIME.test(b.created_at) || !Number.isFinite(Date.parse(b.created_at))) bad('Invalid created_at');
+}
+
+/** Query string → validated positive ids; anything not in `spec` is 400. */
+function query(req, spec) {
+ const out = {};
+ for (const [k, raw] of Object.entries(req.query)) {
+ if (!spec[k] || typeof raw !== 'string' || !/^\d{1,16}$/.test(raw)) bad(`Invalid parameter ${k}`);
+ const v = Number(raw);
+ if (!int(v)) bad(`Invalid ${k}`);
+ out[k] = v;
+ }
+ return out;
+}
+
+function route(name, fn) {
+ return async (req, res) => {
+ try {
+ res.json({ ok: true, ...await fn(req) });
+ } catch (err) {
+ if (err.status) return res.status(err.status).json({ ok: false, error: err.message });
+ console.error(`[ChatTickets] ${name}:`, err);
+ res.status(503).json({ ok: false, error: 'Chat ticket conversation unavailable' });
+ }
+ };
+}
+
+/** The (ticket, service) conversation, created on first use; a concurrent create resolves to one row. */
+async function conversationFor(ticket, service) {
+ const existing = await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);
+ if (existing) return existing;
+ await db.run('INSERT INTO ticket_conversations (ticket_id, service) VALUES (?, ?) ON CONFLICT DO NOTHING', [ticket, service]);
+ return await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);
+}
+
+async function postMessage(req) {
+ const ticket = ticketId(req);
+ const service = callingService(req);
+ validateMessage(req.body);
+ const b = req.body;
+ await db.transaction(async () => {
+ const conv = await conversationFor(ticket, service);
+ await db.run(
+ 'INSERT INTO ticket_messages (conversation_id, author_kind, author, body, created_at) VALUES (?, ?, ?, ?, ?)',
+ [conv.id, b.author_kind, b.author, b.body, b.created_at]);
+ });
+ return { ticket_id: ticket, service, message: { author_kind: b.author_kind, author: b.author, body: b.body, created_at: b.created_at } };
+}
+
+const messageRow = (r) => ({ id: Number(r.id), author_kind: String(r.author_kind), author: String(r.author), body: String(r.body), created_at: String(r.created_at) });
+
+async function getConversation(req) {
+ const ticket = ticketId(req);
+ const service = callingService(req);
+ const q = query(req, { after_id: true, before_id: true, limit: true });
+ const conv = await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);
+ if (!conv) notFound('Ticket conversation not found');
+ const where = ['conversation_id = ?'];
+ const params = [conv.id];
+ if (q.after_id != null) { where.push('id > ?'); params.push(q.after_id); }
+ if (q.before_id != null) { where.push('id < ?'); params.push(q.before_id); }
+ const max = await db.get('SELECT MAX(id) AS max_id FROM ticket_messages WHERE conversation_id = ?', [conv.id]);
+ // after_id pages forward (oldest first); otherwise newest first, back from before_id.
+ const order = q.after_id != null ? 'ASC' : 'DESC';
+ const rows = await db.all(
+ `SELECT id, author_kind, author, body, created_at FROM ticket_messages WHERE ${where.join(' AND ')} ORDER BY id ${order} LIMIT ?`,
+ [...params, Math.min(q.limit || 100, MAX_MESSAGES)]);
+ return { ticket_id: ticket, service, created_at: String(conv.created_at), messages: rows.map(messageRow), max_id: max && max.max_id != null ? Number(max.max_id) : null };
+}
+
+const router = express.Router();
+router.post('/tickets/:ticket_id/messages', serviceAuth.guard('chat.ticket.write'), route('ticket-message', postMessage));
+router.get('/tickets/:ticket_id', serviceAuth.guard('chat.ticket.write'), route('ticket-conversation', getConversation));
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+/**
+ * OpenVibe.Help's per-ticket conversation (server/chat/internal-tickets.js, capability
+ * chat.ticket.write, chat.ticket-conversation@1 added in openvibe-contracts 0.100.0): a
+ * conversation is keyed by (ticket id, calling service) and a service only ever reads its own.
+ */
+
+const assert = require('assert');
+const path = require('path');
+const { validate } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const quiet = { log() {}, info() {}, warn() {}, error() {} };
+
+const t = suite('chat-internal-tickets');
+let h;
+const TICKET = 'tkt_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const HELP = (cap = 'chat.ticket.write') => h.serviceToken([cap], { sub: 'svc:help' });
+const AI = (cap = 'chat.ticket.write') => h.serviceToken([cap], { sub: 'svc:ai' });
+const msg = (author_kind, author, body, created_at = '2026-10-05T09:00:00Z') => ({ author_kind, author, body, created_at });
+
+// chat.ticket-conversation@1 arrives with openvibe-contracts 0.100.0. Until the pin reaches it an
+// older package cannot validate it: the run says so instead of failing (shapes are asserted too).
+function check(ref, body) {
+ let r;
+ try { r = validate(ref, body); } catch (err) {
+ if (/unknown contract|is v\d/.test(err.message)) return body;
+ throw err;
+ }
+ assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);
+ return body;
+}
+async function post(token, ticket, body) {
+ const r = await h.http('POST', `/internal/chat/tickets/${encodeURIComponent(ticket)}/messages`, { token, body });
+ assert.strictEqual(r.status, 200, `${JSON.stringify(body)}: ${r.text}`);
+ return r.body;
+}
+async function get(token, ticket, qs = '', status = 200) {
+ const r = await h.http('GET', `/internal/chat/tickets/${encodeURIComponent(ticket)}${qs}`, { token });
+ assert.strictEqual(r.status, status, `${ticket}${qs}: ${r.text}`);
+ return r.body;
+}
+
+t('migrate the final schema and boot', async () => {
+ // The shared harness migrates with the N-1 window, which holds 0005 (a contract migration) and
+ // with it the later 0006/0007. The ticket tables arrive in 0007, so apply the final schema
+ // first, exactly as test/pg-schema.test.js does.
+ const owner = globalThis.__ovChatTestOwnerDb && globalThis.__ovChatTestOwnerDb();
+ if (!owner) throw new Error('run this file through test/run.js (test/helpers/pg-preload.mjs provides the test database)');
+ await owner.migrate({ dir: MIGRATIONS, windowDays: 0, log: quiet });
+ h = await boot();
+});
+
+t('the route needs its capability, a service token for Chat and loopback', async () => {
+ const url = `/internal/chat/tickets/${TICKET}/messages`;
+ const body = msg('person', 'user:usr_01', 'hello');
+ assert.strictEqual((await h.http('POST', url, { body })).status, 401, 'no token');
+ assert.strictEqual((await h.http('POST', url, { body, token: h.serviceToken(['chat.messages.read'], { sub: 'svc:help' }) })).status, 403, 'wrong capability');
+ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`, { token: h.serviceToken(['chat.messages.read'], { sub: 'svc:help' }) })).status, 403, 'wrong capability (GET)');
+ assert.strictEqual((await h.http('POST', url, { body, token: h.serviceToken(['chat.ticket.write'], { sub: 'svc:help', aud: 'openvibe.live' }) })).status, 401, 'wrong audience');
+ assert.strictEqual((await h.http('POST', url, { body, token: HELP(), headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'from outside');
+ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`)).status, 401, 'GET no token');
+});
+
+t('an app token is refused: only a first-party service token', async () => {
+ const app = h.serviceToken(['chat.ticket.write'], { sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });
+ const body = msg('person', 'user:usr_01', 'hello');
+ assert.strictEqual((await h.http('POST', `/internal/chat/tickets/${TICKET}/messages`, { token: app, body })).status, 403);
+ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`, { token: app })).status, 403);
+});
+
+t('create, append and read back: the conversation is (ticket, service)', async () => {
+ const first = msg('person', 'user:usr_01', 'My export from yesterday is missing the last day.');
+ const created = await post(HELP(), TICKET, first);
+ // The contract describes {ticket_id, service, message}; Chat's answer adds its usual ok.
+ check('chat.ticket-conversation@1', { ticket_id: created.ticket_id, service: created.service, message: created.message });
+ assert.deepStrictEqual(created, { ok: true, ticket_id: TICKET, service: 'help', message: first });
+
+ await post(HELP(), TICKET, msg('agent', 'agent:agt_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'Checking the export job.', '2026-10-05T09:01:30Z'));
+ await post(HELP(), TICKET, msg('staff', 'staff:sam', 'The last hour moves into the next day file.', '2026-10-05T09:02:00Z'));
+
+ const page = await get(HELP(), TICKET);
+ assert.strictEqual(page.ticket_id, TICKET);
+ assert.strictEqual(page.service, 'help');
+ assert.match(page.created_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, 'conversation created_at');
+ assert.strictEqual(page.messages.length, 3);
+ assert.deepStrictEqual(page.messages.map((m) => m.author_kind), ['staff', 'agent', 'person'], 'newest first');
+ assert.deepStrictEqual(page.messages[0], {
+ id: page.max_id, author_kind: 'staff', author: 'staff:sam',
+ body: 'The last hour moves into the next day file.', created_at: '2026-10-05T09:02:00Z',
+ });
+ assert.strictEqual(page.max_id, Math.max(...page.messages.map((m) => m.id)));
+
+ // after_id pages forward (oldest first); before_id and limit page back.
+ const oldest = page.messages[page.messages.length - 1].id;
+ const forward = await get(HELP(), TICKET, `?after_id=${oldest}`);
+ assert.deepStrictEqual(forward.messages.map((m) => m.author_kind), ['agent', 'staff']);
+ assert.strictEqual(forward.max_id, page.max_id);
+ assert.deepStrictEqual((await get(HELP(), TICKET, '?limit=2')).messages.map((m) => m.author_kind), ['staff', 'agent']);
+ assert.deepStrictEqual((await get(HELP(), TICKET, `?before_id=${page.max_id}`)).messages.map((m) => m.author_kind), ['agent', 'person']);
+ assert.deepStrictEqual(await get(HELP(), TICKET, `?after_id=${page.max_id}`), { ok: true, ticket_id: TICKET, service: 'help', created_at: page.created_at, messages: [], max_id: page.max_id });
+ // limit is clamped to 500, not refused: 100000 reads the whole conversation.
+ assert.strictEqual((await get(HELP(), TICKET, '?limit=100000')).messages.length, 3);
+});
+
+t('another service gets its own conversation and never reads help\'s', async () => {
+ await post(AI(), TICKET, msg('agent', 'agent:agt_ai', 'AI here, on the same ticket.'));
+
+ const ai = await get(AI(), TICKET);
+ assert.strictEqual(ai.service, 'ai');
+ assert.deepStrictEqual(ai.messages.map((m) => m.author), ['agent:agt_ai'], 'only ai\'s own message');
+
+ const help = await get(HELP(), TICKET);
+ assert.strictEqual(help.messages.length, 3, 'help\'s conversation did not grow');
+ assert.ok(help.messages.every((m) => m.author !== 'agent:agt_ai'), 'ai\'s message is not in help\'s conversation');
+
+ // A ticket only ai has: help is 404 (it has no conversation), and posting as ai never makes one for help.
+ assert.deepStrictEqual(await get(HELP(), 'tkt_ai_only', '', 404), { ok: false, error: 'Ticket conversation not found' });
+ await post(AI(), 'tkt_ai_only', msg('staff', 'staff:sam', 'Only ai has this ticket.'));
+ assert.strictEqual((await get(HELP(), 'tkt_ai_only', '', 404)).ok, false);
+ assert.strictEqual((await get(AI(), 'tkt_ai_only')).messages.length, 1);
+
+ // An unknown ticket has no conversation at all.
+ assert.deepStrictEqual(await get(HELP(), 'tkt_nobody', '', 404), { ok: false, error: 'Ticket conversation not found' });
+});
+
+t('validation: author_kind, author, body bounds, created_at, ticket id and query', async () => {
+ const url = `/internal/chat/tickets/${TICKET}/messages`;
+ const write = (body) => h.http('POST', url, { token: HELP(), body });
+ const long = 'x'.repeat(6000);
+ const bad = [
+ msg('bot', 'user:usr_01', 'not a kind'),
+ msg('person', '', 'empty author'),
+ msg('person', 'a'.repeat(201), 'long author'),
+ msg('person', 'user:usr_01', ''),
+ msg('person', 'user:usr_01', ' '),
+ msg('person', 'user:usr_01', 'x'.repeat(6001)),
+ msg('person', 'user:usr_01', 'nul \u0000 byte'),
+ msg('person', 'user:\u0000', 'nul in author'),
+ { ...msg('person', 'user:usr_01', 'x'), extra: 1 },
+ { author_kind: 'person', author: 'user:usr_01', body: 'x' },
+ { ...msg('person', 'user:usr_01', 'x'), created_at: 'yesterday' },
+ [1],
+ ];
+ for (const body of bad) assert.strictEqual((await write(body)).status, 400, JSON.stringify(body));
+
+ // 6000 characters is the contract's maximum: accepted and returned unchanged, never as HTML.
+ await post(HELP(), 'tkt_bounds', msg('person', 'user:usr_01', long));
+ assert.strictEqual((await get(HELP(), 'tkt_bounds')).messages[0].body, long);
+
+ // The ticket id comes from the path: blank, whitespace, a slash or over 200 characters is 400.
+ for (const ticket of ['%20', 'a%20b', 'a%2Fb', 'a'.repeat(201)]) {
+ const r = await h.http('POST', `/internal/chat/tickets/${ticket}/messages`, { token: HELP(), body: msg('person', 'user:usr_01', 'x') });
+ assert.strictEqual(r.status, 400, ticket);
+ }
+
+ for (const q of ['?limit=0', '?limit=-1', '?limit=abc', '?limit=', '?after_id=0', '?before_id=0', '?unknown=1']) {
+ const r = await h.http('GET', `/internal/chat/tickets/${TICKET}${q}`, { token: HELP() });
+ assert.strictEqual(r.status, 400, q);
+ }
+});
+
+t('the contract has no idempotency key, so a retry appends another message', async () => {
+ const body = msg('person', 'user:usr_dup', 'sent twice');
+ await post(HELP(), 'tkt_dup', body);
+ await post(HELP(), 'tkt_dup', body);
+ assert.deepStrictEqual((await get(HELP(), 'tkt_dup')).messages.map((m) => m.body), ['sent twice', 'sent twice']);
+});
+
+t.run(async () => {
+ if (h) await h.close();
+});
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'live_mirror_outbox',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'live_mirror_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'token_revocations',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'token_revocations',
- 'network_blocks', 'account_data_events',
+ 'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages',
];
];
const INDEXES = [
const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',