diff --git a/container/agent-runner/src/ipc-mcp.ts b/container/agent-runner/src/ipc-mcp.ts index dd42078..d7a6a29 100644 --- a/container/agent-runner/src/ipc-mcp.ts +++ b/container/agent-runner/src/ipc-mcp.ts @@ -80,14 +80,14 @@ If unsure which mode to use, ask the user. Examples: - "Follow up on my request" → group (needs to know what was requested) - "Generate a daily report" → isolated (just needs instructions in prompt) -SCHEDULE VALUE FORMAT: -• cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am) +SCHEDULE VALUE FORMAT (all times are LOCAL timezone): +• cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) • interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) -• once: ISO 8601 timestamp (e.g., "2026-02-01T15:30:00.000Z"). Calculate this from current time.`, +• once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, { prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'), schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), - schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: ISO timestamp like "2026-02-01T15:30:00.000Z"'), + schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), target_group: z.string().optional().describe('Target group folder (main only, defaults to current group)') }, diff --git a/src/config.ts b/src/config.ts index 1d29490..a8f8c22 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,3 +16,7 @@ export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '3000 export const IPC_POLL_INTERVAL = 1000; export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); + +// Timezone for scheduled tasks (cron expressions, etc.) +// Uses system timezone by default +export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/src/db.ts b/src/db.ts index 670a048..6a15517 100644 --- a/src/db.ts +++ b/src/db.ts @@ -162,18 +162,19 @@ export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFrom .run(msgId, chatJid, sender, senderName, content, timestamp, isFromMe ? 1 : 0); } -export function getNewMessages(jids: string[], lastTimestamp: string): { messages: NewMessage[]; newTimestamp: string } { +export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix: string): { messages: NewMessage[]; newTimestamp: string } { if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; const placeholders = jids.map(() => '?').join(','); + // Filter out bot's own messages by checking content prefix (not is_from_me, since user shares the account) const sql = ` SELECT id, chat_jid, sender, sender_name, content, timestamp FROM messages - WHERE timestamp > ? AND chat_jid IN (${placeholders}) + WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND content NOT LIKE ? ORDER BY timestamp `; - const rows = db.prepare(sql).all(lastTimestamp, ...jids) as NewMessage[]; + const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[]; let newTimestamp = lastTimestamp; for (const row of rows) { @@ -183,14 +184,15 @@ export function getNewMessages(jids: string[], lastTimestamp: string): { message return { messages: rows, newTimestamp }; } -export function getMessagesSince(chatJid: string, sinceTimestamp: string): NewMessage[] { +export function getMessagesSince(chatJid: string, sinceTimestamp: string, botPrefix: string): NewMessage[] { + // Filter out bot's own messages by checking content prefix const sql = ` SELECT id, chat_jid, sender, sender_name, content, timestamp FROM messages - WHERE chat_jid = ? AND timestamp > ? + WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ? ORDER BY timestamp `; - return db.prepare(sql).all(chatJid, sinceTimestamp) as NewMessage[]; + return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; } export function createTask(task: Omit): void { diff --git a/src/index.ts b/src/index.ts index 93c8e8f..7b36066 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,8 @@ import { DATA_DIR, TRIGGER_PATTERN, MAIN_GROUP_FOLDER, - IPC_POLL_INTERVAL + IPC_POLL_INTERVAL, + TIMEZONE } from './config.js'; import { RegisteredGroup, Session, NewMessage } from './types.js'; import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.js'; @@ -128,15 +129,18 @@ async function processMessage(msg: NewMessage): Promise { // Get all messages since last agent interaction so the session has full context const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || ''; - const missedMessages = getMessagesSince(msg.chat_jid, sinceTimestamp); + const missedMessages = getMessagesSince(msg.chat_jid, sinceTimestamp, ASSISTANT_NAME); const lines = missedMessages.map(m => { - const d = new Date(m.timestamp); - const date = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - const time = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); - return `[${date} ${time}] ${m.sender_name}: ${m.content}`; + // Escape XML special characters in content + const escapeXml = (s: string) => s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + return `${escapeXml(m.content)}`; }); - const prompt = lines.join('\n'); + const prompt = `\n${lines.join('\n')}\n`; if (!prompt) return; @@ -335,7 +339,7 @@ async function processTaskIpc( let nextRun: string | null = null; if (scheduleType === 'cron') { try { - const interval = CronExpressionParser.parse(data.schedule_value); + const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE }); nextRun = interval.next().toISOString(); } catch { logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression'); @@ -512,7 +516,7 @@ async function startMessageLoop(): Promise { while (true) { try { const jids = Object.keys(registeredGroups); - const { messages } = getNewMessages(jids, lastTimestamp); + const { messages } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); if (messages.length > 0) logger.info({ count: messages.length }, 'New messages'); for (const msg of messages) { diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 65c353d..979ed66 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -4,7 +4,7 @@ import pino from 'pino'; import { CronExpressionParser } from 'cron-parser'; import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js'; import { ScheduledTask, RegisteredGroup } from './types.js'; -import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, MAIN_GROUP_FOLDER } from './config.js'; +import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, MAIN_GROUP_FOLDER, TIMEZONE } from './config.js'; import { runContainerAgent, writeTasksSnapshot } from './container-runner.js'; const logger = pino({ @@ -96,7 +96,7 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis let nextRun: string | null = null; if (task.schedule_type === 'cron') { - const interval = CronExpressionParser.parse(task.schedule_value); + const interval = CronExpressionParser.parse(task.schedule_value, { tz: TIMEZONE }); nextRun = interval.next().toISOString(); } else if (task.schedule_type === 'interval') { const ms = parseInt(task.schedule_value, 10);