Fix timezone handling and message filtering

- Add TIMEZONE config using system timezone for cron expressions
- Filter bot messages by content prefix instead of is_from_me
  (user shares WhatsApp account with bot)
- Format messages as XML for cleaner agent parsing
- Update schedule_task tool to clarify local time usage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gavriel
2026-02-01 22:54:44 +02:00
parent 066eeb9646
commit 5760b75fa9
5 changed files with 31 additions and 21 deletions

View File

@@ -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) - "Follow up on my request" → group (needs to know what was requested)
- "Generate a daily report" → isolated (just needs instructions in prompt) - "Generate a daily report" → isolated (just needs instructions in prompt)
SCHEDULE VALUE FORMAT: 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) • 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) • 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.'), 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_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)'), 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)') target_group: z.string().optional().describe('Target group folder (main only, defaults to current group)')
}, },

View File

@@ -16,3 +16,7 @@ export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '3000
export const IPC_POLL_INTERVAL = 1000; export const IPC_POLL_INTERVAL = 1000;
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); 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;

View File

@@ -162,18 +162,19 @@ export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFrom
.run(msgId, chatJid, sender, senderName, content, timestamp, isFromMe ? 1 : 0); .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 }; if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
const placeholders = jids.map(() => '?').join(','); 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 = ` const sql = `
SELECT id, chat_jid, sender, sender_name, content, timestamp SELECT id, chat_jid, sender, sender_name, content, timestamp
FROM messages FROM messages
WHERE timestamp > ? AND chat_jid IN (${placeholders}) WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND content NOT LIKE ?
ORDER BY timestamp 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; let newTimestamp = lastTimestamp;
for (const row of rows) { for (const row of rows) {
@@ -183,14 +184,15 @@ export function getNewMessages(jids: string[], lastTimestamp: string): { message
return { messages: rows, newTimestamp }; 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 = ` const sql = `
SELECT id, chat_jid, sender, sender_name, content, timestamp SELECT id, chat_jid, sender, sender_name, content, timestamp
FROM messages FROM messages
WHERE chat_jid = ? AND timestamp > ? WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ?
ORDER BY timestamp 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<ScheduledTask, 'last_run' | 'last_result'>): void { export function createTask(task: Omit<ScheduledTask, 'last_run' | 'last_result'>): void {

View File

@@ -16,7 +16,8 @@ import {
DATA_DIR, DATA_DIR,
TRIGGER_PATTERN, TRIGGER_PATTERN,
MAIN_GROUP_FOLDER, MAIN_GROUP_FOLDER,
IPC_POLL_INTERVAL IPC_POLL_INTERVAL,
TIMEZONE
} from './config.js'; } from './config.js';
import { RegisteredGroup, Session, NewMessage } from './types.js'; import { RegisteredGroup, Session, NewMessage } from './types.js';
import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.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<void> {
// Get all messages since last agent interaction so the session has full context // Get all messages since last agent interaction so the session has full context
const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || ''; 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 lines = missedMessages.map(m => {
const d = new Date(m.timestamp); // Escape XML special characters in content
const date = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const escapeXml = (s: string) => s
const time = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); .replace(/&/g, '&amp;')
return `[${date} ${time}] ${m.sender_name}: ${m.content}`; .replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return `<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`;
}); });
const prompt = lines.join('\n'); const prompt = `<messages>\n${lines.join('\n')}\n</messages>`;
if (!prompt) return; if (!prompt) return;
@@ -335,7 +339,7 @@ async function processTaskIpc(
let nextRun: string | null = null; let nextRun: string | null = null;
if (scheduleType === 'cron') { if (scheduleType === 'cron') {
try { try {
const interval = CronExpressionParser.parse(data.schedule_value); const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE });
nextRun = interval.next().toISOString(); nextRun = interval.next().toISOString();
} catch { } catch {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression'); logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression');
@@ -512,7 +516,7 @@ async function startMessageLoop(): Promise<void> {
while (true) { while (true) {
try { try {
const jids = Object.keys(registeredGroups); 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'); if (messages.length > 0) logger.info({ count: messages.length }, 'New messages');
for (const msg of messages) { for (const msg of messages) {

View File

@@ -4,7 +4,7 @@ import pino from 'pino';
import { CronExpressionParser } from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js'; import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js';
import { ScheduledTask, RegisteredGroup } from './types.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'; import { runContainerAgent, writeTasksSnapshot } from './container-runner.js';
const logger = pino({ const logger = pino({
@@ -96,7 +96,7 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis
let nextRun: string | null = null; let nextRun: string | null = null;
if (task.schedule_type === 'cron') { 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(); nextRun = interval.next().toISOString();
} else if (task.schedule_type === 'interval') { } else if (task.schedule_type === 'interval') {
const ms = parseInt(task.schedule_value, 10); const ms = parseInt(task.schedule_value, 10);