using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; 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 INotificationSettingsService _settings; public LineWebhookController( ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings) { _line = line; _channel = channel; _settings = settings; } [HttpPost("webhook")] [RequestSizeLimit(262_144)] 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(_settings.GetLine().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; } } }