@goodandready/dsh-cron 0.1.13 → 0.1.15

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,14 @@ 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
+
111
119
  ### 5. Schedule Expression Syntax
112
120
  Powered by `croner`, supporting both standard 5-part/6-part cron expressions and user-friendly interval aliases:
113
121
 
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] {
@@ -123,6 +125,27 @@ const React = window.React || require('react');
123
125
  .dsh-cron-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
124
126
  .dsh-cron-card-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
125
127
  .dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888); margin-bottom: 16px; line-height: 1.4; }
128
+
129
+ .dsh-cron-modal-nav { display: flex; gap: 4px; border-bottom: 1px solid #2e2e2e; margin-bottom: 18px; padding-bottom: 2px; }
130
+ .dsh-cron-modal-tab { appearance: none; background: transparent; border: none; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13.5px; color: #888; cursor: pointer; font-weight: 500; transition: color 0.15s, border-color 0.15s; }
131
+ .dsh-cron-modal-tab:hover { color: #ccc; }
132
+ .dsh-cron-modal-tab[data-active="true"] { color: #fff; border-bottom-color: #ededed; }
133
+ .dsh-cron-history-list { display: flex; flex-direction: column; gap: 10px; max-height: 420px; overflow-y: auto; padding-right: 4px; }
134
+ .dsh-cron-history-item { background: #161616; border: 1px solid #2a2a2a; border-radius: 8px; padding: 12px 14px; font-size: 12.5px; }
135
+ .dsh-cron-history-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
136
+ .dsh-cron-history-status { font-weight: 600; font-size: 11.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
137
+ .dsh-cron-history-status[data-status="success"] { background: rgba(16, 185, 129, 0.15); color: #10b981; }
138
+ .dsh-cron-history-status[data-status="error"] { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
139
+ .dsh-cron-history-time { color: #777; font-size: 12px; }
140
+ .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; }
141
+ .dsh-cron-history-empty { text-align: center; padding: 36px 0; color: #666; font-size: 13.5px; }
142
+ .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer; user-select: none; }
143
+ .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #ededed; }
144
+ .dsh-cron-hint { font-size: 12px; color: #888; margin-top: 4px; }
145
+ .dsh-cron-status-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
146
+ .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; }
147
+ .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; }
148
+ .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; }
126
149
  `;
127
150
 
128
151
  function ensureStyles() {
@@ -220,6 +243,20 @@ const React = window.React || require('react');
220
243
  const [formModel, setFormModel] = React.useState('');
221
244
  const [formDelivery, setFormDelivery] = React.useState('isolated');
222
245
  const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
246
+ const [modalTab, setModalTab] = React.useState('params');
247
+ const [taskHistory, setTaskHistory] = React.useState([]);
248
+ const [historyLoading, setHistoryLoading] = React.useState(false);
249
+ const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
250
+ const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
251
+
252
+ // Settings modal state
253
+ const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
254
+ const [settingsBotToken, setSettingsBotToken] = React.useState('');
255
+ const [settingsChatId, setSettingsChatId] = React.useState('');
256
+ const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
257
+ const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
258
+ const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
259
+ const [testStatus, setTestStatus] = React.useState(null);
223
260
 
224
261
  // DSH dialog state
225
262
  const [dshModalOpen, setDshModalOpen] = React.useState(false);
@@ -234,6 +271,7 @@ const React = window.React || require('react');
234
271
  React.useEffect(() => {
235
272
  const onKey = (e) => {
236
273
  if (e.key !== 'Escape') return;
274
+ if (settingsModalOpen) { setSettingsModalOpen(false); return; }
237
275
  if (manualModalOpen) { setManualModalOpen(false); return; }
238
276
  if (dshModalOpen) { setDshModalOpen(false); return; }
239
277
  if (dropdownOpen) { setDropdownOpen(false); return; }
@@ -241,7 +279,7 @@ const React = window.React || require('react');
241
279
  };
242
280
  document.addEventListener('keydown', onKey);
243
281
  return () => document.removeEventListener('keydown', onKey);
244
- }, [manualModalOpen, dshModalOpen, dropdownOpen, onClose]);
282
+ }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
245
283
 
246
284
  const fetchTasks = async () => {
247
285
  try {
@@ -293,12 +331,76 @@ const React = window.React || require('react');
293
331
  } catch (err) {}
294
332
  };
295
333
 
334
+ const openSettingsModal = async () => {
335
+ setTestStatus(null);
336
+ setSettingsModalOpen(true);
337
+ try {
338
+ const res = await fetch('/dsh-cron/settings');
339
+ const data = await res.json();
340
+ if (data && data.ok && data.settings) {
341
+ setSettingsBotToken(data.settings.botToken || '');
342
+ setSettingsChatId(data.settings.chatId || '');
343
+ setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
344
+ setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
345
+ setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
346
+ }
347
+ } catch (e) {}
348
+ };
349
+
350
+ const handleSaveSettings = async (e) => {
351
+ e.preventDefault();
352
+ try {
353
+ const res = await fetch('/dsh-cron/settings', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({
357
+ botToken: settingsBotToken,
358
+ chatId: settingsChatId,
359
+ notifyTelegram: settingsNotifyGlobal,
360
+ onlyOnFailure: settingsOnlyOnFailureGlobal
361
+ })
362
+ });
363
+ const data = await res.json();
364
+ if (data && data.ok) {
365
+ setSettingsModalOpen(false);
366
+ } else {
367
+ alert('Ошибка сохранения: ' + (data?.error || 'неизвестная ошибка'));
368
+ }
369
+ } catch (err) {
370
+ alert('Ошибка сохранения: ' + err.message);
371
+ }
372
+ };
373
+
374
+ const handleTestTelegram = async () => {
375
+ setTestStatus({ loading: true, message: 'Отправка тестового сообщения...' });
376
+ try {
377
+ const res = await fetch('/dsh-cron/telegram/test', {
378
+ method: 'POST',
379
+ headers: { 'Content-Type': 'application/json' },
380
+ body: JSON.stringify({
381
+ botToken: settingsBotToken,
382
+ chatId: settingsChatId
383
+ })
384
+ });
385
+ const data = await res.json();
386
+ if (data && data.ok) {
387
+ setTestStatus({ loading: false, success: true, message: '✅ Тестовое сообщение успешно отправлено в Telegram!' });
388
+ } else {
389
+ setTestStatus({ loading: false, success: false, message: '❌ ' + (data?.error || 'Не удалось отправить сообщение') });
390
+ }
391
+ } catch (err) {
392
+ setTestStatus({ loading: false, success: false, message: '❌ ' + err.message });
393
+ }
394
+ };
395
+
296
396
  const openManualModal = (preset) => {
297
397
  setDropdownOpen(false);
298
398
  setFormId(null);
299
399
  setSelectedTaskMeta(null);
300
400
  setFormType('llm');
301
401
  setFormDelivery('isolated');
402
+ setFormNotifyTelegram(false);
403
+ setFormOnlyOnFailure(false);
302
404
  if (preset) {
303
405
  setFormTitle(preset.title || '');
304
406
  setFormSchedule(preset.schedule || '');
@@ -317,10 +419,26 @@ const React = window.React || require('react');
317
419
  setManualModalOpen(true);
318
420
  };
319
421
 
422
+ const loadTaskHistory = async (taskId) => {
423
+ setHistoryLoading(true);
424
+ try {
425
+ const res = await fetch('/dsh-cron/action/' + encodeURIComponent(taskId) + '/history');
426
+ const data = await res.json();
427
+ if (data.ok) {
428
+ setTaskHistory(data.history || []);
429
+ }
430
+ } catch (e) {
431
+ console.error('[dsh-cron] history load failed:', e);
432
+ } finally {
433
+ setHistoryLoading(false);
434
+ }
435
+ };
436
+
320
437
  const openEditModal = (task) => {
321
438
  setDropdownOpen(false);
322
439
  setFormId(task.id);
323
440
  setSelectedTaskMeta(task);
441
+ setModalTab('params');
324
442
  setFormTitle(task.title || '');
325
443
  setFormSchedule(task.schedule || '');
326
444
  setFormType(task.type || 'llm');
@@ -328,8 +446,11 @@ const React = window.React || require('react');
328
446
  setFormProvider(task.provider || '');
329
447
  setFormModel(task.model || '');
330
448
  setFormDelivery(task.delivery || 'isolated');
449
+ setFormNotifyTelegram(Boolean(task.notifyTelegram));
450
+ setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
331
451
  if (task.provider) loadModels(task.provider);
332
452
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
453
+ loadTaskHistory(task.id);
333
454
  setManualModalOpen(true);
334
455
  };
335
456
 
@@ -422,14 +543,26 @@ const React = window.React || require('react');
422
543
  React.createElement('div', { className: 'dsh-cron-title' }, 'Запланированные задачи'),
423
544
  React.createElement('div', { className: 'dsh-cron-subtitle' }, 'Попросите DSH планировать задачи, ставить напоминания или отслеживать обновления')
424
545
  ),
425
- React.createElement('div', { style: { position: 'relative' } },
426
- React.createElement('button', { className: 'dsh-cron-create-btn', onClick: () => setDropdownOpen(!dropdownOpen) },
427
- 'Создать',
428
- React.createElement('span', { dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } })
546
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
547
+ React.createElement('button', {
548
+ type: 'button',
549
+ className: 'dsh-cron-btn-secondary',
550
+ style: { display: 'flex', alignItems: 'center', gap: '6px', height: '36px', padding: '0 14px' },
551
+ title: 'Настройки Telegram и уведомлений',
552
+ onClick: openSettingsModal
553
+ },
554
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: '#60a5fa' }, dangerouslySetInnerHTML: { __html: ICON_TELEGRAM } }),
555
+ 'Telegram & Оповещения'
429
556
  ),
430
- dropdownOpen && React.createElement('div', { className: 'dsh-cron-dropdown' },
431
- React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, '💬 Создать с DSH'),
432
- React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, '✏️ Настроить вручную')
557
+ React.createElement('div', { style: { position: 'relative' } },
558
+ React.createElement('button', { className: 'dsh-cron-create-btn', onClick: () => setDropdownOpen(!dropdownOpen) },
559
+ 'Создать',
560
+ React.createElement('span', { dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } })
561
+ ),
562
+ dropdownOpen && React.createElement('div', { className: 'dsh-cron-dropdown' },
563
+ React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, '💬 Создать с DSH'),
564
+ React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, '✏️ Настроить вручную')
565
+ )
433
566
  )
434
567
  )
435
568
  ),
@@ -547,7 +680,7 @@ const React = window.React || require('react');
547
680
  style: { maxWidth: '640px' },
548
681
  onClick: (e) => e.stopPropagation()
549
682
  },
550
- React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
683
+ React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: formId ? '12px' : '18px' } },
551
684
  React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0 } }, formId ? 'Редактирование задачи' : 'Новая запланированная задача'),
552
685
  selectedTaskMeta && React.createElement('span', {
553
686
  style: {
@@ -559,6 +692,55 @@ const React = window.React || require('react');
559
692
  }
560
693
  }, selectedTaskMeta.status === 'active' ? '● Активна' : '○ На паузе')
561
694
  ),
695
+
696
+ formId && React.createElement('div', { className: 'dsh-cron-modal-nav' },
697
+ React.createElement('button', {
698
+ type: 'button',
699
+ className: 'dsh-cron-modal-tab',
700
+ 'data-active': modalTab === 'params',
701
+ onClick: () => setModalTab('params')
702
+ }, 'Параметры'),
703
+ React.createElement('button', {
704
+ type: 'button',
705
+ className: 'dsh-cron-modal-tab',
706
+ 'data-active': modalTab === 'history',
707
+ onClick: () => { setModalTab('history'); if (formId) loadTaskHistory(formId); }
708
+ }, 'История запусков (' + (taskHistory.length || 0) + ')')
709
+ ),
710
+
711
+ modalTab === 'history' && formId ? React.createElement('div', null,
712
+ historyLoading ? React.createElement('div', { className: 'dsh-cron-history-empty' }, 'Загрузка истории...') :
713
+ taskHistory.length === 0 ? React.createElement('div', { className: 'dsh-cron-history-empty' }, 'История запусков пока пуста') :
714
+ React.createElement('div', { className: 'dsh-cron-history-list' },
715
+ taskHistory.map((run) => React.createElement('div', { key: run.id, className: 'dsh-cron-history-item' },
716
+ React.createElement('div', { className: 'dsh-cron-history-head' },
717
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
718
+ React.createElement('span', {
719
+ className: 'dsh-cron-history-status',
720
+ 'data-status': run.status
721
+ }, run.status === 'success' ? 'Успешно' : 'Ошибка'),
722
+ React.createElement('span', { className: 'dsh-cron-history-time' }, run.at ? new Date(run.at).toLocaleString() : '')
723
+ ),
724
+ React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
725
+ ),
726
+ run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
727
+ run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: '#ef4444', borderColor: '#552222' } }, run.error)
728
+ ))
729
+ ),
730
+ React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '18px' } },
731
+ React.createElement('button', {
732
+ type: 'button',
733
+ className: 'dsh-cron-btn-secondary',
734
+ onClick: () => { if (formId) loadTaskHistory(formId); }
735
+ }, 'Обновить историю'),
736
+ React.createElement('button', {
737
+ type: 'button',
738
+ className: 'dsh-cron-btn-primary',
739
+ onClick: () => setManualModalOpen(false)
740
+ }, 'Закрыть')
741
+ )
742
+ ) :
743
+
562
744
  React.createElement('form', { onSubmit: handleSaveManual },
563
745
  React.createElement('div', { className: 'dsh-cron-form-group' },
564
746
  React.createElement('label', null, 'Название задачи'),
@@ -623,6 +805,36 @@ const React = window.React || require('react');
623
805
  onChange: (e) => setFormPrompt(e.target.value)
624
806
  })
625
807
  ),
808
+ React.createElement('div', {
809
+ style: {
810
+ background: 'rgba(255,255,255,0.02)',
811
+ border: '1px solid #2e2e2e',
812
+ borderRadius: '8px',
813
+ padding: '12px 14px',
814
+ marginBottom: '16px'
815
+ }
816
+ },
817
+ React.createElement('div', { style: { fontWeight: 500, fontSize: '13px', color: '#eee', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px' } },
818
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: '#60a5fa' }, dangerouslySetInnerHTML: { __html: ICON_TELEGRAM } }),
819
+ 'Уведомления в Telegram'
820
+ ),
821
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginBottom: formNotifyTelegram ? '8px' : '0' } },
822
+ React.createElement('input', {
823
+ type: 'checkbox',
824
+ checked: formNotifyTelegram,
825
+ onChange: (e) => setFormNotifyTelegram(e.target.checked)
826
+ }),
827
+ React.createElement('span', null, 'Отправлять отчет о выполнении в Telegram')
828
+ ),
829
+ formNotifyTelegram && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
830
+ React.createElement('input', {
831
+ type: 'checkbox',
832
+ checked: formOnlyOnFailure,
833
+ onChange: (e) => setFormOnlyOnFailure(e.target.checked)
834
+ }),
835
+ React.createElement('span', null, 'Только при ошибках (onlyOnFailure / silent mode при успехе)')
836
+ )
837
+ ),
626
838
  selectedTaskMeta && React.createElement('div', {
627
839
  style: {
628
840
  background: 'rgba(255,255,255,0.03)',
@@ -645,7 +857,10 @@ const React = window.React || require('react');
645
857
  type: 'button',
646
858
  className: 'dsh-cron-btn-secondary',
647
859
  style: { padding: '4px 10px', fontSize: '12px' },
648
- onClick: () => { handleRunNow(selectedTaskMeta.id); }
860
+ onClick: async () => {
861
+ await handleRunNow(selectedTaskMeta.id);
862
+ if (formId) loadTaskHistory(formId);
863
+ }
649
864
  }, 'Запустить сейчас')
650
865
  ),
651
866
  React.createElement('div', { className: 'dsh-cron-modal-foot' },
@@ -663,6 +878,84 @@ const React = window.React || require('react');
663
878
  )
664
879
  ),
665
880
 
881
+ // 1.5 Settings Modal
882
+ settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
883
+ React.createElement('div', { className: 'dsh-cron-modal', onClick: (e) => e.stopPropagation() },
884
+ React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
885
+ React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' } },
886
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: '#60a5fa' }, dangerouslySetInnerHTML: { __html: ICON_TELEGRAM } }),
887
+ 'Настройки Telegram и уведомлений'
888
+ ),
889
+ React.createElement('button', {
890
+ type: 'button',
891
+ style: { background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '18px' },
892
+ onClick: () => setSettingsModalOpen(false)
893
+ }, '✕')
894
+ ),
895
+ React.createElement('form', { onSubmit: handleSaveSettings },
896
+ React.createElement('div', { className: 'dsh-cron-form-group' },
897
+ React.createElement('label', null, 'Telegram Bot Token'),
898
+ React.createElement('input', {
899
+ type: 'password',
900
+ value: settingsBotToken,
901
+ placeholder: settingsHasDefault ? 'Используется бот из настроек DSH (или введите свой)' : '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
902
+ onChange: (e) => setSettingsBotToken(e.target.value)
903
+ }),
904
+ settingsHasDefault && React.createElement('div', { className: 'dsh-cron-hint' }, '✓ Обнаружен настроенный бот в профиле DSH')
905
+ ),
906
+ React.createElement('div', { className: 'dsh-cron-form-group' },
907
+ React.createElement('label', null, 'Telegram Chat ID'),
908
+ React.createElement('input', {
909
+ value: settingsChatId,
910
+ placeholder: 'Например: 345678901',
911
+ onChange: (e) => setSettingsChatId(e.target.value)
912
+ })
913
+ ),
914
+ React.createElement('div', { style: { background: 'rgba(255,255,255,0.02)', border: '1px solid #2e2e2e', borderRadius: '8px', padding: '12px 14px', marginBottom: '16px' } },
915
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginBottom: '8px' } },
916
+ React.createElement('input', {
917
+ type: 'checkbox',
918
+ checked: settingsNotifyGlobal,
919
+ onChange: (e) => setSettingsNotifyGlobal(e.target.checked)
920
+ }),
921
+ React.createElement('span', null, 'Глобально отправлять отчеты всех задач в Telegram')
922
+ ),
923
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
924
+ React.createElement('input', {
925
+ type: 'checkbox',
926
+ checked: settingsOnlyOnFailureGlobal,
927
+ onChange: (e) => setSettingsOnlyOnFailureGlobal(e.target.checked)
928
+ }),
929
+ React.createElement('span', null, 'Глобальный режим: Только при ошибках (silent при успехе)')
930
+ )
931
+ ),
932
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' } },
933
+ React.createElement('button', {
934
+ type: 'button',
935
+ className: 'dsh-cron-btn-secondary',
936
+ style: { display: 'inline-flex', alignItems: 'center', gap: '6px' },
937
+ disabled: testStatus?.loading,
938
+ onClick: handleTestTelegram
939
+ }, testStatus?.loading ? 'Отправка...' : '🔔 Проверить отправку в Telegram')
940
+ ),
941
+ testStatus && testStatus.message && React.createElement('div', {
942
+ className: 'dsh-cron-status-banner',
943
+ 'data-type': testStatus.success ? 'success' : 'error'
944
+ }, testStatus.message),
945
+ React.createElement('div', { className: 'dsh-cron-modal-foot' },
946
+ React.createElement('button', {
947
+ type: 'button',
948
+ className: 'dsh-cron-btn-secondary',
949
+ onClick: () => setSettingsModalOpen(false)
950
+ }, 'Отмена'),
951
+ React.createElement('button', {
952
+ type: 'submit',
953
+ className: 'dsh-cron-btn-primary'
954
+ }, 'Сохранить настройки')
955
+ )
956
+ )
957
+ )
958
+ ),
666
959
  // 2. "Создать с DSH" modal
667
960
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
668
961
  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
 
@@ -106,6 +107,8 @@ 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,
109
112
  });
110
113
  scheduler.scheduleTask(task);
111
114
  sendJson(res, 200, { ok: true, task });
@@ -137,6 +140,67 @@ export function apply(ctx, config) {
137
140
  }
138
141
  }), 'dsh-cron: /chat/start');
139
142
 
143
+ // GET & POST /dsh-cron/settings
144
+ ctx.effect(() => ctx.webServer.register({
145
+ kind: 'exact',
146
+ path: '/dsh-cron/settings',
147
+ handler: async (req, res) => {
148
+ try {
149
+ if (req.method === 'GET') {
150
+ sendJson(res, 200, { ok: true, settings: store.getSettings() });
151
+ return;
152
+ }
153
+ if (req.method === 'POST') {
154
+ const body = await parseJsonBody(req);
155
+ const updated = store.saveSettings(body);
156
+ sendJson(res, 200, { ok: true, settings: updated });
157
+ return;
158
+ }
159
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
160
+ } catch (err) {
161
+ sendJson(res, 500, { ok: false, error: err.message });
162
+ }
163
+ }
164
+ }), 'dsh-cron: /settings');
165
+
166
+ // POST /dsh-cron/telegram/test
167
+ ctx.effect(() => ctx.webServer.register({
168
+ kind: 'exact',
169
+ path: '/dsh-cron/telegram/test',
170
+ handler: async (req, res) => {
171
+ try {
172
+ if (req.method !== 'POST') {
173
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
174
+ return;
175
+ }
176
+ const body = await parseJsonBody(req);
177
+ const currentSettings = store.getSettings();
178
+ const botToken = (body.botToken || currentSettings.botToken || '').trim();
179
+ const chatId = (body.chatId || currentSettings.chatId || '').trim();
180
+
181
+ if (!botToken) {
182
+ sendJson(res, 400, { ok: false, error: 'Укажите Telegram Bot Token' });
183
+ return;
184
+ }
185
+ if (!chatId) {
186
+ sendJson(res, 400, { ok: false, error: 'Укажите Telegram Chat ID' });
187
+ return;
188
+ }
189
+
190
+ await sendTelegramMessage({
191
+ botToken,
192
+ chatId,
193
+ text: '⏰ *DSH Cron — Проверка связи*\nТестовое уведомление успешно доставлено! Планировщик готов к отправке отчётов.',
194
+ parseMode: 'Markdown'
195
+ });
196
+
197
+ sendJson(res, 200, { ok: true, message: 'Сообщение успешно отправлено в Telegram' });
198
+ } catch (err) {
199
+ sendJson(res, 500, { ok: false, error: err.message });
200
+ }
201
+ }
202
+ }), 'dsh-cron: /telegram/test');
203
+
140
204
  // Prefix routes for actions (/dsh-cron/action/*)
141
205
  ctx.effect(() => ctx.webServer.register({
142
206
  kind: 'prefix',
@@ -177,6 +241,14 @@ export function apply(ctx, config) {
177
241
  return;
178
242
  }
179
243
 
244
+ const historyMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/history$/);
245
+ if (req.method === 'GET' && historyMatch) {
246
+ const taskId = historyMatch[1];
247
+ const history = store.getHistory(taskId, 30);
248
+ sendJson(res, 200, { ok: true, history });
249
+ return;
250
+ }
251
+
180
252
  sendJson(res, 404, { ok: false, error: 'Action not found' });
181
253
  } catch (err) {
182
254
  sendJson(res, 500, { ok: false, error: err.message });
@@ -196,7 +268,9 @@ export function apply(ctx, config) {
196
268
  delivery: { type: 'string', description: 'Режим запуска: current или isolated' },
197
269
  type: { type: 'string', description: 'Тип задачи: "llm" (агент с LLM) или "script" (прямой запуск shell без LLM)' },
198
270
  provider: { type: 'string', description: 'ID провайдера LLM для выполнения задачи' },
199
- model: { type: 'string', description: 'ID модели LLM для выполнения задачи' }
271
+ model: { type: 'string', description: 'ID модели LLM для выполнения задачи' },
272
+ notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
273
+ onlyOnFailure: { type: 'boolean', description: 'Отправлять в Telegram только при ошибках' }
200
274
  },
201
275
  output: {
202
276
  schema: {
@@ -211,7 +285,7 @@ export function apply(ctx, config) {
211
285
  return [{ type: 'text', text: value.message }];
212
286
  }
213
287
  },
214
- execute: async ({ title, schedule, prompt, delivery, provider, model, type }) => {
288
+ execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure }) => {
215
289
  try {
216
290
  const parsed = parseScheduleExpression(schedule);
217
291
  const task = store.set({
@@ -223,6 +297,8 @@ export function apply(ctx, config) {
223
297
  delivery: delivery || 'isolated',
224
298
  provider: provider || undefined,
225
299
  model: model || undefined,
300
+ notifyTelegram: Boolean(notifyTelegram),
301
+ onlyOnFailure: Boolean(onlyOnFailure),
226
302
  status: 'active'
227
303
  });
228
304
  scheduler.scheduleTask(task);
@@ -370,4 +446,4 @@ export function apply(ctx, config) {
370
446
  return;
371
447
  }
372
448
  });
373
- }
449
+ }
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');
@@ -145,13 +146,31 @@ export class TaskScheduler {
145
146
  error = err.message || String(err);
146
147
  } finally {
147
148
  const durationMs = Date.now() - start;
148
- this.store.recordRun(taskId, {
149
+ const runInfo = {
149
150
  at: start,
150
151
  status,
151
152
  durationMs,
152
153
  output,
153
154
  error,
154
- });
155
+ };
156
+ this.store.recordRun(taskId, runInfo);
157
+
158
+ // Telegram notification delivery
159
+ try {
160
+ const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
161
+ if (shouldNotifyTask(task, runInfo, settings)) {
162
+ const botToken = settings.botToken;
163
+ const chatId = settings.chatId;
164
+ if (botToken && chatId) {
165
+ const text = formatTaskTelegramMessage(task, runInfo);
166
+ sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
167
+ console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
168
+ });
169
+ }
170
+ }
171
+ } catch (notifyErr) {
172
+ console.error('[dsh-cron] notification error:', notifyErr.message);
173
+ }
155
174
 
156
175
  const job = this.jobs.get(taskId);
157
176
  if (job) {
@@ -202,4 +221,4 @@ export class TaskScheduler {
202
221
  return null;
203
222
  }
204
223
  }
205
- }
224
+ }
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,8 @@ 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),
99
130
  createdAt: prev.createdAt || task.createdAt || now,
100
131
  updatedAt: now,
101
132
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
@@ -141,4 +172,4 @@ export class TaskStore {
141
172
  const runs = this.history.get(id) || [];
142
173
  return runs.slice(0, limit);
143
174
  }
144
- }
175
+ }
@@ -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.13",
3
+ "version": "0.1.15",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",