using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; namespace AxCopilot.Handlers; /// /// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다. /// /// 예: remind → 오늘 등록된 알림 목록 /// remind 15:00 보고서 제출 → 오후 3시에 알림 /// remind 오후3시 팀장보고 → 한국어 시각 파싱 /// remind del 1 → 1번 알림 취소 /// remind clear → 지난 알림 정리 /// public class RemindHandler : IActionHandler { public string? Prefix => "remind"; public PluginMetadata Metadata => new( "알림", "오늘 특정 시각 알림 — 시간 설정 · 취소 · 목록", "1.0", "AX"); private sealed class RemindEntry { public int Id { get; set; } public DateTime Time { get; set; } public string Message { get; set; } = ""; public bool Fired { get; set; } public CancellationTokenSource? Cts { get; set; } } private static readonly List _reminders = []; private static readonly Dictionary _ctsDic = []; private static readonly object _lock = new(); private static int _nextId = 1; // 외부(TodayHandler)에서 오늘 알림 목록 조회용 internal static List<(DateTime Time, string Message)> GetTodayReminders() { var today = DateTime.Today; lock (_lock) { return _reminders .Where(r => !r.Fired && r.Time.Date == today) .Select(r => (r.Time, r.Message)) .OrderBy(r => r.Time) .ToList(); } } public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); if (string.IsNullOrWhiteSpace(q)) { List pending; lock (_lock) pending = _reminders.Where(r => !r.Fired).OrderBy(r => r.Time).ToList(); if (pending.Count == 0) { items.Add(new LauncherItem("등록된 알림 없음", "remind HH:mm 메시지 또는 remind 오후3시 메시지", null, null, Symbol: "\uE787")); } else { items.Add(new LauncherItem($"알림 {pending.Count}개", "remind <시각> <메시지> / remind del <번호>", null, null, Symbol: "\uE787")); foreach (var r in pending) { var left = r.Time > DateTime.Now ? FormatLeft(r.Time - DateTime.Now) : "시간 지남"; items.Add(new LauncherItem( $"#{r.Id} {r.Time:HH:mm} — {r.Message}", $"남은 시간: {left} · Enter로 취소", null, ("del", r.Id.ToString()), Symbol: "\uE787")); } } return Task.FromResult>(items); } var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); var sub = parts[0].ToLowerInvariant(); // del 명령 if (sub is "del" or "cancel" or "취소") { var idStr = parts.Length > 1 ? parts[1].Trim() : ""; if (!int.TryParse(idStr, out var delId)) { items.Add(new LauncherItem("번호를 입력하세요", "예: remind del 1", null, null, Symbol: "\uE783")); return Task.FromResult>(items); } RemindEntry? target; lock (_lock) target = _reminders.FirstOrDefault(r => r.Id == delId); if (target == null) items.Add(new LauncherItem($"#{delId} 알림을 찾을 수 없습니다", "", null, null, Symbol: "\uE783")); else items.Add(new LauncherItem( $"#{delId} 알림 취소: {target.Time:HH:mm} {target.Message}", "Enter로 취소합니다", null, ("del", delId.ToString()), Symbol: "\uE787")); return Task.FromResult>(items); } // clear 명령 if (sub == "clear") { items.Add(new LauncherItem("지난 알림 정리", "발화된 알림을 목록에서 제거합니다 · Enter 실행", null, ("clear", ""), Symbol: "\uE787")); return Task.FromResult>(items); } // 시각 파싱 시도 var (timeStr, msgStr) = SplitTimeAndMessage(q); if (!string.IsNullOrWhiteSpace(timeStr) && TryParseTime(timeStr, out var alarmTime)) { var leftText = alarmTime > DateTime.Now ? FormatLeft(alarmTime - DateTime.Now) : "이미 지난 시각 (내일로 설정됩니다)"; var message = string.IsNullOrWhiteSpace(msgStr) ? "(메시지 없음)" : msgStr; var encoded = $"{alarmTime:O}|{message}"; items.Add(new LauncherItem( $"알림 설정: {alarmTime:HH:mm} — {message}", $"{leftText} · Enter로 설정", null, ("set", encoded), Symbol: "\uE787")); } else { items.Add(new LauncherItem($"시각 파싱 실패: '{q}'", "예: remind 15:00 보고서 제출 / remind 오후3시 팀장보고", null, null, Symbol: "\uE783")); } return Task.FromResult>(items); } public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { switch (item.Data) { case ("set", string encoded): { var idx = encoded.IndexOf('|'); if (idx < 0) break; var timeIso = encoded[..idx]; var message = encoded[(idx + 1)..]; if (!DateTime.TryParse(timeIso, null, System.Globalization.DateTimeStyles.RoundtripKind, out var alarmTime)) break; var cts = new CancellationTokenSource(); int id; lock (_lock) { id = _nextId++; var entry = new RemindEntry { Id = id, Time = alarmTime, Message = message, Cts = cts }; _reminders.Add(entry); _ctsDic[id] = cts; } NotificationService.Notify("알림", $"#{id} {alarmTime:HH:mm} {message} 설정됨"); _ = RunReminderAsync(id, alarmTime, message, cts.Token); break; } case ("del", string idStr) when int.TryParse(idStr, out var delId): { RemindEntry? entry; lock (_lock) { entry = _reminders.FirstOrDefault(r => r.Id == delId); if (entry != null) _reminders.Remove(entry); if (_ctsDic.TryGetValue(delId, out var c)) { c.Cancel(); _ctsDic.Remove(delId); } } if (entry != null) NotificationService.Notify("알림", $"#{delId} 알림 취소됨"); break; } case ("clear", _): { int cleared; lock (_lock) { cleared = _reminders.RemoveAll(r => r.Fired); } NotificationService.Notify("알림", $"지난 알림 {cleared}개 정리됨"); break; } } return Task.CompletedTask; } // ── 알림 실행 ──────────────────────────────────────────────────────────── private static async Task RunReminderAsync(int id, DateTime at, string message, CancellationToken token) { try { var delay = at - DateTime.Now; if (delay > TimeSpan.Zero) await Task.Delay(delay, token); lock (_lock) { var entry = _reminders.FirstOrDefault(r => r.Id == id); if (entry != null) entry.Fired = true; _ctsDic.Remove(id); } NotificationService.Notify("⏰ 알림", $"{at:HH:mm} {message}"); } catch (OperationCanceledException) { } } // ── 파싱 헬퍼 ──────────────────────────────────────────────────────────── private static (string timeStr, string message) SplitTimeAndMessage(string q) { var sp = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (sp.Length == 0) return ("", ""); return (sp[0], sp.Length > 1 ? sp[1] : ""); } private static bool TryParseTime(string s, out DateTime result) { result = DateTime.MinValue; s = s.Trim(); // HH:mm 또는 H:mm if (System.Text.RegularExpressions.Regex.IsMatch(s, @"^\d{1,2}:\d{2}$")) { var colonParts = s.Split(':'); if (int.TryParse(colonParts[0], out var h) && int.TryParse(colonParts[1], out var m) && h is >= 0 and <= 23 && m is >= 0 and <= 59) { result = DateTime.Today.AddHours(h).AddMinutes(m); if (result <= DateTime.Now) result = result.AddDays(1); return true; } } // 한국어 시각: 오전N시M분, 오후N시M분, N시M분, 오전N시, 오후N시, N시 var korMatch = System.Text.RegularExpressions.Regex.Match(s, @"^(오전|오후)?(\d{1,2})시((\d{1,2})분)?$"); if (korMatch.Success) { var meridiem = korMatch.Groups[1].Value; if (!int.TryParse(korMatch.Groups[2].Value, out var h)) return false; var minStr = korMatch.Groups[4].Value; var m = string.IsNullOrEmpty(minStr) ? 0 : int.TryParse(minStr, out var mp) ? mp : 0; if (meridiem == "오후" && h < 12) h += 12; if (meridiem == "오전" && h == 12) h = 0; if (h is < 0 or > 23 || m is < 0 or > 59) return false; result = DateTime.Today.AddHours(h).AddMinutes(m); if (result <= DateTime.Now) result = result.AddDays(1); return true; } return false; } private static string FormatLeft(TimeSpan ts) { if (ts.TotalSeconds < 60) return $"{(int)ts.TotalSeconds}초 후"; if (ts.TotalMinutes < 60) return $"{(int)ts.TotalMinutes}분 후"; var h = (int)ts.TotalHours; var m = (int)(ts.TotalMinutes - h * 60); return m > 0 ? $"{h}시간 {m}분 후" : $"{h}시간 후"; } }