feat: add room-level assignment write path

This commit is contained in:
Eyejoker
2026-03-31 21:21:57 +09:00
parent 200cbef0f9
commit b9538e54bc
6 changed files with 769 additions and 114 deletions

View File

@@ -491,6 +491,62 @@ if (allowGenericScheduling) {
); );
} }
server.tool(
'assign_room',
`Assign or update a room using room-level settings. Main group only.
This is the room-level replacement for legacy register_group. If folder is omitted, the host generates one automatically.`,
{
jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'),
name: z.string().describe('Display name for the room'),
room_mode: z.enum(['single', 'tribunal']).default('single').describe('single=owner only, tribunal=owner/reviewer roles enabled'),
owner_agent_type: z.enum(['claude-code', 'codex']).optional().describe('Preferred owner agent type for the room'),
folder: z.string().optional().describe('Optional folder override. If omitted, the host generates one automatically.'),
trigger: z.string().optional().describe('Optional canonical trigger label to store for the room'),
requires_trigger: z.boolean().optional().describe('Whether direct trigger mention is required'),
is_main: z.boolean().optional().describe('Whether this room is the privileged main control room'),
work_dir: z.string().optional().describe('Optional working directory override'),
},
async (args: {
jid: string;
name: string;
room_mode?: 'single' | 'tribunal';
owner_agent_type?: 'claude-code' | 'codex';
folder?: string;
trigger?: string;
requires_trigger?: boolean;
is_main?: boolean;
work_dir?: string;
}) => {
if (!isMain) {
return {
content: [{ type: 'text' as const, text: 'Only the main group can assign rooms.' }],
isError: true,
};
}
const data = {
type: 'assign_room',
jid: args.jid,
name: args.name,
room_mode: args.room_mode || 'single',
owner_agent_type: args.owner_agent_type,
folder: args.folder,
trigger: args.trigger,
requiresTrigger: args.requires_trigger,
isMain: args.is_main,
workDir: args.work_dir,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [{ type: 'text' as const, text: `Room "${args.name}" assignment requested.` }],
};
},
);
server.tool( server.tool(
'register_group', 'register_group',
`Register a new chat/group so the agent can respond to messages there. Main group only. `Register a new chat/group so the agent can respond to messages there. Main group only.
@@ -522,7 +578,7 @@ Use available_groups.json to find the JID for a group. The folder name must be c
writeIpcFile(TASKS_DIR, data); writeIpcFile(TASKS_DIR, data);
return { return {
content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }], content: [{ type: 'text' as const, text: `Group "${args.name}" registration requested.` }],
}; };
}, },
); );

View File

