feat: add room-level assignment write path
This commit is contained in:
@@ -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(
|
||||
'register_group',
|
||||
`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);
|
||||
|
||||
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.` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
_initTestDatabase,
|
||||
_initTestDatabaseFromFile,
|
||||
assignRoom,
|
||||
claimServiceHandoff,
|
||||
completeServiceHandoffAndAdvanceTargetCursor,
|
||||
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', () => {
|
||||
it('detects when both Claude and Codex are registered on the same jid', () => {
|
||||
setRegisteredGroup('dc:123', {
|
||||
|
||||
549
src/db.ts
549
src/db.ts
@@ -59,6 +59,19 @@ export interface StoredRoomSettings {
|
||||
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 {
|
||||
name: string;
|
||||
folder: string;
|
||||
@@ -69,6 +82,19 @@ interface RoomRegistrationSnapshot {
|
||||
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 {
|
||||
id: number;
|
||||
group_folder: string;
|
||||
@@ -1899,53 +1925,66 @@ export function getLastRespondingAgentType(
|
||||
|
||||
// --- 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(
|
||||
jid: string,
|
||||
agentType?: string,
|
||||
): (RegisteredGroup & { jid: string }) | undefined {
|
||||
const row = (
|
||||
agentType
|
||||
? db
|
||||
.prepare(
|
||||
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
|
||||
)
|
||||
.get(jid, agentType)
|
||||
: 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',
|
||||
const requestedAgentType = normalizeStoredAgentType(agentType);
|
||||
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
||||
if (stored) {
|
||||
const group = buildRegisteredGroupFromStoredSettings(
|
||||
db,
|
||||
stored,
|
||||
requestedAgentType,
|
||||
);
|
||||
return undefined;
|
||||
if (group) return group;
|
||||
}
|
||||
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: (row.agent_type as RegisteredGroup['agentType']) || undefined,
|
||||
workDir: row.work_dir || undefined,
|
||||
};
|
||||
return getLegacyRegisteredGroup(db, jid, agentType);
|
||||
}
|
||||
|
||||
export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
@@ -1978,68 +2017,115 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
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 {
|
||||
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(
|
||||
name,
|
||||
jid,
|
||||
);
|
||||
if (!getStoredRoomSettingsRowFromDatabase(db, jid)) {
|
||||
syncStoredRoomRegistrationSnapshotForJid(jid);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllRegisteredGroups(
|
||||
agentTypeFilter?: string,
|
||||
): 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> = {};
|
||||
for (const row of rows) {
|
||||
if (!isValidGroupFolder(row.folder)) {
|
||||
logger.warn(
|
||||
{ jid: row.jid, folder: row.folder },
|
||||
'Skipping registered group with invalid folder',
|
||||
const requestedAgentType = normalizeStoredAgentType(agentTypeFilter);
|
||||
const storedRows = getStoredRoomRowsFromDatabase(db);
|
||||
|
||||
for (const stored of storedRows) {
|
||||
const group = buildRegisteredGroupFromStoredSettings(
|
||||
db,
|
||||
stored,
|
||||
requestedAgentType,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const group: RegisteredGroup = {
|
||||
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,
|
||||
};
|
||||
// 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;
|
||||
if (group) {
|
||||
const { jid, ...rest } = group;
|
||||
result[jid] = rest;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2107,6 +2193,293 @@ function normalizeStoredAgentType(
|
||||
: 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(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
54
src/index.ts
54
src/index.ts
@@ -24,15 +24,15 @@ import {
|
||||
import { writeGroupsSnapshot } from './agent-runner.js';
|
||||
import { listAvailableGroups } from './available-groups.js';
|
||||
import {
|
||||
type AssignRoomInput,
|
||||
assignRoom,
|
||||
getAllRegisteredGroups,
|
||||
getAllSessions,
|
||||
getAllTasks,
|
||||
getLatestMessageSeqAtOrBefore,
|
||||
hasRecentRestartAnnouncement,
|
||||
getRegisteredGroup,
|
||||
getRouterState,
|
||||
initDatabase,
|
||||
setRegisteredGroup,
|
||||
setRouterState,
|
||||
deleteAllSessionsForGroup,
|
||||
deleteSession,
|
||||
@@ -217,29 +217,68 @@ function clearSession(
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
groupDir = resolveGroupFolderPath(group.folder);
|
||||
groupDir = resolveGroupFolderPath(assignedGroup.folder);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ jid, folder: group.folder, err },
|
||||
{ jid, folder: assignedGroup.folder, err },
|
||||
'Rejecting group registration with invalid folder',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
registeredGroups[jid] = group;
|
||||
setRegisteredGroup(jid, group);
|
||||
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
|
||||
registeredGroups[jid] = storedGroup;
|
||||
|
||||
// Create group folder
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
logger.info(
|
||||
{ jid, name: group.name, folder: group.folder },
|
||||
{ jid, name: assignedGroup.name, folder: assignedGroup.folder },
|
||||
'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.
|
||||
* Returns groups ordered by most recent activity.
|
||||
@@ -483,6 +522,7 @@ async function main(): Promise<void> {
|
||||
nudgeScheduler: nudgeSchedulerLoop,
|
||||
registeredGroups: () => registeredGroups,
|
||||
registerGroup,
|
||||
assignRoom: assignRoomForIpc,
|
||||
syncGroups: async (force: boolean) => {
|
||||
await Promise.all(
|
||||
channels
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { ASSISTANT_NAME } from './config.js';
|
||||
import {
|
||||
_initTestDatabase,
|
||||
assignRoom,
|
||||
createTask,
|
||||
getAllTasks,
|
||||
getRegisteredAgentTypesForJid,
|
||||
getRegisteredGroup,
|
||||
getStoredRoomSettings,
|
||||
getTaskById,
|
||||
setRegisteredGroup,
|
||||
} from './db.js';
|
||||
@@ -61,6 +65,12 @@ beforeEach(() => {
|
||||
setRegisteredGroup(jid, group);
|
||||
// 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 () => {},
|
||||
getAvailableGroups: () => [],
|
||||
writeGroupsSnapshot: () => {},
|
||||
@@ -794,19 +804,99 @@ describe('register_group success', () => {
|
||||
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(
|
||||
{
|
||||
type: 'register_group',
|
||||
jid: 'partial@g.us',
|
||||
name: 'Partial',
|
||||
// missing folder and trigger
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
70
src/ipc.ts
70
src/ipc.ts
@@ -7,6 +7,7 @@ import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
|
||||
import { readJsonFile } from './utils.js';
|
||||
import { AvailableGroup } from './agent-runner.js';
|
||||
import {
|
||||
type AssignRoomInput,
|
||||
createTask,
|
||||
deleteTask,
|
||||
findDuplicateCiWatcher,
|
||||
@@ -19,13 +20,14 @@ import {
|
||||
DEFAULT_WATCH_CI_MAX_DURATION_MS,
|
||||
isWatchCiTask,
|
||||
} from './task-watch-status.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
import { AgentType, RegisteredGroup, RoomMode } from './types.js';
|
||||
|
||||
export interface IpcDeps {
|
||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||
nudgeScheduler?: () => void;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
||||
assignRoom: (jid: string, room: AssignRoomInput) => void;
|
||||
syncGroups: (force: boolean) => Promise<void>;
|
||||
getAvailableGroups: () => AvailableGroup[];
|
||||
writeGroupsSnapshot: (
|
||||
@@ -292,12 +294,16 @@ export async function processTaskIpc(
|
||||
groupFolder?: string;
|
||||
chatJid?: string;
|
||||
targetJid?: string;
|
||||
// For register_group
|
||||
// For assign_room / register_group
|
||||
jid?: string;
|
||||
name?: string;
|
||||
folder?: string;
|
||||
trigger?: string;
|
||||
requiresTrigger?: boolean;
|
||||
room_mode?: RoomMode;
|
||||
owner_agent_type?: AgentType;
|
||||
isMain?: boolean;
|
||||
workDir?: string;
|
||||
agentConfig?: RegisteredGroup['agentConfig'];
|
||||
},
|
||||
sourceGroup: string, // Verified identity from IPC directory
|
||||
@@ -595,36 +601,74 @@ export async function processTaskIpc(
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assign_room':
|
||||
case 'register_group':
|
||||
// Only main group can register new groups
|
||||
// Only main group can assign/register rooms
|
||||
if (!isMain) {
|
||||
logger.warn(
|
||||
{ sourceGroup },
|
||||
'Unauthorized register_group attempt blocked',
|
||||
{ sourceGroup, type: data.type },
|
||||
`Unauthorized ${data.type} attempt blocked`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (data.jid && data.name && data.folder && data.trigger) {
|
||||
if (!isValidGroupFolder(data.folder)) {
|
||||
if (data.jid && data.name) {
|
||||
if (data.folder && !isValidGroupFolder(data.folder)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, folder: data.folder },
|
||||
'Invalid register_group request - unsafe folder name',
|
||||
`Invalid ${data.type} request - unsafe folder name`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Defense in depth: agent cannot set isMain via IPC
|
||||
deps.registerGroup(data.jid, {
|
||||
if (
|
||||
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,
|
||||
added_at: new Date().toISOString(),
|
||||
agentConfig: data.agentConfig,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
isMain: data.isMain,
|
||||
workDir: data.workDir,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
deps.assignRoom(data.jid, {
|
||||
name: data.name,
|
||||
ownerAgentType: data.owner_agent_type,
|
||||
folder: data.folder,
|
||||
trigger: data.trigger,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
ownerAgentConfig: data.agentConfig,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
{ data },
|
||||
'Invalid register_group request - missing required fields',
|
||||
`Invalid ${data.type} request - missing required fields`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user