@goodandready/dsh-cron 0.1.14 → 0.1.16

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 CHANGED
@@ -108,6 +108,23 @@ When autonomous agents need to set up delayed or recurring actions, they can inv
108
108
  * **`cron_list_tasks`**: Retrieves an overview of active schedules and next scheduled run timestamps.
109
109
  * **`cron_toggle_task`**: Enables or disables an existing task by `id`.
110
110
 
111
+ ### 6. Telegram Notifications & Delivery Routing
112
+ Direct integration with the Telegram Bot API delivers execution reports and error traces straight to your messenger:
113
+
114
+ * **Auto-Detected or Custom Credentials**: Enter a custom `botToken` and `chatId` in the UI settings dialog, or automatically inherit default credentials from `dsh-messenger-gateway` in `settings.yaml`.
115
+ * **'Only on Failure' Mode (Issue #24)**: Prevent notification spam by enabling `onlyOnFailure` globally or on individual tasks. Clean runs remain silent, while non-zero exit codes or agent exceptions immediately dispatch an alert with stdout/stderr traces.
116
+ * **Markdown Formatting**: Messages are formatted with status badges (✅ / ❌), execution duration in milliseconds, schedule descriptions, and monospace code blocks.
117
+ * **Test Dispatch Button**: Verify Telegram connectivity on the spot before scheduling critical production jobs.
118
+
119
+ ### 7. Overlap Policies & Execution Timeout Control (Issues #11, #17)
120
+ Prevent rogue processes from consuming server resources or stacking concurrent duplicate executions:
121
+
122
+ * **Execution Timeout (`timeoutSeconds`)**: Automatically cancels agent sessions or kills shell subprocesses when the configured run time limit is reached (default: 1800s / 30m). Prevents hung tasks and records a descriptive timeout failure in run logs.
123
+ * **Overlap Policy (`overlapPolicy`)**: Controls scheduler behavior when a scheduled tick fires while the previous execution is still running:
124
+ * **`skip`** (default): Drops the overlapping run and records a `skipped` status entry in the run history without spamming.
125
+ * **`queue`**: Queues the next execution and starts it automatically as soon as the active job completes.
126
+ * **`replace`**: Aborts the stuck/active run immediately via `AbortController` and launches the fresh execution.
127
+
111
128
  ### 5. Schedule Expression Syntax
112
129
  Powered by `croner`, supporting both standard 5-part/6-part cron expressions and user-friendly interval aliases:
113
130
 
package/lib/client.js CHANGED
@@ -27,6 +27,8 @@ const React = window.React || require('react');
27
27
  const ICON_ACTIVITY = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>';
28
28
  const ICON_EDIT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>';
29
29
  const ICON_SEND = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
30
+ const ICON_SETTINGS = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>';
31
+ const ICON_TELEGRAM = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8-1.7 8.01c-.13.58-.47.72-.95.45l-2.6-1.92-1.25 1.21c-.14.14-.26.26-.53.26l.19-2.64 4.81-4.35c.21-.19-.05-.29-.32-.11L8.34 13.5 5.78 12.7c-.56-.17-.57-.56.12-.83l10-3.86c.46-.17.87.11.74.79z"/></svg>';
30
32
 
31
33
  const STYLES = `
32
34
  [data-pane=conversation], [class*=centerCol] {
@@ -134,9 +136,17 @@ const React = window.React || require('react');
134
136
  .dsh-cron-history-status { font-weight: 600; font-size: 11.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
135
137
  .dsh-cron-history-status[data-status="success"] { background: rgba(16, 185, 129, 0.15); color: #10b981; }
136
138
  .dsh-cron-history-status[data-status="error"] { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
139
+ .dsh-cron-history-status[data-status="skipped"] { background: rgba(234, 179, 8, 0.15); color: #eab308; }
137
140
  .dsh-cron-history-time { color: #777; font-size: 12px; }
138
141
  .dsh-cron-history-output { background: #0c0c0c; border: 1px solid #222; border-radius: 6px; padding: 8px 10px; font-family: monospace; font-size: 12px; color: #bbb; max-height: 140px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
139
142
  .dsh-cron-history-empty { text-align: center; padding: 36px 0; color: #666; font-size: 13.5px; }
143
+ .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer; user-select: none; }
144
+ .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #ededed; }
145
+ .dsh-cron-hint { font-size: 12px; color: #888; margin-top: 4px; }
146
+ .dsh-cron-status-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
147
+ .dsh-cron-status-banner[data-type="success"] { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
148
+ .dsh-cron-status-banner[data-type="error"] { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; }
149
+ .dsh-cron-status-banner[data-type="info"] { background: rgba(59,130,246,0.15); border: 1px solid rgba(59,130,246,0.3); color: #60a5fa; }
140
150
  `;
141
151
 
142
152
  function ensureStyles() {
@@ -237,6 +247,19 @@ const React = window.React || require('react');
237
247
  const [modalTab, setModalTab] = React.useState('params');
238
248
  const [taskHistory, setTaskHistory] = React.useState([]);
239
249
  const [historyLoading, setHistoryLoading] = React.useState(false);
250
+ const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
251
+ const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
252
+ const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
253
+ const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
254
+
255
+ // Settings modal state
256
+ const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
257
+ const [settingsBotToken, setSettingsBotToken] = React.useState('');
258
+ const [settingsChatId, setSettingsChatId] = React.useState('');
259
+ const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
260
+ const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
261
+ const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
262
+ const [testStatus, setTestStatus] = React.useState(null);
240
263
 
241
264
  // DSH dialog state
242
265
  const [dshModalOpen, setDshModalOpen] = React.useState(false);
@@ -251,6 +274,7 @@ const React = window.React || require('react');
251
274
  React.useEffect(() => {
252
275
  const onKey = (e) => {
253
276
  if (e.key !== 'Escape') return;
277
+ if (settingsModalOpen) { setSettingsModalOpen(false); return; }
254
278
  if (manualModalOpen) { setManualModalOpen(false); return; }
255
279
  if (dshModalOpen) { setDshModalOpen(false); return; }
256
280
  if (dropdownOpen) { setDropdownOpen(false); return; }
@@ -258,7 +282,7 @@ const React = window.React || require('react');
258
282
  };
259
283
  document.addEventListener('keydown', onKey);
260
284
  return () => document.removeEventListener('keydown', onKey);
261
- }, [manualModalOpen, dshModalOpen, dropdownOpen, onClose]);
285
+ }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
262
286
 
263
287
  const fetchTasks = async () => {
264
288
  try {
@@ -310,12 +334,78 @@ const React = window.React || require('react');
310
334
  } catch (err) {}
311
335
  };
312
336
 
337
+ const openSettingsModal = async () => {
338
+ setTestStatus(null);
339
+ setSettingsModalOpen(true);
340
+ try {
341
+ const res = await fetch('/dsh-cron/settings');
342
+ const data = await res.json();
343
+ if (data && data.ok && data.settings) {
344
+ setSettingsBotToken(data.settings.botToken || '');
345
+ setSettingsChatId(data.settings.chatId || '');
346
+ setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
347
+ setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
348
+ setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
349
+ }
350
+ } catch (e) {}
351
+ };
352
+
353
+ const handleSaveSettings = async (e) => {
354
+ e.preventDefault();
355
+ try {
356
+ const res = await fetch('/dsh-cron/settings', {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({
360
+ botToken: settingsBotToken,
361
+ chatId: settingsChatId,
362
+ notifyTelegram: settingsNotifyGlobal,
363
+ onlyOnFailure: settingsOnlyOnFailureGlobal
364
+ })
365
+ });
366
+ const data = await res.json();
367
+ if (data && data.ok) {
368
+ setSettingsModalOpen(false);
369
+ } else {
370
+ alert('Ошибка сохранения: ' + (data?.error || 'неизвестная ошибка'));
371
+ }
372
+ } catch (err) {
373
+ alert('Ошибка сохранения: ' + err.message);
374
+ }
375
+ };
376
+
377
+ const handleTestTelegram = async () => {
378
+ setTestStatus({ loading: true, message: 'Отправка тестового сообщения...' });
379
+ try {
380
+ const res = await fetch('/dsh-cron/telegram/test', {
381
+ method: 'POST',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ body: JSON.stringify({
384
+ botToken: settingsBotToken,
385
+ chatId: settingsChatId
386
+ })
387
+ });
388
+ const data = await res.json();
389
+ if (data && data.ok) {
390
+ setTestStatus({ loading: false, success: true, message: '✅ Тестовое сообщение успешно отправлено в Telegram!' });
391
+ } else {
392
+ setTestStatus({ loading: false, success: false, message: '❌ ' + (data?.error || 'Не удалось отправить сообщение') });
393
+ }
394
+ } catch (err) {
395
+ setTestStatus({ loading: false, success: false, message: '❌ ' + err.message });
396
+ }
397
+ };
398
+
313
399
  const openManualModal = (preset) => {
314
400
  setDropdownOpen(false);
315
401
  setFormId(null);
316
402
  setSelectedTaskMeta(null);
317
403
  setFormType('llm');
318
404
  setFormDelivery('isolated');
405
+ setFormNotifyTelegram(false);
406
+ setFormOnlyOnFailure(false);
407
+ setFormTimeoutSeconds(1800);
408
+ setFormOverlapPolicy('skip');
319
409
  if (preset) {
320
410
  setFormTitle(preset.title || '');
321
411
  setFormSchedule(preset.schedule || '');
@@ -361,6 +451,10 @@ const React = window.React || require('react');
361
451
  setFormProvider(task.provider || '');
362
452
  setFormModel(task.model || '');
363
453
  setFormDelivery(task.delivery || 'isolated');
454
+ setFormNotifyTelegram(Boolean(task.notifyTelegram));
455
+ setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
456
+ setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
457
+ setFormOverlapPolicy(task.overlapPolicy || 'skip');
364
458
  if (task.provider) loadModels(task.provider);
365
459
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
366
460
  loadTaskHistory(task.id);
@@ -456,14 +550,26 @@ const React = window.React || require('react');
456
550
  React.createElement('div', { className: 'dsh-cron-title' }, 'Запланированные задачи'),
457
551
  React.createElement('div', { className: 'dsh-cron-subtitle' }, 'Попросите DSH планировать задачи, ставить напоминания или отслеживать обновления')
458
552
  ),
459
- React.createElement('div', { style: { position: 'relative' } },
460
- React.createElement('button', { className: 'dsh-cron-create-btn', onClick: () => setDropdownOpen(!dropdownOpen) },
461
- 'Создать',
462
- React.createElement('span', { dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } })
553
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
554
+ React.createElement('button', {
555
+ type: 'button',
556
+ className: 'dsh-cron-btn-secondary',
557
+ style: { display: 'flex', alignItems: 'center', gap: '6px', height: '36px', padding: '0 14px' },
558
+ title: 'Настройки Telegram и уведомлений',
559
+ onClick: openSettingsModal
560
+ },
561
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: '#60a5fa' }, dangerouslySetInnerHTML: { __html: ICON_TELEGRAM } }),
562
+ 'Telegram & Оповещения'
463
563
  ),
