From 3eeb314dc28b4b40c53e17a3c88be1358c529437 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 19:13:42 -0700 Subject: [PATCH] Add IMessageChannel and Line REST implementation Co-Authored-By: Claude Sonnet 4.6 --- .../Notifications/LineMessageChannelTests.cs | 77 +++++++++++++++++++ .../Services/Notifications/IMessageChannel.cs | 12 +++ .../Notifications/LineMessageChannel.cs | 52 +++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs create mode 100644 API/ROLAC.API/Services/Notifications/IMessageChannel.cs create mode 100644 API/ROLAC.API/Services/Notifications/LineMessageChannel.cs diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs new file mode 100644 index 0000000..05692c5 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Options; +using ROLAC.API.Services.Notifications; +using Xunit; + +namespace ROLAC.API.Tests.Services.Notifications; + +public class LineMessageChannelTests +{ + // Captures the outgoing request and returns a canned response. + private sealed class CapturingHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastBody { get; private set; } + public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; + public string ResponseBody { get; set; } = "{}"; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) }; + } + } + + private static LineMessageChannel BuildChannel(CapturingHandler handler) + { + var http = new HttpClient(handler); + var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" }); + return new LineMessageChannel(http, options); + } + + [Fact] + public async Task PushToUserAsync_PostsTextMessage_WithBearerToken() + { + var handler = new CapturingHandler(); + var channel = BuildChannel(handler); + + var result = await channel.PushToUserAsync("U123", "hello"); + + Assert.True(result.Success); + Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString()); + Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme); + Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter); + + using var doc = JsonDocument.Parse(handler.LastBody!); + Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString()); + Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString()); + } + + [Fact] + public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken() + { + var handler = new CapturingHandler(); + var channel = BuildChannel(handler); + + await channel.ReplyAsync("RTOKEN", "hi back"); + + Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString()); + using var doc = JsonDocument.Parse(handler.LastBody!); + Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString()); + } + + [Fact] + public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus() + { + var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" }; + var channel = BuildChannel(handler); + + var result = await channel.PushToUserAsync("U123", "hello"); + + Assert.False(result.Success); + Assert.Contains("429", result.Error); + } +} diff --git a/API/ROLAC.API/Services/Notifications/IMessageChannel.cs b/API/ROLAC.API/Services/Notifications/IMessageChannel.cs new file mode 100644 index 0000000..5d9d0f1 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/IMessageChannel.cs @@ -0,0 +1,12 @@ +namespace ROLAC.API.Services.Notifications; + +/// Result of one Line REST call. +public sealed record MessageSendResult(bool Success, string? Error); + +/// Abstraction over a chat channel's send/reply (Line today; future channels later). +public interface IMessageChannel +{ + Task PushToUserAsync(string externalId, string text, CancellationToken ct = default); + Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default); + Task ReplyAsync(string replyToken, string text, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs b/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs new file mode 100644 index 0000000..14692d4 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs @@ -0,0 +1,52 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Options; + +namespace ROLAC.API.Services.Notifications; + +/// Sends text messages and replies via the Line Messaging API REST endpoints. +public sealed class LineMessageChannel : IMessageChannel +{ + private const string PushUrl = "https://api.line.me/v2/bot/message/push"; + private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply"; + + private readonly HttpClient _http; + private readonly LineOptions _options; + + public LineMessageChannel(HttpClient http, IOptions options) + { + _http = http; + _options = options.Value; + } + + public Task PushToUserAsync(string externalId, string text, CancellationToken ct = default) + => PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct); + + public Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default) + => PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct); + + public Task ReplyAsync(string replyToken, string text, CancellationToken ct = default) + => PostAsync(ReplyUrl, new { replyToken, messages = new[] { new { type = "text", text } } }, ct); + + private async Task PostAsync(string url, object payload, CancellationToken ct) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(payload), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken); + + using var response = await _http.SendAsync(request, ct); + if (response.IsSuccessStatusCode) return new MessageSendResult(true, null); + + var body = await response.Content.ReadAsStringAsync(ct); + return new MessageSendResult(false, $"{(int)response.StatusCode}: {body}"); + } + catch (Exception ex) + { + return new MessageSendResult(false, ex.Message); + } + } +}