@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 +17 -0
- package/lib/client.js +246 -9
- package/lib/index.js +78 -4
- package/lib/runner.js +55 -14
- package/lib/scheduler.js +90 -4
- package/lib/store.js +34 -1
- package/lib/telegram.js +147 -0
- package/package.json +1 -1
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: {
|
|
460
|
-
React.createElement('button', {
|
|
461
|
-
'
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
React.createElement('button', { className: 'dsh-cron-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/telegram.js
ADDED
|
@@ -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
|
+
}
|