@goodandready/dsh-cron 0.1.14 → 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] {
@@ -137,6 +139,13 @@ const React = window.React || require('react');
137
139
  .dsh-cron-history-time { color: #777; font-size: 12px; }
138
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; }
139
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; }
140
149
  `;
141
150
 
142
151
  function ensureStyles() {
@@ -237,6 +246,17 @@ const React = window.React || require('react');
237
246
  const [modalTab, setModalTab] = React.useState('params');
238
247
  const [taskHistory, setTaskHistory] = React.useState([]);
239
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);
240
260
 
241
261
  // DSH dialog state
242
262
  const [dshModalOpen, setDshModalOpen] = React.useState(false);
@@ -251,6 +271,7 @@ const React = window.React || require('react');
251
271
  React.useEffect(() => {
252
272
  const onKey = (e) => {
253
273
  if (e.key !== 'Escape') return;
274
+ if (settingsModalOpen) { setSettingsModalOpen(false); return; }
254
275
  if (manualModalOpen) { setManualModalOpen(false); return; }
255
276
  if (dshModalOpen) { setDshModalOpen(false); return; }
256
277
  if (dropdownOpen) { setDropdownOpen(false); return; }
@@ -258,7 +279,7 @@ const React = window.React || require('react');
258
279
  };
259
280
  document.addEventListener('keydown', onKey);
260
281
  return () => document.removeEventListener('keydown', onKey);
261
- }, [manualModalOpen, dshModalOpen, dropdownOpen, onClose]);
282
+ }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
262
283
 
263
284
  const fetchTasks = async () => {
264
285
  try {
@@ -310,12 +331,76 @@ const React = window.React || require('react');
310
331
  } catch (err) {}
311
332
  };
312
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
+
313
396
  const openManualModal = (preset) => {
314
397
  setDropdownOpen(false);
315
398
  setFormId(null);
316
399
  setSelectedTaskMeta(null);
317
400
  setFormType('llm');
318
401
  setFormDelivery('isolated');
402
+ setFormNotifyTelegram(false);
403
+ setFormOnlyOnFailure(false);
319
404
  if (preset) {
320
405
  setFormTitle(preset.title || '');
321
406
  setFormSchedule(preset.schedule || '');
@@ -361,6 +446,8 @@ const React = window.React || require('react');
361
446
  setFormProvider(task.provider || '');
362
447
  setFormModel(task.model || '');
363
448
  setFormDelivery(task.delivery || 'isolated');
449
+ setFormNotifyTelegram(Boolean(task.notifyTelegram));
450
+ setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
364
451
  if (task.provider) loadModels(task.provider);
365
452
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
366
453
  loadTaskHistory(task.id);
@@ -456,14 +543,26 @@ const React = window.React || require('react');
456
543
  React.createElement('div', { className: 'dsh-cron-title' }, 'Запланированные задачи'),
457
544
  React.createElement('div', { className: 'dsh-cron-subtitle' }, 'Попросите DSH планировать задачи, ставить напоминания или отслеживать обновления')
458
545
  ),
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 } })
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 & Оповещения'
463
556
  ),
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) }, '✏️ Настроить вручную')
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
+ )
467
566
  )
468
567
  )
469
568
  ),
@@ -706,6 +805,36 @@ const React = window.React || require('react');
706
805
  onChange: (e) => setFormPrompt(e.target.value)
707
806
  })
708
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
+ ),
709
838
  selectedTaskMeta && React.createElement('div', {
710
839
  style: {
711
840
  background: 'rgba(255,255,255,0.03)',
@@ -749,6 +878,84 @@ const React = window.React || require('react');
749
878
  )
750
879
  ),
751
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
+ ),
752
959
  // 2. "Создать с DSH" modal
753
960
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
754
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',
@@ -204,7 +268,9 @@ export function apply(ctx, config) {
204
268
  delivery: { type: 'string', description: 'Режим запуска: current или isolated' },
205
269
  type: { type: 'string', description: 'Тип задачи: "llm" (агент с LLM) или "script" (прямой запуск shell без LLM)' },
206
270
  provider: { type: 'string', description: 'ID провайдера LLM для выполнения задачи' },
207
- 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 только при ошибках' }
208
274
  },
209
275
  output: {
210
276
  schema: {
@@ -219,7 +285,7 @@ export function apply(ctx, config) {
219
285
  return [{ type: 'text', text: value.message }];
220
286
  }
221
287
  },
222
- execute: async ({ title, schedule, prompt, delivery, provider, model, type }) => {
288
+ execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure }) => {
223
289
  try {
224
290
  const parsed = parseScheduleExpression(schedule);
225
291
  const task = store.set({
@@ -231,6 +297,8 @@ export function apply(ctx, config) {
231
297
  delivery: delivery || 'isolated',
232
298
  provider: provider || undefined,
233
299
  model: model || undefined,
300
+ notifyTelegram: Boolean(notifyTelegram),
301
+ onlyOnFailure: Boolean(onlyOnFailure),
234
302
  status: 'active'
235
303
  });
236
304
  scheduler.scheduleTask(task);
@@ -378,4 +446,4 @@ export function apply(ctx, config) {
378
446
  return;
379
447
  }
380
448
  });
381
- }
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.14",
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",