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_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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 { 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', () => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user