Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/PROVIDER_CAPABILITIES.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions listener/src/providers/provider-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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**');
});
});
124 changes: 124 additions & 0 deletions listener/src/providers/provider-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationCapability>;
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,
};
}