Add scheduled reminders

This commit is contained in:
William Brawner 2023-01-26 21:26:59 -07:00
parent 3d47225e70
commit b04dd5e307
Signed by: wbrawner
GPG key ID: 8FF12381C6C90D35
6 changed files with 213 additions and 3 deletions

View file

@ -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")

View file

@ -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<Int, Int> {
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

View file

@ -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()
}

View file

@ -0,0 +1,25 @@
package com.wbrawner.civicsquizbot
interface ReminderService {
fun getRemindersAt(hour: Int, minute: Int): List<Reminder>
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<Reminder> = 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)
}
}

View file

@ -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 = ?;

View file

@ -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<Int, Int>) {
val actual = time.parseTime()
assert(actual == parsed) { "expected $parsed, got $actual" }
}
companion object {
@JvmStatic
fun args(): Stream<Arguments> = 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),
)
}
}