Add scheduled reminders
This commit is contained in:
parent
3d47225e70
commit
b04dd5e307
6 changed files with 213 additions and 3 deletions
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
25
src/main/java/com/wbrawner/civicsquizbot/ReminderService.kt
Normal file
25
src/main/java/com/wbrawner/civicsquizbot/ReminderService.kt
Normal 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)
|
||||
}
|
||||
}
|
27
src/main/sqldelight/com/wbrawner/civicsquizbot/Reminder.sq
Normal file
27
src/main/sqldelight/com/wbrawner/civicsquizbot/Reminder.sq
Normal 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 = ?;
|
29
src/test/java/com/wbrawner/civicsquizbot/ParseTimeTests.kt
Normal file
29
src/test/java/com/wbrawner/civicsquizbot/ParseTimeTests.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue