Merge branch 'codex/owner/ejclaw'

This commit is contained in:
Eyejoker
2026-04-04 23:03:21 +09:00
9 changed files with 268 additions and 44 deletions

View File

@@ -46,6 +46,7 @@ import { startIpcWatcher } from './ipc.js';
import {
findChannel,
findChannelByName,
findChannelForDeliveryRole,
formatOutbound,
normalizeMessageForDedupe,
} from './router.js';
@@ -471,8 +472,8 @@ async function main(): Promise<void> {
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
sendMessage: (jid, text, senderRole) => {
const channel = findChannelForDeliveryRole(channels, jid, senderRole);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},

View File

@@ -13,7 +13,7 @@ import {
getStoredRoomSettings,
getTaskById,
} from './db.js';
import { processTaskIpc, IpcDeps } from './ipc.js';
import { forwardAuthorizedIpcMessage, processTaskIpc, IpcDeps } from './ipc.js';
import { DEFAULT_WATCH_CI_MAX_DURATION_MS } from './task-watch-status.js';
import { RegisteredGroup } from './types.js';
@@ -536,6 +536,49 @@ describe('IPC message authorization', () => {
isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups),
).toBe(true);
});
it('forwards senderRole through authorized IPC messages', async () => {
const sendMessage = vi.fn(async () => {});
await forwardAuthorizedIpcMessage(
{
type: 'message',
chatJid: 'other@g.us',
text: 'review text',
senderRole: 'reviewer',
},
'other-group',
false,
groups,
sendMessage,
);
expect(sendMessage).toHaveBeenCalledWith(
'other@g.us',
'review text',
'reviewer',
);
});
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
const sendMessage = vi.fn(async () => {});
const result = await forwardAuthorizedIpcMessage(
{
type: 'message',
chatJid: 'third@g.us',
text: 'review text',
senderRole: 'reviewer',
},
'other-group',
false,
groups,
sendMessage,
);
expect(result.outcome).toBe('blocked');
expect(sendMessage).not.toHaveBeenCalled();
});
});
// --- schedule_task with cron and interval types ---

View File

