/**
* @name AutomaticStatusScheduler
* @author Haxurus
* @version 2.0.0
* @description Adds a Set schedule button to Discord's custom status editor and automatically applies saved status rules.
*/
module.exports = (_ => {
const changeLog = {};
return !window.BDFDB_Global || (!window.BDFDB_Global.loaded && !window.BDFDB_Global.started) ? class {
constructor(meta) {
for (const key in meta) this[key] = meta[key];
}
getName() { return this.name; }
getAuthor() { return this.author; }
getVersion() { return this.version; }
getDescription() {
return `The BDFDB Library Plugin required by ${this.name} is missing. Open the plugin settings to download it.\n\n${this.description}`;
}
downloadLibrary() {
BdApi.Net.fetch("https://mwittrien.github.io/BetterDiscordAddons/Library/0BDFDB.plugin.js")
.then(response => {
if (!response || response.status !== 200) throw new Error("Download failed");
return response.text();
})
.then(body => {
if (!body) throw new Error("Downloaded file was empty");
const fs = require("fs");
const path = require("path");
fs.writeFile(
path.join(BdApi.Plugins.folder, "0BDFDB.plugin.js"),
body,
error => {
if (error) {
BdApi.UI.showToast("Could not install BDFDB Library.", {type: "error"});
return;
}
BdApi.UI.showToast("BDFDB Library installed. Enable it and reload Discord.", {type: "success"});
}
);
})
.catch(error => {
BdApi.Logger.error(this.name, error);
BdApi.UI.alert(
"Library download failed",
"Download BDFDB Library manually from the BetterDiscordAddons website and place it in the plugins folder."
);
});
}
load() {
if (!window.BDFDB_Global || !Array.isArray(window.BDFDB_Global.pluginQueue)) {
window.BDFDB_Global = Object.assign({}, window.BDFDB_Global, {pluginQueue: []});
}
if (!window.BDFDB_Global.downloadModal) {
window.BDFDB_Global.downloadModal = true;
BdApi.UI.showConfirmationModal(
"Library missing",
`The BDFDB Library Plugin required by ${this.name} is missing.`,
{
confirmText: "Download now",
cancelText: "Cancel",
onCancel: () => delete window.BDFDB_Global.downloadModal,
onConfirm: () => {
delete window.BDFDB_Global.downloadModal;
this.downloadLibrary();
}
}
);
}
if (!window.BDFDB_Global.pluginQueue.includes(this.name)) {
window.BDFDB_Global.pluginQueue.push(this.name);
}
}
start() { this.load(); }
stop() {}
getSettingsPanel() {
const panel = document.createElement("div");
panel.style.color = "var(--text-normal)";
panel.style.padding = "16px";
panel.innerHTML = `
BDFDB Library is required
Install the library to use this plugin.
`;
const button = document.createElement("button");
button.textContent = "Download now";
button.className = "bd-button bd-button-filled bd-button-color-brand bd-button-medium";
button.addEventListener("click", () => this.downloadLibrary());
panel.append(button);
return panel;
}
} : (([Plugin, BDFDB]) => {
let pluginInstance;
let pendingCapture = null;
let captureTimeout = null;
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DAY_LONG_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const STATUS_LABELS = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
invisible: "Invisible"
};
const DEFAULT_SETTINGS = {
applyOnStartup: true,
showNotifications: true,
schedules: []
};
const ScheduleListComponent = class ScheduleList extends BdApi.React.Component {
constructor(props) {
super(props);
this.state = {version: 0};
}
renderEmoji(schedule) {
const emoji = schedule.customStatusEmoji;
if (!emoji) return null;
if (emoji.id) {
const url = `https://cdn.discordapp.com/emojis/${emoji.id}.webp?size=48&quality=lossless`;
return BdApi.React.createElement("img", {
src: url,
alt: emoji.name || "",
style: {
width: "24px",
height: "24px",
objectFit: "contain",
flex: "0 0 auto"
}
});
}
return BdApi.React.createElement("span", {
style: {fontSize: "22px", lineHeight: 1, flex: "0 0 auto"}
}, emoji.name || "");
}
render() {
const plugin = this.props.plugin;
const schedules = plugin.settings.schedules;
if (!schedules.length) {
return BdApi.React.createElement("div", {
style: {
padding: "18px",
borderRadius: "8px",
background: "var(--background-secondary)",
color: "var(--text-muted)",
lineHeight: 1.5
}
}, "No schedules have been created. Open Discord's custom status editor, prepare the status, and select Set schedule.");
}
return BdApi.React.createElement("div", {
style: {display: "grid", gap: "10px"}
}, schedules.map(schedule => {
const days = schedule.days.map(day => DAY_LABELS[day]).join(", ");
const statusText = schedule.customStatusText || "Clear custom status";
return BdApi.React.createElement("div", {
key: schedule.id,
style: {
display: "flex",
alignItems: "center",
gap: "12px",
padding: "12px",
borderRadius: "8px",
border: "1px solid var(--background-modifier-accent)",
background: "var(--background-secondary)"
}
}, [
this.renderEmoji(schedule),
BdApi.React.createElement("div", {
key: "content",
style: {minWidth: 0, flex: "1 1 auto"}
}, [
BdApi.React.createElement("div", {
key: "name",
style: {
color: "var(--header-primary)",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}
}, schedule.name),
BdApi.React.createElement("div", {
key: "details",
style: {
color: "var(--text-muted)",
fontSize: "12px",
marginTop: "3px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}
}, `${schedule.time} · ${days} · ${STATUS_LABELS[schedule.status]}`),
BdApi.React.createElement("div", {
key: "status",
style: {
color: "var(--text-normal)",
fontSize: "13px",
marginTop: "5px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}
}, statusText)
]),
BdApi.React.createElement("button", {
key: "delete",
type: "button",
children: "Delete",
style: {
flex: "0 0 auto",
border: 0,
borderRadius: "6px",
padding: "8px 12px",
background: "var(--status-danger)",
color: "white",
cursor: "pointer",
fontWeight: 600
},
onClick: () => {
BdApi.UI.showConfirmationModal(
"Delete schedule",
`Delete “${schedule.name}”?`,
{
danger: true,
confirmText: "Delete",
cancelText: "Cancel",
onConfirm: () => {
plugin.deleteSchedule(schedule.id);
this.setState({version: this.state.version + 1});
}
}
);
}
})
]);
}));
}
};
return class AutomaticStatusScheduler extends Plugin {
onLoad() {
pluginInstance = this;
this.settings = structuredClone(DEFAULT_SETTINGS);
this.timer = null;
this.startupTimer = null;
this.isChecking = false;
this.lastAppliedKey = null;
this.lastErrorToastAt = 0;
this.statusSetting = null;
this.modulePatches = {
before: ["ModalRoot"],
after: ["CustomStatusModalWithPreview"]
};
this.css = `
.hass-schedule-form {
display: grid;
gap: 14px;
color: var(--text-normal);
}
.hass-schedule-field {
display: grid;
gap: 6px;
}
.hass-schedule-label {
color: var(--header-secondary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.hass-schedule-input,
.hass-schedule-select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
background: var(--input-background, var(--background-tertiary));
color: var(--text-normal);
outline: none;
}
.hass-schedule-days {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.hass-schedule-day {
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
padding: 8px 4px;
background: var(--background-secondary);
color: var(--text-muted);
cursor: pointer;
font-weight: 600;
}
.hass-schedule-day-selected {
background: var(--brand-500);
border-color: var(--brand-500);
color: white;
}
.hass-status-preview {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 6px;
background: var(--background-secondary);
min-width: 0;
}
.hass-status-preview img {
width: 28px;
height: 28px;
object-fit: contain;
flex: 0 0 auto;
}
.hass-status-preview-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
}
onStart() {
this.loadSettings();
const settingsStore = BDFDB.DiscordUtils.getSettingsStore();
if (settingsStore) {
BDFDB.PatchUtils.patch(this, settingsStore, "updateAsync", {
after: event => this.captureCustomStatusUpdate(event)
});
}
this.startupTimer = setTimeout(() => {
if (this.settings.applyOnStartup) this.checkSchedule(true);
}, 2500);
this.timer = setInterval(() => this.checkSchedule(false), 15000);
this.forceUpdateAll();
}
onStop() {
if (this.timer) clearInterval(this.timer);
if (this.startupTimer) clearTimeout(this.startupTimer);
if (captureTimeout) clearTimeout(captureTimeout);
this.timer = null;
this.startupTimer = null;
captureTimeout = null;
pendingCapture = null;
this.forceUpdateAll();
}
loadSettings() {
const saved = BdApi.Data.load(this.name, "settings") || {};
this.settings = {
...structuredClone(DEFAULT_SETTINGS),
...saved,
schedules: Array.isArray(saved.schedules)
? saved.schedules.map(item => this.normalizeSchedule(item))
: []
};
this.saveSettings();
}
saveSettings() {
BdApi.Data.save(this.name, "settings", this.settings);
}
normalizeSchedule(item = {}) {
const days = Array.isArray(item.days)
? [...new Set(item.days.map(Number).filter(day => day >= 0 && day <= 6))]
: [0, 1, 2, 3, 4, 5, 6];
const status = Object.prototype.hasOwnProperty.call(STATUS_LABELS, item.status)
? item.status
: "online";
const emojiSource = item.customStatusEmoji ?? item.customEmoji ?? item.emoji ?? item.emojiInfo ?? null;
const emoji = this.normalizeEmoji(emojiSource);
const text = String(item.customStatusText ?? item.customText ?? item.text ?? "").trim().slice(0, 128);
return {
id: String(item.id || this.createId()),
name: String(item.name || text || "Scheduled status").trim().slice(0, 80) || "Scheduled status",
time: /^([01]\d|2[0-3]):[0-5]\d$/.test(String(item.time)) ? String(item.time) : "09:00",
status,
customStatusText: text,
customStatusEmoji: emoji,
days: days.length ? days.sort((a, b) => a - b) : [new Date().getDay()],
enabled: item.enabled !== false
};
}
normalizeEmoji(value) {
if (!value) return null;
if (typeof value === "string") {
const customMatch = /^<(a?):([A-Za-z0-9_]+):(\d+)>$/.exec(value.trim());
if (customMatch) {
return {
id: customMatch[3],
name: customMatch[2],
animated: customMatch[1] === "a"
};
}
return value.trim() ? {id: null, name: value.trim().slice(0, 64), animated: false} : null;
}
const id = String(value.id ?? value.emojiId ?? value.emoji_id ?? "").trim();
const name = String(value.name ?? value.emojiName ?? value.emoji_name ?? value.unicode ?? "").trim();
if (!id && !name) return null;
return {
id: id && id !== "0" ? id : null,
name: name.slice(0, 64),
animated: Boolean(value.animated)
};
}
createId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
getDefaultTime() {
const date = new Date(Date.now() + 60 * 60 * 1000);
date.setMinutes(Math.ceil(date.getMinutes() / 5) * 5, 0, 0);
if (date.getMinutes() === 60) {
date.setHours(date.getHours() + 1, 0, 0, 0);
}
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
findStatusSetting() {
const isStatusSetting = setting => setting &&
typeof setting === "object" &&
setting.userSettingsAPIGroup === "status" &&
setting.userSettingsAPIName === "status" &&
typeof setting.getSetting === "function" &&
typeof setting.updateSetting === "function";
if (isStatusSetting(this.statusSetting)) return this.statusSetting;
const found = BdApi.Webpack.getModule(isStatusSetting, {searchExports: true});
if (found) this.statusSetting = found;
return found || null;
}
getCurrentPresence() {
try {
const setting = this.findStatusSetting();
const value = setting?.getSetting?.();
return Object.prototype.hasOwnProperty.call(STATUS_LABELS, value) ? value : "online";
}
catch (_) {
return "online";
}
}
captureCustomStatusUpdate(event) {
if (!pendingCapture || event.methodArguments[0] !== "status") return;
const nextSettings = {value: undefined};
try {
event.methodArguments[1](nextSettings);
}
catch (error) {
BdApi.Logger.error(this.name, "Could not read the custom status update.", error);
return;
}
if (!Object.prototype.hasOwnProperty.call(nextSettings, "customStatus")) return;
const request = pendingCapture;
pendingCapture = null;
if (captureTimeout) clearTimeout(captureTimeout);
captureTimeout = null;
const status = nextSettings.customStatus || {};
const captured = {
text: String(status.text || "").trim().slice(0, 128),
emoji: this.normalizeEmoji({
id: status.emojiId,
name: status.emojiName
})
};
setTimeout(() => {
if (request.cancelled) return;
this.openScheduleModal(captured);
}, 0);
}
requestScheduleCapture(saveAction, modalInstance, event) {
if (!saveAction || typeof saveAction.onClick !== "function") {
BdApi.UI.showToast("The custom status save action could not be found.", {type: "error"});
return;
}
pendingCapture = {requestedAt: Date.now(), cancelled: false};
if (captureTimeout) clearTimeout(captureTimeout);
captureTimeout = setTimeout(() => {
if (!pendingCapture) return;
pendingCapture.cancelled = true;
pendingCapture = null;
BdApi.UI.showToast("The custom status could not be read. Reopen the status editor and try again.", {type: "error"});
}, 5000);
try {
saveAction.onClick(event);
if (!event?.shiftKey) modalInstance?.props?.onClose?.();
}
catch (error) {
pendingCapture = null;
if (captureTimeout) clearTimeout(captureTimeout);
captureTimeout = null;
BdApi.Logger.error(this.name, error);
BdApi.UI.showToast("The custom status could not be saved.", {type: "error"});
}
}
openScheduleModal(capturedStatus) {
const React = BdApi.React;
const initialName = capturedStatus.text || (capturedStatus.emoji ? `${capturedStatus.emoji.name} status` : "Scheduled status");
const draft = {
name: initialName.slice(0, 80),
time: this.getDefaultTime(),
status: this.getCurrentPresence(),
days: [0, 1, 2, 3, 4, 5, 6]
};
const plugin = this;
function ScheduleForm() {
const [name, setName] = React.useState(draft.name);
const [time, setTime] = React.useState(draft.time);
const [presence, setPresence] = React.useState(draft.status);
const [days, setDays] = React.useState(draft.days);
const toggleDay = day => {
let next;
if (days.includes(day)) {
if (days.length === 1) return;
next = days.filter(value => value !== day);
}
else {
next = [...days, day].sort((a, b) => a - b);
}
draft.days = next;
setDays(next);
};
const emojiElement = capturedStatus.emoji
? capturedStatus.emoji.id
? React.createElement("img", {
src: `https://cdn.discordapp.com/emojis/${capturedStatus.emoji.id}.webp?size=64&quality=lossless`,
alt: capturedStatus.emoji.name || ""
})
: React.createElement("span", {style: {fontSize: "26px"}}, capturedStatus.emoji.name)
: null;
return React.createElement("div", {className: "hass-schedule-form"}, [
React.createElement("div", {key: "preview", className: "hass-status-preview"}, [
emojiElement,
React.createElement("div", {
key: "text",
className: "hass-status-preview-text",
style: {color: capturedStatus.text ? "var(--text-normal)" : "var(--text-muted)"}
}, capturedStatus.text || "This rule will clear the custom status.")
]),
React.createElement("label", {key: "name", className: "hass-schedule-field"}, [
React.createElement("span", {key: "label", className: "hass-schedule-label"}, "Rule name"),
React.createElement("input", {
key: "input",
className: "hass-schedule-input",
value: name,
maxLength: 80,
onChange: inputEvent => {
draft.name = inputEvent.target.value;
setName(inputEvent.target.value);
}
})
]),
React.createElement("div", {
key: "time-presence",
style: {display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px"}
}, [
React.createElement("label", {key: "time", className: "hass-schedule-field"}, [
React.createElement("span", {key: "label", className: "hass-schedule-label"}, "Time"),
React.createElement("input", {
key: "input",
type: "time",
className: "hass-schedule-input",
value: time,
onChange: inputEvent => {
draft.time = inputEvent.target.value;
setTime(inputEvent.target.value);
}
})
]),
React.createElement("label", {key: "presence", className: "hass-schedule-field"}, [
React.createElement("span", {key: "label", className: "hass-schedule-label"}, "Presence"),
React.createElement("select", {
key: "select",
className: "hass-schedule-select",
value: presence,
onChange: inputEvent => {
draft.status = inputEvent.target.value;
setPresence(inputEvent.target.value);
}
}, Object.entries(STATUS_LABELS).map(([value, label]) => React.createElement("option", {
key: value,
value
}, label)))
])
]),
React.createElement("div", {key: "days", className: "hass-schedule-field"}, [
React.createElement("span", {key: "label", className: "hass-schedule-label"}, "Days"),
React.createElement("div", {key: "buttons", className: "hass-schedule-days"}, DAY_LABELS.map((label, day) => React.createElement("button", {
key: day,
type: "button",
title: DAY_LONG_LABELS[day],
className: `hass-schedule-day${days.includes(day) ? " hass-schedule-day-selected" : ""}`,
onClick: () => toggleDay(day)
}, label)))
])
]);
}
BdApi.UI.showConfirmationModal(
"Schedule this status",
React.createElement(ScheduleForm),
{
confirmText: "Create schedule",
cancelText: "Cancel",
onConfirm: () => {
const time = /^([01]\d|2[0-3]):[0-5]\d$/.test(draft.time) ? draft.time : this.getDefaultTime();
const schedule = this.normalizeSchedule({
id: this.createId(),
name: draft.name.trim() || capturedStatus.text || "Scheduled status",
time,
status: draft.status,
customStatusText: capturedStatus.text,
customStatusEmoji: capturedStatus.emoji,
days: draft.days,
enabled: true
});
this.settings.schedules.push(schedule);
this.saveSettings();
BdApi.UI.showToast(`Schedule created for ${schedule.time}.`, {type: "success"});
}
}
);
}
processModalRoot(event) {
if (!BDFDB.ReactUtils.findChild(event.instance, {
props: [["className", BDFDB.disCN.customstatusmodalprofilepreview]]
})) return;
event.instance.props.size = BDFDB.LibraryComponents.ModalComponents.ModalSize.MEDIUM;
}
processCustomStatusModalWithPreview(event) {
const actions = event.returnvalue?.props?.actions;
if (!Array.isArray(actions) || !actions.length) return;
if (actions.some(action => action?.hassScheduleAction)) return;
const saveAction = actions[actions.length - 1];
actions.splice(-1, 0, {
hassScheduleAction: true,
text: "Set schedule",
onClick: clickEvent => this.requestScheduleCapture(saveAction, event.instance, clickEvent)
});
}
getSettingsPanel() {
const list = BDFDB.ReactUtils.createElement(BDFDB.LibraryComponents.SettingsPanelList, {
title: "Scheduled statuses",
dividerTop: true,
children: BDFDB.ReactUtils.createElement(ScheduleListComponent, {
plugin: this
})
});
const help = BDFDB.ReactUtils.createElement(BDFDB.LibraryComponents.TextElement, {
style: {marginBottom: "14px", color: "var(--text-muted)", lineHeight: 1.5},
children: "Create schedules from Discord's custom status editor. These settings are intentionally read-only except for deleting rules."
});
return BDFDB.PluginUtils.createSettingsPanel(this, [help, list]);
}
deleteSchedule(id) {
this.settings.schedules = this.settings.schedules.filter(schedule => schedule.id !== id);
this.saveSettings();
BdApi.UI.showToast("Schedule deleted.", {type: "success"});
}
getMostRecentSchedule(now) {
let winner = null;
for (const schedule of this.settings.schedules) {
if (!schedule.enabled || !schedule.days.length) continue;
const [hours, minutes] = schedule.time.split(":").map(Number);
for (let offset = 0; offset <= 7; offset++) {
const candidate = new Date(now);
candidate.setHours(0, 0, 0, 0);
candidate.setDate(candidate.getDate() - offset);
if (!schedule.days.includes(candidate.getDay())) continue;
candidate.setHours(hours, minutes, 0, 0);
if (candidate > now) continue;
if (!winner || candidate > winner.date) {
winner = {schedule, date: candidate};
}
break;
}
}
return winner;
}
makeOccurrenceKey(occurrence) {
return [
occurrence.schedule.id,
occurrence.date.getTime(),
occurrence.schedule.status,
occurrence.schedule.customStatusText,
occurrence.schedule.customStatusEmoji?.id || "",
occurrence.schedule.customStatusEmoji?.name || ""
].join(":");
}
async checkSchedule(force = false) {
if (this.isChecking) return;
const occurrence = this.getMostRecentSchedule(new Date());
if (!occurrence) return;
const key = this.makeOccurrenceKey(occurrence);
if (!force && key === this.lastAppliedKey) return;
this.isChecking = true;
try {
await this.applySchedule(occurrence.schedule);
this.lastAppliedKey = key;
if (this.settings.showNotifications) {
BdApi.UI.showToast(`Applied schedule: ${occurrence.schedule.name}.`, {type: "success"});
}
}
catch (error) {
BdApi.Logger.error(this.name, error);
const now = Date.now();
if (now - this.lastErrorToastAt > 60000) {
this.lastErrorToastAt = now;
BdApi.UI.showToast(`Could not apply the scheduled status: ${error?.message || error}`, {
type: "error",
timeout: 7000
});
}
}
finally {
this.isChecking = false;
}
}
async applySchedule(schedule) {
const emoji = this.normalizeEmoji(schedule.customStatusEmoji);
BDFDB.DiscordUtils.setSetting("status", "status", schedule.status);
BDFDB.DiscordUtils.setSetting("status", "customStatus", {
text: schedule.customStatusText || "",
expiresAtMs: "0",
emojiId: emoji?.id || "0",
emojiName: emoji?.name || ""
});
}
forceUpdateAll() {
this.loadSettings();
BDFDB.PatchUtils.forceAllUpdates(this);
}
};
})(window.BDFDB_Global.PluginUtils.buildPlugin(changeLog));
})();