@goodandready/dsh-cron 0.2.19 → 0.2.20

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.
@@ -0,0 +1,68 @@
1
+ import { previewSchedule } from './scheduler.js';
2
+ import { sendJson, readBody, rejectCrossOrigin } from './http-utils.js';
3
+
4
+ const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
5
+
6
+ export function handleHeartbeatPing({ store, req, res, taskId }) {
7
+ const task = store.get(taskId);
8
+ if (!task) {
9
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
10
+ return;
11
+ }
12
+ const result = typeof store.recordHeartbeat === 'function' ? store.recordHeartbeat(taskId) : null;
13
+ if (!result) {
14
+ sendJson(res, 500, { ok: false, error: 'Could not record heartbeat' });
15
+ return;
16
+ }
17
+ sendJson(res, 200, result);
18
+ }
19
+
20
+ export async function handleSchedulePreview({ req, res, url }) {
21
+ let schedule = '';
22
+ let timezone = '';
23
+ let count = 5;
24
+
25
+ if (req.method === 'POST') {
26
+ const { body, error } = await readBody(req, res);
27
+ if (error) return;
28
+ schedule = body.schedule || '';
29
+ timezone = body.timezone || '';
30
+ count = body.count || 5;
31
+ } else if (req.method === 'GET') {
32
+ schedule = url.searchParams.get('schedule') || '';
33
+ timezone = url.searchParams.get('timezone') || '';
34
+ count = parseInt(url.searchParams.get('count') || '5', 10);
35
+ } else {
36
+ sendJson(res, 405, NOT_ALLOWED);
37
+ return;
38
+ }
39
+
40
+ if (!schedule) {
41
+ sendJson(res, 400, { ok: false, error: 'Field "schedule" is required' });
42
+ return;
43
+ }
44
+
45
+ try {
46
+ const preview = previewSchedule(schedule, { timezone, count });
47
+ sendJson(res, 200, { ok: true, ...preview });
48
+ } catch (err) {
49
+ sendJson(res, 400, { ok: false, error: err.message });
50
+ }
51
+ }
52
+
53
+ export async function handleDryRunTask({ store, scheduler, req, res, id }) {
54
+ if (rejectCrossOrigin(req, res)) return;
55
+ const task = store.get(id);
56
+ if (!task) {
57
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
58
+ return;
59
+ }
60
+
61
+ try {
62
+ const outcome = await scheduler.runTask(id, { dryRun: true });
63
+ sendJson(res, 200, outcome || { ok: true, status: 'completed' });
64
+ } catch (err) {
65
+ sendJson(res, 500, { ok: false, error: err.message });
66
+ }
67
+ }
68
+
@@ -0,0 +1,145 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ buildTaskExport,
4
+ validateImportDocument,
5
+ planImport,
6
+ hasCodeExecutingTask,
7
+ IMPORT_STRATEGIES,
8
+ } from './task-transfer.js';
9
+ import { unknownChannelIds } from './channels.js';
10
+ import {
11
+ sendJson,
12
+ readBody,
13
+ SCRIPT_CONFIRM_HEADER,
14
+ } from './http-utils.js';
15
+ import { MANAGED_BY_CONFIG } from './config-jobs.js';
16
+
17
+ export function handleTaskExport({ store, res }) {
18
+ const doc = buildTaskExport(store.list({ status: 'all' }));
19
+ sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
20
+ }
21
+
22
+ export async function handleTaskImport({ store, scheduler, req, res }) {
23
+ const { body, error } = await readBody(req, res);
24
+ if (error) return;
25
+ const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
26
+ const checked = validateImportDocument(body && body.document);
27
+ if (!checked.ok) {
28
+ sendJson(res, 400, { ok: false, error: checked.error });
29
+ return;
30
+ }
31
+ // The import file is external input: importing code-executing tasks needs
32
+ // the same explicit confirmation that creating one over HTTP needs,
33
+ // otherwise a hand-edited file would bypass that gate (#86).
34
+ if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
35
+ sendJson(res, 403, {
36
+ ok: false,
37
+ error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
38
+ });
39
+ return;
40
+ }
41
+ // #121: an imported file may name a channel this build no longer has. The
42
+ // import stays tolerant by design (#42), but the loss is reported instead of
43
+ // being invisible.
44
+ const rawTasks = Array.isArray(body.document && body.document.tasks) ? body.document.tasks : [];
45
+ const droppedChannels = [];
46
+ for (const item of rawTasks) {
47
+ for (const id of unknownChannelIds(item.channels)) {
48
+ if (!droppedChannels.includes(id)) droppedChannels.push(id);
49
+ }
50
+ }
51
+ if (droppedChannels.length) {
52
+ console.warn('[dsh-cron] import: unknown channel ids were dropped: ' + droppedChannels.join(', '));
53
+ }
54
+ const existingIds = store.list({ status: 'all' }).map((x) => x.id);
55
+ const plan = planImport(checked.tasks, existingIds, strategy);
56
+ // Replacing a task the config owns would be undone at the next start, and
57
+ // the id could then hold two different tasks (#50).
58
+ const configConflicts = plan.replace
59
+ .map((item) => item.id)
60
+ .filter((taskId) => {
61
+ const stored = store.get(taskId);
62
+ return stored && stored.managedBy === MANAGED_BY_CONFIG;
63
+ });
64
+ if (configConflicts.length) {
65
+ sendJson(res, 409, {
66
+ ok: false,
67
+ error: 'These tasks are declared in the profile config and cannot be replaced by an import: ' + configConflicts.join(', '),
68
+ });
69
+ return;
70
+ }
71
+ const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
72
+ if (body && body.dryRun) {
73
+ sendJson(res, 200, {
74
+ ok: true,
75
+ dryRun: true,
76
+ strategy: plan.strategy,
77
+ summary,
78
+ codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
79
+ unknownChannels: droppedChannels,
80
+ });
81
+ return;
82
+ }
83
+ const applied = applyImportPlan({ store, scheduler, plan });
84
+ if (applied.error) {
85
+ sendJson(res, 500, { ok: false, error: applied.error });
86
+ return;
87
+ }
88
+ sendJson(res, 200, {
89
+ ok: true,
90
+ imported: applied.imported.length,
91
+ summary,
92
+ unknownChannels: droppedChannels,
93
+ tasks: applied.imported,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Write an import plan. Every imported task is paused (the file's status is
99
+ * ignored) and every touched id is disarmed explicitly, because writing
100
+ * "paused" does not stop an already-armed croner job. A failed write rolls the
101
+ * store back so a partial import cannot survive.
102
+ */
103
+ export function applyImportPlan({ store, scheduler, plan }) {
104
+ const imported = [];
105
+ const rollback = [];
106
+ const disarm = [];
107
+ try {
108
+ for (const task of plan.add) {
109
+ const before = store.get(task.id);
110
+ const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
111
+ imported.push(created);
112
+ rollback.push({ id: created.id, previous: before || null });
113
+ disarm.push(created.id);
114
+ }
115
+ for (const task of plan.replace) {
116
+ const before = store.get(task.id);
117
+ if (!before) continue;
118
+ const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
119
+ imported.push(replaced);
120
+ rollback.push({ id: replaced.id, previous: before });
121
+ disarm.push(replaced.id);
122
+ }
123
+ } catch (writeErr) {
124
+ for (const entry of rollback.reverse()) {
125
+ try {
126
+ if (entry.previous) store.set(entry.previous);
127
+ else store.delete(entry.id);
128
+ } catch (rollbackErr) {
129
+ console.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
130
+ }
131
+ }
132
+ return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
133
+ }
134
+ // Disarm outside the write transaction: pauseTask is idempotent and a
135
+ // failure here must not undo a successful import.
136
+ for (const id of disarm) {
137
+ try {
138
+ scheduler.pauseTask(id);
139
+ } catch (err) {
140
+ console.error('[dsh-cron] import: could not pause task', id + ':', err.message);
141
+ }
142
+ }
143
+ return { imported };
144
+ }
145
+
@@ -0,0 +1,115 @@
1
+ import { sendJson, readBody } from './http-utils.js';
2
+ import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from './telegram.js';
3
+
4
+ const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
5
+
6
+ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
7
+ if (req.method !== 'POST') {
8
+ sendJson(res, 405, NOT_ALLOWED);
9
+ return;
10
+ }
11
+ const { body, error } = await readBody(req, res);
12
+ if (error || !body) return;
13
+
14
+ const callbackQuery = body.callback_query;
15
+ if (!callbackQuery) {
16
+ sendJson(res, 200, { ok: true });
17
+ return;
18
+ }
19
+
20
+ const queryId = callbackQuery.id;
21
+ const data = String(callbackQuery.data || '');
22
+ const fromId = String(callbackQuery.from?.id || '');
23
+ const chatId = String(callbackQuery.message?.chat?.id || fromId);
24
+
25
+ const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
26
+ const defaults = getDshDefaultTelegramCredentials();
27
+ const botToken = settings.botToken || defaults.botToken;
28
+ const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
29
+
30
+ if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
31
+ console.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId}`);
32
+ if (botToken) {
33
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
34
+ }
35
+ sendJson(res, 403, { ok: false, error: 'Forbidden' });
36
+ return;
37
+ }
38
+
39
+ const parts = data.split(':');
40
+ if (parts[0] !== 'cron' || parts.length < 3) {
41
+ if (botToken) {
42
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unknown action' });
43
+ }
44
+ sendJson(res, 200, { ok: true, ignored: true });
45
+ return;
46
+ }
47
+
48
+ const verb = parts[1];
49
+ const taskId = parts.slice(2).join(':');
50
+ const task = store.get(taskId);
51
+
52
+ if (!task) {
53
+ if (botToken) {
54
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Task not found', showAlert: true });
55
+ }
56
+ sendJson(res, 200, { ok: false, error: 'Task not found' });
57
+ return;
58
+ }
59
+
60
+ if (verb === 'run') {
61
+ try {
62
+ await scheduler.triggerManualRun(taskId);
63
+ if (botToken) {
64
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `🚀 Run triggered: ${task.title}` });
65
+ }
66
+ } catch (runErr) {
67
+ if (botToken) {
68
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `Run error: ${runErr.message}`, showAlert: true });
69
+ }
70
+ }
71
+ } else if (verb === 'pause') {
72
+ const isPaused = task.status === 'paused';
73
+ const newStatus = isPaused ? 'active' : 'paused';
74
+ if (typeof store.update === 'function') {
75
+ store.update(taskId, { status: newStatus });
76
+ } else {
77
+ store.set({ ...task, status: newStatus });
78
+ }
79
+ if (isPaused) {
80
+ scheduler.resume(taskId);
81
+ if (botToken) {
82
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
83
+ }
84
+ } else {
85
+ scheduler.pause(taskId);
86
+ if (botToken) {
87
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
88
+ }
89
+ }
90
+ } else if (verb === 'log') {
91
+ const history = store.getHistory(taskId, 1);
92
+ const lastRun = history && history[0];
93
+ if (!lastRun) {
94
+ if (botToken) {
95
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `No runs recorded for ${task.title}`, showAlert: true });
96
+ }
97
+ } else {
98
+ const summary = `[${task.title}] Last run: ${lastRun.status} (${lastRun.durationMs}ms)\n${lastRun.output || lastRun.error || 'Empty output'}`;
99
+ if (botToken) {
100
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: summary.slice(0, 190) });
101
+ if (summary.length > 190 && chatId) {
102
+ await sendTelegramMessage({
103
+ botToken,
104
+ chatId,
105
+ text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
106
+ parseMode: 'Markdown',
107
+ }).catch(() => {});
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ sendJson(res, 200, { ok: true, verb, taskId });
114
+ }
115
+
package/lib/api.js CHANGED
@@ -6,24 +6,39 @@
6
6
  */
7
7
 
8
8
  import { randomUUID, createHash } from 'node:crypto';
9
- import { parseScheduleExpression, previewSchedule } from './scheduler.js';
9
+ import { parseScheduleExpression } from './scheduler.js';
10
10
  import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
11
11
  import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
12
12
  import { applyTaskPatch } from './task-patch.js';
13
13
  import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
14
14
  import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
15
- import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from './telegram.js';
16
15
  import {
17
16
  validateTaskType,
18
17
  buildDuplicateTask,
19
- buildTaskExport,
20
- validateImportDocument,
21
- planImport,
22
- hasCodeExecutingTask,
23
- IMPORT_STRATEGIES,
24
18
  RESERVED_TASK_IDS,
25
19
  PATCHABLE_TASK_FIELDS,
26
20
  } from './task-transfer.js';
21
+ import {
22
+ handleTaskExport,
23
+ handleTaskImport,
24
+ applyImportPlan,
25
+ } from './api-import-export.js';
26
+ import { handleTelegramWebhook } from './api-webhook.js';
27
+ import {
28
+ handleHeartbeatPing,
29
+ handleSchedulePreview,
30
+ handleDryRunTask,
31
+ } from './api-helpers.js';
32
+
33
+ export {
34
+ handleTaskExport,
35
+ handleTaskImport,
36
+ applyImportPlan,
37
+ handleTelegramWebhook,
38
+ handleHeartbeatPing,
39
+ handleSchedulePreview,
40
+ handleDryRunTask,
41
+ };
27
42
 
28
43
  const NOT_FOUND = { ok: false, error: 'Task not found' };
29
44
  const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
@@ -230,136 +245,7 @@ function buildTaskRecord(store, body, taskType, parsed) {
230
245
  };
231
246
  }
232
247
 
233
- // --------------------------------------------------------- export / import
234
-
235
- function handleTaskExport({ store, res }) {
236
- const doc = buildTaskExport(store.list({ status: 'all' }));
237
- sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
238
- }
239
-
240
- async function handleTaskImport({ store, scheduler, req, res }) {
241
- const { body, error } = await readBody(req, res);
242
- if (error) return;
243
- const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
244
- const checked = validateImportDocument(body && body.document);
245
- if (!checked.ok) {
246
- sendJson(res, 400, { ok: false, error: checked.error });
247
- return;
248
- }
249
- // The import file is external input: importing code-executing tasks needs
250
- // the same explicit confirmation that creating one over HTTP needs,
251
- // otherwise a hand-edited file would bypass that gate (#86).
252
- if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
253
- sendJson(res, 403, {
254
- ok: false,
255
- error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
256
- });
257
- return;
258
- }
259
- // #121: an imported file may name a channel this build no longer has. The
260
- // import stays tolerant by design (#42), but the loss is reported instead of
261
- // being invisible.
262
- const rawTasks = Array.isArray(body.document && body.document.tasks) ? body.document.tasks : [];
263
- const droppedChannels = [];
264
- for (const item of rawTasks) {
265
- for (const id of unknownChannelIds(item.channels)) {
266
- if (!droppedChannels.includes(id)) droppedChannels.push(id);
267
- }
268
- }
269
- if (droppedChannels.length) {
270
- console.warn('[dsh-cron] import: unknown channel ids were dropped: ' + droppedChannels.join(', '));
271
- }
272
- const existingIds = store.list({ status: 'all' }).map((x) => x.id);
273
- const plan = planImport(checked.tasks, existingIds, strategy);
274
- // Replacing a task the config owns would be undone at the next start, and
275
- // the id could then hold two different tasks (#50).
276
- const configConflicts = plan.replace
277
- .map((item) => item.id)
278
- .filter((taskId) => {
279
- const stored = store.get(taskId);
280
- return stored && stored.managedBy === MANAGED_BY_CONFIG;
281
- });
282
- if (configConflicts.length) {
283
- sendJson(res, 409, {
284
- ok: false,
285
- error: 'These tasks are declared in the profile config and cannot be replaced by an import: ' + configConflicts.join(', '),
286
- });
287
- return;
288
- }
289
- const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
290
- if (body && body.dryRun) {
291
- sendJson(res, 200, {
292
- ok: true,
293
- dryRun: true,
294
- strategy: plan.strategy,
295
- summary,
296
- codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
297
- unknownChannels: droppedChannels,
298
- });
299
- return;
300
- }
301
- const applied = applyImportPlan({ store, scheduler, plan });
302
- if (applied.error) {
303
- sendJson(res, 500, { ok: false, error: applied.error });
304
- return;
305
- }
306
- sendJson(res, 200, {
307
- ok: true,
308
- imported: applied.imported.length,
309
- summary,
310
- unknownChannels: droppedChannels,
311
- tasks: applied.imported,
312
- });
313
- }
314
-
315
- /**
316
- * Write an import plan. Every imported task is paused (the file's status is
317
- * ignored) and every touched id is disarmed explicitly, because writing
318
- * "paused" does not stop an already-armed croner job. A failed write rolls the
319
- * store back so a partial import cannot survive.
320
- */
321
- function applyImportPlan({ store, scheduler, plan }) {
322
- const imported = [];
323
- const rollback = [];
324
- const disarm = [];
325
- try {
326
- for (const task of plan.add) {
327
- const before = store.get(task.id);
328
- const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
329
- imported.push(created);
330
- rollback.push({ id: created.id, previous: before || null });
331
- disarm.push(created.id);
332
- }
333
- for (const task of plan.replace) {
334
- const before = store.get(task.id);
335
- if (!before) continue;
336
- const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
337
- imported.push(replaced);
338
- rollback.push({ id: replaced.id, previous: before });
339
- disarm.push(replaced.id);
340
- }
341
- } catch (writeErr) {
342
- for (const entry of rollback.reverse()) {
343
- try {
344
- if (entry.previous) store.set(entry.previous);
345
- else store.delete(entry.id);
346
- } catch (rollbackErr) {
347
- console.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
348
- }
349
- }
350
- return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
351
- }
352
- // Disarm outside the write transaction: pauseTask is idempotent and a
353
- // failure here must not undo a successful import.
354
- for (const id of disarm) {
355
- try {
356
- scheduler.pauseTask(id);
357
- } catch (err) {
358
- console.error('[dsh-cron] import: could not pause task', id + ':', err.message);
359
- }
360
- }
361
- return { imported };
362
- }
248
+ // export/import logic moved to ./api-import-export.js
363
249
 
364
250
  // ------------------------------------------------------------ single task
365
251
 
@@ -606,175 +492,4 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
606
492
  sendJson(res, 405, NOT_ALLOWED);
607
493
  }
608
494
 
609
- export async function handleTelegramWebhook({ store, scheduler, req, res }) {
610
- if (req.method !== 'POST') {
611
- sendJson(res, 405, NOT_ALLOWED);
612
- return;
613
- }
614
- const { body, error } = await readBody(req, res);
615
- if (error || !body) return;
616
-
617
- const callbackQuery = body.callback_query;
618
- if (!callbackQuery) {
619
- sendJson(res, 200, { ok: true });
620
- return;
621
- }
622
-
623
- const queryId = callbackQuery.id;
624
- const data = String(callbackQuery.data || '');
625
- const fromId = String(callbackQuery.from?.id || '');
626
- const chatId = String(callbackQuery.message?.chat?.id || fromId);
627
-
628
- const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
629
- const defaults = getDshDefaultTelegramCredentials();
630
- const botToken = settings.botToken || defaults.botToken;
631
- const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
632
-
633
- if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
634
- console.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId}`);
635
- if (botToken) {
636
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
637
- }
638
- sendJson(res, 403, { ok: false, error: 'Forbidden' });
639
- return;
640
- }
641
-
642
- const parts = data.split(':');
643
- if (parts[0] !== 'cron' || parts.length < 3) {
644
- if (botToken) {
645
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unknown action' });
646
- }
647
- sendJson(res, 200, { ok: true, ignored: true });
648
- return;
649
- }
650
-
651
- const verb = parts[1];
652
- const taskId = parts.slice(2).join(':');
653
- const task = store.get(taskId);
654
-
655
- if (!task) {
656
- if (botToken) {
657
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Task not found', showAlert: true });
658
- }
659
- sendJson(res, 200, { ok: false, error: 'Task not found' });
660
- return;
661
- }
662
-
663
- if (verb === 'run') {
664
- try {
665
- await scheduler.triggerManualRun(taskId);
666
- if (botToken) {
667
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `🚀 Run triggered: ${task.title}` });
668
- }
669
- } catch (runErr) {
670
- if (botToken) {
671
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `Run error: ${runErr.message}`, showAlert: true });
672
- }
673
- }
674
- } else if (verb === 'pause') {
675
- const isPaused = task.status === 'paused';
676
- const newStatus = isPaused ? 'active' : 'paused';
677
- if (typeof store.update === 'function') {
678
- store.update(taskId, { status: newStatus });
679
- } else {
680
- store.set({ ...task, status: newStatus });
681
- }
682
- if (isPaused) {
683
- scheduler.resume(taskId);
684
- if (botToken) {
685
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
686
- }
687
- } else {
688
- scheduler.pause(taskId);
689
- if (botToken) {
690
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
691
- }
692
- }
693
- } else if (verb === 'log') {
694
- const history = store.getHistory(taskId, 1);
695
- const lastRun = history && history[0];
696
- if (!lastRun) {
697
- if (botToken) {
698
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `No runs recorded for ${task.title}`, showAlert: true });
699
- }
700
- } else {
701
- const summary = `[${task.title}] Last run: ${lastRun.status} (${lastRun.durationMs}ms)\n${lastRun.output || lastRun.error || 'Empty output'}`;
702
- if (botToken) {
703
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: summary.slice(0, 190) });
704
- if (summary.length > 190 && chatId) {
705
- await sendTelegramMessage({
706
- botToken,
707
- chatId,
708
- text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
709
- parseMode: 'Markdown',
710
- }).catch(() => {});
711
- }
712
- }
713
- }
714
- }
715
-
716
- sendJson(res, 200, { ok: true, verb, taskId });
717
- }
718
-
719
- export function handleHeartbeatPing({ store, req, res, taskId }) {
720
- const task = store.get(taskId);
721
- if (!task) {
722
- sendJson(res, 404, { ok: false, error: 'Task not found' });
723
- return;
724
- }
725
- const result = typeof store.recordHeartbeat === 'function' ? store.recordHeartbeat(taskId) : null;
726
- if (!result) {
727
- sendJson(res, 500, { ok: false, error: 'Could not record heartbeat' });
728
- return;
729
- }
730
- sendJson(res, 200, result);
731
- }
732
-
733
- export async function handleSchedulePreview({ req, res, url }) {
734
- let schedule = '';
735
- let timezone = '';
736
- let count = 5;
737
-
738
- if (req.method === 'POST') {
739
- const { body, error } = await readBody(req, res);
740
- if (error) return;
741
- schedule = body.schedule || '';
742
- timezone = body.timezone || '';
743
- count = body.count || 5;
744
- } else if (req.method === 'GET') {
745
- schedule = url.searchParams.get('schedule') || '';
746
- timezone = url.searchParams.get('timezone') || '';
747
- count = parseInt(url.searchParams.get('count') || '5', 10);
748
- } else {
749
- sendJson(res, 405, NOT_ALLOWED);
750
- return;
751
- }
752
-
753
- if (!schedule) {
754
- sendJson(res, 400, { ok: false, error: 'Field "schedule" is required' });
755
- return;
756
- }
757
-
758
- try {
759
- const preview = previewSchedule(schedule, { timezone, count });
760
- sendJson(res, 200, { ok: true, ...preview });
761
- } catch (err) {
762
- sendJson(res, 400, { ok: false, error: err.message });
763
- }
764
- }
765
-
766
- export async function handleDryRunTask({ store, scheduler, req, res, id }) {
767
- if (rejectCrossOrigin(req, res)) return;
768
- const task = store.get(id);
769
- if (!task) {
770
- sendJson(res, 404, { ok: false, error: 'Task not found' });
771
- return;
772
- }
773
-
774
- try {
775
- const outcome = await scheduler.runTask(id, { dryRun: true });
776
- sendJson(res, 200, outcome || { ok: true, status: 'completed' });
777
- } catch (err) {
778
- sendJson(res, 500, { ok: false, error: err.message });
779
- }
780
- }
495
+ // webhook and helper endpoints moved to ./api-webhook.js and ./api-helpers.js