360 lines
14 KiB
TypeScript
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");
|
|
}
|
|
}
|