feat(platform-service): add telegram and slack delivery
This commit is contained in:
parent
db9ae4a573
commit
114240c79a
@ -38,6 +38,10 @@ SMTP_PORT=1025
|
|||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_DEFAULT_CHAT_ID=
|
||||||
|
SLACK_WEBHOOK_URL=
|
||||||
|
SLACK_DEFAULT_CHANNEL=
|
||||||
|
|
||||||
# ── Extraction Service (port 4005 + Python sidecar 4006) ─────
|
# ── Extraction Service (port 4005 + Python sidecar 4006) ─────
|
||||||
PYTHON_SIDECAR_URL=http://localhost:4006
|
PYTHON_SIDECAR_URL=http://localhost:4006
|
||||||
|
|||||||
@ -57,6 +57,10 @@ const envSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
SMTP_USER: z.string().optional(),
|
SMTP_USER: z.string().optional(),
|
||||||
SMTP_PASSWORD: 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);
|
export const config = envSchema.parse(process.env);
|
||||||
|
|||||||
@ -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<SendResult> {
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<SendResult> {
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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 { BUILT_IN_TEMPLATES, getTemplate, listTemplateIds } from './templates.js';
|
||||||
import { renderTemplate, interpolate } from './renderer.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 ───────────────────────────────────────────
|
// ── Template Tests ───────────────────────────────────────────
|
||||||
|
|
||||||
describe('templates', () => {
|
describe('templates', () => {
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import { renderTemplate } from './renderer.js';
|
import { renderTemplate } from './renderer.js';
|
||||||
import { sendEmail } from './channels/email.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 * as repo from './repository.js';
|
||||||
import type { DeliveryLogDoc, EmailChannelConfig } from './types.js';
|
import type {
|
||||||
|
DeliveryChannel,
|
||||||
|
DeliveryLogDoc,
|
||||||
|
EmailChannelConfig,
|
||||||
|
SlackChannelConfig,
|
||||||
|
TelegramChannelConfig,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
// ── Delivery Dispatcher ──────────────────────────────────────
|
// ── Delivery Dispatcher ──────────────────────────────────────
|
||||||
// Routes delivery requests to the correct channel adapter.
|
// 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<void> {
|
||||||
|
try {
|
||||||
|
await repo.createDeliveryLog(doc);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeLog(
|
||||||
|
logDoc: DeliveryLogDoc,
|
||||||
|
result: { success: boolean; messageId?: string; error?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
* Send a templated email. Renders the template, sends via configured channel,
|
||||||
* and logs the delivery attempt.
|
* and logs the delivery attempt.
|
||||||
@ -41,7 +92,7 @@ export async function dispatchEmail(
|
|||||||
): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
||||||
const config = resolveEmailConfig();
|
const config = resolveEmailConfig();
|
||||||
const now = new Date();
|
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
|
// Render template
|
||||||
let rendered: { subject: string; bodyHtml: string; bodyText: string };
|
let rendered: { subject: string; bodyHtml: string; bodyText: string };
|
||||||
@ -67,11 +118,7 @@ export async function dispatchEmail(
|
|||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
await createPendingLog(logDoc);
|
||||||
await repo.createDeliveryLog(logDoc);
|
|
||||||
} catch {
|
|
||||||
// Non-fatal — continue sending
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send
|
// Send
|
||||||
const result = await sendEmail(
|
const result = await sendEmail(
|
||||||
@ -88,17 +135,81 @@ export async function dispatchEmail(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update log
|
// Update log
|
||||||
try {
|
await finalizeLog(logDoc, result);
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { extractAuth } from '../../lib/auth.js';
|
import { extractAuth } from '../../lib/auth.js';
|
||||||
import { BadRequestError } from '../../lib/errors.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 { BUILT_IN_TEMPLATES } from './templates.js';
|
||||||
import { dispatchEmail } from './dispatcher.js';
|
import { dispatchEmail, dispatchSlack, dispatchTelegram } from './dispatcher.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
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 };
|
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
|
// List delivery logs
|
||||||
app.get('/delivery/logs', async req => {
|
app.get('/delivery/logs', async req => {
|
||||||
await extractAuth(req);
|
await extractAuth(req);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// ── Delivery Channels ────────────────────────────────────────
|
// ── 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';
|
export type DeliveryStatus = 'pending' | 'sent' | 'failed' | 'bounced';
|
||||||
|
|
||||||
// ── Email Template ───────────────────────────────────────────
|
// ── Email Template ───────────────────────────────────────────
|
||||||
@ -33,6 +33,20 @@ export const SendPushSchema = z.object({
|
|||||||
productId: z.string().min(1),
|
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({
|
export const SendTestEmailSchema = z.object({
|
||||||
to: z.string().email(),
|
to: z.string().email(),
|
||||||
templateId: z.string().min(1),
|
templateId: z.string().min(1),
|
||||||
@ -41,6 +55,8 @@ export const SendTestEmailSchema = z.object({
|
|||||||
|
|
||||||
export type SendEmailInput = z.infer<typeof SendEmailSchema>;
|
export type SendEmailInput = z.infer<typeof SendEmailSchema>;
|
||||||
export type SendPushInput = z.infer<typeof SendPushSchema>;
|
export type SendPushInput = z.infer<typeof SendPushSchema>;
|
||||||
|
export type SendTelegramInput = z.infer<typeof SendTelegramSchema>;
|
||||||
|
export type SendSlackInput = z.infer<typeof SendSlackSchema>;
|
||||||
|
|
||||||
// ── Delivery Log ─────────────────────────────────────────────
|
// ── Delivery Log ─────────────────────────────────────────────
|
||||||
|
|
||||||
@ -79,3 +95,13 @@ export interface PushChannelConfig {
|
|||||||
apns?: { teamId: string; keyId: string; key: string; bundleId: string };
|
apns?: { teamId: string; keyId: string; key: string; bundleId: string };
|
||||||
fcm?: { projectId: string; serviceAccountKey: string };
|
fcm?: { projectId: string; serviceAccountKey: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelegramChannelConfig {
|
||||||
|
botToken?: string;
|
||||||
|
defaultChatId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlackChannelConfig {
|
||||||
|
webhookUrl?: string;
|
||||||
|
defaultChannel?: string;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user