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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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 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.
- Discord: when Discord refused a slow command's acknowledgement (its deferral), the failure is no longer also logged and sent to Sentry as an unhandled rejection with no user or command; it is handled once, by the adapter. A command whose "Checking…" placeholder couldn't be posted (`/status`, `/ha` and others) is no longer reported as a failed command: the handler still doesn't run, and the adapter logs it like any other refused acknowledgement.
- Discord: only "already acknowledged" (40060) is logged as `discord.duplicate_handler` (a second Pixel on the token). "Unknown interaction" (10062) means Pixel didn't acknowledge within Discord's 3 seconds, so it is now logged as `discord.slow_ack` with the command and who ran it, and reported to Sentry as a `SlowAcknowledgementError` naming the command.
- Sentry: traces no longer carry Discord interaction tokens. Every reply to a command goes to a Discord URL with the interaction token in its path, and the request's span (`url.full`, `url.path`) and `http` breadcrumb kept it, so anyone with access to the Sentry project could post as the bot for 15 minutes after each command. Spans now go through the same scrubbing as errors, breadcrumbs and logs, and the token in `/interactions/<id>/<token>` and `/webhooks/<id>/<token>` paths is shown as `[redacted]`. The scrubbing also no longer skips an object that appears twice in what is sent, and keeps an error's message and stack (scrubbed) instead of dropping them.
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,9 @@ 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.
- **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. Opening the form takes a rate-limit token like running a command, and `beforeForm` runs in the person's error-reporting context like a handler. The submit is its own interaction and takes its own token.
- **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: four digits after a date are a year only beside another time (`14 oct 2027 19:00`) or when no clock reads them (`14 oct 2099`), so `14 oct 1900` is 19:00. Months are English names or their short forms; other words are refused. 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`).
- **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.
- **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
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Every scheduled post, with where and when it goes out next.

<PxCommand :level="4" sig="/schedule edit schedule: [when:] [repeat:] [days:] [name:]" tier="member" reply="private" autocomplete form :caps="['schedule-posts']" :options="{ 'schedule:': 'Which of your schedules. Only your own upcoming ones are offered.' }">

Changes one of your own scheduled posts before it goes out: its content, and optionally when and how often.
Changes one of your own scheduled posts before it goes out: its content, and optionally when and how often. A new time or repeat counts from now, so nothing it would have posted before the edit goes out late. Turning a repeat into a one-off needs a new `when`.

</PxCommand>

Expand All @@ -137,7 +137,7 @@ Stops a schedule from posting until it's resumed.

<PxCommand :level="4" sig="/schedule resume schedule:" tier="member" reply="private" autocomplete :caps="['schedule-posts']" :options="{ 'schedule:': 'Which schedule.' }">

Starts a paused schedule again. Nothing missed while paused is posted.
Starts a paused schedule again. Nothing missed while paused is posted. A one-off whose time passed while it was paused can't be resumed: delete it.

</PxCommand>

Expand Down
57 changes: 45 additions & 12 deletions src/features/schedules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature {
};
const whenOption: CommandOption = {
name: "when",
description: "When to post (Europe/Amsterdam), e.g. wed 1900, 14 oct 19:00, tomorrow 9am",
description: `When to post (${zone}), e.g. wed 1900, 14 oct 19:00, tomorrow 9am`,
type: "string",
required: true,
};
Expand Down Expand Up @@ -267,13 +267,33 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature {
throw new UserFacingError(CANT_EDIT);
}

/** Apply optional `when` / `repeat` / `days` on top of what's already stored. */
/**
* Apply optional `when` / `repeat` / `days` on top of what's already stored. Refuses a
* change that would leave nothing to post (a one-off whose start has passed).
*/
function editTiming(
schedule: Schedule,
args: CommandContext["args"],
): {
start: LocalDateTime;
recurrence: Recurrence;
} {
const change = parseEditTiming(schedule, args);
const edited = { ...schedule, start: formatLocal(change.start), recurrence: change.recurrence };
if (!nextRun(edited, zone, now())) {
throw new UserFacingError(
"That would leave it with nothing to post: a one-off needs a time that's still to come. Add `when` with the new time.",
);
}
return change;
}

function parseEditTiming(
schedule: Schedule,
args: CommandContext["args"],
): {
start: LocalDateTime;
recurrence: Recurrence;
} {
const hasWhen = typeof args.when === "string" && args.when !== "";
const hasRepeat = typeof args.repeat === "string";
Expand Down Expand Up @@ -361,11 +381,17 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature {
: { start: parseLocal(schedule.start) as LocalDateTime, recurrence: schedule.recurrence };
const post = editedPost(schedule.post, ctx.args);
const name = typeof ctx.args.name === "string" ? cleanName(ctx.args.name) : schedule.name;
const start = formatLocal(timingChange.start);
const retimed =
start !== schedule.start ||
JSON.stringify(timingChange.recurrence) !== JSON.stringify(schedule.recurrence);
const updated = deps.store.update(schedule.id, {
post,
start: formatLocal(timingChange.start),
start,
recurrence: timingChange.recurrence,
name,
// New timing counts from now, like resume: nothing it "missed" before the edit goes out.
...(retimed ? { lastRunAt: fromNow(schedule, now()) } : {}),
});
if (!updated) refuseEdit(ctx);
ctx.logger.info(
Expand Down Expand Up @@ -596,18 +622,18 @@ export function createSchedulesFeature(deps: SchedulesDeps): Feature {
};
}),
manage("resume", "Start a paused schedule again", (schedule, ctx) => {
// Counts from the same instant as the lastRunAt saved below, so it's the post it will make.
const next = nextRun(schedule, zone, now());
if (!next) {
throw new UserFacingError(
`${label(schedule)} has nothing left to post: its time passed. \`/schedule delete\` removes it.`,
);
}
// From now on: nothing missed while it was paused is posted.
const from =
schedule.lastRunAt && new Date(schedule.lastRunAt) > now()
? schedule.lastRunAt
: now().toISOString();
const updated = deps.store.update(schedule.id, { paused: false, lastRunAt: from });
deps.store.update(schedule.id, { paused: false, lastRunAt: fromNow(schedule, now()) });
ctx.logger.info({ event: "schedule.resumed", id: schedule.id }, "resumed a schedule");
const next = updated ? nextRun(updated, zone, now()) : undefined;
return {
text: next
? `Resumed ${label(schedule)}. Next post: ${describeWhen(toLocal(next, zone))}.`
: `Resumed ${label(schedule)}, but it has no more posts to make.`,
text: `Resumed ${label(schedule)}. Next post: ${describeWhen(toLocal(next, zone))}.`,
private: true,
};
}),
Expand Down Expand Up @@ -712,6 +738,13 @@ function suggestSchedules(schedules: readonly Schedule[], typed: string): Sugges
.map((s) => ({ name: `${s.name} (${s.id})${s.paused ? " · paused" : ""}`, value: s.id }));
}

/** `lastRunAt` for a schedule that should only post from `now` on (the stored one if later). */
function fromNow(schedule: Schedule, now: Date): string {
return schedule.lastRunAt && new Date(schedule.lastRunAt) > now
? schedule.lastRunAt
: now.toISOString();
}

/** When a schedule posts next from `now` on, if ever. */
export function nextRun(schedule: Schedule, zone: string, now: Date): Date | undefined {
const start = parseLocal(schedule.start);
Expand Down
Loading
Loading