diff --git a/backend/Sources/App/configure.swift b/backend/Sources/App/configure.swift index ce86439..bc1c8db 100644 --- a/backend/Sources/App/configure.swift +++ b/backend/Sources/App/configure.swift @@ -42,7 +42,15 @@ public func configure(_ app: Application) async throws { // events EventListHandler(), - UpdateEventsHandler() + UpdateEventsHandler(), + + // subscriptions + SubscribeHandler(), + UnsubscribeHandler(), + ChatSubscriptionsHandler(), + + // alerts + UpcomingAlertsHandler() ].register(in: app) try await app.autoMigrate() diff --git a/backend/Sources/App/pages/subscription.swift b/backend/Sources/App/pages/subscription.swift new file mode 100644 index 0000000..fd9158e --- /dev/null +++ b/backend/Sources/App/pages/subscription.swift @@ -0,0 +1,108 @@ +// +// subscription.swift +// +// +// Created for LandinhoBot subscription feature +// + +import Vapor +import Foundation + +// MARK: - POST /subscribe + +struct SubscribeHandler: AsyncRequestHandler { + var method: HTTPMethod { .POST } + var path: String { "subscribe" } + + func handle(req: Request) async throws -> some AsyncResponseEncodable { + let request = try req.content.decode(SubscriptionRequest.self) + + // Validate category exists + guard try await Category.query(on: req.db) + .filter(\.$tag, .equal, request.categoryTag) + .first() != nil + else { + throw Abort(.notFound, reason: "Category '\(request.categoryTag)' not found") + } + + if let chat = try await Chat.query(on: req.db) + .filter(\.$chatID, .equal, request.chatID) + .first() + { + if !chat.subscribedCategories.contains(request.categoryTag) { + chat.subscribedCategories.append(request.categoryTag) + try await chat.save(on: req.db) + } + return SubscriptionResponse( + chatID: chat.chatID ?? "", + subscribedCategories: chat.subscribedCategories) + } else { + let chat = Chat() + chat.chatID = request.chatID + chat.subscribedCategories = [request.categoryTag] + try await chat.create(on: req.db) + return SubscriptionResponse( + chatID: request.chatID, + subscribedCategories: [request.categoryTag]) + } + } +} + +// MARK: - DELETE /subscribe + +struct UnsubscribeHandler: AsyncRequestHandler { + var method: HTTPMethod { .DELETE } + var path: String { "subscribe" } + + func handle(req: Request) async throws -> some AsyncResponseEncodable { + let request = try req.content.decode(SubscriptionRequest.self) + + guard let chat = try await Chat.query(on: req.db) + .filter(\.$chatID, .equal, request.chatID) + .first() + else { + throw Abort(.notFound, reason: "No subscriptions found for this chat") + } + + chat.subscribedCategories.removeAll { $0 == request.categoryTag } + try await chat.save(on: req.db) + + return SubscriptionResponse( + chatID: chat.chatID ?? "", + subscribedCategories: chat.subscribedCategories) + } +} + +// MARK: - GET /subscriptions/:chatId + +struct ChatSubscriptionsHandler: AsyncRequestHandler { + var method: HTTPMethod { .GET } + var path: String { "subscriptions/:chatId" } + + func handle(req: Request) async throws -> some AsyncResponseEncodable { + let chatID = req.parameters.get("chatId") ?? "" + + guard let chat = try await Chat.query(on: req.db) + .filter(\.$chatID, .equal, chatID) + .first() + else { + return SubscriptionResponse(chatID: chatID, subscribedCategories: []) + } + + return SubscriptionResponse( + chatID: chatID, + subscribedCategories: chat.subscribedCategories) + } +} + +// MARK: - Shared types + +struct SubscriptionRequest: Content { + let chatID: String + let categoryTag: String +} + +struct SubscriptionResponse: Content { + let chatID: String + let subscribedCategories: [String] +} diff --git a/backend/Sources/App/pages/upcoming-alerts.swift b/backend/Sources/App/pages/upcoming-alerts.swift new file mode 100644 index 0000000..d1aa927 --- /dev/null +++ b/backend/Sources/App/pages/upcoming-alerts.swift @@ -0,0 +1,83 @@ +// +// upcoming-alerts.swift +// +// +// Created for LandinhoBot subscription feature +// + +import Vapor +import Foundation + +// MARK: - GET /upcoming-alerts + +struct UpcomingAlertsHandler: AsyncRequestHandler { + var method: HTTPMethod { .GET } + var path: String { "upcoming-alerts" } + + func handle(req: Request) async throws -> some AsyncResponseEncodable { + let thresholdSeconds: Double + if let thresholdParam = try? req.query.decode(UpcomingAlertsRequest.self) { + thresholdSeconds = Double(thresholdParam.threshold) + } else { + thresholdSeconds = 3600 + } + + let now = Date() + let upperBound = now.addingTimeInterval(thresholdSeconds) + + let events = try await RaceEvent.query(on: req.db) + .filter(\.$date, .greaterThanOrEqual, now) + .filter(\.$date, .lessThanOrEqual, upperBound) + .with(\.$race) { raceQuery in + raceQuery.with(\.$category) + } + .all() + + // Fetch all chats once and filter in memory + let allChats = try await Chat.query(on: req.db).all() + + let alertItems: [AlertItem] = events.compactMap { event in + guard + let eventDate = event.date, + let eventTitle = event.title, + let raceTitle = event.race.title, + let raceShortTitle = event.race.shortTitle, + let categoryTag = event.race.category.tag, + let categoryTitle = event.race.category.title + else { return nil } + + let chatIDs = allChats + .filter { $0.subscribedCategories.contains(categoryTag) } + .compactMap { $0.chatID } + + guard !chatIDs.isEmpty else { return nil } + + return AlertItem( + chatIDs: chatIDs, + categoryTag: categoryTag, + categoryTitle: categoryTitle, + raceTitle: raceTitle, + raceShortTitle: raceShortTitle, + eventTitle: eventTitle, + eventDate: eventDate) + } + + return alertItems + } + + struct UpcomingAlertsRequest: Content { + let threshold: Int + } +} + +// MARK: - Response type + +struct AlertItem: Content { + let chatIDs: [String] + let categoryTag: String + let categoryTitle: String + let raceTitle: String + let raceShortTitle: String + let eventTitle: String + let eventDate: Date +} diff --git a/telegram/Sources/LandinhoBot/APIClient/APIClient.swift b/telegram/Sources/LandinhoBot/APIClient/APIClient.swift index 4be2a36..6e8ba2b 100644 --- a/telegram/Sources/LandinhoBot/APIClient/APIClient.swift +++ b/telegram/Sources/LandinhoBot/APIClient/APIClient.swift @@ -32,13 +32,44 @@ struct APIClient { } let data = try await URLSession.shared.data(from: url) + return try decode(data) + } + + func post(body: U) async throws -> T { + guard let url = buildURL(path: endpoint, args: [:]) else { + throw APIClientError(message: "Couldn't build URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, _) = try await URLSession.shared.data(for: request) + return try decode(data) + } + + func delete(body: U) async throws -> T { + guard let url = buildURL(path: endpoint, args: [:]) else { + throw APIClientError(message: "Couldn't build URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, _) = try await URLSession.shared.data(for: request) + return try decode(data) + } + + private func decode(_ data: Data) throws -> T { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 do { - let response = try decoder.decode(T.self, from: data) - return response - } catch (let error) { + return try decoder.decode(T.self, from: data) + } catch let error { let result = String(data: data, encoding: .utf8) ?? "Couldn't decode JSON" throw APIClientError(message: """ diff --git a/telegram/Sources/LandinhoBot/Models/Race.swift b/telegram/Sources/LandinhoBot/Models/Race.swift index f95d238..9b1d5db 100644 --- a/telegram/Sources/LandinhoBot/Models/Race.swift +++ b/telegram/Sources/LandinhoBot/Models/Race.swift @@ -26,3 +26,23 @@ struct RaceEvent: Codable, Equatable, Identifiable { let title: String let date: Date } + +struct SubscriptionResponse: Codable { + let chatID: String + let subscribedCategories: [String] +} + +struct SubscriptionRequest: Codable { + let chatID: String + let categoryTag: String +} + +struct AlertItem: Codable { + let chatIDs: [String] + let categoryTag: String + let categoryTitle: String + let raceTitle: String + let raceShortTitle: String + let eventTitle: String + let eventDate: Date +} diff --git a/telegram/Sources/LandinhoBot/VroomBot/DefaultVroomBot.swift b/telegram/Sources/LandinhoBot/VroomBot/DefaultVroomBot.swift index 75008fa..c926b4c 100644 --- a/telegram/Sources/LandinhoBot/VroomBot/DefaultVroomBot.swift +++ b/telegram/Sources/LandinhoBot/VroomBot/DefaultVroomBot.swift @@ -10,8 +10,12 @@ import Foundation final class DefaultVroomBot: SwiftyBot { + // Lazy so _bot is available after super.init() + private lazy var alertDispatcher = AlertDispatcher(bot: _bot) + override init() { super.init() + alertDispatcher.start() update() } @@ -19,7 +23,10 @@ final class DefaultVroomBot: SwiftyBot { [ HelpCommand(), NextRaceCommand(), - CategoryListCommand() + CategoryListCommand(), + SubscribeCommand(), + UnsubscribeCommand(), + MySubscriptionsCommand() ] } } diff --git a/telegram/Sources/LandinhoBot/VroomBot/Services/AlertDispatcher.swift b/telegram/Sources/LandinhoBot/VroomBot/Services/AlertDispatcher.swift new file mode 100644 index 0000000..c2930b2 --- /dev/null +++ b/telegram/Sources/LandinhoBot/VroomBot/Services/AlertDispatcher.swift @@ -0,0 +1,70 @@ +// +// AlertDispatcher.swift +// +// +// Created for LandinhoBot subscription feature +// + +import Foundation +import TelegramBotSDK + +actor AlertDispatcher { + + private var sentAlerts: Set = [] + private let bot: TelegramBot + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "dd/MM 'às' HH:mm" + return f + }() + + init(bot: TelegramBot) { + self.bot = bot + } + + // nonisolated so it can be called from sync context (e.g. DefaultVroomBot.init) + nonisolated func start() { + Task { + while true { + await checkAndSendAlerts(thresholdSeconds: 3600, label: "1h") + await checkAndSendAlerts(thresholdSeconds: 86400, label: "24h") + try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) + } + } + } + + private func checkAndSendAlerts(thresholdSeconds: Int, label: String) async { + let api = APIClient<[AlertItem]>(endpoint: "upcoming-alerts") + let alerts: [AlertItem] + + do { + alerts = try await api.fetch(arguments: ["threshold": "\(thresholdSeconds)"]) + } catch { + return + } + + for alert in alerts { + let alertKey = "\(alert.eventDate.timeIntervalSince1970):\(alert.categoryTag):\(label)" + guard !sentAlerts.contains(alertKey) else { continue } + + sentAlerts.insert(alertKey) + + let message = formatAlert(alert, label: label) + for chatIDString in alert.chatIDs { + guard let chatID = Int64(chatIDString) else { continue } + try? await bot.sendMessageAsync(chatId: .chat(chatID), text: message) + } + } + } + + private func formatAlert(_ alert: AlertItem, label: String) -> String { + let timeLabel = label == "1h" ? "em 1 hora" : "amanha" + let dateString = Self.formatter.string(from: alert.eventDate) + return """ + \u{1F3C1} [\(alert.categoryTitle)] \(alert.raceTitle) + \(alert.eventTitle) – \(timeLabel)! + \u{1F4C5} \(dateString) + """ + } +} diff --git a/telegram/Sources/LandinhoBot/VroomBot/Services/HelpCommand.swift b/telegram/Sources/LandinhoBot/VroomBot/Services/HelpCommand.swift index f53d266..6a005ad 100644 --- a/telegram/Sources/LandinhoBot/VroomBot/Services/HelpCommand.swift +++ b/telegram/Sources/LandinhoBot/VroomBot/Services/HelpCommand.swift @@ -25,5 +25,14 @@ Lista a próxima corrida que vai acontecer. Passe uma categoria para que ele lis /categories Lista as categorias disponíveis + +/subscribe +Inscreve este chat para receber alertas de corrida de uma categoria. Ex: /subscribe f1 + +/unsubscribe +Cancela a inscrição de alertas de uma categoria. Ex: /unsubscribe f1 + +/mysubscriptions +Lista as categorias que este chat acompanha """ } diff --git a/telegram/Sources/LandinhoBot/VroomBot/Services/MySubscriptionsCommand.swift b/telegram/Sources/LandinhoBot/VroomBot/Services/MySubscriptionsCommand.swift new file mode 100644 index 0000000..45249c1 --- /dev/null +++ b/telegram/Sources/LandinhoBot/VroomBot/Services/MySubscriptionsCommand.swift @@ -0,0 +1,38 @@ +// +// MySubscriptionsCommand.swift +// +// +// Created for LandinhoBot subscription feature +// + +import Foundation + +struct MySubscriptionsCommand: Command { + + let command: String = "mysubscriptions" + let description: String = "Lista as categorias que este chat acompanha" + + func handle(update: ChatUpdate, bot: Bot, debugMessage: (String) -> Void) async throws { + let api = APIClient(endpoint: "subscriptions/\(update.chatID)") + + do { + let response = try await api.fetch() + if response.subscribedCategories.isEmpty { + try await bot.reply( + update, + text: "Este chat não tem inscrições ativas.\nUse /subscribe seguido de uma tag para se inscrever.") + } else { + let categoryList = response.subscribedCategories + .map { "• `\($0)`" } + .joined(separator: "\n") + try await bot.reply( + update, + text: "Inscrições ativas neste chat:\n\n\(categoryList)\n\nPara cancelar, use /unsubscribe seguido da tag.") + } + } catch { + try await bot.reply( + update, + text: "Este chat não tem inscrições ativas.\nUse /subscribe seguido de uma tag para se inscrever.") + } + } +} diff --git a/telegram/Sources/LandinhoBot/VroomBot/Services/SubscribeCommand.swift b/telegram/Sources/LandinhoBot/VroomBot/Services/SubscribeCommand.swift new file mode 100644 index 0000000..e0694ba --- /dev/null +++ b/telegram/Sources/LandinhoBot/VroomBot/Services/SubscribeCommand.swift @@ -0,0 +1,39 @@ +// +// SubscribeCommand.swift +// +// +// Created for LandinhoBot subscription feature +// + +import Foundation + +struct SubscribeCommand: Command { + + let command: String = "subscribe" + let description: String = "Inscreve o chat em alertas de uma categoria" + let subscribeAPI = APIClient(endpoint: "subscribe") + let categoriesAPI = APIClient<[Category]>(endpoint: "category") + + func handle(update: ChatUpdate, bot: Bot, debugMessage: (String) -> Void) async throws { + guard let tag = update.arguments.first, !tag.isEmpty else { + let categories = (try? await categoriesAPI.fetch()) ?? [] + let tagList = categories.map { "`\($0.tag)`" }.joined(separator: ", ") + let hint = tagList.isEmpty ? "" : "\n\nCategorias disponíveis: \(tagList)" + try await bot.reply(update, text: "Por favor informe a tag da categoria.\nEx: /subscribe f1\(hint)") + return + } + + do { + let response = try await subscribeAPI.post( + body: SubscriptionRequest(chatID: update.chatID, categoryTag: tag)) + let categoryList = response.subscribedCategories.joined(separator: ", ") + try await bot.reply( + update, + text: "Inscrito com sucesso na categoria `\(tag)`!\nSuas inscrições: \(categoryList)") + } catch { + try await bot.reply( + update, + text: "Categoria `\(tag)` não encontrada. Use /categories para ver as disponíveis.") + } + } +} diff --git a/telegram/Sources/LandinhoBot/VroomBot/Services/UnsubscribeCommand.swift b/telegram/Sources/LandinhoBot/VroomBot/Services/UnsubscribeCommand.swift new file mode 100644 index 0000000..0d53749 --- /dev/null +++ b/telegram/Sources/LandinhoBot/VroomBot/Services/UnsubscribeCommand.swift @@ -0,0 +1,43 @@ +// +// UnsubscribeCommand.swift +// +// +// Created for LandinhoBot subscription feature +// + +import Foundation + +struct UnsubscribeCommand: Command { + + let command: String = "unsubscribe" + let description: String = "Cancela a inscrição de alertas de uma categoria" + let api = APIClient(endpoint: "subscribe") + + func handle(update: ChatUpdate, bot: Bot, debugMessage: (String) -> Void) async throws { + guard let tag = update.arguments.first, !tag.isEmpty else { + try await bot.reply( + update, + text: "Por favor informe a tag da categoria.\nEx: /unsubscribe f1") + return + } + + do { + let response = try await api.delete( + body: SubscriptionRequest(chatID: update.chatID, categoryTag: tag)) + if response.subscribedCategories.isEmpty { + try await bot.reply( + update, + text: "Inscrição em `\(tag)` cancelada. Você não tem mais inscrições ativas.") + } else { + let categoryList = response.subscribedCategories.joined(separator: ", ") + try await bot.reply( + update, + text: "Inscrição em `\(tag)` cancelada. Inscrições restantes: \(categoryList)") + } + } catch { + try await bot.reply( + update, + text: "Não encontrei inscrição ativa em `\(tag)` para este chat.") + } + } +}