revenge-plugins/plugins/message-logger/src/patches/fluxIntercept.ts

218 lines
8.4 KiB
TypeScript

import { findByProps, findByStoreName } from "@vendetta/metro";
import { FluxDispatcher } from "@vendetta/metro/common";
import { before } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { shouldIgnore } from "../lib/filters";
import { snapshotMessage } from "../lib/capture";
import { addLogEntry } from "../lib/store";
import type { FilterOptions } from "../lib/types";
const TAG = "[MessageLogger]";
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
const CLEANUP_FLAG = "__msgLoggerCleanup";
const ChannelMessages = findByProps("_channelMessages");
const UserStore = findByStoreName("UserStore");
// Ids currently kept "alive" as a faked MESSAGE_UPDATE instead of a real delete, mapped to the
// channel they belong to - tracked so onUnload can put them back the way it found them (dispatch a
// real delete for each) rather than leaving the client's own message cache holding fake state
// forever if the plugin gets disabled.
export const fakedMessages = new Map<string, string>();
// Every past content version for a message, oldest first, current content NOT included (rowStyling
// appends that itself from the live record). Same id-in-external-map pattern as fakedMessages -
// row.message.id survives into the row JSON, a custom property doesn't (confirmed live).
export const editHistory = new Map<string, string[]>();
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 limitsFromStorage(): { maxEntries: number; maxAgeDays: number; maxPerChannel: number } {
return {
maxEntries: numberOption(storage.options?.maxEntries, 2000),
maxAgeDays: numberOption(storage.options?.maxAgeDays, 30),
maxPerChannel: numberOption(storage.options?.maxEntriesPerChannel, 0),
};
}
function filterOptionsFromStorage(): FilterOptions {
const o = storage.options ?? {};
return {
ignoreBots: !!o.ignoreBots,
ignoreOwnMessages: !!o.ignoreOwnMessages,
ignoreOwnEdits: !!o.ignoreOwnEdits,
ignoreDMs: !!o.ignoreDMs,
ignoredChannelIds: o.ignoredChannelIds ?? "",
ignoredGuildIds: o.ignoredGuildIds ?? "",
ignoredUserIds: o.ignoredUserIds ?? "",
ignoredKeywords: o.ignoredKeywords ?? "",
};
}
function ignoredFor(message: any, isEdit: boolean): boolean {
const authorId = message?.author?.id;
return shouldIgnore(
{
channelId: message?.channel_id ?? message?.channelId,
guildId: message?.guild_id ?? message?.guildId,
authorId,
isBot: !!(message?.author?.bot ?? message?.author?.isNonUserBot?.()),
isOwnMessage: !!authorId && authorId === currentUserId(),
isEdit,
content: message?.content ?? "",
},
filterOptionsFromStorage(),
);
}
function hasLoggableContent(message: any): boolean {
return !!(
message?.content ||
message?.attachments?.length ||
message?.embeds?.length ||
message?.components?.length ||
(message?.stickerItems ?? message?.sticker_items)?.length ||
message?.poll
);
}
function fakeUpdateFor(original: any): any {
return { type: "MESSAGE_UPDATE", message: { ...original, [FAKE_DELETE_FLAG]: true } };
}
function captureAndMaybeKeep(original: any, channelId: string, id: string): any | undefined {
const EPHEMERAL = 64;
if (!original?.author?.id) return undefined; // never cached, or already evicted - nothing left to do
if ((original.flags & EPHEMERAL) === EPHEMERAL) return undefined; // an ephemeral dismiss, not a real delete
if (!hasLoggableContent(original)) return undefined;
if (!storage.options?.logDeleted) return undefined;
if (ignoredFor(original, false)) return undefined;
const keepInline = !!storage.options?.keepDeletedInline;
addLogEntry(snapshotMessage(original, "deleted", undefined, keepInline), limitsFromStorage());
if (!keepInline) return undefined;
fakedMessages.set(id, channelId);
return fakeUpdateFor(original);
}
function handleSingleDelete(event: any): any {
if (event[CLEANUP_FLAG]) return undefined; // our own revert dispatch on unload - pass through untouched
const channelId = event.channelId ?? event.channel_id;
const original = ChannelMessages?.get?.(channelId)?.get?.(event.id);
const fake = captureAndMaybeKeep(original, channelId, event.id);
return fake ? [fake] : undefined;
}
function handleBulkDelete(event: any): any {
const channelId = event.channelId ?? event.channel_id;
const ids: string[] = Array.isArray(event.ids) ? event.ids : [];
if (!ids.length) return undefined;
const kept: string[] = [];
const dropped: string[] = [];
for (const id of ids) {
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
const fake = captureAndMaybeKeep(original, channelId, id);
if (fake) kept.push(id);
else dropped.push(id);
}
if (!kept.length) return undefined; // nothing kept inline - let the real bulk delete proceed as-is
// Dispatching more actions from inside a dispatch-in-progress is a real re-entrancy risk (Flux
// implementations commonly disallow it) - deferred to a microtask, one dispatch per kept message,
// running after this bulk-delete's own dispatch has fully finished.
queueMicrotask(() => {
for (const id of kept) {
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
if (!original) continue;
try {
FluxDispatcher.dispatch(fakeUpdateFor(original));
} catch (e: any) {
console.error(TAG, "Deferred bulk-keep dispatch failed for", id, e?.message ?? e);
}
}
});
if (!dropped.length) {
// Every id in this bulk delete is being kept inline via the deferred dispatches above -
// replace the original bulk delete with a no-op so none of them get removed for real.
return [{ type: "MESSAGE_DELETE_BULK", channelId, ids: [] }];
}
return [{ type: "MESSAGE_DELETE_BULK", channelId, ids: dropped }];
}
function handleUpdate(event: any): void {
const updated = event?.message;
if (!updated?.id) return;
if (updated[FAKE_DELETE_FLAG]) return; // our own fake-delete-as-update, not a real edit
const channelId = updated.channel_id ?? updated.channelId;
const original = ChannelMessages?.get?.(channelId)?.get?.(updated.id);
if (!original) return;
const newContent = updated.content ?? original.content;
if (original.content === newContent) return; // MESSAGE_UPDATE also fires for non-content changes
if (!storage.options?.logEdited) return;
if (ignoredFor(original, true)) return;
const history = editHistory.get(updated.id) ?? [];
history.push(original.content ?? "");
editHistory.set(updated.id, history);
addLogEntry(snapshotMessage(original, "edited", newContent, false), limitsFromStorage());
}
export function patchFluxIntercept(cleanups: (() => void)[]): boolean {
if (!ChannelMessages) {
console.warn(TAG, "ChannelMessages module not found, skipping capture");
return false;
}
cleanups.push(
before("dispatch", FluxDispatcher, (args: any[]) => {
const event = args[0];
if (!event?.type) return;
try {
switch (event.type) {
case "MESSAGE_DELETE":
return handleSingleDelete(event);
case "MESSAGE_DELETE_BULK":
return handleBulkDelete(event);
case "MESSAGE_UPDATE":
handleUpdate(event);
return;
}
} catch (e: any) {
console.error(TAG, "Flux intercept error:", e?.message ?? e);
}
}),
);
return true;
}
/** Reverts every message currently faked-alive back to a real delete - called on unload. */
export function revertFakedMessages(): void {
for (const [id, channelId] of fakedMessages) {
try {
FluxDispatcher.dispatch({ type: "MESSAGE_DELETE", channelId, id, [CLEANUP_FLAG]: true });
} catch (e: any) {
console.error(TAG, "Failed to revert faked message", id, e?.message ?? e);
}
}
fakedMessages.clear();
editHistory.clear();
}