192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
/**
|
|
* Notify Step — send notification to a configured channel.
|
|
* Config: { channel_id: 1, message: "...", card_template?: {...} }
|
|
* Channel config is loaded from notification_channels table.
|
|
*/
|
|
|
|
import { registerStepExecutor } from '../pipeline-engine';
|
|
import { postgresClient } from '../postgres-client';
|
|
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
|
|
|
|
async function executeNotify(
|
|
step: PipelineStep,
|
|
_context: PipelineContext,
|
|
_executionId: number
|
|
): Promise<StepExecutorResult> {
|
|
const channelId = Number(step.config.channel_id);
|
|
|
|
if (!channelId || isNaN(channelId)) {
|
|
return { success: false, error: 'Missing or invalid channel_id' };
|
|
}
|
|
|
|
const result = await postgresClient.query<NotificationChannel>(
|
|
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
|
|
[channelId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return { success: false, error: `Notification channel #${channelId} not found or inactive` };
|
|
}
|
|
|
|
const channel = result.rows[0];
|
|
const message = step.config.message || '';
|
|
|
|
switch (channel.channel_type) {
|
|
case 'teams':
|
|
return await sendTeams(channel, step.config, message);
|
|
case 'telegram':
|
|
return await sendTelegram(channel, message);
|
|
case 'ntfy':
|
|
return await sendNtfy(channel, step.config, message);
|
|
case 'webhook':
|
|
return await sendWebhook(channel, step.config, message);
|
|
default:
|
|
return { success: false, error: `Unknown channel type: ${channel.channel_type}` };
|
|
}
|
|
}
|
|
|
|
async function sendTeams(
|
|
channel: NotificationChannel,
|
|
config: Record<string, any>,
|
|
message: string
|
|
): Promise<StepExecutorResult> {
|
|
const webhookUrl = channel.config.webhook_url;
|
|
if (!webhookUrl) {
|
|
return { success: false, error: 'Teams channel missing webhook_url' };
|
|
}
|
|
|
|
// If a card_template is provided, use it as Adaptive Card
|
|
const body = config.card_template || {
|
|
type: 'message',
|
|
attachments: [{
|
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
content: {
|
|
type: 'AdaptiveCard',
|
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
version: '1.4',
|
|
body: [
|
|
{ type: 'TextBlock', text: config.title || 'Pulse Notification', weight: 'bolder', size: 'medium' },
|
|
{ type: 'TextBlock', text: message, wrap: true },
|
|
],
|
|
},
|
|
}],
|
|
};
|
|
|
|
const resp = await fetch(webhookUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const errText = await resp.text();
|
|
return { success: false, error: `Teams webhook failed (${resp.status}): ${errText.substring(0, 200)}` };
|
|
}
|
|
|
|
return { success: true, output: { notified: true, channel: 'teams' } };
|
|
}
|
|
|
|
async function sendTelegram(
|
|
channel: NotificationChannel,
|
|
message: string
|
|
): Promise<StepExecutorResult> {
|
|
const botToken = channel.config.bot_token;
|
|
const chatId = channel.config.chat_id;
|
|
|
|
if (!botToken || !chatId) {
|
|
return { success: false, error: 'Telegram channel missing bot_token or chat_id' };
|
|
}
|
|
|
|
const resp = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
chat_id: chatId,
|
|
text: message,
|
|
parse_mode: channel.config.parse_mode || 'HTML',
|
|
}),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const errText = await resp.text();
|
|
return { success: false, error: `Telegram API failed (${resp.status}): ${errText.substring(0, 200)}` };
|
|
}
|
|
|
|
return { success: true, output: { notified: true, channel: 'telegram' } };
|
|
}
|
|
|
|
async function sendNtfy(
|
|
channel: NotificationChannel,
|
|
config: Record<string, any>,
|
|
message: string
|
|
): Promise<StepExecutorResult> {
|
|
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
|
|
const topic = channel.config.topic;
|
|
|
|
if (!topic) {
|
|
return { success: false, error: 'ntfy channel missing topic' };
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'text/plain',
|
|
};
|
|
|
|
if (config.title || channel.config.default_title) {
|
|
headers['Title'] = config.title || channel.config.default_title;
|
|
}
|
|
if (config.priority || channel.config.default_priority) {
|
|
headers['Priority'] = config.priority || channel.config.default_priority;
|
|
}
|
|
if (channel.config.auth_token) {
|
|
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
|
|
}
|
|
|
|
const resp = await fetch(`${serverUrl}/${topic}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: message,
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const errText = await resp.text();
|
|
return { success: false, error: `ntfy failed (${resp.status}): ${errText.substring(0, 200)}` };
|
|
}
|
|
|
|
return { success: true, output: { notified: true, channel: 'ntfy' } };
|
|
}
|
|
|
|
async function sendWebhook(
|
|
channel: NotificationChannel,
|
|
config: Record<string, any>,
|
|
message: string
|
|
): Promise<StepExecutorResult> {
|
|
const url = channel.config.url;
|
|
if (!url) {
|
|
return { success: false, error: 'Webhook channel missing url' };
|
|
}
|
|
|
|
const method = channel.config.method || 'POST';
|
|
const customHeaders = channel.config.headers || {};
|
|
|
|
const body = config.body_template
|
|
? config.body_template
|
|
: { message, timestamp: new Date().toISOString() };
|
|
|
|
const resp = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...customHeaders,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const errText = await resp.text();
|
|
return { success: false, error: `Webhook failed (${resp.status}): ${errText.substring(0, 200)}` };
|
|
}
|
|
|
|
return { success: true, output: { notified: true, channel: 'webhook' } };
|
|
}
|
|
|
|
registerStepExecutor('notify', executeNotify);
|