Rework core integration: validated capability layer, docs, and session state
This commit is contained in:
@@ -18,15 +18,10 @@ export const PORTABLE =
|
||||
!existsSync(join(vesktopDir, "Uninstall Vesktop.exe"));
|
||||
|
||||
export const DATA_DIR =
|
||||
process.env.VENCORD_USER_DATA_DIR ||
|
||||
process.env.VESKSLOP_DATA_DIR ||
|
||||
(PORTABLE ? join(vesktopDir, "Data") : join(app.getPath("appData"), "veskslop"));
|
||||
process.env.VENCORD_USER_DATA_DIR || (PORTABLE ? join(vesktopDir, "Data") : join(app.getPath("userData")));
|
||||
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
/** Durable auth credential. Seeded into the client before it boots. */
|
||||
export const TOKEN_FILE = process.env.VESKSLOP_TOKEN_FILE || join(DATA_DIR, "token");
|
||||
|
||||
export const SESSION_DATA_DIR = join(DATA_DIR, "sessionData");
|
||||
app.setPath("sessionData", SESSION_DATA_DIR);
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import { join } from "path";
|
||||
import { IpcEvents } from "../shared/IpcEvents";
|
||||
import { setBadgeCount } from "./appBadge";
|
||||
import { autoStart } from "./autoStart";
|
||||
import { TOKEN_FILE } from "./constants";
|
||||
import { enableHardwareAcceleration } from "./main";
|
||||
import { mainWin } from "./mainWindow";
|
||||
import { Settings, State } from "./settings";
|
||||
@@ -61,17 +60,6 @@ handleSync(IpcEvents.GET_SETTINGS, () => Settings.plain);
|
||||
handleSync(IpcEvents.GET_VERSION, () => app.getVersion());
|
||||
handleSync(IpcEvents.GET_ENABLE_HARDWARE_ACCELERATION, () => enableHardwareAcceleration);
|
||||
|
||||
handleSync(IpcEvents.VESKSLOP_GET_TOKEN, () => {
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = readFileSync(TOKEN_FILE, "utf8").trim() || undefined;
|
||||
} catch {
|
||||
// No token file: the client falls back to its normal login.
|
||||
}
|
||||
|
||||
return { token, force: process.env.VESKSLOP_FORCE_TOKEN === "1" };
|
||||
});
|
||||
|
||||
handleSync(
|
||||
IpcEvents.SUPPORTS_WINDOWS_TRANSPARENCY,
|
||||
() => process.platform === "win32" && Number(release().split(".").pop()) >= 22621
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { app } from "electron";
|
||||
import { rmSync } from "fs";
|
||||
import { rm } from "fs/promises";
|
||||
import { createServer, Server, Socket } from "net";
|
||||
import { join } from "path";
|
||||
import { IpcCommands } from "shared/IpcEvents";
|
||||
|
||||
import { sendRendererCommand } from "./ipcCommands";
|
||||
|
||||
const { env, platform } = process;
|
||||
|
||||
let server: Server;
|
||||
|
||||
const SOCKET_PATH =
|
||||
platform === "win32"
|
||||
? "\\\\?\\pipe\\vesktop.sock"
|
||||
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop.sock");
|
||||
|
||||
/** JSON command name -> renderer IpcCommand. Extend here to expose more interactions. */
|
||||
const COMMANDS: Record<string, IpcCommands> = {
|
||||
"state:get": IpcCommands.STATE_GET,
|
||||
"message:send": IpcCommands.MESSAGE_SEND,
|
||||
"message:read": IpcCommands.MESSAGE_READ,
|
||||
"dm:send": IpcCommands.DM_SEND
|
||||
};
|
||||
|
||||
export async function initSocket() {
|
||||
await rm(SOCKET_PATH, { force: true });
|
||||
|
||||
server = createServer(socket => {
|
||||
socket.setEncoding("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) void handleLine(socket, line);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => socket.destroy());
|
||||
});
|
||||
|
||||
server.listen(SOCKET_PATH);
|
||||
server.on("error", err => {
|
||||
console.error("Vesktop IPC Socket Error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLine(socket: Socket, line: string) {
|
||||
let request: { id?: string | number; command?: string; data?: unknown };
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch {
|
||||
socket.write(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await dispatch(request.command, request.data);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: true, data }) + "\n");
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: false, error }) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(command: string | undefined, data: unknown) {
|
||||
if (command === "ping") return "pong";
|
||||
|
||||
const ipcCommand = command && COMMANDS[command];
|
||||
if (!ipcCommand) throw new Error(`Unknown command: ${command}`);
|
||||
|
||||
return sendRendererCommand(ipcCommand, data);
|
||||
}
|
||||
|
||||
app.on("will-quit", () => {
|
||||
server?.close();
|
||||
rmSync(SOCKET_PATH, { force: true });
|
||||
});
|
||||
+3
-5
@@ -14,7 +14,6 @@ import { app, BrowserWindow, nativeTheme } from "electron";
|
||||
|
||||
import { DATA_DIR } from "./constants";
|
||||
import { createFirstLaunchTour } from "./firstLaunch";
|
||||
import { initSocket } from "./ipcSocket";
|
||||
import { createWindows, mainWin } from "./mainWindow";
|
||||
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
||||
import { registerScreenShareHandler } from "./screenShare";
|
||||
@@ -106,7 +105,6 @@ function init() {
|
||||
|
||||
registerScreenShareHandler();
|
||||
registerMediaPermissionsHandler();
|
||||
initSocket();
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -129,10 +127,10 @@ if (!app.requestSingleInstanceLock({ IS_DEV })) {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (process.env.VESKSLOP_HEADLESS === "1" || Object.hasOwn(State.store, "firstLaunch")) {
|
||||
createWindows();
|
||||
} else {
|
||||
if (!Object.hasOwn(State.store, "firstLaunch")) {
|
||||
createFirstLaunchTour();
|
||||
} else {
|
||||
createWindows();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,20 +9,6 @@ import { contextBridge, ipcRenderer, webFrame } from "electron/renderer";
|
||||
import { IpcEvents } from "../shared/IpcEvents";
|
||||
import { VesktopNative } from "./VesktopNative";
|
||||
|
||||
// Veskslop: seed the Discord auth token before the client boots, unless a session already exists.
|
||||
try {
|
||||
const seed = ipcRenderer.sendSync(IpcEvents.VESKSLOP_GET_TOKEN) as
|
||||
| { token?: string; force?: boolean }
|
||||
| undefined;
|
||||
|
||||
if (seed?.token && (seed.force || !localStorage.getItem("token"))) {
|
||||
localStorage.setItem("token", JSON.stringify(seed.token));
|
||||
console.log("[veskslop] seeded auth token");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[veskslop] failed to seed auth token", error);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("VesktopNative", VesktopNative);
|
||||
|
||||
// While sandboxed, Electron "polyfills" these APIs as local variables.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,6 @@ export const enum IpcEvents {
|
||||
GET_SETTINGS = "VCD_GET_SETTINGS",
|
||||
SET_SETTINGS = "VCD_SET_SETTINGS",
|
||||
|
||||
VESKSLOP_GET_TOKEN = "VCD_VESKSLOP_GET_TOKEN",
|
||||
|
||||
IS_USING_CUSTOM_VENCORD_DIR = "VCD_IS_USING_CUSTOM_VENCORD_DIR",
|
||||
SHOW_CUSTOM_VENCORD_DIR = "VCD_SHOW_CUSTOM_VENCORD_DIR",
|
||||
SELECT_VENCORD_DIR = "VCD_SELECT_VENCORD_DIR",
|
||||
@@ -85,11 +83,5 @@ export const enum IpcCommands {
|
||||
|
||||
GET_LANGUAGES = "navigator.languages",
|
||||
|
||||
SCREEN_SHARE_PICKER = "screenshare:picker",
|
||||
|
||||
/* Veskslop puppet commands */
|
||||
STATE_GET = "state:get",
|
||||
MESSAGE_SEND = "message:send",
|
||||
MESSAGE_READ = "message:read",
|
||||
DM_SEND = "dm:send"
|
||||
SCREEN_SHARE_PICKER = "screenshare:picker"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Vesktop ⇄ Veskslop daemon protocol.
|
||||
*
|
||||
* This is the only surface the external daemon can reach. Every action is
|
||||
* validated by the core before anything touches Discord, and every failure is
|
||||
* returned as a structured error instead of throwing.
|
||||
*/
|
||||
|
||||
export const enum PuppetAction {
|
||||
Ping = "ping",
|
||||
EventsSubscribe = "events.subscribe",
|
||||
StateGet = "state.get",
|
||||
MessageSend = "message.send",
|
||||
MessageRead = "message.read",
|
||||
DmSend = "dm.send"
|
||||
}
|
||||
|
||||
export const PuppetActions: readonly PuppetAction[] = [
|
||||
PuppetAction.Ping,
|
||||
PuppetAction.EventsSubscribe,
|
||||
PuppetAction.StateGet,
|
||||
PuppetAction.MessageSend,
|
||||
PuppetAction.MessageRead,
|
||||
PuppetAction.DmSend
|
||||
];
|
||||
|
||||
export const enum PuppetErrorCode {
|
||||
InvalidRequest = "INVALID_REQUEST",
|
||||
PayloadTooLarge = "PAYLOAD_TOO_LARGE",
|
||||
UnknownAction = "UNKNOWN_ACTION",
|
||||
CoreUnavailable = "CORE_UNAVAILABLE",
|
||||
NotLoggedIn = "NOT_LOGGED_IN",
|
||||
InvalidSnowflake = "INVALID_SNOWFLAKE",
|
||||
InvalidParam = "INVALID_PARAM",
|
||||
ContentEmpty = "CONTENT_EMPTY",
|
||||
ContentTooLong = "CONTENT_TOO_LONG",
|
||||
ChannelNotFound = "CHANNEL_NOT_FOUND",
|
||||
NotSendable = "NOT_SENDABLE",
|
||||
SendFailed = "SEND_FAILED",
|
||||
DmUnavailable = "DM_UNAVAILABLE",
|
||||
RateLimited = "RATE_LIMITED",
|
||||
Internal = "INTERNAL"
|
||||
}
|
||||
|
||||
export interface PuppetError {
|
||||
code: PuppetErrorCode | string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PuppetRequest {
|
||||
id?: string | number;
|
||||
action: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PuppetResult =
|
||||
| { ok: true; result?: unknown }
|
||||
| { ok: false; error: PuppetError };
|
||||
|
||||
export type PuppetResponse =
|
||||
| { id?: string | number; ok: true; result?: unknown }
|
||||
| { id?: string | number; ok: false; error: PuppetError };
|
||||
|
||||
export interface PuppetEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Hard safety limits applied core-side, regardless of what the daemon asks for. */
|
||||
export const PUPPET_LIMITS = {
|
||||
maxContentLength: 2000,
|
||||
maxReadLimit: 100,
|
||||
minActionIntervalMs: 500,
|
||||
maxActionsPerMinute: 30,
|
||||
maxRequestBytes: 64 * 1024,
|
||||
maxChannelsPerGuild: 1000
|
||||
};
|
||||
|
||||
const SNOWFLAKE_RE = /^\d{17,20}$/;
|
||||
|
||||
export function isSnowflake(value: unknown): value is string {
|
||||
return typeof value === "string" && SNOWFLAKE_RE.test(value);
|
||||
}
|
||||
Reference in New Issue
Block a user