Rework core integration: validated capability layer, docs, and session state

This commit is contained in:
Fisto
2026-09-11 14:09:04 +02:00
parent 2a15f057df
commit e62d3007d8
33 changed files with 1897 additions and 612 deletions
+199
View File
@@ -0,0 +1,199 @@
/*
* 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 { ChannelStore, GuildStore, MessageStore, UserStore } 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]);
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) {
try {
await sendMessage(channelId, { content }, false);
} catch (error) {
throw new ActionError(PuppetErrorCode.SendFailed, `Discord rejected the message: ${String(error)}`);
}
}
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.MessageSend: {
const { channelId } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
const content = validateContent(params.content);
getSendableChannel(channelId);
enforceOutboundRateLimit();
await send(channelId, content);
return ok({ channelId, sent: 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.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");
}
}