464
- dropdownOpen && React.createElement('div', { className: 'dsh-cron-dropdown' },
465
- React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, '💬 Создать с DSH'),
466
- React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, '✏️ Настроить вручную')
564
+ React.createElement('div', { style: { position: 'relative' } },
565
+ React.createElement('button', { className: 'dsh-cron-create-btn', onClick: () => setDropdownOpen(!dropdownOpen) },
566
+ 'Создать',
567
+ React.createElement('span', { dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } })
568
+ ),
569
+ dropdownOpen && React.createElement('div', { className: 'dsh-cron-dropdown' },
570
+ React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, '💬 Создать с DSH'),
571
+ React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, '✏️ Настроить вручную')
572
+ )
467
573
  )
468
574
  )
469
575
  ),
@@ -619,7 +725,7 @@ const React = window.React || require('react');
619
725
  React.createElement('span', {
620
726
  className: 'dsh-cron-history-status',
621
727
  'data-status': run.status
622
- }, run.status === 'success' ? 'Успешно' : 'Ошибка'),
728
+ }, run.status === 'success' ? 'Успешно' : run.status === 'skipped' ? 'Пропущен' : 'Ошибка'),
623
729
  React.createElement('span', { className: 'dsh-cron-history-time' }, run.at ? new Date(run.at).toLocaleString() : '')
