From b04dd5e30751764c92904049e61e2f9c63eb6e46 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Thu, 26 Jan 2023 21:26:59 -0700 Subject: [PATCH] Add scheduled reminders --- build.gradle.kts | 1 + .../civicsquizbot/CivicsQuizHandler.kt | 131 +++++++++++++++++- .../java/com/wbrawner/civicsquizbot/Main.kt | 3 +- .../wbrawner/civicsquizbot/ReminderService.kt | 25 ++++ .../com/wbrawner/civicsquizbot/Reminder.sq | 27 ++++ .../wbrawner/civicsquizbot/ParseTimeTests.kt | 29 ++++ 6 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/wbrawner/civicsquizbot/ReminderService.kt create mode 100644 src/main/sqldelight/com/wbrawner/civicsquizbot/Reminder.sq create mode 100644 src/test/java/com/wbrawner/civicsquizbot/ParseTimeTests.kt diff --git a/build.gradle.kts b/build.gradle.kts index f259e8a..240e1fe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ repositories { dependencies { implementation("org.telegram:telegrambots:6.4.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.1") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("app.cash.sqldelight:jdbc-driver:2.0.0-alpha05") diff --git a/src/main/java/com/wbrawner/civicsquizbot/CivicsQuizHandler.kt b/src/main/java/com/wbrawner/civicsquizbot/CivicsQuizHandler.kt index 585dba6..32ba497 100644 --- a/src/main/java/com/wbrawner/civicsquizbot/CivicsQuizHandler.kt +++ b/src/main/java/com/wbrawner/civicsquizbot/CivicsQuizHandler.kt @@ -9,9 +9,16 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMar import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow import org.telegram.telegrambots.meta.exceptions.TelegramApiException +import java.time.DateTimeException +import java.time.LocalTime +import java.time.ZoneId +import java.util.* +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit class CivicsQuizHandler( - private val questionService: QuestionService + private val questionService: QuestionService, + private val reminderService: ReminderService ) : TelegramLongPollingBot() { private val acknowledgementPhrases = listOf( "Got it", @@ -21,6 +28,30 @@ class CivicsQuizHandler( "👍", "✅" ) + private val reminderPhrases = listOf( + "Ready to study?", + "IITT'SSS STUDY TIMMEEEE!!!", + "Hey. You know what to do.", + "🦅", + "These questions aren't going to answer themselves" + ) + + init { + Executors.newScheduledThreadPool(1).apply { + scheduleAtFixedRate( + { + val time = LocalTime.now() + reminderService.getRemindersAt(time.hour, time.minute) + .forEach { reminder -> + sendReminder(reminder.user_id) + } + }, + 0, + 1, + TimeUnit.MINUTES + ); + } + } override fun getBotUsername(): String = "CivicsQuizBot" @@ -38,6 +69,7 @@ class CivicsQuizHandler( Command.SHOW_ANSWER -> sendAnswer(message.chatId) Command.NEW_QUESTION -> sendQuestion(message.chatId) Command.NEED_PRACTICE, Command.TOO_EASY -> handleFeedback(message.chatId, command) + Command.REMINDER -> handleReminder(chatId = message.chatId, message.text) else -> sendOptions(message.chatId) } } @@ -57,6 +89,35 @@ class CivicsQuizHandler( sendQuestion(chatId) } + private fun handleReminder(chatId: Long, message: String) { + if (!message.startsWith("/reminder")) { + throw IllegalStateException("Attempted to handle non-reminder message from reminder path") + } + val messageParts = message.split(" ", limit = 3) + when (messageParts[1]) { + "set" -> try { + val (hour, minute) = messageParts.last().parseTime() + reminderService.setReminderForUser(Reminder(user_id = chatId, hour = hour, minute = minute)) + sendMessage(chatId, "I'll remind you every day at ${messageParts[2]}") + } catch (e: DateTimeException) { + sendMessage( + chatId, + "Sorry, I didn't understand your time zone. Try something like \"America/Chicago\" or \"UTC-6\"" + + "from here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List" + ) + } + + "remove", "stop", "off" -> { + reminderService.deleteReminderForUser(chatId) + sendMessage(chatId, "I won't remind you anymore") + } + } + } + + private fun sendReminder(chatId: Long) { + sendMessage(chatId, reminderPhrases.random(), Command.NEW_QUESTION) + } + private fun sendAnswer(chatId: Long) { questionService.answerLastQuestion(chatId)?.let { answer -> sendMessage(chatId, answer, Command.NEED_PRACTICE, Command.TOO_EASY) @@ -88,13 +149,79 @@ class CivicsQuizHandler( } } -fun String.asCommand() = Command.values().firstOrNull { it.text.contains(this) } +fun String.parseTime(): Pair { + val parts = split(" ", ":") + var hour = when (val h = parts.first().toIntOrNull()) { + is Int -> h + else -> { + val hourString = parts.first().lowercase() + if (hourString.contains("am")) { + hourString.replace("am", "").toInt() + } else if (hourString.contains("pm")) { + hourString.replace("pm", "").toInt().plus(12) + } else { + hourString.replace(Regex("^\\d+"), "").toInt() + } + } + } + logger.info("Parsed hour: $hour") + var minutes: Int? = null + var timeZone: TimeZone? = null + when (val m = parts.getOrNull(1)?.toIntOrNull()) { + is Int -> minutes = m + else -> { + if (parts.size > 1) { + logger.info("second element not a number: ${parts[1]}") + val part1 = parts[1].lowercase() + if (part1 == "pm") { + if (hour < 12) { + hour += 12 + } + } else if (part1.contains("am")) { + minutes = part1.replace("am", "").toIntOrNull() + } else if (part1.contains("pm")) { + minutes = part1.replace("pm", "").toIntOrNull() + if (hour < 12) { + hour += 12 + } + } else { + try { + logger.info("Trying to parse second element as time zone") + val zoneId = ZoneId.of(parts[1]).normalized() + logger.info("Parsed zoneId as $zoneId") + timeZone = TimeZone.getTimeZone(zoneId) + } catch (e: DateTimeException) { + logger.error("Failed to parse ${parts[1]} as time zone", e) + } + } + } + } + } + if (timeZone == null && parts.size > 2) { + logger.info("timeZone is still null, trying to parse again") + timeZone = TimeZone.getTimeZone(ZoneId.of(parts.last())) + } + timeZone?.let { + val adjustment = (it.rawOffset / TimeUnit.HOURS.toMillis(1)).toInt() + logger.info("adjusting hour by $adjustment hours according to timezone ($it)") + hour -= adjustment + } + if (hour > 23) { + hour -= 24 + } + return hour to (minutes ?: 0) +} + +fun String.asCommand() = Command.values().firstOrNull { command -> + command.text.contains(this) || command.text.any { this.startsWith(it) } +} enum class Command(vararg val text: String) { NEW_QUESTION("New question", "/start"), SHOW_ANSWER("Show answer"), NEED_PRACTICE("I need to practice this"), TOO_EASY("This was easy"), + REMINDER("/reminder"), } val Any.logger: Logger diff --git a/src/main/java/com/wbrawner/civicsquizbot/Main.kt b/src/main/java/com/wbrawner/civicsquizbot/Main.kt index ae4dcd1..dbe4bd1 100644 --- a/src/main/java/com/wbrawner/civicsquizbot/Main.kt +++ b/src/main/java/com/wbrawner/civicsquizbot/Main.kt @@ -32,9 +32,10 @@ object Main { val driver = dataSource.asJdbcDriver() val database = Database(driver) val questionService = DatabaseQuestionService(database, questions) + val reminderService = DatabaseReminderService(database) try { val telegramBotsApi = TelegramBotsApi(DefaultBotSession::class.java) - telegramBotsApi.registerBot(CivicsQuizHandler(questionService)) + telegramBotsApi.registerBot(CivicsQuizHandler(questionService, reminderService)) } catch (e: TelegramApiException) { e.printStackTrace() } diff --git a/src/main/java/com/wbrawner/civicsquizbot/ReminderService.kt b/src/main/java/com/wbrawner/civicsquizbot/ReminderService.kt new file mode 100644 index 0000000..e3739fa --- /dev/null +++ b/src/main/java/com/wbrawner/civicsquizbot/ReminderService.kt @@ -0,0 +1,25 @@ +package com.wbrawner.civicsquizbot + +interface ReminderService { + fun getRemindersAt(hour: Int, minute: Int): List + fun setReminderForUser(reminder: Reminder) + fun deleteReminderForUser(userId: Long) +} + +class DatabaseReminderService(private val database: Database) : ReminderService { + init { + database.reminderQueries.create() + } + + override fun getRemindersAt(hour: Int, minute: Int): List = database.reminderQueries + .selectByTime(hour, minute) + .executeAsList() + + override fun setReminderForUser(reminder: Reminder) { + database.reminderQueries.upsertReminder(reminder.user_id, reminder.hour, reminder.minute) + } + + override fun deleteReminderForUser(userId: Long) { + database.reminderQueries.deleteReminder(userId) + } +} \ No newline at end of file diff --git a/src/main/sqldelight/com/wbrawner/civicsquizbot/Reminder.sq b/src/main/sqldelight/com/wbrawner/civicsquizbot/Reminder.sq new file mode 100644 index 0000000..d4955ea --- /dev/null +++ b/src/main/sqldelight/com/wbrawner/civicsquizbot/Reminder.sq @@ -0,0 +1,27 @@ +create: +CREATE TABLE IF NOT EXISTS reminder ( + user_id BIGINT NOT NULL PRIMARY KEY, + hour INTEGER NOT NULL, + minute INTEGER NOT NULL +); + +selectByUserId: +SELECT * +FROM reminder +WHERE user_id = ?; + +selectByTime: +SELECT * +FROM reminder +WHERE hour = ? +AND minute = ?; + +upsertReminder: +INSERT INTO reminder (user_id, hour, minute) +VALUES (:user_id, :hour, :minute) +ON CONFLICT(user_id) DO +UPDATE SET hour = :hour, minute = :minute; + +deleteReminder: +DELETE FROM reminder +WHERE user_id = ?; \ No newline at end of file diff --git a/src/test/java/com/wbrawner/civicsquizbot/ParseTimeTests.kt b/src/test/java/com/wbrawner/civicsquizbot/ParseTimeTests.kt new file mode 100644 index 0000000..4f46e1b --- /dev/null +++ b/src/test/java/com/wbrawner/civicsquizbot/ParseTimeTests.kt @@ -0,0 +1,29 @@ +package com.wbrawner.civicsquizbot + +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class ParseTimeTests { + @ParameterizedTest(name = "{0}") + @MethodSource("args") + fun `strings are parsed correctly`(time: String, parsed: Pair) { + val actual = time.parseTime() + assert(actual == parsed) { "expected $parsed, got $actual" } + } + + companion object { + @JvmStatic + fun args(): Stream = Stream.of( + arguments("7", 7 to 0), + arguments("9:30", 9 to 30), + arguments("12:15pm", 12 to 15), + arguments("23:45 GMT+2", 21 to 45), + arguments("8am UTC-6", 14 to 0), + arguments("6 am America/Chicago", 12 to 0), + arguments("20 UTC-8", 4 to 0), + ) + } +} \ No newline at end of file