1.0.33
This commit is contained in:
85 files changed
+10118
-1046
No files matched your search
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Apprise passthrough — POST to a self-hosted caronc/apprise-api server.
|
||||
*
|
||||
* Users configure all their providers (Signal, Matrix, MQTT, IFTTT, AWS SNS,
|
||||
* dozens more) in their own Apprise server; Dockhand just forwards each
|
||||
* notification once. The big win: every provider Apprise upstream supports
|
||||
* is now reachable from Dockhand without us having to write a sender for it.
|
||||
*
|
||||
* Supported formats:
|
||||
* apprise://host[:port]/key → HTTP, stateful (Apprise stored config key)
|
||||
* apprises://host[:port]/key → HTTPS variant
|
||||
* apprise://host[:port]/prefix/key → path-prefixed Apprise behind a reverse proxy
|
||||
* apprise://host[:port]/key?tag=devops → optional tag filter
|
||||
*
|
||||
* Setup docs: https://github.com/caronc/apprise-api
|
||||
*/
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const isSecure = appriseUrl.startsWith('apprises');
|
||||
const raw = appriseUrl.replace(/^apprises?:\/\//, '');
|
||||
|
||||
let cleanPath = raw;
|
||||
let queryParams = new URLSearchParams();
|
||||
const qIndex = raw.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
queryParams = new URLSearchParams(raw.substring(qIndex + 1));
|
||||
cleanPath = raw.substring(0, qIndex);
|
||||
}
|
||||
|
||||
const parts = cleanPath.split('/').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return { success: false, error: 'Invalid Apprise URL. Expected: apprise://host[:port]/key' };
|
||||
}
|
||||
const hostPort = parts[0];
|
||||
// The Apprise key is the last path segment. Anything between host and key
|
||||
// is a path prefix (some users mount Apprise behind a reverse proxy
|
||||
// at /apprise/ — we preserve that).
|
||||
const key = parts[parts.length - 1];
|
||||
const pathPrefix = parts.slice(1, -1).join('/');
|
||||
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}${pathPrefix ? '/' + pathPrefix : ''}`;
|
||||
|
||||
// Map our payload type to Apprise's NotifyType. 'error' → 'failure' is
|
||||
// the only rename; everything else lines up.
|
||||
const apprisesType = payload.type === 'error'
|
||||
? 'failure'
|
||||
: payload.type === 'warning'
|
||||
? 'warning'
|
||||
: payload.type === 'success'
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
const titleWithEnv = payload.environmentName
|
||||
? `${payload.title} [${payload.environmentName}]`
|
||||
: payload.title;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: titleWithEnv,
|
||||
body: payload.message,
|
||||
type: apprisesType
|
||||
};
|
||||
const tag = queryParams.get('tag');
|
||||
if (tag) body.tag = tag;
|
||||
const format = queryParams.get('format');
|
||||
if (format) body.format = format; // text | markdown | html
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Apprise-API uses specific status codes:
|
||||
// 200 → success, 204 → key not configured, 424 → at least one
|
||||
// downstream provider failed or tag didn't match.
|
||||
if (response.status === 204) {
|
||||
return { success: false, error: `Apprise: no configuration found for key "${key}"` };
|
||||
}
|
||||
if (response.status === 424) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Apprise: at least one downstream provider failed${text ? ` — ${text}` : ''}` };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Apprise error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Apprise connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Bark — iOS push via bark-server (https://github.com/Finb/bark-server).
|
||||
*
|
||||
* Supported formats:
|
||||
* bark://device_key → uses official api.day.app over HTTPS
|
||||
* bark://host/device_key → custom server over HTTP
|
||||
* bark://host[:port]/k1/k2/... → multi-device batch (Apprise convention)
|
||||
* barks://host[:port]/... → HTTPS variant
|
||||
*
|
||||
* Query params honored (per https://bark.day.app/#/en-us/tutorial):
|
||||
* ?sound=name, ?level=active|timeSensitive|critical|passive,
|
||||
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
|
||||
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
|
||||
*/
|
||||
import type { NotificationPayload, NotificationResult } from './shared';
|
||||
|
||||
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const isSecure = appriseUrl.startsWith('barks');
|
||||
const path = appriseUrl.replace(/^barks?:\/\//, '');
|
||||
|
||||
// Split off query string before slicing the path so '?' in a device key
|
||||
// (in principle possible, though Bark's keys are 22-char base62) doesn't
|
||||
// confuse the parser.
|
||||
let cleanPath = path;
|
||||
let queryParams = new URLSearchParams();
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
queryParams = new URLSearchParams(path.substring(qIndex + 1));
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
if (!cleanPath) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let deviceKeys: string[];
|
||||
if (!cleanPath.includes('/')) {
|
||||
// bark://device_key → official server, HTTPS regardless of bark:// vs barks://
|
||||
baseUrl = 'https://api.day.app';
|
||||
deviceKeys = [cleanPath];
|
||||
} else {
|
||||
const parts = cleanPath.split('/').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
const hostPort = parts[0];
|
||||
deviceKeys = parts.slice(1);
|
||||
baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
|
||||
}
|
||||
|
||||
// Map our payload type to Bark's `level`. Query-supplied level wins.
|
||||
// info → active (banner + sound, doesn't bypass DND)
|
||||
// warning → timeSensitive (cuts through Focus modes)
|
||||
// error → critical (cuts through silent mode; user must enable)
|
||||
const defaultLevel = payload.type === 'error'
|
||||
? 'critical'
|
||||
: payload.type === 'warning'
|
||||
? 'timeSensitive'
|
||||
: 'active';
|
||||
const level = queryParams.get('level') || defaultLevel;
|
||||
|
||||
const titleWithEnv = payload.environmentName
|
||||
? `${payload.title} [${payload.environmentName}]`
|
||||
: payload.title;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: titleWithEnv,
|
||||
body: payload.message,
|
||||
level
|
||||
};
|
||||
// Single-target uses device_key; batch uses device_keys (per Bark API v2).
|
||||
if (deviceKeys.length === 1) {
|
||||
body.device_key = deviceKeys[0];
|
||||
} else {
|
||||
body.device_keys = deviceKeys;
|
||||
}
|
||||
|
||||
// String passthroughs Bark understands. Unknown params are dropped on the
|
||||
// server side anyway so no point forwarding them.
|
||||
const passthroughString = ['sound', 'group', 'icon', 'url', 'copy', 'subtitle', 'category', 'ciphertext', 'isArchive', 'autoCopy', 'call', 'action', 'volume'];
|
||||
for (const key of passthroughString) {
|
||||
const v = queryParams.get(key);
|
||||
if (v !== null && v !== '') body[key] = v;
|
||||
}
|
||||
// Numeric passthroughs.
|
||||
for (const key of ['badge', 'ttl']) {
|
||||
const v = queryParams.get(key);
|
||||
if (v !== null && v !== '') {
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isNaN(n)) body[key] = n;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/push`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
// Bark returns HTTP 200 with { code, message, timestamp } — `code !== 200`
|
||||
// signals a logical failure (e.g. invalid device key) that we'd otherwise
|
||||
// swallow as a success.
|
||||
const json: any = await response.json().catch(() => null);
|
||||
if (json && typeof json.code === 'number' && json.code !== 200) {
|
||||
return { success: false, error: `Bark error: ${json.message || `code ${json.code}`}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Discord webhook notifications. discord:// or discords://. */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// discord://webhook_id/webhook_token or discords://...
|
||||
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: titleWithEnv,
|
||||
description: payload.message,
|
||||
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
||||
...(payload.environmentName && {
|
||||
footer: { text: `Environment: ${payload.environmentName}` }
|
||||
})
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// json://hostname/path or jsons://hostname/path
|
||||
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
environment: payload.environmentName || null,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Gotify. gotify:// or gotifys:// (HTTPS). */
|
||||
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = buildGotifyUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
|
||||
|
||||
try {
|
||||
const response = await fetch(parsed.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: parsed.priority ?? defaultPriority
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Notification router — picks the right per-provider sender based on the
|
||||
* channel type (SMTP / Apprise URL) and (for Apprise URLs) the URL scheme.
|
||||
*
|
||||
* Public surface used by API routes and the rest of the app:
|
||||
* - sendNotification (fan out to every enabled channel)
|
||||
* - testNotification (one channel, with a fixed test payload)
|
||||
* - sendEnvironmentNotification (Docker container event → matching channels)
|
||||
* - sendEventNotification (auto-update / git / vuln / system events)
|
||||
* - NotificationPayload, NotificationResult types
|
||||
*
|
||||
* Per-provider implementations live in sibling files (./bark, ./discord, …).
|
||||
* This file orchestrates only — it never knows what's inside a Bark or
|
||||
* Telegram URL.
|
||||
*/
|
||||
|
||||
import {
|
||||
getEnabledNotificationSettings,
|
||||
getEnabledEnvironmentNotifications,
|
||||
getEnvironment,
|
||||
type NotificationSettingData,
|
||||
type SmtpConfig,
|
||||
type AppriseConfig,
|
||||
type NotificationEventType
|
||||
} from '../db';
|
||||
|
||||
import type { NotificationPayload, NotificationResult } from './shared';
|
||||
export type { NotificationPayload, NotificationResult } from './shared';
|
||||
|
||||
import { sendSmtpNotification } from './smtp';
|
||||
import { sendDiscord } from './discord';
|
||||
import { sendSlack } from './slack';
|
||||
import { sendMattermost } from './mattermost';
|
||||
import { sendTelegram } from './telegram';
|
||||
import { sendGotify } from './gotify';
|
||||
import { sendNtfy } from './ntfy';
|
||||
import { sendBark } from './bark';
|
||||
import { sendSignal } from './signal';
|
||||
import { sendApprise } from './apprise';
|
||||
import { sendPushover } from './pushover';
|
||||
import { sendGenericWebhook } from './generic-webhook';
|
||||
import { sendWorkflows } from './workflows';
|
||||
|
||||
// Send to every URL in an Apprise channel. Errors are aggregated so a single
|
||||
// bad URL doesn't silently mask a healthy one.
|
||||
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of config.urls) {
|
||||
try {
|
||||
const result = await sendToAppriseUrl(url, payload);
|
||||
if (!result.success && result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to send: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Route a single Apprise URL to the right sender. The switch is the ONLY
|
||||
// place that needs to grow when a new provider is added.
|
||||
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
// Custom schemes like 'tgram://' aren't valid URLs to new URL(),
|
||||
// so we match the prefix directly.
|
||||
const protocolMatch = url.match(/^([a-z]+):\/\//i);
|
||||
if (!protocolMatch) {
|
||||
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
|
||||
}
|
||||
const protocol = protocolMatch[1].toLowerCase();
|
||||
|
||||
switch (protocol) {
|
||||
case 'discord':
|
||||
case 'discords':
|
||||
return await sendDiscord(url, payload);
|
||||
case 'slack':
|
||||
case 'slacks':
|
||||
return await sendSlack(url, payload);
|
||||
case 'mmost':
|
||||
case 'mmosts':
|
||||
return await sendMattermost(url, payload);
|
||||
case 'tgram':
|
||||
return await sendTelegram(url, payload);
|
||||
case 'gotify':
|
||||
case 'gotifys':
|
||||
return await sendGotify(url, payload);
|
||||
case 'ntfy':
|
||||
case 'ntfys':
|
||||
return await sendNtfy(url, payload);
|
||||
case 'bark':
|
||||
case 'barks':
|
||||
return await sendBark(url, payload);
|
||||
case 'signal':
|
||||
case 'signals':
|
||||
return await sendSignal(url, payload);
|
||||
case 'apprise':
|
||||
case 'apprises':
|
||||
return await sendApprise(url, payload);
|
||||
case 'pushover':
|
||||
return await sendPushover(url, payload);
|
||||
case 'json':
|
||||
case 'jsons':
|
||||
return await sendGenericWebhook(url, payload);
|
||||
case 'workflows':
|
||||
return await sendWorkflows(url, payload);
|
||||
default:
|
||||
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
|
||||
const settings = await getEnabledNotificationSettings();
|
||||
const results: { name: string; success: boolean }[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
let result: NotificationResult = { success: false };
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
results.push({ name: setting.name, success: result.success });
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.every(r => r.success),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
|
||||
const payload: NotificationPayload = {
|
||||
title: 'Dockhand Test Notification',
|
||||
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
|
||||
type: 'info'
|
||||
};
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown notification type' };
|
||||
}
|
||||
|
||||
// Map Docker action to notification event type
|
||||
function mapActionToEventType(action: string): NotificationEventType | null {
|
||||
const mapping: Record<string, NotificationEventType> = {
|
||||
'start': 'container_started',
|
||||
'stop': 'container_stopped',
|
||||
'restart': 'container_restarted',
|
||||
'die': 'container_exited',
|
||||
'kill': 'container_exited',
|
||||
'oom': 'container_oom',
|
||||
'health_status: unhealthy': 'container_unhealthy',
|
||||
'health_status: healthy': 'container_healthy',
|
||||
'pull': 'image_pulled'
|
||||
};
|
||||
return mapping[action] || null;
|
||||
}
|
||||
|
||||
// Scanner image patterns to exclude from notifications
|
||||
const SCANNER_IMAGE_PATTERNS = [
|
||||
'anchore/grype',
|
||||
'aquasec/trivy',
|
||||
'ghcr.io/anchore/grype',
|
||||
'ghcr.io/aquasecurity/trivy'
|
||||
];
|
||||
|
||||
function isScannerContainer(image: string | null | undefined): boolean {
|
||||
if (!image) return false;
|
||||
const lowerImage = image.toLowerCase();
|
||||
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
export async function sendEnvironmentNotification(
|
||||
environmentId: number,
|
||||
action: string,
|
||||
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
|
||||
image?: string | null
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
const eventType = mapActionToEventType(action);
|
||||
if (!eventType) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) {
|
||||
return { success: false, sent: 0 };
|
||||
}
|
||||
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
if (envNotifications.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
const enrichedPayload: NotificationPayload = {
|
||||
...payload,
|
||||
environmentId,
|
||||
environmentName: env.name
|
||||
};
|
||||
|
||||
// Skip all notifications for scanner containers (Trivy, Grype)
|
||||
if (isScannerContainer(image)) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
for (const notif of envNotifications) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (notif.channelType === 'smtp') {
|
||||
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
||||
} else if (notif.channelType === 'apprise') {
|
||||
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
|
||||
export async function sendEventNotification(
|
||||
eventType: NotificationEventType,
|
||||
payload: NotificationPayload,
|
||||
environmentId?: number
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
let enrichedPayload = { ...payload };
|
||||
if (environmentId) {
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (env) {
|
||||
enrichedPayload.environmentId = environmentId;
|
||||
enrichedPayload.environmentName = env.name;
|
||||
}
|
||||
}
|
||||
|
||||
let channels: Array<{
|
||||
channel_type: 'smtp' | 'apprise';
|
||||
channel_name: string;
|
||||
config: SmtpConfig | AppriseConfig;
|
||||
}> = [];
|
||||
|
||||
if (environmentId) {
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
channels = envNotifications
|
||||
.filter(n => n.channelType && n.channelName)
|
||||
.map(n => ({
|
||||
channel_type: n.channelType!,
|
||||
channel_name: n.channelName!,
|
||||
config: n.config
|
||||
}));
|
||||
} else {
|
||||
const globalSettings = await getEnabledNotificationSettings();
|
||||
channels = globalSettings
|
||||
.filter(s => s.eventTypes?.includes(eventType))
|
||||
.map(s => ({
|
||||
channel_type: s.type,
|
||||
channel_name: s.name,
|
||||
config: s.config
|
||||
}));
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (channel.channel_type === 'smtp') {
|
||||
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
||||
} else if (channel.channel_type === 'apprise') {
|
||||
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
|
||||
const isSecure = appriseUrl.startsWith('mmosts');
|
||||
const protocol = isSecure ? 'https' : 'http';
|
||||
|
||||
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
|
||||
|
||||
// Check for botname (username@hostname format)
|
||||
let username: string | undefined;
|
||||
const atIndex = urlPart.indexOf('@');
|
||||
if (atIndex !== -1) {
|
||||
username = urlPart.substring(0, atIndex);
|
||||
urlPart = urlPart.substring(atIndex + 1);
|
||||
}
|
||||
|
||||
// The token is the last segment, everything else is hostname[:port][/path]
|
||||
const lastSlashIndex = urlPart.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
|
||||
}
|
||||
|
||||
const token = urlPart.substring(lastSlashIndex + 1);
|
||||
const hostAndPath = urlPart.substring(0, lastSlashIndex);
|
||||
|
||||
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
const body: Record<string, string> = {
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
};
|
||||
|
||||
if (username) {
|
||||
body.username = username;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// ntfy://topic (public ntfy.sh)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with basic auth)
|
||||
// ntfy://token@host/topic (custom server with bearer token)
|
||||
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
|
||||
let url: string;
|
||||
let authHeader: string | null = null;
|
||||
|
||||
let queryAuth: string | null = null;
|
||||
let queryTags: string | null = null;
|
||||
let queryTitle: string | null = null;
|
||||
let queryPriority: string | null = null;
|
||||
let cleanPath = path;
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
const params = new URLSearchParams(path.substring(qIndex + 1));
|
||||
queryAuth = params.get('auth');
|
||||
queryTags = params.get('tags');
|
||||
queryTitle = params.get('title');
|
||||
queryPriority = params.get('priority');
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
|
||||
if (basicMatch) {
|
||||
const [, user, pass, hostAndTopic] = basicMatch;
|
||||
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
authHeader = `Basic ${basic}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
|
||||
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
|
||||
if (tokenMatch) {
|
||||
const [, token, hostAndTopic] = tokenMatch;
|
||||
authHeader = `Bearer ${token}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else {
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
}
|
||||
} else if (cleanPath.includes('/')) {
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
} else {
|
||||
url = `https://ntfy.sh/${cleanPath}`;
|
||||
}
|
||||
|
||||
if (!authHeader && queryAuth) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultTags = payload.type || 'info';
|
||||
const headers: Record<string, string> = {
|
||||
'Title': queryTitle || titleWithEnv,
|
||||
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
|
||||
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
|
||||
};
|
||||
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.message
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Pushover. pushover://user_key/api_token. */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
|
||||
}
|
||||
|
||||
const [, userKey, apiToken] = match;
|
||||
const url = 'https://api.pushover.net/1/messages.json';
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared types + helpers used by every notification provider.
|
||||
*
|
||||
* Imported by the router (./index.ts) and by every per-provider file
|
||||
* (discord.ts, slack.ts, …). Keeps the providers free of cross-imports —
|
||||
* each provider only depends on this module.
|
||||
*/
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
environmentId?: number;
|
||||
environmentName?: string;
|
||||
}
|
||||
|
||||
export interface NotificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
export async function drainResponse(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
try { await response.arrayBuffer(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Append `[env name]` to a title when present. Used by every provider. */
|
||||
export function titleWithEnv(payload: NotificationPayload): string {
|
||||
return payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Signal — via bbernhard/signal-cli-rest-api
|
||||
* (https://github.com/bbernhard/signal-cli-rest-api).
|
||||
*
|
||||
* Supported formats:
|
||||
* signal://host[:port]/+source/+target1[/+target2/...]
|
||||
* signals://host[:port]/+source/+target1[/+target2/...] (HTTPS)
|
||||
*
|
||||
* `+source` is the sender's registered Signal number (E.164 format). The '+'
|
||||
* is optional in the URL — we re-add it. Recipients can be Signal phone
|
||||
* numbers (numeric, '+' gets added) or group IDs (signal-cli's "group.<base64>"
|
||||
* form, passed through untouched).
|
||||
*/
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendSignal(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const isSecure = appriseUrl.startsWith('signals');
|
||||
const raw = appriseUrl.replace(/^signals?:\/\//, '');
|
||||
|
||||
// Strip query string so a future `?foo=bar` doesn't end up in the last
|
||||
// recipient. Currently we don't honor any params, but the parsing should
|
||||
// be forward-compatible.
|
||||
const qIndex = raw.indexOf('?');
|
||||
const cleanPath = qIndex === -1 ? raw : raw.substring(0, qIndex);
|
||||
|
||||
const parts = cleanPath.split('/').filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return { success: false, error: 'Invalid Signal URL. Expected: signal://host[:port]/+source/+target1[/+target2/...]' };
|
||||
}
|
||||
const hostPort = parts[0];
|
||||
|
||||
// Phone numbers may or may not start with '+' in the URL — Signal needs
|
||||
// the '+'. Group IDs (signal-cli's "group.<base64>" form) and other
|
||||
// non-numeric recipients are passed through untouched.
|
||||
const normalize = (n: string) => {
|
||||
if (n.startsWith('+')) return n;
|
||||
if (/^\d+$/.test(n)) return `+${n}`;
|
||||
return n;
|
||||
};
|
||||
const source = normalize(parts[1]);
|
||||
const recipients = parts.slice(2).map(normalize);
|
||||
|
||||
// signal-cli-rest-api uses 'message' for body and 'number' for sender;
|
||||
// title is prepended to the body since Signal messages don't have a title field.
|
||||
const titleWithEnv = payload.environmentName
|
||||
? `${payload.title} [${payload.environmentName}]`
|
||||
: payload.title;
|
||||
const messageText = `${titleWithEnv}\n\n${payload.message}`;
|
||||
|
||||
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v2/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
number: source,
|
||||
recipients,
|
||||
message: messageText
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Signal error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Signal connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Slack incoming webhook. slack:// or slacks:// or a raw hooks.slack.com URL. */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// slack://token_a/token_b/token_c or webhook URL
|
||||
let url: string;
|
||||
if (appriseUrl.includes('hooks.slack.com')) {
|
||||
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
|
||||
} else {
|
||||
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
|
||||
url = `https://hooks.slack.com/services/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** SMTP email notifications via nodemailer. */
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { SmtpConfig } from '../db';
|
||||
import type { NotificationPayload, NotificationResult } from './shared';
|
||||
|
||||
export async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
: '';
|
||||
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
|
||||
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
|
||||
to: config.to_emails.join(', '),
|
||||
subject: `[Dockhand]${envText} ${payload.title}`,
|
||||
text: `${payload.title}${envText}\n\n${payload.message}`,
|
||||
html
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `SMTP error: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Telegram bot. tgram://bot_token/chat_id[:topic_id]. */
|
||||
import { escapeTelegramMarkdown, parseTelegramUrl } from '$lib/utils/notification-parsers';
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseTelegramUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
|
||||
}
|
||||
|
||||
const { botToken, chatId, topicId } = parsed;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
|
||||
...(topicId ? { message_thread_id: topicId } : {}),
|
||||
parse_mode: 'Markdown',
|
||||
link_preview_options: {
|
||||
is_disabled: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})) as { description?: string };
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */
|
||||
import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseWorkflowsUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
|
||||
}
|
||||
|
||||
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.2',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'heading',
|
||||
wrap: true,
|
||||
text: titleWithEnv
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'default',
|
||||
wrap: true,
|
||||
text: payload.message
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user