624
730
  ),
625
731
  React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
@@ -673,6 +779,29 @@ const React = window.React || require('react');
673
779
  })
674
780
  )
675
781
  ),
782
+ React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
783
+ React.createElement('div', { className: 'dsh-cron-form-group' },
784
+ React.createElement('label', null, 'Таймаут выполнения (секунды)'),
785
+ React.createElement('input', {
786
+ type: 'number',
787
+ min: 1,
788
+ value: formTimeoutSeconds,
789
+ placeholder: '1800',
790
+ onChange: (e) => setFormTimeoutSeconds(e.target.value)
791
+ })
792
+ ),
793
+ React.createElement('div', { className: 'dsh-cron-form-group' },
794
+ React.createElement('label', null, 'Политика наложения (Overlap)'),
795
+ React.createElement('select', {
796
+ value: formOverlapPolicy,
797
+ onChange: (e) => setFormOverlapPolicy(e.target.value)
798
+ },
799
+ React.createElement('option', { value: 'skip' }, 'Пропустить новый запуск (skip)'),
800
+ React.createElement('option', { value: 'queue' }, 'Встать в очередь (queue)'),
801
+ React.createElement('option', { value: 'replace' }, 'Отменить текущий и перезапустить (replace)')
802
+ )
803
+ )
804
+ ),
676
805
  formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
677
806
  React.createElement('div', { className: 'dsh-cron-form-group' },
678
807
  React.createElement('label', null, 'Провайдер LLM'),
@@ -706,6 +835,36 @@ const React = window.React || require('react');
706
835
  onChange: (e) => setFormPrompt(e.target.value)
707
836
  })
708
837
  ),
