Rework core integration: validated capability layer, docs, and session state
This commit is contained in:
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
|
||||
import { ChannelStore, GuildStore, MessageStore, SettingsRouter, UserStore } from "@vencord/types/webpack/common";
|
||||
import { SettingsRouter } from "@vencord/types/webpack/common";
|
||||
import { IpcCommands } from "shared/IpcEvents";
|
||||
|
||||
import { openScreenSharePicker } from "./components/ScreenSharePicker";
|
||||
@@ -53,68 +52,3 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => {
|
||||
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
|
||||
|
||||
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
|
||||
|
||||
/* Veskslop puppet handlers */
|
||||
|
||||
onIpcCommand(IpcCommands.STATE_GET, () => {
|
||||
const user = UserStore.getCurrentUser();
|
||||
const guilds = GuildStore.getGuildsArray().map(guild => {
|
||||
const channels = Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type
|
||||
}));
|
||||
|
||||
return { id: guild.id, name: guild.name, channels };
|
||||
});
|
||||
|
||||
return { user: { id: user.id, username: user.username }, guilds };
|
||||
});
|
||||
|
||||
onIpcCommand(IpcCommands.MESSAGE_SEND, async (data: { channelId?: string; content?: string }) => {
|
||||
if (!data?.channelId || typeof data.content !== "string") {
|
||||
throw new Error("message:send requires { channelId, content }");
|
||||
}
|
||||
|
||||
await sendMessage(data.channelId, { content: data.content }, false);
|
||||
return { channelId: data.channelId, sent: true };
|
||||
});
|
||||
|
||||
onIpcCommand(IpcCommands.MESSAGE_READ, (data: { channelId?: string; limit?: number }) => {
|
||||
if (!data?.channelId) throw new Error("message:read requires { channelId }");
|
||||
|
||||
const limit = Math.min(Math.max(data.limit ?? 20, 1), 100);
|
||||
const messages = MessageStore.getMessages(data.channelId)?._array ?? [];
|
||||
|
||||
return {
|
||||
channelId: data.channelId,
|
||||
cached: messages.length,
|
||||
messages: messages.slice(-limit).map(message => ({
|
||||
id: message.id,
|
||||
author: { id: message.author.id, username: message.author.username },
|
||||
content: message.content,
|
||||
timestamp: message.timestamp
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
onIpcCommand(IpcCommands.DM_SEND, async (data: { userId?: string; content?: string }) => {
|
||||
if (!data?.userId || typeof data.content !== "string") {
|
||||
throw new Error("dm:send requires { userId, content }");
|
||||
}
|
||||
|
||||
let channelId = ChannelStore.getDMFromUserId(data.userId);
|
||||
if (!channelId) {
|
||||
openPrivateChannel(data.userId);
|
||||
|
||||
for (let i = 0; i < 20 && !channelId; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
channelId = ChannelStore.getDMFromUserId(data.userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!channelId) throw new Error(`No DM channel for user ${data.userId} yet`);
|
||||
|
||||
await sendMessage(channelId, { content: data.content }, false);
|
||||
return { userId: data.userId, channelId, sent: true };
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user