kernelbot 1.0.26 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/README.md +198 -124
- package/bin/kernel.js +208 -4
- package/config.example.yaml +14 -1
- package/package.json +1 -1
- package/src/agent.js +839 -209
- package/src/automation/automation-manager.js +377 -0
- package/src/automation/automation.js +79 -0
- package/src/automation/index.js +2 -0
- package/src/automation/scheduler.js +141 -0
- package/src/bot.js +1001 -18
- package/src/claude-auth.js +93 -0
- package/src/coder.js +48 -6
- package/src/conversation.js +33 -0
- package/src/intents/detector.js +50 -0
- package/src/intents/index.js +2 -0
- package/src/intents/planner.js +58 -0
- package/src/persona.js +68 -0
- package/src/prompts/orchestrator.js +124 -0
- package/src/prompts/persona.md +21 -0
- package/src/prompts/system.js +59 -6
- package/src/prompts/workers.js +148 -0
- package/src/providers/anthropic.js +23 -16
- package/src/providers/base.js +76 -2
- package/src/providers/index.js +1 -0
- package/src/providers/models.js +2 -1
- package/src/providers/openai-compat.js +5 -3
- package/src/security/audit.js +0 -0
- package/src/security/auth.js +0 -0
- package/src/security/confirm.js +7 -2
- package/src/self.js +122 -0
- package/src/services/stt.js +139 -0
- package/src/services/tts.js +124 -0
- package/src/skills/catalog.js +506 -0
- package/src/skills/custom.js +128 -0
- package/src/swarm/job-manager.js +216 -0
- package/src/swarm/job.js +85 -0
- package/src/swarm/worker-registry.js +79 -0
- package/src/tools/browser.js +458 -335
- package/src/tools/categories.js +3 -3
- package/src/tools/coding.js +5 -0
- package/src/tools/docker.js +0 -0
- package/src/tools/git.js +0 -0
- package/src/tools/github.js +0 -0
- package/src/tools/index.js +3 -0
- package/src/tools/jira.js +0 -0
- package/src/tools/monitor.js +0 -0
- package/src/tools/network.js +0 -0
- package/src/tools/orchestrator-tools.js +428 -0
- package/src/tools/os.js +14 -1
- package/src/tools/persona.js +32 -0
- package/src/tools/process.js +0 -0
- package/src/utils/config.js +153 -15
- package/src/utils/display.js +0 -0
- package/src/utils/logger.js +0 -0
- package/src/worker.js +396 -0
- package/.agents/skills/interface-design/SKILL.md +0 -391
- package/.agents/skills/interface-design/references/critique.md +0 -67
- package/.agents/skills/interface-design/references/example.md +0 -86
- package/.agents/skills/interface-design/references/principles.md +0 -235
- package/.agents/skills/interface-design/references/validation.md +0 -48
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { Automation } from './automation.js';
|
|
5
|
+
import { scheduleNext, cancel } from './scheduler.js';
|
|
6
|
+
import { getLogger } from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
const DATA_DIR = join(homedir(), '.kernelbot');
|
|
9
|
+
const DATA_FILE = join(DATA_DIR, 'automations.json');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MAX_PER_CHAT = 10;
|
|
12
|
+
const DEFAULT_MIN_INTERVAL = 5; // minutes
|
|
13
|
+
|
|
14
|
+
export class AutomationManager {
|
|
15
|
+
constructor() {
|
|
16
|
+
/** @type {Map<string, Automation>} id → Automation */
|
|
17
|
+
this.automations = new Map();
|
|
18
|
+
/** @type {Map<string, any>} id → timer ID */
|
|
19
|
+
this.timers = new Map();
|
|
20
|
+
/** @type {Map<string, Promise>} chatId → execution chain (serialize per chat) */
|
|
21
|
+
this._chatLocks = new Map();
|
|
22
|
+
|
|
23
|
+
// Injected via init()
|
|
24
|
+
this._sendMessage = null;
|
|
25
|
+
this._sendChatAction = null;
|
|
26
|
+
this._agentFactory = null;
|
|
27
|
+
this._config = null;
|
|
28
|
+
|
|
29
|
+
this._load();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Initialize with bot context. Called after bot is created.
|
|
34
|
+
* @param {object} opts
|
|
35
|
+
* @param {Function} opts.sendMessage - (chatId, text, opts?) => Promise
|
|
36
|
+
* @param {Function} opts.sendChatAction - (chatId, action) => Promise
|
|
37
|
+
* @param {Function} opts.agentFactory - (chatId) => { agent, onUpdate, sendPhoto }
|
|
38
|
+
* @param {object} opts.config
|
|
39
|
+
*/
|
|
40
|
+
init({ sendMessage, sendChatAction, agentFactory, config }) {
|
|
41
|
+
this._sendMessage = sendMessage;
|
|
42
|
+
this._sendChatAction = sendChatAction;
|
|
43
|
+
this._agentFactory = agentFactory;
|
|
44
|
+
this._config = config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Arm all enabled automations (called on startup). */
|
|
48
|
+
startAll() {
|
|
49
|
+
const logger = getLogger();
|
|
50
|
+
let armed = 0;
|
|
51
|
+
for (const auto of this.automations.values()) {
|
|
52
|
+
if (auto.enabled) {
|
|
53
|
+
this._arm(auto);
|
|
54
|
+
armed++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (armed > 0) {
|
|
58
|
+
logger.info(`[AutomationManager] Armed ${armed} automation(s) on startup`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Cancel all timers (called on shutdown). */
|
|
63
|
+
shutdown() {
|
|
64
|
+
for (const timerId of this.timers.values()) {
|
|
65
|
+
cancel(timerId);
|
|
66
|
+
}
|
|
67
|
+
this.timers.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── CRUD ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a new automation.
|
|
74
|
+
* @param {string} chatId
|
|
75
|
+
* @param {object} data - { name, description, schedule }
|
|
76
|
+
* @returns {Automation}
|
|
77
|
+
*/
|
|
78
|
+
create(chatId, data) {
|
|
79
|
+
const logger = getLogger();
|
|
80
|
+
|
|
81
|
+
// Enforce limits
|
|
82
|
+
const chatAutos = this.listForChat(chatId);
|
|
83
|
+
const maxPerChat = this._config?.automation?.max_per_chat || DEFAULT_MAX_PER_CHAT;
|
|
84
|
+
if (chatAutos.length >= maxPerChat) {
|
|
85
|
+
throw new Error(`Maximum automations per chat (${maxPerChat}) reached.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._validateSchedule(data.schedule);
|
|
89
|
+
|
|
90
|
+
const auto = new Automation({
|
|
91
|
+
chatId,
|
|
92
|
+
name: data.name,
|
|
93
|
+
description: data.description,
|
|
94
|
+
schedule: data.schedule,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this.automations.set(auto.id, auto);
|
|
98
|
+
this._arm(auto);
|
|
99
|
+
this._save();
|
|
100
|
+
|
|
101
|
+
logger.info(`[AutomationManager] Created automation ${auto.id} "${auto.name}" for chat ${chatId}`);
|
|
102
|
+
return auto;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** List all automations for a chat. */
|
|
106
|
+
listForChat(chatId) {
|
|
107
|
+
const id = String(chatId);
|
|
108
|
+
return [...this.automations.values()].filter((a) => a.chatId === id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** List all automations across all chats. */
|
|
112
|
+
listAll() {
|
|
113
|
+
return [...this.automations.values()];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Get a single automation by ID. */
|
|
117
|
+
get(id) {
|
|
118
|
+
return this.automations.get(id) || null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Update an automation.
|
|
123
|
+
* @param {string} id
|
|
124
|
+
* @param {object} changes - partial fields to update
|
|
125
|
+
* @returns {Automation|null}
|
|
126
|
+
*/
|
|
127
|
+
update(id, changes) {
|
|
128
|
+
const logger = getLogger();
|
|
129
|
+
const auto = this.automations.get(id);
|
|
130
|
+
if (!auto) return null;
|
|
131
|
+
|
|
132
|
+
if (changes.name !== undefined) auto.name = changes.name;
|
|
133
|
+
if (changes.description !== undefined) auto.description = changes.description;
|
|
134
|
+
|
|
135
|
+
if (changes.schedule !== undefined) {
|
|
136
|
+
this._validateSchedule(changes.schedule);
|
|
137
|
+
auto.schedule = changes.schedule;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (changes.enabled !== undefined) {
|
|
141
|
+
auto.enabled = changes.enabled;
|
|
142
|
+
if (auto.enabled) {
|
|
143
|
+
this._arm(auto);
|
|
144
|
+
} else {
|
|
145
|
+
this._disarm(auto);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Re-arm if schedule changed while enabled
|
|
150
|
+
if (changes.schedule !== undefined && auto.enabled) {
|
|
151
|
+
this._arm(auto);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this._save();
|
|
155
|
+
logger.info(`[AutomationManager] Updated automation ${id}: ${JSON.stringify(changes)}`);
|
|
156
|
+
return auto;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Delete an automation.
|
|
161
|
+
* @param {string} id
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
delete(id) {
|
|
165
|
+
const logger = getLogger();
|
|
166
|
+
const auto = this.automations.get(id);
|
|
167
|
+
if (!auto) return false;
|
|
168
|
+
|
|
169
|
+
this._disarm(auto);
|
|
170
|
+
this.automations.delete(id);
|
|
171
|
+
this._save();
|
|
172
|
+
|
|
173
|
+
logger.info(`[AutomationManager] Deleted automation ${id} "${auto.name}"`);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Trigger an automation immediately (manual run).
|
|
179
|
+
* @param {string} id
|
|
180
|
+
*/
|
|
181
|
+
async runNow(id) {
|
|
182
|
+
const auto = this.automations.get(id);
|
|
183
|
+
if (!auto) throw new Error(`Automation ${id} not found.`);
|
|
184
|
+
await this._executeAutomation(auto);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Timer management ──────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/** Arm (or re-arm) a timer for an automation. */
|
|
190
|
+
_arm(auto) {
|
|
191
|
+
const logger = getLogger();
|
|
192
|
+
|
|
193
|
+
// Cancel existing timer if any
|
|
194
|
+
this._disarm(auto);
|
|
195
|
+
|
|
196
|
+
const { timerId, nextRun } = scheduleNext(auto, () => {
|
|
197
|
+
this._onTimerFire(auto);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
auto.nextRun = nextRun;
|
|
201
|
+
this.timers.set(auto.id, timerId);
|
|
202
|
+
|
|
203
|
+
logger.debug(`[AutomationManager] Armed ${auto.id} "${auto.name}" — next: ${new Date(nextRun).toLocaleString()}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Cancel an automation's timer. */
|
|
207
|
+
_disarm(auto) {
|
|
208
|
+
const timerId = this.timers.get(auto.id);
|
|
209
|
+
if (timerId != null) {
|
|
210
|
+
cancel(timerId);
|
|
211
|
+
this.timers.delete(auto.id);
|
|
212
|
+
auto.nextRun = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Called when a timer fires. */
|
|
217
|
+
_onTimerFire(auto) {
|
|
218
|
+
const logger = getLogger();
|
|
219
|
+
|
|
220
|
+
// Guard: automation may have been deleted or disabled while timer was pending
|
|
221
|
+
const current = this.automations.get(auto.id);
|
|
222
|
+
if (!current || !current.enabled) {
|
|
223
|
+
logger.debug(`[AutomationManager] Timer fired for ${auto.id} but automation is disabled/deleted — skipping`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Serialize execution per chat to prevent conversation history corruption
|
|
228
|
+
this._enqueueExecution(current);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Enqueue automation execution into per-chat chain. */
|
|
232
|
+
_enqueueExecution(auto) {
|
|
233
|
+
const chatId = auto.chatId;
|
|
234
|
+
const prev = this._chatLocks.get(chatId) || Promise.resolve();
|
|
235
|
+
const next = prev.then(() => this._executeAutomation(auto)).catch(() => {});
|
|
236
|
+
this._chatLocks.set(chatId, next);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Execute an automation run. */
|
|
240
|
+
async _executeAutomation(auto) {
|
|
241
|
+
const logger = getLogger();
|
|
242
|
+
|
|
243
|
+
// Convert chatId back to number to match Telegram's format
|
|
244
|
+
// (Automation stores as string for JSON safety, but conversationManager uses number keys)
|
|
245
|
+
const chatId = Number(auto.chatId);
|
|
246
|
+
|
|
247
|
+
logger.info(`[AutomationManager] Executing automation ${auto.id} "${auto.name}" for chat ${chatId}`);
|
|
248
|
+
|
|
249
|
+
// Update run stats
|
|
250
|
+
auto.lastRun = Date.now();
|
|
251
|
+
auto.runCount++;
|
|
252
|
+
this._save();
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
// Notify user
|
|
256
|
+
if (this._sendChatAction) {
|
|
257
|
+
await this._sendChatAction(chatId, 'typing').catch(() => {});
|
|
258
|
+
}
|
|
259
|
+
if (this._sendMessage) {
|
|
260
|
+
await this._sendMessage(chatId, `⏰ Running: **${auto.name}**...`).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Get agent context for this chat
|
|
264
|
+
if (!this._agentFactory) {
|
|
265
|
+
throw new Error('AutomationManager not initialized — no agentFactory');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { agent, onUpdate, sendPhoto } = this._agentFactory(chatId);
|
|
269
|
+
const prompt = `[AUTOMATION: ${auto.name}] ${auto.description}`;
|
|
270
|
+
|
|
271
|
+
// Run through the orchestrator like a normal user message
|
|
272
|
+
const reply = await agent.processMessage(
|
|
273
|
+
chatId,
|
|
274
|
+
prompt,
|
|
275
|
+
{ id: 'automation', username: 'automation' },
|
|
276
|
+
onUpdate,
|
|
277
|
+
sendPhoto,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Send the response
|
|
281
|
+
if (reply && this._sendMessage) {
|
|
282
|
+
await this._sendMessage(chatId, reply).catch(() => {});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
auto.lastError = null;
|
|
286
|
+
logger.info(`[AutomationManager] Automation ${auto.id} completed — reply: "${(reply || '').slice(0, 150)}"`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
auto.lastError = err.message;
|
|
289
|
+
logger.error(`[AutomationManager] Automation ${auto.id} failed: ${err.message}`);
|
|
290
|
+
|
|
291
|
+
if (this._sendMessage) {
|
|
292
|
+
await this._sendMessage(
|
|
293
|
+
chatId,
|
|
294
|
+
`⚠️ Automation **${auto.name}** failed: ${err.message}`,
|
|
295
|
+
).catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Re-arm for next execution (regardless of success/failure)
|
|
300
|
+
const current = this.automations.get(auto.id);
|
|
301
|
+
if (current && current.enabled) {
|
|
302
|
+
this._arm(current);
|
|
303
|
+
this._save();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Validation ────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
_validateSchedule(schedule) {
|
|
310
|
+
if (!schedule || !schedule.type) {
|
|
311
|
+
throw new Error('Schedule must have a type (cron, interval, or random).');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const minInterval = this._config?.automation?.min_interval_minutes || DEFAULT_MIN_INTERVAL;
|
|
315
|
+
|
|
316
|
+
switch (schedule.type) {
|
|
317
|
+
case 'cron':
|
|
318
|
+
if (!schedule.expression) {
|
|
319
|
+
throw new Error('Cron schedule requires an expression field.');
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
|
|
323
|
+
case 'interval':
|
|
324
|
+
if (!schedule.minutes || schedule.minutes < minInterval) {
|
|
325
|
+
throw new Error(`Interval must be at least ${minInterval} minutes.`);
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
|
|
329
|
+
case 'random':
|
|
330
|
+
if (!schedule.minMinutes || !schedule.maxMinutes) {
|
|
331
|
+
throw new Error('Random schedule requires minMinutes and maxMinutes.');
|
|
332
|
+
}
|
|
333
|
+
if (schedule.minMinutes < minInterval) {
|
|
334
|
+
throw new Error(`Minimum random interval must be at least ${minInterval} minutes.`);
|
|
335
|
+
}
|
|
336
|
+
if (schedule.maxMinutes <= schedule.minMinutes) {
|
|
337
|
+
throw new Error('maxMinutes must be greater than minMinutes.');
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
default:
|
|
342
|
+
throw new Error(`Unknown schedule type: ${schedule.type}. Use cron, interval, or random.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Persistence ───────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
_load() {
|
|
349
|
+
const logger = getLogger();
|
|
350
|
+
try {
|
|
351
|
+
if (existsSync(DATA_FILE)) {
|
|
352
|
+
const raw = readFileSync(DATA_FILE, 'utf-8');
|
|
353
|
+
const data = JSON.parse(raw);
|
|
354
|
+
for (const item of data) {
|
|
355
|
+
const auto = Automation.fromJSON(item);
|
|
356
|
+
this.automations.set(auto.id, auto);
|
|
357
|
+
}
|
|
358
|
+
logger.info(`[AutomationManager] Loaded ${this.automations.size} automation(s) from disk`);
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
logger.error(`[AutomationManager] Failed to load automations: ${err.message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
_save() {
|
|
366
|
+
try {
|
|
367
|
+
if (!existsSync(DATA_DIR)) {
|
|
368
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
369
|
+
}
|
|
370
|
+
const data = [...this.automations.values()].map((a) => a.toJSON());
|
|
371
|
+
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const logger = getLogger();
|
|
374
|
+
logger.error(`[AutomationManager] Failed to save automations: ${err.message}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single recurring automation — a scheduled task that the orchestrator runs.
|
|
5
|
+
*/
|
|
6
|
+
export class Automation {
|
|
7
|
+
constructor({ chatId, name, description, schedule }) {
|
|
8
|
+
this.id = randomBytes(4).toString('hex');
|
|
9
|
+
this.chatId = String(chatId);
|
|
10
|
+
this.name = name;
|
|
11
|
+
this.description = description; // the task prompt
|
|
12
|
+
this.schedule = schedule; // { type, expression?, minutes?, minMinutes?, maxMinutes? }
|
|
13
|
+
this.enabled = true;
|
|
14
|
+
this.lastRun = null;
|
|
15
|
+
this.nextRun = null;
|
|
16
|
+
this.runCount = 0;
|
|
17
|
+
this.lastError = null;
|
|
18
|
+
this.createdAt = Date.now();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Human-readable one-line summary. */
|
|
22
|
+
toSummary() {
|
|
23
|
+
const status = this.enabled ? '🟢' : '⏸️';
|
|
24
|
+
const scheduleStr = formatSchedule(this.schedule);
|
|
25
|
+
const nextStr = this.nextRun
|
|
26
|
+
? `next: ${new Date(this.nextRun).toLocaleString()}`
|
|
27
|
+
: 'not scheduled';
|
|
28
|
+
const runs = this.runCount > 0 ? ` | ${this.runCount} runs` : '';
|
|
29
|
+
return `${status} \`${this.id}\` **${this.name}** — ${scheduleStr} (${nextStr}${runs})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Serialize for persistence. */
|
|
33
|
+
toJSON() {
|
|
34
|
+
return {
|
|
35
|
+
id: this.id,
|
|
36
|
+
chatId: this.chatId,
|
|
37
|
+
name: this.name,
|
|
38
|
+
description: this.description,
|
|
39
|
+
schedule: this.schedule,
|
|
40
|
+
enabled: this.enabled,
|
|
41
|
+
lastRun: this.lastRun,
|
|
42
|
+
nextRun: this.nextRun,
|
|
43
|
+
runCount: this.runCount,
|
|
44
|
+
lastError: this.lastError,
|
|
45
|
+
createdAt: this.createdAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Deserialize from persistence. */
|
|
50
|
+
static fromJSON(data) {
|
|
51
|
+
const auto = Object.create(Automation.prototype);
|
|
52
|
+
auto.id = data.id;
|
|
53
|
+
auto.chatId = String(data.chatId);
|
|
54
|
+
auto.name = data.name;
|
|
55
|
+
auto.description = data.description;
|
|
56
|
+
auto.schedule = data.schedule;
|
|
57
|
+
auto.enabled = data.enabled;
|
|
58
|
+
auto.lastRun = data.lastRun;
|
|
59
|
+
auto.nextRun = data.nextRun;
|
|
60
|
+
auto.runCount = data.runCount;
|
|
61
|
+
auto.lastError = data.lastError;
|
|
62
|
+
auto.createdAt = data.createdAt;
|
|
63
|
+
return auto;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Format a schedule object for display. */
|
|
68
|
+
function formatSchedule(schedule) {
|
|
69
|
+
switch (schedule.type) {
|
|
70
|
+
case 'cron':
|
|
71
|
+
return `cron: \`${schedule.expression}\``;
|
|
72
|
+
case 'interval':
|
|
73
|
+
return `every ${schedule.minutes}m`;
|
|
74
|
+
case 'random':
|
|
75
|
+
return `random ${schedule.minMinutes}–${schedule.maxMinutes}m`;
|
|
76
|
+
default:
|
|
77
|
+
return schedule.type;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure timer engine for automations — cron, interval, and random schedules.
|
|
3
|
+
* Uses setTimeout (no new dependencies).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calculate the next fire time for an automation.
|
|
8
|
+
* @param {object} schedule - { type, expression?, minutes?, minMinutes?, maxMinutes? }
|
|
9
|
+
* @param {number|null} lastRun - timestamp of last execution
|
|
10
|
+
* @returns {number} next fire timestamp
|
|
11
|
+
*/
|
|
12
|
+
export function getNextFireTime(schedule, lastRun) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
|
|
15
|
+
switch (schedule.type) {
|
|
16
|
+
case 'cron':
|
|
17
|
+
return nextCronTime(schedule.expression, now);
|
|
18
|
+
|
|
19
|
+
case 'interval': {
|
|
20
|
+
const intervalMs = schedule.minutes * 60 * 1000;
|
|
21
|
+
if (lastRun) {
|
|
22
|
+
const next = lastRun + intervalMs;
|
|
23
|
+
return next > now ? next : now + 1000; // if overdue, fire soon
|
|
24
|
+
}
|
|
25
|
+
return now + intervalMs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case 'random': {
|
|
29
|
+
const minMs = schedule.minMinutes * 60 * 1000;
|
|
30
|
+
const maxMs = schedule.maxMinutes * 60 * 1000;
|
|
31
|
+
const delayMs = minMs + Math.random() * (maxMs - minMs);
|
|
32
|
+
return now + delayMs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
default:
|
|
36
|
+
throw new Error(`Unknown schedule type: ${schedule.type}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Schedule a timer for an automation. Returns the timer ID.
|
|
42
|
+
* @param {object} automation - Automation instance
|
|
43
|
+
* @param {Function} callback - called when timer fires
|
|
44
|
+
* @returns {{ timerId: any, nextRun: number }}
|
|
45
|
+
*/
|
|
46
|
+
export function scheduleNext(automation, callback) {
|
|
47
|
+
const nextRun = getNextFireTime(automation.schedule, automation.lastRun);
|
|
48
|
+
const delay = Math.max(nextRun - Date.now(), 1000); // at least 1s
|
|
49
|
+
|
|
50
|
+
const timerId = setTimeout(callback, delay);
|
|
51
|
+
return { timerId, nextRun };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cancel a scheduled timer.
|
|
56
|
+
* @param {any} timerId
|
|
57
|
+
*/
|
|
58
|
+
export function cancel(timerId) {
|
|
59
|
+
if (timerId != null) clearTimeout(timerId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Cron parser ────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse a 5-field cron expression and compute the next occurrence after `after`.
|
|
66
|
+
* Fields: minute hour dayOfMonth month dayOfWeek
|
|
67
|
+
* Supports: numbers, ranges (1-5), steps (star/5), lists (1,3,5), star
|
|
68
|
+
*/
|
|
69
|
+
function nextCronTime(expression, after) {
|
|
70
|
+
const fields = expression.trim().split(/\s+/);
|
|
71
|
+
if (fields.length !== 5) {
|
|
72
|
+
throw new Error(`Invalid cron expression (need 5 fields): ${expression}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const [minuteF, hourF, domF, monthF, dowF] = fields;
|
|
76
|
+
const minutes = parseField(minuteF, 0, 59);
|
|
77
|
+
const hours = parseField(hourF, 0, 23);
|
|
78
|
+
const doms = parseField(domF, 1, 31);
|
|
79
|
+
const months = parseField(monthF, 1, 12);
|
|
80
|
+
const dows = parseField(dowF, 0, 6); // 0=Sunday
|
|
81
|
+
|
|
82
|
+
const start = new Date(after + 60_000); // start from next minute
|
|
83
|
+
start.setSeconds(0, 0);
|
|
84
|
+
|
|
85
|
+
// Search up to 366 days ahead
|
|
86
|
+
const limit = 366 * 24 * 60;
|
|
87
|
+
for (let i = 0; i < limit; i++) {
|
|
88
|
+
const candidate = new Date(start.getTime() + i * 60_000);
|
|
89
|
+
const m = candidate.getMinutes();
|
|
90
|
+
const h = candidate.getHours();
|
|
91
|
+
const dom = candidate.getDate();
|
|
92
|
+
const mon = candidate.getMonth() + 1; // 1-based
|
|
93
|
+
const dow = candidate.getDay();
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
minutes.has(m) &&
|
|
97
|
+
hours.has(h) &&
|
|
98
|
+
doms.has(dom) &&
|
|
99
|
+
months.has(mon) &&
|
|
100
|
+
dows.has(dow)
|
|
101
|
+
) {
|
|
102
|
+
return candidate.getTime();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fallback: 24h from now
|
|
107
|
+
return after + 24 * 60 * 60 * 1000;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse a single cron field into a Set of valid values.
|
|
112
|
+
*/
|
|
113
|
+
function parseField(field, min, max) {
|
|
114
|
+
const values = new Set();
|
|
115
|
+
|
|
116
|
+
for (const part of field.split(',')) {
|
|
117
|
+
if (part === '*') {
|
|
118
|
+
for (let i = min; i <= max; i++) values.add(i);
|
|
119
|
+
} else if (part.includes('/')) {
|
|
120
|
+
const [range, stepStr] = part.split('/');
|
|
121
|
+
const step = parseInt(stepStr, 10);
|
|
122
|
+
let start = min;
|
|
123
|
+
let end = max;
|
|
124
|
+
if (range !== '*') {
|
|
125
|
+
if (range.includes('-')) {
|
|
126
|
+
[start, end] = range.split('-').map(Number);
|
|
127
|
+
} else {
|
|
128
|
+
start = parseInt(range, 10);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (let i = start; i <= end; i += step) values.add(i);
|
|
132
|
+
} else if (part.includes('-')) {
|
|
133
|
+
const [s, e] = part.split('-').map(Number);
|
|
134
|
+
for (let i = s; i <= e; i++) values.add(i);
|
|
135
|
+
} else {
|
|
136
|
+
values.add(parseInt(part, 10));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return values;
|
|
141
|
+
}
|