feat(platform-service): add telegram and slack delivery

This commit is contained in:
root 2026-03-14 06:01:59 +00:00
parent db9ae4a573
commit 114240c79a
8 changed files with 341 additions and 22 deletions

View File

@ -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

View File

@ -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);

View File

@ -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) };
}
}

View File

@ -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) };
}
}

View File

@ -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', () => {

View File

@ -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<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,
* 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;
}

View File

@ -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);

View File

@ -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<typeof SendEmailSchema>;
export type SendPushInput = z.infer<typeof SendPushSchema>;
export type SendTelegramInput = z.infer<typeof SendTelegramSchema>;
export type SendSlackInput = z.infer<typeof SendSlackSchema>;
// ── 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;
}