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 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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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_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,
|
||||||
|
|||||||
Reference in New Issue
Block a user