wulf-pulse/lib/services/pipeline-steps/approval.ts

138 lines
4.8 KiB
TypeScript

/**
* Approval Step — send approval request, pause pipeline until callback.
* Config: { channel_id: 1, message: "...", options: ["Approve","Reject","Escalate"], timeout_min: 60 }
*/
import { registerStepExecutor } from '../pipeline-engine';
import { postgresClient } from '../postgres-client';
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
async function executeApproval(
step: PipelineStep,
context: PipelineContext,
executionId: number
): Promise<StepExecutorResult> {
const channelId = Number(step.config.channel_id);
const message = step.config.message || 'Approval required';
const options = step.config.options || ['Approve', 'Reject'];
const timeoutMin = Number(step.config.timeout_min) || 60;
const expiresAt = new Date(Date.now() + timeoutMin * 60 * 1000);
// Create approval request record
const result = await postgresClient.query<{ id: number }>(
`INSERT INTO approval_requests (execution_id, step_order, channel_id, message, options, status, expires_at)
VALUES ($1, $2, $3, $4, $5, 'pending', $6)
RETURNING id`,
[executionId, step.step_order, channelId || null, message, JSON.stringify(options), expiresAt]
);
const approvalId = result.rows[0].id;
const callbackUrl = `${process.env.WEBHOOK_BASE_URL || ''}/api/pipelines/approval/${approvalId}`;
console.log(`[PIPELINE:approval] Created approval #${approvalId}, callback: ${callbackUrl}`);
// Send notification with approval buttons if channel is configured
if (channelId) {
const chResult = await postgresClient.query<NotificationChannel>(
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
[channelId]
);
if (chResult.rows.length > 0) {
const channel = chResult.rows[0];
await sendApprovalNotification(channel, message, options, approvalId, callbackUrl, context);
}
}
// Return waiting — pipeline will pause here
return {
success: true,
waiting: true,
output: { approval_id: approvalId, callback_url: callbackUrl },
};
}
async function sendApprovalNotification(
channel: NotificationChannel,
message: string,
options: string[],
approvalId: number,
callbackUrl: string,
context: PipelineContext
): Promise<void> {
try {
if (channel.channel_type === 'teams') {
const actions = options.map(opt => ({
type: 'Action.OpenUrl',
title: opt,
url: `${callbackUrl}?response=${encodeURIComponent(opt)}`,
}));
const card = {
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: 'Approval Required', weight: 'bolder', size: 'medium' },
{ type: 'TextBlock', text: message, wrap: true },
{ type: 'TextBlock', text: `Approval #${approvalId}`, size: 'small', isSubtle: true },
],
actions,
},
}],
};
await fetch(channel.config.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(card),
});
} else if (channel.channel_type === 'telegram') {
const keyboard = {
inline_keyboard: [options.map(opt => ({
text: opt,
callback_data: JSON.stringify({ approval_id: approvalId, response: opt }),
}))],
};
await fetch(`https://api.telegram.org/bot${channel.config.bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: channel.config.chat_id,
text: `🔔 *Approval Required*\n\n${message}\n\n_Approval #${approvalId}_`,
parse_mode: 'Markdown',
reply_markup: keyboard,
}),
});
} else if (channel.channel_type === 'ntfy') {
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
const headers: Record<string, string> = {
'Title': 'Approval Required',
'Priority': 'high',
'Tags': 'warning',
'Actions': options.map(opt =>
`http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST`
).join('; '),
};
if (channel.config.auth_token) {
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
}
await fetch(`${serverUrl}/${channel.config.topic}`, {
method: 'POST',
headers,
body: message,
});
}
} catch (err) {
console.error(`[PIPELINE:approval] Failed to send notification:`, err);
}
}
registerStepExecutor('approval', executeApproval);