@@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { import {
_initTestDatabase, _initTestDatabase,
_initTestDatabaseFromFile, _initTestDatabaseFromFile,
assignRoom,
claimServiceHandoff, claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor, completeServiceHandoffAndAdvanceTargetCursor,
createPairedTask, createPairedTask,
@@ -774,6 +775,57 @@ describe('registered group isMain', () => {
}); });
}); });
describe('room assignment writes', () => {
it('assigns a single room with an auto-generated folder', () => {
const group = assignRoom('tg:-1001', {
name: 'Telegram Dev Team',
roomMode: 'single',
ownerAgentType: 'claude-code',
});
expect(group).toBeDefined();
expect(group!.folder).toMatch(/^grp_telegram_/);
expect(group!.agentType).toBe('claude-code');
expect(getStoredRoomSettings('tg:-1001')).toMatchObject({
chatJid: 'tg:-1001',
roomMode: 'single',
modeSource: 'explicit',
name: 'Telegram Dev Team',
ownerAgentType: 'claude-code',
});
expect(getRegisteredAgentTypesForJid('tg:-1001')).toEqual(['claude-code']);
});
it('materializes tribunal capability rows while serving metadata from room_settings', () => {
assignRoom('dc:assigned-room', {
name: 'Assigned Room',
roomMode: 'tribunal',
ownerAgentType: 'codex',
folder: 'assigned-room',
});
const allGroups = getAllRegisteredGroups();
const claudeGroups = getAllRegisteredGroups('claude-code');
const codexGroups = getAllRegisteredGroups('codex');
expect(allGroups['dc:assigned-room']).toMatchObject({
name: 'Assigned Room',
folder: 'assigned-room',
agentType: 'codex',
});
expect(claudeGroups['dc:assigned-room']).toMatchObject({
name: 'Assigned Room',
folder: 'assigned-room',
agentType: 'claude-code',
});
expect(codexGroups['dc:assigned-room']).toMatchObject({
name: 'Assigned Room',
folder: 'assigned-room',
agentType: 'codex',
});
});
});
describe('paired room registration', () => { describe('paired room registration', () => {
it('detects when both Claude and Codex are registered on the same jid', () => { it('detects when both Claude and Codex are registered on the same jid', () => {
setRegisteredGroup('dc:123', { setRegisteredGroup('dc:123', {

553
src/db.ts
View File

@@ -59,6 +59,19 @@ export interface StoredRoomSettings {
workDir?: string; workDir?: string;
} }
export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
ownerAgentType?: AgentType;
folder?: string;
trigger?: string;
requiresTrigger?: boolean;
isMain?: boolean;
workDir?: string;
addedAt?: string;
ownerAgentConfig?: RegisteredGroup['agentConfig'];
}
interface RoomRegistrationSnapshot { interface RoomRegistrationSnapshot {
name: string; name: string;
folder: string; folder: string;
@@ -69,6 +82,19 @@ interface RoomRegistrationSnapshot {
workDir: string | null; workDir: string | null;
} }
interface RegisteredGroupDatabaseRow {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}
export interface WorkItem { export interface WorkItem {
id: number; id: number;
group_folder: string; group_folder: string;
@@ -1899,53 +1925,66 @@ export function getLastRespondingAgentType(
// --- Registered group accessors --- // --- Registered group accessors ---
function buildRegisteredGroupFromStoredSettings(
database: Database,
stored: StoredRoomSettings,
requestedAgentType?: AgentType,
): (RegisteredGroup & { jid: string }) | undefined {
const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid);
const resolvedAgentType = requestedAgentType
? capabilityTypes.includes(requestedAgentType)
? requestedAgentType
: undefined
: stored.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined);
if (!resolvedAgentType) return undefined;
if (!stored.folder || !isValidGroupFolder(stored.folder)) {
logger.warn(
{ jid: stored.chatJid, folder: stored.folder ?? null },
'Skipping stored room with invalid folder',
);
return undefined;
}
const capabilityMetadata = getRegisteredGroupCapabilityMetadata(
database,
stored.chatJid,
resolvedAgentType,
);
return {
jid: stored.chatJid,
name: stored.name || stored.chatJid,
folder: stored.folder,
trigger: stored.trigger || `@${ASSISTANT_NAME}`,
added_at:
capabilityMetadata?.added_at || new Date(0).toISOString(),
agentConfig: capabilityMetadata?.agentConfig,
requiresTrigger: stored.requiresTrigger,
isMain: stored.isMain ? true : undefined,
agentType: resolvedAgentType,
workDir: stored.workDir,
};
}
export function getRegisteredGroup( export function getRegisteredGroup(
jid: string, jid: string,
agentType?: string, agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
const row = ( const requestedAgentType = normalizeStoredAgentType(agentType);
agentType const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
? db if (stored) {
.prepare( const group = buildRegisteredGroupFromStoredSettings(
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?', db,
) stored,
.get(jid, agentType) requestedAgentType,
: db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid)
) as
| {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}
| undefined;
if (!row) return undefined;
if (!isValidGroupFolder(row.folder)) {
logger.warn(
{ jid: row.jid, folder: row.folder },
'Skipping registered group with invalid folder',
); );
return undefined; if (group) return group;
} }
return { return getLegacyRegisteredGroup(db, jid, agentType);
jid: row.jid,
name: row.name,
folder: row.folder,
trigger: row.trigger_pattern,
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined,
agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined,
workDir: row.work_dir || undefined,
};
} }
export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
@@ -1978,68 +2017,115 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
tx(); tx();
} }
export function assignRoom(
chatJid: string,
input: AssignRoomInput,
): (RegisteredGroup & { jid: string }) | undefined {
const existing = getStoredRoomSettingsRowFromDatabase(db, chatJid);
const roomMode = input.roomMode || existing?.roomMode || 'single';
const ownerAgentType =
input.ownerAgentType || existing?.ownerAgentType || OWNER_AGENT_TYPE;
const folder = resolveAssignedRoomFolder(db, chatJid, input.name, input.folder);
const snapshot: RoomRegistrationSnapshot = {
name: input.name,
folder,
triggerPattern:
input.trigger || existing?.trigger || `@${ASSISTANT_NAME}`,
requiresTrigger: input.requiresTrigger ?? existing?.requiresTrigger ?? true,
isMain: input.isMain ?? existing?.isMain ?? false,
ownerAgentType,
workDir: input.workDir ?? existing?.workDir ?? null,
};
const now = new Date().toISOString();
db.transaction(() => {
if (existing) {
db.prepare(
`UPDATE room_settings
SET room_mode = ?,
mode_source = 'explicit',
name = ?,
folder = ?,
trigger_pattern = ?,
requires_trigger = ?,
is_main = ?,
owner_agent_type = ?,
work_dir = ?,
updated_at = ?
WHERE chat_jid = ?`,
).run(
roomMode,
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType,
snapshot.workDir,
now,
chatJid,
);
} else {
insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot);
}
materializeRegisteredGroupsForRoom(
db,
chatJid,
snapshot,
roomMode,
ownerAgentType,
input.ownerAgentConfig,
input.addedAt ?? now,
);
})();
return getRegisteredGroup(chatJid);
}
export function updateRegisteredGroupName(jid: string, name: string): void { export function updateRegisteredGroupName(jid: string, name: string): void {
db.prepare(
`UPDATE room_settings
SET name = ?, updated_at = ?
WHERE chat_jid = ?`,
).run(name, new Date().toISOString(), jid);
db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run( db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run(
name, name,
jid, jid,
); );
syncStoredRoomRegistrationSnapshotForJid(jid); if (!getStoredRoomSettingsRowFromDatabase(db, jid)) {
syncStoredRoomRegistrationSnapshotForJid(jid);
}
} }
export function getAllRegisteredGroups( export function getAllRegisteredGroups(
agentTypeFilter?: string, agentTypeFilter?: string,
): Record<string, RegisteredGroup> { ): Record<string, RegisteredGroup> {
const rows = (
agentTypeFilter
? db
.prepare('SELECT * FROM registered_groups WHERE agent_type = ?')
.all(agentTypeFilter)
: db.prepare('SELECT * FROM registered_groups').all()
) as Array<{
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}>;
const result: Record<string, RegisteredGroup> = {}; const result: Record<string, RegisteredGroup> = {};
for (const row of rows) { const requestedAgentType = normalizeStoredAgentType(agentTypeFilter);
if (!isValidGroupFolder(row.folder)) { const storedRows = getStoredRoomRowsFromDatabase(db);
logger.warn(
{ jid: row.jid, folder: row.folder }, for (const stored of storedRows) {
'Skipping registered group with invalid folder', const group = buildRegisteredGroupFromStoredSettings(
); db,
continue; stored,
} requestedAgentType,
const group: RegisteredGroup = { );
name: row.name, if (group) {
folder: row.folder, const { jid, ...rest } = group;
trigger: row.trigger_pattern, result[jid] = rest;
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined,
agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined,
workDir: row.work_dir || undefined,
};
// In unified mode (no agentTypeFilter), when the same JID has both
// claude-code and codex rows, prefer the codex entry (owner).
const existing = result[row.jid];
if (existing && !agentTypeFilter) {
if (group.agentType === 'codex') {
result[row.jid] = group;
}
// else keep existing (already codex or first-seen)
} else {
result[row.jid] = group;
} }
} }
for (const legacyRow of getLegacyRegisteredGroupRows(db, agentTypeFilter)) {
if (result[legacyRow.jid]) continue;
const group = parseRegisteredGroupRow(legacyRow);
if (group) {
const { jid, ...rest } = group;
result[jid] = rest;
}
}
return result; return result;
} }
@@ -2107,6 +2193,293 @@ function normalizeStoredAgentType(
: undefined; : undefined;
} }
function parseRegisteredGroupRow(
row: RegisteredGroupDatabaseRow | undefined,
): (RegisteredGroup & { jid: string }) | undefined {
if (!row) return undefined;
if (!isValidGroupFolder(row.folder)) {
logger.warn(
{ jid: row.jid, folder: row.folder },
'Skipping registered group with invalid folder',
);
return undefined;
}
return {
jid: row.jid,
name: row.name,
folder: row.folder,
trigger: row.trigger_pattern,
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined,
agentType: normalizeStoredAgentType(row.agent_type),
workDir: row.work_dir || undefined,
};
}
function getLegacyRegisteredGroupRows(
database: Database,
agentTypeFilter?: string,
): RegisteredGroupDatabaseRow[] {
return (
agentTypeFilter
? database
.prepare('SELECT * FROM registered_groups WHERE agent_type = ?')
.all(agentTypeFilter)
: database.prepare('SELECT * FROM registered_groups').all()
) as RegisteredGroupDatabaseRow[];
}
function getLegacyRegisteredGroup(
database: Database,
jid: string,
agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined {
const row = (
agentType
? database
.prepare(
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
)
.get(jid, agentType)
: database.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid)
) as RegisteredGroupDatabaseRow | undefined;
return parseRegisteredGroupRow(row);
}
function getRegisteredGroupCapabilityMetadata(
database: Database,
jid: string,
preferredAgentType?: AgentType,
): Pick<RegisteredGroup, 'added_at' | 'agentConfig'> | undefined {
const row = (
preferredAgentType
? database
.prepare(
`SELECT added_at, agent_config
FROM registered_groups
WHERE jid = ? AND agent_type = ?
LIMIT 1`,
)
.get(jid, preferredAgentType)
: database
.prepare(
`SELECT added_at, agent_config
FROM registered_groups
WHERE jid = ?
ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at
LIMIT 1`,
)
.get(jid, OWNER_AGENT_TYPE)
) as { added_at: string; agent_config: string | null } | undefined;
if (!row) return undefined;
return {
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
};
}
function getStoredRoomRowsFromDatabase(database: Database): StoredRoomSettings[] {
return database
.prepare(
`SELECT chat_jid
FROM room_settings
ORDER BY chat_jid`,
)
.all()
.map((row) =>
getStoredRoomSettingsRowFromDatabase(database, (row as { chat_jid: string }).chat_jid),
)
.filter((row): row is StoredRoomSettings => Boolean(row));
}
function detectChannelPrefixForFolder(chatJid: string): string {
if (chatJid.startsWith('dc:')) return 'discord';
if (chatJid.startsWith('tg:')) return 'telegram';
return 'whatsapp';
}
function slugifyGroupFolderSegment(value: string): string {
const slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
return slug || 'room';
}
function collectReservedFolders(
database: Database,
exceptChatJid?: string,
): Set<string> {
const folders = new Set<string>();
const rows = database
.prepare(
`SELECT folder
FROM room_settings
WHERE folder IS NOT NULL
AND (? IS NULL OR chat_jid != ?)
UNION
SELECT folder
FROM registered_groups
WHERE ? IS NULL OR jid != ?`,
)
.all(exceptChatJid ?? null, exceptChatJid ?? null, exceptChatJid ?? null, exceptChatJid ?? null) as Array<{
folder: string | null;
}>;
for (const row of rows) {
if (row.folder) folders.add(row.folder);
}
return folders;
}
function buildGeneratedRoomFolder(
database: Database,
chatJid: string,
name: string,
): string {
const prefix = detectChannelPrefixForFolder(chatJid);
const slug = slugifyGroupFolderSegment(name);
const fallback = slugifyGroupFolderSegment(chatJid.replace(/[:@.]/g, '-'));
const baseCore = slug || fallback;
const maxBaseLength = 64 - (`grp_${prefix}_`.length + 4);
const truncatedCore = baseCore.slice(0, Math.max(8, maxBaseLength));
const candidateBase = `grp_${prefix}_${truncatedCore}`;
const reserved = collectReservedFolders(database, chatJid);
if (!reserved.has(candidateBase) && isValidGroupFolder(candidateBase)) {
return candidateBase;
}
for (let suffix = 2; suffix < 1000; suffix += 1) {
const candidate = `${candidateBase.slice(0, Math.max(1, 64 - `${suffix}`.length - 1))}-${suffix}`;
if (!reserved.has(candidate) && isValidGroupFolder(candidate)) {
return candidate;
}
}
throw new Error(`Unable to generate unique group folder for ${chatJid}`);
}
function resolveAssignedRoomFolder(
database: Database,
chatJid: string,
name: string,
explicitFolder?: string,
): string {
const reserved = collectReservedFolders(database, chatJid);
if (explicitFolder) {
if (!isValidGroupFolder(explicitFolder)) {
throw new Error(`Invalid group folder "${explicitFolder}" for JID ${chatJid}`);
}
if (reserved.has(explicitFolder)) {
throw new Error(`Group folder "${explicitFolder}" is already assigned`);
}
return explicitFolder;
}
const existingFolder = getStoredRoomSettingsRowFromDatabase(database, chatJid)?.folder;
if (existingFolder) return existingFolder;
return buildGeneratedRoomFolder(database, chatJid, name);
}
function getDesiredRegisteredAgentTypes(
roomMode: RoomMode,
ownerAgentType: AgentType,
): AgentType[] {
const types = new Set<AgentType>([ownerAgentType]);
if (roomMode === 'tribunal') {
types.add(REVIEWER_AGENT_TYPE);
}
return [...types];
}
function materializeRegisteredGroupsForRoom(
database: Database,
chatJid: string,
snapshot: RoomRegistrationSnapshot,
roomMode: RoomMode,
ownerAgentType: AgentType,
ownerAgentConfig?: RegisteredGroup['agentConfig'],
addedAt?: string,
): void {
const now = new Date().toISOString();
const desiredTypes = getDesiredRegisteredAgentTypes(roomMode, ownerAgentType);
const existingRows = database
.prepare(
`SELECT agent_type, added_at, agent_config
FROM registered_groups
WHERE jid = ?`,
)
.all(chatJid) as Array<{
agent_type: string | null;
added_at: string;
agent_config: string | null;
}>;
const existingByType = new Map<AgentType, { added_at: string; agent_config: string | null }>();
for (const row of existingRows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (agentType) {
existingByType.set(agentType, row);
}
}
for (const agentType of desiredTypes) {
const existing = existingByType.get(agentType);
const agentConfig =
agentType === ownerAgentType
? ownerAgentConfig ??
(existing?.agent_config ? JSON.parse(existing.agent_config) : undefined)
: existing?.agent_config
? JSON.parse(existing.agent_config)
: undefined;
const rowAddedAt = existing?.added_at ?? addedAt ?? now;
database
.prepare(
`INSERT OR REPLACE INTO registered_groups (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
chatJid,
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
rowAddedAt,
agentConfig ? JSON.stringify(agentConfig) : null,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
agentType,
snapshot.workDir,
);
}
const placeholders = desiredTypes.map(() => '?').join(',');
database
.prepare(
`DELETE FROM registered_groups
WHERE jid = ?
AND agent_type NOT IN (${placeholders})`,
)
.run(chatJid, ...desiredTypes);
}
function getStoredRoomSettingsRowFromDatabase( function getStoredRoomSettingsRowFromDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,

View File

@@ -24,15 +24,15 @@ import {
import { writeGroupsSnapshot } from './agent-runner.js'; import { writeGroupsSnapshot } from './agent-runner.js';
import { listAvailableGroups } from './available-groups.js'; import { listAvailableGroups } from './available-groups.js';
import { import {
type AssignRoomInput,
assignRoom,
getAllRegisteredGroups, getAllRegisteredGroups,
getAllSessions, getAllSessions,
getAllTasks, getAllTasks,
getLatestMessageSeqAtOrBefore, getLatestMessageSeqAtOrBefore,
hasRecentRestartAnnouncement, hasRecentRestartAnnouncement,
getRegisteredGroup,
getRouterState, getRouterState,
initDatabase, initDatabase,
setRegisteredGroup,
setRouterState, setRouterState,
deleteAllSessionsForGroup, deleteAllSessionsForGroup,
deleteSession, deleteSession,
@@ -217,29 +217,68 @@ function clearSession(
} }
function registerGroup(jid: string, group: RegisteredGroup): void { function registerGroup(jid: string, group: RegisteredGroup): void {
const assignedGroup = assignRoom(jid, {
name: group.name,
ownerAgentType: group.agentType,
folder: group.folder,
trigger: group.trigger,
requiresTrigger: group.requiresTrigger,
isMain: group.isMain,
workDir: group.workDir,
addedAt: group.added_at,
ownerAgentConfig: group.agentConfig,
});
if (!assignedGroup) {
logger.warn({ jid }, 'Failed to assign room during legacy registration');
return;
}
let groupDir: string; let groupDir: string;
try { try {
groupDir = resolveGroupFolderPath(group.folder); groupDir = resolveGroupFolderPath(assignedGroup.folder);
} catch (err) { } catch (err) {
logger.warn( logger.warn(
{ jid, folder: group.folder, err }, { jid, folder: assignedGroup.folder, err },
'Rejecting group registration with invalid folder', 'Rejecting group registration with invalid folder',
); );
return; return;
} }
registeredGroups[jid] = group; const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
setRegisteredGroup(jid, group); registeredGroups[jid] = storedGroup;
// Create group folder // Create group folder
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
logger.info( logger.info(
{ jid, name: group.name, folder: group.folder }, { jid, name: assignedGroup.name, folder: assignedGroup.folder },
'Group registered', 'Group registered',
); );
} }
function assignRoomForIpc(jid: string, input: AssignRoomInput): void {
const assignedGroup = assignRoom(jid, input);
if (!assignedGroup) {
logger.warn({ jid }, 'Failed to assign room from IPC');
return;
}
let groupDir: string;
try {
groupDir = resolveGroupFolderPath(assignedGroup.folder);
} catch (err) {
logger.warn(
{ jid, folder: assignedGroup.folder, err },
'Rejecting room assignment with invalid folder',
);
return;
}
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
registeredGroups[jid] = storedGroup;
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
}
/** /**
* Get available groups list for the agent. * Get available groups list for the agent.
* Returns groups ordered by most recent activity. * Returns groups ordered by most recent activity.
@@ -483,6 +522,7 @@ async function main(): Promise<void> {
nudgeScheduler: nudgeSchedulerLoop, nudgeScheduler: nudgeSchedulerLoop,
registeredGroups: () => registeredGroups, registeredGroups: () => registeredGroups,
registerGroup, registerGroup,
assignRoom: assignRoomForIpc,
syncGroups: async (force: boolean) => { syncGroups: async (force: boolean) => {
await Promise.all( await Promise.all(
channels channels

View File

@@ -1,10 +1,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ASSISTANT_NAME } from './config.js';
import { import {
_initTestDatabase, _initTestDatabase,
assignRoom,
createTask, createTask,
getAllTasks, getAllTasks,
getRegisteredAgentTypesForJid,
getRegisteredGroup, getRegisteredGroup,
getStoredRoomSettings,
getTaskById, getTaskById,
setRegisteredGroup, setRegisteredGroup,
} from './db.js'; } from './db.js';
@@ -61,6 +65,12 @@ beforeEach(() => {
setRegisteredGroup(jid, group); setRegisteredGroup(jid, group);
// Mock the fs.mkdirSync that registerGroup does // Mock the fs.mkdirSync that registerGroup does
}, },
assignRoom: (jid, room) => {
const assigned = assignRoom(jid, room);
if (!assigned) return;
const { jid: _ignoredJid, ...group } = assigned;
groups[jid] = group;
},
syncGroups: async () => {}, syncGroups: async () => {},
getAvailableGroups: () => [], getAvailableGroups: () => [],
writeGroupsSnapshot: () => {}, writeGroupsSnapshot: () => {},
@@ -794,19 +804,99 @@ describe('register_group success', () => {
expect(group!.trigger).toBe('@Andy'); expect(group!.trigger).toBe('@Andy');
}); });
it('register_group rejects request with missing fields', async () => { it('register_group auto-fills missing folder and trigger for single rooms', async () => {
await processTaskIpc( await processTaskIpc(
{ {
type: 'register_group', type: 'register_group',
jid: 'partial@g.us', jid: 'partial@g.us',
name: 'Partial', name: 'Partial',
// missing folder and trigger
}, },
'whatsapp_main', 'whatsapp_main',
true, true,
deps, deps,
); );
expect(getRegisteredGroup('partial@g.us')).toBeUndefined(); const group = getRegisteredGroup('partial@g.us');
expect(group).toBeDefined();
expect(group!.folder).toMatch(/^grp_whatsapp_/);
expect(group!.trigger).toBe(`@${ASSISTANT_NAME}`);
expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('single');
});
it('register_group preserves an existing tribunal room mode', async () => {
assignRoom('legacy-tribunal@g.us', {
name: 'Legacy Tribunal',
roomMode: 'tribunal',
ownerAgentType: 'codex',
folder: 'legacy-tribunal',
});
await processTaskIpc(
{
type: 'register_group',
jid: 'legacy-tribunal@g.us',
name: 'Legacy Tribunal Renamed',
folder: 'legacy-tribunal',
},
'whatsapp_main',
true,
deps,
);
expect(getStoredRoomSettings('legacy-tribunal@g.us')).toMatchObject({
chatJid: 'legacy-tribunal@g.us',
roomMode: 'tribunal',
modeSource: 'explicit',
name: 'Legacy Tribunal Renamed',
});
expect(getRegisteredAgentTypesForJid('legacy-tribunal@g.us').sort()).toEqual([
'claude-code',
'codex',
]);
});
});
describe('assign_room success', () => {
it('main group can assign a tribunal room and materialize capability rows', async () => {
await processTaskIpc(
{
type: 'assign_room',
jid: 'tribunal@g.us',
name: 'Tribunal Room',
room_mode: 'tribunal',
owner_agent_type: 'codex',
},
'whatsapp_main',
true,
deps,
);
const stored = getStoredRoomSettings('tribunal@g.us');
expect(stored).toMatchObject({
chatJid: 'tribunal@g.us',
roomMode: 'tribunal',
modeSource: 'explicit',
name: 'Tribunal Room',
ownerAgentType: 'codex',
});
expect(stored?.folder).toMatch(/^grp_whatsapp_/);
expect(getRegisteredAgentTypesForJid('tribunal@g.us').sort()).toEqual([
'claude-code',
'codex',
]);
});
it('assign_room rejects request with missing fields', async () => {
await processTaskIpc(
{
type: 'assign_room',
jid: 'broken@g.us',
},
'whatsapp_main',
true,
deps,
);
expect(getRegisteredGroup('broken@g.us')).toBeUndefined();
}); });
}); });

View File

@@ -7,6 +7,7 @@ import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
import { readJsonFile } from './utils.js'; import { readJsonFile } from './utils.js';
import { AvailableGroup } from './agent-runner.js'; import { AvailableGroup } from './agent-runner.js';
import { import {
type AssignRoomInput,
createTask, createTask,
deleteTask, deleteTask,
findDuplicateCiWatcher, findDuplicateCiWatcher,
@@ -19,13 +20,14 @@ import {
DEFAULT_WATCH_CI_MAX_DURATION_MS, DEFAULT_WATCH_CI_MAX_DURATION_MS,
isWatchCiTask, isWatchCiTask,
} from './task-watch-status.js'; } from './task-watch-status.js';
import { RegisteredGroup } from './types.js'; import { AgentType, RegisteredGroup, RoomMode } from './types.js';
export interface IpcDeps { export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise<void>; sendMessage: (jid: string, text: string) => Promise<void>;
nudgeScheduler?: () => void; nudgeScheduler?: () => void;
registeredGroups: () => Record<string, RegisteredGroup>; registeredGroups: () => Record<string, RegisteredGroup>;
registerGroup: (jid: string, group: RegisteredGroup) => void; registerGroup: (jid: string, group: RegisteredGroup) => void;
assignRoom: (jid: string, room: AssignRoomInput) => void;
syncGroups: (force: boolean) => Promise<void>; syncGroups: (force: boolean) => Promise<void>;
getAvailableGroups: () => AvailableGroup[]; getAvailableGroups: () => AvailableGroup[];
writeGroupsSnapshot: ( writeGroupsSnapshot: (
@@ -292,12 +294,16 @@ export async function processTaskIpc(
groupFolder?: string; groupFolder?: string;
chatJid?: string; chatJid?: string;
targetJid?: string; targetJid?: string;
// For register_group // For assign_room / register_group
jid?: string; jid?: string;
name?: string; name?: string;
folder?: string; folder?: string;
trigger?: string; trigger?: string;
requiresTrigger?: boolean; requiresTrigger?: boolean;
room_mode?: RoomMode;
owner_agent_type?: AgentType;
isMain?: boolean;
workDir?: string;
agentConfig?: RegisteredGroup['agentConfig']; agentConfig?: RegisteredGroup['agentConfig'];
}, },
sourceGroup: string, // Verified identity from IPC directory sourceGroup: string, // Verified identity from IPC directory
@@ -595,36 +601,74 @@ export async function processTaskIpc(
} }
break; break;
case 'assign_room':
case 'register_group': case 'register_group':
// Only main group can register new groups // Only main group can assign/register rooms
if (!isMain) { if (!isMain) {
logger.warn( logger.warn(
{ sourceGroup }, { sourceGroup, type: data.type },
'Unauthorized register_group attempt blocked', `Unauthorized ${data.type} attempt blocked`,
); );
break; break;
} }
if (data.jid && data.name && data.folder && data.trigger) { if (data.jid && data.name) {
if (!isValidGroupFolder(data.folder)) { if (data.folder && !isValidGroupFolder(data.folder)) {
logger.warn( logger.warn(
{ sourceGroup, folder: data.folder }, { sourceGroup, folder: data.folder },
'Invalid register_group request - unsafe folder name', `Invalid ${data.type} request - unsafe folder name`,
); );
break; break;
} }
// Defense in depth: agent cannot set isMain via IPC if (
deps.registerGroup(data.jid, { data.room_mode !== undefined &&
data.room_mode !== 'single' &&
data.room_mode !== 'tribunal'
) {
logger.warn(
{ sourceGroup, roomMode: data.room_mode },
'Invalid assign_room request - unknown room_mode',
);
break;
}
if (
data.owner_agent_type !== undefined &&
data.owner_agent_type !== 'claude-code' &&
data.owner_agent_type !== 'codex'
) {
logger.warn(
{ sourceGroup, ownerAgentType: data.owner_agent_type },
'Invalid assign_room request - unknown owner_agent_type',
);
break;
}
if (data.type === 'assign_room') {
deps.assignRoom(data.jid, {
name: data.name,
roomMode: data.room_mode,
ownerAgentType: data.owner_agent_type,
folder: data.folder,
trigger: data.trigger,
requiresTrigger: data.requiresTrigger,
isMain: data.isMain,
workDir: data.workDir,
});
break;
}
deps.assignRoom(data.jid, {
name: data.name, name: data.name,
ownerAgentType: data.owner_agent_type,
folder: data.folder, folder: data.folder,
trigger: data.trigger, trigger: data.trigger,
added_at: new Date().toISOString(),
agentConfig: data.agentConfig,
requiresTrigger: data.requiresTrigger, requiresTrigger: data.requiresTrigger,
ownerAgentConfig: data.agentConfig,
addedAt: new Date().toISOString(),
}); });
} else { } else {
logger.warn( logger.warn(
{ data }, { data },
'Invalid register_group request - missing required fields', `Invalid ${data.type} request - missing required fields`,
); );
} }
break; break;