diff --git a/.env.example b/.env.example index 39fdc2e7..db9bc8fa 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ SMTP_PORT=1025 SMTP_SECURE=false SMTP_USER= SMTP_PASSWORD= +TELEGRAM_BOT_TOKEN= +TELEGRAM_DEFAULT_CHAT_ID= +SLACK_WEBHOOK_URL= +SLACK_DEFAULT_CHANNEL= # ── Extraction Service (port 4005 + Python sidecar 4006) ───── PYTHON_SIDECAR_URL=http://localhost:4006 diff --git a/services/platform-service/src/lib/config.ts b/services/platform-service/src/lib/config.ts index 4d2e3279..7043a410 100644 --- a/services/platform-service/src/lib/config.ts +++ b/services/platform-service/src/lib/config.ts @@ -57,6 +57,10 @@ const envSchema = z.object({ .optional(), SMTP_USER: z.string().optional(), SMTP_PASSWORD: z.string().optional(), + TELEGRAM_BOT_TOKEN: z.string().optional(), + TELEGRAM_DEFAULT_CHAT_ID: z.string().optional(), + SLACK_WEBHOOK_URL: z.string().optional(), + SLACK_DEFAULT_CHANNEL: z.string().optional(), }); export const config = envSchema.parse(process.env); diff --git a/services/platform-service/src/modules/delivery/channels/slack.ts b/services/platform-service/src/modules/delivery/channels/slack.ts new file mode 100644 index 00000000..8cab6908 --- /dev/null +++ b/services/platform-service/src/modules/delivery/channels/slack.ts @@ -0,0 +1,43 @@ +import type { SlackChannelConfig } from '../types.js'; +import type { SendResult } from './email.js'; + +export interface SlackMessage { + text: string; + channel?: string; + username?: string; +} + +export async function sendSlackMessage( + message: SlackMessage, + config: SlackChannelConfig +): Promise { + if (!config.webhookUrl) { + return { success: false, error: 'Slack webhook URL not configured' }; + } + + try { + const response = await fetch(config.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: message.text, + channel: message.channel || config.defaultChannel, + username: message.username, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { success: false, error: `Slack error ${response.status}: ${errorText}` }; + } + + return { + success: true, + messageId: `slack_${crypto.randomUUID()}`, + }; + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/services/platform-service/src/modules/delivery/channels/telegram.ts b/services/platform-service/src/modules/delivery/channels/telegram.ts new file mode 100644 index 00000000..b73c8a63 --- /dev/null +++ b/services/platform-service/src/modules/delivery/channels/telegram.ts @@ -0,0 +1,54 @@ +import type { TelegramChannelConfig } from '../types.js'; +import type { SendResult } from './email.js'; + +export interface TelegramMessage { + text: string; + chatId: string; + disableNotification?: boolean; +} + +export async function sendTelegramMessage( + message: TelegramMessage, + config: TelegramChannelConfig +): Promise { + if (!config.botToken) { + return { success: false, error: 'Telegram bot token not configured' }; + } + + try { + const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_id: message.chatId, + text: message.text, + disable_notification: message.disableNotification ?? false, + }), + }); + + const payload = (await response.json()) as { + ok?: boolean; + description?: string; + result?: { message_id?: number }; + }; + + if (!response.ok || !payload.ok) { + return { + success: false, + error: payload.description || `Telegram error ${response.status}`, + }; + } + + return { + success: true, + messageId: + typeof payload.result?.message_id === 'number' + ? String(payload.result.message_id) + : `telegram_${crypto.randomUUID()}`, + }; + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/services/platform-service/src/modules/delivery/delivery.test.ts b/services/platform-service/src/modules/delivery/delivery.test.ts index f2ca8d0d..9e00fa3e 100644 --- a/services/platform-service/src/modules/delivery/delivery.test.ts +++ b/services/platform-service/src/modules/delivery/delivery.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { SendEmailSchema, SendTestEmailSchema, SendPushSchema } from './types.js'; +import { + SendEmailSchema, + SendPushSchema, + SendSlackSchema, + SendTelegramSchema, + SendTestEmailSchema, +} from './types.js'; import { BUILT_IN_TEMPLATES, getTemplate, listTemplateIds } from './templates.js'; import { renderTemplate, interpolate } from './renderer.js'; @@ -78,6 +84,42 @@ describe('SendTestEmailSchema', () => { }); }); +describe('SendTelegramSchema', () => { + it('accepts valid telegram send request', () => { + const result = SendTelegramSchema.safeParse({ + text: 'Build succeeded', + productId: 'lysnrai', + chatId: '-1001234567890', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing text', () => { + const result = SendTelegramSchema.safeParse({ + productId: 'lysnrai', + }); + expect(result.success).toBe(false); + }); +}); + +describe('SendSlackSchema', () => { + it('accepts valid slack send request', () => { + const result = SendSlackSchema.safeParse({ + text: 'Build succeeded', + productId: 'lysnrai', + channel: '#ops-alerts', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing text', () => { + const result = SendSlackSchema.safeParse({ + productId: 'lysnrai', + }); + expect(result.success).toBe(false); + }); +}); + // ── Template Tests ─────────────────────────────────────────── describe('templates', () => { diff --git a/services/platform-service/src/modules/delivery/dispatcher.ts b/services/platform-service/src/modules/delivery/dispatcher.ts index 438c28d6..5e207ebb 100644 --- a/services/platform-service/src/modules/delivery/dispatcher.ts +++ b/services/platform-service/src/modules/delivery/dispatcher.ts @@ -1,7 +1,15 @@ import { renderTemplate } from './renderer.js'; import { sendEmail } from './channels/email.js'; +import { sendSlackMessage } from './channels/slack.js'; +import { sendTelegramMessage } from './channels/telegram.js'; import * as repo from './repository.js'; -import type { DeliveryLogDoc, EmailChannelConfig } from './types.js'; +import type { + DeliveryChannel, + DeliveryLogDoc, + EmailChannelConfig, + SlackChannelConfig, + TelegramChannelConfig, +} from './types.js'; // ── Delivery Dispatcher ────────────────────────────────────── // Routes delivery requests to the correct channel adapter. @@ -25,6 +33,49 @@ export function resolveEmailConfig(): EmailChannelConfig { }; } +export function resolveTelegramConfig(): TelegramChannelConfig { + return { + botToken: process.env.TELEGRAM_BOT_TOKEN, + defaultChatId: process.env.TELEGRAM_DEFAULT_CHAT_ID, + }; +} + +export function resolveSlackConfig(): SlackChannelConfig { + return { + webhookUrl: process.env.SLACK_WEBHOOK_URL, + defaultChannel: process.env.SLACK_DEFAULT_CHANNEL, + }; +} + +function buildDeliveryPk(productId: string, channel: DeliveryChannel, now: Date): string { + return `${productId}:${channel}:${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}`; +} + +async function createPendingLog(doc: DeliveryLogDoc): Promise { + try { + await repo.createDeliveryLog(doc); + } catch { + // Non-fatal + } +} + +async function finalizeLog( + logDoc: DeliveryLogDoc, + result: { success: boolean; messageId?: string; error?: string } +): Promise { + try { + await repo.updateDeliveryLog({ + ...logDoc, + status: result.success ? 'sent' : 'failed', + sentAt: result.success ? new Date().toISOString() : undefined, + error: result.error, + metadata: result.messageId ? { messageId: result.messageId } : undefined, + }); + } catch { + // Non-fatal + } +} + /** * Send a templated email. Renders the template, sends via configured channel, * and logs the delivery attempt. @@ -41,7 +92,7 @@ export async function dispatchEmail( ): Promise<{ success: boolean; messageId?: string; error?: string }> { const config = resolveEmailConfig(); const now = new Date(); - const pk = `${options.productId}:email:${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}`; + const pk = buildDeliveryPk(options.productId, 'email', now); // Render template let rendered: { subject: string; bodyHtml: string; bodyText: string }; @@ -67,11 +118,7 @@ export async function dispatchEmail( createdAt: now.toISOString(), }; - try { - await repo.createDeliveryLog(logDoc); - } catch { - // Non-fatal — continue sending - } + await createPendingLog(logDoc); // Send const result = await sendEmail( @@ -88,17 +135,81 @@ export async function dispatchEmail( ); // Update log - try { - await repo.updateDeliveryLog({ - ...logDoc, - status: result.success ? 'sent' : 'failed', - sentAt: result.success ? new Date().toISOString() : undefined, - error: result.error, - metadata: result.messageId ? { messageId: result.messageId } : undefined, - }); - } catch { - // Non-fatal - } + await finalizeLog(logDoc, result); return result; } + +export async function dispatchTelegram(options: { + text: string; + productId: string; + chatId?: string; + disableNotification?: boolean; + userId?: string; +}): Promise<{ success: boolean; messageId?: string; error?: string }> { + const config = resolveTelegramConfig(); + const chatId = options.chatId || config.defaultChatId; + if (!chatId) { + return { success: false, error: 'Telegram chat ID not configured' }; + } + + const now = new Date(); + const logDoc: DeliveryLogDoc = { + id: `del_${crypto.randomUUID()}`, + productId: options.productId, + pk: buildDeliveryPk(options.productId, 'telegram', now), + userId: options.userId, + channel: 'telegram', + to: chatId, + subject: 'Telegram notification', + status: 'pending', + createdAt: now.toISOString(), + }; + + await createPendingLog(logDoc); + const result = await sendTelegramMessage( + { + chatId, + text: options.text, + disableNotification: options.disableNotification, + }, + config + ); + await finalizeLog(logDoc, result); + return result; +} + +export async function dispatchSlack(options: { + text: string; + productId: string; + channel?: string; + username?: string; + userId?: string; +}): Promise<{ success: boolean; messageId?: string; error?: string }> { + const config = resolveSlackConfig(); + const destination = options.channel || config.defaultChannel || 'configured-webhook'; + const now = new Date(); + const logDoc: DeliveryLogDoc = { + id: `del_${crypto.randomUUID()}`, + productId: options.productId, + pk: buildDeliveryPk(options.productId, 'slack', now), + userId: options.userId, + channel: 'slack', + to: destination, + subject: 'Slack notification', + status: 'pending', + createdAt: now.toISOString(), + }; + + await createPendingLog(logDoc); + const result = await sendSlackMessage( + { + text: options.text, + channel: options.channel, + username: options.username, + }, + config + ); + await finalizeLog(logDoc, result); + return result; +} diff --git a/services/platform-service/src/modules/delivery/routes.ts b/services/platform-service/src/modules/delivery/routes.ts index d9d32798..0befe311 100644 --- a/services/platform-service/src/modules/delivery/routes.ts +++ b/services/platform-service/src/modules/delivery/routes.ts @@ -1,9 +1,14 @@ import type { FastifyInstance } from 'fastify'; import { extractAuth } from '../../lib/auth.js'; import { BadRequestError } from '../../lib/errors.js'; -import { SendEmailSchema, SendTestEmailSchema } from './types.js'; +import { + SendEmailSchema, + SendSlackSchema, + SendTelegramSchema, + SendTestEmailSchema, +} from './types.js'; import { BUILT_IN_TEMPLATES } from './templates.js'; -import { dispatchEmail } from './dispatcher.js'; +import { dispatchEmail, dispatchSlack, dispatchTelegram } from './dispatcher.js'; import * as repo from './repository.js'; const DEFAULT_PRODUCT_ID = 'lysnrai'; @@ -75,6 +80,36 @@ export async function deliveryRoutes(app: FastifyInstance) { return { success: result.success, messageId: result.messageId, error: result.error }; }); + app.post('/delivery/send-telegram', async req => { + await extractAuth(req); + const parsed = SendTelegramSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const result = await dispatchTelegram(parsed.data); + if (!result.success) { + throw new BadRequestError(`Telegram delivery failed: ${result.error}`); + } + + return { success: true, messageId: result.messageId }; + }); + + app.post('/delivery/send-slack', async req => { + await extractAuth(req); + const parsed = SendSlackSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const result = await dispatchSlack(parsed.data); + if (!result.success) { + throw new BadRequestError(`Slack delivery failed: ${result.error}`); + } + + return { success: true, messageId: result.messageId }; + }); + // List delivery logs app.get('/delivery/logs', async req => { await extractAuth(req); diff --git a/services/platform-service/src/modules/delivery/types.ts b/services/platform-service/src/modules/delivery/types.ts index fa7151d7..6ee78152 100644 --- a/services/platform-service/src/modules/delivery/types.ts +++ b/services/platform-service/src/modules/delivery/types.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; // ── Delivery Channels ──────────────────────────────────────── -export type DeliveryChannel = 'email' | 'push_apns' | 'push_fcm' | 'sms'; +export type DeliveryChannel = 'email' | 'telegram' | 'slack' | 'push_apns' | 'push_fcm' | 'sms'; export type DeliveryStatus = 'pending' | 'sent' | 'failed' | 'bounced'; // ── Email Template ─────────────────────────────────────────── @@ -33,6 +33,20 @@ export const SendPushSchema = z.object({ productId: z.string().min(1), }); +export const SendTelegramSchema = z.object({ + text: z.string().min(1), + productId: z.string().min(1), + chatId: z.string().min(1).optional(), + disableNotification: z.boolean().optional(), +}); + +export const SendSlackSchema = z.object({ + text: z.string().min(1), + productId: z.string().min(1), + channel: z.string().min(1).optional(), + username: z.string().min(1).optional(), +}); + export const SendTestEmailSchema = z.object({ to: z.string().email(), templateId: z.string().min(1), @@ -41,6 +55,8 @@ export const SendTestEmailSchema = z.object({ export type SendEmailInput = z.infer; export type SendPushInput = z.infer; +export type SendTelegramInput = z.infer; +export type SendSlackInput = z.infer; // ── Delivery Log ───────────────────────────────────────────── @@ -79,3 +95,13 @@ export interface PushChannelConfig { apns?: { teamId: string; keyId: string; key: string; bundleId: string }; fcm?: { projectId: string; serviceAccountKey: string }; } + +export interface TelegramChannelConfig { + botToken?: string; + defaultChatId?: string; +} + +export interface SlackChannelConfig { + webhookUrl?: string; + defaultChannel?: string; +}