feat: add Codex CLI support, dual Discord bots, and Discord typing fix

- Add Codex as alternative agent type (agentType: 'codex' per group)
- Codex container runner with session resume and 100-turn limit
- Dual Discord bot support (DISCORD_BOT_TOKEN + DISCORD_CODEX_BOT_TOKEN)
- Each bot handles only its agentType's registered groups
- Discord typing indicator refreshes every 8s (was single-shot, expired after ~10s)
- Agent runner 100-turn limit to prevent infinite bot-to-bot loops
- Apple Container runtime support (convert-to-apple-container skill)
- Session commands (/compact) support
- DB migration for agent_type column
This commit is contained in:
Eyejoker
2026-03-10 20:45:48 +09:00
parent d572bab5c6
commit 4a34a9c802
24 changed files with 2606 additions and 176 deletions

View File

@@ -31,6 +31,7 @@ import {
getAllRegisteredGroups,
getAllSessions,
getAllTasks,
getLastHumanMessageTimestamp,
getMessagesSince,
getNewMessages,
getRegisteredGroup,
@@ -52,6 +53,7 @@ import {
loadSenderAllowlist,
shouldDropMessage,
} from './sender-allowlist.js';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -164,6 +166,33 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
if (missedMessages.length === 0) return true;
// --- Session command interception (before trigger check) ---
const cmdResult = await handleSessionCommand({
missedMessages,
isMainGroup,
groupName: group.name,
triggerPattern: TRIGGER_PATTERN,
timezone: TIMEZONE,
deps: {
sendMessage: (text) => channel.sendMessage(chatJid, text),
setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput),
closeStdin: () => queue.closeStdin(chatJid),
advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); },
formatMessages,
canSenderInteract: (msg) => {
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
return isMainGroup || !reqTrigger || (hasTrigger && (
msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())
));
},
},
});
if (cmdResult.handled) return cmdResult.success;
// --- End session command interception ---
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const allowlistCfg = loadSenderAllowlist();
@@ -172,7 +201,9 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
TRIGGER_PATTERN.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
if (!hasTrigger) {
return true;
}
}
const prompt = formatMessages(missedMessages, TIMEZONE);
@@ -388,6 +419,51 @@ async function startMessageLoop(): Promise<void> {
}
const isMainGroup = group.isMain === true;
// --- Bot-collaboration timeout ---
// If all new messages are from bots, only process if a human
// sent a message within the last 12 hours.
const BOT_COLLAB_TIMEOUT_MS = 12 * 60 * 60 * 1000;
const allFromBots = groupMessages.every(
(m) => m.is_from_me || !!m.is_bot_message,
);
if (allFromBots) {
const lastHuman = getLastHumanMessageTimestamp(chatJid);
if (
!lastHuman ||
Date.now() - new Date(lastHuman).getTime() >
BOT_COLLAB_TIMEOUT_MS
) {
logger.info(
{ chatJid, lastHuman },
'Bot-collaboration timeout: no human message within 12h, skipping',
);
continue;
}
}
// --- End bot-collaboration timeout ---
// --- Session command interception (message loop) ---
// Scan ALL messages in the batch for a session command.
const loopCmdMsg = groupMessages.find(
(m) => extractSessionCommand(m.content, TRIGGER_PATTERN) !== null,
);
if (loopCmdMsg) {
// Only close active container if the sender is authorized — otherwise an
// untrusted user could kill in-flight work by sending /compact (DoS).
// closeStdin no-ops internally when no container is active.
if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) {
queue.closeStdin(chatJid);
}
// Enqueue so processGroupMessages handles auth + cursor advancement.
// Don't pipe via IPC — slash commands need a fresh container with
// string prompt (not MessageStream) for SDK recognition.
queue.enqueueMessageCheck(chatJid);
continue;
}
// --- End session command interception ---
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
@@ -431,7 +507,7 @@ async function startMessageLoop(): Promise<void> {
);
} else {
// No active container — enqueue for a new one
queue.enqueueMessageCheck(chatJid);
queue.enqueueMessageCheck(chatJid, group.folder);
}
}
}
@@ -455,7 +531,7 @@ function recoverPendingMessages(): void {
{ group: group.name, pendingCount: pending.length },
'Recovery: found unprocessed messages',
);
queue.enqueueMessageCheck(chatJid);
queue.enqueueMessageCheck(chatJid, group.folder);
}
}
}
@@ -472,7 +548,7 @@ async function main(): Promise<void> {
loadState();
// Start credential proxy (containers route API calls through this)
const proxyServer = await startCredentialProxy(
await startCredentialProxy(
CREDENTIAL_PROXY_PORT,
PROXY_BIND_HOST,
);
@@ -480,7 +556,6 @@ async function main(): Promise<void> {
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
proxyServer.close();
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);