Skip to content

Commit

Permalink
Re-remind-me: New reminder parser/scheduler (#231)
Browse files Browse the repository at this point in the history
The new parser is custom-built and relies on extracting written patterns
from the text instead of on cron patterns. The scheduler consumes those
patterns and handles scheduling job execution. The scheduler component
maintains a sorted list of next job executions, and schedules the next
run based on the first item in that list.

An initial list of parsing and scheduler tests is included.

Still missing: formatting tests! May hold on those for now.

Fixes #139, fixes #45.
  • Loading branch information
Shadowfiend authored Jan 16, 2023
2 parents dde4fb3 + ca2f6ed commit c34de16
Show file tree
Hide file tree
Showing 12 changed files with 2,937 additions and 234 deletions.
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
}
33 changes: 32 additions & 1 deletion lib/adapter-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// These functions return immediately if the adapter in use isn't properly
// set up, or isn't a flowdock adapter, enabling better error handling.

import { Adapter } from "hubot"
import { Envelope, Adapter } from "hubot"
import { Matrix } from "hubot-matrix"
import { JoinRule } from "matrix-js-sdk"

Expand Down Expand Up @@ -204,6 +204,37 @@ export function isRoomNonPublic(
return false
}

/**
* Generates a matrix URL for a particular event, room, and server.
*/
export function matrixUrlFor(
roomId: string,
serverName: string,
eventId: string,
): string {
return `https://matrix.to/#/${roomId}/${eventId}?via=${serverName}`
}

/**
* Sends the given messages to the given thread id; if we don't know how to
* send to a thread with the given adapter, sends a regular message.
*/
export function sendThreaded(
adapter: Adapter,
envelope: Envelope,
threadId: string,
...messages: string[]
) {
if (threadId === undefined || !isMatrixAdapter(adapter)) {
// If it isn't the matrix adapter or there is no thread, fall back on a standard message.
adapter.send(envelope, ...messages)
} else {
messages.forEach((message) =>
adapter.sendThreaded(envelope, threadId, message),
)
}
}

export {
getRoomIdFromName,
getRoomNameFromId,
Expand Down
62 changes: 62 additions & 0 deletions lib/remind/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type JobMessageInfo = {
userId: string
message: string
room: string
threadId?: string
}

export type BaseJobSpec<Type extends string, SpecType> = {
type: Type
spec: SpecType
}

export type BaseJobDefinition<Type extends string, SpecType> = BaseJobSpec<
Type,
SpecType
> & {
messageInfo: JobMessageInfo
}

export type BaseJob<Type extends string, SpecType> = BaseJobDefinition<
Type,
SpecType
> & {
next: string
}

export type JobSpec =
| BaseJobSpec<"single", SingleShotDefinition>
| BaseJobSpec<"recurring", RecurringDefinition>

export type JobDefinition =
| BaseJobDefinition<"single", SingleShotDefinition>
| BaseJobDefinition<"recurring", RecurringDefinition>

export type SingleJob = BaseJob<"single", SingleShotDefinition>
export type RecurringJob = BaseJob<"recurring", RecurringDefinition>

export type Job = SingleJob | RecurringJob

export type PersistedJob =
| (SingleJob & { id: number })
| (RecurringJob & { id: number })

export type SingleShotDefinition = {
hour: number
minute: number
dayOfWeek: number
}

export type RecurringDefinition =
| (SingleShotDefinition & {
repeat: "week"
interval: number
})
| {
hour: number
minute: number
repeat: "month"
dayOfMonth: number
}

export type RecurrenceSpec = SingleShotDefinition | RecurringDefinition
89 changes: 89 additions & 0 deletions lib/remind/formatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as dayjs from "dayjs"
import * as utc from "dayjs/plugin/utc"
import * as timezone from "dayjs/plugin/timezone"
import * as localizedFormat from "dayjs/plugin/localizedFormat"
import * as advancedFormat from "dayjs/plugin/advancedFormat"

import { encodeThreadId, matrixUrlFor } from "../adapter-util"
import { PersistedJob, RecurringDefinition } from "./data"

dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(localizedFormat)
dayjs.extend(advancedFormat)

// List of escape regex patterns (search to replace) to use when formatting a
// reminder message for display. Used to show raw input and avoid tagging
// people when simply trying to explore the reminder list.
const formattingEscapes = {
"(@@?)": "[$1]",
"```": "\n```\n",
"#": "[#]",
"\n": "\n>",
}

function formatNextOccurrence(nextIsoRecurrenceDate: string): string {
return dayjs(nextIsoRecurrenceDate).format("llll z")
}

function formatRecurringSpec(
spec: RecurringDefinition,
nextOccurrence: string,
): string {
const formattedNextOccurrence = formatNextOccurrence(nextOccurrence)

if (spec.repeat === "week") {
const baseDate = dayjs()
.day(spec.dayOfWeek)
.hour(spec.hour)
.minute(spec.minute)

return (
formattedNextOccurrence +
baseDate.format("[ (recurs weekly on] dddd [at] HH:mm[)]")
)
}

const baseDate = dayjs()
.date(spec.dayOfMonth)
.hour(spec.hour)
.minute(spec.minute)

return (
formattedNextOccurrence +
baseDate.format("[ (recurs monthly on the] Do [at] HH:mm[)]")
)
}

export function formatJobForMessage(job: PersistedJob): string {
const { message: jobMessage, room, threadId } = job.messageInfo

const formattedSpec =
job.type === "single"
? formatNextOccurrence(job.next)
: formatRecurringSpec(job.spec, job.next)

// FIXME Resolve an actual display name here? Or let the adpater feed it to us?
const jobRoomDisplayName = room

const targetDisplayText =
threadId === undefined
? `(to ${jobRoomDisplayName})`
: `(to [thread in ${jobRoomDisplayName}](${matrixUrlFor(
room,
"thesis.co",
encodeThreadId(threadId),
)}))`

const messageParsed = Object.entries(formattingEscapes).reduce(
(formattedMessage, [pattern, replacement]) =>
formattedMessage.replace(new RegExp(pattern, "g"), replacement),
jobMessage,
)

return `ID ${job.id}: **${formattedSpec}** ${targetDisplayText}:\n>${messageParsed}\n\n`
}

export function formatJobsForListMessage(jobs: PersistedJob[]) {
return jobs.map((job) => formatJobForMessage(job)).join("\n\n")
}
Loading

0 comments on commit c34de16

Please sign in to comment.