lazy-gravity 0.11.0 β 0.12.0
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/README.md +7 -0
- package/dist/bot/index.js +335 -2
- package/dist/commands/registerSlashCommands.js +62 -0
- package/dist/database/scheduleRepository.js +50 -6
- package/dist/events/interactionCreateHandler.js +5 -1
- package/dist/events/messageCreateHandler.js +10 -24
- package/dist/services/heartbeatService.js +261 -0
- package/dist/services/scheduleService.js +101 -4
- package/dist/utils/configLoader.js +8 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -90,12 +90,19 @@ Just type in any bound channel:
|
|
|
90
90
|
- `π /template list` β Display registered templates with execute buttons
|
|
91
91
|
- `π /template add <name> <prompt>` β Register a new prompt template
|
|
92
92
|
- `π /template delete <name>` β Delete a template
|
|
93
|
+
- `π
/schedule list` β Show all scheduled tasks with next localized run times
|
|
94
|
+
- `π
/schedule add <cron> <prompt>` β Register a recurring task for the current channel's bound project
|
|
95
|
+
- `π
/schedule remove <id>` β Delete a scheduled task by ID
|
|
96
|
+
- `π
/schedule clear` β Remove all scheduled tasks and reset the task ID counter
|
|
97
|
+
- `π
/schedule backup` β Export all scheduled tasks as a JSON file attachment
|
|
98
|
+
- `π
/schedule restore <file>` β Restore scheduled tasks from a JSON file attachment
|
|
93
99
|
- `π /join` β Join an existing Antigravity session (shows up to 20 recent sessions)
|
|
94
100
|
- `π /mirror` β Toggle PCβDiscord message mirroring for the current session
|
|
95
101
|
- `π /stop` β Force-stop a running Antigravity task
|
|
96
102
|
- `π /shutdown` β Shut down the IDE while keeping the active CDP project connections and session bindings
|
|
97
103
|
- `πΈ /screenshot` β Capture and send Antigravity's current screen
|
|
98
104
|
- `π§ /status` β Show bot connection status, current mode, and active project
|
|
105
|
+
- `π /heartbeat [on|off|status]` β Configure periodic bot heartbeat notifications
|
|
99
106
|
- `β
/autoaccept [on|off|status]` β Toggle auto-approval of file edit dialogs
|
|
100
107
|
- `π /output [embed|plain]` β Toggle output format between Embed and Plain Text (plain text is easier to copy on mobile)
|
|
101
108
|
- `π /logs [lines] [level]` β View recent bot logs (ephemeral)
|
package/dist/bot/index.js
CHANGED
|
@@ -62,6 +62,10 @@ const workspaceBindingRepository_1 = require("../database/workspaceBindingReposi
|
|
|
62
62
|
const channelPreferenceRepository_1 = require("../database/channelPreferenceRepository");
|
|
63
63
|
const chatSessionRepository_1 = require("../database/chatSessionRepository");
|
|
64
64
|
const artifactThreadRepository_1 = require("../database/artifactThreadRepository");
|
|
65
|
+
const scheduleRepository_1 = require("../database/scheduleRepository");
|
|
66
|
+
const scheduleService_1 = require("../services/scheduleService");
|
|
67
|
+
const cron_parser_1 = __importDefault(require("cron-parser"));
|
|
68
|
+
const workspaceQueue_1 = require("./workspaceQueue");
|
|
65
69
|
const workspaceService_1 = require("../services/workspaceService");
|
|
66
70
|
const workspaceCommandHandler_1 = require("../commands/workspaceCommandHandler");
|
|
67
71
|
const chatCommandHandler_1 = require("../commands/chatCommandHandler");
|
|
@@ -108,6 +112,7 @@ const buttonHandler_1 = require("../handlers/buttonHandler");
|
|
|
108
112
|
const selectHandler_1 = require("../handlers/selectHandler");
|
|
109
113
|
const approvalButtonAction_1 = require("../handlers/approvalButtonAction");
|
|
110
114
|
const planningButtonAction_1 = require("../handlers/planningButtonAction");
|
|
115
|
+
const heartbeatService_1 = require("../services/heartbeatService");
|
|
111
116
|
const errorPopupButtonAction_1 = require("../handlers/errorPopupButtonAction");
|
|
112
117
|
const runCommandButtonAction_1 = require("../handlers/runCommandButtonAction");
|
|
113
118
|
const modelButtonAction_1 = require("../handlers/modelButtonAction");
|
|
@@ -1100,9 +1105,13 @@ const startBot = async (cliLogLevel) => {
|
|
|
1100
1105
|
const workspaceBindingRepo = new workspaceBindingRepository_1.WorkspaceBindingRepository(db);
|
|
1101
1106
|
const chatSessionRepo = new chatSessionRepository_1.ChatSessionRepository(db);
|
|
1102
1107
|
const artifactThreadRepo = new artifactThreadRepository_1.ArtifactThreadRepository(db);
|
|
1108
|
+
const scheduleRepo = new scheduleRepository_1.ScheduleRepository(db);
|
|
1109
|
+
const scheduleService = new scheduleService_1.ScheduleService(scheduleRepo);
|
|
1110
|
+
const workspaceQueue = new workspaceQueue_1.WorkspaceQueue();
|
|
1103
1111
|
const artifactService = new artifactService_1.ArtifactService();
|
|
1104
1112
|
const workspaceService = new workspaceService_1.WorkspaceService(config.workspaceBaseDir);
|
|
1105
1113
|
const channelManager = new channelManager_1.ChannelManager();
|
|
1114
|
+
const heartbeatService = new heartbeatService_1.HeartbeatService();
|
|
1106
1115
|
// Auto-launch Antigravity with CDP port if not already running
|
|
1107
1116
|
await (0, antigravityLauncher_1.ensureAntigravityRunning)();
|
|
1108
1117
|
// Initialize CDP bridge (lazy connection: pool creation only)
|
|
@@ -1217,6 +1226,8 @@ const startBot = async (cliLogLevel) => {
|
|
|
1217
1226
|
}));
|
|
1218
1227
|
client.once(discord_js_1.Events.ClientReady, async (readyClient) => {
|
|
1219
1228
|
logger_1.logger.info(`Ready! Logged in as ${readyClient.user.tag} | extractionMode=${config.extractionMode}`);
|
|
1229
|
+
heartbeatService.init(readyClient, bridge);
|
|
1230
|
+
heartbeatService.start();
|
|
1220
1231
|
try {
|
|
1221
1232
|
await (0, registerSlashCommands_1.registerSlashCommands)(discordToken, discordClientId, config.guildId);
|
|
1222
1233
|
}
|
|
@@ -1257,6 +1268,74 @@ const startBot = async (cliLogLevel) => {
|
|
|
1257
1268
|
catch (error) {
|
|
1258
1269
|
logger_1.logger.warn('Failed to send startup dashboard embed:', error);
|
|
1259
1270
|
}
|
|
1271
|
+
// Restore scheduled tasks
|
|
1272
|
+
const scheduleJobCallback = async (schedule) => {
|
|
1273
|
+
logger_1.logger.info(`[Schedule] Trigger callback running for task ${schedule.id}. Workspace: ${schedule.workspacePath}, promptLength: ${schedule.prompt.length}`);
|
|
1274
|
+
try {
|
|
1275
|
+
let channelId = schedule.channelId;
|
|
1276
|
+
const isWindows = process.platform === 'win32';
|
|
1277
|
+
const bindings = workspaceBindingRepo.findAll().filter(b => {
|
|
1278
|
+
const absPath = workspaceService.getWorkspacePath(b.workspacePath);
|
|
1279
|
+
return isWindows
|
|
1280
|
+
? absPath.toLowerCase() === schedule.workspacePath.toLowerCase()
|
|
1281
|
+
: absPath === schedule.workspacePath;
|
|
1282
|
+
});
|
|
1283
|
+
if (!channelId || !bindings.some(b => b.channelId === channelId)) {
|
|
1284
|
+
channelId = bindings[0]?.channelId;
|
|
1285
|
+
}
|
|
1286
|
+
if (!channelId) {
|
|
1287
|
+
logger_1.logger.warn(`[Schedule] No channel bound to workspace ${schedule.workspacePath}. Skipping task ${schedule.id}.`);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const channel = await readyClient.channels.fetch(channelId).catch(() => null);
|
|
1291
|
+
if (channel && channel.isTextBased() && 'send' in channel) {
|
|
1292
|
+
logger_1.logger.info(`[Schedule] Sending trigger notification to channel ${channelId} for task ${schedule.id}`);
|
|
1293
|
+
const message = await channel.send({
|
|
1294
|
+
content: 'β° **Scheduled Task Triggered**',
|
|
1295
|
+
allowedMentions: { parse: [] }
|
|
1296
|
+
});
|
|
1297
|
+
const projectLabel = bridge.pool.extractProjectName(schedule.workspacePath);
|
|
1298
|
+
workspaceQueue.incrementDepth(schedule.workspacePath);
|
|
1299
|
+
await workspaceQueue.enqueue(schedule.workspacePath, async () => {
|
|
1300
|
+
try {
|
|
1301
|
+
logger_1.logger.info(`[Schedule] Dispatching prompt to promptDispatcher for task ${schedule.id}`);
|
|
1302
|
+
const preferredAccount = bridge.pool.getPreferredAccountForWorkspace(schedule.workspacePath)
|
|
1303
|
+
|| (config.antigravityAccounts?.[0]?.name ?? 'default');
|
|
1304
|
+
const cdp = await bridge.pool.getOrConnect(schedule.workspacePath, { name: preferredAccount });
|
|
1305
|
+
await promptDispatcher.send({
|
|
1306
|
+
message: message,
|
|
1307
|
+
prompt: schedule.prompt,
|
|
1308
|
+
cdp,
|
|
1309
|
+
inboundImages: [],
|
|
1310
|
+
options: {
|
|
1311
|
+
chatSessionService,
|
|
1312
|
+
chatSessionRepo,
|
|
1313
|
+
channelManager,
|
|
1314
|
+
titleGenerator,
|
|
1315
|
+
userPrefRepo,
|
|
1316
|
+
artifactService,
|
|
1317
|
+
extractionMode: config.extractionMode,
|
|
1318
|
+
},
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
finally {
|
|
1322
|
+
const remainingDepth = workspaceQueue.decrementDepth(schedule.workspacePath);
|
|
1323
|
+
if (remainingDepth > 0) {
|
|
1324
|
+
logger_1.logger.info(`[Queue:${projectLabel}] Task done, ${remainingDepth} remaining`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
logger_1.logger.warn(`[Schedule] Channel ${channelId} not found or not text-based for task ${schedule.id}.`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
catch (error) {
|
|
1334
|
+
logger_1.logger.error(`[Schedule] Failed to execute task ${schedule.id}:`, error);
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
const restoredCount = scheduleService.restoreAll(scheduleJobCallback);
|
|
1338
|
+
logger_1.logger.info(`[Schedule] Restored ${restoredCount} scheduled tasks.`);
|
|
1260
1339
|
});
|
|
1261
1340
|
// [Discord Interactions API] Slash command interaction handler
|
|
1262
1341
|
client.on(discord_js_1.Events.InteractionCreate, (0, interactionCreateHandler_1.createInteractionCreateHandler)({
|
|
@@ -1290,7 +1369,9 @@ const startBot = async (cliLogLevel) => {
|
|
|
1290
1369
|
channelManager,
|
|
1291
1370
|
titleGenerator,
|
|
1292
1371
|
antigravityAccounts: config.antigravityAccounts,
|
|
1293
|
-
|
|
1372
|
+
heartbeatService,
|
|
1373
|
+
scheduleService,
|
|
1374
|
+
handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, scheduleServiceArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, artifactService, scheduleServiceArg, heartbeatService),
|
|
1294
1375
|
handleTemplateUse: async (interaction, templateId) => {
|
|
1295
1376
|
const template = templateRepo.findById(templateId);
|
|
1296
1377
|
if (!template) {
|
|
@@ -1412,6 +1493,8 @@ const startBot = async (cliLogLevel) => {
|
|
|
1412
1493
|
accountPrefRepo,
|
|
1413
1494
|
channelPrefRepo,
|
|
1414
1495
|
antigravityAccounts: config.antigravityAccounts,
|
|
1496
|
+
heartbeatService,
|
|
1497
|
+
workspaceQueue,
|
|
1415
1498
|
}));
|
|
1416
1499
|
await client.login(discordToken);
|
|
1417
1500
|
} // end: else (credentials present)
|
|
@@ -1682,10 +1765,22 @@ async function autoRenameChannel(message, chatSessionRepo, titleGenerator, chann
|
|
|
1682
1765
|
logger_1.logger.error('[AutoRename] Rename failed:', err);
|
|
1683
1766
|
}
|
|
1684
1767
|
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Utility to parse a cron expression and format the next run time.
|
|
1770
|
+
*/
|
|
1771
|
+
function formatNextRunTime(cronExpression) {
|
|
1772
|
+
try {
|
|
1773
|
+
const interval = cron_parser_1.default.parse(cronExpression);
|
|
1774
|
+
return interval.next().toDate().toLocaleString();
|
|
1775
|
+
}
|
|
1776
|
+
catch (err) {
|
|
1777
|
+
return 'Invalid Cron';
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1685
1780
|
/**
|
|
1686
1781
|
* Handle Discord Interactions API slash commands
|
|
1687
1782
|
*/
|
|
1688
|
-
async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService, scheduleService) {
|
|
1783
|
+
async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService, scheduleService, heartbeatService) {
|
|
1689
1784
|
const commandName = interaction.commandName;
|
|
1690
1785
|
const getAccountPort = (accountName) => {
|
|
1691
1786
|
const match = antigravityAccounts.find((account) => account.name === accountName);
|
|
@@ -2248,6 +2343,244 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
|
|
|
2248
2343
|
});
|
|
2249
2344
|
break;
|
|
2250
2345
|
}
|
|
2346
|
+
case 'heartbeat': {
|
|
2347
|
+
const subcommand = interaction.options.getSubcommand();
|
|
2348
|
+
if (!heartbeatService) {
|
|
2349
|
+
await interaction.editReply({ content: 'Heartbeat service not available.' });
|
|
2350
|
+
break;
|
|
2351
|
+
}
|
|
2352
|
+
const envOverrides = [];
|
|
2353
|
+
if (process.env.HEARTBEAT_ENABLED !== undefined)
|
|
2354
|
+
envOverrides.push('HEARTBEAT_ENABLED');
|
|
2355
|
+
if (process.env.HEARTBEAT_INTERVAL_MS !== undefined)
|
|
2356
|
+
envOverrides.push('HEARTBEAT_INTERVAL_MS');
|
|
2357
|
+
if (process.env.HEARTBEAT_CHANNEL_ID !== undefined)
|
|
2358
|
+
envOverrides.push('HEARTBEAT_CHANNEL_ID');
|
|
2359
|
+
let warningPrefix = '';
|
|
2360
|
+
if (envOverrides.length > 0) {
|
|
2361
|
+
warningPrefix = `β οΈ **Warning**: Environment override(s) active: ${envOverrides.join(', ')}. Changes saved to config.json may not take effect until overrides are removed.\n\n`;
|
|
2362
|
+
}
|
|
2363
|
+
if (subcommand === 'on') {
|
|
2364
|
+
const intervalStr = interaction.options.getString('interval') || '1h';
|
|
2365
|
+
const targetChannel = interaction.options.getChannel('channel') || interaction.channel;
|
|
2366
|
+
if (!targetChannel || typeof targetChannel.isTextBased !== 'function' || !targetChannel.isTextBased()) {
|
|
2367
|
+
await interaction.editReply({ content: 'β οΈ Please select a valid text channel.' });
|
|
2368
|
+
break;
|
|
2369
|
+
}
|
|
2370
|
+
// Check permissions
|
|
2371
|
+
const botUser = interaction.client.user;
|
|
2372
|
+
const permissions = targetChannel.permissionsFor?.(botUser);
|
|
2373
|
+
if (!permissions || !permissions.has('SendMessages') || !permissions.has('EmbedLinks')) {
|
|
2374
|
+
await interaction.editReply({ content: 'β οΈ Bot does not have permission to send messages and embed links in that channel.' });
|
|
2375
|
+
break;
|
|
2376
|
+
}
|
|
2377
|
+
const intervalMs = (0, heartbeatService_1.parseInterval)(intervalStr);
|
|
2378
|
+
if (intervalMs === null || intervalMs <= 0) {
|
|
2379
|
+
await interaction.editReply({ content: 'β οΈ Invalid interval format. Use a value with a unit, e.g. "1d", "1h", "30m" (bare numbers are not allowed).' });
|
|
2380
|
+
break;
|
|
2381
|
+
}
|
|
2382
|
+
if (intervalMs < 10000) {
|
|
2383
|
+
await interaction.editReply({ content: 'β οΈ Interval must be at least 10 seconds.' });
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
if (intervalMs > 2147483647) {
|
|
2387
|
+
await interaction.editReply({ content: 'β οΈ Interval cannot be greater than 24.8 days (2147483647 ms).' });
|
|
2388
|
+
break;
|
|
2389
|
+
}
|
|
2390
|
+
await heartbeatService.updateConfig(true, intervalMs, targetChannel.id);
|
|
2391
|
+
await interaction.editReply({
|
|
2392
|
+
content: `${warningPrefix}π Heartbeat enabled! Sending updates every **${intervalStr}** to channel <#${targetChannel.id}>.`
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
else if (subcommand === 'off') {
|
|
2396
|
+
await heartbeatService.disable();
|
|
2397
|
+
await interaction.editReply({ content: `${warningPrefix}π Heartbeat disabled.` });
|
|
2398
|
+
}
|
|
2399
|
+
else if (subcommand === 'status') {
|
|
2400
|
+
const config = (0, config_1.loadConfig)();
|
|
2401
|
+
const uptimeMs = Date.now() - heartbeatService.botStartTime;
|
|
2402
|
+
const uptimeStr = (0, heartbeatService_1.formatDuration)(uptimeMs);
|
|
2403
|
+
const lastActivityStr = (0, heartbeatService_1.formatRelativeTime)(heartbeatService.lastActivityTimestamp);
|
|
2404
|
+
const activeWorkspaces = bridge.pool.getActiveWorkspaceNames();
|
|
2405
|
+
const activeCount = activeWorkspaces.length;
|
|
2406
|
+
const activeList = activeCount > 0 ? activeWorkspaces.join(', ') : 'None';
|
|
2407
|
+
const intervalVal = config.heartbeatIntervalMs != null ? (0, heartbeatService_1.formatDuration)(config.heartbeatIntervalMs) : 'N/A';
|
|
2408
|
+
const statusEmbed = new discord_js_1.EmbedBuilder()
|
|
2409
|
+
.setTitle('π Heartbeat Status')
|
|
2410
|
+
.setColor(config.heartbeatEnabled ? 0x00CC88 : 0x888888)
|
|
2411
|
+
.addFields({ name: 'Enabled', value: config.heartbeatEnabled ? 'π’ Yes' : 'βͺ No', inline: true }, { name: 'Interval', value: config.heartbeatEnabled ? intervalVal : 'N/A', inline: true }, { name: 'Target Channel', value: config.heartbeatChannelId ? `<#${config.heartbeatChannelId}>` : 'N/A', inline: true }, { name: 'Active Sessions', value: `${activeCount} (${activeList})`, inline: true }, { name: 'Uptime', value: uptimeStr, inline: true }, { name: 'Last Activity', value: lastActivityStr, inline: true })
|
|
2412
|
+
.setTimestamp();
|
|
2413
|
+
await interaction.editReply({ embeds: [statusEmbed] });
|
|
2414
|
+
}
|
|
2415
|
+
break;
|
|
2416
|
+
}
|
|
2417
|
+
case 'schedule': {
|
|
2418
|
+
if (!scheduleService) {
|
|
2419
|
+
await interaction.editReply({ content: 'Schedule service not available.' });
|
|
2420
|
+
break;
|
|
2421
|
+
}
|
|
2422
|
+
const subcommand = interaction.options.getSubcommand();
|
|
2423
|
+
if (subcommand === 'list') {
|
|
2424
|
+
const schedules = scheduleService.listSchedules();
|
|
2425
|
+
if (schedules.length === 0) {
|
|
2426
|
+
await interaction.editReply({ content: 'No scheduled tasks found.' });
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2429
|
+
let formatted = '';
|
|
2430
|
+
let truncatedCount = 0;
|
|
2431
|
+
for (const s of schedules) {
|
|
2432
|
+
const nextRunStr = formatNextRunTime(s.cronExpression);
|
|
2433
|
+
const line = `**ID:** ${s.id} | **Cron:** \`${s.cronExpression}\` | **Next:** ${nextRunStr} | **Prompt:** ${s.prompt}\n`;
|
|
2434
|
+
if (formatted.length + line.length > 3900) {
|
|
2435
|
+
truncatedCount = schedules.length - schedules.indexOf(s);
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
formatted += line;
|
|
2439
|
+
}
|
|
2440
|
+
if (truncatedCount > 0) {
|
|
2441
|
+
formatted += `\n*...and ${truncatedCount} more task(s) (truncated due to Discord size limit).*`;
|
|
2442
|
+
}
|
|
2443
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
2444
|
+
.setTitle('π Scheduled Tasks')
|
|
2445
|
+
.setDescription(formatted)
|
|
2446
|
+
.setColor(0x00CC88);
|
|
2447
|
+
await interaction.editReply({ embeds: [embed] });
|
|
2448
|
+
break;
|
|
2449
|
+
}
|
|
2450
|
+
if (subcommand === 'add') {
|
|
2451
|
+
const cronExpr = interaction.options.getString('cron', true);
|
|
2452
|
+
const promptText = interaction.options.getString('prompt', true);
|
|
2453
|
+
const workspacePath = wsHandler.getWorkspaceForChannel(interaction.channelId);
|
|
2454
|
+
if (!workspacePath) {
|
|
2455
|
+
await interaction.editReply({ content: 'β οΈ This channel is not bound to a workspace. Please bind it first.' });
|
|
2456
|
+
break;
|
|
2457
|
+
}
|
|
2458
|
+
try {
|
|
2459
|
+
const jobCb = scheduleService.getJobCallback();
|
|
2460
|
+
if (!jobCb) {
|
|
2461
|
+
await interaction.editReply({ content: 'β οΈ Schedule service is still initializing. Please try again in a few seconds.' });
|
|
2462
|
+
break;
|
|
2463
|
+
}
|
|
2464
|
+
const record = scheduleService.addSchedule(cronExpr, promptText, workspacePath, interaction.channelId, jobCb);
|
|
2465
|
+
const nextRun = formatNextRunTime(cronExpr);
|
|
2466
|
+
const nextRunStr = nextRun !== 'Invalid Cron' ? ` (Next run: ${nextRun})` : '';
|
|
2467
|
+
await interaction.editReply({ content: `β
Scheduled task added! (ID: ${record.id})${nextRunStr}` });
|
|
2468
|
+
}
|
|
2469
|
+
catch (error) {
|
|
2470
|
+
await interaction.editReply({ content: `β Failed to add schedule: ${error.message}` });
|
|
2471
|
+
}
|
|
2472
|
+
break;
|
|
2473
|
+
}
|
|
2474
|
+
if (subcommand === 'remove') {
|
|
2475
|
+
const id = interaction.options.getInteger('id', true);
|
|
2476
|
+
const success = scheduleService.removeSchedule(id);
|
|
2477
|
+
if (success) {
|
|
2478
|
+
await interaction.editReply({ content: `β
Removed scheduled task ID: ${id}` });
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
await interaction.editReply({ content: `β οΈ Scheduled task ID ${id} not found.` });
|
|
2482
|
+
}
|
|
2483
|
+
break;
|
|
2484
|
+
}
|
|
2485
|
+
if (subcommand === 'clear') {
|
|
2486
|
+
const initialSchedules = scheduleService.listSchedules();
|
|
2487
|
+
const count = initialSchedules.length;
|
|
2488
|
+
if (count === 0) {
|
|
2489
|
+
await interaction.editReply({ content: 'π
No scheduled tasks found to clear.' });
|
|
2490
|
+
break;
|
|
2491
|
+
}
|
|
2492
|
+
const initialIdsJson = JSON.stringify(initialSchedules.map(s => s.id).sort((a, b) => a - b));
|
|
2493
|
+
const confirmBtnId = `schedule_clear_confirm_${interaction.id}`;
|
|
2494
|
+
const cancelBtnId = `schedule_clear_cancel_${interaction.id}`;
|
|
2495
|
+
const row = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder()
|
|
2496
|
+
.setCustomId(confirmBtnId)
|
|
2497
|
+
.setLabel(`${(0, i18n_1.t)('Confirm Clear')} (${count})`)
|
|
2498
|
+
.setStyle(discord_js_1.ButtonStyle.Danger), new discord_js_1.ButtonBuilder()
|
|
2499
|
+
.setCustomId(cancelBtnId)
|
|
2500
|
+
.setLabel((0, i18n_1.t)('Cancel'))
|
|
2501
|
+
.setStyle(discord_js_1.ButtonStyle.Secondary));
|
|
2502
|
+
const message = await interaction.editReply({
|
|
2503
|
+
content: `β οΈ **Warning**: This will delete all **${count}** scheduled task(s) and reset the ID counter to 0. Are you sure you want to proceed?`,
|
|
2504
|
+
components: [row]
|
|
2505
|
+
});
|
|
2506
|
+
const collector = message.createMessageComponentCollector({
|
|
2507
|
+
filter: (i) => i.user.id === interaction.user.id && (i.customId === confirmBtnId || i.customId === cancelBtnId),
|
|
2508
|
+
time: 30000,
|
|
2509
|
+
max: 1
|
|
2510
|
+
});
|
|
2511
|
+
collector.on('collect', async (i) => {
|
|
2512
|
+
if (i.customId === confirmBtnId) {
|
|
2513
|
+
const currentSchedules = scheduleService.listSchedules();
|
|
2514
|
+
const currentIdsJson = JSON.stringify(currentSchedules.map(s => s.id).sort((a, b) => a - b));
|
|
2515
|
+
if (initialIdsJson !== currentIdsJson) {
|
|
2516
|
+
await i.update({
|
|
2517
|
+
content: 'β οΈ **Action aborted**: The scheduled tasks list changed while waiting for confirmation. No schedules were cleared.',
|
|
2518
|
+
components: []
|
|
2519
|
+
});
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
scheduleService.resetSchedules();
|
|
2523
|
+
await i.update({
|
|
2524
|
+
content: `β
Successfully removed all **${count}** scheduled task(s) and reset the task ID counter to 0.`,
|
|
2525
|
+
components: []
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
else {
|
|
2529
|
+
await i.update({
|
|
2530
|
+
content: 'β Action cancelled. Scheduled tasks were not cleared.',
|
|
2531
|
+
components: []
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
});
|
|
2535
|
+
collector.on('end', async (collected) => {
|
|
2536
|
+
if (collected.size === 0) {
|
|
2537
|
+
await interaction.editReply({
|
|
2538
|
+
content: 'β οΈ Action timed out. Scheduled tasks were not cleared.',
|
|
2539
|
+
components: []
|
|
2540
|
+
}).catch(() => { });
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
if (subcommand === 'backup') {
|
|
2546
|
+
const json = scheduleService.backupSchedules();
|
|
2547
|
+
const buffer = Buffer.from(json, 'utf-8');
|
|
2548
|
+
await interaction.editReply({
|
|
2549
|
+
content: 'π **LazyGravity Schedules Backup**',
|
|
2550
|
+
files: [{
|
|
2551
|
+
attachment: buffer,
|
|
2552
|
+
name: 'schedules_backup.json'
|
|
2553
|
+
}]
|
|
2554
|
+
});
|
|
2555
|
+
break;
|
|
2556
|
+
}
|
|
2557
|
+
if (subcommand === 'restore') {
|
|
2558
|
+
const attachment = interaction.options.getAttachment('file', true);
|
|
2559
|
+
if (!attachment.name.endsWith('.json')) {
|
|
2560
|
+
await interaction.editReply({ content: 'β Attachment must be a `.json` file.' });
|
|
2561
|
+
break;
|
|
2562
|
+
}
|
|
2563
|
+
try {
|
|
2564
|
+
// Download file content using global fetch (available in Node 18+)
|
|
2565
|
+
const response = await fetch(attachment.url);
|
|
2566
|
+
if (!response.ok)
|
|
2567
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
2568
|
+
const jsonText = await response.text();
|
|
2569
|
+
const jobCb = scheduleService.getJobCallback();
|
|
2570
|
+
if (!jobCb) {
|
|
2571
|
+
await interaction.editReply({ content: 'β οΈ Schedule service is still initializing. Please try again in a few seconds.' });
|
|
2572
|
+
break;
|
|
2573
|
+
}
|
|
2574
|
+
const restoredCount = scheduleService.restoreSchedules(jsonText, jobCb);
|
|
2575
|
+
await interaction.editReply({ content: `β
Successfully restored ${restoredCount} scheduled tasks from backup!` });
|
|
2576
|
+
}
|
|
2577
|
+
catch (error) {
|
|
2578
|
+
await interaction.editReply({ content: `β Failed to restore schedules: ${error.message}` });
|
|
2579
|
+
}
|
|
2580
|
+
break;
|
|
2581
|
+
}
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2251
2584
|
default:
|
|
2252
2585
|
await interaction.editReply({
|
|
2253
2586
|
content: `Unknown command: /${commandName}`,
|
|
@@ -168,6 +168,66 @@ const openCommand = new discord_js_1.SlashCommandBuilder()
|
|
|
168
168
|
.setName('filepath')
|
|
169
169
|
.setDescription((0, i18n_1.t)('Absolute or relative path to the file'))
|
|
170
170
|
.setRequired(true));
|
|
171
|
+
/** /heartbeat command definition */
|
|
172
|
+
const heartbeatCommand = new discord_js_1.SlashCommandBuilder()
|
|
173
|
+
.setName('heartbeat')
|
|
174
|
+
.setDescription((0, i18n_1.t)('Configure periodic bot heartbeat notifications'))
|
|
175
|
+
.addSubcommand((sub) => sub
|
|
176
|
+
.setName('on')
|
|
177
|
+
.setDescription((0, i18n_1.t)('Enable periodic heartbeats'))
|
|
178
|
+
.addStringOption((option) => option
|
|
179
|
+
.setName('interval')
|
|
180
|
+
.setDescription((0, i18n_1.t)('Interval (e.g., 1d, 1h, 30m - unit required)'))
|
|
181
|
+
.setRequired(false))
|
|
182
|
+
.addChannelOption((option) => option
|
|
183
|
+
.setName('channel')
|
|
184
|
+
.setDescription((0, i18n_1.t)('Target channel for heartbeat (defaults to current)'))
|
|
185
|
+
.addChannelTypes(discord_js_1.ChannelType.GuildText, discord_js_1.ChannelType.GuildAnnouncement)
|
|
186
|
+
.setRequired(false)))
|
|
187
|
+
.addSubcommand((sub) => sub
|
|
188
|
+
.setName('off')
|
|
189
|
+
.setDescription((0, i18n_1.t)('Disable periodic heartbeats')))
|
|
190
|
+
.addSubcommand((sub) => sub
|
|
191
|
+
.setName('status')
|
|
192
|
+
.setDescription((0, i18n_1.t)('Display current heartbeat config and status')));
|
|
193
|
+
/** /schedule command definition */
|
|
194
|
+
const scheduleCommand = new discord_js_1.SlashCommandBuilder()
|
|
195
|
+
.setName('schedule')
|
|
196
|
+
.setDescription((0, i18n_1.t)('Manage scheduled tasks'))
|
|
197
|
+
.addSubcommand((sub) => sub
|
|
198
|
+
.setName('list')
|
|
199
|
+
.setDescription((0, i18n_1.t)('Show all scheduled tasks with next-run times')))
|
|
200
|
+
.addSubcommand((sub) => sub
|
|
201
|
+
.setName('add')
|
|
202
|
+
.setDescription((0, i18n_1.t)('Register a recurring task'))
|
|
203
|
+
.addStringOption((option) => option
|
|
204
|
+
.setName('cron')
|
|
205
|
+
.setDescription((0, i18n_1.t)('Cron expression (e.g. "0 * * * *")'))
|
|
206
|
+
.setRequired(true))
|
|
207
|
+
.addStringOption((option) => option
|
|
208
|
+
.setName('prompt')
|
|
209
|
+
.setDescription((0, i18n_1.t)('Prompt content to execute'))
|
|
210
|
+
.setRequired(true)))
|
|
211
|
+
.addSubcommand((sub) => sub
|
|
212
|
+
.setName('remove')
|
|
213
|
+
.setDescription((0, i18n_1.t)('Delete a scheduled task'))
|
|
214
|
+
.addIntegerOption((option) => option
|
|
215
|
+
.setName('id')
|
|
216
|
+
.setDescription((0, i18n_1.t)('ID of the task to delete'))
|
|
217
|
+
.setRequired(true)))
|
|
218
|
+
.addSubcommand((sub) => sub
|
|
219
|
+
.setName('clear')
|
|
220
|
+
.setDescription((0, i18n_1.t)('Remove all scheduled tasks and reset task IDs')))
|
|
221
|
+
.addSubcommand((sub) => sub
|
|
222
|
+
.setName('backup')
|
|
223
|
+
.setDescription((0, i18n_1.t)('Export all scheduled tasks as a JSON file attachment')))
|
|
224
|
+
.addSubcommand((sub) => sub
|
|
225
|
+
.setName('restore')
|
|
226
|
+
.setDescription((0, i18n_1.t)('Restore scheduled tasks from a JSON file attachment'))
|
|
227
|
+
.addAttachmentOption((option) => option
|
|
228
|
+
.setName('file')
|
|
229
|
+
.setDescription((0, i18n_1.t)('The schedules_backup.json file to import'))
|
|
230
|
+
.setRequired(true)));
|
|
171
231
|
/** Array of commands to register */
|
|
172
232
|
exports.slashCommands = [
|
|
173
233
|
helpCommand,
|
|
@@ -191,6 +251,8 @@ exports.slashCommands = [
|
|
|
191
251
|
logsCommand,
|
|
192
252
|
artifactsCommand,
|
|
193
253
|
openCommand,
|
|
254
|
+
heartbeatCommand,
|
|
255
|
+
scheduleCommand,
|
|
194
256
|
];
|
|
195
257
|
/**
|
|
196
258
|
* Register slash commands with Discord
|
|
@@ -11,9 +11,6 @@ class ScheduleRepository {
|
|
|
11
11
|
this.db = db;
|
|
12
12
|
this.initialize();
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
15
|
-
* Initialize table (create if not exists)
|
|
16
|
-
*/
|
|
17
14
|
initialize() {
|
|
18
15
|
this.db.exec(`
|
|
19
16
|
CREATE TABLE IF NOT EXISTS schedules (
|
|
@@ -21,25 +18,33 @@ class ScheduleRepository {
|
|
|
21
18
|
cron_expression TEXT NOT NULL,
|
|
22
19
|
prompt TEXT NOT NULL,
|
|
23
20
|
workspace_path TEXT NOT NULL,
|
|
21
|
+
channel_id TEXT,
|
|
24
22
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
25
23
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
26
24
|
)
|
|
27
25
|
`);
|
|
26
|
+
// Perform schema migration if channel_id doesn't exist (fallback for old databases)
|
|
27
|
+
const tableInfo = this.db.pragma("table_info(schedules)");
|
|
28
|
+
const hasChannelId = tableInfo.some((col) => col.name === 'channel_id');
|
|
29
|
+
if (!hasChannelId) {
|
|
30
|
+
this.db.exec('ALTER TABLE schedules ADD COLUMN channel_id TEXT');
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
/**
|
|
30
34
|
* Create a new schedule
|
|
31
35
|
*/
|
|
32
36
|
create(input) {
|
|
33
37
|
const stmt = this.db.prepare(`
|
|
34
|
-
INSERT INTO schedules (cron_expression, prompt, workspace_path, enabled)
|
|
35
|
-
VALUES (?, ?, ?, ?)
|
|
38
|
+
INSERT INTO schedules (cron_expression, prompt, workspace_path, channel_id, enabled)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?)
|
|
36
40
|
`);
|
|
37
|
-
const result = stmt.run(input.cronExpression, input.prompt, input.workspacePath, input.enabled ? 1 : 0);
|
|
41
|
+
const result = stmt.run(input.cronExpression, input.prompt, input.workspacePath, input.channelId || '', input.enabled ? 1 : 0);
|
|
38
42
|
return {
|
|
39
43
|
id: result.lastInsertRowid,
|
|
40
44
|
cronExpression: input.cronExpression,
|
|
41
45
|
prompt: input.prompt,
|
|
42
46
|
workspacePath: input.workspacePath,
|
|
47
|
+
channelId: input.channelId,
|
|
43
48
|
enabled: input.enabled,
|
|
44
49
|
};
|
|
45
50
|
}
|
|
@@ -73,6 +78,40 @@ class ScheduleRepository {
|
|
|
73
78
|
const result = this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
|
|
74
79
|
return result.changes > 0;
|
|
75
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Clear all schedules and reset the autoincrement ID back to 0
|
|
83
|
+
*/
|
|
84
|
+
reset() {
|
|
85
|
+
this.db.transaction(() => {
|
|
86
|
+
this.db.exec('DELETE FROM schedules');
|
|
87
|
+
this.db.exec("DELETE FROM sqlite_sequence WHERE name = 'schedules'");
|
|
88
|
+
})();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Bulk restore schedules. Clears the table and inserts all provided records.
|
|
92
|
+
*/
|
|
93
|
+
bulkRestore(records) {
|
|
94
|
+
const result = [];
|
|
95
|
+
this.db.transaction(() => {
|
|
96
|
+
this.reset();
|
|
97
|
+
const stmt = this.db.prepare(`
|
|
98
|
+
INSERT INTO schedules (cron_expression, prompt, workspace_path, channel_id, enabled)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?)
|
|
100
|
+
`);
|
|
101
|
+
for (const record of records) {
|
|
102
|
+
const insertResult = stmt.run(record.cronExpression, record.prompt, record.workspacePath, record.channelId || '', record.enabled ? 1 : 0);
|
|
103
|
+
result.push({
|
|
104
|
+
id: insertResult.lastInsertRowid,
|
|
105
|
+
cronExpression: record.cronExpression,
|
|
106
|
+
prompt: record.prompt,
|
|
107
|
+
workspacePath: record.workspacePath,
|
|
108
|
+
channelId: record.channelId,
|
|
109
|
+
enabled: record.enabled,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
76
115
|
/**
|
|
77
116
|
* Partially update a schedule
|
|
78
117
|
*/
|
|
@@ -91,6 +130,10 @@ class ScheduleRepository {
|
|
|
91
130
|
sets.push('workspace_path = ?');
|
|
92
131
|
values.push(input.workspacePath);
|
|
93
132
|
}
|
|
133
|
+
if (input.channelId !== undefined) {
|
|
134
|
+
sets.push('channel_id = ?');
|
|
135
|
+
values.push(input.channelId);
|
|
136
|
+
}
|
|
94
137
|
if (input.enabled !== undefined) {
|
|
95
138
|
sets.push('enabled = ?');
|
|
96
139
|
values.push(input.enabled ? 1 : 0);
|
|
@@ -111,6 +154,7 @@ class ScheduleRepository {
|
|
|
111
154
|
cronExpression: row.cron_expression,
|
|
112
155
|
prompt: row.prompt,
|
|
113
156
|
workspacePath: row.workspace_path,
|
|
157
|
+
channelId: row.channel_id || undefined,
|
|
114
158
|
enabled: row.enabled === 1,
|
|
115
159
|
createdAt: row.created_at,
|
|
116
160
|
};
|
|
@@ -112,6 +112,10 @@ function createInteractionCreateHandler(deps) {
|
|
|
112
112
|
return { ok: true };
|
|
113
113
|
};
|
|
114
114
|
return async (interaction) => {
|
|
115
|
+
const isHeartbeatCommand = 'commandName' in interaction && interaction.commandName === 'heartbeat';
|
|
116
|
+
if (deps.heartbeatService && deps.config.allowedUserIds.includes(interaction.user.id) && !isHeartbeatCommand) {
|
|
117
|
+
deps.heartbeatService.recordActivity();
|
|
118
|
+
}
|
|
115
119
|
if (interaction.isAutocomplete()) {
|
|
116
120
|
if (!deps.config.allowedUserIds.includes(interaction.user.id)) {
|
|
117
121
|
await interaction.respond([]).catch(logger_1.logger.error);
|
|
@@ -1448,7 +1452,7 @@ function createInteractionCreateHandler(deps) {
|
|
|
1448
1452
|
throw deferError;
|
|
1449
1453
|
}
|
|
1450
1454
|
try {
|
|
1451
|
-
await deps.handleSlashInteraction(commandInteraction, deps.slashCommandHandler, deps.bridge, deps.wsHandler, deps.chatHandler, deps.cleanupHandler, deps.modeService, deps.modelService, deps.bridge.autoAccept, deps.client, deps.accountPrefRepo, deps.channelPrefRepo, deps.antigravityAccounts, deps.chatSessionRepo, deps.scheduleService);
|
|
1455
|
+
await deps.handleSlashInteraction(commandInteraction, deps.slashCommandHandler, deps.bridge, deps.wsHandler, deps.chatHandler, deps.cleanupHandler, deps.modeService, deps.modelService, deps.bridge.autoAccept, deps.client, deps.accountPrefRepo, deps.channelPrefRepo, deps.antigravityAccounts, deps.chatSessionRepo, deps.scheduleService, deps.heartbeatService);
|
|
1452
1456
|
}
|
|
1453
1457
|
catch (error) {
|
|
1454
1458
|
logger_1.logger.error(`[SlashCommand] command=${commandInteraction.commandName} channel=${commandInteraction.channelId} ` +
|
|
@@ -10,7 +10,9 @@ const modeService_1 = require("../services/modeService");
|
|
|
10
10
|
const imageHandler_1 = require("../utils/imageHandler");
|
|
11
11
|
const accountUtils_1 = require("../utils/accountUtils");
|
|
12
12
|
const logger_1 = require("../utils/logger");
|
|
13
|
+
const workspaceQueue_1 = require("../bot/workspaceQueue");
|
|
13
14
|
function createMessageCreateHandler(deps) {
|
|
15
|
+
const workspaceQueue = deps.workspaceQueue ?? new workspaceQueue_1.WorkspaceQueue();
|
|
14
16
|
const getCurrentCdp = deps.getCurrentCdp ?? cdpBridgeManager_1.getCurrentCdp;
|
|
15
17
|
const ensureApprovalDetector = deps.ensureApprovalDetector ?? cdpBridgeManager_1.ensureApprovalDetector;
|
|
16
18
|
const ensureErrorPopupDetector = deps.ensureErrorPopupDetector ?? cdpBridgeManager_1.ensureErrorPopupDetector;
|
|
@@ -31,29 +33,15 @@ function createMessageCreateHandler(deps) {
|
|
|
31
33
|
return match ? match.cdpPort : null;
|
|
32
34
|
};
|
|
33
35
|
const getSessionAccountName = (channelId) => deps.chatSessionRepo.findByChannelId(channelId)?.activeAccountName ?? null;
|
|
34
|
-
// Per-workspace prompt queue: serializes sendβresponse cycles
|
|
35
|
-
const workspaceQueues = new Map();
|
|
36
|
-
const workspaceQueueDepths = new Map();
|
|
37
|
-
function enqueueForWorkspace(workspacePath, task) {
|
|
38
|
-
// .catch: ensure a prior rejection never stalls the chain
|
|
39
|
-
const current = (workspaceQueues.get(workspacePath) ?? Promise.resolve()).catch(() => { });
|
|
40
|
-
const next = current.then(async () => {
|
|
41
|
-
try {
|
|
42
|
-
await task();
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
logger_1.logger.error('[WorkspaceQueue] task error:', err?.message || err);
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
workspaceQueues.set(workspacePath, next);
|
|
49
|
-
return next;
|
|
50
|
-
}
|
|
51
36
|
return async (message) => {
|
|
52
37
|
if (message.author.bot)
|
|
53
38
|
return;
|
|
54
39
|
if (!deps.config.allowedUserIds.includes(message.author.id)) {
|
|
55
40
|
return;
|
|
56
41
|
}
|
|
42
|
+
if (deps.heartbeatService) {
|
|
43
|
+
deps.heartbeatService.recordActivity();
|
|
44
|
+
}
|
|
57
45
|
const parsed = (0, messageParser_1.parseMessageContent)(message.content);
|
|
58
46
|
if (parsed.isCommand && parsed.commandName) {
|
|
59
47
|
if (parsed.commandName === 'autoaccept') {
|
|
@@ -160,7 +148,7 @@ function createMessageCreateHandler(deps) {
|
|
|
160
148
|
await message.reply(`β
Switched session account to **${requested}**.`).catch(() => { });
|
|
161
149
|
return;
|
|
162
150
|
}
|
|
163
|
-
const slashOnlyCommands = ['help', 'stop', 'model', 'mode', 'project', 'chat', 'new', 'cleanup', 'join', 'mirror', 'output'];
|
|
151
|
+
const slashOnlyCommands = ['help', 'stop', 'model', 'mode', 'project', 'chat', 'new', 'cleanup', 'join', 'mirror', 'output', 'heartbeat'];
|
|
164
152
|
if (slashOnlyCommands.includes(parsed.commandName)) {
|
|
165
153
|
await message.reply({
|
|
166
154
|
content: `π‘ Please use \`/${parsed.commandName}\` as a slash command.\nType \`/${parsed.commandName}\` in the Discord input field to see suggestions.`,
|
|
@@ -255,9 +243,8 @@ function createMessageCreateHandler(deps) {
|
|
|
255
243
|
if (workspacePath) {
|
|
256
244
|
const projectLabel = deps.bridge.pool.extractProjectName(workspacePath);
|
|
257
245
|
// Track queue depth for hourglass reactions
|
|
258
|
-
const currentDepth =
|
|
259
|
-
|
|
260
|
-
const newDepth = currentDepth + 1;
|
|
246
|
+
const currentDepth = workspaceQueue.getDepth(workspacePath);
|
|
247
|
+
const newDepth = workspaceQueue.incrementDepth(workspacePath);
|
|
261
248
|
if (currentDepth > 0) {
|
|
262
249
|
logger_1.logger.info(`[Queue:${projectLabel}] Enqueued (depth: ${newDepth}, channel: ${message.channelId})`);
|
|
263
250
|
await message.react('β³').catch(() => { });
|
|
@@ -266,7 +253,7 @@ function createMessageCreateHandler(deps) {
|
|
|
266
253
|
logger_1.logger.info(`[Queue:${projectLabel}] Processing immediately (depth: ${newDepth}, channel: ${message.channelId})`);
|
|
267
254
|
}
|
|
268
255
|
const queueStartTime = Date.now();
|
|
269
|
-
await
|
|
256
|
+
await workspaceQueue.enqueue(workspacePath, async () => {
|
|
270
257
|
const waitMs = Date.now() - queueStartTime;
|
|
271
258
|
if (waitMs > 100) {
|
|
272
259
|
logger_1.logger.info(`[Queue:${projectLabel}] Task started after ${Math.round(waitMs / 1000)}s wait (channel: ${message.channelId})`);
|
|
@@ -437,8 +424,7 @@ function createMessageCreateHandler(deps) {
|
|
|
437
424
|
await message.reply(`Failed to connect to workspace: ${e.message}`);
|
|
438
425
|
}
|
|
439
426
|
finally {
|
|
440
|
-
const remainingDepth =
|
|
441
|
-
workspaceQueueDepths.set(workspacePath, remainingDepth);
|
|
427
|
+
const remainingDepth = workspaceQueue.decrementDepth(workspacePath);
|
|
442
428
|
if (remainingDepth > 0) {
|
|
443
429
|
logger_1.logger.info(`[Queue:${projectLabel}] Task done, ${remainingDepth} remaining`);
|
|
444
430
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HeartbeatService = void 0;
|
|
4
|
+
exports.parseInterval = parseInterval;
|
|
5
|
+
exports.formatDuration = formatDuration;
|
|
6
|
+
exports.formatRelativeTime = formatRelativeTime;
|
|
7
|
+
const discord_js_1 = require("discord.js");
|
|
8
|
+
const logger_1 = require("../utils/logger");
|
|
9
|
+
const configLoader_1 = require("../utils/configLoader");
|
|
10
|
+
class HeartbeatService {
|
|
11
|
+
client = null;
|
|
12
|
+
bridge = null;
|
|
13
|
+
intervalId = null;
|
|
14
|
+
generationToken = 0;
|
|
15
|
+
isSending = false;
|
|
16
|
+
botStartTime = Date.now();
|
|
17
|
+
lastActivityTimestamp = Date.now();
|
|
18
|
+
constructor() { }
|
|
19
|
+
init(client, bridge) {
|
|
20
|
+
this.client = client;
|
|
21
|
+
this.bridge = bridge;
|
|
22
|
+
this.botStartTime = Date.now();
|
|
23
|
+
this.lastActivityTimestamp = Date.now();
|
|
24
|
+
}
|
|
25
|
+
recordActivity() {
|
|
26
|
+
this.lastActivityTimestamp = Date.now();
|
|
27
|
+
}
|
|
28
|
+
start() {
|
|
29
|
+
this.stop();
|
|
30
|
+
const config = configLoader_1.ConfigLoader.load();
|
|
31
|
+
if (!config.heartbeatEnabled) {
|
|
32
|
+
logger_1.logger.info('[HeartbeatService] Periodic heartbeat is disabled.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const channelId = config.heartbeatChannelId;
|
|
36
|
+
if (!channelId) {
|
|
37
|
+
logger_1.logger.warn('[HeartbeatService] Enabled but heartbeatChannelId is not configured.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let interval = config.heartbeatIntervalMs ?? 3600000;
|
|
41
|
+
if (interval < 10000) {
|
|
42
|
+
logger_1.logger.warn(`[HeartbeatService] Interval ${interval}ms is too low. Clamping to 10000ms.`);
|
|
43
|
+
interval = 10000;
|
|
44
|
+
}
|
|
45
|
+
else if (interval > 2147483647) {
|
|
46
|
+
logger_1.logger.warn(`[HeartbeatService] Interval ${interval}ms is too high (max is 2147483647ms). Clamping to 2147483647ms.`);
|
|
47
|
+
interval = 2147483647;
|
|
48
|
+
}
|
|
49
|
+
logger_1.logger.info(`[HeartbeatService] Starting periodic heartbeat every ${interval}ms to channel ${channelId}`);
|
|
50
|
+
// Run immediately on start
|
|
51
|
+
this.sendHeartbeat().catch(err => {
|
|
52
|
+
logger_1.logger.error('[HeartbeatService] Failed to send initial heartbeat:', err);
|
|
53
|
+
});
|
|
54
|
+
this.intervalId = setInterval(() => {
|
|
55
|
+
this.sendHeartbeat().catch(err => {
|
|
56
|
+
logger_1.logger.error('[HeartbeatService] Failed to send periodic heartbeat:', err);
|
|
57
|
+
});
|
|
58
|
+
}, interval);
|
|
59
|
+
}
|
|
60
|
+
stop() {
|
|
61
|
+
this.generationToken++;
|
|
62
|
+
if (this.intervalId) {
|
|
63
|
+
clearInterval(this.intervalId);
|
|
64
|
+
this.intervalId = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async updateConfig(enabled, intervalMs, channelId) {
|
|
68
|
+
this.generationToken++;
|
|
69
|
+
const config = configLoader_1.ConfigLoader.load();
|
|
70
|
+
// If channel changed, clear the last message ID
|
|
71
|
+
if (config.heartbeatChannelId !== channelId) {
|
|
72
|
+
if (config.heartbeatChannelId && config.heartbeatLastMessageId) {
|
|
73
|
+
try {
|
|
74
|
+
const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId);
|
|
75
|
+
if (oldChannel && oldChannel.isTextBased()) {
|
|
76
|
+
const oldMsg = await oldChannel.messages.fetch(config.heartbeatLastMessageId);
|
|
77
|
+
if (oldMsg) {
|
|
78
|
+
await oldMsg.delete().catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
logger_1.logger.debug('[HeartbeatService] Failed to delete old heartbeat message from previous channel:', err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
configLoader_1.ConfigLoader.save({ heartbeatLastMessageId: undefined });
|
|
87
|
+
}
|
|
88
|
+
// Save to config.json
|
|
89
|
+
configLoader_1.ConfigLoader.save({
|
|
90
|
+
heartbeatEnabled: enabled,
|
|
91
|
+
heartbeatIntervalMs: intervalMs,
|
|
92
|
+
heartbeatChannelId: channelId,
|
|
93
|
+
});
|
|
94
|
+
logger_1.logger.info(`[HeartbeatService] Config updated: enabled=${enabled}, interval=${intervalMs}ms, channel=${channelId}`);
|
|
95
|
+
// Restart loop
|
|
96
|
+
this.start();
|
|
97
|
+
}
|
|
98
|
+
async disable() {
|
|
99
|
+
this.generationToken++;
|
|
100
|
+
const config = configLoader_1.ConfigLoader.load();
|
|
101
|
+
if (config.heartbeatChannelId && config.heartbeatLastMessageId) {
|
|
102
|
+
try {
|
|
103
|
+
const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId);
|
|
104
|
+
if (oldChannel && oldChannel.isTextBased()) {
|
|
105
|
+
const oldMsg = await oldChannel.messages.fetch(config.heartbeatLastMessageId);
|
|
106
|
+
if (oldMsg) {
|
|
107
|
+
await oldMsg.delete().catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
logger_1.logger.debug('[HeartbeatService] Failed to delete heartbeat message upon disabling:', err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
configLoader_1.ConfigLoader.save({
|
|
116
|
+
heartbeatEnabled: false,
|
|
117
|
+
heartbeatLastMessageId: undefined,
|
|
118
|
+
});
|
|
119
|
+
this.stop();
|
|
120
|
+
logger_1.logger.info('[HeartbeatService] Heartbeat disabled.');
|
|
121
|
+
}
|
|
122
|
+
async sendHeartbeat() {
|
|
123
|
+
if (!this.client || !this.bridge) {
|
|
124
|
+
logger_1.logger.warn('[HeartbeatService] Cannot send heartbeat: client or bridge not initialized.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (this.isSending) {
|
|
128
|
+
logger_1.logger.debug('[HeartbeatService] Heartbeat send already in flight. Skipping overlapping execution.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const gen = this.generationToken;
|
|
132
|
+
this.isSending = true;
|
|
133
|
+
const config = configLoader_1.ConfigLoader.load();
|
|
134
|
+
const channelId = config.heartbeatChannelId;
|
|
135
|
+
if (!channelId) {
|
|
136
|
+
this.isSending = false;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
141
|
+
if (gen !== this.generationToken) {
|
|
142
|
+
logger_1.logger.debug(`[HeartbeatService] Aborting heartbeat send: stale generation (expected ${gen}, current ${this.generationToken})`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!channel || !channel.isTextBased()) {
|
|
146
|
+
logger_1.logger.warn(`[HeartbeatService] Channel ${channelId} not found or is not a text channel.`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const embed = this.buildHeartbeatEmbed();
|
|
150
|
+
const lastMessageId = config.heartbeatLastMessageId;
|
|
151
|
+
if (lastMessageId) {
|
|
152
|
+
try {
|
|
153
|
+
const message = await channel.messages.fetch(lastMessageId);
|
|
154
|
+
if (gen !== this.generationToken) {
|
|
155
|
+
logger_1.logger.debug('[HeartbeatService] Aborting message edit: stale generation');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (message && message.author.id === this.client.user?.id) {
|
|
159
|
+
await message.edit({ embeds: [embed] });
|
|
160
|
+
logger_1.logger.debug(`[HeartbeatService] Updated heartbeat message in-place: ${lastMessageId}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (fetchErr) {
|
|
165
|
+
logger_1.logger.debug(`[HeartbeatService] Failed to fetch or edit message ${lastMessageId}, sending new one.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (gen !== this.generationToken) {
|
|
169
|
+
logger_1.logger.debug('[HeartbeatService] Aborting message send: stale generation');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Send new message
|
|
173
|
+
const newMsg = await channel.send({ embeds: [embed] });
|
|
174
|
+
if (gen !== this.generationToken) {
|
|
175
|
+
logger_1.logger.debug(`[HeartbeatService] Stale generation after send. Deleting new message ${newMsg.id} to avoid leakage.`);
|
|
176
|
+
await newMsg.delete().catch(() => { });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
configLoader_1.ConfigLoader.save({ heartbeatLastMessageId: newMsg.id });
|
|
180
|
+
logger_1.logger.info(`[HeartbeatService] Sent new heartbeat message: ${newMsg.id}`);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger_1.logger.error('[HeartbeatService] Error in sendHeartbeat:', error);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
this.isSending = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
buildHeartbeatEmbed() {
|
|
190
|
+
const uptimeMs = Date.now() - this.botStartTime;
|
|
191
|
+
const uptimeStr = formatDuration(uptimeMs);
|
|
192
|
+
const activeWorkspaces = this.bridge?.pool.getActiveWorkspaceNames() || [];
|
|
193
|
+
const activeCount = activeWorkspaces.length;
|
|
194
|
+
const activeList = activeCount > 0 ? activeWorkspaces.join(', ') : 'None';
|
|
195
|
+
const lastActivityStr = formatRelativeTime(this.lastActivityTimestamp);
|
|
196
|
+
return new discord_js_1.EmbedBuilder()
|
|
197
|
+
.setTitle('π LazyGravity Heartbeat')
|
|
198
|
+
.setColor(0x00CC88)
|
|
199
|
+
.addFields({ name: 'Status', value: 'π’ Running', inline: true }, { name: 'Uptime', value: uptimeStr, inline: true }, { name: 'Active Sessions', value: `${activeCount} (${activeList})`, inline: true }, { name: 'Last Activity', value: lastActivityStr, inline: true })
|
|
200
|
+
.setFooter({ text: `Last updated: ${new Date().toLocaleString()}` })
|
|
201
|
+
.setTimestamp();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
exports.HeartbeatService = HeartbeatService;
|
|
205
|
+
/**
|
|
206
|
+
* Parse a duration string like "1h", "6h", "30m", or "2d" to milliseconds.
|
|
207
|
+
* Requiring a unit prevents ambiguity with bare numbers.
|
|
208
|
+
*/
|
|
209
|
+
function parseInterval(str) {
|
|
210
|
+
const match = str.trim().toLowerCase().match(/^(\d+)(ms|s|m|h|d)$/);
|
|
211
|
+
if (!match)
|
|
212
|
+
return null;
|
|
213
|
+
const value = parseInt(match[1], 10);
|
|
214
|
+
const unit = match[2];
|
|
215
|
+
switch (unit) {
|
|
216
|
+
case 'ms': return value;
|
|
217
|
+
case 's': return value * 1000;
|
|
218
|
+
case 'm': return value * 60 * 1000;
|
|
219
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
220
|
+
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
221
|
+
default: return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Format milliseconds to a human-readable duration (e.g. "2h 15m")
|
|
226
|
+
*/
|
|
227
|
+
function formatDuration(ms) {
|
|
228
|
+
const seconds = Math.floor((ms / 1000) % 60);
|
|
229
|
+
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
|
230
|
+
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
|
|
231
|
+
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (days > 0)
|
|
234
|
+
parts.push(`${days}d`);
|
|
235
|
+
if (hours > 0)
|
|
236
|
+
parts.push(`${hours}h`);
|
|
237
|
+
if (minutes > 0)
|
|
238
|
+
parts.push(`${minutes}m`);
|
|
239
|
+
if (seconds > 0 || parts.length === 0)
|
|
240
|
+
parts.push(`${seconds}s`);
|
|
241
|
+
return parts.join(' ');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Format a past timestamp to a relative string (e.g. "5m ago")
|
|
245
|
+
*/
|
|
246
|
+
function formatRelativeTime(timestamp) {
|
|
247
|
+
const diff = Date.now() - timestamp;
|
|
248
|
+
if (diff < 5000)
|
|
249
|
+
return 'just now';
|
|
250
|
+
const seconds = Math.floor(diff / 1000);
|
|
251
|
+
if (seconds < 60)
|
|
252
|
+
return `${seconds}s ago`;
|
|
253
|
+
const minutes = Math.floor(seconds / 60);
|
|
254
|
+
if (minutes < 60)
|
|
255
|
+
return `${minutes}m ago`;
|
|
256
|
+
const hours = Math.floor(minutes / 60);
|
|
257
|
+
if (hours < 24)
|
|
258
|
+
return `${hours}h ${minutes % 60}m ago`;
|
|
259
|
+
const days = Math.floor(hours / 24);
|
|
260
|
+
return `${days}d ago`;
|
|
261
|
+
}
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.ScheduleService = void 0;
|
|
37
37
|
const cron = __importStar(require("node-cron"));
|
|
38
|
+
const logger_1 = require("../utils/logger");
|
|
38
39
|
/**
|
|
39
40
|
* Service class for managing scheduled jobs.
|
|
40
41
|
*
|
|
@@ -46,9 +47,16 @@ class ScheduleService {
|
|
|
46
47
|
repo;
|
|
47
48
|
/** Map managing active cron tasks (schedule ID -> ScheduledTask) */
|
|
48
49
|
activeTasks = new Map();
|
|
50
|
+
jobCallback;
|
|
49
51
|
constructor(repo) {
|
|
50
52
|
this.repo = repo;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Get the stored job callback.
|
|
56
|
+
*/
|
|
57
|
+
getJobCallback() {
|
|
58
|
+
return this.jobCallback;
|
|
59
|
+
}
|
|
52
60
|
/**
|
|
53
61
|
* Called on bot startup. Loads all enabled schedules from DB and registers/resumes them with node-cron.
|
|
54
62
|
*
|
|
@@ -56,6 +64,7 @@ class ScheduleService {
|
|
|
56
64
|
* @returns Number of restored schedules
|
|
57
65
|
*/
|
|
58
66
|
restoreAll(jobCallback) {
|
|
67
|
+
this.jobCallback = jobCallback;
|
|
59
68
|
const enabledSchedules = this.repo.findEnabled();
|
|
60
69
|
for (const schedule of enabledSchedules) {
|
|
61
70
|
this.registerCronTask(schedule, jobCallback);
|
|
@@ -73,20 +82,36 @@ class ScheduleService {
|
|
|
73
82
|
* @returns Created schedule record
|
|
74
83
|
* @throws On invalid cron expression
|
|
75
84
|
*/
|
|
76
|
-
addSchedule(cronExpression, prompt, workspacePath, jobCallback) {
|
|
85
|
+
addSchedule(cronExpression, prompt, workspacePath, channelIdOrCallback, jobCallback) {
|
|
86
|
+
let finalChannelId = '';
|
|
87
|
+
let finalJobCallback;
|
|
88
|
+
if (typeof channelIdOrCallback === 'function') {
|
|
89
|
+
finalJobCallback = channelIdOrCallback;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
finalChannelId = channelIdOrCallback;
|
|
93
|
+
finalJobCallback = jobCallback;
|
|
94
|
+
}
|
|
95
|
+
if (!finalJobCallback) {
|
|
96
|
+
throw new Error('Job callback is not initialized.');
|
|
97
|
+
}
|
|
77
98
|
// Validate cron expression
|
|
78
99
|
if (!cron.validate(cronExpression)) {
|
|
79
100
|
throw new Error(`Invalid cron expression: ${cronExpression}`);
|
|
80
101
|
}
|
|
81
102
|
// Save to DB
|
|
82
|
-
const
|
|
103
|
+
const recordInput = {
|
|
83
104
|
cronExpression,
|
|
84
105
|
prompt,
|
|
85
106
|
workspacePath,
|
|
86
107
|
enabled: true,
|
|
87
|
-
}
|
|
108
|
+
};
|
|
109
|
+
if (finalChannelId) {
|
|
110
|
+
recordInput.channelId = finalChannelId;
|
|
111
|
+
}
|
|
112
|
+
const record = this.repo.create(recordInput);
|
|
88
113
|
// Register with node-cron
|
|
89
|
-
this.registerCronTask(record,
|
|
114
|
+
this.registerCronTask(record, finalJobCallback);
|
|
90
115
|
return record;
|
|
91
116
|
}
|
|
92
117
|
/**
|
|
@@ -115,6 +140,76 @@ class ScheduleService {
|
|
|
115
140
|
}
|
|
116
141
|
this.activeTasks.clear();
|
|
117
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Remove all scheduled tasks.
|
|
145
|
+
* Stops all active cron tasks in memory, empties the database table,
|
|
146
|
+
* and resets the autoincrement ID back to 0.
|
|
147
|
+
*/
|
|
148
|
+
resetSchedules() {
|
|
149
|
+
this.repo.reset();
|
|
150
|
+
this.stopAll();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Export all schedules as a JSON string
|
|
154
|
+
*/
|
|
155
|
+
backupSchedules() {
|
|
156
|
+
const list = this.listSchedules();
|
|
157
|
+
// Export only portable fields (exclude autoincrement ID and timestamps)
|
|
158
|
+
const portable = list.map(s => ({
|
|
159
|
+
cronExpression: s.cronExpression,
|
|
160
|
+
prompt: s.prompt,
|
|
161
|
+
workspacePath: s.workspacePath,
|
|
162
|
+
channelId: s.channelId,
|
|
163
|
+
enabled: s.enabled
|
|
164
|
+
}));
|
|
165
|
+
return JSON.stringify(portable, null, 2);
|
|
166
|
+
}
|
|
167
|
+
restoreSchedules(jsonContent, jobCallback) {
|
|
168
|
+
if (!jobCallback) {
|
|
169
|
+
throw new Error('Job callback is not initialized.');
|
|
170
|
+
}
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(jsonContent);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
throw new Error('Invalid backup format: root must be an array of schedule objects.');
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(parsed)) {
|
|
179
|
+
throw new Error('Invalid backup format: root must be an array of schedule objects.');
|
|
180
|
+
}
|
|
181
|
+
// Validate items
|
|
182
|
+
const validated = [];
|
|
183
|
+
for (const item of parsed) {
|
|
184
|
+
if (!item || typeof item !== 'object') {
|
|
185
|
+
throw new Error('Invalid backup format: each schedule must contain cronExpression, prompt, and workspacePath.');
|
|
186
|
+
}
|
|
187
|
+
if (typeof item.cronExpression !== 'string' || typeof item.prompt !== 'string' || typeof item.workspacePath !== 'string') {
|
|
188
|
+
throw new Error('Invalid backup format: each schedule must contain cronExpression, prompt, and workspacePath.');
|
|
189
|
+
}
|
|
190
|
+
if (!cron.validate(item.cronExpression)) {
|
|
191
|
+
throw new Error(`Invalid cron expression in backup: "${item.cronExpression}"`);
|
|
192
|
+
}
|
|
193
|
+
validated.push({
|
|
194
|
+
cronExpression: item.cronExpression,
|
|
195
|
+
prompt: item.prompt,
|
|
196
|
+
workspacePath: item.workspacePath,
|
|
197
|
+
channelId: typeof item.channelId === 'string' ? item.channelId : undefined,
|
|
198
|
+
enabled: typeof item.enabled === 'boolean' ? item.enabled : true
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Write to DB first (atomic transaction)
|
|
202
|
+
const restoredRecords = this.repo.bulkRestore(validated);
|
|
203
|
+
// Stop memory crons now that DB write succeeded
|
|
204
|
+
this.stopAll();
|
|
205
|
+
// Resume crons in memory
|
|
206
|
+
for (const record of restoredRecords) {
|
|
207
|
+
if (record.enabled) {
|
|
208
|
+
this.registerCronTask(record, jobCallback);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return restoredRecords.length;
|
|
212
|
+
}
|
|
118
213
|
/**
|
|
119
214
|
* Get a list of all schedules
|
|
120
215
|
*/
|
|
@@ -125,7 +220,9 @@ class ScheduleService {
|
|
|
125
220
|
* Internal method to register a task with node-cron
|
|
126
221
|
*/
|
|
127
222
|
registerCronTask(schedule, jobCallback) {
|
|
223
|
+
logger_1.logger.info(`[Schedule] Registering cron task ID ${schedule.id} with expression "${schedule.cronExpression}"`);
|
|
128
224
|
const task = cron.schedule(schedule.cronExpression, () => {
|
|
225
|
+
logger_1.logger.info(`[Schedule] Cron trigger fired for task ID ${schedule.id}`);
|
|
129
226
|
jobCallback(schedule);
|
|
130
227
|
});
|
|
131
228
|
this.activeTasks.set(schedule.id, task);
|
|
@@ -113,6 +113,10 @@ function mergeConfig(persisted) {
|
|
|
113
113
|
if (platforms.includes('telegram') && !telegramToken) {
|
|
114
114
|
throw new Error('TELEGRAM_BOT_TOKEN is required when platforms include "telegram"');
|
|
115
115
|
}
|
|
116
|
+
const heartbeatEnabled = resolveBoolean(process.env.HEARTBEAT_ENABLED, persisted.heartbeatEnabled, false);
|
|
117
|
+
const heartbeatIntervalMs = resolvePositiveInt(process.env.HEARTBEAT_INTERVAL_MS, persisted.heartbeatIntervalMs, 3600000);
|
|
118
|
+
const heartbeatChannelId = process.env.HEARTBEAT_CHANNEL_ID ?? persisted.heartbeatChannelId ?? undefined;
|
|
119
|
+
const heartbeatLastMessageId = persisted.heartbeatLastMessageId ?? undefined;
|
|
116
120
|
return {
|
|
117
121
|
discordToken,
|
|
118
122
|
clientId,
|
|
@@ -128,6 +132,10 @@ function mergeConfig(persisted) {
|
|
|
128
132
|
telegramAllowedUserIds,
|
|
129
133
|
platforms,
|
|
130
134
|
cdpHost,
|
|
135
|
+
heartbeatEnabled,
|
|
136
|
+
heartbeatIntervalMs,
|
|
137
|
+
heartbeatChannelId,
|
|
138
|
+
heartbeatLastMessageId,
|
|
131
139
|
};
|
|
132
140
|
}
|
|
133
141
|
function resolveAllowedUserIds(persisted) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazy-gravity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Control Antigravity from anywhere β a local, secure bot (Discord + Telegram) that lets you remotely operate Antigravity on your home PC from your smartphone.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@inquirer/select": "^5.1.0",
|
|
51
51
|
"better-sqlite3": "^12.6.2",
|
|
52
52
|
"commander": "^15.0.0",
|
|
53
|
+
"cron-parser": "^5.6.1",
|
|
53
54
|
"discord.js": "^14.25.1",
|
|
54
55
|
"dotenv": "^17.3.1",
|
|
55
56
|
"grammy": "^1.41.1",
|