@goodandready/dsh-cron 0.2.3 → 0.2.4

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.
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Delivery channels and router (#26, #20, #21, #22, #23, #47, #28).
3
+ *
4
+ * Every adapter is split into a pure payload/request builder (unit-testable
5
+ * without network) and a thin send step using an injected fetch. The router
6
+ * decides which channels a run goes to and never throws: failures are
7
+ * collected so one broken channel cannot hide another.
8
+ */
9
+
10
+ import { formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
11
+ import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
12
+ import { resolveTemplateText, truncateText } from './templates.js';
13
+
14
+ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'email', 'tts', 'gitea'];
15
+
16
+ export const CHANNEL_LABELS = {
17
+ telegram: 'Telegram',
18
+ kanban: 'dsh-kanban card',
19
+ discord: 'Discord webhook',
20
+ slack: 'Slack webhook',
21
+ ntfy: 'ntfy push',
22
+ bark: 'Bark push',
23
+ pushplus: 'PushPlus',
24
+ email: 'Email (SMTP)',
25
+ tts: 'Voice via dsh-tts',
26
+ gitea: 'Gitea issue',
27
+ };
28
+
29
+ const isFailed = (status) => status === 'error' || status === 'timeout';
30
+
31
+ /** The message text for a channel: custom template, else built-in defaults. */
32
+ export function messageTextFor(channelId, task, runInfo, settings = {}) {
33
+ const templates = (settings && settings.channelTemplates) || {};
34
+ const template = templates[channelId] || settings.template || '';
35
+ return resolveTemplateText({ template, task, runInfo });
36
+ }
37
+
38
+ /**
39
+ * Which channels deliver a given run.
40
+ * Explicit per-task channels win; otherwise the legacy flags decide.
41
+ */
42
+ export function resolveChannels(task, settings = {}) {
43
+ if (Array.isArray(task.channels)) {
44
+ const explicit = task.channels.filter((id) => CHANNEL_IDS.includes(id));
45
+ if (explicit.length) return explicit;
46
+ }
47
+ const out = [];
48
+ const notify = task.notifyTelegram !== undefined ? task.notifyTelegram : settings.notifyTelegram;
49
+ if (notify) out.push('telegram');
50
+ if ((task.kanbanMode || 'none') !== 'none') out.push('kanban');
51
+ return out;
52
+ }
53
+
54
+ /** Failure/only-on-failure filtering, per channel. */
55
+ export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
56
+ if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
57
+ const onlyOnFail = task.onlyOnFailure !== undefined
58
+ ? Boolean(task.onlyOnFailure)
59
+ : Boolean(settings.onlyOnFailure);
60
+ if (onlyOnFail && !isFailed(runInfo.status)) return false;
61
+ return true;
62
+ }
63
+
64
+ // ---------------------------------------------------------------- builders
65
+
66
+ export function buildDiscordPayload({ text, task, runInfo }) {
67
+ const failed = isFailed(runInfo.status);
68
+ return {
69
+ content: truncateText(text, 1900),
70
+ embeds: [{
71
+ title: `${failed ? '❌' : '✅'} ${truncateText(task.title || 'Task', 200)}`,
72
+ description: truncateText(failed ? (runInfo.error || '') : (runInfo.output || ''), 1900) || undefined,
73
+ color: failed ? 0xef4444 : 0x10b981,
74
+ footer: { text: truncateText(task.scheduleText || task.schedule || '', 200) },
75
+ timestamp: new Date(runInfo.at || Date.now()).toISOString(),
76
+ }],
77
+ };
78
+ }
79
+
80
+ export function buildSlackPayload({ text }) {
81
+ return { text: truncateText(text, 3000) };
82
+ }
83
+
84
+ export function buildNtfyRequest({ settings = {}, text, task, token }) {
85
+ const base = String(settings.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
86
+ const topic = String(settings.ntfyTopic || '').trim();
87
+ if (!topic) throw new Error('ntfyTopic is not configured');
88
+ const headers = {
89
+ Title: truncateText(task && task.title ? task.title : 'DSH Cron', 120),
90
+ Tags: isFailed(task && task.lastStatus) ? 'warning' : 'robot',
91
+ 'Content-Type': 'text/plain; charset=utf-8',
92
+ };
93
+ if (token) headers.Authorization = `Bearer ${token}`;
94
+ return { url: `${base}/${encodeURIComponent(topic)}`, method: 'POST', headers, body: truncateText(text, 4000) };
95
+ }
96
+
97
+ export function buildBarkRequest({ settings = {}, text, task }) {
98
+ const server = String(settings.barkServerUrl || 'https://api.day.app').replace(/\/+$/, '');
99
+ const key = String(settings.barkKey || '').trim();
100
+ if (!key) throw new Error('barkKey is not configured');
101
+ const title = truncateText(task && task.title ? task.title : 'DSH Cron', 100);
102
+ return {
103
+ url: `${server}/${encodeURIComponent(key)}/${encodeURIComponent(title)}/${encodeURIComponent(truncateText(text, 1200))}`,
104
+ method: 'GET',
105
+ headers: {},
106
+ };
107
+ }
108
+
109
+ export function buildPushplusRequest({ settings = {}, text, task, token }) {
110
+ const effective = token || settings.pushplusToken || '';
111
+ if (!effective) throw new Error('pushplus token is not configured');
112
+ const url = String(settings.pushplusUrl || 'https://www.pushplus.plus/send').trim();
113
+ return {
114
+ url,
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({
118
+ token: effective,
119
+ title: truncateText(task && task.title ? task.title : 'DSH Cron', 100),
120
+ content: truncateText(text, 4000),
121
+ template: 'markdown',
122
+ }),
123
+ };
124
+ }
125
+
126
+ export function buildGiteaIssuePayload({ task, runInfo }) {
127
+ const failed = isFailed(runInfo.status);
128
+ return {
129
+ title: `${failed ? '[Cron failure]' : '[Cron run]'} ${truncateText(task.title || 'Task', 200)}`,
130
+ body: [
131
+ `**Cron task:** ${task.title} (\`${task.id}\`)`,
132
+ `**Schedule:** ${task.scheduleText || task.schedule || ''}`,
133
+ `**Status:** ${runInfo.status}`,
134
+ `**Started at:** ${new Date(runInfo.at || Date.now()).toISOString()}`,
135
+ runInfo.error ? `\n**Error:**\n\`\`\`\n${truncateText(runInfo.error, 1000)}\n\`\`\`` : '',
136
+ runInfo.output ? `\n**Output:**\n\`\`\`\n${truncateText(runInfo.output, 1000)}\n\`\`\`` : '',
137
+ ].filter(Boolean).join('\n\n'),
138
+ labels: failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
139
+ };
140
+ }
141
+
142
+ export function buildEmailMessage({ settings = {}, text, task, runInfo, password, timeoutMs }) {
143
+ const to = String(settings.smtpTo || '').trim();
144
+ if (!to) throw new Error('smtpTo is not configured');
145
+ const from = String(settings.smtpFrom || settings.smtpUser || '').trim();
146
+ const failed = isFailed(runInfo.status);
147
+ const bound = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
148
+ return {
149
+ from: from || undefined,
150
+ to,
151
+ subject: `${failed ? '❌' : '✅'} ${task.title || 'DSH Cron'} — ${runInfo.status}`,
152
+ text: truncateText(text, 10000),
153
+ transport: {
154
+ host: settings.smtpHost || '',
155
+ port: Number(settings.smtpPort) || 587,
156
+ secure: Boolean(settings.smtpSecure),
157
+ auth: settings.smtpUser ? { user: settings.smtpUser, pass: password || '' } : undefined,
158
+ // Nodemailer has no AbortSignal support: without these a stalled SMTP
159
+ // server would hold the run (defaults are 2–10 minutes).
160
+ connectionTimeout: bound,
161
+ greetingTimeout: bound,
162
+ socketTimeout: bound,
163
+ },
164
+ };
165
+ }
166
+
167
+ // ------------------------------------------------------------------ sender
168
+
169
+ export const DEFAULT_DELIVERY_TIMEOUT_MS = 15000;
170
+
171
+ /**
172
+ * Bound every outbound request. Without this a single unresponsive endpoint
173
+ * blocks the remaining channels and, because the run is awaited inside the
174
+ * croner callback with protect enabled, silently skips subsequent ticks.
175
+ */
176
+ function deliverySignal(timeoutMs) {
177
+ const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
178
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
179
+ return AbortSignal.timeout(ms);
180
+ }
181
+ const controller = new AbortController();
182
+ const timer = setTimeout(() => controller.abort(), ms);
183
+ if (typeof timer.unref === 'function') timer.unref();
184
+ return controller.signal;
185
+ }
186
+
187
+ function isAbort(err) {
188
+ return Boolean(err) && (err.name === 'AbortError' || err.name === 'TimeoutError');
189
+ }
190
+
191
+ /**
192
+ * Hard deadline around one channel's whole work. Signal-based aborts only
193
+ * help where the callee supports AbortSignal (fetch), while credential
194
+ * resolution, SMTP and any injected transport ignore it — this is the backstop
195
+ * that keeps every channel bounded regardless of implementation.
196
+ */
197
+ function withDeadline(promise, timeoutMs, channelId) {
198
+ const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
199
+ return new Promise((resolve, reject) => {
200
+ const timer = setTimeout(() => {
201
+ reject(new Error(`${channelId}: timed out after ${ms} ms`));
202
+ }, ms);
203
+ Promise.resolve(promise).then(
204
+ (value) => { clearTimeout(timer); resolve(value); },
205
+ (err) => { clearTimeout(timer); reject(err); },
206
+ );
207
+ });
208
+ }
209
+
210
+ async function sendHttp(fetchFn, request, channelId, signal, timeoutMs) {
211
+ let res;
212
+ try {
213
+ res = await fetchFn(request.url, {
214
+ method: request.method,
215
+ headers: request.headers,
216
+ body: request.body,
217
+ signal,
218
+ });
219
+ } catch (err) {
220
+ if (isAbort(err)) throw new Error(`${channelId}: timed out after ${timeoutMs} ms`);
221
+ throw err;
222
+ }
223
+ if (!res || !res.ok) {
224
+ const status = res && res.status ? res.status : 'no-response';
225
+ throw new Error(`${channelId}: HTTP ${status}`);
226
+ }
227
+ return { status: res.status };
228
+ }
229
+
230
+ /** POST/PUT style JSON request helper used by the tts and gitea channels. */
231
+ async function postJson(http, url, headers, body, channelId, signal, timeoutMs) {
232
+ let res;
233
+ try {
234
+ res = await http(url, { method: 'POST', headers, body, signal });
235
+ } catch (err) {
236
+ if (isAbort(err)) throw new Error(`${channelId}: timed out after ${timeoutMs} ms`);
237
+ throw err;
238
+ }
239
+ if (!res || !res.ok) throw new Error(`${channelId}: HTTP ${res && res.status ? res.status : 'no-response'}`);
240
+ return res.json().catch(() => ({}));
241
+ }
242
+
243
+ /**
244
+ * One handler per channel. Each returns a small detail object and throws a
245
+ * descriptive Error on failure; the router turns that into a failure entry.
246
+ */
247
+ export const CHANNEL_HANDLERS = {
248
+ async telegram({ task, runInfo, settings, secrets, http, signal, timeoutMs }) {
249
+ const botToken = secrets.botToken || '';
250
+ const chatId = String(settings.chatId || '').trim();
251
+ if (!botToken || !chatId) throw new Error('telegram: botToken or chatId is not configured');
252
+ const text = settings.template
253
+ ? messageTextFor('telegram', task, runInfo, settings)
254
+ : formatTaskTelegramMessage(task, runInfo);
255
+ try {
256
+ await sendTelegramMessage({ botToken, chatId, text, fetchFn: http, signal });
257
+ } catch (err) {
258
+ if (isAbort(err)) throw new Error(`telegram: timed out after ${timeoutMs} ms`);
259
+ throw err;
260
+ }
261
+ return { chatId };
262
+ },
263
+
264
+ async kanban({ task, runInfo, settings, http, signal }) {
265
+ const isErr = isFailed(runInfo.status);
266
+ const result = await createKanbanCard({
267
+ title: `${isErr ? '[Cron failure]' : '[Cron completed]'} ${task.title}`,
268
+ body: buildGiteaIssuePayload({ task, runInfo }).body,
269
+ board: 'main',
270
+ column: isErr ? 'backlog' : 'done',
271
+ labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
272
+ kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000',
273
+ fetchFn: http,
274
+ signal,
275
+ });
276
+ if (!result.success) throw new Error(`kanban: ${result.error}`);
277
+ return { column: isErr ? 'backlog' : 'done' };
278
+ },
279
+
280
+ async discord({ task, runInfo, settings, http, signal, timeoutMs }) {
281
+ const url = String(settings.discordWebhookUrl || '').trim();
282
+ if (!url) throw new Error('discord: discordWebhookUrl is not configured');
283
+ const payload = buildDiscordPayload({ text: messageTextFor('discord', task, runInfo, settings), task, runInfo });
284
+ return sendHttp(http, { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, 'discord', signal, timeoutMs);
285
+ },
286
+
287
+ async slack({ task, runInfo, settings, http, signal, timeoutMs }) {
288
+ const url = String(settings.slackWebhookUrl || '').trim();
289
+ if (!url) throw new Error('slack: slackWebhookUrl is not configured');
290
+ const payload = buildSlackPayload({ text: messageTextFor('slack', task, runInfo, settings) });
291
+ return sendHttp(http, { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, 'slack', signal, timeoutMs);
292
+ },
293
+
294
+ async ntfy({ task, runInfo, settings, secrets, http, signal, timeoutMs, resolveSecret }) {
295
+ const token = settings.ntfyTokenRef ? await resolveSecret(settings.ntfyTokenRef) : null;
296
+ const req = buildNtfyRequest({ settings, text: messageTextFor('ntfy', task, runInfo, settings), task, token });
297
+ return sendHttp(http, req, 'ntfy', signal, timeoutMs);
298
+ },
299
+
300
+ async bark({ task, runInfo, settings, http, signal, timeoutMs }) {
301
+ const req = buildBarkRequest({ settings, text: messageTextFor('bark', task, runInfo, settings), task });
302
+ return sendHttp(http, req, 'bark', signal, timeoutMs);
303
+ },
304
+
305
+ async pushplus({ task, runInfo, settings, secrets, http, signal, timeoutMs, resolveSecret }) {
306
+ const token = settings.pushplusTokenRef ? await resolveSecret(settings.pushplusTokenRef) : null;
307
+ const req = buildPushplusRequest({ settings, text: messageTextFor('pushplus', task, runInfo, settings), task, token });
308
+ return sendHttp(http, req, 'pushplus', signal, timeoutMs);
309
+ },
310
+
311
+ async email({ task, runInfo, settings, deps, resolveSecret, timeoutMs }) {
312
+ const password = settings.smtpPasswordRef ? await resolveSecret(settings.smtpPasswordRef) : (settings.smtpPassword || '');
313
+ const message = buildEmailMessage({ settings, text: messageTextFor('email', task, runInfo, settings), task, runInfo, password, timeoutMs });
314
+ let createTransport = deps.createTransport;
315
+ if (!createTransport) {
316
+ try {
317
+ const mod = await import('nodemailer');
318
+ createTransport = (mod.default || mod).createTransport;
319
+ } catch {
320
+ throw new Error('email: nodemailer is not installed in the harness (install it or use another channel)');
321
+ }
322
+ }
323
+ const transport = createTransport(message.transport);
324
+ await transport.sendMail({ from: message.from, to: message.to, subject: message.subject, text: message.text });
325
+ return { to: message.to };
326
+ },
327
+
328
+ async tts({ task, runInfo, settings, http, signal, timeoutMs }) {
329
+ const base = String(settings.ttsBaseUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '');
330
+ const payload = { text: truncateText(messageTextFor('tts', task, runInfo, settings), 600) };
331
+ const data = await postJson(http, `${base}/dsh-tts/speak`, { 'Content-Type': 'application/json' }, JSON.stringify(payload), 'tts', signal, timeoutMs);
332
+ return { provider: data.provider || 'dsh-tts', tookMs: data.tookMs || null };
333
+ },
334
+
335
+ async gitea({ task, runInfo, settings, http, signal, timeoutMs, resolveSecret }) {
336
+ const base = String(settings.giteaBaseUrl || '').replace(/\/+$/, '');
337
+ const repo = String(settings.giteaRepo || '').trim();
338
+ if (!base || !repo) throw new Error('gitea: giteaBaseUrl and giteaRepo are required');
339
+ const token = settings.giteaTokenRef ? await resolveSecret(settings.giteaTokenRef) : '';
340
+ if (!token) throw new Error(`gitea: credential "${settings.giteaTokenRef || ''}" did not resolve`);
341
+ const payload = buildGiteaIssuePayload({ task, runInfo });
342
+ const issue = await postJson(
343
+ http,
344
+ `${base}/api/v1/repos/${repo}/issues`,
345
+ { 'Content-Type': 'application/json', Authorization: `token ${token}` },
346
+ JSON.stringify(payload),
347
+ 'gitea',
348
+ signal,
349
+ timeoutMs,
350
+ );
351
+ return { number: issue.number || null };
352
+ },
353
+ };
354
+
355
+ /**
356
+ * Deliver one run to one channel.
357
+ * `resolveSecret(ref)` resolves credential references; `deps.createTransport`
358
+ * is an injectable nodemailer-compatible factory for tests.
359
+ */
360
+ export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn, deps = {}, timeoutMs = DEFAULT_DELIVERY_TIMEOUT_MS }) {
361
+ const handler = CHANNEL_HANDLERS[channelId];
362
+ if (!handler) throw new Error(`unknown channel: ${channelId}`);
363
+ const resolveSecret = typeof secrets.resolveSecret === 'function' ? secrets.resolveSecret : async () => null;
364
+ const http = fetchFn || globalThis.fetch;
365
+ const effectiveTimeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
366
+ const work = handler({
367
+ task,
368
+ runInfo,
369
+ settings,
370
+ secrets,
371
+ http,
372
+ deps,
373
+ resolveSecret,
374
+ signal: deliverySignal(effectiveTimeout),
375
+ timeoutMs: effectiveTimeout,
376
+ });
377
+ return withDeadline(work, effectiveTimeout, channelId);
378
+ }
379
+
380
+ /**
381
+ * Deliver a finished run to every applicable channel.
382
+ *
383
+ * Channels are dispatched concurrently and every channel is individually
384
+ * bounded (AbortSignal for fetch-based channels plus a hard deadline around the
385
+ * whole handler), so a slow endpoint cannot delay the rest of the report nor
386
+ * hold the cron callback open. Never throws: per-channel failures are returned
387
+ * in `failures`.
388
+ */
389
+ export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, fetchFn, deps = {} }) {
390
+ const timeoutMs = Number(settings.deliveryTimeoutMs) > 0
391
+ ? Number(settings.deliveryTimeoutMs)
392
+ : DEFAULT_DELIVERY_TIMEOUT_MS;
393
+
394
+ let channels = [];
395
+ try {
396
+ channels = resolveChannels(task, settings);
397
+ } catch (err) {
398
+ return { channels: [], delivered: [], skipped: [], failures: [{ channel: 'router', error: (err && err.message) || String(err) }] };
399
+ }
400
+
401
+ const outcomes = await Promise.all(channels.map(async (channelId) => {
402
+ try {
403
+ if (!shouldSendToChannel(channelId, task, runInfo, settings)) {
404
+ return { channel: channelId, skipped: true };
405
+ }
406
+ const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn, deps, timeoutMs });
407
+ return { channel: channelId, detail: detail || null };
408
+ } catch (err) {
409
+ return { channel: channelId, error: (err && err.message) || String(err) };
410
+ }
411
+ }));
412
+
413
+ const delivered = [];
414
+ const skipped = [];
415
+ const failures = [];
416
+ for (const outcome of outcomes) {
417
+ if (outcome.skipped) skipped.push(outcome.channel);
418
+ else if (outcome.error) failures.push({ channel: outcome.channel, error: outcome.error });
419
+ else delivered.push({ channel: outcome.channel, detail: outcome.detail });
420
+ }
421
+ return { channels, delivered, skipped, failures };
422
+ }