838
+ React.createElement('div', {
839
+ style: {
840
+ background: 'rgba(255,255,255,0.02)',
841
+ border: '1px solid #2e2e2e',
842
+ borderRadius: '8px',
843
+ padding: '12px 14px',
844
+ marginBottom: '16px'
845
+ }
846
+ },
847
+ React.createElement('div', { style: { fontWeight: 500, fontSize: '13px', color: '#eee', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px' } },
848
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: '#60a5fa' }, dangerouslySetInnerHTML: { __html: ICON_TELEGRAM } }),
849
+ 'Уведомления в Telegram'
850
+ ),
851
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginBottom: formNotifyTelegram ? '8px' : '0' } },
852
+ React.createElement('input', {
853
+ type: 'checkbox',
854
+ checked: formNotifyTelegram,
855
+ onChange: (e) => setFormNotifyTelegram(e.target.checked)
856
+ }),
857
+ React.createElement('span', null, 'Отправлять отчет о выполнении в Telegram')
858
+ ),
859
+ formNotifyTelegram && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
860
+ React.createElement('input', {
861
+ type: 'checkbox',
862
+ checked: formOnlyOnFailure,
863
+ onChange: (e) => setFormOnlyOnFailure(e.target.checked)
864
+ }),
865
+ React.createElement('span', null, 'Только при ошибках (onlyOnFailure / silent mode при успехе)')
866
+ )
867
+ ),
709
868
  selectedTaskMeta && React.createElement('div', {
710
869
  style: {
711
870
  background: 'rgba(255,255,255,0.03)',
@@ -749,6 +908,84 @@ const React = window.React || require('react');
749
908
  )
750
909
  ),
751
910
 
911
+ // 1.5 Settings Modal
912
+ settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
913
+ React.createElement('div', { className: 'dsh-cron-modal', onClick: (e) => e.stopPropagation() },
914
+ React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
915
+ React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' } },
916
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: '#60a5fa' }, dangerouslySetInnerHTML: { __html: ICON_TELEGRAM } }),
917
+ 'Настройки Telegram и уведомлений'
918
+ ),
919
+ React.createElement('button', {
920
+ type: 'button',
921
+ style: { background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '18px' },
922
+ onClick: () => setSettingsModalOpen(false)
923
+ }, '✕')
924
+ ),
925
+ React.createElement('form', { onSubmit: handleSaveSettings },
926
+ React.createElement('div', { className: 'dsh-cron-form-group' },
927
+ React.createElement('label', null, 'Telegram Bot Token'),
928
+ React.createElement('input', {
929
+ type: 'password',
930
+ value: settingsBotToken,
931
+ placeholder: settingsHasDefault ? 'Используется бот из настроек DSH (или введите свой)' : '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
932
+ onChange: (e) => setSettingsBotToken(e.target.value)
933
+ }),
934
+ settingsHasDefault && React.createElement('div', { className: 'dsh-cron-hint' }, '✓ Обнаружен настроенный бот в профиле DSH')
935
+ ),
936
+ React.createElement('div', { className: 'dsh-cron-form-group' },
937
+ React.createElement('label', null, 'Telegram Chat ID'),
938
+ React.createElement('input', {
939
+ value: settingsChatId,
940
+ placeholder: 'Например: 345678901',
941
+ onChange: (e) => setSettingsChatId(e.target.value)
942
+ })
943
+ ),
944
+ React.createElement('div', { style: { background: 'rgba(255,255,255,0.02)', border: '1px solid #2e2e2e', borderRadius: '8px', padding: '12px 14px', marginBottom: '16px' } },
945
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginBottom: '8px' } },
946
+ React.createElement('input', {
947
+ type: 'checkbox',
948
+ checked: settingsNotifyGlobal,
949
+ onChange: (e) => setSettingsNotifyGlobal(e.target.checked)
950
+ }),
951
+ React.createElement('span', null, 'Глобально отправлять отчеты всех задач в Telegram')
952
+ ),
953
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
954
+ React.createElement('input', {
955
+ type: 'checkbox',
956
+ checked: settingsOnlyOnFailureGlobal,
957
+ onChange: (e) => setSettingsOnlyOnFailureGlobal(e.target.checked)
958
+ }),
959
+ React.createElement('span', null, 'Глобальный режим: Только при ошибках (silent при успехе)')
960
+ )
961
+ ),
962
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' } },
963
+ React.createElement('button', {
964
+ type: 'button',
965
+ className: 'dsh-cron-btn-secondary',
966
+ style: { display: 'inline-flex', alignItems: 'center', gap: '6px' },
967
+ disabled: testStatus?.loading,
968
+ onClick: handleTestTelegram
969
+ }, testStatus?.loading ? 'Отправка...' : '🔔 Проверить отправку в Telegram')
970
+ ),
971
+ testStatus && testStatus.message && React.createElement('div', {
972
+ className: 'dsh-cron-status-banner',
973
+ 'data-type': testStatus.success ? 'success' : 'error'
974
+ }, testStatus.message),
975
+ React.createElement('div', { className: 'dsh-cron-modal-foot' },
976
+ React.createElement('button', {
977
+ type: 'button',
978
+ className: 'dsh-cron-btn-secondary',
979
+ onClick: () => setSettingsModalOpen(false)
980
+ }, 'Отмена'),
981
+ React.createElement('button', {
982
+ type: 'submit',
983
+ className: 'dsh-cron-btn-primary'
984
+ }, 'Сохранить настройки')
985
+ )
986
+ )
987
+ )
988
+ ),
752
989
  // 2. "Создать с DSH" modal
753
990
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
754
991
  React.createElement('div', { className: 'dsh-cron-modal', onClick: (e) => e.stopPropagation() },
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
3
3
  import { SessionRunner } from './runner.js';
4
4
  import { getModelsHandler } from './models-handler.js';
5
5
  import { chatStartHandler } from './chat-start.js';
6
+ import { sendTelegramMessage } from './telegram.js';
6
7
  import { defineTool } from '@deepseek-ai/dsh-tools';
7
8
  import { randomUUID } from 'node:crypto';
8
9
 
@@ -32,7 +33,7 @@ function sendJson(res, statusCode, data) {
32
33
  export function apply(ctx, config) {
33
34
  const store = new TaskStore();
34
35
  const runner = new SessionRunner(ctx);
35
- const scheduler = new TaskScheduler(store, (task) => runner.execute(task));
36
+ const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
36
37
 
37
38
  scheduler.start();
38
39
 
@@ -106,6 +107,10 @@ export function apply(ctx, config) {
106
107
  status: body.status || 'active',
107
108
  provider: body.provider || undefined,
108
109
  model: body.model || undefined,
110
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : false,
111
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : false,
112
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : 1800,
113
+ overlapPolicy: body.overlapPolicy || 'skip',
109
114
  });
110
115
  scheduler.scheduleTask(task);
111
116
  sendJson(res, 200, { ok: true, task });
@@ -137,6 +142,67 @@ export function apply(ctx, config) {
137
142
  }
138
143
  }), 'dsh-cron: /chat/start');
139
144
 
