openvibers/openvibe.chat Commits

Let server channels host owner Actor agents (#527)

OP OpenVibers committed 3 days ago · 01a065b2b1 · 16 files, +243 −29

* Let server channels host owner Actor agents

* Cover Actor controls in channel and owner settings tests

* Open ready V3 channel controls by default

* Enable stage controls on active Chat calls

Changes

ddiff --git a/.env.example b/.env.example
iindex e438146..2b96ac0 100644
---- a/.env.example
++++ b/.env.example
@@@ -28,6 +28,13 @@ TRUST_PROXY=2
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# Actor agents in channels are on unless the owner turns them off in Chat settings.
++# This env setting is an operator override that keeps them on.
++#CHAT_ACTOR_MEMBERS=0
++# V3 slash commands and server emoji are on by default; set either to 0 to turn it off.
++#CHAT_SLASH_COMMANDS_ENABLED=1
++#CHAT_SERVER_EMOJI_ENABLED=1
++# Forum and stage channels are on by default; the owner can turn either off in Chat settings.
# Vibe model comparison: AI's catalogue and runs, Exchange's old chat export, and optional Bench preferences.
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#OV_EXCHANGE_INTERNAL_URL=http://127.0.0.1:5150
ddiff --git a/README.md b/README.md
iindex f31f272..5652474 100644
---- a/README.md
++++ b/README.md
@@@ -342,7 +342,15 @@ quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked
`CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (people
only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
--account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
++account keeps the ordinary AI members it added (they belong to the room) without its subject on them, and exports them.
++
++**Actor agents in server channels** (`actor_members_enabled` in Chat owner settings, on by default; `CHAT_ACTOR_MEMBERS=1` forces it on): a server manager can attach one of their own
++OpenVibe.Actor agents to a channel from its AI members settings. Anyone who can post in that channel can mention the
++agent; Chat starts the owner's personal agent through `actor.agent.run`, records a durable room task, and follows it
++through the existing workspace poller. The answer returns as that agent's room message. Actor applies the owner's
++budget, daily cap and approvals. The settings form says the answer is public to the channel. Deleting the owner's
++account removes its attached Actor agents; ordinary room AI members remain. Network must grant Chat `actor.agent.run`
++and Actor must allow Chat to read only the personal agent tasks it started.
**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer
is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may
@@@ -399,6 +407,11 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:co
memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
nobody is.
++Forum channels are available by default for a server manager who can connect a Community forum they moderate;
++the owner can disable them with `forum_channels_enabled`. Slash commands and custom server emoji are available by
++default and can be disabled with `CHAT_SLASH_COMMANDS_ENABLED=0` and `CHAT_SERVER_EMOJI_ENABLED=0`. Stage channels
++use Chat's voice rooms and are available by default; the owner can disable them with `stage_channels_enabled`.
++
**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who
may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the
file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it
ddiff --git a/migrations/0077_actor_channel_members.sql b/migrations/0077_actor_channel_members.sql
nnew file mode 100644
iindex 0000000..78f524d
---- /dev/null
++++ b/migrations/0077_actor_channel_members.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- A personal OpenVibe.Actor agent can join a server channel as an AI member. Its room task uses the existing
++-- durable Actor poller, and the member id keeps the finished answer under the agent that did the work.
++ALTER TABLE room_tasks ADD COLUMN IF NOT EXISTS actor_member_id bigint REFERENCES room_ai_members(id) ON DELETE SET NULL;
++CREATE INDEX IF NOT EXISTS room_tasks_actor_member ON room_tasks (actor_member_id) WHERE actor_member_id IS NOT NULL;
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 5606fdd..c79b38a 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -190,12 +190,13 @@ const ERASE_AFTER_ROOMS = [
['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
[null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
-- // An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
++ // An attached Actor agent belongs to its owner; an ordinary room AI member belongs to the room.
++ ['actor_ai_members', "DELETE FROM room_ai_members WHERE link_service = 'actor' AND created_by_subject = ANY(@s)"],
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
// A hook queues work as the person who made it: it goes with them.
['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],
// A workspace room's queue keeps the work the team relies on, without who asked or who took it.
-- [null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
++ [null, "UPDATE room_tasks SET requested_by = CASE WHEN requested_by = ANY(@ids) THEN 0 ELSE requested_by END, requested_subject = CASE WHEN requested_subject = ANY(@s) THEN NULL ELSE requested_subject END WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],
ddiff --git a/server/config.js b/server/config.js
iindex 26da9f5..9987d86 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -87,7 +87,7 @@ module.exports = {
visitorTurnsPerDay: Math.max(1, int(process.env.CHAT_INBOX_VISITOR_TURNS_PER_DAY, 40)) },
// OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):
// tasks for the person, with Chat's own token.
-- actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
++ actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950'), members: bool(process.env.CHAT_ACTOR_MEMBERS) },
// OpenVibe.Today: a person's own reminders, set by Vibe ("remind me…", plan L48), with Chat's own token.
today: { internalUrl: strip(process.env.CHAT_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100') },
@@@ -123,7 +123,7 @@ module.exports = {
maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),
},
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
-- slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, false) },
++ slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, true) },
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
live: {
@@@ -200,7 +200,7 @@ module.exports = {
// Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
// OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
emotes: {
-- serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, false),
++ serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, true),
maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex 3c8a083..056a361 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -57,6 +57,7 @@ class AiMemberError extends Error { constructor(status, code, message) { super(m
const fail = (status, code, message) => { throw new AiMemberError(status, code, message); };
const enabled = () => Boolean(config.ai.members && config.oauth.clientSecret);
++const actorMembersEnabled = () => config.actor.members || require('../settings').value('actor_members_enabled', null, true) === true;
async function list(room) {
return await db.all('SELECT * FROM room_ai_members WHERE room_id = ? ORDER BY id', [room.id]);
@@@ -99,11 +100,22 @@ async function engineOf(input, user) {
async function add(room, user, can, input = {}) {
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');
if (room.kind === 'system') fail(422, 'rooms.ai_kind', 'Announcement rooms have no AI members');
-- const v = validate(input);
++ let actorAgent = null;
++ if (input.actor_agent_id != null) {
++ if (!actorMembersEnabled() || !room.server_id) fail(403, 'rooms.actor_disabled', 'Actor agents in server channels are not enabled');
++ if (!user || !user.subject_id) fail(403, 'rooms.actor_owner', 'Sign in as the agent\'s owner to add it');
++ const found = await require('./vibe-agents').detail(user.subject_id, input.actor_agent_id);
++ if (!found.agent || found.agent.id !== input.actor_agent_id) fail(422, 'rooms.actor_agent', found.error || 'Choose one of your own OpenVibe Agents');
++ actorAgent = found.agent;
++ }
++ const actorName = actorAgent ? String(actorAgent.name || '').replace(/[^\p{L}\p{N} ._'-]+/gu, ' ').trim().slice(0, LIMITS.name).trim() : null;
++ const v = validate(actorAgent ? { name: actorName.length >= 2 ? actorName : 'Actor agent' } : input);
if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);
if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
-- const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
-- const e = (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };
++ const link = actorAgent ? { service: 'actor', resource: actorAgent.id }
++ : input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
++ const e = actorAgent ? { engine: 'openvibe', engine_subject: null, engine_label: null }
++ : (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };
const r = await db.run(
`INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject, engine, engine_subject, engine_label)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
@@@ -115,6 +127,7 @@ async function update(room, can, id, input = {}, user = null) {
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');
const m = await byId(room, id);
if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');
++ if (m.link_service === 'actor' && input.engine && input.engine !== 'openvibe') fail(422, 'rooms.actor_engine', 'This member runs on OpenVibe Actor');
const v = validate(input, { partial: true });
if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);
@@@ -385,6 +398,17 @@ async function afterPost(room, message, user, deps = {}) {
broadcast({ type: 'room_message', room: room.slug, message: saved || posted });
return saved || posted;
};
++ if (member.link_service === 'actor') {
++ if (!actorMembersEnabled() || !room.server_id || !member.created_by_subject) return null;
++ if (!personOk(room.id, user.id, Date.now())) return null;
++ if (await repliesToday(room) >= config.ai.memberRepliesPerDay) return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);
++ const task = await require('./workspace').addAgentMemberRun(room, user, member, message, deps).catch((err) => {
++ console.warn('[Chat Actor member] start:', err && err.message);
++ return null;
++ });
++ if (task) await countReply(room);
++ return task;
++ }
if (inFlight.has(room.id)) return null;
const own = member.engine === 'own' && member.engine_subject;
if (vibe) {
@@@ -522,7 +546,7 @@ function stop(roomId) {
}
/** What the room shows a member thinks with: "OpenVibe", or the own key's provider, model and whose key it is. */
--const modelOf = (m) => (m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');
++const modelOf = (m) => (m.link_service === 'actor' ? 'OpenVibe Actor' : m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');
const toWire = (m) => ({
id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),
@@@ -532,4 +556,4 @@ const toWire = (m) => ({
function _reset() { inFlight.clear(); personHour.clear(); stoppers.clear(); }
--module.exports = { PLAN_DAY, MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, stop, transcriptOf, vibePrompt, toWire, _reset };
++module.exports = { PLAN_DAY, MAX_PER_ROOM, LIMITS, AiMemberError, enabled, actorMembersEnabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, stop, transcriptOf, vibePrompt, toWire, _reset };
ddiff --git a/server/rooms/server-routes.js b/server/rooms/server-routes.js
iindex 534a63f..9ecfdf4 100644
---- a/server/rooms/server-routes.js
++++ b/server/rooms/server-routes.js
@@@ -169,7 +169,7 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
try {
const b = req.body || {};
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' && require('../settings').value('forum_channels_enabled', null, true) !== 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 });
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex 1d469a7..579306e 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -167,9 +167,9 @@ async function insertChannel(server, user, { name, kind, topic = null, position
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, 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('../settings').value('forum_channels_enabled', null, true) !== 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, true) !== 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`);
let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
ddiff --git a/server/rooms/vibe-agents.js b/server/rooms/vibe-agents.js
iindex ee52ab9..3967268 100644
---- a/server/rooms/vibe-agents.js
++++ b/server/rooms/vibe-agents.js
@@@ -20,6 +20,7 @@ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let tokens = null;
++let runTokens = null;
function tokenClient() {
if (tokens) return tokens;
const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@@ -31,6 +32,15 @@ function tokenClient() {
});
return tokens;
}
++function runTokenClient() {
++ if (runTokens) return runTokens;
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ runTokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.actor', scope: 'actor.agent.run',
++ });
++ return runTokens;
++}
/** What Vibe is told about an agent: no instructions back (it wrote them), and the page where the person manages it. */
const wire = (a) => ({
@@@ -41,11 +51,11 @@ const wire = (a) => ({
page: `${ACTOR_SITE}/me/agents/${encodeURIComponent(a.id)}`,
});
--async function call(method, path, subject, body, { fetchImpl = globalThis.fetch } = {}) {
++async function call(method, path, subject, body, { fetchImpl = globalThis.fetch, runScope = false } = {}) {
if (!config.oauth.clientSecret) return { error: 'OpenVibe Agents are not available on this server' };
if (!SUBJECT.test(String(subject || ''))) return { error: 'they need to be signed in with an OpenVibe account' };
let token;
-- try { token = await tokenClient().getToken(); } catch { return { error: 'OpenVibe Agents are not available right now' }; }
++ try { token = await (runScope ? runTokenClient() : tokenClient()).getToken(); } catch { return { error: 'OpenVibe Agents are not available right now' }; }
try {
const res = await fetchImpl(`${config.actor.internalUrl}${path}`, {
method, headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
@@@ -82,6 +92,22 @@ async function list(subject, deps = {}) {
return { agents: agents.slice(0, 30).map(wire), manage: `${ACTOR_SITE}/me/agents` };
}
++/** The exact personal agent, verified by Actor against this person's subject. */
++async function detail(subject, id, deps = {}) {
++ if (!/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return { error: 'Choose one of your own OpenVibe Agents' };
++ const out = await call('GET', `/api/v1/me/agents/${encodeURIComponent(id)}`, subject, null, deps);
++ return out.error ? out : { agent: out.json };
++}
++
++/** Start a member's turn under its owner's Actor budget and daily cap. */
++async function run(subject, id, input, deps = {}) {
++ if (!/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return { error: 'That agent is no longer available' };
++ const out = await call('POST', `/api/v1/me/agents/${encodeURIComponent(id)}/run`, subject,
++ { input: String(input || '').slice(0, 8000) }, { ...deps, runScope: true });
++ return out.error ? out : { task: out.json };
++}
++
function _setTokens(t) { tokens = t; }
++function _setRunTokens(t) { runTokens = t; }
--module.exports = { create, list, SCHEDULES, _setTokens };
++module.exports = { create, list, detail, run, SCHEDULES, _setTokens, _setRunTokens };
ddiff --git a/server/rooms/workspace.js b/server/rooms/workspace.js
iindex b1b118e..dcdf625 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -135,6 +135,32 @@ async function add(room, user, { title, details = '', kind = 'research', via = '
return task;
}
++/** A server member asked an attached Actor agent. Its owner opted in when attaching it to this channel. */
++async function addAgentMemberRun(room, user, member, message, deps = {}) {
++ if (!require('./ai-members').actorMembersEnabled() || !room.server_id || member.link_service !== 'actor' || !member.created_by_subject)
++ fail(403, 'work.actor_member_disabled', 'Actor agents are not enabled in this channel');
++ const title = oneLine(String(message.message || '').replace(new RegExp(`@${member.handle}\\b`, 'i'), ''), LIMITS.title) || `Ask ${member.name}`;
++ const history = await rooms.history(room, { limit: 16 });
++ const context = history.map((m) => `${oneLine(m.display_name || m.username || 'Member', 40)}: ${oneLine(m.message, 500)}`).join('\n').slice(-6000);
++ const input = `You are answering in the OpenVibe.Chat server channel #${room.name}. ${user.display_name || user.username || 'A member'} asked you. Your answer will be posted for everyone in that channel. Do not include private memory, credentials or account details in the public answer. Recent channel messages:\n${context}\n\nAnswer the latest message directly.`.slice(0, 8000);
++ const inserted = await db.run(`INSERT INTO room_tasks (room_id, title, details, kind, requested_by, requested_subject, via, source_message, runner, actor_member_id)
++ VALUES (?, ?, '', 'answer', ?, ?, 'api', ?, ?, ?)`,
++ [room.id, title, user.id, member.created_by_subject, message.id, `agent:${member.link_resource}`, member.id]);
++ const id = Number(inserted.lastInsertRowid);
++ const agent = require('./vibe-agents');
++ const started = await agent.run(member.created_by_subject, member.link_resource, input, deps);
++ if (!started.task || !ACTOR_TASK.test(String(started.task.id || ''))) {
++ await setStatus(room, id, { status: 'failed', error: oneLine(started.error || 'OpenVibe Actor did not start this agent', LIMITS.error), finished_at: nowText() });
++ await announce(room, await row(room, id), deps).catch(() => {});
++ return shape(await row(room, id), room);
++ }
++ await setStatus(room, id, { actor_task: started.task.id, status: 'running', started_at: nowText() });
++ const task = shape(await row(room, id), room);
++ await announceStep(room, await row(room, id), { status: 'running' });
++ changed(room, task);
++ return task;
++}
++
async function setStatus(room, id, patch) {
const sets = [];
const vals = [];
@@@ -353,7 +379,10 @@ async function showDesk(room, t, desk, deps = {}) {
/** The room hears how a task ended, from its Vibe (or its first AI member), once. */
async function announce(room, t, deps = {}) {
if (!t || t.posted_message || !DONE.has(t.status)) { changed(room, t && shape(t, room)); return null; }
-- const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
++ if (String(t.runner || '').startsWith('agent:') && !t.actor_member_id) { changed(room, shape(t, room)); return null; }
++ const members = t.actor_member_id
++ ? await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND id = ? LIMIT 1', [room.id, t.actor_member_id])
++ : await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
const member = members[0];
const who = t.requester_name || t.requester_username || 'someone';
const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in it
@@@ -395,4 +424,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
function _setTokens(t) { tokens = t; }
--module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
++module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, addAgentMemberRun, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
ddiff --git a/server/settings.js b/server/settings.js
iindex 4bc4f55..5a2794b 100644
---- a/server/settings.js
++++ b/server/settings.js
@@@ -39,17 +39,23 @@ const SCHEMA = Object.freeze({
additionalProperties: false,
properties: {
forum_channels_enabled: {
-- type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: false,
++ type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: true,
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,
++ type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: true,
description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
'x-required-for': 'Stage channels',
'x-owner-input': { group: 'voices', unlocks: 'Stage channel controls in Chat servers using existing voice rooms.', get_it_at: 'https://openvibe.chat', get_it_how: 'This is a Chat feature switch. Enable it to let server owners create stage channels.' },
},
++ actor_members_enabled: {
++ type: 'boolean', title: 'Actor agents in channels', 'x-group': 'Channels', default: true,
++ description: 'Allow server managers to attach their own Actor agents to channels. Members can ask them there; the agent owner pays from their Actor budget.',
++ 'x-required-for': 'Actor agents as channel members',
++ 'x-owner-input': { group: 'voices', unlocks: 'An owner controlled Actor agent can answer in a server channel.', get_it_at: 'https://openvibe.chat', get_it_how: 'Enable this Chat switch after the Actor read delegation and Network grant are deployed.' },
++ },
amazon_polly_access_key_id: {
type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,
description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 28ef76e..c9cf057 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -1235,6 +1235,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
const addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
++ const actorMembersOn = !!(aiMembers.actorMembersEnabled() && room.server_id);
++ const actorAgents = actorMembersOn && can.manage && actor.user.subject_id
++ ? (await require('../rooms/vibe-agents').list(actor.user.subject_id)).agents || [] : [];
let myKey = null;
if (can.manage && room.kind !== 'system' && aiMembers.enabled()) { try { myKey = await credentials.get(actor.user.subject_id); } catch { myKey = null; } }
const engineField = (a) => {
@@@ -1243,7 +1246,9 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
return `<label>Thinks with <select name="engine"><option value="openvibe"${cur === 'openvibe' ? ' selected' : ''}>OpenVibe's model</option><option value="own"${cur === 'own' ? ' selected' : ''}${cur === 'own' || myKey ? '' : ' disabled'}>${esc(ownLabel)}</option></select></label>
${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider, or a model on your own computer), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;
};
-- const aiForm = (a) => `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
++ const aiForm = (a) => a && a.link_service === 'actor'
++ ? `<form class="oc-form oc-ai-form" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}"><p>Runs as your OpenVibe Actor agent. Its owner can change its instructions and budget on <a href="https://openvibe.actor/me/agents/${esc(a.link_resource)}">Actor</a>.</p><label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save</button></form>`
++ : `<form class="oc-form oc-ai-form oc-grid-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
${a ? '' : '<h3>Add an AI member</h3>'}
<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
@@@ -1253,9 +1258,10 @@ ${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${
<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
const aiSection = can.manage && room.kind !== 'system' ? `<details class="oc-card oc-fold" id="oc-ai"${ais.length ? '' : ' open'}><summary><span>AI members${ais.length ? ` <span class="oc-muted">· ${ais.map((a) => esc(a.name)).join(', ')}</span>` : ''}</span>${IC.chevron}</summary>
${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
--<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
++<p class="oc-muted oc-small">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). Standard members use OpenVibe.AI or your own key and stay in chat. An attached Actor agent uses its owner's budget and approval rules, and posts its result here. At most ${aiMembers.MAX_PER_ROOM}.</p>
${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-pill oc-pill-danger">${IC.trash}<span>Remove ${esc(a.name)}</span></button></form></section>`).join('')}
${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ? `<form class="oc-form oc-ai-quick" method="post" action="/r/${esc(room.slug)}/ai-members"><input type="hidden" name="name" value="Vibe"><input type="hidden" name="persona" value="${esc(VIBE_IN_ROOMS)}"><p><button type="submit">Add Vibe</button> <span class="oc-muted">OpenVibe's assistant joins the room: anyone here can ask it with @vibe.</span></p></form>` : ''}
++${actorMembersOn && ais.length < aiMembers.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/ai-members"><h3>Add one of your Actor agents</h3><p class="oc-muted">Anyone who can post here can ask it. Runs use your Actor budget and daily cap; the answer is public to this channel. Its approval rules still apply.</p><label>Agent <select name="actor_agent_id" required><option value="">Choose an agent</option>${actorAgents.map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><button class="ovk-btn ovk-btn--primary" type="submit"${actorAgents.length ? '' : ' disabled'}>Add agent</button>${actorAgents.length ? '' : '<p><a href="https://openvibe.actor/me/agents">Create an agent on Actor</a> first.</p>'}</form>` : ''}
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
const listedRooms = await rooms.list(actor.user);
@@@ -1511,7 +1517,7 @@ ${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the addr
}
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
});
-- router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine }); }));
++ router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine, actor_agent_id: b.actor_agent_id }); }));
router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1', engine: b.engine }, actor.user); }));
router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 0c3585a..ba7b050 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -277,6 +277,10 @@ ${sheet}`,
href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
})),
}));
++ if (ais.length) roleGroups.push({ title: 'Agents', people: ais.map((a) => ({
++ username: `@${a.handle}`, display_name: a.name, roles: [require('../rooms/ai-members').modelOf(a)],
++ href: `/s/${server.slug}/${pick.slug}#oc-compose`,
++ })) });
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
// The sheet's last step: the invite link, with Copy and Share (a fresh server comes with one).
const inviteCode = String(req.query.invite || '').toLowerCase();
@@@ -547,7 +551,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('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>
++<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, true) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, true) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, true) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
${config.emotes.serverEmojiEnabled ? `<section id="oc-server-emojis" data-server-emoji-manager="${esc(server.slug)}"><h2>Emoji</h2><p class="oc-muted">${emojiRows.length} of 50 used. PNG, JPEG, GIF or WebP, up to 512 KB.</p><ul class="oc-members">${emojiRows.map((e) => `<li><img class="oc-server-emoji" src="${esc(e.media_url || '')}" alt=":${esc(e.code)}:" width="22" height="22"> <code>:${esc(e.code)}:</code> <button type="button" class="ovk-btn ovk-btn--quiet" data-emoji-remove="${Number(e.id)}">Remove</button></li>`).join('')}</ul><form data-emoji-upload><label>Name <input name="name" required pattern="[A-Za-z0-9_]{2,32}" maxlength="32"></label><div data-emoji-picker></div> <button class="ovk-btn ovk-btn--primary" type="submit">Add emoji</button></form><p role="status" data-emoji-status></p></section>` : ''}
<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>
@@@ -593,7 +597,7 @@ ${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 || {};
-- 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' && require('../settings').value('forum_channels_enabled', null, true) !== 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 });
ddiff --git a/test/actor-channel-members.test.js b/test/actor-channel-members.test.js
nnew file mode 100644
iindex 0000000..138f599
---- /dev/null
++++ b/test/actor-channel-members.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('Actor agents in Chat channels');
++const AGENT = 'agt_01M4W5JBFEG3QG9ER8W1A2K0RX';
++const TASK = 'tsk_01M4XENCR31NXDJQYDCCW2J9R9';
++let h, owner, member, channel, actorCalls, actorResult;
++let realFetch;
++const serverApi = (method, path, user, body) => h.http(method, `/api/chat/servers${path}`, { token: user && user.token, body });
++const roomApi = (method, path, user, body) => h.http(method, `/api/chat/rooms${path}`, { token: user && user.token, body });
++
++t('setup and owner switch', async () => {
++ h = await boot({ env: { CHAT_ACTOR_MEMBERS: '1', CHAT_WEB_URL: 'https://openvibe.chat' } });
++ owner = h.addUser('owner', { subject: ids.newId('user') });
++ member = h.addUser('member', { subject: ids.newId('user') });
++ for (const user of [owner, member]) await h.ctx.upsertUser(h.live.users.get(user.id));
++ actorCalls = [];
++ actorResult = { id: TASK, state: 'queued' };
++ const ownerSubject = h.live.users.get(owner.id).subject_id;
++ require('../server/rooms/vibe-agents')._setTokens({ getToken: async () => 'manage' });
++ require('../server/rooms/vibe-agents')._setRunTokens({ getToken: async () => 'run' });
++ require('../server/rooms/workspace')._setTokens({ getToken: async () => 'read' });
++ realFetch = globalThis.fetch;
++ globalThis.fetch = async (url, opts = {}) => {
++ if (!String(url).startsWith('http://127.0.0.1:4950/')) return realFetch(url, opts);
++ const path = new URL(url).pathname;
++ actorCalls.push({ path, method: opts.method || 'GET', subject: opts.headers && opts.headers['X-OV-Subject'], body: opts.body ? JSON.parse(opts.body) : null });
++ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ if (path === `/api/v1/me/agents/${AGENT}` && opts.method !== 'POST') return opts.headers['X-OV-Subject'] === ownerSubject
++ ? json({ id: AGENT, name: 'Researcher', status: 'active' }) : json({ code: 'actor.agent.not_found' }, 404);
++ if (path === '/api/v1/me/agents') return json({ agents: [{ id: AGENT, name: 'Researcher', status: 'active', schedule: 'off' }] });
++ if (path === `/api/v1/me/agents/${AGENT}/run`) return json({ id: TASK, state: 'queued' }, 201);
++ if (path === `/api/v1/tasks/${TASK}`) return json(actorResult);
++ return json({ code: 'not_found' }, 404);
++ };
++ const made = await serverApi('POST', '/', owner, { name: 'Build Club' });
++ assert.strictEqual(made.status, 201, made.text);
++ channel = (await serverApi('GET', '/build-club', owner)).body.channels[0].room;
++ assert.strictEqual((await serverApi('POST', '/build-club/join', member)).status, 200);
++ const config = require('../server/config');
++ const settings = require('../server/settings');
++ const previous = settings.current();
++ config.actor.members = false;
++ settings.activate({ value: (key, _env, fallback) => key === 'actor_members_enabled' ? false : fallback });
++ try {
++ assert.strictEqual((await roomApi('POST', `/${channel}/ai-members`, owner, { actor_agent_id: AGENT })).status, 403);
++ } finally { config.actor.members = true; settings.activate(previous); }
++});
++
++t('owner attaches their agent and members can see and ask it', async () => {
++ // The server channel page is exercised through HTTP below.
++ assert.ok(require('../server/web/server-pages').mountServerPages);
++ const attached = await roomApi('POST', `/${channel}/ai-members`, owner, { actor_agent_id: AGENT });
++ assert.strictEqual(attached.status, 201, attached.text);
++ assert.deepStrictEqual([attached.body.name, attached.body.model, attached.body.link.service], ['Researcher', 'OpenVibe Actor', 'actor']);
++ const page = await realFetch(`${h.base}/s/build-club/general`, { headers: { cookie: `ov_token=${member.token}` } });
++ const html = await page.text();
++ assert.strictEqual(page.status, 200);
++ assert.match(html, /Agents <span class="oc-muted">1<\/span>/);
++ assert.match(html, /@researcher/);
++ const settingsPage = await realFetch(`${h.base}/r/${channel}/settings`, { headers: { cookie: `ov_token=${owner.token}` } });
++ assert.strictEqual(settingsPage.status, 200);
++ assert.match(await settingsPage.text(), /Add one of your Actor agents/);
++ const posted = await roomApi('POST', `/${channel}/messages`, member, { message: '@researcher summarize our plan' });
++ assert.strictEqual(posted.status, 201, posted.text);
++ const db = require('../server/db/database');
++ let task = null;
++ for (let i = 0; i < 50; i++) {
++ task = await db.get('SELECT * FROM room_tasks WHERE source_message = ?', [posted.body.message.id]);
++ if (task && task.actor_task) break;
++ await new Promise((resolve) => setTimeout(resolve, 40));
++ }
++ assert.ok(task && task.actor_task === TASK, 'the mention started a durable Actor task');
++ const run = actorCalls.find((call) => call.path.endsWith(`/${AGENT}/run`));
++ assert.strictEqual(run.subject, h.live.users.get(owner.id).subject_id, 'the attached agent runs on its owner account');
++ assert.match(run.body.input, /summarize our plan/);
++ actorResult = { id: TASK, state: 'succeeded', result: { answer: 'The plan is to build and test one feature.' }, cost: { usd: 0.003 } };
++ await require('../server/rooms/workspace').poll();
++ const messages = (await roomApi('GET', `/${channel}/messages`, member)).body.messages;
++ assert.strictEqual(messages[0].display_name, 'Researcher');
++ assert.match(messages[0].message, /The plan is to build and test one feature/);
++ await require('../server/rooms/workspace').poll();
++ assert.strictEqual((await roomApi('GET', `/${channel}/messages`, member)).body.messages.length, messages.length, 'one result after repeated polling');
++});
++
++t.run(async () => { if (realFetch) globalThis.fetch = realFetch; if (h) await h.close(); });
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
iindex 9487be4..b0f3c53 100644
---- a/test/owner-settings.test.js
++++ b/test/owner-settings.test.js
@@@ -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', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);
++ assert.deepStrictEqual(Object.keys(props).sort(), ['actor_members_enabled', '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) {
ddiff --git a/test/servers.test.js b/test/servers.test.js
iindex 15f80e5..c331087 100644
---- a/test/servers.test.js
++++ b/test/servers.test.js
@@@ -109,8 +109,13 @@ 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 () => {
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');
++ const settings = require('../server/settings');
++ const previous = settings.current();
++ settings.activate({ value: (key, _env, fallback) => ['forum_channels_enabled', 'stage_channels_enabled'].includes(key) ? false : fallback });
++ try {
++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'the owner can turn forum channels off');
++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'the owner can turn stage channels off');
++ } finally { settings.activate(previous); }
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']);
r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });