revenge-plugins/plugins/rose-utils/src/modules/MessageLogger/index.ts

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() {},
},
});