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:
@@ -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 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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,8 +51,20 @@ const GROUP_DM_MAX = 10
|
||||
const DM_POLL_MAX_OPTIONS = 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_SCHEDULE_MIN_DELAY_MS = 60 * 1000
|
||||
const DM_SCHEDULE_MAX_DELAY_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const {
|
||||
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 = {
|
||||
ONLINE: 'online',
|
||||
@@ -226,15 +238,24 @@ function parseDmScheduleDelaySpec (raw) {
|
||||
|
||||
function parseDmScheduleCreateCommand (content) {
|
||||
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
|
||||
const delayMs = parseDmScheduleDelaySpec(m[2])
|
||||
if (!delayMs) throw new Error('schedule delay must be like 10m, 2h, or 1d')
|
||||
const spec = String(m[2] || '').trim()
|
||||
const body = String(m[3] || '').trim()
|
||||
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 {
|
||||
content: body.slice(0, 2000),
|
||||
sendAt: Date.now() + delayMs,
|
||||
sendAt,
|
||||
timezone: 'local',
|
||||
recurrence: 'none'
|
||||
}
|
||||
@@ -257,7 +278,7 @@ function normalizeDmScheduledMessagePayload (payload) {
|
||||
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 recurrence = String(payload.recurrence || 'none').trim().slice(0, 40) || 'none'
|
||||
const recurrence = normalizeDmScheduleRecurrence(payload.recurrence)
|
||||
return {
|
||||
content: content.slice(0, 2000),
|
||||
sendAt: Math.floor(sendAt),
|
||||
@@ -737,6 +758,16 @@ module.exports = {
|
||||
DM_POLL_DEFAULT_EXPIRES_MS,
|
||||
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,
|
||||
pollVoteEmoji,
|
||||
parseDmPollCreateCommand,
|
||||
parseDmScheduleDelaySpec,
|
||||
|
||||
Reference in New Issue
Block a user