Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/features/schedules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -189,6 +190,7 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature {
fallbackName: string,
): Promise<Reply> {
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({
Expand Down Expand Up @@ -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 =
Expand Down
105 changes: 105 additions & 0 deletions src/features/schedules/limits.test.ts
Original file line number Diff line number Diff line change
@@ -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)}…`]));
});
});
95 changes: 95 additions & 0 deletions src/features/schedules/limits.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading