From 6a9ab43198463cfa77394bdfec05a59debde1a34 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Sun, 18 Oct 2026 12:45:28 +0200 Subject: [PATCH] Re-read schedules before posting and keep filled posts within Discord limits The runner iterated the schedule array from the start of the tick, so a schedule deleted, paused or edited while an earlier one was posting still went out with its stale flags and text. It now re-reads each schedule at the top of the loop and again after the creator-access check. Lengths were only checked before tokens were filled, so a post near a limit could overflow at post time and fail at every occurrence. Create and edit now refuse a post that its tokens would push over a limit at any of its next 12 occurrences, and the runner clips an overflowing filled post to fit (logging schedule.clipped, never the text) instead of failing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/architecture.md | 4 +- docs/runbook.md | 2 +- src/features/schedules/index.ts | 3 + src/features/schedules/limits.test.ts | 105 +++++++++++++++++++++++ src/features/schedules/limits.ts | 95 ++++++++++++++++++++ src/features/schedules/runner.test.ts | 84 ++++++++++++++++++ src/features/schedules/runner.ts | 26 ++++-- src/features/schedules/schedules.test.ts | 32 +++++++ 9 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 src/features/schedules/limits.test.ts create mode 100644 src/features/schedules/limits.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5410c..e71109d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ## [Unreleased] +### Fixed + +- `/schedule`: a schedule deleted, paused or edited while another one due at the same time was posting no longer goes out as it was. Pixel reads each schedule again right before posting it. +- `/schedule`: a message or poll that `{{tokens}}` would push past Discord's length limits (2000 characters for a message, 300 for a question, 55 for an answer) is now refused when it's created or edited, instead of failing at every occurrence. One that still comes out too long when it's posted is cut to fit and logged as `schedule.clipped`. + ## [0.17.0] - 2026-10-15 ### Added diff --git a/docs/architecture.md b/docs/architecture.md index 24fb7b9..e09bb25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -336,11 +336,11 @@ The [spaceapi.io directory](https://api.spaceapi.io/openapi.json) was considered - **Who:** members (and admins) holding the `schedule-posts` capability, granted with `/admin capabilities grant`. A schedule only keeps posting while whoever made it still has that access: when it's due, Pixel checks again and pauses it otherwise. - **Where:** a text or announcement channel picked with the command (a new core `channel` option type). People can only schedule into channels where **they** can post, polls only where they can create polls, and `mentions` only where they can ping everyone (from the permissions Discord reports for the person in that channel). Pixel also checks its own permissions there before saving, and again when posting. -- **What:** a plain **message** (up to 2000 characters of markdown; pings are off unless `mentions` is on), or a native **Discord poll** (a question, 2–10 answers of up to 55 characters, open for 1 hour to 2 weeks, single or multiple choice). Discord counts the votes, shows them live and announces the result when it closes, so Pixel stores nothing about votes. User-set text can include tokens (`{{date}}`, `{{date+2}}`, and the rest of the date family); they are stored as written and filled from the scheduled occurrence when the post goes out. `/schedule preview` and the private confirmation after create or edit show the same fill as of the next occurrence (message text; poll question and answers). See Interpolator above. +- **What:** a plain **message** (up to 2000 characters of markdown; pings are off unless `mentions` is on), or a native **Discord poll** (a question, 2–10 answers of up to 55 characters, open for 1 hour to 2 weeks, single or multiple choice). Discord counts the votes, shows them live and announces the result when it closes, so Pixel stores nothing about votes. User-set text can include tokens (`{{date}}`, `{{date+2}}`, and the rest of the date family); they are stored as written and filled from the scheduled occurrence when the post goes out. `/schedule preview` and the private confirmation after create or edit show the same fill as of the next occurrence (message text; poll question and answers). Discord's length limits apply to the filled text, so create and edit refuse a post that its tokens would push over a limit at any of its next 12 occurrences; if one still overflows when it goes out (say a token that grew), Pixel cuts it to the limit and logs `schedule.clipped` (never the text) instead of failing at every occurrence. See Interpolator above. - **Forms:** the text, and the poll's question and answers (one per line), are typed in a **modal**, because slash options are one line. Commands declare these as `form` fields on string options. Discord can answer a slash command with a modal **or** a message, not both, so Pixel parses `when` (and the other slash options) **before** opening the modal (`Dispatcher.prepareForm` / `beforeForm`). A bad time is a private error and the modal never opens, so the body isn't typed against a timestamp that would be thrown away. The modal title is the interpreted time. `beforeForm` can also prefill fields (`values`) so an edit opens with the text already saved, and name which declared fields to show (`fields`); an empty list skips the form so `/schedule edit` can list the caller's items. The adapter keeps the typed options under a random token for 15 minutes for that one person, and runs the command when the form is submitted. The dispatcher checks everything again. - **Edit:** `/schedule edit` is the same access as `/schedule` (member + `schedule-posts`). Only the Discord user who created a schedule (by immutable user ID, `createdBy.ref`) may edit it, and only while it still has an upcoming occurrence (a one-off is removed after it posts; a repeat can still be edited for later occurrences). Channel, mentions, poll duration and multiple-choice are not edited. The list, suggestions and refusals never reveal anyone else's schedules. - **When:** Discord has no date picker for bots (no slash option type, no modal component, no Components v2 picker). Autocomplete would force picking from a short list of dates, so `when` is **free-form text**. Pixel parses it (`core/when.ts`: `wed 19:00`, `wed 1900`, `19u30`, `14 oct 19:00`, `tomorrow 9am`, and similar) into one future wall-clock moment in `PIXEL_TIMEZONE` and **confirms that time** in the private reply. Compact 24h (`1900`) and Dutch `19u` are times, not guessed by a date library. A day without a time is 19:00; a time without a day is the next clock hit. `repeat` is once, weekly, fortnightly (on one or more `days`, such as `wed sat` — autocomplete there is only a weekday vocabulary, not dates), monthly or every 2 months (on the start's day of the month, or the month's last day). Times stay on the wall clock through the summer/winter clock change (`core/recurrence.ts`). -- **Running:** the feature checks every 30 seconds (`features/schedules/runner.ts`). A due post goes out once; if Pixel was down, it still posts up to an hour late, skips anything later, and only the latest of several missed occurrences can go out. Every occurrence is recorded as handled whether it posted, was skipped or failed, and remembered in memory too, so nothing ever posts twice. Failures are logged and reported, never retried. A one-off is removed once it's handled. +- **Running:** the feature checks every 30 seconds (`features/schedules/runner.ts`). A due post goes out once; if Pixel was down, it still posts up to an hour late, skips anything later, and only the latest of several missed occurrences can go out. Every occurrence is recorded as handled whether it posted, was skipped or failed, and remembered in memory too, so nothing ever posts twice. Failures are logged and reported, never retried. Each schedule is read again just before it's handled and after every wait, so one deleted, paused or edited while another was posting goes out as it is now, or not at all. A one-off is removed once it's handled. - **Storage:** `data/schedules.yaml` (`services/schedules.ts`), written atomically, at most 50 schedules. Unlike the rest of `data/`, it's **not safe to delete**. If it's invalid, nothing is posted or changed (and it's never overwritten) until it's fixed; Pixel logs and reports why. - **Posting** goes through the core `ChannelPosts`, which the Discord adapter plugs a poster into once it's connected, like the calendar. Every create, edit, pause, resume, delete and post is logged with who and which schedule, never the message body. diff --git a/docs/runbook.md b/docs/runbook.md index e18ae00..66ff7b4 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -260,7 +260,7 @@ Members with the `schedule-posts` capability schedule messages and polls with `/ After a deploy that adds `/schedule`, run **`just register dev`** or **`just register prod`** so Discord lists the commands, then grant the capability. No new environment variables: times use `PIXEL_TIMEZONE` (Europe/Amsterdam). The bot needs Send Messages in the target channel, and Send Polls if it will post polls. - **They live in `data/schedules.yaml`.** Back it up with the access files; deleting it deletes every schedule. -- **Tokens** in the message, poll question or answers (`{{date}}`, `{{day}}`, `{{month}}`, `{{year}}`, `{{dateWithTime}}`) fill in when the post goes out, in `PIXEL_TIMEZONE`. A whole-day offset (`{{date+2}}`, `{{day+2}}`) is from the scheduled day, so a Monday post can say Wednesday. A name Pixel doesn't know is left as written. Don't put secrets in a scheduled post. +- **Tokens** in the message, poll question or answers (`{{date}}`, `{{day}}`, `{{month}}`, `{{year}}`, `{{dateWithTime}}`) fill in when the post goes out, in `PIXEL_TIMEZONE`. A whole-day offset (`{{date+2}}`, `{{day+2}}`) is from the scheduled day, so a Monday post can say Wednesday. A name Pixel doesn't know is left as written. Discord's limits (2000 characters for a message, 300 for a poll question, 55 for an answer) count the filled text, so `/schedule` refuses a post its tokens would push over. `schedule.clipped` in the logs means one still came out too long and was cut to fit. Don't put secrets in a scheduled post. - **Change one before it posts:** `/schedule edit` (only the person who scheduled it). Without a schedule it lists theirs; with one it opens the same compose modal, prefilled. One-offs that already went out, and other people's schedules, get a generic refusal. - **Stop one now:** `/schedule pause` or `/schedule delete`. To stop someone's schedules, revoke their capability: each of theirs pauses the next time it's due. - **A post didn't go out:** look in the logs for `schedule.failed` (Pixel can't post there any more: permissions, a deleted channel), `schedule.skipped` (Pixel was down more than an hour past the time) or `schedule.paused_no_access`. Pixel never retries a failed post; the next occurrence tries again. diff --git a/src/features/schedules/index.ts b/src/features/schedules/index.ts index dcfd7f5..a5a3c56 100644 --- a/src/features/schedules/index.ts +++ b/src/features/schedules/index.ts @@ -34,6 +34,7 @@ import { type Schedule, type ScheduleStore, } from "../../services/schedules.ts"; +import { checkFilledLength } from "./limits.ts"; import { runDue } from "./runner.ts"; /** Lets someone schedule messages and polls. Granted with `/admin capabilities`. */ @@ -189,6 +190,7 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature { fallbackName: string, ): Promise { const { start, recurrence } = timing(ctx.args); + checkFilledLength(post, { start, recurrence }, zone, now(), deps.interpolator); const channel = await checkChannel(ctx, post); const name = cleanName(typeof ctx.args.name === "string" ? ctx.args.name : fallbackName); const schedule = deps.store.add({ @@ -360,6 +362,7 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature { ? editTiming(schedule, ctx.args) : { start: parseLocal(schedule.start) as LocalDateTime, recurrence: schedule.recurrence }; const post = editedPost(schedule.post, ctx.args); + checkFilledLength(post, timingChange, zone, now(), deps.interpolator); const name = typeof ctx.args.name === "string" ? cleanName(ctx.args.name) : schedule.name; const updated = deps.store.update(schedule.id, { post, diff --git a/src/features/schedules/limits.test.ts b/src/features/schedules/limits.test.ts new file mode 100644 index 0000000..18db0e0 --- /dev/null +++ b/src/features/schedules/limits.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { ChannelPost } from "../../core/channel-posts.ts"; +import { UserFacingError } from "../../core/errors.ts"; +import { createInterpolator } from "../../core/interpolate.ts"; +import type { LocalDateTime, Recurrence } from "../../core/recurrence.ts"; +import { checkFilledLength, clipToLimits, overLimit } from "./limits.ts"; + +const AMS = "Europe/Amsterdam"; +// Mon 12 Oct 2026, 10:00 in Amsterdam. +const NOW = new Date("2026-10-12T08:00:00Z"); +const interpolator = createInterpolator({ timezone: AMS, now: () => NOW }); +const WED_19: LocalDateTime = { year: 2026, month: 10, day: 14, hour: 19, minute: 0 }; +const ONCE: Recurrence = { kind: "once" }; + +const message = (text: string): ChannelPost => ({ kind: "message", text, mentions: false }); +const poll = (question: string, answers: string[]): ChannelPost => ({ + kind: "poll", + question, + answers, + durationHours: 24, + multiple: false, +}); +const check = (post: ChannelPost, start = WED_19, recurrence: Recurrence = ONCE) => + checkFilledLength(post, { start, recurrence }, AMS, NOW, interpolator); + +describe("overLimit", () => { + it("is nothing when everything fits, and names what's too long", () => { + expect(overLimit(message("x".repeat(2000)))).toBeUndefined(); + expect(overLimit(message("x".repeat(2001)))).toMatch(/message .* 2000-character/); + expect(overLimit(poll("x".repeat(300), ["a", "x".repeat(55)]))).toBeUndefined(); + expect(overLimit(poll("x".repeat(301), ["a", "b"]))).toMatch(/question .* 300-character/); + expect(overLimit(poll("q", ["a", "x".repeat(56)]))).toMatch(/answer .* 55-character/); + }); + + it("counts characters, not UTF-16 units", () => { + expect(overLimit(poll("q", ["a", "🍕".repeat(55)]))).toBeUndefined(); + }); +}); + +describe("checkFilledLength", () => { + it("accepts a post that still fits once its tokens are filled", () => { + // "{{date}}" (8) becomes "14 October 2026" (15). + expect(() => check(message(`${"x".repeat(1985)}{{date}}`))).not.toThrow(); + }); + + it("refuses a message that only goes over the limit once tokens are filled, without echoing it", () => { + const text = `${"x".repeat(1986)}{{date}}`; + expect(() => check(message(text))).toThrow(UserFacingError); + expect(() => check(message(text))).toThrow( + /tokens filled in, the message would be over Discord's 2000-character limit on Wed 14 Oct 2026, 19:00/, + ); + try { + check(message(text)); + } catch (error) { + expect((error as Error).message).not.toContain("xxxx"); + } + }); + + it("refuses a poll question or answer pushed over its limit", () => { + // "{{dateWithTime}}" (16) becomes "14 October 2026, 19:00 CEST" (27). + expect(() => check(poll(`${"x".repeat(290)}{{dateWithTime}}`, ["a", "b"]))).toThrow( + /question would be over/, + ); + expect(() => check(poll("q", ["a", `${"x".repeat(29)}{{dateWithTime}}`]))).toThrow( + /answer would be over/, + ); + expect(() => check(poll("q", ["a", `${"x".repeat(28)}{{dateWithTime}}`]))).not.toThrow(); + }); + + it("checks later occurrences too, where a longer month name can push it over", () => { + // Monthly from 14 October: "14 October 2026" (15) fits, "14 November 2026" (16) doesn't. + const text = `${"x".repeat(1985)}{{date}}`; + const monthly: Recurrence = { kind: "monthly", everyMonths: 1 }; + expect(() => check(message(text), WED_19, monthly)).toThrow(/Sat 14 Nov 2026/); + }); + + it("checks as of now when there's no occurrence left", () => { + const past: LocalDateTime = { ...WED_19, day: 1 }; + // Monday 12 October 2026 as "{{date}}" is 15 characters, like the 14th. + expect(() => check(message(`${"x".repeat(1985)}{{date}}`), past)).not.toThrow(); + expect(() => check(message(`${"x".repeat(1986)}{{date}}`), past)).toThrow( + /Mon 12 Oct 2026, 10:00/, + ); + }); +}); + +describe("clipToLimits", () => { + it("leaves a post that fits alone", () => { + const post = message("Hello"); + expect(clipToLimits(post)).toEqual({ post, clipped: false }); + }); + + it("cuts a message to the limit, ending in an ellipsis", () => { + const { post, clipped } = clipToLimits(message("x".repeat(2010))); + expect(clipped).toBe(true); + expect(post.kind === "message" && [...post.text].length).toBe(2000); + expect(post.kind === "message" && post.text.endsWith("x…")).toBe(true); + }); + + it("cuts a poll's question and long answers, and leaves the rest", () => { + const { post, clipped } = clipToLimits(poll("q".repeat(310), ["Yes", "🍕".repeat(60)])); + expect(clipped).toBe(true); + expect(post).toEqual(poll(`${"q".repeat(299)}…`, ["Yes", `${"🍕".repeat(54)}…`])); + }); +}); diff --git a/src/features/schedules/limits.ts b/src/features/schedules/limits.ts new file mode 100644 index 0000000..2f2a06b --- /dev/null +++ b/src/features/schedules/limits.ts @@ -0,0 +1,95 @@ +import type { ChannelPost } from "../../core/channel-posts.ts"; +import { UserFacingError } from "../../core/errors.ts"; +import { type Interpolate, interpolateChannelPost } from "../../core/interpolate.ts"; +import { + type LocalDateTime, + nextOccurrence, + type Recurrence, + toLocal, +} from "../../core/recurrence.ts"; +import { describeWhen } from "../../core/when.ts"; +import { + MAX_MESSAGE_LENGTH, + MAX_POLL_ANSWER, + MAX_POLL_QUESTION, +} from "../../services/schedules.ts"; + +/** + * The platform's length limits apply to a post after its `{{tokens}}` are filled, and + * tokens grow (`{{date}}` is 8 characters, "30 September 2026" is 17). So a body that + * fits as typed can be too long when it goes out. Checked at create and edit time; + * clipped as a last resort at post time so a schedule never fails at every occurrence. + */ + +/** How many upcoming occurrences are checked at create and edit time. */ +export const CHECKED_OCCURRENCES = 12; + +const length = (text: string) => [...text].length; + +/** What's too long in a filled post, or undefined if it all fits. */ +export function overLimit(post: ChannelPost): string | undefined { + if (post.kind === "message") { + return length(post.text) > MAX_MESSAGE_LENGTH + ? `the message would be over Discord's ${MAX_MESSAGE_LENGTH}-character limit` + : undefined; + } + if (length(post.question) > MAX_POLL_QUESTION) { + return `the question would be over Discord's ${MAX_POLL_QUESTION}-character limit`; + } + if (post.answers.some((answer) => length(answer) > MAX_POLL_ANSWER)) { + return `an answer would be over Discord's ${MAX_POLL_ANSWER}-character limit`; + } + return undefined; +} + +/** + * Refuses a post that's too long once its tokens are filled, at any of its next + * occurrences from `now` (or at `now` if it has none left). + */ +export function checkFilledLength( + post: ChannelPost, + timing: { start: LocalDateTime; recurrence: Recurrence }, + zone: string, + now: Date, + interpolator: Interpolate, +): void { + const occurrences: Date[] = []; + let after = now; + for (let i = 0; i < CHECKED_OCCURRENCES; i++) { + const next = nextOccurrence(timing.start, timing.recurrence, zone, after); + if (!next) break; + occurrences.push(next); + after = next; + } + for (const at of occurrences.length > 0 ? occurrences : [now]) { + const problem = overLimit( + interpolateChannelPost(post, (text) => interpolator.interpolate(text, at)), + ); + if (problem) { + throw new UserFacingError( + `With its tokens filled in, ${problem} on ${describeWhen(toLocal(at, zone))}. Make it shorter.`, + ); + } + } +} + +function clip(text: string, max: number): string { + const chars = [...text]; + return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : text; +} + +/** The filled post cut down to the platform's limits, and whether anything was cut. */ +export function clipToLimits(post: ChannelPost): { post: ChannelPost; clipped: boolean } { + if (!overLimit(post)) return { post, clipped: false }; + if (post.kind === "message") { + return { post: { ...post, text: clip(post.text, MAX_MESSAGE_LENGTH) }, clipped: true }; + } + return { + post: { + ...post, + question: clip(post.question, MAX_POLL_QUESTION), + answers: post.answers.map((answer) => clip(answer, MAX_POLL_ANSWER)), + }, + clipped: true, + }; +} diff --git a/src/features/schedules/runner.test.ts b/src/features/schedules/runner.test.ts index 9a670d6..b17bde9 100644 --- a/src/features/schedules/runner.test.ts +++ b/src/features/schedules/runner.test.ts @@ -266,6 +266,90 @@ describe("runDue", () => { ]); }); + describe("reads each schedule fresh, so a change made while another posts counts", () => { + async function twoDue(change: (store: ScheduleStore, second: string) => void) { + const ctx = setup(); + ctx.store.add(sample({ post: { kind: "message", text: "First", mentions: false } })); + const second = ctx.store.add( + sample({ post: { kind: "message", text: "Second", mentions: false } }), + ); + ctx.posts.post.mockImplementationOnce(async (channelId: string, post: ChannelPost) => { + ctx.posted.push([channelId, post]); + change(ctx.store, second.id); + }); + await ctx.run(plus(10_000)); + return { ...ctx, second }; + } + + it("doesn't post one deleted meanwhile", async () => { + const { posted, store } = await twoDue((store, id) => store.remove(id)); + expect(posted.map(([, post]) => post)).toEqual([ + { kind: "message", text: "First", mentions: false }, + ]); + expect(store.all()).toHaveLength(1); + }); + + it("doesn't post one paused meanwhile, and leaves it paused and unrecorded", async () => { + const { posted, store, second } = await twoDue((store, id) => + store.update(id, { paused: true }), + ); + expect(posted).toHaveLength(1); + expect(store.get(second.id)).toMatchObject({ paused: true, lastRunAt: null }); + }); + + it("posts the edited text of one edited meanwhile", async () => { + const { posted } = await twoDue((store, id) => + store.update(id, { post: { kind: "message", text: "Edited", mentions: false } }), + ); + expect(posted.map(([, post]) => post)).toEqual([ + { kind: "message", text: "First", mentions: false }, + { kind: "message", text: "Edited", mentions: false }, + ]); + }); + + it("doesn't post one moved to another time meanwhile", async () => { + const { posted, store, second } = await twoDue((store, id) => + store.update(id, { start: "2026-10-15T19:00", recurrence: { kind: "once" } }), + ); + expect(posted).toHaveLength(1); + expect(store.get(second.id)?.lastRunAt).toBeNull(); + }); + + it("doesn't post one deleted or paused while checking its creator's access", async () => { + for (const change of [ + (store: ScheduleStore, id: string) => store.remove(id), + (store: ScheduleStore, id: string) => store.update(id, { paused: true }), + ]) { + const { store, posts, run, stillAllowed } = setup(); + const s = store.add(sample()); + stillAllowed.mockImplementationOnce(async () => { + change(store, s.id); + return true; + }); + await run(plus(10_000)); + expect(posts.post).not.toHaveBeenCalled(); + } + }); + }); + + it("clips a post that tokens made too long instead of failing, and logs no text", async () => { + const { store, posted, run, warn, error } = setup(); + // Stored before the create-time check existed: "{{date}}" (8) fills to 15 characters. + store.add( + sample({ post: { kind: "message", text: `${"x".repeat(1990)}{{date}}`, mentions: false } }), + ); + await run(plus(10_000)); + const sent = posted[0]?.[1]; + expect(sent?.kind === "message" && [...sent.text].length).toBe(2000); + expect(sent?.kind === "message" && sent.text.endsWith("…")).toBe(true); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "schedule.clipped" }), + expect.any(String), + ); + expect(error).not.toHaveBeenCalled(); + expect(JSON.stringify(warn.mock.calls)).not.toContain("xxxx"); + }); + it("does nothing until Discord is ready, or while the file is broken", async () => { const notReady = setup({ ready: false }); notReady.store.add(sample()); diff --git a/src/features/schedules/runner.ts b/src/features/schedules/runner.ts index ee36077..bf4fb1e 100644 --- a/src/features/schedules/runner.ts +++ b/src/features/schedules/runner.ts @@ -4,12 +4,13 @@ import type { Logger } from "../../core/logger.ts"; import type { ErrorReporter } from "../../core/ports/error-reporter.ts"; import { nextOccurrence, parseLocal, toInstant } from "../../core/recurrence.ts"; import type { Schedule, ScheduleStore } from "../../services/schedules.ts"; +import { clipToLimits } from "./limits.ts"; /** A post up to this late (Pixel was down) still goes out; later than that it's skipped. */ export const LATE_LIMIT_MS = 60 * 60_000; export type RunnerDeps = { - store: Pick; + store: Pick; posts: Pick; timezone: string; /** Fills `{{tokens}}` in the stored body at post time. */ @@ -52,13 +53,16 @@ export function dueOccurrence( /** * Handles every schedule that's due: posts it (once), skips it if it's more than an * hour late, and remembers the occurrence either way so nothing is posted twice. A - * one-off is removed once handled. Never throws. + * one-off is removed once handled. Each schedule is read again just before it's + * handled and after every wait, so one deleted, paused or edited meanwhile (say while + * an earlier one was posting) is never posted as it was. Never throws. */ export async function runDue(deps: RunnerDeps, now: Date): Promise { if (deps.store.problem || !deps.posts.ready) return; const log = deps.logger.child({ component: "schedules" }); - for (const schedule of deps.store.all()) { - if (schedule.paused) continue; + for (const { id } of deps.store.all()) { + const schedule = deps.store.get(id); + if (!schedule || schedule.paused) continue; const due = dueOccurrence(schedule, deps.timezone, now); if (!due) continue; const key = `${schedule.id}@${due.at.toISOString()}`; @@ -86,12 +90,20 @@ export async function runDue(deps: RunnerDeps, now: Date): Promise { ); continue; } else { - await deps.posts.post( - schedule.channelId, - interpolateChannelPost(schedule.post, (text) => + // Deleted, paused or edited while we waited: post what's there now, or nothing. + const current = deps.store.get(schedule.id); + if (!current || current.paused) continue; + if (dueOccurrence(current, deps.timezone, now)?.at.getTime() !== due.at.getTime()) continue; + const filled = clipToLimits( + interpolateChannelPost(current.post, (text) => deps.interpolator.interpolate(text, due.at), ), ); + if (filled.clipped) { + // Tokens made it longer than the platform allows. Never log the text itself. + log.warn({ event: "schedule.clipped", ...fields }, "filled post too long, clipped it"); + } + await deps.posts.post(current.channelId, filled.post); log.info( { event: "schedule.posted", ...fields, due: due.at.toISOString() }, "posted a scheduled post", diff --git a/src/features/schedules/schedules.test.ts b/src/features/schedules/schedules.test.ts index 34e796e..de13bd7 100644 --- a/src/features/schedules/schedules.test.ts +++ b/src/features/schedules/schedules.test.ts @@ -418,6 +418,38 @@ describe("/schedule poll", () => { it("needs a question", async () => { expect((await setup().poll({ question: " " })).reply.text).toMatch(/question can't be empty/); }); + + it("refuses an answer that only goes over the limit once its tokens are filled", async () => { + // "{{date}}" (8) becomes "14 October 2026" (15): 49 as typed, 56 when posted. + const { poll, store } = setup(); + const result = await poll({ answers: `Yes\n${"x".repeat(41)}{{date}}` }); + expect(result.reply.text).toMatch(/answer would be over Discord's 55-character limit/); + expect(store.all()).toEqual([]); + }); +}); + +describe("tokens and Discord's length limits", () => { + const tooLong = `${"x".repeat(1986)}{{date}}`; + + it("refuses a message that tokens would push over 2000 characters, and saves nothing", async () => { + const { message, store } = setup(); + const result = await message({ text: tooLong }); + expect(result.reply.text).toMatch( + /tokens filled in, the message would be over Discord's 2000-character limit/, + ); + expect(result.reply.text).not.toContain("xxxx"); + expect(store.all()).toEqual([]); + expect((await message({ text: `${"x".repeat(1985)}{{date}}` })).reply.text).toHaveLength(2000); + }); + + it("refuses an edit that tokens would push over the limit, and keeps the old text", async () => { + const { message, run, store } = setup(); + await message(); + const id = store.all()[0]?.id as string; + const result = await run("edit", { schedule: id, text: tooLong }); + expect(result.reply.text).toMatch(/2000-character limit/); + expect(store.get(id)?.post).toMatchObject({ text: "Pizza night tonight!" }); + }); }); describe("parseAnswers", () => {