145
+ // GET & POST /dsh-cron/settings
146
+ ctx.effect(() => ctx.webServer.register({
147
+ kind: 'exact',
148
+ path: '/dsh-cron/settings',
149
+ handler: async (req, res) => {
150
+ try {
151
+ if (req.method === 'GET') {
152
+ sendJson(res, 200, { ok: true, settings: store.getSettings() });
153
+ return;
154
+ }
155
+ if (req.method === 'POST') {
156
+ const body = await parseJsonBody(req);
157
+ const updated = store.saveSettings(body);
158
+ sendJson(res, 200, { ok: true, settings: updated });
159
+ return;
160
+ }
161
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
162
+ } catch (err) {
163
+ sendJson(res, 500, { ok: false, error: err.message });
164
+ }
165
+ }
166
+ }), 'dsh-cron: /settings');
167
+
168
+ // POST /dsh-cron/telegram/test
169
+ ctx.effect(() => ctx.webServer.register({
170
+ kind: 'exact',
171
+ path: '/dsh-cron/telegram/test',
172
+ handler: async (req, res) => {
173
+ try {
174
+ if (req.method !== 'POST') {
175
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
176
+ return;
177
+ }
178
+ const body = await parseJsonBody(req);
179
+ const currentSettings = store.getSettings();
180
+ const botToken = (body.botToken || currentSettings.botToken || '').trim();
181
+ const chatId = (body.chatId || currentSettings.chatId || '').trim();
182
+
183
+ if (!botToken) {
184
+ sendJson(res, 400, { ok: false, error: 'Укажите Telegram Bot Token' });
185
+ return;
186
+ }
187
+ if (!chatId) {
188
+ sendJson(res, 400, { ok: false, error: 'Укажите Telegram Chat ID' });
189
+ return;
190
+ }
191
+
192
+ await sendTelegramMessage({
193
+ botToken,
194
+ chatId,
195
+ text: '⏰ *DSH Cron — Проверка связи*\nТестовое уведомление успешно доставлено! Планировщик готов к отправке отчётов.',
196
+ parseMode: 'Markdown'
197
+ });
198
+
199
+ sendJson(res, 200, { ok: true, message: 'Сообщение успешно отправлено в Telegram' });
200
+ } catch (err) {
201
+ sendJson(res, 500, { ok: false, error: err.message });
202
+ }
203
+ }
204
+ }), 'dsh-cron: /telegram/test');
205
+
140
206
  // Prefix routes for actions (/dsh-cron/action/*)
