From 4c22cfaf19fe3d8143298bb467400919c992873f Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 19:18:50 -0700 Subject: [PATCH] Add Line webhook controller with signature verification and dispatch Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/LineWebhookController.cs | 88 +++++++++++++++++++ .../DTOs/Notifications/LineWebhookDtos.cs | 28 ++++++ 2 files changed, 116 insertions(+) create mode 100644 API/ROLAC.API/Controllers/LineWebhookController.cs create mode 100644 API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs diff --git a/API/ROLAC.API/Controllers/LineWebhookController.cs b/API/ROLAC.API/Controllers/LineWebhookController.cs new file mode 100644 index 0000000..42e9a70 --- /dev/null +++ b/API/ROLAC.API/Controllers/LineWebhookController.cs @@ -0,0 +1,88 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using ROLAC.API.DTOs.Notifications; +using ROLAC.API.Services.Notifications; + +namespace ROLAC.API.Controllers; + +/// +/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches +/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry; +/// returns 400 only on signature failure. +/// +[ApiController] +[Route("api/line")] +[AllowAnonymous] +public sealed class LineWebhookController : ControllerBase +{ + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + private readonly ILineNotificationService _line; + private readonly IMessageChannel _channel; + private readonly LineOptions _options; + + public LineWebhookController( + ILineNotificationService line, IMessageChannel channel, IOptions options) + { + _line = line; + _channel = channel; + _options = options.Value; + } + + [HttpPost("webhook")] + public async Task Webhook(CancellationToken ct) + { + using var reader = new StreamReader(Request.Body, Encoding.UTF8); + var rawBody = await reader.ReadToEndAsync(ct); + var signature = Request.Headers["X-Line-Signature"].FirstOrDefault(); + + if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature)) + return BadRequest(); + + var payload = JsonSerializer.Deserialize(rawBody, JsonOpts); + if (payload?.Events is not null) + foreach (var evt in payload.Events) + await DispatchAsync(evt, ct); + + return Ok(); + } + + private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct) + { + switch (evt.Type) + { + case "follow": + if (evt.ReplyToken is not null) + await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct); + break; + + case "message": + if (evt.Message?.Type == "text" + && evt.Source?.UserId is { } userId + && evt.Message.Text is { } text) + { + var result = await _line.TryBindMemberAsync(userId, text, ct); + if (evt.ReplyToken is not null) + await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct); + } + break; + + case "join": + if (evt.Source?.GroupId is { } joinGroupId) + { + await _line.RegisterGroupAsync(joinGroupId, ct); + if (evt.ReplyToken is not null) + await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct); + } + break; + + case "leave": + if (evt.Source?.GroupId is { } leaveGroupId) + await _line.DeactivateGroupAsync(leaveGroupId, ct); + break; + } + } +} diff --git a/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs b/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs new file mode 100644 index 0000000..6d9b441 --- /dev/null +++ b/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs @@ -0,0 +1,28 @@ +namespace ROLAC.API.DTOs.Notifications; + +/// Top-level Line webhook payload (deserialized case-insensitively). +public sealed class LineWebhookPayload +{ + public List? Events { get; set; } +} + +public sealed class LineWebhookEvent +{ + public string? Type { get; set; } // follow | message | join | leave | ... + public string? ReplyToken { get; set; } + public LineWebhookSource? Source { get; set; } + public LineWebhookMessage? Message { get; set; } +} + +public sealed class LineWebhookSource +{ + public string? Type { get; set; } // user | group | room + public string? UserId { get; set; } + public string? GroupId { get; set; } +} + +public sealed class LineWebhookMessage +{ + public string? Type { get; set; } // text | image | ... + public string? Text { get; set; } +}