openvibers/openvibe.chat Commits
Add Community backed forum channels (#458)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- A Chat forum channel is a view of a Community forum space; Community remains the thread store.++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS forum_space text COLLATE "C";++CREATE INDEX IF NOT EXISTS idx_rooms_forum_space ON rooms (forum_space) WHERE forum_space IS NOT NULL AND archived_at IS NULL;@@@ -234,6 +234,11 @@ module.exports = {scope: 'bench.round.create bench.judgement.cast',},exchange: { internalUrl: strip(process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150') },++ community: {++ internalUrl: strip(process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),++ publicUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),++ timeoutMs: Math.max(1000, int(process.env.CHAT_COMMUNITY_TIMEOUT_MS, 5000)),++ },// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner'snnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++++/** Forum channels are views over Community's forum API. Chat never stores forum posts or replies. */++const config = require('../config');++++class ForumError extends Error {++ constructor(status, message, code = 'forum.unavailable') { super(message); this.status = status; this.code = code; }++}++const slugOf = (v) => {++ const s = String(v || '').trim().toLowerCase();++ return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(s) ? s : null;++};++++async function call(method, path, { token = null, body = null } = {}) {++ const headers = { Accept: 'application/json' };++ if (token) headers.Authorization = `Bearer ${token}`;++ if (body !== null) headers['Content-Type'] = 'application/json';++ let response;++ try {++ response = await fetch(`${config.community.internalUrl}/api/v1/spaces${path}`, {++ method, headers, ...(body !== null ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(config.community.timeoutMs),++ });++ } catch {++ throw new ForumError(503, 'Community forums are unavailable right now');++ }++ const data = await response.json().catch(() => null);++ if (!response.ok) throw new ForumError(response.status, data && (data.detail || data.error) || 'Community refused the forum request', data && data.code || 'forum.community_error');++ return data;++}++++async function verifyManage(space, token) {++ const slug = slugOf(space);++ if (!slug) throw new ForumError(422, 'Choose a valid Community forum address', 'forum.space_invalid');++ if (!token) throw new ForumError(401, 'Sign in again before connecting a forum', 'forum.sign_in');++ const out = await call('GET', `/${encodeURIComponent(slug)}/threads?limit=1`, { token });++ if (!out || !out.space || out.space.style !== 'forum') throw new ForumError(422, 'That Community space is not a forum', 'forum.not_a_forum');++ if (!out.viewer || !out.viewer.can_moderate) throw new ForumError(403, 'You must moderate that Community forum to connect it', 'forum.not_a_moderator');++ return { slug, space: out.space };++}++++async function list(space, token, { sort = 'active', category = '', page = 1, limit = 30 } = {}) {++ const slug = slugOf(space);++ if (!slug) throw new ForumError(404, 'No such forum', 'forum.not_found');++ const q = new URLSearchParams();++ q.set('sort', ['active', 'new'].includes(sort) ? sort : 'active');++ q.set('page', String(Math.max(1, Math.min(1000, Number(page) || 1))));++ q.set('limit', String(Math.max(1, Math.min(50, Number(limit) || 30))));++ if (category) q.set('category', String(category).slice(0, 50));++ return await call('GET', `/${encodeURIComponent(slug)}/threads?${q}`, { token });++}++++async function thread(space, slug, token, { page = 1 } = {}) {++ const s = slugOf(space);++ const t = slugOf(slug);++ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');++ return await call('GET', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}?page=${Math.max(1, Number(page) || 1)}`, { token });++}++++async function create(space, token, body) {++ const slug = slugOf(space);++ if (!slug) throw new ForumError(404, 'No such forum', 'forum.not_found');++ return await call('POST', `/${encodeURIComponent(slug)}/threads`, { token, body });++}++async function reply(space, threadSlug, token, body) {++ const s = slugOf(space), t = slugOf(threadSlug);++ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');++ return await call('POST', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}/posts`, { token, body });++}++async function moderate(space, threadSlug, token, body) {++ const s = slugOf(space), t = slugOf(threadSlug);++ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');++ return await call('PUT', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}/state`, { token, body });++}++async function categories(space, token) {++ const s = slugOf(space);++ if (!s) throw new ForumError(404, 'No such forum', 'forum.not_found');++ return await call('GET', `/${encodeURIComponent(s)}/categories`, { token });++}++++module.exports = { ForumError, slugOf, verifyManage, list, thread, create, reply, moderate, categories };@@@ -12,7 +12,7 @@* POST /:slug/leave* GET /:slug/members (moderators also see who is banned)* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }-- * POST /:slug/channels owner/admins: { name, kind: text | voice | stage, topic? }++ * POST /:slug/channels owner/admins: { name, kind: text | voice | forum | stage, community_space?, topic? }* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)* GET /:slug/invites owner/admins: the live invites@@@ -29,6 +29,7 @@ const { limits } = require('../net/actor-limits');const rooms = require('./rooms');const servers = require('./servers');const ctx = require('../live-context');++const communityForum = require('./community-forum');const router = express.Router();const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });@@@ -37,12 +38,23 @@ const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });function send(res, err) {if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });++ if (err instanceof communityForum.ForumError) return res.status(err.status).json({ error: err.message, code: err.code });console.error('[Servers]', err && err.message);return res.status(500).json({ error: 'Something went wrong' });}const chatServer = () => require('../chat/chat-server');const roomRoutes = () => require('./routes');const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };++const forumToken = (req) => {++ const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || ''));++ return m ? m[1].trim() : req.cookies && req.cookies.ov_token || null;++};++async function forumChannel(req, res, server) {++ const room = await channelOf(req, res, server);++ if (!room) return null;++ if (!room.forum_space) { res.status(404).json({ error: 'No such forum channel', code: 'forum.not_found' }); return null; }++ return room;++}/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */async function applyServerChange(server) {@@@ -156,11 +168,56 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {const server = await readable(req, res); if (!server) return;try {const b = req.body || {};-- const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic });++ if (b.kind === 'forum' && req.authSource === 'api_token') return res.status(403).json({ error: 'Connect a forum with your own sign-in', code: 'forum.person_only' });++ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });++ if (b.kind === 'forum' && !(await servers.access(server, req.user)).manage) return res.status(403).json({ error: 'Only the server owner and admins add channels', code: 'servers.not_admin' });++ if (b.kind === 'forum') await communityForum.verifyManage(b.community_space, forumToken(req));++ const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic, community_space: b.community_space });res.status(201).json({ channel: servers.publicChannel(room) });} catch (err) { send(res, err); }});++router.get('/:slug/:channel/forum', optionalAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await forumChannel(req, res, server); if (!room) return;++ try {++ const out = await communityForum.list(room.forum_space, forumToken(req), req.query);++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(out);++ } catch (err) { send(res, err); }++});++router.get('/:slug/:channel/forum/categories', optionalAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await forumChannel(req, res, server); if (!room) return;++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await communityForum.categories(room.forum_space, forumToken(req))); }++ catch (err) { send(res, err); }++});++router.get('/:slug/:channel/forum/:thread', optionalAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await forumChannel(req, res, server); if (!room) return;++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await communityForum.thread(room.forum_space, req.params.thread, forumToken(req), { page: req.query.page })); }++ catch (err) { send(res, err); }++});++router.post('/:slug/:channel/forum', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await forumChannel(req, res, server); if (!room) return;++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Post with your own sign-in', code: 'forum.person_only' });++ try { res.status(201).json(await communityForum.create(room.forum_space, forumToken(req), req.body || {})); }++ catch (err) { send(res, err); }++});++router.post('/:slug/:channel/forum/:thread/replies', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await forumChannel(req, res, server); if (!room) return;++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Reply with your own sign-in', code: 'forum.person_only' });++ try { res.status(201).json(await communityForum.reply(room.forum_space, req.params.thread, forumToken(req), req.body || {})); }++ catch (err) { send(res, err); }++});++router.put('/:slug/:channel/forum/:thread/state', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await forumChannel(req, res, server); if (!room) return;++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Moderate with your own sign-in', code: 'forum.person_only' });++ try { res.json(await communityForum.moderate(room.forum_space, req.params.thread, forumToken(req), req.body || {})); }++ catch (err) { send(res, err); }++});router.get('/:slug/:channel/stage', optionalAuth, async (req, res) => {const server = await readable(req, res); if (!server) return;const room = await stageChannel(req, res, server); if (!room) return;@@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invitconst OFFICIAL_SLUG = 'openvibe';const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };--const CHANNEL_KINDS = { text: 'community', voice: 'call', stage: 'call' };++const CHANNEL_KINDS = { text: 'community', voice: 'call', forum: 'community', stage: 'call' };/** Channel names the server's own pages use (/s/<server>/settings …). */const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);const MAX_SERVERS_PER_OWNER = 5;@@@ -134,7 +134,7 @@ function publicServer(s, extra = {}) {/** A channel as the server shows it. */function publicChannel(r, extra = {}) {return {-- slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',++ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.forum_space ? 'forum' : r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,category: r.category || null,...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: r.stage_channel ? 'participant' : rooms.joinRoleOf(r) } : {}), ...extra,@@@ -160,12 +160,14 @@ async function channelBySlug(server, channelSlug) {return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;}--async function insertChannel(server, user, { name, kind, topic = null, position = null }) {++async function insertChannel(server, user, { name, kind, topic = null, position = null, community_space = null }) {const n = cleanName(name, 1, 40);if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');const roomKind = CHANNEL_KINDS[kind];-- if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');++ if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text, voice, forum or stage');++ if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');++ if (kind === 'forum' && !require('./community-forum').slugOf(community_space)) fail(422, 'servers.forum_space', 'Connect a valid Community forum address');if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);@@@ -177,9 +179,9 @@ async function insertChannel(server, user, { name, kind, topic = null, positionconst pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.-- const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, stage_channel)-- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,-- [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'stage']);++ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, forum_space, stage_channel)++ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)`,++ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'forum' ? String(community_space).toLowerCase() : null, kind === 'stage']);if (kind === 'stage') await db.run('INSERT INTO chat_stage_sessions (room_id, topic) VALUES (?, ?)', [r.lastInsertRowid, t]);return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);}@@@ -408,9 +410,9 @@ async function update(server, actor, { name, description, visibility, rules, selreturn await byId(server.id);}--async function createChannel(server, actor, { name, kind = 'text', topic = null } = {}) {++async function createChannel(server, actor, { name, kind = 'text', topic = null, community_space = null } = {}) {if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');-- const room = await insertChannel(server, actor, { name, kind, topic });++ const room = await insertChannel(server, actor, { name, kind, topic, community_space });// Every member reads the new channel from its first message on.await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'@@@ -38,6 +38,12 @@ const SCHEMA = Object.freeze({type: 'object',additionalProperties: false,properties: {++ forum_channels_enabled: {++ type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: false,++ description: 'Allow server owners to connect channels to Community forums.',++ 'x-required-for': 'Community backed forum channels',++ 'x-owner-input': { group: 'voices', unlocks: 'Forum channels backed by Community discussions and the shared search feed.', get_it_at: 'https://openvibe.community', get_it_how: 'This is a Chat feature switch. Enable it to let server owners connect forums they moderate.' },++ },stage_channels_enabled: {type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',@@@ -194,6 +194,43 @@ ${sheet}`,if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });const room = await rooms.bySlug(pick.room);const rcan = await rooms.access(room, me);++ if (room.forum_space) {++ const forum = require('../rooms/community-forum');++ const bearer = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || ''));++ const token = bearer ? bearer[1].trim() : req.cookies && req.cookies.ov_token || null;++ try {++ const data = req.query.post++ ? await forum.thread(room.forum_space, req.query.post, token, { page: req.query.page })++ : await forum.list(room.forum_space, token, req.query);++ const mine = me ? (await servers.list(me)).mine : [];++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;++ const categories = data.categories || [];++ const post = data.thread || null;++ const itemHref = (slug) => `/s/${server.slug}/${pick.slug}?post=${encodeURIComponent(slug)}`;++ const sortLinks = `<nav aria-label="Forum order"><a href="?sort=active">Latest activity</a> · <a href="?sort=new">Newest</a></nav>`;++ const tags = categories.length ? `<nav aria-label="Tags"><a href="?sort=active">All tags</a>${categories.map((tag) => ` · <a href="?category=${encodeURIComponent(tag.slug)}">${esc(tag.name)}</a>`).join('')}</nav>` : '';++ const searchHref = `${require('../config').community.publicUrl}/search?type=thread&q=${encodeURIComponent(data.space && data.space.name || '')}`;++ const listMarkup = (data.threads || []).map((t) => `<article><h3><a href="${itemHref(t.slug)}">${esc(t.title)}</a></h3><p>${esc(t.author && (t.author.display_name || t.author.username) || 'Member')} · ${Number(t.reply_count) || 0} replies · ${esc(t.last_activity_at || t.created_at || '')}${t.pinned ? ' · Pinned' : ''}${t.locked ? ' · Locked' : ''}</p>${t.category ? `<p>Tag: ${esc(t.category.name)}</p>` : ''}</article>`).join('') || '<p>No posts yet.</p>';++ const postMarkup = post ? `<article><h2>${esc(post.title)}</h2><p>${esc(post.author && (post.author.display_name || post.author.username) || 'Member')} · ${esc(post.last_activity_at || post.created_at || '')}${post.pinned ? ' · Pinned' : ''}${post.locked ? ' · Locked' : ''}</p>${(data.posts || []).map((p) => `<section><p>${esc(p.author && (p.author.display_name || p.author.username) || 'Member')} · ${esc(p.created_at || '')}</p><div>${p.body_html || ''}</div></section>`).join('')}<p><a href="${esc(`${require('../config').community.publicUrl}/s/${encodeURIComponent(room.forum_space)}/t/${encodeURIComponent(post.slug)}`)}">Reply or edit in Community</a></p></article>` : `<section aria-label="Forum posts">${listMarkup}</section>`;++ const forumForm = me && !post ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum"><h3>Start a post</h3><label>Title <input name="title" maxlength="200" required></label><label>Post <textarea name="body" maxlength="40000" rows="5" required></textarea></label>${categories.length ? `<label>Tag <select name="category"><option value="">No tag</option>${categories.map((c) => `<option value="${esc(c.slug)}">${esc(c.name)}</option>`).join('')}</select></label>` : ''}<button class="ovk-btn ovk-btn--primary" type="submit">Post</button></form>` : '';++ const replyForm = me && post && data.viewer && data.viewer.can_reply ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/replies"><label>Reply <textarea name="body" maxlength="40000" rows="4" required></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Reply</button></form>` : '';++ const modForm = me && post && data.viewer && data.viewer.can_moderate ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/state"><label><input type="checkbox" name="pinned" value="true"${post.pinned ? ' checked' : ''}> Pinned</label><label><input type="checkbox" name="locked" value="true"${post.locked ? ' checked' : ''}> Locked</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save moderation</button></form>` : '';++ const shellBody = shell.shell({++ rail: shell.rail({ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })), dmUnread }),++ side: shell.sideServer({ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,++ categories: [{ name: 'Forum channels', channels: chans.filter((c) => c.kind === 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) },++ { name: 'Other channels', channels: chans.filter((c) => c.kind !== 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) }], occupants: {},++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {} }),++ sideLabel: server.name,++ main: `${shell.channelHeader({ name: room.name, kind: 'forum', topic: room.topic || data.space && data.space.description, members: true })}<h2>${esc(data.space && data.space.name || 'Forum')}</h2>${sortLinks}${tags}<p><a href="${esc(searchHref)}">Search posts</a></p>${postMarkup}${forumForm}${replyForm}${modForm}`,++ mainLabel: `Forum: ${room.name}`,++ members: shell.membersColumn({ groups: [] }), membersLabel: `People in ${server.name}`,++ });++ return await page(req, res, 200, { actor, shell: true, footer: 'none', title: `${post ? post.title : 'Forum'} · ${server.name}`, path: req.path, active: 'servers', robots: 'noindex,follow', script: true, description: server.description || `${server.name} forum on OpenVibe.Chat.`, body: shellBody });++ } catch (err) {++ return await page(req, res, err.status || 503, { actor, shell: true, title: 'Forum unavailable', path: req.path, active: 'servers', robots: 'noindex', body: `<h1>Forum unavailable</h1><p>${esc(err.message)}</p><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });++ }++ }const msgs = await rooms.history(room, { limit: 60 });if (me && rcan.role) await rooms.markRead(room, me);const mine = me ? (await servers.list(me)).mine : [];@@@ -471,7 +508,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro};const active = people.filter((p) => p.role !== 'blocked');const banned = people.filter((p) => p.role === 'blocked');-- const chanRow = (c) => `<li><span>${c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>++ const chanRow = (c) => `<li><span>${c.kind === 'forum' ? '▤' : c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span><form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form><details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>@@@ -499,7 +536,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label><p><button type="submit">Save</button></p></form><h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>--<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select> <button type="submit">Add</button></form>++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form><section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2><p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p><form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">@@@ -544,7 +581,10 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth}));router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {const b = req.body || {};-- const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'stage'].includes(b.kind) ? b.kind : 'text' });++ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });++ if (b.kind === 'forum' && !(await servers.access(server, user)).manage) throw Object.assign(new Error('Only the server owner and admins add channels'), { status: 403 });++ if (b.kind === 'forum') await require('../rooms/community-forum').verifyManage(b.community_space, req.cookies && req.cookies.ov_token);++ const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'forum', 'stage'].includes(b.kind) ? b.kind : 'text', community_space: b.community_space });res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);}));router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {@@@ -618,11 +658,42 @@ ${errorOf(req)}${action}</section>`,// A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).router.get('/s/:slug/:channel', serverPage);++ router.post('/s/:slug/:channel/forum', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;++ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}/${room.channel_slug}`;++ if (req.body && req.body.category) req.body.category = String(req.body.category);++ const token = req.cookies && req.cookies.ov_token;++ const out = await require('../rooms/community-forum').create(room.forum_space, token, req.body || {});++ res.redirect(303, `${req.back}?post=${encodeURIComponent(out.thread.slug)}`);++ }));++ router.post('/s/:slug/:channel/forum/:thread/replies', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;++ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}/${room.channel_slug}?post=${encodeURIComponent(req.params.thread)}`;++ await require('../rooms/community-forum').reply(room.forum_space, req.params.thread, req.cookies && req.cookies.ov_token, req.body || {});++ res.redirect(303, req.back);++ }));++ router.post('/s/:slug/:channel/forum/:thread/state', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;++ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}/${room.channel_slug}?post=${encodeURIComponent(req.params.thread)}`;++ await require('../rooms/community-forum').moderate(room.forum_space, req.params.thread, req.cookies && req.cookies.ov_token,++ { pinned: req.body && req.body.pinned === 'true', locked: req.body && req.body.locked === 'true' });++ res.redirect(303, req.back);++ }));router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const server = await servers.bySlug(req.params.slug);const room = server ? await servers.channelBySlug(server, req.params.channel) : null;if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');++ if (room.forum_space) return res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);req.back = `/s/${server.slug}/${room.channel_slug}`;const message = await rooms.post(room, actor.user, req.body && req.body.message);require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });nnew file mode 100644@@@ -0,0 +1,25 @@++'use strict';++const assert = require('assert');++const { suite } = require('./helpers');++const forum = require('../server/rooms/community-forum');++++const t = suite('community forum client');++++t('validates forum connections with the signed-in person token and supports activity, newest and category filters', async () => {++ assert.strictEqual(forum.slugOf('../private'), null);++ const oldFetch = global.fetch;++ const seen = [];++ global.fetch = async (url, options) => {++ seen.push({ url: String(url), options });++ return { ok: true, status: 200, json: async () => ({ space: { slug: 'announcements', style: 'forum' }, viewer: { can_moderate: true }, threads: [] }) };++ };++ try {++ const connected = await forum.verifyManage('announcements', 'user-token');++ assert.strictEqual(connected.space.slug, 'announcements');++ assert.strictEqual(seen[0].options.headers.Authorization, 'Bearer user-token');++ assert.match(seen[0].url, /\/api\/v1\/spaces\/announcements\/threads\?limit=1$/);++ await forum.list('announcements', 'user-token', { sort: 'new', category: 'updates' });++ assert.match(seen[1].url, /sort=new/);++ assert.match(seen[1].url, /category=updates/);++ } finally { global.fetch = oldFetch; }++});@@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);}-- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json', 'stage_channels_enabled']);++ assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);const region = props.amazon_polly_region;assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);for (const v of region.enum) {@@@ -41,7 +41,7 @@ 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_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent','idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',-- 'idx_dm_contacts_user',++ 'idx_dm_contacts_user', 'idx_rooms_forum_space','chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',];const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',@@@ -116,6 +116,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue','server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export','server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',++ 'server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs','server/net/service-auth.js': 'Network JWKS (configured)','server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations','server/prefs/network-modules.js': 'Network user modules (configured)',@@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban met('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'forum channels default off');assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- A Chat forum channel is a view of a Community forum space; Community remains the thread store.
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS forum_space text COLLATE "C";
+CREATE INDEX IF NOT EXISTS idx_rooms_forum_space ON rooms (forum_space) WHERE forum_space IS NOT NULL AND archived_at IS NULL;
@@ -234,6 +234,11 @@ module.exports = {
@@ -234,6 +234,11 @@ module.exports = {
scope: 'bench.round.create bench.judgement.cast',
scope: 'bench.round.create bench.judgement.cast',
},
},
exchange: { internalUrl: strip(process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150') },
exchange: { internalUrl: strip(process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150') },
+ community: {
+ internalUrl: strip(process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),
+ publicUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
+ timeoutMs: Math.max(1000, int(process.env.CHAT_COMMUNITY_TIMEOUT_MS, 5000)),
+ },
// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's
// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+
+/** Forum channels are views over Community's forum API. Chat never stores forum posts or replies. */
+const config = require('../config');
+
+class ForumError extends Error {
+ constructor(status, message, code = 'forum.unavailable') { super(message); this.status = status; this.code = code; }
+}
+const slugOf = (v) => {
+ const s = String(v || '').trim().toLowerCase();
+ return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(s) ? s : null;
+};
+
+async function call(method, path, { token = null, body = null } = {}) {
+ const headers = { Accept: 'application/json' };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ if (body !== null) headers['Content-Type'] = 'application/json';
+ let response;
+ try {
+ response = await fetch(`${config.community.internalUrl}/api/v1/spaces${path}`, {
+ method, headers, ...(body !== null ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(config.community.timeoutMs),
+ });
+ } catch {
+ throw new ForumError(503, 'Community forums are unavailable right now');
+ }
+ const data = await response.json().catch(() => null);
+ if (!response.ok) throw new ForumError(response.status, data && (data.detail || data.error) || 'Community refused the forum request', data && data.code || 'forum.community_error');
+ return data;
+}
+
+async function verifyManage(space, token) {
+ const slug = slugOf(space);
+ if (!slug) throw new ForumError(422, 'Choose a valid Community forum address', 'forum.space_invalid');
+ if (!token) throw new ForumError(401, 'Sign in again before connecting a forum', 'forum.sign_in');
+ const out = await call('GET', `/${encodeURIComponent(slug)}/threads?limit=1`, { token });
+ if (!out || !out.space || out.space.style !== 'forum') throw new ForumError(422, 'That Community space is not a forum', 'forum.not_a_forum');
+ if (!out.viewer || !out.viewer.can_moderate) throw new ForumError(403, 'You must moderate that Community forum to connect it', 'forum.not_a_moderator');
+ return { slug, space: out.space };
+}
+
+async function list(space, token, { sort = 'active', category = '', page = 1, limit = 30 } = {}) {
+ const slug = slugOf(space);
+ if (!slug) throw new ForumError(404, 'No such forum', 'forum.not_found');
+ const q = new URLSearchParams();
+ q.set('sort', ['active', 'new'].includes(sort) ? sort : 'active');
+ q.set('page', String(Math.max(1, Math.min(1000, Number(page) || 1))));
+ q.set('limit', String(Math.max(1, Math.min(50, Number(limit) || 30))));
+ if (category) q.set('category', String(category).slice(0, 50));
+ return await call('GET', `/${encodeURIComponent(slug)}/threads?${q}`, { token });
+}
+
+async function thread(space, slug, token, { page = 1 } = {}) {
+ const s = slugOf(space);
+ const t = slugOf(slug);
+ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');
+ return await call('GET', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}?page=${Math.max(1, Number(page) || 1)}`, { token });
+}
+
+async function create(space, token, body) {
+ const slug = slugOf(space);
+ if (!slug) throw new ForumError(404, 'No such forum', 'forum.not_found');
+ return await call('POST', `/${encodeURIComponent(slug)}/threads`, { token, body });
+}
+async function reply(space, threadSlug, token, body) {
+ const s = slugOf(space), t = slugOf(threadSlug);
+ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');
+ return await call('POST', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}/posts`, { token, body });
+}
+async function moderate(space, threadSlug, token, body) {
+ const s = slugOf(space), t = slugOf(threadSlug);
+ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');
+ return await call('PUT', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}/state`, { token, body });
+}
+async function categories(space, token) {
+ const s = slugOf(space);
+ if (!s) throw new ForumError(404, 'No such forum', 'forum.not_found');
+ return await call('GET', `/${encodeURIComponent(s)}/categories`, { token });
+}
+
+module.exports = { ForumError, slugOf, verifyManage, list, thread, create, reply, moderate, categories };
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* POST /:slug/leave
* POST /:slug/leave
* GET /:slug/members (moderators also see who is banned)
* GET /:slug/members (moderators also see who is banned)
* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
- * POST /:slug/channels owner/admins: { name, kind: text | voice | stage, topic? }
+ * POST /:slug/channels owner/admins: { name, kind: text | voice | forum | stage, community_space?, topic? }
* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
* GET /:slug/invites owner/admins: the live invites
* GET /:slug/invites owner/admins: the live invites
@@ -29,6 +29,7 @@ const { limits } = require('../net/actor-limits');
@@ -29,6 +29,7 @@ const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
const servers = require('./servers');
const servers = require('./servers');
const ctx = require('../live-context');
const ctx = require('../live-context');
+const communityForum = require('./community-forum');
const router = express.Router();
const router = express.Router();
const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });
const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });
@@ -37,12 +38,23 @@ const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });
@@ -37,12 +38,23 @@ const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });
function send(res, err) {
function send(res, err) {
if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
+ if (err instanceof communityForum.ForumError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Servers]', err && err.message);
console.error('[Servers]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
return res.status(500).json({ error: 'Something went wrong' });
}
}
const chatServer = () => require('../chat/chat-server');
const chatServer = () => require('../chat/chat-server');
const roomRoutes = () => require('./routes');
const roomRoutes = () => require('./routes');
const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };
const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };
+const forumToken = (req) => {
+ const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || ''));
+ return m ? m[1].trim() : req.cookies && req.cookies.ov_token || null;
+};
+async function forumChannel(req, res, server) {
+ const room = await channelOf(req, res, server);
+ if (!room) return null;
+ if (!room.forum_space) { res.status(404).json({ error: 'No such forum channel', code: 'forum.not_found' }); return null; }
+ return room;
+}
/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */
/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */
async function applyServerChange(server) {
async function applyServerChange(server) {
@@ -156,11 +168,56 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
@@ -156,11 +168,56 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
const server = await readable(req, res); if (!server) return;
const server = await readable(req, res); if (!server) return;
try {
try {
const b = req.body || {};
const b = req.body || {};
- const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic });
+ if (b.kind === 'forum' && req.authSource === 'api_token') return res.status(403).json({ error: 'Connect a forum with your own sign-in', code: 'forum.person_only' });
+ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });
+ if (b.kind === 'forum' && !(await servers.access(server, req.user)).manage) return res.status(403).json({ error: 'Only the server owner and admins add channels', code: 'servers.not_admin' });
+ if (b.kind === 'forum') await communityForum.verifyManage(b.community_space, forumToken(req));
+ const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic, community_space: b.community_space });
res.status(201).json({ channel: servers.publicChannel(room) });
res.status(201).json({ channel: servers.publicChannel(room) });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+router.get('/:slug/:channel/forum', optionalAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await forumChannel(req, res, server); if (!room) return;
+ try {
+ const out = await communityForum.list(room.forum_space, forumToken(req), req.query);
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(out);
+ } catch (err) { send(res, err); }
+});
+router.get('/:slug/:channel/forum/categories', optionalAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await forumChannel(req, res, server); if (!room) return;
+ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await communityForum.categories(room.forum_space, forumToken(req))); }
+ catch (err) { send(res, err); }
+});
+router.get('/:slug/:channel/forum/:thread', optionalAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await forumChannel(req, res, server); if (!room) return;
+ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await communityForum.thread(room.forum_space, req.params.thread, forumToken(req), { page: req.query.page })); }
+ catch (err) { send(res, err); }
+});
+router.post('/:slug/:channel/forum', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await forumChannel(req, res, server); if (!room) return;
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Post with your own sign-in', code: 'forum.person_only' });
+ try { res.status(201).json(await communityForum.create(room.forum_space, forumToken(req), req.body || {})); }
+ catch (err) { send(res, err); }
+});
+router.post('/:slug/:channel/forum/:thread/replies', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await forumChannel(req, res, server); if (!room) return;
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Reply with your own sign-in', code: 'forum.person_only' });
+ try { res.status(201).json(await communityForum.reply(room.forum_space, req.params.thread, forumToken(req), req.body || {})); }
+ catch (err) { send(res, err); }
+});
+router.put('/:slug/:channel/forum/:thread/state', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await forumChannel(req, res, server); if (!room) return;
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Moderate with your own sign-in', code: 'forum.person_only' });
+ try { res.json(await communityForum.moderate(room.forum_space, req.params.thread, forumToken(req), req.body || {})); }
+ catch (err) { send(res, err); }
+});
router.get('/:slug/:channel/stage', optionalAuth, async (req, res) => {
router.get('/:slug/:channel/stage', optionalAuth, async (req, res) => {
const server = await readable(req, res); if (!server) return;
const server = await readable(req, res); if (!server) return;
const room = await stageChannel(req, res, server); if (!room) return;
const room = await stageChannel(req, res, server); if (!room) return;
@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invit
@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invit
const OFFICIAL_SLUG = 'openvibe';
const OFFICIAL_SLUG = 'openvibe';
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
-const CHANNEL_KINDS = { text: 'community', voice: 'call', stage: 'call' };
+const CHANNEL_KINDS = { text: 'community', voice: 'call', forum: 'community', stage: 'call' };
/** Channel names the server's own pages use (/s/<server>/settings …). */
/** Channel names the server's own pages use (/s/<server>/settings …). */
const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
const MAX_SERVERS_PER_OWNER = 5;
const MAX_SERVERS_PER_OWNER = 5;
@@ -134,7 +134,7 @@ function publicServer(s, extra = {}) {
@@ -134,7 +134,7 @@ function publicServer(s, extra = {}) {
/** A channel as the server shows it. */
/** A channel as the server shows it. */
function publicChannel(r, extra = {}) {
function publicChannel(r, extra = {}) {
return {
return {
- slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',
+ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.forum_space ? 'forum' : r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
category: r.category || null,
category: r.category || null,
...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: r.stage_channel ? 'participant' : rooms.joinRoleOf(r) } : {}), ...extra,
...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: r.stage_channel ? 'participant' : rooms.joinRoleOf(r) } : {}), ...extra,
@@ -160,12 +160,14 @@ async function channelBySlug(server, channelSlug) {
@@ -160,12 +160,14 @@ async function channelBySlug(server, channelSlug) {
return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;
return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;
}
}
-async function insertChannel(server, user, { name, kind, topic = null, position = null }) {
+async function insertChannel(server, user, { name, kind, topic = null, position = null, community_space = null }) {
const n = cleanName(name, 1, 40);
const n = cleanName(name, 1, 40);
if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
const roomKind = CHANNEL_KINDS[kind];
const roomKind = CHANNEL_KINDS[kind];
- if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');
+ if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text, voice, forum or stage');
+ if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');
+ if (kind === 'forum' && !require('./community-forum').slugOf(community_space)) fail(422, 'servers.forum_space', 'Connect a valid Community forum address');
if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');
if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
@@ -177,9 +179,9 @@ async function insertChannel(server, user, { name, kind, topic = null, position
@@ -177,9 +179,9 @@ async function insertChannel(server, user, { name, kind, topic = null, position
const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
- const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, stage_channel)
- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,
- [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'stage']);
+ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, forum_space, stage_channel)
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)`,
+ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'forum' ? String(community_space).toLowerCase() : null, kind === 'stage']);
if (kind === 'stage') await db.run('INSERT INTO chat_stage_sessions (room_id, topic) VALUES (?, ?)', [r.lastInsertRowid, t]);
if (kind === 'stage') await db.run('INSERT INTO chat_stage_sessions (room_id, topic) VALUES (?, ?)', [r.lastInsertRowid, t]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
}
}
@@ -408,9 +410,9 @@ async function update(server, actor, { name, description, visibility, rules, sel
@@ -408,9 +410,9 @@ async function update(server, actor, { name, description, visibility, rules, sel
return await byId(server.id);
return await byId(server.id);
}
}
-async function createChannel(server, actor, { name, kind = 'text', topic = null } = {}) {
+async function createChannel(server, actor, { name, kind = 'text', topic = null, community_space = null } = {}) {
if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');
if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');
- const room = await insertChannel(server, actor, { name, kind, topic });
+ const room = await insertChannel(server, actor, { name, kind, topic, community_space });
// Every member reads the new channel from its first message on.
// Every member reads the new channel from its first message on.
await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'
SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'
@@ -38,6 +38,12 @@ const SCHEMA = Object.freeze({
@@ -38,6 +38,12 @@ const SCHEMA = Object.freeze({
type: 'object',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
+ forum_channels_enabled: {
+ type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: false,
+ description: 'Allow server owners to connect channels to Community forums.',
+ 'x-required-for': 'Community backed forum channels',
+ 'x-owner-input': { group: 'voices', unlocks: 'Forum channels backed by Community discussions and the shared search feed.', get_it_at: 'https://openvibe.community', get_it_how: 'This is a Chat feature switch. Enable it to let server owners connect forums they moderate.' },
+ },
stage_channels_enabled: {
stage_channels_enabled: {
type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,
type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,
description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
@@ -194,6 +194,43 @@ ${sheet}`,
@@ -194,6 +194,43 @@ ${sheet}`,
if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
const room = await rooms.bySlug(pick.room);
const room = await rooms.bySlug(pick.room);
const rcan = await rooms.access(room, me);
const rcan = await rooms.access(room, me);
+ if (room.forum_space) {
+ const forum = require('../rooms/community-forum');
+ const bearer = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || ''));
+ const token = bearer ? bearer[1].trim() : req.cookies && req.cookies.ov_token || null;
+ try {
+ const data = req.query.post
+ ? await forum.thread(room.forum_space, req.query.post, token, { page: req.query.page })
+ : await forum.list(room.forum_space, token, req.query);
+ const mine = me ? (await servers.list(me)).mine : [];
+ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
+ const categories = data.categories || [];
+ const post = data.thread || null;
+ const itemHref = (slug) => `/s/${server.slug}/${pick.slug}?post=${encodeURIComponent(slug)}`;
+ const sortLinks = `<nav aria-label="Forum order"><a href="?sort=active">Latest activity</a> · <a href="?sort=new">Newest</a></nav>`;
+ const tags = categories.length ? `<nav aria-label="Tags"><a href="?sort=active">All tags</a>${categories.map((tag) => ` · <a href="?category=${encodeURIComponent(tag.slug)}">${esc(tag.name)}</a>`).join('')}</nav>` : '';
+ const searchHref = `${require('../config').community.publicUrl}/search?type=thread&q=${encodeURIComponent(data.space && data.space.name || '')}`;
+ const listMarkup = (data.threads || []).map((t) => `<article><h3><a href="${itemHref(t.slug)}">${esc(t.title)}</a></h3><p>${esc(t.author && (t.author.display_name || t.author.username) || 'Member')} · ${Number(t.reply_count) || 0} replies · ${esc(t.last_activity_at || t.created_at || '')}${t.pinned ? ' · Pinned' : ''}${t.locked ? ' · Locked' : ''}</p>${t.category ? `<p>Tag: ${esc(t.category.name)}</p>` : ''}</article>`).join('') || '<p>No posts yet.</p>';
+ const postMarkup = post ? `<article><h2>${esc(post.title)}</h2><p>${esc(post.author && (post.author.display_name || post.author.username) || 'Member')} · ${esc(post.last_activity_at || post.created_at || '')}${post.pinned ? ' · Pinned' : ''}${post.locked ? ' · Locked' : ''}</p>${(data.posts || []).map((p) => `<section><p>${esc(p.author && (p.author.display_name || p.author.username) || 'Member')} · ${esc(p.created_at || '')}</p><div>${p.body_html || ''}</div></section>`).join('')}<p><a href="${esc(`${require('../config').community.publicUrl}/s/${encodeURIComponent(room.forum_space)}/t/${encodeURIComponent(post.slug)}`)}">Reply or edit in Community</a></p></article>` : `<section aria-label="Forum posts">${listMarkup}</section>`;
+ const forumForm = me && !post ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum"><h3>Start a post</h3><label>Title <input name="title" maxlength="200" required></label><label>Post <textarea name="body" maxlength="40000" rows="5" required></textarea></label>${categories.length ? `<label>Tag <select name="category"><option value="">No tag</option>${categories.map((c) => `<option value="${esc(c.slug)}">${esc(c.name)}</option>`).join('')}</select></label>` : ''}<button class="ovk-btn ovk-btn--primary" type="submit">Post</button></form>` : '';
+ const replyForm = me && post && data.viewer && data.viewer.can_reply ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/replies"><label>Reply <textarea name="body" maxlength="40000" rows="4" required></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Reply</button></form>` : '';
+ const modForm = me && post && data.viewer && data.viewer.can_moderate ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/state"><label><input type="checkbox" name="pinned" value="true"${post.pinned ? ' checked' : ''}> Pinned</label><label><input type="checkbox" name="locked" value="true"${post.locked ? ' checked' : ''}> Locked</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save moderation</button></form>` : '';
+ const shellBody = shell.shell({
+ rail: shell.rail({ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })), dmUnread }),
+ side: shell.sideServer({ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,
+ categories: [{ name: 'Forum channels', channels: chans.filter((c) => c.kind === 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) },
+ { name: 'Other channels', channels: chans.filter((c) => c.kind !== 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) }], occupants: {},
+ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {} }),
+ sideLabel: server.name,
+ main: `${shell.channelHeader({ name: room.name, kind: 'forum', topic: room.topic || data.space && data.space.description, members: true })}<h2>${esc(data.space && data.space.name || 'Forum')}</h2>${sortLinks}${tags}<p><a href="${esc(searchHref)}">Search posts</a></p>${postMarkup}${forumForm}${replyForm}${modForm}`,
+ mainLabel: `Forum: ${room.name}`,
+ members: shell.membersColumn({ groups: [] }), membersLabel: `People in ${server.name}`,
+ });
+ return await page(req, res, 200, { actor, shell: true, footer: 'none', title: `${post ? post.title : 'Forum'} · ${server.name}`, path: req.path, active: 'servers', robots: 'noindex,follow', script: true, description: server.description || `${server.name} forum on OpenVibe.Chat.`, body: shellBody });
+ } catch (err) {
+ return await page(req, res, err.status || 503, { actor, shell: true, title: 'Forum unavailable', path: req.path, active: 'servers', robots: 'noindex', body: `<h1>Forum unavailable</h1><p>${esc(err.message)}</p><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
+ }
+ }
const msgs = await rooms.history(room, { limit: 60 });
const msgs = await rooms.history(room, { limit: 60 });
if (me && rcan.role) await rooms.markRead(room, me);
if (me && rcan.role) await rooms.markRead(room, me);
const mine = me ? (await servers.list(me)).mine : [];
const mine = me ? (await servers.list(me)).mine : [];
@@ -471,7 +508,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
@@ -471,7 +508,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
};
};
const active = people.filter((p) => p.role !== 'blocked');
const active = people.filter((p) => p.role !== 'blocked');
const banned = people.filter((p) => p.role === 'blocked');
const banned = people.filter((p) => p.role === 'blocked');
- const chanRow = (c) => `<li><span>${c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
+ const chanRow = (c) => `<li><span>${c.kind === 'forum' ? '▤' : c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
@@ -499,7 +536,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
@@ -499,7 +536,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
-<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select> <button type="submit">Add</button></form>
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
@@ -544,7 +581,10 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
@@ -544,7 +581,10 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
}));
}));
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
const b = req.body || {};
const b = req.body || {};
- const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'stage'].includes(b.kind) ? b.kind : 'text' });
+ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });
+ if (b.kind === 'forum' && !(await servers.access(server, user)).manage) throw Object.assign(new Error('Only the server owner and admins add channels'), { status: 403 });
+ if (b.kind === 'forum') await require('../rooms/community-forum').verifyManage(b.community_space, req.cookies && req.cookies.ov_token);
+ const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'forum', 'stage'].includes(b.kind) ? b.kind : 'text', community_space: b.community_space });
res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
}));
}));
router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
@@ -618,11 +658,42 @@ ${errorOf(req)}${action}</section>`,
@@ -618,11 +658,42 @@ ${errorOf(req)}${action}</section>`,
// A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
// A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
router.get('/s/:slug/:channel', serverPage);
router.get('/s/:slug/:channel', serverPage);
+ router.post('/s/:slug/:channel/forum', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
+ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}/${room.channel_slug}`;
+ if (req.body && req.body.category) req.body.category = String(req.body.category);
+ const token = req.cookies && req.cookies.ov_token;
+ const out = await require('../rooms/community-forum').create(room.forum_space, token, req.body || {});
+ res.redirect(303, `${req.back}?post=${encodeURIComponent(out.thread.slug)}`);
+ }));
+ router.post('/s/:slug/:channel/forum/:thread/replies', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
+ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}/${room.channel_slug}?post=${encodeURIComponent(req.params.thread)}`;
+ await require('../rooms/community-forum').reply(room.forum_space, req.params.thread, req.cookies && req.cookies.ov_token, req.body || {});
+ res.redirect(303, req.back);
+ }));
+ router.post('/s/:slug/:channel/forum/:thread/state', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
+ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}/${room.channel_slug}?post=${encodeURIComponent(req.params.thread)}`;
+ await require('../rooms/community-forum').moderate(room.forum_space, req.params.thread, req.cookies && req.cookies.ov_token,
+ { pinned: req.body && req.body.pinned === 'true', locked: req.body && req.body.locked === 'true' });
+ res.redirect(303, req.back);
+ }));
router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {
router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const server = await servers.bySlug(req.params.slug);
const server = await servers.bySlug(req.params.slug);
const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');
+ if (room.forum_space) return res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
req.back = `/s/${server.slug}/${room.channel_slug}`;
req.back = `/s/${server.slug}/${room.channel_slug}`;
const message = await rooms.post(room, actor.user, req.body && req.body.message);
const message = await rooms.post(room, actor.user, req.body && req.body.message);
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+'use strict';
+const assert = require('assert');
+const { suite } = require('./helpers');
+const forum = require('../server/rooms/community-forum');
+
+const t = suite('community forum client');
+
+t('validates forum connections with the signed-in person token and supports activity, newest and category filters', async () => {
+ assert.strictEqual(forum.slugOf('../private'), null);
+ const oldFetch = global.fetch;
+ const seen = [];
+ global.fetch = async (url, options) => {
+ seen.push({ url: String(url), options });
+ return { ok: true, status: 200, json: async () => ({ space: { slug: 'announcements', style: 'forum' }, viewer: { can_moderate: true }, threads: [] }) };
+ };
+ try {
+ const connected = await forum.verifyManage('announcements', 'user-token');
+ assert.strictEqual(connected.space.slug, 'announcements');
+ assert.strictEqual(seen[0].options.headers.Authorization, 'Bearer user-token');
+ assert.match(seen[0].url, /\/api\/v1\/spaces\/announcements\/threads\?limit=1$/);
+ await forum.list('announcements', 'user-token', { sort: 'new', category: 'updates' });
+ assert.match(seen[1].url, /sort=new/);
+ assert.match(seen[1].url, /category=updates/);
+ } finally { global.fetch = oldFetch; }
+});
@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
}
}
- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json', 'stage_channels_enabled']);
+ assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);
const region = props.amazon_polly_region;
const region = props.amazon_polly_region;
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
for (const v of region.enum) {
for (const v of region.enum) {
@@ -41,7 +41,7 @@ const INDEXES = [
@@ -41,7 +41,7 @@ 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',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
- 'idx_dm_contacts_user',
+ 'idx_dm_contacts_user', 'idx_rooms_forum_space',
'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',
'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',
];
];
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
@@ -116,6 +116,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -116,6 +116,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',
'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',
'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',
'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',
'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',
'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',
+ 'server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs',
'server/net/service-auth.js': 'Network JWKS (configured)',
'server/net/service-auth.js': 'Network JWKS (configured)',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/prefs/network-modules.js': 'Network user modules (configured)',
@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
+ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'forum channels default off');
assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');
assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);