From 2adb418b28ad9c69651dbfbb217d398093dc2c88 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sun, 14 Jun 2026 04:22:36 +0100 Subject: [PATCH] Add WhatsApp template APIs --- README.md | 17 ++- docs/API_COVERAGE.md | 20 +-- .../Messaging/ITermiiMessagingClient.cs | 8 ++ .../SendWhatsAppTemplateMediaRequest.cs | 21 +++ .../Messaging/SendWhatsAppTemplateRequest.cs | 35 +++++ src/Termii/Messaging/TermiiMessagingClient.cs | 38 +++++ .../Messaging/WhatsAppTemplateComponent.cs | 13 ++ src/Termii/Messaging/WhatsAppTemplateMedia.cs | 24 ++++ .../Messaging/WhatsAppTemplateResponse.cs | 22 +++ .../TermiiWhatsAppTemplateClientTests.cs | 132 ++++++++++++++++++ 10 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs create mode 100644 src/Termii/Messaging/SendWhatsAppTemplateRequest.cs create mode 100644 src/Termii/Messaging/WhatsAppTemplateComponent.cs create mode 100644 src/Termii/Messaging/WhatsAppTemplateMedia.cs create mode 100644 src/Termii/Messaging/WhatsAppTemplateResponse.cs create mode 100644 tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs diff --git a/README.md b/README.md index bb50e79..2684b68 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,18 @@ var response = await client.Numbers.SendAsync(new SendNumberMessageRequest }); ``` +Send a WhatsApp template message: + +```csharp +var template = await client.Messaging.SendWhatsAppTemplateAsync(new SendWhatsAppTemplateRequest +{ + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + Language = "en" +}); +``` + ## Sender IDs Fetch sender IDs: @@ -316,6 +328,7 @@ Request models also validate required fields before sending. Missing required va Implemented in the current SDK: - Messaging: send single, WhatsApp conversational, and bulk messages. +- WhatsApp templates: send template messages with or without media. - Sender IDs: list and request sender IDs. - Number API: send message through a dedicated Termii number. - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. @@ -323,10 +336,6 @@ Implemented in the current SDK: - Campaigns: list, create, update, and delete phonebooks. - Product emails: send template-based notification emails. -Deferred or not yet implemented: - -- WhatsApp template/device message APIs. - See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. ## Examples diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 3824162..a74600d 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -45,6 +45,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added | +| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added | | Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added | | Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | | Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | @@ -57,21 +59,19 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | -| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | In progress | #34 | Unit tests added | -| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | Unit tests added | +| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Implemented | #34, PR #35 | Unit tests added | +| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | Implemented | #31, PR #39 | Unit tests added | -## Deferred Coverage +## Receiver-Side Coverage -The following documented APIs are useful but should come after the first SDK milestone unless a consumer need pushes them forward. +The following SDK support is not an outbound Termii API call. | Area | Capability | Method | Path | Status | Notes | | --- | --- | --- | --- | --- | --- | -| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | -| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | ## Postman Collection Reconciliation diff --git a/src/Termii/Messaging/ITermiiMessagingClient.cs b/src/Termii/Messaging/ITermiiMessagingClient.cs index 9a68176..c990964 100644 --- a/src/Termii/Messaging/ITermiiMessagingClient.cs +++ b/src/Termii/Messaging/ITermiiMessagingClient.cs @@ -9,4 +9,12 @@ Task SendAsync( Task SendBulkAsync( SendBulkMessageRequest request, CancellationToken cancellationToken = default); + + Task SendWhatsAppTemplateAsync( + SendWhatsAppTemplateRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppTemplateMediaAsync( + SendWhatsAppTemplateMediaRequest request, + CancellationToken cancellationToken = default); } diff --git a/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs new file mode 100644 index 0000000..0ec5098 --- /dev/null +++ b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendWhatsAppTemplateMediaRequest : SendWhatsAppTemplateRequest +{ + [JsonPropertyName("media")] + public WhatsAppTemplateMedia Media { get; set; } = new(); + + internal override void Validate() + { + base.Validate(); + + if (Media is null) + { + throw new ArgumentNullException(nameof(Media)); + } + + Media.Validate(); + } +} diff --git a/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs new file mode 100644 index 0000000..647fef7 --- /dev/null +++ b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public class SendWhatsAppTemplateRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("device_id")] + public string DeviceId { get; set; } = string.Empty; + + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + + [JsonPropertyName("template_name")] + public string? TemplateName { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("variables")] + public Dictionary? Variables { get; set; } + + [JsonPropertyName("components")] + public IReadOnlyCollection? Components { get; set; } + + internal virtual void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Required(DeviceId, nameof(DeviceId)); + TermiiRequestValidation.Required(TemplateId, nameof(TemplateId)); + } +} diff --git a/src/Termii/Messaging/TermiiMessagingClient.cs b/src/Termii/Messaging/TermiiMessagingClient.cs index 0c7fea0..02f1abb 100644 --- a/src/Termii/Messaging/TermiiMessagingClient.cs +++ b/src/Termii/Messaging/TermiiMessagingClient.cs @@ -49,6 +49,44 @@ public Task SendBulkAsync( cancellationToken); } + public Task SendWhatsAppTemplateAsync( + SendWhatsAppTemplateRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/send/template", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppTemplateMediaAsync( + SendWhatsAppTemplateMediaRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/send/template/media", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + private sealed class SendMessagePayload { public SendMessagePayload(SendMessageRequest request) diff --git a/src/Termii/Messaging/WhatsAppTemplateComponent.cs b/src/Termii/Messaging/WhatsAppTemplateComponent.cs new file mode 100644 index 0000000..1c07ab6 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateComponent.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateComponent +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("parameters")] + public IReadOnlyCollection>? Parameters { get; set; } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateMedia.cs b/src/Termii/Messaging/WhatsAppTemplateMedia.cs new file mode 100644 index 0000000..c3d9746 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateMedia.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateMedia +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("caption")] + public string? Caption { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(Type, nameof(Type)); + TermiiRequestValidation.Required(Url, nameof(Url)); + } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateResponse.cs b/src/Termii/Messaging/WhatsAppTemplateResponse.cs new file mode 100644 index 0000000..677e1d9 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs new file mode 100644 index 0000000..ae50c00 --- /dev/null +++ b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text.Json; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiWhatsAppTemplateClientTests +{ + [Fact] + public async Task SendWhatsAppTemplateAsyncPostsTemplateBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Template accepted","message_id":"template-123","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendWhatsAppTemplateAsync( + new SendWhatsAppTemplateRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + TemplateName = "order_update", + Language = "en", + Variables = new Dictionary + { + ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(), + ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(), + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/send/template", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("device-123", body.RootElement.GetProperty("device_id").GetString()); + Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("order_update", body.RootElement.GetProperty("template_name").GetString()); + Assert.Equal("en", body.RootElement.GetProperty("language").GetString()); + Assert.Equal("Ada", body.RootElement.GetProperty("variables").GetProperty("first_name").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal("Template accepted", response.Message); + Assert.Equal("template-123", response.MessageId); + Assert.Equal("queued", response.Status); + } + + [Fact] + public async Task SendWhatsAppTemplateMediaAsyncPostsMediaTemplateBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Media template accepted","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendWhatsAppTemplateMediaAsync( + new SendWhatsAppTemplateMediaRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-media-123", + Language = "en", + Media = new WhatsAppTemplateMedia + { + Type = "document", + Url = "https://example.test/invoice.pdf", + Caption = "Invoice", + FileName = "invoice.pdf", + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + var media = body.RootElement.GetProperty("media"); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/send/template/media", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("template-media-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("document", media.GetProperty("type").GetString()); + Assert.Equal("https://example.test/invoice.pdf", media.GetProperty("url").GetString()); + Assert.Equal("Invoice", media.GetProperty("caption").GetString()); + Assert.Equal("invoice.pdf", media.GetProperty("filename").GetString()); + Assert.Equal("Media template accepted", response.Message); + } + + [Fact] + public async Task SendWhatsAppTemplateAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateAsync( + new SendWhatsAppTemplateRequest + { + PhoneNumber = "", + DeviceId = "device-123", + TemplateId = "template-123", + }, + CancellationToken.None)); + } + + [Fact] + public async Task SendWhatsAppTemplateMediaAsyncRejectsMissingMediaUrl() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateMediaAsync( + new SendWhatsAppTemplateMediaRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + Media = new WhatsAppTemplateMedia + { + Type = "image", + Url = "", + }, + }, + CancellationToken.None)); + } +}