Files
Veskslop-t/src/renderer/puppet/actions.ts
T

360 lines
14 KiB
TypeScript

/*
* Veskslop core actions — the validated capability surface.
*
* Everything the external daemon can ask the client to do lives here. Inputs
* are validated, outbound actions are rate limited, and failures are returned
* as structured errors so a buggy daemon can never crash the client or send
* obviously malformed data to Discord.
*/
import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
import { findByPropsLazy } from "@vencord/types/webpack";
import {
ChannelStore,
GuildStore,
MessageStore,
UserStore,
VoiceStateStore
} from "@vencord/types/webpack/common";
import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetResult } from "shared/puppetProtocol";
/** Channel types that can receive text messages. */
const SENDABLE_CHANNEL_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
/** Discord's message fetch action, located lazily at runtime. */
const MessageActions = findByPropsLazy("fetchMessages");
/** Discord's reaction actions, located lazily at runtime. */
const ReactionActions = findByPropsLazy("addReaction", "removeReaction");
function compareIds(a: string, b: string): number {
try {
const left = BigInt(a);
const right = BigInt(b);
return left < right ? -1 : left > right ? 1 : 0;
} catch {
return a < b ? -1 : a > b ? 1 : 0;
}
}
class ActionError extends Error {
constructor(
public readonly code: PuppetErrorCode,
message: string
) {
super(message);
}
}
function ok(result?: unknown): PuppetResult {
return { ok: true, result };
}
function fail(code: PuppetErrorCode, message: string): PuppetResult {
return { ok: false, error: { code, message } };
}
function assert(condition: unknown, code: PuppetErrorCode, message: string): asserts condition {
if (!condition) throw new ActionError(code, message);
}
function validateContent(content: unknown): string {
assert(typeof content === "string", PuppetErrorCode.InvalidParam, "content must be a string");
const trimmed = content.trim();
assert(trimmed.length > 0, PuppetErrorCode.ContentEmpty, "content is empty");
assert(
trimmed.length <= PUPPET_LIMITS.maxContentLength,
PuppetErrorCode.ContentTooLong,
`content exceeds ${PUPPET_LIMITS.maxContentLength} characters`
);
// eslint-disable-next-line no-control-regex
assert(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(trimmed), PuppetErrorCode.InvalidParam, "content has control characters");
return trimmed;
}
const actionTimestamps: number[] = [];
/** Hard rate limit on outbound actions; the daemon's pacing is not trusted on its own. */
function enforceOutboundRateLimit() {
const now = Date.now();
while (actionTimestamps.length && now - actionTimestamps[0] > 60_000) actionTimestamps.shift();
assert(
actionTimestamps.length < PUPPET_LIMITS.maxActionsPerMinute,
PuppetErrorCode.RateLimited,
"too many outbound actions"
);
const last = actionTimestamps.at(-1);
assert(
!last || now - last >= PUPPET_LIMITS.minActionIntervalMs,
PuppetErrorCode.RateLimited,
"outbound actions are too frequent"
);
actionTimestamps.push(now);
}
function getSendableChannel(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
assert(channel, PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
assert(
SENDABLE_CHANNEL_TYPES.has(channel.type),
PuppetErrorCode.NotSendable,
`channel type ${channel.type} cannot receive messages`
);
return channel;
}
function buildState() {
const user = UserStore.getCurrentUser();
if (!user) return null;
return {
user: { id: user.id, username: user.username },
guilds: GuildStore.getGuildsArray().map(guild => ({
id: guild.id,
name: guild.name,
channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id))
.slice(0, PUPPET_LIMITS.maxChannelsPerGuild)
.map(channel => ({ id: channel.id, name: channel.name, type: channel.type }))
}))
};
}
async function send(channelId: string, content: string): Promise<unknown> {
try {
return await sendMessage(channelId, { content }, false);
} catch (error) {
throw new ActionError(PuppetErrorCode.SendFailed, `Discord rejected the message: ${String(error)}`);
}
}
/** Find the ID of a just-sent message. Use the send result first, then the store. */
async function resolveMessageId(raw: unknown, channelId: string, content: string): Promise<string | undefined> {
if (raw && typeof raw === "object" && typeof (raw as any).id === "string") return (raw as any).id;
const selfId = UserStore.getCurrentUser()?.id;
for (let attempt = 0; attempt < 10; attempt++) {
const messages: any[] = MessageStore.getMessages(channelId)?._array ?? [];
const match = [...messages].reverse().find(message => message.author?.id === selfId && message.content === content);
if (match) return match.id;
await new Promise(resolve => setTimeout(resolve, 100));
}
return undefined;
}
async function resolveDmChannel(userId: string): Promise<string> {
let channelId = ChannelStore.getDMFromUserId(userId);
if (!channelId) {
openPrivateChannel(userId);
for (let i = 0; i < 20 && !channelId; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
channelId = ChannelStore.getDMFromUserId(userId);
}
}
assert(channelId, PuppetErrorCode.DmUnavailable, `no DM channel for user ${userId} yet`);
return channelId;
}
export async function handlePuppetAction(
action: string,
params: Record<string, unknown> = {}
): Promise<PuppetResult> {
try {
switch (action) {
case PuppetAction.Ping:
return ok("pong");
case PuppetAction.StateGet: {
const state = buildState();
assert(state, PuppetErrorCode.NotLoggedIn, "no user is logged in");
return ok(state);
}
case PuppetAction.VoiceGet: {
const { guildId, userId } = params;
if (guildId !== undefined) {
assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake");
}
if (userId !== undefined) {
assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake");
}
const targetId = (userId as string | undefined) ?? UserStore.getCurrentUser()?.id;
assert(targetId, PuppetErrorCode.NotLoggedIn, "no user is logged in");
const voiceState = VoiceStateStore.getVoiceState((guildId as string | null) ?? null, targetId);
if (!voiceState?.channelId) {
return ok({ guildId: guildId ?? null, channelId: null, channelName: null, members: [] });
}
const channel = ChannelStore.getChannel(voiceState.channelId);
const states = VoiceStateStore.getVoiceStatesForChannel(voiceState.channelId);
return ok({
guildId: channel?.guild_id ?? guildId ?? null,
channelId: voiceState.channelId,
channelName: channel?.name ?? null,
members: Object.values(states).map(state => {
const user = UserStore.getUser(state.userId);
return {
id: state.userId,
username: user?.username ?? state.userId,
mute: state.mute,
deaf: state.deaf
};
})
});
}
case PuppetAction.MessageSend: {
const { channelId } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
const content = validateContent(params.content);
getSendableChannel(channelId);
enforceOutboundRateLimit();
const messageId = await resolveMessageId(await send(channelId, content), channelId, content);
return ok({ channelId, messageId, sent: true });
}
case PuppetAction.ReactionAdd: {
const { channelId, messageId, emoji } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
assert(isSnowflake(messageId), PuppetErrorCode.InvalidSnowflake, "messageId must be a snowflake");
assert(
typeof emoji === "string" && emoji.length > 0 && emoji.length <= 32 && !/[\r\n]/.test(emoji),
PuppetErrorCode.InvalidParam,
"emoji must be a short string"
);
enforceOutboundRateLimit();
const addReaction = (ReactionActions as any)?.addReaction;
assert(
typeof addReaction === "function",
PuppetErrorCode.NotSupported,
"reaction action is not available in this client"
);
try {
await addReaction(channelId, messageId, emoji);
} catch (error) {
throw new ActionError(
PuppetErrorCode.ReactionFailed,
`Discord rejected the reaction: ${String(error)}`
);
}
return ok({ channelId, messageId, emoji, added: true });
}
case PuppetAction.MessageRead: {
const { channelId, limit = 20 } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
assert(
Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= PUPPET_LIMITS.maxReadLimit,
PuppetErrorCode.InvalidParam,
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxReadLimit}`
);
assert(ChannelStore.getChannel(channelId), PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
const messages = MessageStore.getMessages(channelId)?._array ?? [];
return ok({
cached: messages.length,
messages: messages.slice(-Number(limit)).map(message => ({
id: message.id,
author: { id: message.author.id, username: message.author.username },
content: message.content,
timestamp: message.timestamp
}))
});
}
case PuppetAction.MessageFetch: {
const { channelId, limit = 50 } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
assert(
Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= PUPPET_LIMITS.maxFetchLimit,
PuppetErrorCode.InvalidParam,
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxFetchLimit}`
);
if (params.before !== undefined) {
assert(isSnowflake(params.before), PuppetErrorCode.InvalidSnowflake, "before must be a snowflake");
}
if (params.after !== undefined) {
assert(isSnowflake(params.after), PuppetErrorCode.InvalidSnowflake, "after must be a snowflake");
}
assert(!(params.before && params.after), PuppetErrorCode.InvalidParam, "before and after are mutually exclusive");
assert(ChannelStore.getChannel(channelId), PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
const fetchMessages = (MessageActions as any)?.fetchMessages;
assert(
typeof fetchMessages === "function",
PuppetErrorCode.NotSupported,
"message fetch is not available in this client"
);
const before = params.before as string | undefined;
const after = params.after as string | undefined;
await fetchMessages({
channelId,
limit: Number(limit),
...(before ? { before } : {}),
...(after ? { after } : {})
});
const all = MessageStore.getMessages(channelId)?._array ?? [];
const filtered = all.filter(message => {
if (after && compareIds(message.id, after) <= 0) return false;
if (before && compareIds(message.id, before) >= 0) return false;
return true;
});
return ok({
channelId,
fetched: filtered.length,
messages: filtered.slice(-Number(limit)).map(message => ({
id: message.id,
author: { id: message.author.id, username: message.author.username },
content: message.content,
timestamp: message.timestamp,
attachments: (message.attachments ?? []).map(attachment => ({ filename: attachment.filename }))
}))
});
}
case PuppetAction.DmSend: {
const { userId } = params;
assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake");
const content = validateContent(params.content);
enforceOutboundRateLimit();
const channelId = await resolveDmChannel(userId);
await send(channelId, content);
return ok({ userId, channelId, sent: true });
}
default:
return fail(PuppetErrorCode.UnknownAction, `unknown action: ${action}`);
}
} catch (error) {
if (error instanceof ActionError) return fail(error.code, error.message);
console.error("[puppet] action failed:", action, error);
return fail(PuppetErrorCode.Internal, "action failed");
}
}