Portal Community

Interface Contract

public interface IMessagingChannelAdapter
{
    /// Unique channel type key — must match the node's "provider" config field.
    string ChannelType { get; }

    /// Send a message. Throw MessagingException on unrecoverable errors.
    /// Transient errors (network, rate limit) should throw TransientMessagingException
    /// so the executor's retry policy can retry without counting it as a failure.
    Task<MessagingResult> SendAsync(
        MessagingMessage message,
        MessagingConfig config,
        CancellationToken cancellationToken);
}

public record MessagingMessage
{
    public string To { get; init; } = default!;
    public string? Subject { get; init; }
    public string? Title { get; init; }
    public string? Text { get; init; }
    public string? BodyHtml { get; init; }
    public string? BodyText { get; init; }
    public object? Blocks { get; init; }
    public string? Body { get; init; }
    public Dictionary<string, object?>? Data { get; init; }
    public List<EmailAttachment>? Attachments { get; init; }
}

public record MessagingResult
{
    public string MessageId { get; init; } = default!;
    public string Channel { get; init; } = default!;
    public string Recipient { get; init; } = default!;
    public DateTimeOffset SentAt { get; init; }
    public string Status { get; init; } = "sent";
}

MessagingChannelRegistry

public class MessagingChannelRegistry : IMessagingChannelRegistry
{
    private readonly IReadOnlyDictionary<string, IMessagingChannelAdapter> _adapters;

    public MessagingChannelRegistry(IEnumerable<IMessagingChannelAdapter> adapters)
    {
        _adapters = adapters.ToDictionary(a => a.ChannelType, StringComparer.OrdinalIgnoreCase);
    }

    public IMessagingChannelAdapter GetAdapter(string channelType)
    {
        if (_adapters.TryGetValue(channelType, out var adapter))
            return adapter;
        throw new InvalidOperationException(
            $"No messaging adapter registered for channel type '{channelType}'. " +
            $"Registered: {string.Join(", ", _adapters.Keys)}");
    }

    public IReadOnlyCollection<string> RegisteredChannels =>
        _adapters.Keys.ToList().AsReadOnly();
}

Adding a New Channel — Three Steps

Step 1: Implement IMessagingChannelAdapter

// Example: Microsoft Teams adapter
public class TeamsChannelAdapter : IMessagingChannelAdapter
{
    private readonly ICredentialResolver _credentials;
    private readonly IHttpClientFactory _httpFactory;

    public string ChannelType => "Teams";

    public async Task<MessagingResult> SendAsync(
        MessagingMessage message,
        MessagingConfig config,
        CancellationToken ct)
    {
        // Retrieve webhook URL stored as credential
        var webhookUrl = await _credentials.GetPasswordAsync(config.CredentialId, ct);
        var client = _httpFactory.CreateClient();

        // Teams Adaptive Card payload
        var payload = new
        {
            type = "message",
            attachments = new[]
            {
                new
                {
                    contentType = "application/vnd.microsoft.card.adaptive",
                    content = new
                    {
                        type = "AdaptiveCard",
                        version = "1.4",
                        body = new[] { new { type = "TextBlock", text = message.Text, wrap = true } }
                    }
                }
            }
        };

        var response = await client.PostAsJsonAsync(webhookUrl, payload, ct);
        response.EnsureSuccessStatusCode();

        return new MessagingResult
        {
            MessageId = Guid.NewGuid().ToString(),
            Channel = "teams",
            Recipient = config.ChannelId,
            SentAt = DateTimeOffset.UtcNow,
            Status = "sent"
        };
    }
}

Step 2: Register with DI

// In your module's service registration
services.AddSingleton<IMessagingChannelAdapter, TeamsChannelAdapter>();

// The MessagingChannelRegistry is registered as:
services.AddSingleton<IMessagingChannelRegistry, MessagingChannelRegistry>();

Step 3: Add Atlas Form Fields for Config

Add a new entry to the SendMessage node's Atlas Form definition so the designer can configure the Teams-specific fields (webhook credential, channel name) when provider = "Teams" is selected.

Executor — Channel Resolution

public class SendMessageExecutor : BaseNodeExecutor<SendMessageConfig>
{
    private readonly IMessagingChannelRegistry _registry;
    private readonly IExpressionEvaluator _evaluator;

    protected override async Task<NodeExecutionResult> ExecuteAsync(
        SendMessageConfig config,
        NodeDataContext ctx,
        CancellationToken ct)
    {
        // Resolve the correct adapter for the configured provider
        var adapter = _registry.GetAdapter(config.Provider);

        // Evaluate all expression fields in the config
        var message = await _evaluator.EvaluateMessagingMessage(config, ctx);

        var result = await adapter.SendAsync(message, config, ct);

        return NodeExecutionResult.Success(new
        {
            result.MessageId,
            result.Channel,
            result.SentAt,
            result.Status,
            result.Recipient
        });
    }
}
ChannelType key casing: The MessagingChannelRegistry performs case-insensitive lookup. Use a consistent casing in your ChannelType property (e.g., "Teams") and matching node config value (e.g., "provider": "teams") — both will resolve correctly.