@@ -34,7 +34,11 @@ import {
import { AgentType, RegisteredGroup, RoomMode } from './types.js';
export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise<void>;
sendMessage: (
jid: string,
text: string,
senderRole?: string,
) => Promise<void>;
nudgeScheduler?: () => void;
registeredGroups: () => Record<string, RegisteredGroup>;
assignRoom: (jid: string, room: AssignRoomInput) => void;
@@ -47,6 +51,53 @@ export interface IpcDeps {
) => void;
}
export interface IpcMessagePayload {
type?: string;
chatJid?: string;
text?: string;
senderRole?: string;
}
export interface IpcMessageForwardResult {
outcome: 'ignored' | 'sent' | 'blocked';
chatJid?: string;
targetGroup?: string | null;
isMainOverride?: boolean;
}
export async function forwardAuthorizedIpcMessage(
msg: IpcMessagePayload,
sourceGroup: string,
isMain: boolean,
registeredGroups: Record<string, RegisteredGroup>,
sendMessage: IpcDeps['sendMessage'],
): Promise<IpcMessageForwardResult> {
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
return { outcome: 'ignored' };
}
const targetGroup = registeredGroups[msg.chatJid];
const isMainOverride = isMain === true;
if (
!(isMainOverride || (targetGroup && targetGroup.folder === sourceGroup))
) {
return {
outcome: 'blocked',
chatJid: msg.chatJid,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
};
}
await sendMessage(msg.chatJid, msg.text, msg.senderRole);
return {
outcome: 'sent',
chatJid: msg.chatJid,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
};
}
let ipcWatcherRunning = false;
const IPC_PROCESSING_DIRNAME = '.processing';
@@ -186,42 +237,36 @@ export function startIpcWatcher(deps: IpcDeps): void {
const data = readJsonFile(claimedPath);
if (!data || typeof data !== 'object')
throw new Error('Invalid JSON');
const msg = data as {
type?: string;
chatJid?: string;
text?: string;
};
if (msg.type === 'message' && msg.chatJid && msg.text) {
// Authorization: verify this group can send to this chatJid
const targetGroup = registeredGroups[msg.chatJid];
const isMainOverride = isMain === true;
if (
isMainOverride ||
(targetGroup && targetGroup.folder === sourceGroup)
) {
await deps.sendMessage(msg.chatJid, msg.text);
logger.info(
{
transition: 'ipc:auth:allow',
chatJid: msg.chatJid,
sourceGroup,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
},
'IPC message sent',
);
} else {
logger.warn(
{
transition: 'ipc:auth:deny',
chatJid: msg.chatJid,
sourceGroup,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
},
'Unauthorized IPC message attempt blocked',
);
}
const msg = data as IpcMessagePayload;
const forwardResult = await forwardAuthorizedIpcMessage(
msg,
sourceGroup,
isMain,
registeredGroups,
deps.sendMessage,
);
if (forwardResult.outcome === 'sent') {
logger.info(
{
transition: 'ipc:auth:allow',
chatJid: forwardResult.chatJid,
sourceGroup,
targetGroup: forwardResult.targetGroup ?? null,
isMainOverride: forwardResult.isMainOverride,
},
'IPC message sent',
);
} else if (forwardResult.outcome === 'blocked') {
logger.warn(
{
transition: 'ipc:auth:deny',
chatJid: forwardResult.chatJid,
sourceGroup,
targetGroup: forwardResult.targetGroup ?? null,
isMainOverride: forwardResult.isMainOverride,
},
'Unauthorized IPC message attempt blocked',
);
}
fs.unlinkSync(claimedPath);
} catch (err) {

54
src/router.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { findChannelForDeliveryRole } from './router.js';
import { type Channel } from './types.js';
function createChannel(name: string, ownedJids: string[]): Channel {
return {
name,
connect: async () => {},
sendMessage: async () => {},
isConnected: () => true,
ownsJid: (jid) => ownedJids.includes(jid),
disconnect: async () => {},
};
}
describe('findChannelForDeliveryRole', () => {
const jid = 'dc:123';
it('prefers the reviewer channel for reviewer IPC messages', () => {
const ownerChannel = createChannel('discord-main', [jid]);
const reviewerChannel = createChannel('discord-review', [jid]);
expect(
findChannelForDeliveryRole(
[ownerChannel, reviewerChannel],
jid,
'reviewer',
),
).toBe(reviewerChannel);
});
it('prefers the arbiter channel for arbiter IPC messages', () => {
const ownerChannel = createChannel('discord-main', [jid]);
const reviewerChannel = createChannel('discord-review', [jid]);
const arbiterChannel = createChannel('discord-arbiter', [jid]);
expect(
findChannelForDeliveryRole(
[ownerChannel, reviewerChannel, arbiterChannel],
jid,
'arbiter',
),
).toBe(arbiterChannel);
});
it('falls back to the first JID-owning channel when no role channel exists', () => {
const ownerChannel = createChannel('discord-main', [jid]);
expect(findChannelForDeliveryRole([ownerChannel], jid, 'reviewer')).toBe(
ownerChannel,
);
});
});

View File

@@ -93,6 +93,26 @@ export function findChannel(
return channels.find((c) => c.ownsJid(jid));
}
export function findChannelForDeliveryRole(
channels: Channel[],
jid: string,
senderRole?: string,
): Channel | undefined {
if (senderRole === 'reviewer') {
return (
findChannelByName(channels, 'discord-review') ||
findChannel(channels, jid)
);
}
if (senderRole === 'arbiter') {
return (
findChannelByName(channels, 'discord-arbiter') ||
findChannel(channels, jid)
);
}
return findChannel(channels, jid);
}
export function findChannelByName(
channels: Channel[],
name: string,