246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
import { findByStoreName } from "@vendetta/metro";
|
|
import { FluxDispatcher, clipboard } from "@vendetta/metro/common";
|
|
import { before } from "@vendetta/patcher";
|
|
import { showToast } from "@vendetta/ui/toasts";
|
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
|
import { Module, ModuleCategory } from "../../lib/Module";
|
|
import { snapshotMessage } from "./lib/capture";
|
|
import { shouldIgnore, type FilterOptions } from "./lib/filters";
|
|
import { addLogEntry, clearLog, getLog } from "./lib/store";
|
|
import openLogViewer from "./ui/LogViewerPage";
|
|
|
|
// Patches FluxDispatcher.dispatch itself rather than subscribing normally, since a subscribe
|
|
// callback has no guarantee of running before MessageStore's own - dispatch runs before any
|
|
// listener, while the cache still holds the pre-delete/pre-edit message.
|
|
const MessageStore = findByStoreName("MessageStore");
|
|
const UserStore = findByStoreName("UserStore");
|
|
|
|
function currentUserId(): string | undefined {
|
|
return UserStore?.getCurrentUser?.()?.id;
|
|
}
|
|
|
|
function numberOption(raw: string | undefined, fallback: number): number {
|
|
const n = parseInt(raw ?? "", 10);
|
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
}
|
|
|
|
function handleDelete(action: any, options: FilterOptions & { notifyOnDelete: boolean }, limits: any) {
|
|
const message = MessageStore?.getMessage?.(action.channelId, action.id);
|
|
if (!message) return; // not cached (never loaded, or already evicted) - nothing left to log
|
|
|
|
logIfAllowed(message, "deleted", undefined, options, limits);
|
|
}
|
|
|
|
function handleBulkDelete(action: any, options: FilterOptions & { notifyOnDelete: boolean }, limits: any) {
|
|
const ids: string[] = action?.ids ?? [];
|
|
for (const id of ids) {
|
|
const message = MessageStore?.getMessage?.(action.channelId, id);
|
|
if (message) logIfAllowed(message, "deleted", undefined, options, limits);
|
|
}
|
|
}
|
|
|
|
function handleUpdate(action: any, options: FilterOptions & { notifyOnDelete: boolean }, limits: any) {
|
|
const updated = action?.message;
|
|
if (!updated?.id || (!updated?.channel_id && !updated?.channelId)) return;
|
|
|
|
const channelId = updated.channel_id ?? updated.channelId;
|
|
const oldMessage = MessageStore?.getMessage?.(channelId, updated.id);
|
|
if (!oldMessage) return;
|
|
|
|
// MESSAGE_UPDATE also fires for things that aren't content edits at all (embeds finishing
|
|
// loading after a link unfurls, pinned state, etc) - only log when the actual text changed.
|
|
const newContent = updated.content ?? oldMessage.content;
|
|
if (oldMessage.content === newContent) return;
|
|
|
|
logIfAllowed(oldMessage, "edited", newContent, options, limits);
|
|
}
|
|
|
|
function logIfAllowed(
|
|
message: any,
|
|
kind: "deleted" | "edited",
|
|
newContent: string | undefined,
|
|
options: FilterOptions & { notifyOnDelete: boolean },
|
|
limits: { maxEntries: number; maxAgeDays: number; maxPerChannel: number },
|
|
) {
|
|
const authorId = message?.author?.id;
|
|
const guildId = message?.guild_id ?? message?.guildId;
|
|
|
|
const ignored = shouldIgnore(
|
|
{
|
|
channelId: message?.channel_id ?? message?.channelId,
|
|
guildId,
|
|
authorId,
|
|
isBot: !!(message?.author?.bot ?? message?.author?.isNonUserBot?.()),
|
|
isOwnMessage: !!authorId && authorId === currentUserId(),
|
|
content: message?.content ?? "",
|
|
},
|
|
options,
|
|
);
|
|
if (ignored) return;
|
|
|
|
const entry = snapshotMessage(message, kind, newContent);
|
|
addLogEntry(entry, limits);
|
|
|
|
if (options.notifyOnDelete) {
|
|
const who = entry.authorDisplayName || entry.authorUsername || "Someone";
|
|
const preview = (kind === "deleted" ? entry.content : entry.newContent) || "(no text content)";
|
|
showToast(
|
|
`${kind === "deleted" ? "Deleted" : "Edited"}: ${who} - ${preview.slice(0, 60)}`,
|
|
getAssetIDByName("ic_message_edit") ?? undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
export default new Module({
|
|
id: "message-logger",
|
|
label: "Message Logger",
|
|
meta: {
|
|
sublabel: "Logs deleted and edited messages, persisted on-device across restarts - searchable viewer, ignore lists, and configurable limits",
|
|
category: ModuleCategory.Useful,
|
|
},
|
|
settings: {
|
|
logDeleted: {
|
|
label: "Log deleted messages",
|
|
type: "toggle",
|
|
default: true,
|
|
},
|
|
logEdited: {
|
|
label: "Log edited messages",
|
|
type: "toggle",
|
|
default: true,
|
|
},
|
|
notifyOnDelete: {
|
|
label: "Toast on capture",
|
|
subLabel: "Shows a toast every time something gets logged - can be noisy in busy servers",
|
|
type: "toggle",
|
|
default: false,
|
|
},
|
|
ignoreBots: {
|
|
label: "Ignore bots",
|
|
type: "toggle",
|
|
default: false,
|
|
},
|
|
ignoreOwnMessages: {
|
|
label: "Ignore your own messages",
|
|
type: "toggle",
|
|
default: true,
|
|
},
|
|
ignoreDMs: {
|
|
label: "Ignore DMs",
|
|
type: "toggle",
|
|
default: false,
|
|
},
|
|
onlyLogChannelIds: {
|
|
label: "Only log these channel IDs",
|
|
subLabel: "Comma-separated - if set, every other filter below is ignored and ONLY these channels are logged",
|
|
type: "text",
|
|
default: "",
|
|
placeholder: "e.g. 123456789012345678",
|
|
},
|
|
ignoredChannelIds: {
|
|
label: "Ignored channel IDs",
|
|
type: "text",
|
|
default: "",
|
|
placeholder: "comma-separated",
|
|
},
|
|
ignoredGuildIds: {
|
|
label: "Ignored server IDs",
|
|
type: "text",
|
|
default: "",
|
|
placeholder: "comma-separated",
|
|
},
|
|
ignoredUserIds: {
|
|
label: "Ignored user IDs",
|
|
type: "text",
|
|
default: "",
|
|
placeholder: "comma-separated",
|
|
},
|
|
ignoredKeywords: {
|
|
label: "Ignored keywords",
|
|
subLabel: "Comma-separated - skip logging any message containing one of these (case-insensitive)",
|
|
type: "text",
|
|
default: "",
|
|
placeholder: "e.g. password, secret",
|
|
},
|
|
maxEntries: {
|
|
label: "Max total entries",
|
|
subLabel: "Oldest entries are dropped once this is exceeded - 0 for unlimited",
|
|
type: "text",
|
|
default: "2000",
|
|
},
|
|
maxEntriesPerChannel: {
|
|
label: "Max entries per channel",
|
|
subLabel: "0 for unlimited (only the total cap above applies)",
|
|
type: "text",
|
|
default: "0",
|
|
},
|
|
maxAgeDays: {
|
|
label: "Max age (days)",
|
|
subLabel: "Entries older than this are dropped - 0 for unlimited",
|
|
type: "text",
|
|
default: "30",
|
|
},
|
|
viewLog: {
|
|
label: "View log",
|
|
type: "button",
|
|
action() {
|
|
openLogViewer();
|
|
},
|
|
},
|
|
copyLogAsJson: {
|
|
label: "Copy entire log as JSON",
|
|
type: "button",
|
|
action() {
|
|
const log = getLog();
|
|
clipboard.setString(JSON.stringify(log, null, 2));
|
|
showToast(`Copied ${log.length} log entr${log.length === 1 ? "y" : "ies"} to clipboard`, undefined);
|
|
},
|
|
},
|
|
clearLogButton: {
|
|
label: "Clear entire log",
|
|
subLabel: "This can't be undone",
|
|
type: "button",
|
|
action() {
|
|
const count = getLog().length;
|
|
clearLog();
|
|
showToast(`Cleared ${count} log entr${count === 1 ? "y" : "ies"}`, undefined);
|
|
},
|
|
},
|
|
},
|
|
handlers: {
|
|
onStart() {
|
|
if (!MessageStore) return;
|
|
|
|
this.patches.add(
|
|
before("dispatch", FluxDispatcher, (args: any[]) => {
|
|
const action = args[0];
|
|
if (!action?.type) return;
|
|
|
|
try {
|
|
const options = this.storage.options as unknown as FilterOptions & { notifyOnDelete: boolean };
|
|
const limits = {
|
|
maxEntries: numberOption(this.storage.options.maxEntries, 2000),
|
|
maxAgeDays: numberOption(this.storage.options.maxAgeDays, 30),
|
|
maxPerChannel: numberOption(this.storage.options.maxEntriesPerChannel, 0),
|
|
};
|
|
|
|
switch (action.type) {
|
|
case "MESSAGE_DELETE":
|
|
if (this.storage.options.logDeleted) handleDelete(action, options, limits);
|
|
break;
|
|
case "MESSAGE_DELETE_BULK":
|
|
if (this.storage.options.logDeleted) handleBulkDelete(action, options, limits);
|
|
break;
|
|
case "MESSAGE_UPDATE":
|
|
if (this.storage.options.logEdited) handleUpdate(action, options, limits);
|
|
break;
|
|
}
|
|
} catch {
|
|
// A broken capture should never block the actual dispatch from proceeding.
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
onStop() {},
|
|
},
|
|
});
|