MessageLogger: restore plugin (RosePlugs crash fixed, unrelated to this)
This commit is contained in:
parent
2530cf8a25
commit
8eaba8d6dc
11 changed files with 946 additions and 0 deletions
11
plugins/message-logger/manifest.json
Normal file
11
plugins/message-logger/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "MessageLogger",
|
||||
"description": "Keeps deleted and edited messages visible in the chat, the way Equicord's MessageLogger does on desktop - plus a viewer for anything that couldn't be kept inline.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_message_edit"
|
||||
}
|
||||
}
|
||||
53
plugins/message-logger/src/index.ts
Normal file
53
plugins/message-logger/src/index.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import Settings from "./ui/Settings";
|
||||
import { patchFluxIntercept, revertFakedMessages } from "./patches/fluxIntercept";
|
||||
import { patchRowStyling } from "./patches/rowStyling";
|
||||
import { patchMessageRecord } from "./patches/messageRecord";
|
||||
|
||||
const TAG = "[MessageLogger]";
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
function initStorage() {
|
||||
storage.options ??= {};
|
||||
const o = storage.options;
|
||||
o.logDeleted ??= true;
|
||||
o.keepDeletedInline ??= true;
|
||||
o.logEdited ??= true;
|
||||
o.ignoreBots ??= false;
|
||||
o.ignoreOwnMessages ??= false;
|
||||
o.ignoreOwnEdits ??= false;
|
||||
o.ignoreDMs ??= false;
|
||||
o.ignoredChannelIds ??= "";
|
||||
o.ignoredGuildIds ??= "";
|
||||
o.ignoredUserIds ??= "";
|
||||
o.ignoredKeywords ??= "";
|
||||
o.maxEntries ??= "2000";
|
||||
o.maxEntriesPerChannel ??= "0";
|
||||
o.maxAgeDays ??= "30";
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad() {
|
||||
initStorage();
|
||||
console.log(TAG, "onLoad");
|
||||
|
||||
let patched = 0;
|
||||
if (patchFluxIntercept(cleanups)) patched++;
|
||||
if (patchRowStyling(cleanups)) patched++;
|
||||
if (patchMessageRecord(cleanups)) patched++;
|
||||
|
||||
console.log(TAG, `onLoad done - ${patched}/3 patches applied`);
|
||||
},
|
||||
onUnload() {
|
||||
console.log(TAG, "onUnload");
|
||||
try {
|
||||
revertFakedMessages();
|
||||
} catch (e: any) {
|
||||
logger.error("[MessageLogger] Failed to revert faked messages:", e?.message ?? e);
|
||||
}
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups.length = 0;
|
||||
},
|
||||
settings: Settings,
|
||||
};
|
||||
56
plugins/message-logger/src/lib/capture.ts
Normal file
56
plugins/message-logger/src/lib/capture.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { LoggedAttachment, LoggedMessage } from "./types";
|
||||
|
||||
// Every field read below is part of Discord's own public message object schema - checked in both
|
||||
// camelCase and snake_case since MessageStore's cached objects don't consistently normalize every
|
||||
// field the same way across message sources (gateway vs. REST-backfilled history).
|
||||
function attachmentsOf(message: any): LoggedAttachment[] {
|
||||
const attachments = message?.attachments;
|
||||
if (!Array.isArray(attachments)) return [];
|
||||
|
||||
return attachments.map((a: any) => ({
|
||||
id: String(a?.id ?? ""),
|
||||
filename: a?.filename ?? "attachment",
|
||||
url: a?.url ?? "",
|
||||
proxyUrl: a?.proxyURL ?? a?.proxy_url,
|
||||
contentType: a?.contentType ?? a?.content_type,
|
||||
width: a?.width,
|
||||
height: a?.height,
|
||||
}));
|
||||
}
|
||||
|
||||
function avatarUrlOf(author: any): string | undefined {
|
||||
if (!author?.id) return undefined;
|
||||
if (!author.avatar) {
|
||||
// Discord's default avatar set - index derived from the discriminator/id the same way
|
||||
// Discord's own client picks one for users with no avatar set.
|
||||
const index = author.discriminator && author.discriminator !== "0"
|
||||
? Number(author.discriminator) % 5
|
||||
: Number((BigInt(author.id) >> 22n) % 6n);
|
||||
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||
}
|
||||
const ext = author.avatar.startsWith("a_") ? "gif" : "png";
|
||||
return `https://cdn.discordapp.com/avatars/${author.id}/${author.avatar}.${ext}`;
|
||||
}
|
||||
|
||||
export function snapshotMessage(message: any, kind: LoggedMessage["kind"], newContent: string | undefined, keptInline: boolean): LoggedMessage {
|
||||
return {
|
||||
id: String(message?.id ?? ""),
|
||||
channelId: String(message?.channel_id ?? message?.channelId ?? ""),
|
||||
guildId: message?.guild_id ?? message?.guildId ?? undefined,
|
||||
authorId: message?.author?.id ?? "",
|
||||
authorUsername: message?.author?.username,
|
||||
authorDisplayName: message?.author?.globalName ?? message?.author?.global_name ?? undefined,
|
||||
authorAvatarUrl: avatarUrlOf(message?.author),
|
||||
content: message?.content ?? "",
|
||||
newContent,
|
||||
kind,
|
||||
loggedAt: Date.now(),
|
||||
timestamp: message?.timestamp ? String(message.timestamp) : undefined,
|
||||
attachments: attachmentsOf(message),
|
||||
embeds: Array.isArray(message?.embeds) ? message.embeds : [],
|
||||
components: Array.isArray(message?.components) ? message.components : [],
|
||||
stickerItems: message?.stickerItems ?? message?.sticker_items ?? [],
|
||||
poll: message?.poll,
|
||||
keptInline,
|
||||
};
|
||||
}
|
||||
40
plugins/message-logger/src/lib/filters.ts
Normal file
40
plugins/message-logger/src/lib/filters.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { FilterOptions } from "./types";
|
||||
|
||||
function parseIdList(raw: string | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
return new Set(raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function parseKeywordList(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
export interface FilterContext {
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
authorId?: string;
|
||||
isBot: boolean;
|
||||
isOwnMessage: boolean;
|
||||
isEdit: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** True if this message/edit/delete should be skipped entirely - never captured. */
|
||||
export function shouldIgnore(ctx: FilterContext, options: FilterOptions): boolean {
|
||||
if (options.ignoreBots && ctx.isBot) return true;
|
||||
if (options.ignoreOwnMessages && ctx.isOwnMessage) return true;
|
||||
if (options.ignoreOwnEdits && ctx.isEdit && ctx.isOwnMessage) return true;
|
||||
if (options.ignoreDMs && !ctx.guildId) return true;
|
||||
if (ctx.channelId && parseIdList(options.ignoredChannelIds).has(ctx.channelId)) return true;
|
||||
if (ctx.guildId && parseIdList(options.ignoredGuildIds).has(ctx.guildId)) return true;
|
||||
if (ctx.authorId && parseIdList(options.ignoredUserIds).has(ctx.authorId)) return true;
|
||||
|
||||
const keywords = parseKeywordList(options.ignoredKeywords);
|
||||
if (keywords.length > 0) {
|
||||
const haystack = ctx.content.toLowerCase();
|
||||
if (keywords.some((kw) => haystack.includes(kw))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
50
plugins/message-logger/src/lib/store.ts
Normal file
50
plugins/message-logger/src/lib/store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { storage } from "@vendetta/plugin";
|
||||
import type { LoggedMessage } from "./types";
|
||||
|
||||
const logStorage = storage as { log?: LoggedMessage[] };
|
||||
|
||||
export function getLog(): LoggedMessage[] {
|
||||
logStorage.log ??= [];
|
||||
return logStorage.log;
|
||||
}
|
||||
|
||||
function pruneLog(maxEntries: number, maxAgeDays: number, maxPerChannel: number): void {
|
||||
let log = getLog();
|
||||
|
||||
if (maxAgeDays > 0) {
|
||||
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
log = log.filter((e) => e.loggedAt >= cutoff);
|
||||
}
|
||||
|
||||
if (maxPerChannel > 0) {
|
||||
const perChannelCount = new Map<string, number>();
|
||||
const kept: LoggedMessage[] = [];
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const entry = log[i];
|
||||
const count = perChannelCount.get(entry.channelId) ?? 0;
|
||||
if (count >= maxPerChannel) continue;
|
||||
perChannelCount.set(entry.channelId, count + 1);
|
||||
kept.push(entry);
|
||||
}
|
||||
log = kept.reverse();
|
||||
}
|
||||
|
||||
if (maxEntries > 0 && log.length > maxEntries) {
|
||||
log = log.slice(log.length - maxEntries);
|
||||
}
|
||||
|
||||
logStorage.log = log;
|
||||
}
|
||||
|
||||
export function addLogEntry(entry: LoggedMessage, limits: { maxEntries: number; maxAgeDays: number; maxPerChannel: number }): void {
|
||||
getLog().push(entry);
|
||||
pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel);
|
||||
}
|
||||
|
||||
export function removeLogEntry(loggedAt: number): void {
|
||||
logStorage.log = getLog().filter((e) => e.loggedAt !== loggedAt);
|
||||
}
|
||||
|
||||
export function clearLog(): void {
|
||||
logStorage.log = [];
|
||||
}
|
||||
41
plugins/message-logger/src/lib/types.ts
Normal file
41
plugins/message-logger/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export interface LoggedAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
proxyUrl?: string;
|
||||
contentType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface LoggedMessage {
|
||||
id: string;
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
authorId: string;
|
||||
authorUsername?: string;
|
||||
authorDisplayName?: string;
|
||||
authorAvatarUrl?: string;
|
||||
content: string;
|
||||
newContent?: string;
|
||||
kind: "deleted" | "edited" | "bulk-deleted";
|
||||
loggedAt: number;
|
||||
timestamp?: string;
|
||||
attachments: LoggedAttachment[];
|
||||
embeds: any[];
|
||||
components: any[];
|
||||
stickerItems: any[];
|
||||
poll?: any;
|
||||
keptInline: boolean;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
ignoreBots: boolean;
|
||||
ignoreOwnMessages: boolean;
|
||||
ignoreOwnEdits: boolean;
|
||||
ignoreDMs: boolean;
|
||||
ignoredChannelIds: string;
|
||||
ignoredGuildIds: string;
|
||||
ignoredUserIds: string;
|
||||
ignoredKeywords: string;
|
||||
}
|
||||
208
plugins/message-logger/src/patches/fluxIntercept.ts
Normal file
208
plugins/message-logger/src/patches/fluxIntercept.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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>();
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
48
plugins/message-logger/src/patches/messageRecord.ts
Normal file
48
plugins/message-logger/src/patches/messageRecord.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { findByName, findByProps } from "@vendetta/metro";
|
||||
import { after, instead } from "@vendetta/patcher";
|
||||
|
||||
const TAG = "[MessageLogger]";
|
||||
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
|
||||
|
||||
// Discord wraps raw message JSON into a "MessageRecord" class before it ever reaches row
|
||||
// generation - a plain custom field set directly on the raw object doesn't reliably survive that
|
||||
// wrap. Confirmed against three independent existing plugins doing the same delete-as-update trick
|
||||
// (all thread a custom flag through this exact layer) - without this, the flag rowStyling.ts looks
|
||||
// for is gone by the time a row actually gets built.
|
||||
export function patchMessageRecord(cleanups: (() => void)[]): boolean {
|
||||
const MessageRecordUtils = findByProps("updateMessageRecord", "createMessageRecord");
|
||||
const MessageRecord = findByName("MessageRecord", false);
|
||||
|
||||
if (!MessageRecordUtils || !MessageRecord) {
|
||||
console.warn(TAG, "MessageRecord module(s) not found - deleted messages may not render styled");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
cleanups.push(
|
||||
after("createMessageRecord", MessageRecordUtils, ([message]: any[], record: any) => {
|
||||
if (message?.[FAKE_DELETE_FLAG]) record[FAKE_DELETE_FLAG] = true;
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
instead("updateMessageRecord", MessageRecordUtils, ([oldRecord, newRecord]: any[], orig: any) => {
|
||||
if (newRecord?.[FAKE_DELETE_FLAG]) {
|
||||
return MessageRecordUtils.createMessageRecord(newRecord, oldRecord?.reactions);
|
||||
}
|
||||
return orig.apply(MessageRecordUtils, [oldRecord, newRecord]);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
after("default", MessageRecord, ([props]: any[], record: any) => {
|
||||
if (props?.[FAKE_DELETE_FLAG]) record[FAKE_DELETE_FLAG] = true;
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.warn(TAG, "Failed to patch MessageRecord:", e?.message ?? e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
58
plugins/message-logger/src/patches/rowStyling.ts
Normal file
58
plugins/message-logger/src/patches/rowStyling.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { ReactNative } from "@vendetta/metro/common";
|
||||
import { after, before } from "@vendetta/patcher";
|
||||
import { waitFor } from "@shared/lib/waitFor";
|
||||
import { rawFindByName } from "@shared/lib/rawFind";
|
||||
|
||||
const TAG = "[MessageLogger]";
|
||||
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
|
||||
|
||||
function handleRow(row: any) {
|
||||
const message = row?.message;
|
||||
if (!message?.[FAKE_DELETE_FLAG]) return;
|
||||
|
||||
message.edited = "deleted";
|
||||
message.textColor = ReactNative.processColor("#E4404380");
|
||||
row.backgroundHighlight ??= {};
|
||||
row.backgroundHighlight.backgroundColor = ReactNative.processColor("#da373c22");
|
||||
row.backgroundHighlight.gutterColor = ReactNative.processColor("#da373cff");
|
||||
}
|
||||
|
||||
// Confirmed live: shared/lib/patchRows.ts's one-shot RowManager.prototype.generate lookup missed
|
||||
// entirely - the method doesn't exist yet at plugin onLoad time (not registered until Discord's own
|
||||
// code naturally requires that module), and a one-shot findByName never gets a second chance. Same
|
||||
// class of bug RoleColorEverywhere's own patches/rows.ts already had to work around with a raw,
|
||||
// uncached, retrying lookup instead of relying on the shared helper.
|
||||
export function patchRowStyling(cleanups: (() => void)[]): boolean {
|
||||
const { NativeModules } = ReactNative;
|
||||
const DCDChatManager = NativeModules?.DCDChatManager;
|
||||
|
||||
if (DCDChatManager?.updateRows) {
|
||||
cleanups.push(
|
||||
before("updateRows", DCDChatManager, (args: any[]) => {
|
||||
try {
|
||||
const rows = JSON.parse(args[1]);
|
||||
for (const row of rows) handleRow(row);
|
||||
args[1] = JSON.stringify(rows);
|
||||
} catch {
|
||||
// Leave args untouched - better to show unstyled rows than break message loading.
|
||||
}
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const handle = waitFor(
|
||||
() => {
|
||||
const RowManager = rawFindByName<any>("RowManager");
|
||||
return RowManager?.prototype?.generate ? RowManager : undefined;
|
||||
},
|
||||
(RowManager) => {
|
||||
cleanups.push(after("generate", RowManager.prototype, (_args: any[], row: any) => {
|
||||
try { handleRow(row); } catch (e: any) { console.warn(TAG, "Row styling failed:", e?.message ?? e); }
|
||||
}));
|
||||
},
|
||||
);
|
||||
cleanups.push(() => handle.cancel());
|
||||
|
||||
return true;
|
||||
}
|
||||
244
plugins/message-logger/src/ui/LogViewerPage.tsx
Normal file
244
plugins/message-logger/src/ui/LogViewerPage.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { findByName, findByProps } from "@vendetta/metro";
|
||||
import { React, ReactNative, clipboard } from "@vendetta/metro/common";
|
||||
import { TextInput } from "@shared/ui/table";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { resolveSemanticColorSafe } from "@shared/lib/color";
|
||||
import { getLog, removeLogEntry } from "../lib/store";
|
||||
import type { LoggedMessage } from "../lib/types";
|
||||
|
||||
// Same Navigation/Navigator/close-button lookup already proven working elsewhere in this repo
|
||||
// (ViewRaw, RoseUtils' own log viewer) - reused verbatim including the fallback chain.
|
||||
const Navigation = findByProps("push", "pushLazy", "pop");
|
||||
const modalCloseButton =
|
||||
findByProps("getRenderCloseButton")?.getRenderCloseButton ??
|
||||
findByProps("getHeaderCloseButton")?.getHeaderCloseButton;
|
||||
const Navigator = findByName("Navigator") ?? findByProps("Navigator")?.Navigator;
|
||||
|
||||
const { View, Text, ScrollView, TouchableOpacity } = ReactNative;
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function textColor(): string {
|
||||
return resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
}
|
||||
|
||||
function formatTime(value: number | string | undefined): string {
|
||||
if (!value) return "unknown time";
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(kind: LoggedMessage["kind"]): string {
|
||||
if (kind === "deleted") return "🗑 Deleted";
|
||||
if (kind === "bulk-deleted") return "🗑 Bulk deleted";
|
||||
return "✎ Edited";
|
||||
}
|
||||
|
||||
function entryMatches(entry: LoggedMessage, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const needle = query.toLowerCase();
|
||||
return (
|
||||
entry.content?.toLowerCase().includes(needle) ||
|
||||
entry.newContent?.toLowerCase().includes(needle) ||
|
||||
entry.authorUsername?.toLowerCase().includes(needle) ||
|
||||
entry.authorDisplayName?.toLowerCase().includes(needle) ||
|
||||
entry.channelId?.includes(needle) ||
|
||||
entry.guildId?.includes(needle) ||
|
||||
entry.authorId?.includes(needle) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function EntryDetail({ entry, onClose, onDeleted }: { entry: LoggedMessage; onClose: () => void; onDeleted: () => void }) {
|
||||
const color = textColor();
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 4, color }}>
|
||||
{kindLabel(entry.kind)} · {formatTime(entry.timestamp)} · logged {formatTime(entry.loggedAt)}
|
||||
{entry.keptInline ? " · kept in chat" : ""}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, fontWeight: "700", marginBottom: 8, color }}>
|
||||
{entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown author"}
|
||||
</Text>
|
||||
|
||||
{entry.kind === "edited" ? (
|
||||
<>
|
||||
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2, color }}>Before:</Text>
|
||||
<Text style={{ fontSize: 14, marginBottom: 10, color }} selectable>{entry.content || "(empty)"}</Text>
|
||||
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2, color }}>After:</Text>
|
||||
<Text style={{ fontSize: 14, marginBottom: 10, color }} selectable>{entry.newContent || "(empty)"}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={{ fontSize: 14, marginBottom: 10, color }} selectable>{entry.content || "(empty)"}</Text>
|
||||
)}
|
||||
|
||||
{entry.attachments.length > 0 && (
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2, color }}>
|
||||
Attachments ({entry.attachments.length}):
|
||||
</Text>
|
||||
{entry.attachments.map((a, i) => (
|
||||
<Text key={i} style={{ fontSize: 13, color }} selectable>
|
||||
{a.filename}{a.url ? ` - ${a.url}` : ""}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{entry.embeds.length > 0 && (
|
||||
<Text style={{ fontSize: 13, marginBottom: 10, color }}>
|
||||
{entry.embeds.length} embed{entry.embeds.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
)}
|
||||
{entry.components.length > 0 && (
|
||||
<Text style={{ fontSize: 13, marginBottom: 10, color }}>
|
||||
{entry.components.length} component{entry.components.length === 1 ? "" : "s"} (Components V2)
|
||||
</Text>
|
||||
)}
|
||||
{entry.stickerItems.length > 0 && (
|
||||
<Text style={{ fontSize: 13, marginBottom: 10, color }}>
|
||||
{entry.stickerItems.length} sticker{entry.stickerItems.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
)}
|
||||
{entry.poll && (
|
||||
<Text style={{ fontSize: 13, marginBottom: 10, color }}>Included a poll</Text>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 11, opacity: 0.5, marginBottom: 16, color }} selectable>
|
||||
Message ID: {entry.id}{"\n"}Channel ID: {entry.channelId}{entry.guildId ? `\nServer ID: ${entry.guildId}` : ""}
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
clipboard.setString(JSON.stringify(entry, null, 2));
|
||||
showToast("Copied entry as JSON", getAssetIDByName("ic_copy_24px"));
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color }}>Copy JSON</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeLogEntry(entry.loggedAt);
|
||||
showToast("Removed entry", undefined);
|
||||
onDeleted();
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: "#F23F42" }}>Delete entry</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color }}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function LogList() {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [kindFilter, setKindFilter] = React.useState<"all" | "deleted" | "edited">("all");
|
||||
const [selected, setSelected] = React.useState<LoggedMessage | null>(null);
|
||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE);
|
||||
const [, forceUpdate] = React.useReducer((n: number) => n + 1, 0);
|
||||
const color = textColor();
|
||||
|
||||
const all = getLog();
|
||||
const filtered = React.useMemo(() => {
|
||||
return all
|
||||
.filter((e) => (kindFilter === "all" ? true : kindFilter === "deleted" ? e.kind !== "edited" : e.kind === "edited"))
|
||||
.filter((e) => entryMatches(e, query.trim()))
|
||||
.slice()
|
||||
.reverse();
|
||||
}, [all.length, query, kindFilter]);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<EntryDetail
|
||||
entry={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onDeleted={() => {
|
||||
setSelected(null);
|
||||
forceUpdate();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<TextInput
|
||||
label="Search"
|
||||
placeholder="Search content, author, channel/server/user ID..."
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View style={{ flexDirection: "row", marginTop: 8, gap: 8 }}>
|
||||
{(["all", "deleted", "edited"] as const).map((k) => (
|
||||
<TouchableOpacity key={k} onPress={() => setKindFilter(k)}>
|
||||
<Text style={{ fontSize: 13, fontWeight: kindFilter === k ? "700" : "400", opacity: kindFilter === k ? 1 : 0.6, color }}>
|
||||
{k === "all" ? "All" : k === "deleted" ? "Deleted" : "Edited"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, opacity: 0.5, marginTop: 8, color }}>
|
||||
{filtered.length} of {all.length} total
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{filtered.slice(0, visibleCount).map((entry) => (
|
||||
<TouchableOpacity
|
||||
key={entry.loggedAt}
|
||||
onPress={() => setSelected(entry)}
|
||||
style={{ paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: "rgba(128,128,128,0.15)" }}
|
||||
>
|
||||
<Text style={{ fontSize: 12, opacity: 0.6, color }}>
|
||||
{kindLabel(entry.kind)} · {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown"} · {formatTime(entry.timestamp)}
|
||||
</Text>
|
||||
<Text numberOfLines={2} style={{ fontSize: 14, color }}>
|
||||
{(entry.kind === "edited" ? entry.newContent : entry.content) || "(no text content)"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Text style={{ padding: 16, opacity: 0.6, color }}>No log entries match.</Text>
|
||||
)}
|
||||
{visibleCount < filtered.length && (
|
||||
<TouchableOpacity onPress={() => setVisibleCount((n) => n + PAGE_SIZE)} style={{ padding: 16, alignItems: "center" }}>
|
||||
<Text style={{ fontWeight: "600", color }}>Load more ({filtered.length - visibleCount} remaining)</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function openLogViewer(): void {
|
||||
if (!Navigator || !Navigation) {
|
||||
showToast("Couldn't open the log viewer - Navigator not found", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.push(() => (
|
||||
<Navigator
|
||||
initialRouteName="MessageLoggerViewer"
|
||||
goBackOnBackPress
|
||||
screens={{
|
||||
MessageLoggerViewer: {
|
||||
title: "Message Log",
|
||||
headerLeft: modalCloseButton?.(() => Navigation.pop()),
|
||||
render: () => <LogList />,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
137
plugins/message-logger/src/ui/Settings.tsx
Normal file
137
plugins/message-logger/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { React, clipboard } from "@vendetta/metro/common";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
|
||||
import { getLog, clearLog } from "../lib/store";
|
||||
import openLogViewer from "./LogViewerPage";
|
||||
|
||||
export default function Settings() {
|
||||
useProxy(storage);
|
||||
const o = storage.options;
|
||||
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
Deleted messages are kept visible in the chat (styled like a deletion, the way
|
||||
Equicord's MessageLogger looks on desktop) whenever possible. Everything is also
|
||||
saved to a searchable log that survives Discord reloads, since keeping a message
|
||||
in the live chat only lasts until the app actually restarts and re-fetches history.
|
||||
</NoteBox>
|
||||
|
||||
<TableRowGroup title="What to capture">
|
||||
<TableSwitchRow
|
||||
label="Log deleted messages"
|
||||
value={!!o.logDeleted}
|
||||
onValueChange={(v: boolean) => { o.logDeleted = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Keep deleted messages in the chat"
|
||||
subLabel="Off: only saved to the log below, removed from chat immediately like normal"
|
||||
value={!!o.keepDeletedInline}
|
||||
onValueChange={(v: boolean) => { o.keepDeletedInline = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Log edited messages"
|
||||
value={!!o.logEdited}
|
||||
onValueChange={(v: boolean) => { o.logEdited = v; }}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Ignore">
|
||||
<TableSwitchRow
|
||||
label="Ignore bots"
|
||||
value={!!o.ignoreBots}
|
||||
onValueChange={(v: boolean) => { o.ignoreBots = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore your own messages"
|
||||
value={!!o.ignoreOwnMessages}
|
||||
onValueChange={(v: boolean) => { o.ignoreOwnMessages = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore your own edits"
|
||||
subLabel="Only applies if 'ignore your own messages' above is off"
|
||||
value={!!o.ignoreOwnEdits}
|
||||
onValueChange={(v: boolean) => { o.ignoreOwnEdits = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore DMs"
|
||||
value={!!o.ignoreDMs}
|
||||
onValueChange={(v: boolean) => { o.ignoreDMs = v; }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Ignored channel IDs"
|
||||
placeholder="comma-separated"
|
||||
value={o.ignoredChannelIds}
|
||||
onChange={(v: string) => { o.ignoredChannelIds = v; }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Ignored server IDs"
|
||||
placeholder="comma-separated"
|
||||
value={o.ignoredGuildIds}
|
||||
onChange={(v: string) => { o.ignoredGuildIds = v; }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Ignored user IDs"
|
||||
placeholder="comma-separated"
|
||||
value={o.ignoredUserIds}
|
||||
onChange={(v: string) => { o.ignoredUserIds = v; }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Ignored keywords"
|
||||
subLabel="Comma-separated - skips logging any message containing one of these"
|
||||
placeholder="e.g. password, secret"
|
||||
value={o.ignoredKeywords}
|
||||
onChange={(v: string) => { o.ignoredKeywords = v; }}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Log limits">
|
||||
<TextInput
|
||||
label="Max total entries"
|
||||
subLabel="Oldest entries are dropped once this is exceeded - 0 for unlimited"
|
||||
value={o.maxEntries}
|
||||
onChange={(v: string) => { o.maxEntries = v; }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Max entries per channel"
|
||||
subLabel="0 for unlimited"
|
||||
value={o.maxEntriesPerChannel}
|
||||
onChange={(v: string) => { o.maxEntriesPerChannel = v; }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Max age (days)"
|
||||
subLabel="0 for unlimited"
|
||||
value={o.maxAgeDays}
|
||||
onChange={(v: string) => { o.maxAgeDays = v; }}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Log">
|
||||
<TableRow label="View log" onPress={() => openLogViewer()} />
|
||||
<TableRow
|
||||
label="Copy entire log as JSON"
|
||||
onPress={() => {
|
||||
const log = getLog();
|
||||
clipboard.setString(JSON.stringify(log, null, 2));
|
||||
showToast(`Copied ${log.length} log entr${log.length === 1 ? "y" : "ies"} to clipboard`, undefined);
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<PrimaryButton
|
||||
label="Clear entire log"
|
||||
onPress={() => {
|
||||
const count = getLog().length;
|
||||
clearLog();
|
||||
showToast(`Cleared ${count} log entr${count === 1 ? "y" : "ies"}`, undefined);
|
||||
}}
|
||||
style={{ margin: 16, backgroundColor: "#F23F42" }}
|
||||
/>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue