diff --git a/docs/PROVIDER_CAPABILITIES.md b/docs/PROVIDER_CAPABILITIES.md new file mode 100644 index 00000000..500908e4 --- /dev/null +++ b/docs/PROVIDER_CAPABILITIES.md @@ -0,0 +1,26 @@ +# 🧩 Notification Provider Capability Metadata & Degradation + +This document details the provider capability discovery and graceful degradation system for NotifyChain (Issue #708). + +--- + +## 1. Capability Taxonomy + +Providers declare their supported feature sets via `NotificationCapability`: + +* `rich_formatting`: Markdown syntax (bold, italics, inline code). +* `embedded_links`: Rich action buttons / hyperlinked titles. +* `attachments`: Binary media and document attachments. +* `message_updates`: Live in-place message editing. +* `threading`: Conversational threading / reply keys. +* `batching`: Multi-event batch payload processing. + +--- + +## 2. Graceful Degradation Strategy + +The core dispatch pipeline is completely decoupled from provider-specific logic. When a rich event is dispatched to a limited destination (e.g. Plain Webhook, SMS): + +1. **Markdown is stripped** to readable plaintext. +2. **Action links are expanded** into raw URLs. +3. **Attachments are referenced** textually with fallback notices without dropping notification delivery. diff --git a/listener/src/providers/provider-capabilities.test.ts b/listener/src/providers/provider-capabilities.test.ts new file mode 100644 index 00000000..96b0312d --- /dev/null +++ b/listener/src/providers/provider-capabilities.test.ts @@ -0,0 +1,45 @@ +import { + adaptPayloadForProvider, + DISCORD_CAPABILITY_PROFILE, + SMS_CAPABILITY_PROFILE, + WEBHOOK_CAPABILITY_PROFILE, + NotificationCapability, +} from './provider-capabilities'; + +describe('Provider Capability Metadata & Graceful Degradation (Issue #708)', () => { + const samplePayload = { + title: '🚨 Payment Received', + body: 'You received 500 XLM from Alice.', + markdownContent: '**🚨 Payment Received**\nYou received `500 XLM` from Alice.', + actions: [{ label: 'View Transaction', url: 'https://stellar.expert/tx/123' }], + attachments: [{ filename: 'receipt.pdf', url: 'https://example.com/receipt.pdf' }], + }; + + test('renders full rich formatting and embedded links on capable providers (Discord)', () => { + const formatted = adaptPayloadForProvider(samplePayload, DISCORD_CAPABILITY_PROFILE); + + expect(formatted.hasDegradedFeatures).toBe(false); + expect(formatted.omittedFeatures).toEqual([]); + expect(formatted.renderedText).toContain('**🚨 Payment Received**'); + expect(formatted.renderedText).toContain('[View Transaction](https://stellar.expert/tx/123)'); + }); + + test('gracefully degrades markdown and embeds on plain providers (SMS)', () => { + const formatted = adaptPayloadForProvider(samplePayload, SMS_CAPABILITY_PROFILE); + + expect(formatted.hasDegradedFeatures).toBe(true); + expect(formatted.omittedFeatures).toContain(NotificationCapability.RICH_FORMATTING); + expect(formatted.omittedFeatures).toContain(NotificationCapability.EMBEDDED_LINKS); + expect(formatted.omittedFeatures).toContain(NotificationCapability.ATTACHMENTS); + expect(formatted.renderedText).not.toContain('**'); + expect(formatted.renderedText).toContain('View Transaction: https://stellar.expert/tx/123'); + expect(formatted.renderedText).toContain('Attachments omitted: receipt.pdf'); + }); + + test('preserves generic webhook capabilities without hard dependencies', () => { + const formatted = adaptPayloadForProvider(samplePayload, WEBHOOK_CAPABILITY_PROFILE); + + expect(formatted.omittedFeatures).toContain(NotificationCapability.ATTACHMENTS); + expect(formatted.renderedText).toContain('**🚨 Payment Received**'); + }); +}); diff --git a/listener/src/providers/provider-capabilities.ts b/listener/src/providers/provider-capabilities.ts new file mode 100644 index 00000000..0dade1c9 --- /dev/null +++ b/listener/src/providers/provider-capabilities.ts @@ -0,0 +1,124 @@ +/** + * Provider Capability Metadata System (Issue #708) + * + * Enables notification providers to declare supported capabilities (rich formatting, + * embeds, attachments, message updates) and gracefully downgrades unsupported features. + */ + +export enum NotificationCapability { + RICH_FORMATTING = 'rich_formatting', // Markdown, bold, italics + EMBEDDED_LINKS = 'embedded_links', // Clickable hyperlink buttons + ATTACHMENTS = 'attachments', // File uploads, image media + MESSAGE_UPDATES = 'message_updates', // Editing dispatched notifications + THREADING = 'threading', // Thread/channel reply nesting + BATCHING = 'batching', // Multi-event batch payload delivery +} + +export interface ProviderCapabilities { + providerName: string; + version: string; + supportedCapabilities: Set; + maxPayloadBytes: number; + maxBatchSize: number; +} + +export interface NotificationPayload { + title: string; + body: string; + markdownContent?: string; + attachments?: Array<{ filename: string; url: string }>; + actions?: Array<{ label: string; url: string }>; +} + +export interface FormattedNotification { + renderedText: string; + hasDegradedFeatures: boolean; + omittedFeatures: NotificationCapability[]; +} + +/** + * Standard Capability Profiles for Core Providers + */ +export const DISCORD_CAPABILITY_PROFILE: ProviderCapabilities = { + providerName: 'Discord', + version: '1.0.0', + supportedCapabilities: new Set([ + NotificationCapability.RICH_FORMATTING, + NotificationCapability.EMBEDDED_LINKS, + NotificationCapability.ATTACHMENTS, + NotificationCapability.BATCHING, + ]), + maxPayloadBytes: 8192, + maxBatchSize: 10, +}; + +export const WEBHOOK_CAPABILITY_PROFILE: ProviderCapabilities = { + providerName: 'GenericWebhook', + version: '1.0.0', + supportedCapabilities: new Set([ + NotificationCapability.RICH_FORMATTING, + NotificationCapability.BATCHING, + ]), + maxPayloadBytes: 65536, + maxBatchSize: 100, +}; + +export const SMS_CAPABILITY_PROFILE: ProviderCapabilities = { + providerName: 'SMS', + version: '1.0.0', + supportedCapabilities: new Set([]), // Plain text only + maxPayloadBytes: 160, + maxBatchSize: 1, +}; + +/** + * Adapts and degrades a notification payload according to the destination provider's capabilities. + */ +export function adaptPayloadForProvider( + payload: NotificationPayload, + capabilities: ProviderCapabilities +): FormattedNotification { + const omittedFeatures: NotificationCapability[] = []; + let renderedText = ''; + + // 1. Formatting + if (capabilities.supportedCapabilities.has(NotificationCapability.RICH_FORMATTING)) { + renderedText = payload.markdownContent || `**${payload.title}**\n${payload.body}`; + } else { + // Strip markdown formatting + renderedText = `${payload.title}\n${payload.body}` + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1'); + if (payload.markdownContent) { + omittedFeatures.push(NotificationCapability.RICH_FORMATTING); + } + } + + // 2. Action links + if (payload.actions && payload.actions.length > 0) { + if (capabilities.supportedCapabilities.has(NotificationCapability.EMBEDDED_LINKS)) { + const linkList = payload.actions.map((a) => `[${a.label}](${a.url})`).join(' | '); + renderedText += `\n\n${linkList}`; + } else { + // Append raw URLs + const rawUrls = payload.actions.map((a) => `${a.label}: ${a.url}`).join('\n'); + renderedText += `\n\n${rawUrls}`; + omittedFeatures.push(NotificationCapability.EMBEDDED_LINKS); + } + } + + // 3. Attachments + if (payload.attachments && payload.attachments.length > 0) { + if (!capabilities.supportedCapabilities.has(NotificationCapability.ATTACHMENTS)) { + omittedFeatures.push(NotificationCapability.ATTACHMENTS); + renderedText += `\n(Attachments omitted: ${payload.attachments.map((a) => a.filename).join(', ')})`; + } + } + + return { + renderedText, + hasDegradedFeatures: omittedFeatures.length > 0, + omittedFeatures, + }; +}