diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f3927..53d8144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ### Fixed - `/spacestate`: the Sentry context for a failed call now says why the network failed (`errorCause`, such as `ENOTFOUND`, `ECONNREFUSED` or `UNEXPECTED_REDIRECT`), and shows the address without its query. Sentry was replacing the old redacted address with `[Filtered]`. +- `/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`. - `/schedule edit`: changing `when`, `repeat` or `days` now counts from the edit. Before, a weekly post that last went out on Monday and was moved to Thursday at 19:20 could post straight away for that Thursday 19:00, while the reply said next week. - `/schedule edit` refuses to turn a repeat into a one-off whose start has passed (add a new `when`), and `/schedule resume` refuses a one-off whose time passed while it was paused (it says to delete it). Both used to keep a schedule that could never post. - `/schedule` `when` option: its description names the configured `PIXEL_TIMEZONE` instead of always saying Europe/Amsterdam. Re-register the commands (`just register`) for a zone other than Europe/Amsterdam to see it. diff --git a/docs/architecture.md b/docs/architecture.md index 5708937..cb71e04 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. A change to `when`, `repeat` or `days` counts from the edit (`lastRunAt` moves to now, as on resume), so an occurrence of the new timing that fell before the edit never posts late. An edit that would leave nothing to post (a repeat turned into a one-off whose start has passed) is refused, and so is resuming a one-off whose time passed while paused, so neither keeps a dead schedule. 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 5da39b0..8aec9d8 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 7d7c584..9414682 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({ @@ -380,6 +382,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 start = formatLocal(timingChange.start); const retimed = 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 b359b62..8864722 100644 --- a/src/features/schedules/schedules.test.ts +++ b/src/features/schedules/schedules.test.ts @@ -428,6 +428,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", () => {