@goodandready/dsh-cron 0.2.9 → 0.2.11

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/lib/api.js CHANGED
@@ -6,12 +6,13 @@
6
6
  */
7
7
 
8
8
  import { randomUUID, createHash } from 'node:crypto';
9
- import { parseScheduleExpression } from './scheduler.js';
9
+ import { parseScheduleExpression, previewSchedule } 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';
15
16
  import {
16
17
  validateTaskType,
17
18
  buildDuplicateTask,
@@ -54,6 +55,21 @@ export function createCronApiHandler(store, scheduler, collection) {
54
55
  const id = parts[2];
55
56
  const action = parts[3];
56
57
 
58
+ if (scope === 'telegram' && id === 'webhook') {
59
+ await handleTelegramWebhook({ store, scheduler, req, res });
60
+ return;
61
+ }
62
+
63
+ if (scope === 'heartbeat' && id) {
64
+ handleHeartbeatPing({ store, req, res, taskId: id });
65
+ return;
66
+ }
67
+
68
+ if (scope === 'schedule' && id === 'preview') {
69
+ await handleSchedulePreview({ req, res, url });
70
+ return;
71
+ }
72
+
57
73
  if (!id) {
58
74
  await handleTaskCollection({ store, scheduler, collection, req, res, url, scope });
59
75
  return;
@@ -346,6 +362,31 @@ function applyImportPlan({ store, scheduler, plan }) {
346
362
  // ------------------------------------------------------------ single task
347
363
 
348
364
  async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
365
+ if ((req.method === 'GET' || req.method === 'POST') && action === 'heartbeat') {
366
+ handleHeartbeatPing({ store, req, res, taskId: id });
367
+ return;
368
+ }
369
+
370
+ if (req.method === 'POST' && action === 'dry-run') {
371
+ await handleDryRunTask({ store, scheduler, req, res, id });
372
+ return;
373
+ }
374
+
375
+ if (req.method === 'GET' && action === 'archive') {
376
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
377
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
378
+ const search = url.searchParams.get('search') || '';
379
+ const result = typeof store.getArchivedRuns === 'function'
380
+ ? store.getArchivedRuns(id, { limit, offset, search })
381
+ : { runs: [], total: 0 };
382
+ sendJson(res, 200, { ok: true, taskId: id, ...result });
383
+ return;
384
+ }
385
+ if (req.method === 'GET' && action === 'stats') {
386
+ const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
387
+ sendJson(res, 200, { ok: true, taskId: id, stats });
388
+ return;
389
+ }
349
390
  if (req.method === 'GET' && (action === 'history' || !action)) {
350
391
  const limit = parseInt(url.searchParams.get('limit') || '20', 10);
351
392
  sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
@@ -511,7 +552,15 @@ function mergeExecutionFields(current, body, parsed) {
511
552
  * tasks and the config-owned refusals are the same code, not a second copy.
512
553
  */
513
554
  export async function handleExternalTaskRequest({ store, scheduler, req, res, url }) {
514
- const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, tasks, :id?, :action?
555
+ const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
556
+ if (parts[2] === 'heartbeat' && parts[3]) {
557
+ handleHeartbeatPing({ store, req, res, taskId: parts[3] });
558
+ return;
559
+ }
560
+ if (parts[2] === 'schedule' && parts[3] === 'preview') {
561
+ await handleSchedulePreview({ req, res, url });
562
+ return;
563
+ }
515
564
  const id = parts[3];
516
565
  const action = parts[4];
517
566
 
@@ -550,3 +599,176 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
550
599
  }
551
600
  sendJson(res, 405, NOT_ALLOWED);
552
601
  }
602
+
603
+ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
604
+ if (req.method !== 'POST') {
605
+ sendJson(res, 405, NOT_ALLOWED);
606
+ return;
607
+ }
608
+ const { body, error } = await readBody(req, res);
609
+ if (error || !body) return;
610
+
611
+ const callbackQuery = body.callback_query;
612
+ if (!callbackQuery) {
613
+ sendJson(res, 200, { ok: true });
614
+ return;
615
+ }
616
+
617
+ const queryId = callbackQuery.id;
618
+ const data = String(callbackQuery.data || '');
619
+ const fromId = String(callbackQuery.from?.id || '');
620
+ const chatId = String(callbackQuery.message?.chat?.id || fromId);
621
+
622
+ const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
623
+ const defaults = getDshDefaultTelegramCredentials();
624
+ const botToken = settings.botToken || defaults.botToken;
625
+ const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
626
+
627
+ if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
628
+ console.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId}`);
629
+ if (botToken) {
630
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
631
+ }
632
+ sendJson(res, 403, { ok: false, error: 'Forbidden' });
633
+ return;
634
+ }
635
+
636
+ const parts = data.split(':');
637
+ if (parts[0] !== 'cron' || parts.length < 3) {
638
+ if (botToken) {
639
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unknown action' });
640
+ }
641
+ sendJson(res, 200, { ok: true, ignored: true });
642
+ return;
643
+ }
644
+
645
+ const verb = parts[1];
646
+ const taskId = parts.slice(2).join(':');
647
+ const task = store.get(taskId);
648
+
649
+ if (!task) {
650
+ if (botToken) {
651
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Task not found', showAlert: true });
652
+ }
653
+ sendJson(res, 200, { ok: false, error: 'Task not found' });
654
+ return;
655
+ }
656
+
657
+ if (verb === 'run') {
658
+ try {
659
+ await scheduler.triggerManualRun(taskId);
660
+ if (botToken) {
661
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `🚀 Run triggered: ${task.title}` });
662
+ }
663
+ } catch (runErr) {
664
+ if (botToken) {
665
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `Run error: ${runErr.message}`, showAlert: true });
666
+ }
667
+ }
668
+ } else if (verb === 'pause') {
669
+ const isPaused = task.status === 'paused';
670
+ const newStatus = isPaused ? 'active' : 'paused';
671
+ if (typeof store.update === 'function') {
672
+ store.update(taskId, { status: newStatus });
673
+ } else {
674
+ store.set({ ...task, status: newStatus });
675
+ }
676
+ if (isPaused) {
677
+ scheduler.resume(taskId);
678
+ if (botToken) {
679
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
680
+ }
681
+ } else {
682
+ scheduler.pause(taskId);
683
+ if (botToken) {
684
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
685
+ }
686
+ }
687
+ } else if (verb === 'log') {
688
+ const history = store.getHistory(taskId, 1);
689
+ const lastRun = history && history[0];
690
+ if (!lastRun) {
691
+ if (botToken) {
692
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `No runs recorded for ${task.title}`, showAlert: true });
693
+ }
694
+ } else {
695
+ const summary = `[${task.title}] Last run: ${lastRun.status} (${lastRun.durationMs}ms)\n${lastRun.output || lastRun.error || 'Empty output'}`;
696
+ if (botToken) {
697
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: summary.slice(0, 190) });
698
+ if (summary.length > 190 && chatId) {
699
+ await sendTelegramMessage({
700
+ botToken,
701
+ chatId,
702
+ text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
703
+ parseMode: 'Markdown',
704
+ }).catch(() => {});
705
+ }
706
+ }
707
+ }
708
+ }
709
+
710
+ sendJson(res, 200, { ok: true, verb, taskId });
711
+ }
712
+
713
+ export function handleHeartbeatPing({ store, req, res, taskId }) {
714
+ const task = store.get(taskId);
715
+ if (!task) {
716
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
717
+ return;
718
+ }
719
+ const result = typeof store.recordHeartbeat === 'function' ? store.recordHeartbeat(taskId) : null;
720
+ if (!result) {
721
+ sendJson(res, 500, { ok: false, error: 'Could not record heartbeat' });
722
+ return;
723
+ }
724
+ sendJson(res, 200, result);
725
+ }
726
+
727
+ export async function handleSchedulePreview({ req, res, url }) {
728
+ let schedule = '';
729
+ let timezone = '';
730
+ let count = 5;
731
+
732
+ if (req.method === 'POST') {
733
+ const { body, error } = await readBody(req, res);
734
+ if (error) return;
735
+ schedule = body.schedule || '';
736
+ timezone = body.timezone || '';
737
+ count = body.count || 5;
738
+ } else if (req.method === 'GET') {
739
+ schedule = url.searchParams.get('schedule') || '';
740
+ timezone = url.searchParams.get('timezone') || '';
741
+ count = parseInt(url.searchParams.get('count') || '5', 10);
742
+ } else {
743
+ sendJson(res, 405, NOT_ALLOWED);
744
+ return;
745
+ }
746
+
747
+ if (!schedule) {
748
+ sendJson(res, 400, { ok: false, error: 'Field "schedule" is required' });
749
+ return;
750
+ }
751
+
752
+ try {
753
+ const preview = previewSchedule(schedule, { timezone, count });
754
+ sendJson(res, 200, { ok: true, ...preview });
755
+ } catch (err) {
756
+ sendJson(res, 400, { ok: false, error: err.message });
757
+ }
758
+ }
759
+
760
+ export async function handleDryRunTask({ store, scheduler, req, res, id }) {
761
+ if (rejectCrossOrigin(req, res)) return;
762
+ const task = store.get(id);
763
+ if (!task) {
764
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
765
+ return;
766
+ }
767
+
768
+ try {
769
+ const outcome = await scheduler.runTask(id, { dryRun: true });
770
+ sendJson(res, 200, outcome || { ok: true, status: 'completed' });
771
+ } catch (err) {
772
+ sendJson(res, 500, { ok: false, error: err.message });
773
+ }
774
+ }
package/lib/channels.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * collected so one broken channel cannot hide another.
8
8
  */
9
9
 
10
- import { formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
10
+ import { formatTaskTelegramMessage, sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
11
11
  import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
12
12
  import { resolveTemplateText, truncateText } from './templates.js';
13
13
 
@@ -256,7 +256,8 @@ export const CHANNEL_HANDLERS = {
256
256
  ? messageTextFor('telegram', task, runInfo, settings)
257
257
  : formatTaskTelegramMessage(task, runInfo);
258
258
  try {
259
- await sendTelegramMessage({ botToken, chatId, text, fetchFn: http, signal });
259
+ const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
260
+ await sendTelegramMessage({ botToken, chatId, text, replyMarkup, fetchFn: http, signal });
260
261
  } catch (err) {
261
262
  if (isAbort(err)) throw new Error(`telegram: timed out after ${timeoutMs} ms`);
262
263
  throw err;