Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion backend/Sources/App/configure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
108 changes: 108 additions & 0 deletions backend/Sources/App/pages/subscription.swift
Original file line number Diff line number Diff line change
@@ -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]
}
83 changes: 83 additions & 0 deletions backend/Sources/App/pages/upcoming-alerts.swift
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 34 additions & 3 deletions telegram/Sources/LandinhoBot/APIClient/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,44 @@ struct APIClient<T: Decodable> {
}

let data = try await URLSession.shared.data(from: url)
return try decode(data)
}

func post<U: Encodable>(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<U: Encodable>(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: """

Expand Down
20 changes: 20 additions & 0 deletions telegram/Sources/LandinhoBot/Models/Race.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
9 changes: 8 additions & 1 deletion telegram/Sources/LandinhoBot/VroomBot/DefaultVroomBot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,23 @@ 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()
}

override var commands: [Command] {
[
HelpCommand(),
NextRaceCommand(),
CategoryListCommand()
CategoryListCommand(),
SubscribeCommand(),
UnsubscribeCommand(),
MySubscriptionsCommand()
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// AlertDispatcher.swift
//
//
// Created for LandinhoBot subscription feature
//

import Foundation
import TelegramBotSDK

actor AlertDispatcher {

private var sentAlerts: Set<String> = []
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)
"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <categoria>
Inscreve este chat para receber alertas de corrida de uma categoria. Ex: /subscribe f1

/unsubscribe <categoria>
Cancela a inscrição de alertas de uma categoria. Ex: /unsubscribe f1

/mysubscriptions
Lista as categorias que este chat acompanha
"""
}
Loading