diff --git a/api/paidAction/lib/item.js b/api/paidAction/lib/item.js index 879b1cb53d..bebb04d05a 100644 --- a/api/paidAction/lib/item.js +++ b/api/paidAction/lib/item.js @@ -45,7 +45,7 @@ export const getItemMentions = async ({ text }, { me, tx }) => { return [] } -export async function performBotBehavior ({ text, id }, { me, tx }) { +export async function performBotBehavior ({ text, id }, { me, tx, lnd }) { // delete any existing deleteItem or reminder jobs for this item const userId = me?.id || USER_ID.anon id = Number(id) @@ -57,7 +57,15 @@ export async function performBotBehavior ({ text, id }, { me, tx }) { await deleteReminders({ id, userId, models: tx }) if (text) { - const deleteAt = getDeleteAt(text) + // compute deleteAt with block-awareness + let deleteAt + try { + const { getHeight } = await import('ln-service') + const height = lnd ? (await getHeight({ lnd }))?.current_block_height : undefined + deleteAt = getDeleteAt(text, { currentBlockHeight: height }) + } catch (e) { + deleteAt = getDeleteAt(text) + } if (deleteAt) { await tx.$queryRaw` INSERT INTO pgboss.job (name, data, startafter, keepuntil) @@ -68,7 +76,18 @@ export async function performBotBehavior ({ text, id }, { me, tx }) { ${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')` } - const remindAt = getRemindAt(text) + // compute remindAt with block-awareness. If absolute block height was specified, + // we need the current chain height. We try to read it via lnd if available. + let remindAt + try { + // lazy import to avoid bundling lnd client on client side + const { getHeight } = await import('ln-service') + const height = lnd ? (await getHeight({ lnd }))?.current_block_height : undefined + remindAt = getRemindAt(text, { currentBlockHeight: height }) + } catch (e) { + // fallback to time-based parse only + remindAt = getRemindAt(text) + } if (remindAt) { await tx.$queryRaw` INSERT INTO pgboss.job (name, data, startafter, keepuntil) diff --git a/lib/item.js b/lib/item.js index 7bde10af17..adfd4f630f 100644 --- a/lib/item.js +++ b/lib/item.js @@ -13,11 +13,19 @@ export const defaultCommentSort = (pinned, bio, createdAt) => { export const isJob = item => item.subName === 'jobs' // a delete directive preceded by a non word character that isn't a backtick -const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi +const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year|block)s?/gi + +// support absolute block height deletes +const deleteBlockAtPattern = /\B@delete\s+at\s+block\s+(\d+)/gi const deleteMentionPattern = /\B@delete/i -const reminderPattern = /\B@remindme\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi +// support time-based and block-based relative reminders, +// where unit may be seconds/minutes/etc or blocks +const reminderPattern = /\B@remindme\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year|block)s?/gi + +// support absolute block height reminders +const reminderBlockAtPattern = /\B@remindme\s+at\s+block\s+(\d+)/gi const reminderMentionPattern = /\B@remindme/i @@ -30,22 +38,81 @@ export const getDeleteCommand = (text) => { return commands.length ? commands[commands.length - 1] : undefined } -export const getDeleteAt = (text) => { - const command = getDeleteCommand(text) - if (command) { - const { number, unit } = command - return datePivot(new Date(), { [`${unit}s`]: number }) +export const getDeleteAt = (text, opts = {}) => { + if (!text) return null + + const relMatches = [...text.matchAll(deletePattern)] + const lastRel = relMatches.length ? relMatches[relMatches.length - 1] : null + const lastRelIndex = lastRel?.index ?? -1 + + const absMatches = [...text.matchAll(deleteBlockAtPattern)] + const lastAbs = absMatches.length ? absMatches[absMatches.length - 1] : null + const lastAbsIndex = lastAbs?.index ?? -1 + + if (lastRelIndex < 0 && lastAbsIndex < 0) return null + + const now = new Date() + + if (lastAbsIndex > lastRelIndex) { + const targetHeight = parseInt(lastAbs[1]) + const { currentBlockHeight } = opts + if (Number.isInteger(currentBlockHeight)) { + const delta = targetHeight - currentBlockHeight + const minutes = Math.max(0, delta) * 10 + return datePivot(now, { minutes }) + } + return null + } + + const number = parseInt(lastRel[1]) + const unit = lastRel[2] + if (unit === 'block') { + const minutes = number * 10 + return datePivot(now, { minutes }) } - return null + return datePivot(now, { [`${unit}s`]: number }) } -export const getRemindAt = (text) => { - const command = getReminderCommand(text) - if (command) { - const { number, unit } = command - return datePivot(new Date(), { [`${unit}s`]: number }) +export const getRemindAt = (text, opts = {}) => { + if (!text) return null + + // gather matches for relative (in N unit) including blocks + const relMatches = [...text.matchAll(reminderPattern)] + const lastRel = relMatches.length ? relMatches[relMatches.length - 1] : null + const lastRelIndex = lastRel?.index ?? -1 + + // gather matches for absolute (at block X) + const absMatches = [...text.matchAll(reminderBlockAtPattern)] + const lastAbs = absMatches.length ? absMatches[absMatches.length - 1] : null + const lastAbsIndex = lastAbs?.index ?? -1 + + // if neither present, nothing to do + if (lastRelIndex < 0 && lastAbsIndex < 0) return null + + const now = new Date() + + // prefer the last directive that appears in the text + if (lastAbsIndex > lastRelIndex) { + // absolute block target + const targetHeight = parseInt(lastAbs[1]) + const { currentBlockHeight } = opts + if (Number.isInteger(currentBlockHeight)) { + const delta = targetHeight - currentBlockHeight + const minutes = Math.max(0, delta) * 10 + return datePivot(now, { minutes }) + } + // if we don't know current height, we can't compute accurately; return null + return null + } + + // relative directive + const number = parseInt(lastRel[1]) + const unit = lastRel[2] + if (unit === 'block') { + const minutes = number * 10 + return datePivot(now, { minutes }) } - return null + return datePivot(now, { [`${unit}s`]: number }) } export const hasDeleteCommand = (text) => !!getDeleteCommand(text)