/**
* @name ServerHistory
* @author Haxurus
* @description Keeps a persistent history of Discord servers and group DMs that become unavailable or from which the account is removed.
* @version 1.1.0
*/
module.exports = class ServerHistory {
constructor(meta) {
this.meta = meta;
this.api = new BdApi(meta.name);
this.defaults = {
notifications: true,
trackGroups: true,
trackUnavailable: true,
maxEntries: 1000,
reconciliationInterval: 15,
missingChecksRequired: 3
};
this.settings = {...this.defaults};
this.history = [];
this.knownGuilds = {};
this.knownGroups = {};
this.missingGuildChecks = new Map();
this.missingGroupChecks = new Map();
this.manualGuildActions = new Map();
this.manualGroupActions = new Map();
this.subscriptions = [];
this.storeUnsubscribers = [];
this.timers = new Set();
this.panelRoots = new Set();
this.startedAt = 0;
this.interval = null;
this.pendingStoreReconcile = null;
this.onGuildDelete = this.handleGuildDelete.bind(this);
this.onGuildCreate = this.handleGuildCreate.bind(this);
this.onGuildUnavailable = this.handleGuildUnavailable.bind(this);
this.onStoreChange = this.handleStoreChange.bind(this);
this.onChannelDelete = this.handleChannelDelete.bind(this);
this.onChannelCreate = this.handleChannelCreate.bind(this);
this.onChannelUpdate = this.handleChannelUpdate.bind(this);
this.onRecipientRemove = this.handleRecipientRemove.bind(this);
this.onRecipientAdd = this.handleRecipientAdd.bind(this);
this.onConnectionOpen = this.handleConnectionOpen.bind(this);
}
start() {
this.startedAt = Date.now();
this.loadData();
this.resolveModules();
this.addStyles();
if (!this.GuildStore || !this.ChannelStore) {
this.api.UI.showToast("Server History: unable to find the required Discord stores.", {type: "error"});
this.api.Logger.error("Missing Discord stores", {
GuildStore: Boolean(this.GuildStore),
ChannelStore: Boolean(this.ChannelStore)
});
return;
}
this.patchManualActions();
this.subscribeToEvents();
this.subscribeToStores();
this.seedCurrentEntities();
this.syncUnavailableGuildsFromStore();
this.scheduleReconciliation(8000);
this.scheduleReconciliation(20000);
const seconds = Math.max(10, Number(this.settings.reconciliationInterval) || 15);
this.interval = setInterval(() => this.reconcile(), seconds * 1000);
this.api.UI.showToast("Server History is active.", {type: "success"});
}
stop() {
for (const [event, handler] of this.subscriptions) {
try {
this.Dispatcher?.unsubscribe?.(event, handler);
}
catch (error) {
this.api.Logger.warn(`Unable to unsubscribe from ${event}`, error);
}
}
this.subscriptions = [];
for (const unsubscribe of this.storeUnsubscribers) {
try {
unsubscribe();
}
catch (error) {
this.api.Logger.warn("Unable to remove a Discord store listener", error);
}
}
this.storeUnsubscribers = [];
this.pendingStoreReconcile = null;
if (this.interval) clearInterval(this.interval);
this.interval = null;
for (const timer of this.timers) clearTimeout(timer);
this.timers.clear();
this.api.Patcher.unpatchAll();
this.api.DOM.removeStyle();
this.panelRoots.clear();
}
loadData() {
try {
this.settings = Object.assign({}, this.defaults, this.api.Data.load("settings") || {});
const storedHistory = this.api.Data.load("history");
this.history = Array.isArray(storedHistory) ? storedHistory : [];
this.knownGuilds = this.api.Data.load("knownGuilds") || {};
this.knownGroups = this.api.Data.load("knownGroups") || {};
}
catch (error) {
this.api.Logger.error("Error while loading data", error);
this.settings = {...this.defaults};
this.history = [];
this.knownGuilds = {};
this.knownGroups = {};
}
this.trimHistory();
}
resolveModules() {
const {Webpack} = BdApi;
const {Filters} = Webpack;
this.GuildStore = Webpack.getStore("GuildStore");
this.ChannelStore = Webpack.getStore("ChannelStore");
this.PrivateChannelSortStore = Webpack.getStore("PrivateChannelSortStore");
this.UserStore = Webpack.getStore("UserStore");
this.GatewayConnectionStore = Webpack.getStore("GatewayConnectionStore");
this.GuildAvailabilityStore = Webpack.getStore("GuildAvailabilityStore");
this.Dispatcher = Webpack.getModule(
Filters.byKeys("dispatch", "subscribe", "unsubscribe"),
{searchExports: true}
);
this.GuildActions = Webpack.getModule(
module => module && (
typeof module.leaveGuild === "function" ||
typeof module.deleteGuild === "function"
),
{searchExports: true}
);
this.GroupActions = Webpack.getModule(
module => module && (
typeof module.leaveGroupDM === "function" ||
typeof module.leaveGroupDm === "function"
),
{searchExports: true}
);
}
patchManualActions() {
if (this.GuildActions?.leaveGuild) {
this.api.Patcher.before(this.GuildActions, "leaveGuild", (_, args) => {
const guildId = this.extractId(args?.[0]);
if (guildId) this.manualGuildActions.set(guildId, {type: "manual_leave", at: Date.now()});
});
}
if (this.GuildActions?.deleteGuild) {
this.api.Patcher.before(this.GuildActions, "deleteGuild", (_, args) => {
const guildId = this.extractId(args?.[0]);
if (guildId) this.manualGuildActions.set(guildId, {type: "manual_delete", at: Date.now()});
});
}
const groupMethod = this.GroupActions?.leaveGroupDM
? "leaveGroupDM"
: this.GroupActions?.leaveGroupDm
? "leaveGroupDm"
: null;
if (groupMethod) {
this.api.Patcher.before(this.GroupActions, groupMethod, (_, args) => {
const channelId = this.extractId(args?.[0]);
if (channelId) this.manualGroupActions.set(channelId, {type: "manual_leave", at: Date.now()});
});
}
}
subscribeToEvents() {
if (!this.Dispatcher?.subscribe) {
this.api.Logger.warn("Discord dispatcher is unavailable: only periodic reconciliation will be used.");
return;
}
const events = [
["GUILD_DELETE", this.onGuildDelete],
["GUILD_CREATE", this.onGuildCreate],
["GUILD_UNAVAILABLE", this.onGuildUnavailable],
["CHANNEL_DELETE", this.onChannelDelete],
["CHANNEL_CREATE", this.onChannelCreate],
["CHANNEL_UPDATE", this.onChannelUpdate],
["CHANNEL_RECIPIENT_REMOVE", this.onRecipientRemove],
["CHANNEL_RECIPIENT_ADD", this.onRecipientAdd],
["CONNECTION_OPEN", this.onConnectionOpen],
["CONNECTION_RESUMED", this.onConnectionOpen],
["POST_CONNECTION_OPEN", this.onConnectionOpen]
];
for (const [event, handler] of events) {
try {
this.Dispatcher.subscribe(event, handler);
this.subscriptions.push([event, handler]);
}
catch (error) {
this.api.Logger.warn(`Unable to subscribe to ${event}`, error);
}
}
}
subscribeToStores() {
const stores = [
["GuildStore", this.GuildStore],
["GuildAvailabilityStore", this.GuildAvailabilityStore],
["ChannelStore", this.ChannelStore],
["PrivateChannelSortStore", this.PrivateChannelSortStore]
];
for (const [name, store] of stores) {
if (typeof store?.addChangeListener !== "function") continue;
try {
const returnedUnsubscribe = store.addChangeListener(this.onStoreChange);
if (typeof returnedUnsubscribe === "function") {
this.storeUnsubscribers.push(returnedUnsubscribe);
}
else if (typeof store.removeChangeListener === "function") {
this.storeUnsubscribers.push(() => store.removeChangeListener(this.onStoreChange));
}
}
catch (error) {
this.api.Logger.warn(`Unable to subscribe to ${name} changes`, error);
}
}
}
handleStoreChange() {
if (this.pendingStoreReconcile) {
clearTimeout(this.pendingStoreReconcile);
this.timers.delete(this.pendingStoreReconcile);
}
this.pendingStoreReconcile = this.schedule(() => {
this.pendingStoreReconcile = null;
this.syncUnavailableGuildsFromStore();
this.reconcile();
}, 350);
}
seedCurrentEntities() {
const currentGuilds = this.getCurrentGuilds();
const currentGroups = this.getCurrentGroups();
let guildsChanged = false;
let groupsChanged = false;
for (const guild of Object.values(currentGuilds)) {
const previous = this.knownGuilds[guild.id];
const next = this.guildRecordFromGuild(guild, previous?.status || "active");
if (!previous) {
this.knownGuilds[guild.id] = next;
guildsChanged = true;
}
else if (previous.name !== next.name || previous.icon !== next.icon || previous.ownerId !== next.ownerId) {
this.knownGuilds[guild.id] = {...previous, ...next, status: previous.status};
guildsChanged = true;
}
}
if (this.settings.trackGroups) {
for (const group of Object.values(currentGroups)) {
const previous = this.knownGroups[group.id];
const next = this.groupRecordFromChannel(group, previous?.status || "active");
if (!previous) {
this.knownGroups[group.id] = next;
groupsChanged = true;
}
else if (previous.name !== next.name || JSON.stringify(previous.recipientIds) !== JSON.stringify(next.recipientIds)) {
this.knownGroups[group.id] = {...previous, ...next, status: previous.status};
groupsChanged = true;
}
}
}
if (guildsChanged) this.saveKnownGuilds();
if (groupsChanged) this.saveKnownGroups();
}
handleGuildUnavailable(event) {
const guildId = this.extractGuildId(event);
if (!guildId) return;
const cachedGuild = this.getGuild(guildId);
const previous = this.knownGuilds[guildId] || (cachedGuild ? this.guildRecordFromGuild(cachedGuild) : null);
const name = previous?.name || cachedGuild?.name || `Server ${guildId}`;
if (this.settings.trackUnavailable && previous?.status !== "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType: "guild_unavailable",
confidence: "certain",
description: "Discord reported the server as temporarily unavailable."
});
}
this.knownGuilds[guildId] = {
...(previous || {id: guildId, name, firstSeenAt: Date.now()}),
id: guildId,
name,
status: "unavailable",
unavailableSince: previous?.unavailableSince || Date.now(),
updatedAt: Date.now()
};
this.missingGuildChecks.delete(guildId);
this.saveKnownGuilds();
}
handleGuildDelete(event) {
const guildId = this.extractGuildId(event);
if (!guildId) return;
const unavailable = Boolean(
event?.unavailable ??
event?.guild?.unavailable ??
this.isGuildUnavailable(guildId)
);
const cachedGuild = this.getGuild(guildId);
const previous = this.knownGuilds[guildId] || (cachedGuild ? this.guildRecordFromGuild(cachedGuild) : null);
const name = previous?.name || cachedGuild?.name || `Server ${guildId}`;
if (unavailable) {
this.handleGuildUnavailable({guildId});
return;
}
const manual = this.consumeRecentAction(this.manualGuildActions, guildId);
let eventType = "guild_removed_external";
let description = "The server was removed from your account list. Discord does not tell the client whether this was caused by a kick, ban, or server deletion.";
let confidence = "certain";
if (manual?.type === "manual_leave") {
eventType = "guild_left_manually";
description = "You voluntarily initiated leaving the server.";
}
else if (manual?.type === "manual_delete") {
eventType = "guild_deleted_manually";
description = "You voluntarily initiated deleting the server.";
}
if (previous?.status !== "removed" || manual) {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType,
confidence,
description
});
}
this.knownGuilds[guildId] = {
...(previous || {id: guildId, name}),
id: guildId,
name,
status: "removed",
removedAt: Date.now(),
removalType: eventType
};
this.missingGuildChecks.delete(guildId);
this.saveKnownGuilds();
}
handleGuildCreate(event) {
const guild = event?.guild || event;
const guildId = this.extractGuildId(event);
if (!guildId) return;
const actualGuild = guild?.name ? guild : this.getGuild(guildId);
const previous = this.knownGuilds[guildId];
const name = actualGuild?.name || previous?.name || `Server ${guildId}`;
const unavailable = Boolean(event?.unavailable ?? actualGuild?.unavailable);
if (unavailable) {
if (this.settings.trackUnavailable && previous?.status !== "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType: "guild_unavailable",
confidence: "certain",
description: "Discord loaded the server as temporarily unavailable."
});
}
this.knownGuilds[guildId] = this.guildRecordFromGuild(actualGuild || {id: guildId, name}, "unavailable");
this.saveKnownGuilds();
return;
}
if (previous?.status === "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType: "guild_restored",
confidence: "certain",
description: "The server is available again."
});
}
else if (previous?.status === "removed") {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType: "guild_rejoined",
confidence: "certain",
description: "The server appeared in the account again."
});
}
this.knownGuilds[guildId] = this.guildRecordFromGuild(actualGuild || {id: guildId, name}, "active");
this.missingGuildChecks.delete(guildId);
this.saveKnownGuilds();
}
handleChannelDelete(event) {
if (!this.settings.trackGroups) return;
const channel = event?.channel || event;
const channelId = this.extractChannelId(event);
if (!channelId) return;
const previous = this.knownGroups[channelId];
const actualChannel = this.isGroupChannel(channel) ? channel : null;
if (!previous && !actualChannel) return;
const group = actualChannel || this.getChannel(channelId);
const name = previous?.name || this.getGroupName(group) || `Group DM ${channelId}`;
const manual = this.consumeRecentAction(this.manualGroupActions, channelId);
const eventType = manual ? "group_left_manually" : "group_removed";
const description = manual
? "You voluntarily initiated leaving the group DM."
: "The group DM is no longer available. You may have been removed, or the group DM may have been deleted.";
if (previous?.status !== "removed" || manual) {
this.addHistory({
entityType: "group",
entityId: channelId,
name,
eventType,
confidence: manual ? "certain" : "likely",
description
});
}
this.knownGroups[channelId] = {
...(previous || {id: channelId, name}),
id: channelId,
name,
status: "removed",
removedAt: Date.now(),
removalType: eventType
};
this.missingGroupChecks.delete(channelId);
this.saveKnownGroups();
}
handleChannelCreate(event) {
if (!this.settings.trackGroups) return;
const channel = event?.channel || event;
if (!this.isGroupChannel(channel)) return;
const previous = this.knownGroups[channel.id];
const name = this.getGroupName(channel);
if (previous?.status === "removed") {
this.addHistory({
entityType: "group",
entityId: channel.id,
name,
eventType: "group_rejoined",
confidence: "certain",
description: "The group DM appeared in the account again."
});
}
this.knownGroups[channel.id] = this.groupRecordFromChannel(channel, "active");
this.missingGroupChecks.delete(channel.id);
this.saveKnownGroups();
}
handleChannelUpdate(event) {
if (!this.settings.trackGroups) return;
const channel = event?.channel || event;
if (!this.isGroupChannel(channel)) return;
const previous = this.knownGroups[channel.id];
this.knownGroups[channel.id] = this.groupRecordFromChannel(channel, previous?.status || "active");
this.saveKnownGroups();
}
handleRecipientRemove(event) {
if (!this.settings.trackGroups) return;
const channelId = this.extractChannelId(event);
const removedUserId = event?.user?.id || event?.userId || event?.user_id;
const currentUserId = this.UserStore?.getCurrentUser?.()?.id;
if (!channelId || !removedUserId || removedUserId !== currentUserId) return;
const previous = this.knownGroups[channelId];
const channel = this.getChannel(channelId);
const name = previous?.name || this.getGroupName(channel) || `Group DM ${channelId}`;
const manual = this.consumeRecentAction(this.manualGroupActions, channelId);
this.addHistory({
entityType: "group",
entityId: channelId,
name,
eventType: manual ? "group_left_manually" : "group_self_removed",
confidence: "certain",
description: manual
? "You voluntarily initiated leaving the group DM."
: "Your account was removed from the group DM."
});
this.knownGroups[channelId] = {
...(previous || {id: channelId, name}),
id: channelId,
name,
status: "removed",
removedAt: Date.now(),
removalType: manual ? "group_left_manually" : "group_self_removed"
};
this.saveKnownGroups();
}
handleRecipientAdd(event) {
if (!this.settings.trackGroups) return;
const channelId = this.extractChannelId(event);
if (!channelId) return;
this.schedule(() => {
const channel = this.getChannel(channelId);
if (this.isGroupChannel(channel)) this.handleChannelCreate({channel});
}, 500);
}
handleConnectionOpen() {
this.schedule(() => this.syncUnavailableGuildsFromStore(), 1500);
this.scheduleReconciliation(5000);
this.scheduleReconciliation(15000);
}
reconcile() {
if (!this.isConnected()) return;
if (Date.now() - this.startedAt < 7000) return;
this.reconcileGuilds();
if (this.settings.trackGroups) this.reconcileGroups();
this.refreshOpenPanels();
}
reconcileGuilds() {
const current = this.getCurrentGuilds();
const currentIds = new Set(Object.keys(current));
const unavailableIds = this.getUnavailableGuildIds();
const knownIds = Object.keys(this.knownGuilds);
if (knownIds.length > 0 && currentIds.size === 0 && unavailableIds.size === 0) return;
let changed = false;
for (const guildId of unavailableIds) {
if (currentIds.has(guildId)) continue;
const previous = this.knownGuilds[guildId];
const name = previous?.name || `Server ${guildId}`;
if (this.settings.trackUnavailable && previous?.status !== "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType: "guild_unavailable",
confidence: "certain",
description: "Discord's availability store indicates that the server is temporarily unavailable."
});
}
if (!previous || previous.status !== "unavailable") {
this.knownGuilds[guildId] = {
...(previous || {id: guildId, name, firstSeenAt: Date.now()}),
id: guildId,
name,
status: "unavailable",
unavailableSince: previous?.unavailableSince || Date.now(),
updatedAt: Date.now()
};
changed = true;
}
this.missingGuildChecks.delete(guildId);
}
for (const guild of Object.values(current)) {
const previous = this.knownGuilds[guild.id];
const unavailable = this.isGuildUnavailable(guild.id);
const status = unavailable ? "unavailable" : "active";
const name = guild.name || previous?.name || `Server ${guild.id}`;
if (unavailable && this.settings.trackUnavailable && previous?.status !== "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guild.id,
name,
eventType: "guild_unavailable",
confidence: "certain",
description: "Discord's internal availability check indicates that the server is temporarily unavailable."
});
}
else if (!unavailable && previous?.status === "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guild.id,
name,
eventType: "guild_restored",
confidence: "certain",
description: "The server is available again."
});
}
else if (!unavailable && previous?.status === "removed") {
this.addHistory({
entityType: "guild",
entityId: guild.id,
name,
eventType: "guild_rejoined",
confidence: "certain",
description: "The server appeared in the account again."
});
}
const next = this.guildRecordFromGuild(guild, status);
if (!previous || previous.status !== next.status || previous.name !== next.name || previous.icon !== next.icon) {
this.knownGuilds[guild.id] = next;
changed = true;
}
this.missingGuildChecks.delete(guild.id);
}
const requiredChecks = Math.max(2, Number(this.settings.missingChecksRequired) || 3);
for (const guildId of knownIds) {
const previous = this.knownGuilds[guildId];
if (currentIds.has(guildId) || unavailableIds.has(guildId) || previous.status === "removed") continue;
const checks = (this.missingGuildChecks.get(guildId) || 0) + 1;
this.missingGuildChecks.set(guildId, checks);
if (checks < requiredChecks) continue;
this.addHistory({
entityType: "guild",
entityId: guildId,
name: previous.name || `Server ${guildId}`,
eventType: "guild_missing_after_restart",
confidence: "likely",
description: "The server no longer appears after several checks while Discord is connected. The removal may have happened while the plugin or Discord was closed."
});
this.knownGuilds[guildId] = {
...previous,
status: "removed",
removedAt: Date.now(),
removalType: "guild_missing_after_restart"
};
this.missingGuildChecks.delete(guildId);
changed = true;
}
if (changed) this.saveKnownGuilds();
}
reconcileGroups() {
const current = this.getCurrentGroups();
const currentIds = new Set(Object.keys(current));
const knownIds = Object.keys(this.knownGroups);
if (knownIds.length > 0 && currentIds.size === 0) return;
let changed = false;
for (const group of Object.values(current)) {
const previous = this.knownGroups[group.id];
if (previous?.status === "removed") {
this.addHistory({
entityType: "group",
entityId: group.id,
name: this.getGroupName(group),
eventType: "group_rejoined",
confidence: "certain",
description: "The group DM appeared in the account again."
});
}
const next = this.groupRecordFromChannel(group, "active");
if (!previous || previous.status !== "active" || previous.name !== next.name || JSON.stringify(previous.recipientIds) !== JSON.stringify(next.recipientIds)) {
this.knownGroups[group.id] = next;
changed = true;
}
this.missingGroupChecks.delete(group.id);
}
const requiredChecks = Math.max(4, Number(this.settings.missingChecksRequired) + 1 || 4);
for (const groupId of knownIds) {
const previous = this.knownGroups[groupId];
if (currentIds.has(groupId) || previous.status === "removed") continue;
const checks = (this.missingGroupChecks.get(groupId) || 0) + 1;
this.missingGroupChecks.set(groupId, checks);
if (checks < requiredChecks) continue;
this.addHistory({
entityType: "group",
entityId: groupId,
name: previous.name || `Group DM ${groupId}`,
eventType: "group_missing_after_restart",
confidence: "possible",
description: "The group DM is no longer present in the private-message cache after several checks. It may have been deleted, or you may have been removed."
});
this.knownGroups[groupId] = {
...previous,
status: "removed",
removedAt: Date.now(),
removalType: "group_missing_after_restart"
};
this.missingGroupChecks.delete(groupId);
changed = true;
}
if (changed) this.saveKnownGroups();
}
getCurrentGuilds() {
try {
return this.GuildStore?.getGuilds?.() || {};
}
catch (error) {
this.api.Logger.warn("Error while reading servers", error);
return {};
}
}
getCurrentGroups() {
const found = {};
const addChannel = value => {
const channel = typeof value === "string" ? this.getChannel(value) : value;
if (this.isGroupChannel(channel)) found[channel.id] = channel;
};
try {
const mutable = this.ChannelStore?.getMutablePrivateChannels?.();
if (Array.isArray(mutable)) mutable.forEach(addChannel);
else if (mutable && typeof mutable === "object") Object.values(mutable).forEach(addChannel);
}
catch (error) {
this.api.Logger.debug("getMutablePrivateChannels is unavailable", error);
}
try {
const sorted = this.ChannelStore?.getSortedPrivateChannels?.();
if (Array.isArray(sorted)) sorted.forEach(addChannel);
else if (sorted && typeof sorted === "object") Object.values(sorted).forEach(addChannel);
}
catch (error) {
this.api.Logger.debug("getSortedPrivateChannels is unavailable", error);
}
try {
const ids = this.PrivateChannelSortStore?.getPrivateChannelIds?.();
if (Array.isArray(ids)) ids.forEach(addChannel);
}
catch (error) {
this.api.Logger.debug("getPrivateChannelIds is unavailable", error);
}
return found;
}
getGuild(id) {
try {
return this.GuildStore?.getGuild?.(id) || this.getCurrentGuilds()?.[id] || null;
}
catch (_) {
return null;
}
}
getChannel(id) {
try {
return this.ChannelStore?.getChannel?.(id) || null;
}
catch (_) {
return null;
}
}
getUnavailableGuildIds() {
const result = new Set();
try {
const raw = this.GuildAvailabilityStore?.unavailableGuilds;
if (Array.isArray(raw)) {
for (const id of raw) if (id != null) result.add(String(id));
}
else if (raw instanceof Set || raw instanceof Map) {
for (const value of raw instanceof Map ? raw.keys() : raw.values()) {
if (value != null) result.add(String(value));
}
}
else if (raw && typeof raw === "object") {
for (const [id, unavailable] of Object.entries(raw)) {
if (unavailable) result.add(String(id));
}
}
}
catch (error) {
this.api.Logger.debug("Unable to read unavailable server IDs", error);
}
return result;
}
syncUnavailableGuildsFromStore() {
const unavailableIds = this.getUnavailableGuildIds();
if (unavailableIds.size === 0) return;
let changed = false;
for (const guildId of unavailableIds) {
const previous = this.knownGuilds[guildId];
const cachedGuild = this.getGuild(guildId);
const name = previous?.name || cachedGuild?.name || `Server ${guildId}`;
if (this.settings.trackUnavailable && previous?.status !== "unavailable") {
this.addHistory({
entityType: "guild",
entityId: guildId,
name,
eventType: "guild_unavailable",
confidence: "certain",
description: "Discord's availability store indicates that the server is temporarily unavailable."
});
}
if (!previous || previous.status !== "unavailable") {
this.knownGuilds[guildId] = {
...(previous || {id: guildId, name, firstSeenAt: Date.now()}),
id: guildId,
name,
status: "unavailable",
unavailableSince: previous?.unavailableSince || Date.now(),
updatedAt: Date.now()
};
changed = true;
}
this.missingGuildChecks.delete(guildId);
}
if (changed) this.saveKnownGuilds();
}
isGuildUnavailable(id) {
try {
if (typeof this.GuildAvailabilityStore?.isUnavailable === "function") {
return Boolean(this.GuildAvailabilityStore.isUnavailable(id));
}
return this.getUnavailableGuildIds().has(String(id));
}
catch (_) {
return false;
}
}
isConnected() {
try {
if (typeof this.GatewayConnectionStore?.isConnected === "function") {
return Boolean(this.GatewayConnectionStore.isConnected());
}
}
catch (_) {}
return navigator.onLine !== false;
}
guildRecordFromGuild(guild, status = "active") {
return {
id: String(guild?.id || ""),
name: guild?.name || `Server ${guild?.id || "unknown"}`,
icon: guild?.icon || null,
ownerId: guild?.ownerId || guild?.owner_id || null,
status,
firstSeenAt: this.knownGuilds[guild?.id]?.firstSeenAt || Date.now(),
updatedAt: Date.now()
};
}
groupRecordFromChannel(channel, status = "active") {
return {
id: String(channel?.id || ""),
name: this.getGroupName(channel),
icon: channel?.icon || null,
recipientIds: this.getRecipientIds(channel),
status,
firstSeenAt: this.knownGroups[channel?.id]?.firstSeenAt || Date.now(),
updatedAt: Date.now()
};
}
getGroupName(channel) {
if (!channel) return "Unknown group DM";
if (typeof channel.name === "string" && channel.name.trim()) return channel.name.trim();
const users = [];
if (Array.isArray(channel.rawRecipients)) users.push(...channel.rawRecipients);
if (users.length === 0 && Array.isArray(channel.recipients)) {
for (const recipient of channel.recipients) {
if (typeof recipient === "object" && recipient) users.push(recipient);
else {
const user = this.UserStore?.getUser?.(recipient);
if (user) users.push(user);
}
}
}
const names = users
.map(user => user?.globalName || user?.displayName || user?.username || user?.id)
.filter(Boolean);
return names.length ? `Group DM with ${names.join(", ")}` : `Group DM ${channel.id}`;
}
getRecipientIds(channel) {
const ids = [];
if (Array.isArray(channel?.recipients)) {
for (const recipient of channel.recipients) ids.push(String(recipient?.id || recipient));
}
else if (Array.isArray(channel?.rawRecipients)) {
for (const recipient of channel.rawRecipients) if (recipient?.id) ids.push(String(recipient.id));
}
return [...new Set(ids)].sort();
}
isGroupChannel(channel) {
return Boolean(channel && (
channel.type === 3 ||
channel.type === "GROUP_DM" ||
channel.type === "GROUP_DM_CHANNEL"
));
}
extractId(value) {
if (!value) return null;
if (typeof value === "string" || typeof value === "number") return String(value);
return value.id || value.guildId || value.guild_id || value.channelId || value.channel_id || null;
}
extractGuildId(event) {
return String(event?.guildId || event?.guild_id || event?.guild?.id || event?.id || "") || null;
}
extractChannelId(event) {
return String(event?.channelId || event?.channel_id || event?.channel?.id || event?.id || "") || null;
}
consumeRecentAction(map, id) {
const action = map.get(id);
map.delete(id);
if (!action || Date.now() - action.at > 30000) return null;
return action;
}
addHistory(entry) {
const now = Date.now();
const duplicate = this.history.find(item =>
item.entityId === entry.entityId &&
item.eventType === entry.eventType &&
Math.abs(now - Number(item.timestamp)) < 5000
);
if (duplicate) return false;
const record = {
id: `${now}-${Math.random().toString(36).slice(2, 9)}`,
timestamp: now,
entityType: entry.entityType,
entityId: String(entry.entityId),
name: entry.name || "Unknown",
eventType: entry.eventType,
confidence: entry.confidence || "unknown",
description: entry.description || ""
};
this.history.unshift(record);
this.trimHistory();
this.saveHistory();
this.notify(record);
this.refreshOpenPanels();
return true;
}
trimHistory() {
const max = Math.min(10000, Math.max(50, Number(this.settings.maxEntries) || 1000));
if (this.history.length > max) this.history = this.history.slice(0, max);
}
notify(record) {
if (!this.settings.notifications) return;
const label = this.getEventLabel(record.eventType);
const toastType = record.eventType.includes("restored") || record.eventType.includes("rejoined")
? "success"
: record.eventType.includes("unavailable")
? "warning"
: "error";
this.api.UI.showToast(`${label}: ${record.name}`, {type: toastType, timeout: 6000});
}
saveSettings() {
this.api.Data.save("settings", this.settings);
}
saveHistory() {
this.api.Data.save("history", this.history);
}
saveKnownGuilds() {
this.api.Data.save("knownGuilds", this.knownGuilds);
}
saveKnownGroups() {
this.api.Data.save("knownGroups", this.knownGroups);
}
schedule(callback, delay) {
const timer = setTimeout(() => {
this.timers.delete(timer);
try {
callback();
}
catch (error) {
this.api.Logger.error("Error during a scheduled operation", error);
}
}, delay);
this.timers.add(timer);
return timer;
}
scheduleReconciliation(delay) {
this.schedule(() => this.reconcile(), delay);
}
getEventLabel(type) {
const labels = {
guild_unavailable: "Server unavailable",
guild_restored: "Server available again",
guild_left_manually: "Left server voluntarily",
guild_deleted_manually: "Server deleted voluntarily",
guild_removed_external: "Externally removed from server",
guild_missing_after_restart: "Server no longer found",
guild_rejoined: "Server rejoined",
group_left_manually: "Left group DM voluntarily",
group_self_removed: "Removed from group DM",
group_removed: "Group DM no longer available",
group_missing_after_restart: "Group DM no longer found",
group_rejoined: "Group DM rejoined"
};
return labels[type] || type;
}
getConfidenceLabel(confidence) {
const labels = {
certain: "Confirmed",
likely: "Likely",
possible: "Possible",
unknown: "Undetermined"
};
return labels[confidence] || confidence;
}
getSettingsPanel() {
const root = document.createElement("div");
root.className = "hsh-root";
this.panelRoots.add(root);
const header = document.createElement("div");
header.className = "hsh-header";
const title = document.createElement("h2");
title.textContent = "Server and Group DM History";
const subtitle = document.createElement("p");
subtitle.textContent = "Keeps a local record even when Discord removes a server or group DM from the interface.";
header.append(title, subtitle);
const stats = document.createElement("div");
stats.className = "hsh-stats";
const settingsCard = document.createElement("section");
settingsCard.className = "hsh-card";
const settingsTitle = document.createElement("h3");
settingsTitle.textContent = "Settings";
settingsCard.append(settingsTitle);
settingsCard.append(
this.createToggle("Notifications", "Show a notification when an event is recorded.", "notifications"),
this.createToggle("Track group DMs", "Also record private group DMs and participant removals.", "trackGroups"),
this.createToggle("Track unavailability", "Record when Discord reports a server as temporarily unavailable.", "trackUnavailable")
);
const maxRow = document.createElement("label");
maxRow.className = "hsh-setting-row";
const maxText = document.createElement("div");
maxText.innerHTML = "Maximum number of eventsFrom 50 to 10,000 entries.";
const maxInput = document.createElement("input");
maxInput.className = "hsh-number";
maxInput.type = "number";
maxInput.min = "50";
maxInput.max = "10000";
maxInput.step = "50";
maxInput.value = String(this.settings.maxEntries);
maxInput.addEventListener("change", () => {
this.settings.maxEntries = Math.min(10000, Math.max(50, Number(maxInput.value) || 1000));
maxInput.value = String(this.settings.maxEntries);
this.trimHistory();
this.saveSettings();
this.saveHistory();
this.refreshOpenPanels();
});
maxRow.append(maxText, maxInput);
settingsCard.append(maxRow);
const historyCard = document.createElement("section");
historyCard.className = "hsh-card";
const historyHeader = document.createElement("div");
historyHeader.className = "hsh-history-header";
const historyTitle = document.createElement("h3");
historyTitle.textContent = "History";
historyHeader.append(historyTitle);
const toolbar = document.createElement("div");
toolbar.className = "hsh-toolbar";
const search = document.createElement("input");
search.className = "hsh-input";
search.type = "search";
search.placeholder = "Search by name or ID...";
const filter = document.createElement("select");
filter.className = "hsh-select";
for (const [value, text] of [["all", "All"], ["guild", "Server"], ["group", "Group DMs"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
filter.append(option);
}
const exportJson = this.createButton("Export JSON", "secondary", () => this.exportHistory("json"));
const exportCsv = this.createButton("Export CSV", "secondary", () => this.exportHistory("csv"));
const clear = this.createButton("Clear", "danger", () => this.confirmClearHistory());
toolbar.append(search, filter, exportJson, exportCsv, clear);
const list = document.createElement("div");
list.className = "hsh-history-list";
const info = document.createElement("div");
info.className = "hsh-info";
info.textContent = "Note: Discord can reliably distinguish an unavailable server from a server removed from the account, but it does not tell the client whether the removal was caused by a kick, ban, or server deletion. The plugin can identify voluntary departures performed while it is active.";
historyCard.append(historyHeader, toolbar, list, info);
root.append(header, stats, settingsCard, historyCard);
const render = () => {
this.renderStats(stats);
this.renderHistory(list, search.value, filter.value);
};
root._hshRender = render;
search.addEventListener("input", render);
filter.addEventListener("change", render);
render();
return root;
}
createToggle(title, note, settingKey) {
const row = document.createElement("label");
row.className = "hsh-setting-row";
const text = document.createElement("div");
const strong = document.createElement("strong");
strong.textContent = title;
const span = document.createElement("span");
span.textContent = note;
text.append(strong, span);
const input = document.createElement("input");
input.type = "checkbox";
input.className = "hsh-toggle-input";
input.checked = Boolean(this.settings[settingKey]);
const visual = document.createElement("span");
visual.className = "hsh-toggle";
input.addEventListener("change", () => {
this.settings[settingKey] = input.checked;
this.saveSettings();
if (settingKey === "trackGroups" && input.checked) this.seedCurrentEntities();
});
const control = document.createElement("span");
control.className = "hsh-toggle-wrap";
control.append(input, visual);
row.append(text, control);
return row;
}
createButton(text, variant, onClick) {
const button = document.createElement("button");
button.type = "button";
button.className = `hsh-button hsh-button-${variant}`;
button.textContent = text;
button.addEventListener("click", onClick);
return button;
}
renderStats(container) {
container.replaceChildren();
const activeGuilds = Object.values(this.knownGuilds).filter(item => item.status === "active").length;
const unavailableGuilds = Object.values(this.knownGuilds).filter(item => item.status === "unavailable").length;
const activeGroups = Object.values(this.knownGroups).filter(item => item.status === "active").length;
for (const [value, label] of [
[activeGuilds, "Active servers"],
[unavailableGuilds, "Unavailable"],
[activeGroups, "Known group DMs"],
[this.history.length, "Saved events"]
]) {
const card = document.createElement("div");
card.className = "hsh-stat";
const number = document.createElement("strong");
number.textContent = String(value);
const text = document.createElement("span");
text.textContent = label;
card.append(number, text);
container.append(card);
}
}
renderHistory(container, query = "", filter = "all") {
container.replaceChildren();
const normalized = query.trim().toLocaleLowerCase("en-US");
const items = this.history.filter(item => {
if (filter !== "all" && item.entityType !== filter) return false;
if (!normalized) return true;
return `${item.name} ${item.entityId} ${this.getEventLabel(item.eventType)}`
.toLocaleLowerCase("en-US")
.includes(normalized);
});
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "hsh-empty";
empty.textContent = this.history.length ? "No events match the current filters." : "No events have been recorded.";
container.append(empty);
return;
}
for (const item of items) {
const row = document.createElement("article");
row.className = `hsh-event hsh-event-${item.entityType}`;
const top = document.createElement("div");
top.className = "hsh-event-top";
const name = document.createElement("strong");
name.textContent = item.name;
const badge = document.createElement("span");
badge.className = `hsh-badge hsh-badge-${item.confidence}`;
badge.textContent = this.getConfidenceLabel(item.confidence);
top.append(name, badge);
const eventLabel = document.createElement("div");
eventLabel.className = "hsh-event-label";
eventLabel.textContent = this.getEventLabel(item.eventType);
const description = document.createElement("p");
description.textContent = item.description;
const meta = document.createElement("div");
meta.className = "hsh-event-meta";
const date = new Date(Number(item.timestamp));
meta.textContent = `${date.toLocaleString("en-US")} - ID: ${item.entityId}`;
row.append(top, eventLabel, description, meta);
container.append(row);
}
}
refreshOpenPanels() {
for (const root of [...this.panelRoots]) {
if (!root.isConnected) {
this.panelRoots.delete(root);
continue;
}
try {
root._hshRender?.();
}
catch (error) {
this.api.Logger.warn("Error while refreshing the panel", error);
}
}
}
confirmClearHistory() {
this.api.UI.showConfirmationModal(
"Clear history?",
"This removes all saved events while preserving the list of known servers and group DMs.",
{
confirmText: "Clear",
cancelText: "Cancel",
danger: true,
onConfirm: () => {
this.history = [];
this.saveHistory();
this.refreshOpenPanels();
this.api.UI.showToast("History cleared.", {type: "success"});
}
}
);
}
async exportHistory(format) {
try {
const extension = format === "csv" ? "csv" : "json";
const now = new Date().toISOString().replace(/[:.]/g, "-");
const defaultPath = `discord-server-history-${now}.${extension}`;
const result = await this.api.UI.openDialog({
mode: "save",
title: `Export history as ${extension.toUpperCase()}`,
defaultPath,
filters: [{name: extension.toUpperCase(), extensions: [extension]}],
showOverwriteConfirmation: true
});
if (result?.canceled || result?.cancelled || !result?.filePath) return;
const content = format === "csv" ? this.toCsv(this.history) : JSON.stringify(this.history, null, 2);
require("fs").writeFileSync(result.filePath, content, "utf8");
this.api.UI.showToast("History exported.", {type: "success"});
}
catch (error) {
this.api.Logger.error("Error while exporting history", error);
this.api.UI.showToast("Export failed.", {type: "error"});
}
}
toCsv(items) {
const escape = value => `"${String(value ?? "").replace(/"/g, '""')}"`;
const rows = [["date", "entity_type", "id", "name", "event", "confidence", "description"]];
for (const item of items) {
rows.push([
new Date(Number(item.timestamp)).toISOString(),
item.entityType,
item.entityId,
item.name,
item.eventType,
item.confidence,
item.description
]);
}
return rows.map(row => row.map(escape).join(",")).join("\r\n");
}
addStyles() {
this.api.DOM.addStyle(`
.hsh-root {
color: var(--text-normal);
padding-bottom: 32px;
}
.hsh-header h2, .hsh-card h3 {
margin: 0;
color: var(--header-primary);
}
.hsh-header p {
margin: 6px 0 0;
color: var(--text-muted);
line-height: 1.45;
}
.hsh-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 18px 0;
}
.hsh-stat, .hsh-card {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
border-radius: 10px;
}
.hsh-stat {
padding: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hsh-stat strong {
font-size: 22px;
color: var(--header-primary);
}
.hsh-stat span {
color: var(--text-muted);
font-size: 12px;
}
.hsh-card {
padding: 16px;
margin-bottom: 14px;
}
.hsh-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px 0;
border-bottom: 1px solid var(--background-modifier-accent);
cursor: pointer;
}
.hsh-setting-row:last-child {
border-bottom: 0;
}
.hsh-setting-row > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.hsh-setting-row strong {
color: var(--header-primary);
}
.hsh-setting-row span {
color: var(--text-muted);
font-size: 13px;
}
.hsh-toggle-wrap {
position: relative;
flex: 0 0 auto;
}
.hsh-toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.hsh-toggle {
display: block;
width: 40px;
height: 24px;
border-radius: 999px;
background: var(--background-modifier-selected);
transition: 150ms ease;
}
.hsh-toggle::after {
content: "";
display: block;
width: 18px;
height: 18px;
margin: 3px;
border-radius: 50%;
background: white;
transition: 150ms ease;
}
.hsh-toggle-input:checked + .hsh-toggle {
background: var(--brand-500);
}
.hsh-toggle-input:checked + .hsh-toggle::after {
transform: translateX(16px);
}
.hsh-number, .hsh-input, .hsh-select {
box-sizing: border-box;
border: 1px solid var(--input-border, var(--background-modifier-accent));
border-radius: 6px;
background: var(--input-background, var(--background-tertiary));
color: var(--text-normal);
min-height: 36px;
padding: 8px 10px;
outline: none;
}
.hsh-number {
width: 110px;
}
.hsh-input:focus, .hsh-select:focus, .hsh-number:focus {
border-color: var(--brand-500);
}
.hsh-history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.hsh-toolbar {
display: grid;
grid-template-columns: minmax(160px, 1fr) 120px auto auto auto;
gap: 8px;
margin-bottom: 12px;
}
.hsh-button {
border: 0;
border-radius: 6px;
min-height: 36px;
padding: 8px 12px;
color: white;
cursor: pointer;
font-weight: 600;
}
.hsh-button-secondary {
background: var(--brand-500);
}
.hsh-button-secondary:hover {
background: var(--brand-560);
}
.hsh-button-danger {
background: var(--status-danger);
}
.hsh-history-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 520px;
overflow: auto;
padding-right: 4px;
}
.hsh-event {
border: 1px solid var(--background-modifier-accent);
border-left: 4px solid var(--brand-500);
border-radius: 8px;
background: var(--background-primary);
padding: 12px;
}
.hsh-event-group {
border-left-color: var(--status-positive);
}
.hsh-event-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hsh-event-top strong {
color: var(--header-primary);
overflow-wrap: anywhere;
}
.hsh-event-label {
margin-top: 4px;
color: var(--text-link);
font-weight: 600;
font-size: 13px;
}
.hsh-event p {
margin: 7px 0;
color: var(--text-normal);
line-height: 1.4;
}
.hsh-event-meta {
color: var(--text-muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.hsh-badge {
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
color: white;
background: var(--background-modifier-selected);
}
.hsh-badge-certain { background: var(--status-positive); }
.hsh-badge-likely { background: var(--status-warning); color: var(--black-500, #111); }
.hsh-badge-possible, .hsh-badge-unknown { background: var(--status-danger); }
.hsh-empty {
padding: 32px 12px;
text-align: center;
color: var(--text-muted);
border: 1px dashed var(--background-modifier-accent);
border-radius: 8px;
}
.hsh-info {
margin-top: 12px;
padding: 11px 12px;
border-radius: 8px;
background: var(--background-secondary-alt);
color: var(--text-muted);
font-size: 12px;
line-height: 1.45;
}
@media (max-width: 850px) {
.hsh-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hsh-toolbar { grid-template-columns: 1fr 1fr; }
.hsh-toolbar .hsh-input { grid-column: 1 / -1; }
}
`);
}
};