/* * 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 { 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 { 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 { 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 = {} ): Promise { 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"); } }