141
207
  ctx.effect(() => ctx.webServer.register({
142
208
  kind: 'prefix',
@@ -204,7 +270,11 @@ export function apply(ctx, config) {
204
270
  delivery: { type: 'string', description: 'Режим запуска: current или isolated' },
205
271
  type: { type: 'string', description: 'Тип задачи: "llm" (агент с LLM) или "script" (прямой запуск shell без LLM)' },
206
272
  provider: { type: 'string', description: 'ID провайдера LLM для выполнения задачи' },
207
- model: { type: 'string', description: 'ID модели LLM для выполнения задачи' }
273
+ model: { type: 'string', description: 'ID модели LLM для выполнения задачи' },
274
+ notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
275
+ onlyOnFailure: { type: 'boolean', description: 'Отправлять в Telegram только при ошибках' },
276
+ timeoutSeconds: { type: 'integer', description: 'Таймаут выполнения задачи в секундах (по умолчанию 1800 = 30 мин)' },
277
+ overlapPolicy: { type: 'string', description: 'Политика наложения: "skip" (по умолчанию), "queue", "replace"' }
208
278
  },
209
279
  output: {
210
280
  schema: {
@@ -219,7 +289,7 @@ export function apply(ctx, config) {
219
289
  return [{ type: 'text', text: value.message }];
220
290
  }
221
291
  },
222
- execute: async ({ title, schedule, prompt, delivery, provider, model, type }) => {
292
+ execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure, timeoutSeconds, overlapPolicy }) => {
223
293
  try {
224
294
  const parsed = parseScheduleExpression(schedule);
225
295
  const task = store.set({
@@ -231,6 +301,10 @@ export function apply(ctx, config) {
231
301
  delivery: delivery || 'isolated',
232
302
  provider: provider || undefined,
233
303
  model: model || undefined,
304
+ notifyTelegram: Boolean(notifyTelegram),
305
+ onlyOnFailure: Boolean(onlyOnFailure),
306
+ timeoutSeconds: timeoutSeconds !== undefined ? Number(timeoutSeconds) : 1800,
307
+ overlapPolicy: overlapPolicy || 'skip',
234
308
  status: 'active'
235
309
  });
236
310
  scheduler.scheduleTask(task);
@@ -378,4 +452,4 @@ export function apply(ctx, config) {
378
452
  return;
379
453
  }
380
454
  });
381
- }
455
+ }
package/lib/runner.js CHANGED
@@ -6,25 +6,62 @@ export class SessionRunner {
6
6
  this.ctx = ctx;
7
7
  }
8
8
 
9
- async execute(task) {
9
+ async execute(task, options = {}) {
10
+ const timeoutSeconds = Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) : 1800;
11
+ const timeoutMs = timeoutSeconds * 1000;
12
+ const externalSignal = options.signal;
13
+
14
+ // Internal abort controller combining external signal + timeout
15
+ const controller = new AbortController();
16
+ let timeoutId = null;
17
+
18
+ if (externalSignal) {
19
+ if (externalSignal.aborted) {
20
+ throw new Error(externalSignal.reason?.message || 'Task aborted before execution');
21
+ }
22
+ externalSignal.addEventListener('abort', () => {
23
+ controller.abort(externalSignal.reason || new Error('Task aborted by overlap policy'));
24
+ }, { once: true });
25
+ }
26
+
27
+ const timeoutPromise = new Promise((_, reject) => {
28
+ timeoutId = setTimeout(() => {
29
+ const timeoutErr = new Error(`Task execution timed out after ${timeoutSeconds} seconds`);
30
+ controller.abort(timeoutErr);
31
+ reject(timeoutErr);
32
+ }, timeoutMs);
33
+ });
34
+
35
+ const executionPromise = this._run(task, controller.signal);
36
+
37
+ try {
38
+ return await Promise.race([executionPromise, timeoutPromise]);
39
+ } finally {
40
+ if (timeoutId) clearTimeout(timeoutId);
41
+ }
42
+ }
43
+
44
+ async _run(task, signal) {
10
45
  console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
11
46
 
12
47
  // 1. Direct shell / script task fallback (NO-LLM)
13
48
  if (task.type === 'script' || task.mode === 'no-llm') {
14
49
  const { exec } = await import('node:child_process');
15
- const { promisify } = await import('node:util');
16
- const execAsync = promisify(exec);
17
- try {
18
- const { stdout, stderr } = await execAsync(task.prompt, {
19
- timeout: 60000,
20
- cwd: process.cwd()
50
+ return new Promise((resolve, reject) => {
51
+ const child = exec(task.prompt, {
52
+ cwd: process.cwd(),
53
+ signal,
54
+ }, (err, stdout, stderr) => {
55
+ if (err) {
56
+ if (signal && signal.aborted) {
57
+ return reject(signal.reason || new Error('Command aborted'));
58
+ }
59
+ return reject(err);
60
+ }
61
+ const out = (stdout || stderr || '').trim();
62
+ resolve(out || 'Script completed with code 0');
21
63
  });
22
- const out = (stdout || stderr || '').trim();
23
- return out || 'Script completed with code 0';
24
- } catch (err) {
25
- console.error(`[dsh-cron] script task failed:`, err.message);
26
- throw err;
27
- }
64
+ });
28
65
  }
29
66
 
30
67
  // 2. If ctx.agents is available, run through agent session (LLM)
@@ -59,6 +96,10 @@ export class SessionRunner {
59
96
  agentOptions: provider && model ? { provider, model } : undefined,
60
97
  });
61
98
 
99
+ if (signal && signal.aborted) {
100
+ throw (signal.reason || new Error('Aborted'));
101
+ }
102
+
62
103
  await handle.agent.whenIdle();
63
104
  handle.agent.followup(createUserMessage({
64
105
  content: [{ type: 'text', text: task.prompt }],
@@ -92,4 +133,4 @@ export class SessionRunner {
92
133
 
93
134
  return `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`;
94
135
  }
95
- }
136
+ }
package/lib/scheduler.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Cron } from 'croner';
2
+ import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
2
3
 
3
4
  function pad(v) {
4
5
  return String(v).padStart(2, '0');
@@ -82,6 +83,7 @@ export class TaskScheduler {
82
83
  this.store = store;
83
84
  this.executeFn = executeFn;
84
85
  this.jobs = new Map();
86
+ this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
85
87
  }
86
88
 
87
89
  start() {
@@ -100,6 +102,13 @@ export class TaskScheduler {
100
102
  } catch {}
101
103
  }
102
104
  this.jobs.clear();
105
+
106
+ for (const [, run] of this.running.entries()) {
107
+ try {
108
+ run.controller.abort(new Error('Планировщик остановлен'));
109
+ } catch {}
110
+ }
111
+ this.running.clear();
103
112
  }
104
113
 
105
114
  scheduleTask(task) {
@@ -131,6 +140,41 @@ export class TaskScheduler {
131
140
  const task = this.store.get(taskId);
132
141
  if (!task) return;
133
142
 
143
+ const overlapPolicy = task.overlapPolicy || 'skip';
144
+
145
+ // Check overlap with currently running task
146
+ const active = this.running.get(taskId);
147
+ if (active) {
148
+ if (overlapPolicy === 'skip') {
149
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, skipping run (overlapPolicy: skip)`);
150
+ this.store.recordRun(taskId, {
151
+ at: Date.now(),
152
+ status: 'skipped',
153
+ durationMs: 0,
154
+ output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
155
+ error: null,
156
+ });
157
+ return;
158
+ }
159
+ if (overlapPolicy === 'replace') {
160
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
161
+ active.controller.abort(new Error('Прервано из-за политики overlap: replace'));
162
+ }
163
+ if (overlapPolicy === 'queue') {
164
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
165
+ active.queueCount = (active.queueCount || 0) + 1;
166
+ return;
167
+ }
168
+ }
169
+
170
+ const controller = new AbortController();
171
+ const currentRun = {
172
+ controller,
173
+ startedAt: Date.now(),
174
+ queueCount: 0,
175
+ };
176
+ this.running.set(taskId, currentRun);
177
+
134
178
  const start = Date.now();
135
179
  let status = 'success';
136
180
  let output = '';
@@ -138,20 +182,46 @@ export class TaskScheduler {
138
182
 
139
183
  try {
140
184
  if (typeof this.executeFn === 'function') {
141
- output = await this.executeFn(task);
185
+ output = await this.executeFn(task, { signal: controller.signal });
142
186
  }
143
187
  } catch (err) {
144
188
  status = 'error';
145
189
  error = err.message || String(err);
146
190
  } finally {
147
191
  const durationMs = Date.now() - start;
148
- this.store.recordRun(taskId, {
192
+ const runInfo = {
149
193
  at: start,
150
194
  status,
151
195
  durationMs,
152
196
  output,
153
197
  error,
154
- });
198
+ };
199
+ this.store.recordRun(taskId, runInfo);
200
+
201
+ // Clean up running map
202
+ const finishedRun = this.running.get(taskId);
203
+ if (finishedRun === currentRun) {
204
+ this.running.delete(taskId);
205
+ }
206
+
207
+ const queuedCount = finishedRun?.queueCount || 0;
208
+
209
+ // Telegram notification delivery
210
+ try {
211
+ const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
212
+ if (shouldNotifyTask(task, runInfo, settings)) {
213
+ const botToken = settings.botToken;
214
+ const chatId = settings.chatId;
215
+ if (botToken && chatId) {
216
+ const text = formatTaskTelegramMessage(task, runInfo);
217
+ sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
218
+ console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
219
+ });
220
+ }
221
+ }
222
+ } catch (notifyErr) {
223
+ console.error('[dsh-cron] notification error:', notifyErr.message);
224
+ }
155
225
 
156
226
  const job = this.jobs.get(taskId);
157
227
  if (job) {
@@ -159,6 +229,12 @@ export class TaskScheduler {
159
229
  task.nextRunAt = next ? next.getTime() : null;
160
230
  this.store.set(task);
161
231
  }
232
+
233
+ if (queuedCount > 0) {
234
+ setImmediate(() => {
235
+ this.runTask(taskId);
236
+ });
237
+ }
162
238
  }
163
239
  }
164
240
 
@@ -171,6 +247,11 @@ export class TaskScheduler {
171
247
  this.jobs.get(taskId).stop();
172
248
  this.jobs.delete(taskId);
173
249
  }
250
+ const running = this.running.get(taskId);
251
+ if (running) {
252
+ running.controller.abort(new Error('Задача приостановлена'));
253
+ this.running.delete(taskId);
254
+ }
174
255
  this.store.set(task);
175
256
  return task;
176
257
  }
@@ -189,6 +270,11 @@ export class TaskScheduler {
189
270
  this.jobs.get(taskId).stop();
190
271
  this.jobs.delete(taskId);
191
272
  }
273
+ const running = this.running.get(taskId);
274
+ if (running) {
275
+ running.controller.abort(new Error('Задача удалена'));
276
+ this.running.delete(taskId);
277
+ }
192
278
  return this.store.delete(taskId);
193
279
  }
194
280
 
@@ -202,4 +288,4 @@ export class TaskScheduler {
202
288
  return null;
203
289
  }
204
290
  }
205
- }
291
+ }
package/lib/store.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { getDshDefaultTelegramCredentials } from './telegram.js';
3
4
 
4
5
  export function getDefaultStorePath() {
5
6
  const base = process.env.DSH_DATA_DIR || path.join(process.env.HOME || '.', '.dsh', 'data');
@@ -11,6 +12,7 @@ export class TaskStore {
11
12
  this.filePath = filePath || getDefaultStorePath();
12
13
  this.tasks = new Map();
13
14
  this.history = new Map();
15
+ this.settings = {};
14
16
  this.init();
15
17
  }
16
18
 
@@ -33,6 +35,9 @@ export class TaskStore {
33
35
  this.history.set(k, Array.isArray(v) ? v : []);
34
36
  }
35
37
  }
38
+ if (data.settings && typeof data.settings === 'object') {
39
+ this.settings = data.settings;
40
+ }
36
41
  }
37
42
  } catch (err) {
38
43
  console.error('[dsh-cron] store read error:', err.message);
@@ -50,6 +55,7 @@ export class TaskStore {
50
55
  updatedAt: new Date().toISOString(),
51
56
  tasks: Array.from(this.tasks.values()),
52
57
  history: Object.fromEntries(this.history.entries()),
58
+ settings: this.settings || {},
53
59
  };
54
60
  const tmp = this.filePath + '.tmp.' + Date.now();
55
61
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
@@ -59,6 +65,29 @@ export class TaskStore {
59
65
  }
60
66
  }
61
67
 
68
+ getSettings() {
69
+ const defaults = getDshDefaultTelegramCredentials();
70
+ return {
71
+ botToken: this.settings.botToken !== undefined ? this.settings.botToken : (defaults.botToken || ''),
72
+ chatId: this.settings.chatId !== undefined ? this.settings.chatId : (defaults.chatId || ''),
73
+ notifyTelegram: Boolean(this.settings.notifyTelegram),
74
+ onlyOnFailure: Boolean(this.settings.onlyOnFailure),
75
+ hasDefaultCredentials: Boolean(defaults.botToken && defaults.chatId),
76
+ };
77
+ }
78
+
79
+ saveSettings(newSettings = {}) {
80
+ this.settings = {
81
+ ...this.settings,
82
+ botToken: newSettings.botToken !== undefined ? String(newSettings.botToken).trim() : (this.settings.botToken || ''),
83
+ chatId: newSettings.chatId !== undefined ? String(newSettings.chatId).trim() : (this.settings.chatId || ''),
84
+ notifyTelegram: newSettings.notifyTelegram !== undefined ? Boolean(newSettings.notifyTelegram) : Boolean(this.settings.notifyTelegram),
85
+ onlyOnFailure: newSettings.onlyOnFailure !== undefined ? Boolean(newSettings.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
86
+ };
87
+ this.save();
88
+ return this.getSettings();
89
+ }
90
+
62
91
  list(filter = {}) {
63
92
  let all = Array.from(this.tasks.values());
64
93
  if (filter.status && filter.status !== 'all') {
@@ -96,6 +125,10 @@ export class TaskStore {
96
125
  delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
97
126
  provider: task.provider !== undefined ? task.provider : prev.provider,
98
127
  model: task.model !== undefined ? task.model : prev.model,
128
+ notifyTelegram: task.notifyTelegram !== undefined ? Boolean(task.notifyTelegram) : (prev.notifyTelegram || false),
129
+ onlyOnFailure: task.onlyOnFailure !== undefined ? Boolean(task.onlyOnFailure) : (prev.onlyOnFailure || false),
130
+ timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
131
+ overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
99
132
  createdAt: prev.createdAt || task.createdAt || now,
100
133
  updatedAt: now,
101
134
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
@@ -141,4 +174,4 @@ export class TaskStore {
141
174
  const runs = this.history.get(id) || [];
142
175
  return runs.slice(0, limit);
143
176
  }
144
- }
177
+ }
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ /**
6
+ * Extract simple key from YAML text without external dependencies.
7
+ */
8
+ export function extractSettingFromYaml(text, section, key) {
9
+ if (!text) return undefined;
10
+ const lines = text.split('\n');
11
+ let inside = false;
12
+ for (const line of lines) {
13
+ if (/^[^\s#]/.test(line)) inside = line.startsWith(section + ':');
14
+ if (!inside) continue;
15
+ const hit = new RegExp('^[ \t]+' + key + ':[ \t]*(.+?)[ \t]*$').exec(line);
16
+ if (hit) return hit[1].replace(/^['"]|['"]$/g, '').trim();
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ /**
22
+ * Extract first allowed chat id from YAML allowedUserIds list.
23
+ */
24
+ export function extractFirstAllowedChatId(text) {
25
+ if (!text) return undefined;
26
+ const lines = text.split('\n');
27
+ let armed = false;
28
+ for (const line of lines) {
29
+ if (/allowedUserIds:/.test(line)) { armed = true; continue; }
30
+ if (!armed) continue;
31
+ const hit = /^\s*-\s*(\d+)\s*$/.exec(line);
32
+ if (hit) return hit[1];
33
+ if (/^\s*[a-zA-Z]/.test(line)) return undefined;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /**
39
+ * Read system DSH settings for fallback credentials.
40
+ */
41
+ export function getDshDefaultTelegramCredentials() {
42
+ try {
43
+ const p = path.join(process.env.HOME || os.homedir(), '.dsh', 'settings.yaml');
44
+ if (fs.existsSync(p)) {
45
+ const raw = fs.readFileSync(p, 'utf-8');
46
+ const botToken = extractSettingFromYaml(raw, 'dsh-messenger-gateway', 'botToken');
47
+ const chatId = extractFirstAllowedChatId(raw);
48
+ return { botToken, chatId };
49
+ }
50
+ } catch {}
51
+ return { botToken: undefined, chatId: undefined };
52
+ }
53
+
54
+ /**
55
+ * Escape markdown special characters for Telegram legacy markdown.
56
+ */
57
+ export function escapeMarkdown(str) {
58
+ if (!str) return '';
59
+ return String(str).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
60
+ }
61
+
62
+ /**
63
+ * Format task execution report for Telegram.
64
+ */
65
+ export function formatTaskTelegramMessage(task, runInfo) {
66
+ const isSuccess = runInfo.status === 'success';
67
+ const statusEmoji = isSuccess ? '✅' : '❌';
68
+ const statusText = isSuccess ? 'Успешно' : 'Ошибка';
69
+ const duration = runInfo.durationMs != null ? `${runInfo.durationMs} ms` : '—';
70
+ const title = task.title || 'Задача';
71
+ const schedule = task.scheduleText || task.schedule || '';
72
+
73
+ const lines = [
74
+ `⏰ *DSH Cron:* ${title}`,
75
+ `*Статус:* ${statusEmoji} ${statusText}`,
76
+ schedule ? `*Расписание:* ${schedule}` : null,
77
+ `*Длительность:* ${duration}`,
78
+ ].filter(Boolean);
79
+
80
+ if (runInfo.error) {
81
+ const errText = String(runInfo.error).slice(0, 1500);
82
+ lines.push('', '*Ошибка:*', '```', errText, '```');
83
+ } else if (runInfo.output && isSuccess) {
84
+ const outText = String(runInfo.output).trim().slice(0, 2000);
85
+ if (outText) {
86
+ lines.push('', '*Вывод:*', '```', outText, '```');
87
+ }
88
+ }
89
+
90
+ return lines.join('\n');
91
+ }
92
+
93
+ /**
94
+ * Send telegram message via Bot API.
95
+ */
96
+ export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown' }) {
97
+ if (!botToken) throw new Error('Telegram botToken не указан');
98
+ if (!chatId) throw new Error('Telegram chatId не указан');
99
+
100
+ const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
101
+ const body = {
102
+ chat_id: chatId,
103
+ text,
104
+ disable_notification: false,
105
+ };
106
+ if (parseMode) body.parse_mode = parseMode;
107
+
108
+ const res = await fetch(url, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify(body),
112
+ });
113
+
114
+ const data = await res.json().catch(() => ({}));
115
+ if (!res.ok || !data.ok) {
116
+ if (parseMode && data.description && data.description.includes("can't parse entities")) {
117
+ delete body.parse_mode;
118
+ const retryRes = await fetch(url, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify(body),
122
+ });
123
+ const retryData = await retryRes.json().catch(() => ({}));
124
+ if (retryRes.ok && retryData.ok) {
125
+ return retryData;
126
+ }
127
+ }
128
+ const msg = data.description || res.statusText || `HTTP ${res.status}`;
129
+ throw new Error(`Telegram API Error: ${msg}`);
130
+ }
131
+ return data;
132
+ }
133
+
134
+ /**
135
+ * Determine if a notification should be sent for a given task and run result.
136
+ */
137
+ export function shouldNotifyTask(task, runInfo, globalSettings = {}) {
138
+ const isEnabled = task.notifyTelegram ?? globalSettings.notifyTelegram ?? false;
139
+ if (!isEnabled) return false;
140
+
141
+ const onlyOnFail = task.onlyOnFailure ?? globalSettings.onlyOnFailure ?? false;
142
+ if (onlyOnFail && runInfo.status !== 'error') {
143
+ return false;
144
+ }
145
+
146
+ return true;
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",