Merge branch 'main' into fix/stale-session-recovery
This commit is contained in:
@@ -2,12 +2,14 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'ONECLI_URL',
|
||||
'TZ',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
@@ -51,6 +53,10 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
); // 10MB default
|
||||
export const ONECLI_URL =
|
||||
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(
|
||||
1,
|
||||
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
|
||||
);
|
||||
export const IPC_POLL_INTERVAL = 1000;
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||
@@ -62,12 +68,30 @@ function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(
|
||||
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
|
||||
'i',
|
||||
);
|
||||
export function buildTriggerPattern(trigger: string): RegExp {
|
||||
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
|
||||
}
|
||||
|
||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||
// Uses system timezone by default
|
||||
export const TIMEZONE =
|
||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
|
||||
|
||||
export function getTriggerPattern(trigger?: string): RegExp {
|
||||
const normalizedTrigger = trigger?.trim();
|
||||
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
|
||||
}
|
||||
|
||||
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
|
||||
|
||||
// Timezone for scheduled tasks, message formatting, etc.
|
||||
// Validates each candidate is a real IANA identifier before accepting.
|
||||
function resolveConfigTimezone(): string {
|
||||
const candidates = [
|
||||
process.env.TZ,
|
||||
envConfig.TZ,
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
];
|
||||
for (const tz of candidates) {
|
||||
if (tz && isValidTimezone(tz)) return tz;
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
export const TIMEZONE = resolveConfigTimezone();
|
||||
|
||||
@@ -51,6 +51,14 @@ vi.mock('./mount-security.js', () => ({
|
||||
validateAdditionalMounts: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Mock container-runtime
|
||||
vi.mock('./container-runtime.js', () => ({
|
||||
CONTAINER_RUNTIME_BIN: 'docker',
|
||||
hostGatewayArgs: () => [],
|
||||
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
|
||||
stopContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock OneCLI SDK
|
||||
vi.mock('@onecli-sh/sdk', () => ({
|
||||
OneCLI: class {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Container Runner for NanoClaw
|
||||
* Spawns agent execution in containers and handles IPC
|
||||
*/
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface ContainerInput {
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
script?: string;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
@@ -191,8 +192,17 @@ function buildVolumeMounts(
|
||||
group.folder,
|
||||
'agent-runner-src',
|
||||
);
|
||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
if (fs.existsSync(agentRunnerSrc)) {
|
||||
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
|
||||
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
|
||||
const needsCopy =
|
||||
!fs.existsSync(groupAgentRunnerDir) ||
|
||||
!fs.existsSync(cachedIndex) ||
|
||||
(fs.existsSync(srcIndex) &&
|
||||
fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
|
||||
if (needsCopy) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupAgentRunnerDir,
|
||||
@@ -426,15 +436,15 @@ export async function runContainerAgent(
|
||||
{ group: group.name, containerName },
|
||||
'Container timeout, stopping gracefully',
|
||||
);
|
||||
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
|
||||
if (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, containerName, err },
|
||||
'Graceful stop failed, force killing',
|
||||
);
|
||||
container.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
try {
|
||||
stopContainer(containerName);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, containerName, err },
|
||||
'Graceful stop failed, force killing',
|
||||
);
|
||||
container.kill('SIGKILL');
|
||||
}
|
||||
};
|
||||
|
||||
let timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
@@ -672,6 +682,7 @@ export function writeTasksSnapshot(
|
||||
id: string;
|
||||
groupFolder: string;
|
||||
prompt: string;
|
||||
script?: string | null;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
|
||||
@@ -39,11 +39,20 @@ describe('readonlyMountArgs', () => {
|
||||
});
|
||||
|
||||
describe('stopContainer', () => {
|
||||
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
|
||||
expect(stopContainer('nanoclaw-test-123')).toBe(
|
||||
it('calls docker stop for valid container names', () => {
|
||||
stopContainer('nanoclaw-test-123');
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects names with shell metacharacters', () => {
|
||||
expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name');
|
||||
expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name');
|
||||
expect(() => stopContainer('foo`id`')).toThrow('Invalid container name');
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- ensureContainerRuntimeRunning ---
|
||||
|
||||
@@ -27,9 +27,12 @@ export function readonlyMountArgs(
|
||||
return ['-v', `${hostPath}:${containerPath}:ro`];
|
||||
}
|
||||
|
||||
/** Returns the shell command to stop a container by name. */
|
||||
export function stopContainer(name: string): string {
|
||||
return `${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`;
|
||||
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
|
||||
export function stopContainer(name: string): void {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
||||
throw new Error(`Invalid container name: ${name}`);
|
||||
}
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
|
||||
}
|
||||
|
||||
/** Ensure the container runtime is running, starting it if needed. */
|
||||
@@ -82,7 +85,7 @@ export function cleanupOrphans(): void {
|
||||
const orphans = output.trim().split('\n').filter(Boolean);
|
||||
for (const name of orphans) {
|
||||
try {
|
||||
execSync(stopContainer(name), { stdio: 'pipe' });
|
||||
stopContainer(name);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
|
||||
67
src/db-migration.test.ts
Normal file
67
src/db-migration.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('database migrations', () => {
|
||||
it('defaults Telegram backfill chats to direct messages', async () => {
|
||||
const repoRoot = process.cwd();
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-db-test-'));
|
||||
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
||||
|
||||
const dbPath = path.join(tempDir, 'store', 'messages.db');
|
||||
const legacyDb = new Database(dbPath);
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE chats (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_message_time TEXT
|
||||
);
|
||||
`);
|
||||
legacyDb
|
||||
.prepare(
|
||||
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z');
|
||||
legacyDb
|
||||
.prepare(
|
||||
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z');
|
||||
legacyDb
|
||||
.prepare(
|
||||
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z');
|
||||
legacyDb.close();
|
||||
|
||||
vi.resetModules();
|
||||
const { initDatabase, getAllChats, _closeDatabase } =
|
||||
await import('./db.js');
|
||||
|
||||
initDatabase();
|
||||
|
||||
const chats = getAllChats();
|
||||
expect(chats.find((chat) => chat.jid === 'tg:12345')).toMatchObject({
|
||||
channel: 'telegram',
|
||||
is_group: 0,
|
||||
});
|
||||
expect(chats.find((chat) => chat.jid === 'tg:-10012345')).toMatchObject({
|
||||
channel: 'telegram',
|
||||
is_group: 0,
|
||||
});
|
||||
expect(chats.find((chat) => chat.jid === 'room@g.us')).toMatchObject({
|
||||
channel: 'whatsapp',
|
||||
is_group: 1,
|
||||
});
|
||||
|
||||
_closeDatabase();
|
||||
} finally {
|
||||
process.chdir(repoRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteTask,
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
getTaskById,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
storeMessage,
|
||||
updateTask,
|
||||
} from './db.js';
|
||||
import { formatMessages } from './router.js';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
@@ -208,6 +210,92 @@ describe('getMessagesSince', () => {
|
||||
expect(msgs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('recovers cursor from last bot reply when lastAgentTimestamp is missing', () => {
|
||||
// beforeEach already inserts m3 (bot reply at 00:00:03) and m4 (user at 00:00:04)
|
||||
// Add more old history before the bot reply
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
store({
|
||||
id: `history-${i}`,
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `old message ${i}`,
|
||||
timestamp: `2023-06-${String(i).padStart(2, '0')}T12:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
// New message after the bot reply (m3 at 00:00:03)
|
||||
store({
|
||||
id: 'new-1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'new message after bot reply',
|
||||
timestamp: '2024-01-02T00:00:00.000Z',
|
||||
});
|
||||
|
||||
// Recover cursor from the last bot message (m3 from beforeEach)
|
||||
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
|
||||
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
|
||||
|
||||
// Using recovered cursor: only gets messages after the bot reply
|
||||
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
|
||||
// m4 (third, 00:00:04) + new-1 — skips all 50 old messages and m1/m2
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].content).toBe('third');
|
||||
expect(msgs[1].content).toBe('new message after bot reply');
|
||||
});
|
||||
|
||||
it('caps messages to configured limit even with recovered cursor', () => {
|
||||
// beforeEach inserts m3 (bot at 00:00:03). Add 30 messages after it.
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
store({
|
||||
id: `pending-${i}`,
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `pending message ${i}`,
|
||||
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
|
||||
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
|
||||
|
||||
// With limit=10, only the 10 most recent are returned
|
||||
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
|
||||
expect(msgs).toHaveLength(10);
|
||||
// Most recent 10: pending-21 through pending-30
|
||||
expect(msgs[0].content).toBe('pending message 21');
|
||||
expect(msgs[9].content).toBe('pending message 30');
|
||||
});
|
||||
|
||||
it('returns last N messages when no bot reply and no cursor exist', () => {
|
||||
// Use a fresh group with no bot messages
|
||||
storeChatMetadata('fresh@g.us', '2024-01-01T00:00:00.000Z');
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
store({
|
||||
id: `fresh-${i}`,
|
||||
chat_jid: 'fresh@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `message ${i}`,
|
||||
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
const recovered = getLastBotMessageTimestamp('fresh@g.us', 'Andy');
|
||||
expect(recovered).toBeUndefined();
|
||||
|
||||
// No cursor → sinceTimestamp = '' but limit caps the result
|
||||
const msgs = getMessagesSince('fresh@g.us', '', 'Andy', 10);
|
||||
expect(msgs).toHaveLength(10);
|
||||
|
||||
const prompt = formatMessages(msgs, 'Asia/Jerusalem');
|
||||
const messageTagCount = (prompt.match(/<message /g) || []).length;
|
||||
expect(messageTagCount).toBe(10);
|
||||
});
|
||||
|
||||
it('filters pre-migration bot messages via content prefix backstop', () => {
|
||||
// Simulate a message written before migration: has prefix but is_bot_message = 0
|
||||
store({
|
||||
|
||||
43
src/db.ts
43
src/db.ts
@@ -93,6 +93,13 @@ function createSchema(database: Database.Database): void {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add script column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(
|
||||
@@ -134,7 +141,7 @@ function createSchema(database: Database.Database): void {
|
||||
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
|
||||
`UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`,
|
||||
);
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
@@ -158,6 +165,11 @@ export function _initTestDatabase(): void {
|
||||
createSchema(db);
|
||||
}
|
||||
|
||||
/** @internal - for tests only. */
|
||||
export function _closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store chat metadata only (no message content).
|
||||
* Used for all chats to enable group discovery without storing sensitive content.
|
||||
@@ -363,19 +375,33 @@ export function getMessagesSince(
|
||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
||||
}
|
||||
|
||||
export function getLastBotMessageTimestamp(
|
||||
chatJid: string,
|
||||
botPrefix: string,
|
||||
): string | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(timestamp) as ts FROM messages
|
||||
WHERE chat_jid = ? AND (is_bot_message = 1 OR content LIKE ?)`,
|
||||
)
|
||||
.get(chatJid, `${botPrefix}:%`) as { ts: string | null } | undefined;
|
||||
return row?.ts ?? undefined;
|
||||
}
|
||||
|
||||
export function createTask(
|
||||
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
task.id,
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
task.prompt,
|
||||
task.script || null,
|
||||
task.schedule_type,
|
||||
task.schedule_value,
|
||||
task.context_mode || 'isolated',
|
||||
@@ -410,7 +436,12 @@ export function updateTask(
|
||||
updates: Partial<
|
||||
Pick<
|
||||
ScheduledTask,
|
||||
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'
|
||||
| 'prompt'
|
||||
| 'script'
|
||||
| 'schedule_type'
|
||||
| 'schedule_value'
|
||||
| 'next_run'
|
||||
| 'status'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
@@ -421,6 +452,10 @@ export function updateTask(
|
||||
fields.push('prompt = ?');
|
||||
values.push(updates.prompt);
|
||||
}
|
||||
if (updates.script !== undefined) {
|
||||
fields.push('script = ?');
|
||||
values.push(updates.script || null);
|
||||
}
|
||||
if (updates.schedule_type !== undefined) {
|
||||
fields.push('schedule_type = ?');
|
||||
values.push(updates.schedule_type);
|
||||
|
||||
@@ -30,8 +30,9 @@ export function readEnvFile(keys: string[]): Record<string, string> {
|
||||
if (!wanted.has(key)) continue;
|
||||
let value = trimmed.slice(eqIdx + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
getTriggerPattern,
|
||||
TRIGGER_PATTERN,
|
||||
} from './config.js';
|
||||
import {
|
||||
escapeXml,
|
||||
formatMessages,
|
||||
@@ -161,6 +165,28 @@ describe('TRIGGER_PATTERN', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTriggerPattern', () => {
|
||||
it('uses the configured per-group trigger when provided', () => {
|
||||
const pattern = getTriggerPattern('@Claw');
|
||||
|
||||
expect(pattern.test('@Claw hello')).toBe(true);
|
||||
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the default trigger when group trigger is missing', () => {
|
||||
const pattern = getTriggerPattern(undefined);
|
||||
|
||||
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats regex characters in custom triggers literally', () => {
|
||||
const pattern = getTriggerPattern('@C.L.A.U.D.E');
|
||||
|
||||
expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true);
|
||||
expect(pattern.test('@CXLXAUXDXE hello')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outbound formatting (internal tag stripping + prefix) ---
|
||||
|
||||
describe('stripInternalTags', () => {
|
||||
@@ -207,7 +233,7 @@ describe('formatOutbound', () => {
|
||||
|
||||
describe('trigger gating (requiresTrigger interaction)', () => {
|
||||
// Replicates the exact logic from processGroupMessages and startMessageLoop:
|
||||
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
|
||||
// if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger }
|
||||
function shouldRequireTrigger(
|
||||
isMainGroup: boolean,
|
||||
requiresTrigger: boolean | undefined,
|
||||
@@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => {
|
||||
function shouldProcess(
|
||||
isMainGroup: boolean,
|
||||
requiresTrigger: boolean | undefined,
|
||||
trigger: string | undefined,
|
||||
messages: NewMessage[],
|
||||
): boolean {
|
||||
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
|
||||
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
|
||||
const triggerPattern = getTriggerPattern(trigger);
|
||||
return messages.some((m) => triggerPattern.test(m.content.trim()));
|
||||
}
|
||||
|
||||
it('main group always processes (no trigger needed)', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(true, undefined, msgs)).toBe(true);
|
||||
expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('main group processes even with requiresTrigger=true', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(true, true, msgs)).toBe(true);
|
||||
expect(shouldProcess(true, true, undefined, msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(false, undefined, msgs)).toBe(false);
|
||||
expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=true requires trigger', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(false, true, msgs)).toBe(false);
|
||||
expect(shouldProcess(false, true, undefined, msgs)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=true processes when trigger present', () => {
|
||||
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
|
||||
expect(shouldProcess(false, true, msgs)).toBe(true);
|
||||
expect(shouldProcess(false, true, undefined, msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group uses its per-group trigger instead of the default trigger', () => {
|
||||
const msgs = [makeMsg({ content: '@Claw do something' })];
|
||||
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => {
|
||||
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
|
||||
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(false, false, msgs)).toBe(true);
|
||||
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
72
src/index.ts
72
src/index.ts
@@ -5,11 +5,14 @@ import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DEFAULT_TRIGGER,
|
||||
getTriggerPattern,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
ONECLI_URL,
|
||||
POLL_INTERVAL,
|
||||
TIMEZONE,
|
||||
TRIGGER_PATTERN,
|
||||
} from './config.js';
|
||||
import './channels/index.js';
|
||||
import {
|
||||
@@ -32,6 +35,7 @@ import {
|
||||
getAllSessions,
|
||||
deleteSession,
|
||||
getAllTasks,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
getRouterState,
|
||||
@@ -111,6 +115,27 @@ function loadState(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the message cursor for a group, recovering from the last bot reply
|
||||
* if lastAgentTimestamp is missing (new group, corrupted state, restart).
|
||||
*/
|
||||
function getOrRecoverCursor(chatJid: string): string {
|
||||
const existing = lastAgentTimestamp[chatJid];
|
||||
if (existing) return existing;
|
||||
|
||||
const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME);
|
||||
if (botTs) {
|
||||
logger.info(
|
||||
{ chatJid, recoveredFrom: botTs },
|
||||
'Recovered message cursor from last bot reply',
|
||||
);
|
||||
lastAgentTimestamp[chatJid] = botTs;
|
||||
saveState();
|
||||
return botTs;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function saveState(): void {
|
||||
setRouterState('last_timestamp', lastTimestamp);
|
||||
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
||||
@@ -134,6 +159,26 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
// Create group folder
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
// Copy CLAUDE.md template into the new group folder so agents have
|
||||
// identity and instructions from the first run. (Fixes #1391)
|
||||
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(groupMdFile)) {
|
||||
const templateFile = path.join(
|
||||
GROUPS_DIR,
|
||||
group.isMain ? 'main' : 'global',
|
||||
'CLAUDE.md',
|
||||
);
|
||||
if (fs.existsSync(templateFile)) {
|
||||
let content = fs.readFileSync(templateFile, 'utf-8');
|
||||
if (ASSISTANT_NAME !== 'Andy') {
|
||||
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
|
||||
}
|
||||
fs.writeFileSync(groupMdFile, content);
|
||||
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
|
||||
ensureOneCLIAgent(jid, group);
|
||||
|
||||
@@ -184,21 +229,22 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
|
||||
const isMainGroup = group.isMain === true;
|
||||
|
||||
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
||||
const missedMessages = getMessagesSince(
|
||||
chatJid,
|
||||
sinceTimestamp,
|
||||
getOrRecoverCursor(chatJid),
|
||||
ASSISTANT_NAME,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
);
|
||||
|
||||
if (missedMessages.length === 0) return true;
|
||||
|
||||
// For non-main groups, check if trigger is required and present
|
||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||
const triggerPattern = getTriggerPattern(group.trigger);
|
||||
const allowlistCfg = loadSenderAllowlist();
|
||||
const hasTrigger = missedMessages.some(
|
||||
(m) =>
|
||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
||||
triggerPattern.test(m.content.trim()) &&
|
||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
if (!hasTrigger) return true;
|
||||
@@ -307,6 +353,7 @@ async function runAgent(
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
script: t.script || undefined,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
@@ -422,7 +469,7 @@ async function startMessageLoop(): Promise<void> {
|
||||
}
|
||||
messageLoopRunning = true;
|
||||
|
||||
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
|
||||
logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
@@ -468,10 +515,11 @@ async function startMessageLoop(): Promise<void> {
|
||||
// Non-trigger messages accumulate in DB and get pulled as
|
||||
// context when a trigger eventually arrives.
|
||||
if (needsTrigger) {
|
||||
const triggerPattern = getTriggerPattern(group.trigger);
|
||||
const allowlistCfg = loadSenderAllowlist();
|
||||
const hasTrigger = groupMessages.some(
|
||||
(m) =>
|
||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
||||
triggerPattern.test(m.content.trim()) &&
|
||||
(m.is_from_me ||
|
||||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
@@ -482,8 +530,9 @@ async function startMessageLoop(): Promise<void> {
|
||||
// context that accumulated between triggers is included.
|
||||
const allPending = getMessagesSince(
|
||||
chatJid,
|
||||
lastAgentTimestamp[chatJid] || '',
|
||||
getOrRecoverCursor(chatJid),
|
||||
ASSISTANT_NAME,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
);
|
||||
const messagesToSend =
|
||||
allPending.length > 0 ? allPending : groupMessages;
|
||||
@@ -522,8 +571,12 @@ async function startMessageLoop(): Promise<void> {
|
||||
*/
|
||||
function recoverPendingMessages(): void {
|
||||
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
||||
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
||||
const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
|
||||
const pending = getMessagesSince(
|
||||
chatJid,
|
||||
getOrRecoverCursor(chatJid),
|
||||
ASSISTANT_NAME,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
);
|
||||
if (pending.length > 0) {
|
||||
logger.info(
|
||||
{ group: group.name, pendingCount: pending.length },
|
||||
@@ -707,6 +760,7 @@ async function main(): Promise<void> {
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
script: t.script || undefined,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
|
||||
@@ -162,6 +162,7 @@ export async function processTaskIpc(
|
||||
schedule_type?: string;
|
||||
schedule_value?: string;
|
||||
context_mode?: string;
|
||||
script?: string;
|
||||
groupFolder?: string;
|
||||
chatJid?: string;
|
||||
targetJid?: string;
|
||||
@@ -260,6 +261,7 @@ export async function processTaskIpc(
|
||||
group_folder: targetFolder,
|
||||
chat_jid: targetJid,
|
||||
prompt: data.prompt,
|
||||
script: data.script || null,
|
||||
schedule_type: scheduleType,
|
||||
schedule_value: data.schedule_value,
|
||||
context_mode: contextMode,
|
||||
@@ -352,6 +354,7 @@ export async function processTaskIpc(
|
||||
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
if (data.prompt !== undefined) updates.prompt = data.prompt;
|
||||
if (data.script !== undefined) updates.script = data.script || null;
|
||||
if (data.schedule_type !== undefined)
|
||||
updates.schedule_type = data.schedule_type as
|
||||
| 'cron'
|
||||
@@ -438,7 +441,10 @@ export async function processTaskIpc(
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Defense in depth: agent cannot set isMain via IPC
|
||||
// Defense in depth: agent cannot set isMain via IPC.
|
||||
// Preserve isMain from the existing registration so IPC config
|
||||
// updates (e.g. adding additionalMounts) don't strip the flag.
|
||||
const existingGroup = registeredGroups[data.jid];
|
||||
deps.registerGroup(data.jid, {
|
||||
name: data.name,
|
||||
folder: data.folder,
|
||||
@@ -446,6 +452,7 @@ export async function processTaskIpc(
|
||||
added_at: new Date().toISOString(),
|
||||
containerConfig: data.containerConfig,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
isMain: existingGroup?.isMain,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
import pino from 'pino';
|
||||
const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const;
|
||||
type Level = keyof typeof LEVELS;
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
const COLORS: Record<Level, string> = {
|
||||
debug: '\x1b[34m',
|
||||
info: '\x1b[32m',
|
||||
warn: '\x1b[33m',
|
||||
error: '\x1b[31m',
|
||||
fatal: '\x1b[41m\x1b[37m',
|
||||
};
|
||||
const KEY_COLOR = '\x1b[35m';
|
||||
const MSG_COLOR = '\x1b[36m';
|
||||
const RESET = '\x1b[39m';
|
||||
const FULL_RESET = '\x1b[0m';
|
||||
|
||||
// Route uncaught errors through pino so they get timestamps in stderr
|
||||
const threshold =
|
||||
LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info;
|
||||
|
||||
function formatErr(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return `{\n "type": "${err.constructor.name}",\n "message": "${err.message}",\n "stack":\n ${err.stack}\n }`;
|
||||
}
|
||||
return JSON.stringify(err);
|
||||
}
|
||||
|
||||
function formatData(data: Record<string, unknown>): string {
|
||||
let out = '';
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === 'err') {
|
||||
out += `\n ${KEY_COLOR}err${RESET}: ${formatErr(v)}`;
|
||||
} else {
|
||||
out += `\n ${KEY_COLOR}${k}${RESET}: ${JSON.stringify(v)}`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ts(): string {
|
||||
const d = new Date();
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function log(
|
||||
level: Level,
|
||||
dataOrMsg: Record<string, unknown> | string,
|
||||
msg?: string,
|
||||
): void {
|
||||
if (LEVELS[level] < threshold) return;
|
||||
const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`;
|
||||
const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout;
|
||||
if (typeof dataOrMsg === 'string') {
|
||||
stream.write(
|
||||
`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`,
|
||||
);
|
||||
} else {
|
||||
stream.write(
|
||||
`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('debug', dataOrMsg, msg),
|
||||
info: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('info', dataOrMsg, msg),
|
||||
warn: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('warn', dataOrMsg, msg),
|
||||
error: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('error', dataOrMsg, msg),
|
||||
fatal: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('fatal', dataOrMsg, msg),
|
||||
};
|
||||
|
||||
// Route uncaught errors through logger so they get timestamps in stderr
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.fatal({ err }, 'Uncaught exception');
|
||||
process.exit(1);
|
||||
|
||||
@@ -9,16 +9,10 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
|
||||
import { MOUNT_ALLOWLIST_PATH } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
|
||||
// Cache the allowlist in memory - only reloads on process restart
|
||||
let cachedAllowlist: MountAllowlist | null = null;
|
||||
let allowlistLoadError: string | null = null;
|
||||
@@ -63,7 +57,8 @@ export function loadMountAllowlist(): MountAllowlist | null {
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
||||
allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`;
|
||||
// Do NOT cache this as an error — file may be created later without restart.
|
||||
// Only parse/structural errors are permanently cached.
|
||||
logger.warn(
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
|
||||
@@ -215,6 +210,11 @@ function isValidContainerPath(containerPath: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
|
||||
if (containerPath.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ async function runTask(
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
script: t.script,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
@@ -179,6 +180,7 @@ async function runTask(
|
||||
isMain,
|
||||
isScheduledTask: true,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
script: task.script || undefined,
|
||||
},
|
||||
(proc, containerName) =>
|
||||
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { formatLocalTime } from './timezone.js';
|
||||
import {
|
||||
formatLocalTime,
|
||||
isValidTimezone,
|
||||
resolveTimezone,
|
||||
} from './timezone.js';
|
||||
|
||||
// --- formatLocalTime ---
|
||||
|
||||
@@ -26,4 +30,44 @@ describe('formatLocalTime', () => {
|
||||
expect(ny).toContain('8:00');
|
||||
expect(tokyo).toContain('9:00');
|
||||
});
|
||||
|
||||
it('does not throw on invalid timezone, falls back to UTC', () => {
|
||||
expect(() =>
|
||||
formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'),
|
||||
).not.toThrow();
|
||||
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
|
||||
// Should format as UTC (noon UTC = 12:00 PM)
|
||||
expect(result).toContain('12:00');
|
||||
expect(result).toContain('PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidTimezone', () => {
|
||||
it('accepts valid IANA identifiers', () => {
|
||||
expect(isValidTimezone('America/New_York')).toBe(true);
|
||||
expect(isValidTimezone('UTC')).toBe(true);
|
||||
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
|
||||
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid timezone strings', () => {
|
||||
expect(isValidTimezone('IST-2')).toBe(false);
|
||||
expect(isValidTimezone('XYZ+3')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage strings', () => {
|
||||
expect(isValidTimezone('')).toBe(false);
|
||||
expect(isValidTimezone('NotATimezone')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimezone', () => {
|
||||
it('returns the timezone if valid', () => {
|
||||
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
|
||||
});
|
||||
|
||||
it('falls back to UTC for invalid timezone', () => {
|
||||
expect(resolveTimezone('IST-2')).toBe('UTC');
|
||||
expect(resolveTimezone('')).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
/**
|
||||
* Check whether a timezone string is a valid IANA identifier
|
||||
* that Intl.DateTimeFormat can use.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given timezone if valid IANA, otherwise fall back to UTC.
|
||||
*/
|
||||
export function resolveTimezone(tz: string): string {
|
||||
return isValidTimezone(tz) ? tz : 'UTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UTC ISO timestamp to a localized display string.
|
||||
* Uses the Intl API (no external dependencies).
|
||||
* Falls back to UTC if the timezone is invalid.
|
||||
*/
|
||||
export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
const date = new Date(utcIso);
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ScheduledTask {
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
prompt: string;
|
||||
script?: string | null;
|
||||
schedule_type: 'cron' | 'interval' | 'once';
|
||||
schedule_value: string;
|
||||
context_mode: 'group' | 'isolated';
|
||||
|
||||
Reference in New Issue
Block a user