mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
One row per live command on a new Commands tab of the management page,
each with an on/off switch, an optional "off until" moment and a cooldown
that overrides the bot's default for that command alone. Every switch is
live: the bot keeps them in its own SQLite (command_settings, a permanent
table) and reads the row on every attempt, so nothing goes through
/_reload and nothing restarts.
A command that is off answers "BEEP BOOP !kot WYŁĄCZONE." (with "DO HH:MM"
for a timed switch) rather than going quiet, so nobody reports it broken;
the refusal is filed as refused_off, which the analytics count with the
other refusals, and it neither starts the cooldown nor counts as a use.
The bot grows GET /_commands and PUT /_commands/{name}; the management app
proxies them under /api/commands and passes the bot's 404/503 through
unwrapped. !cisza and !odcisz carry no cooldown and the page says so
instead of accepting a number the bot would ignore (test_wiring_ungated
keeps that set honest against the real table).
Proven end to end: scripts/e2e.sh now brackets a PUT with switch_off.yaml
and switch_on.yaml, the same actor refused and then served, with no reload
between them. Also bumps vitest and @vitest/coverage-v8 to 5.0.0 and
@types/react-dom to 19.2.7, which the freshness gate asked for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
56 lines
1.7 KiB
TypeScript
56 lines
1.7 KiB
TypeScript
// The "off until" field is a <input type="datetime-local">, which speaks
|
|
// "YYYY-MM-DDTHH:MM" in the browser's own zone, while the bot stores epoch
|
|
// seconds. These two are the whole translation, kept apart from the view so
|
|
// the arithmetic is tested on its own rather than through a form.
|
|
|
|
const MS_PER_SECOND = 1000;
|
|
|
|
function two(n: number): string {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
/**
|
|
Epoch seconds to the local wall-clock string the input wants; "" for none.
|
|
*/
|
|
export function toLocalInput(seconds: null | number): string {
|
|
if (seconds === null) {
|
|
return "";
|
|
}
|
|
const at = new Date(seconds * MS_PER_SECOND);
|
|
const date = `${String(at.getFullYear())}-${two(at.getMonth() + 1)}-${two(at.getDate())}`;
|
|
return `${date}T${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
}
|
|
|
|
/**
|
|
The input's string back to epoch seconds; null for an empty or unparsable field.
|
|
*/
|
|
export function fromLocalInput(text: string): null | number {
|
|
if (text === "") {
|
|
return null;
|
|
}
|
|
const at = new Date(text);
|
|
const ms = at.getTime();
|
|
return Number.isNaN(ms) ? null : Math.floor(ms / MS_PER_SECOND);
|
|
}
|
|
|
|
/**
|
|
Whether a command is off at `nowSeconds`: the hard switch, or a deadline still ahead.
|
|
*/
|
|
export function isOffAt(
|
|
row: { disabled_until: null | number; enabled: boolean },
|
|
nowSeconds: number,
|
|
): boolean {
|
|
return !row.enabled || (row.disabled_until !== null && row.disabled_until > nowSeconds);
|
|
}
|
|
|
|
/**
|
|
A cooldown field's text to what is sent: null clears the override.
|
|
*/
|
|
export function cooldownFrom(text: string): null | number {
|
|
if (text.trim() === "") {
|
|
return null;
|
|
}
|
|
const value = Number(text);
|
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
}
|