Add DM scheduled message policy helpers for Phase 659.

Centralize queue caps, retry backoff, recurrence math, and relative
shortcuts (tomorrow9am) used by platform scheduling and UI validation.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 17:09:13 -04:00
co-authored by Cursor
parent c718f094d5
commit 5dddb0b683
3 changed files with 126 additions and 8 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Cross-cutting constants, topic naming, RPC framing, permission helpers, message
**Phase 659 (v0.8.638):** Added DM poll payload helpers — `parseDmPollCreateCommand` (supports `/poll`, `/pollm`, `/polla`, `/pollma`, `--expires`), `buildDmPollMessageContent`, `parseDmPollMessageContent`, `pollVoteEmoji`, and poll option constants (`DM_POLL_MAX_OPTIONS`, `DM_POLL_EMOJIS`, `DM_POLL_DEFAULT_EXPIRES_MS`). **Phase 659 (v0.8.638):** Added DM poll payload helpers — `parseDmPollCreateCommand` (supports `/poll`, `/pollm`, `/polla`, `/pollma`, `--expires`), `buildDmPollMessageContent`, `parseDmPollMessageContent`, `pollVoteEmoji`, and poll option constants (`DM_POLL_MAX_OPTIONS`, `DM_POLL_EMOJIS`, `DM_POLL_DEFAULT_EXPIRES_MS`).
**Phase 659 (v0.8.638):** Added DM scheduled-message helpers — `parseDmScheduleCreateCommand` (`/schedule`, `/sendlater`), `parseDmScheduleDelaySpec`, and `normalizeDmScheduledMessagePayload` schema validation (`sendAt`, `timezone`, `recurrence`) with queue delay bounds. **Phase 659 (v0.8.638+):** DM scheduled-message helpers — `parseDmScheduleCreateCommand`, `parseDmScheduleDelaySpec`, `normalizeDmScheduledMessagePayload`, plus `dm-scheduled-policy.js` (queue cap, retry backoff, recurrence presets, `tomorrow9am` shortcut, timezone preview helper).
**Phase 410 (v0.8.638):** Added paste/navigation validation — `validatePastedNavigationInput`, `pasteValidationToast`, `decodePortableInvitePayload`, and `PASTE_NAV_MAX_LEN` for invite/deep-link gates before IPC and `navigateDeepLink`. **Phase 410 (v0.8.638):** Added paste/navigation validation — `validatePastedNavigationInput`, `pasteValidationToast`, `decodePortableInvitePayload`, and `PASTE_NAV_MAX_LEN` for invite/deep-link gates before IPC and `navigateDeepLink`.
+87
View File
@@ -0,0 +1,87 @@
'use strict'
const DM_SCHEDULE_MIN_DELAY_MS = 60 * 1000
const DM_SCHEDULE_MAX_DELAY_MS = 30 * 24 * 60 * 60 * 1000
const DM_SCHEDULE_MAX_QUEUED_PER_CHANNEL = 20
const DM_SCHEDULE_MAX_SEND_ATTEMPTS = 5
const DM_SCHEDULE_RETRY_BASE_MS = 60 * 1000
const DM_SCHEDULE_STALE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DM_SCHEDULE_RECURRENCE_PRESETS = ['none', 'daily', 'weekly', 'monthly']
function computeDmScheduleRetryDelayMs (attempt = 1) {
const n = Math.max(1, Number(attempt) || 1)
return Math.min(
DM_SCHEDULE_RETRY_BASE_MS * Math.pow(2, n - 1),
30 * 60 * 1000
)
}
function computeNextRecurrenceSendAt (recurrence, fromMs = Date.now()) {
const r = String(recurrence || 'none').toLowerCase()
if (r === 'none') return null
const d = new Date(fromMs)
if (r === 'daily') {
d.setDate(d.getDate() + 1)
return d.getTime()
}
if (r === 'weekly') {
d.setDate(d.getDate() + 7)
return d.getTime()
}
if (r === 'monthly') {
d.setMonth(d.getMonth() + 1)
return d.getTime()
}
return null
}
function parseDmScheduleRelativeShortcut (spec) {
const s = String(spec || '').trim().toLowerCase()
if (s === 'tomorrow9am' || s === 'tomorrow-9am' || s === 'tomorrow') {
const d = new Date()
d.setDate(d.getDate() + 1)
if (s === 'tomorrow') {
return d.getTime()
}
d.setHours(9, 0, 0, 0)
const ms = d.getTime()
if (ms <= Date.now()) return null
return ms
}
return null
}
function normalizeDmScheduleRecurrence (recurrence) {
const r = String(recurrence || 'none').trim().toLowerCase()
return DM_SCHEDULE_RECURRENCE_PRESETS.includes(r) ? r : 'none'
}
function formatDmScheduleTimezonePreview (timezone = 'local', atMs = Date.now()) {
const tz = String(timezone || 'local').trim() || 'local'
try {
const fmt = new Intl.DateTimeFormat(undefined, {
timeZone: tz === 'local' ? undefined : tz,
dateStyle: 'medium',
timeStyle: 'short',
timeZoneName: 'short'
})
return fmt.format(new Date(atMs))
} catch {
return new Date(atMs).toLocaleString()
}
}
module.exports = {
DM_SCHEDULE_MIN_DELAY_MS,
DM_SCHEDULE_MAX_DELAY_MS,
DM_SCHEDULE_MAX_QUEUED_PER_CHANNEL,
DM_SCHEDULE_MAX_SEND_ATTEMPTS,
DM_SCHEDULE_RETRY_BASE_MS,
DM_SCHEDULE_STALE_RETENTION_MS,
DM_SCHEDULE_RECURRENCE_PRESETS,
computeDmScheduleRetryDelayMs,
computeNextRecurrenceSendAt,
parseDmScheduleRelativeShortcut,
normalizeDmScheduleRecurrence,
formatDmScheduleTimezonePreview
}
+38 -7
View File
@@ -51,8 +51,20 @@ const GROUP_DM_MAX = 10
const DM_POLL_MAX_OPTIONS = 8 const DM_POLL_MAX_OPTIONS = 8
const DM_POLL_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣'] const DM_POLL_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣']
const DM_POLL_DEFAULT_EXPIRES_MS = 24 * 60 * 60 * 1000 const DM_POLL_DEFAULT_EXPIRES_MS = 24 * 60 * 60 * 1000
const DM_SCHEDULE_MIN_DELAY_MS = 60 * 1000 const {
const DM_SCHEDULE_MAX_DELAY_MS = 30 * 24 * 60 * 60 * 1000 DM_SCHEDULE_MIN_DELAY_MS,
DM_SCHEDULE_MAX_DELAY_MS,
DM_SCHEDULE_MAX_QUEUED_PER_CHANNEL,
DM_SCHEDULE_MAX_SEND_ATTEMPTS,
DM_SCHEDULE_RETRY_BASE_MS,
DM_SCHEDULE_STALE_RETENTION_MS,
DM_SCHEDULE_RECURRENCE_PRESETS,
computeDmScheduleRetryDelayMs,
computeNextRecurrenceSendAt,
parseDmScheduleRelativeShortcut,
normalizeDmScheduleRecurrence,
formatDmScheduleTimezonePreview
} = require('./dm-scheduled-policy')
const USER_STATUS = { const USER_STATUS = {
ONLINE: 'online', ONLINE: 'online',
@@ -226,15 +238,24 @@ function parseDmScheduleDelaySpec (raw) {
function parseDmScheduleCreateCommand (content) { function parseDmScheduleCreateCommand (content) {
const raw = String(content || '').trim() const raw = String(content || '').trim()
const m = raw.match(/^\/(schedule|sendlater)\s+(\d+[mhd])\s+([\s\S]+)$/i) const m = raw.match(/^\/(schedule|sendlater)\s+(\S+)\s+([\s\S]+)$/i)
if (!m) return null if (!m) return null
const delayMs = parseDmScheduleDelaySpec(m[2]) const spec = String(m[2] || '').trim()
if (!delayMs) throw new Error('schedule delay must be like 10m, 2h, or 1d')
const body = String(m[3] || '').trim() const body = String(m[3] || '').trim()
if (!body) throw new Error('scheduled message content is required') if (!body) throw new Error('scheduled message content is required')
let sendAt = null
const delayMs = parseDmScheduleDelaySpec(spec)
if (delayMs) sendAt = Date.now() + delayMs
else {
const shortcutAt = parseDmScheduleRelativeShortcut(spec)
if (shortcutAt) sendAt = shortcutAt
}
if (!sendAt) {
throw new Error('schedule delay must be like 10m, 2h, 1d, or tomorrow9am')
}
return { return {
content: body.slice(0, 2000), content: body.slice(0, 2000),
sendAt: Date.now() + delayMs, sendAt,
timezone: 'local', timezone: 'local',
recurrence: 'none' recurrence: 'none'
} }
@@ -257,7 +278,7 @@ function normalizeDmScheduledMessagePayload (payload) {
throw new Error('scheduled message cannot be more than 30 days in the future') throw new Error('scheduled message cannot be more than 30 days in the future')
} }
const timezone = String(payload.timezone || 'local').trim().slice(0, 80) || 'local' const timezone = String(payload.timezone || 'local').trim().slice(0, 80) || 'local'
const recurrence = String(payload.recurrence || 'none').trim().slice(0, 40) || 'none' const recurrence = normalizeDmScheduleRecurrence(payload.recurrence)
return { return {
content: content.slice(0, 2000), content: content.slice(0, 2000),
sendAt: Math.floor(sendAt), sendAt: Math.floor(sendAt),
@@ -737,6 +758,16 @@ module.exports = {
DM_POLL_DEFAULT_EXPIRES_MS, DM_POLL_DEFAULT_EXPIRES_MS,
DM_SCHEDULE_MIN_DELAY_MS, DM_SCHEDULE_MIN_DELAY_MS,
DM_SCHEDULE_MAX_DELAY_MS, DM_SCHEDULE_MAX_DELAY_MS,
DM_SCHEDULE_MAX_QUEUED_PER_CHANNEL,
DM_SCHEDULE_MAX_SEND_ATTEMPTS,
DM_SCHEDULE_RETRY_BASE_MS,
DM_SCHEDULE_STALE_RETENTION_MS,
DM_SCHEDULE_RECURRENCE_PRESETS,
computeDmScheduleRetryDelayMs,
computeNextRecurrenceSendAt,
parseDmScheduleRelativeShortcut,
normalizeDmScheduleRecurrence,
formatDmScheduleTimezonePreview,
pollVoteEmoji, pollVoteEmoji,
parseDmPollCreateCommand, parseDmPollCreateCommand,
parseDmScheduleDelaySpec, parseDmScheduleDelaySpec,