Rework pacing, pause poll, add user search, voice overview, and DM list
This commit is contained in:
@@ -4,3 +4,5 @@ node_modules/
|
|||||||
.token
|
.token
|
||||||
.secrets/
|
.secrets/
|
||||||
.veskslop-state.json
|
.veskslop-state.json
|
||||||
|
actions.jsonl
|
||||||
|
actions.jsonl.*
|
||||||
|
|||||||
@@ -93,11 +93,6 @@ export function isTrusted(
|
|||||||
return userId === ownerId || !!whitelist?.includes(userId);
|
return userId === ownerId || !!whitelist?.includes(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same 500-2800 ms pacing used by the control bridge, for autonomous replies. */
|
|
||||||
export function randomActionDelay(): number {
|
|
||||||
return 500 + Math.floor(Math.random() * 2301);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Context assembly */
|
/* Context assembly */
|
||||||
|
|
||||||
export interface ContextOptions {
|
export interface ContextOptions {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class CoreClient {
|
|||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private readonly pending = new Map<number, Pending>();
|
private readonly pending = new Map<number, Pending>();
|
||||||
private readonly listeners = new Set<CoreEventListener>();
|
private readonly listeners = new Set<CoreEventListener>();
|
||||||
|
private readonly closeListeners = new Set<() => void>();
|
||||||
|
|
||||||
private constructor(private readonly socket: net.Socket) {
|
private constructor(private readonly socket: net.Socket) {
|
||||||
socket.setEncoding("utf-8");
|
socket.setEncoding("utf-8");
|
||||||
@@ -52,6 +53,8 @@ export class CoreClient {
|
|||||||
request.reject(new CoreError("DISCONNECTED", "core disconnected"));
|
request.reject(new CoreError("DISCONNECTED", "core disconnected"));
|
||||||
}
|
}
|
||||||
this.pending.clear();
|
this.pending.clear();
|
||||||
|
|
||||||
|
for (const listener of this.closeListeners) listener();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("error", () => socket.destroy());
|
socket.on("error", () => socket.destroy());
|
||||||
@@ -109,6 +112,11 @@ export class CoreClient {
|
|||||||
return () => this.listeners.delete(listener);
|
return () => this.listeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClose(listener: () => void) {
|
||||||
|
this.closeListeners.add(listener);
|
||||||
|
return () => this.closeListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/* Convenience actions */
|
/* Convenience actions */
|
||||||
|
|
||||||
async getState() {
|
async getState() {
|
||||||
@@ -146,6 +154,24 @@ export class CoreClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async voiceOverview(guildId: string) {
|
||||||
|
return this.request("voice.overview", { guildId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async userSearch(query: string, guildId?: string, limit?: number) {
|
||||||
|
return this.request("user.search", {
|
||||||
|
query,
|
||||||
|
...(guildId ? { guildId } : {}),
|
||||||
|
...(limit ? { limit } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dmList(limit?: number) {
|
||||||
|
return this.request("dm.list", {
|
||||||
|
...(limit ? { limit } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.socket.destroy();
|
this.socket.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-5
@@ -20,6 +20,7 @@ const FAKE_GUILDS = [
|
|||||||
{
|
{
|
||||||
id: "100000000000000001",
|
id: "100000000000000001",
|
||||||
name: "Mock Guild",
|
name: "Mock Guild",
|
||||||
|
icon: null,
|
||||||
channels: [
|
channels: [
|
||||||
{ id: "200000000000000001", name: "general", type: 0 },
|
{ id: "200000000000000001", name: "general", type: 0 },
|
||||||
{ id: "200000000000000002", name: "voice", type: 2 }
|
{ id: "200000000000000002", name: "voice", type: 2 }
|
||||||
@@ -27,21 +28,70 @@ const FAKE_GUILDS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MOCK_IMAGE_URL =
|
||||||
|
"data:image/svg+xml," +
|
||||||
|
encodeURIComponent(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160">' +
|
||||||
|
'<rect width="240" height="160" fill="#2b2741"/>' +
|
||||||
|
'<text x="120" y="86" fill="#d2cefd" font-family="sans-serif" font-size="16" text-anchor="middle">mock image</text>' +
|
||||||
|
"</svg>"
|
||||||
|
);
|
||||||
|
|
||||||
const FAKE_MESSAGES = [
|
const FAKE_MESSAGES = [
|
||||||
{
|
{
|
||||||
id: "300000000000000001",
|
id: "300000000000000001",
|
||||||
author: { id: "1", username: "mockuser" },
|
author: { id: "1", username: "mockuser", avatar: null },
|
||||||
content: "hello from the mock",
|
content: "hello from the mock",
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: "chart.png",
|
||||||
|
url: MOCK_IMAGE_URL,
|
||||||
|
contentType: "image/png",
|
||||||
|
width: 640,
|
||||||
|
height: 480
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAKE_USERS = [
|
||||||
|
{ id: "999", username: "mockuser", globalName: "Mock User", avatar: null, bot: false },
|
||||||
|
{ id: "177117296140091393", username: "zzib", globalName: "Zzib", avatar: null, bot: false },
|
||||||
|
{ id: "888", username: "testbot", globalName: null, avatar: null, bot: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
const FAKE_HISTORY = Array.from({ length: 8 }, (_, index) => ({
|
const FAKE_HISTORY = Array.from({ length: 8 }, (_, index) => ({
|
||||||
id: String(400000000000000000n + BigInt(index)),
|
id: String(400000000000000000n + BigInt(index)),
|
||||||
author: { id: index % 2 ? "177117296140091393" : "999", username: index % 2 ? "zzib" : "mockuser" },
|
author: {
|
||||||
|
id: index % 2 ? "177117296140091393" : "999",
|
||||||
|
username: index % 2 ? "zzib" : "mockuser",
|
||||||
|
avatar: null
|
||||||
|
},
|
||||||
content: `history message ${index}`,
|
content: `history message ${index}`,
|
||||||
timestamp: new Date(Date.now() - (8 - index) * 60_000).toISOString(),
|
timestamp: new Date(Date.now() - (8 - index) * 60_000).toISOString(),
|
||||||
attachments: index === 7 ? [{ filename: "clip.mp4", content_type: "video/mp4" }] : []
|
attachments:
|
||||||
|
index === 6
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
filename: "chart.png",
|
||||||
|
url: MOCK_IMAGE_URL,
|
||||||
|
contentType: "image/png",
|
||||||
|
width: 640,
|
||||||
|
height: 480
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: index === 7
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
filename: "clip.mp4",
|
||||||
|
url: "https://cdn.discordapp.com/attachments/200000000000000001/400000000000000007/clip.mp4",
|
||||||
|
contentType: "video/mp4",
|
||||||
|
width: 1280,
|
||||||
|
height: 720
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handle(action: string | undefined, params: any) {
|
function handle(action: string | undefined, params: any) {
|
||||||
@@ -49,7 +99,7 @@ function handle(action: string | undefined, params: any) {
|
|||||||
case "ping":
|
case "ping":
|
||||||
return "pong";
|
return "pong";
|
||||||
case "state.get":
|
case "state.get":
|
||||||
return { user: { id: "999", username: "mockuser" }, guilds: FAKE_GUILDS };
|
return { user: { id: "999", username: "mockuser", avatar: null }, guilds: FAKE_GUILDS };
|
||||||
case "voice.get":
|
case "voice.get":
|
||||||
return {
|
return {
|
||||||
guildId: params?.guildId ?? "100000000000000001",
|
guildId: params?.guildId ?? "100000000000000001",
|
||||||
@@ -60,6 +110,57 @@ function handle(action: string | undefined, params: any) {
|
|||||||
{ id: "177117296140091393", username: "zzib", mute: false, deaf: false }
|
{ id: "177117296140091393", username: "zzib", mute: false, deaf: false }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
case "voice.overview":
|
||||||
|
return {
|
||||||
|
guildId: params?.guildId,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelId: "200000000000000002",
|
||||||
|
channelName: "voice",
|
||||||
|
members: [
|
||||||
|
{ id: "999", username: "mockuser", mute: false, deaf: false },
|
||||||
|
{ id: "177117296140091393", username: "zzib", mute: false, deaf: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case "user.search": {
|
||||||
|
const needle = String(params?.query ?? "")
|
||||||
|
.replace(/^@/, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const users = FAKE_USERS.filter(
|
||||||
|
user =>
|
||||||
|
user.username.toLowerCase().includes(needle) ||
|
||||||
|
(user.globalName ?? "").toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
return { query: params?.query, guildId: params?.guildId ?? null, users };
|
||||||
|
}
|
||||||
|
case "dm.list":
|
||||||
|
return {
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: "700000000000000001",
|
||||||
|
type: 1,
|
||||||
|
name: null,
|
||||||
|
icon: null,
|
||||||
|
lastMessageId: "710000000000000001",
|
||||||
|
recipients: [
|
||||||
|
{ id: "177117296140091393", username: "zzib", globalName: "Zzib", avatar: null, bot: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "700000000000000002",
|
||||||
|
type: 3,
|
||||||
|
name: "oboys + 2",
|
||||||
|
icon: null,
|
||||||
|
lastMessageId: "710000000000000002",
|
||||||
|
recipients: [
|
||||||
|
{ id: "888", username: "testbot", globalName: null, avatar: null, bot: true },
|
||||||
|
{ id: "999", username: "mockuser", globalName: "Mock User", avatar: null, bot: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
case "message.send":
|
case "message.send":
|
||||||
return { channelId: params?.channelId, messageId: "msg-1", sent: true };
|
return { channelId: params?.channelId, messageId: "msg-1", sent: true };
|
||||||
case "reaction.add":
|
case "reaction.add":
|
||||||
|
|||||||
+68
-12
@@ -1,34 +1,90 @@
|
|||||||
/*
|
/*
|
||||||
* Veskslop Phase 0 spike — global query pacing.
|
* Veskslop daemon — pacing.
|
||||||
*
|
*
|
||||||
* Current policy: every command sent to the client is spaced by a random
|
* Outbound sends use a human-emulation policy: the first send of a burst goes
|
||||||
* 500-2800 ms. Commands are serialized through one queue, and identical
|
* out quickly (30-200 ms), later sends in the same burst are spaced 500-2800 ms.
|
||||||
* concurrent queries are coalesced so the total query count stays minimal.
|
* A burst ends after a random 5-20 s of quiet. Read queries are serialized with
|
||||||
|
* a small gap so they do not look like a scan.
|
||||||
*
|
*
|
||||||
* The long-term human-emulation policy (snappy first action, slower bulk
|
* The core adds a second, hard rate limit. See docs/core-bridge.md.
|
||||||
* actions, risky fast mode) lives in AGENTS.md and is not active yet.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current per-query delay: 500-2800 ms. */
|
export const PACING = {
|
||||||
export function queryDelay(): number {
|
firstMinMs: 30,
|
||||||
return 500 + Math.floor(Math.random() * 2301);
|
firstMaxMs: 200,
|
||||||
|
burstMinMs: 500,
|
||||||
|
burstMaxMs: 2800,
|
||||||
|
idleMinMs: 5000,
|
||||||
|
idleMaxMs: 20000,
|
||||||
|
readMinMs: 100,
|
||||||
|
readMaxMs: 300
|
||||||
|
};
|
||||||
|
|
||||||
|
function randomBetween(min: number, max: number): number {
|
||||||
|
return min + Math.floor(Math.random() * (max - min + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay before the first send of a burst. */
|
||||||
|
export function firstActionDelay(): number {
|
||||||
|
return randomBetween(PACING.firstMinMs, PACING.firstMaxMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay between sends inside one burst. */
|
||||||
|
export function burstActionDelay(): number {
|
||||||
|
return randomBetween(PACING.burstMinMs, PACING.burstMaxMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the current burst stays active after a send. */
|
||||||
|
export function burstWindow(): number {
|
||||||
|
return randomBetween(PACING.idleMinMs, PACING.idleMaxMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan the next outbound send. Pure so the policy is testable.
|
||||||
|
* A send while `now > burstUntil` starts a new burst with a fast first action.
|
||||||
|
*/
|
||||||
|
export function planSend(now: number, burstUntil: number): { delay: number; burstUntil: number } {
|
||||||
|
const delay = now > burstUntil ? firstActionDelay() : burstActionDelay();
|
||||||
|
return { delay, burstUntil: now + burstWindow() };
|
||||||
}
|
}
|
||||||
|
|
||||||
let queue: Promise<unknown> = Promise.resolve();
|
let queue: Promise<unknown> = Promise.resolve();
|
||||||
let queryCount = 0;
|
let queryCount = 0;
|
||||||
|
let burstUntil = 0;
|
||||||
|
|
||||||
/** Serialize a REST query behind the queue with the configured delay. */
|
/** Serialize an outbound send with the human-emulation delay. */
|
||||||
|
export function pacedSend<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const run = queue.then(async () => {
|
||||||
|
const plan = planSend(Date.now(), burstUntil);
|
||||||
|
burstUntil = plan.burstUntil;
|
||||||
|
|
||||||
|
await sleep(plan.delay);
|
||||||
|
queryCount++;
|
||||||
|
if (process.env.DEBUG === "1") {
|
||||||
|
console.error(`[send] query #${queryCount} (+${plan.delay}ms)`);
|
||||||
|
}
|
||||||
|
return task();
|
||||||
|
});
|
||||||
|
|
||||||
|
queue = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize a read query with a small gap. Reads do not affect the send burst. */
|
||||||
export function paced<T>(task: () => Promise<T>): Promise<T> {
|
export function paced<T>(task: () => Promise<T>): Promise<T> {
|
||||||
const delay = queryDelay();
|
const delay = randomBetween(PACING.readMinMs, PACING.readMaxMs);
|
||||||
const run = queue.then(async () => {
|
const run = queue.then(async () => {
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
queryCount++;
|
queryCount++;
|
||||||
if (process.env.DEBUG === "1") {
|
if (process.env.DEBUG === "1") {
|
||||||
console.error(`[rest] query #${queryCount} (+${delay}ms)`);
|
console.error(`[read] query #${queryCount} (+${delay}ms)`);
|
||||||
}
|
}
|
||||||
return task();
|
return task();
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-5
@@ -19,7 +19,6 @@ import {
|
|||||||
parsePoll,
|
parsePoll,
|
||||||
parseTier,
|
parseTier,
|
||||||
POLL_EMOJIS,
|
POLL_EMOJIS,
|
||||||
randomActionDelay,
|
|
||||||
stripMentions,
|
stripMentions,
|
||||||
toCachedMessage,
|
toCachedMessage,
|
||||||
type AssembledContext,
|
type AssembledContext,
|
||||||
@@ -29,11 +28,13 @@ import {
|
|||||||
type TrustTier
|
type TrustTier
|
||||||
} from "./communication.js";
|
} from "./communication.js";
|
||||||
import { MessageCache } from "./message-cache.js";
|
import { MessageCache } from "./message-cache.js";
|
||||||
import { paced } from "./pacing.js";
|
import { paced, pacedSend } from "./pacing.js";
|
||||||
|
|
||||||
const DM_CHANNEL = 1;
|
const DM_CHANNEL = 1;
|
||||||
const GROUP_DM_CHANNEL = 3;
|
const GROUP_DM_CHANNEL = 3;
|
||||||
|
|
||||||
|
const POLL_ENABLED = false;
|
||||||
|
|
||||||
const DEFAULT_OWNER_ID = "177117296140091393";
|
const DEFAULT_OWNER_ID = "177117296140091393";
|
||||||
const STATE_FILE =
|
const STATE_FILE =
|
||||||
process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname;
|
process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname;
|
||||||
@@ -237,6 +238,11 @@ export class Puppet {
|
|||||||
return this.actionLog.read(limit);
|
return this.actionLog.read(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Write a web UI action to the persistent log. */
|
||||||
|
appendAction(entry: Record<string, unknown> & { type: string }) {
|
||||||
|
this.actionLog.append(entry);
|
||||||
|
}
|
||||||
|
|
||||||
addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
|
addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
|
||||||
for (const id of userIds) {
|
for (const id of userIds) {
|
||||||
if (id && id !== this.ownerId) this.tiers.set(id, tier);
|
if (id && id !== this.ownerId) this.tiers.set(id, tier);
|
||||||
@@ -440,7 +446,7 @@ export class Puppet {
|
|||||||
|
|
||||||
if (command?.name === "whitelist") {
|
if (command?.name === "whitelist") {
|
||||||
await this.handleWhitelistCommand(command, author.id, tier, channelId);
|
await this.handleWhitelistCommand(command, author.id, tier, channelId);
|
||||||
} else if (command?.name === "poll") {
|
} else if (POLL_ENABLED && command?.name === "poll") {
|
||||||
await this.handlePollCommand(author.id, channelId, text);
|
await this.handlePollCommand(author.id, channelId, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,11 +670,11 @@ export class Puppet {
|
|||||||
const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName);
|
const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName);
|
||||||
const mode = override ?? (sensitive ? "dm" : policy.defaultMode);
|
const mode = override ?? (sensitive ? "dm" : policy.defaultMode);
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await pacedSend(async () => {
|
||||||
if (mode === "dm") await this.core.sendDm(requesterId, content);
|
if (mode === "dm") await this.core.sendDm(requesterId, content);
|
||||||
else await this.core.sendMessage(originChannelId, content);
|
else await this.core.sendMessage(originChannelId, content);
|
||||||
|
});
|
||||||
|
|
||||||
this.actionLog.append({
|
this.actionLog.append({
|
||||||
type: "reply",
|
type: "reply",
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import {
|
|||||||
parseCommand,
|
parseCommand,
|
||||||
parsePoll,
|
parsePoll,
|
||||||
parseTier,
|
parseTier,
|
||||||
randomActionDelay,
|
|
||||||
redactSecrets,
|
redactSecrets,
|
||||||
stripMentions,
|
stripMentions,
|
||||||
toCachedMessage,
|
toCachedMessage,
|
||||||
type CachedMessage,
|
type CachedMessage,
|
||||||
type PuppetMessage
|
type PuppetMessage
|
||||||
} from "./communication.js";
|
} from "./communication.js";
|
||||||
|
import { PACING, planSend } from "./pacing.js";
|
||||||
|
|
||||||
function cached(id: number, content = `m${id}`): CachedMessage {
|
function cached(id: number, content = `m${id}`): CachedMessage {
|
||||||
return { id: String(id), authorId: "1", authorName: "a", content, timestamp: "" };
|
return { id: String(id), authorId: "1", authorName: "a", content, timestamp: "" };
|
||||||
@@ -77,11 +77,31 @@ function entry(id: number, trusted: boolean): PuppetMessage {
|
|||||||
assert.equal(isTrusted("stranger", "owner", ["trusted"]), false);
|
assert.equal(isTrusted("stranger", "owner", ["trusted"]), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// pacing stays in the 500-2800 ms window
|
// pacing: fast first send in a burst, 500-2800 ms afterwards, variable reset
|
||||||
{
|
{
|
||||||
for (let i = 0; i < 200; i++) {
|
for (let i = 0; i < 200; i++) {
|
||||||
const delay = randomActionDelay();
|
const first = planSend(1_000_000, 0);
|
||||||
assert.ok(delay >= 500 && delay <= 2800, `delay out of range: ${delay}`);
|
assert.ok(
|
||||||
|
first.delay >= PACING.firstMinMs && first.delay <= PACING.firstMaxMs,
|
||||||
|
`first delay out of range: ${first.delay}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
first.burstUntil - 1_000_000 >= PACING.idleMinMs && first.burstUntil - 1_000_000 <= PACING.idleMaxMs,
|
||||||
|
`burst window out of range: ${first.burstUntil}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const followUp = planSend(1_000_000, 1_001_000);
|
||||||
|
assert.ok(
|
||||||
|
followUp.delay >= PACING.burstMinMs && followUp.delay <= PACING.burstMaxMs,
|
||||||
|
`burst delay out of range: ${followUp.delay}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// A fresh burst after the window expired is fast again.
|
||||||
|
const resumed = planSend(2_000_000, 1_000_000);
|
||||||
|
assert.ok(
|
||||||
|
resumed.delay >= PACING.firstMinMs && resumed.delay <= PACING.firstMaxMs,
|
||||||
|
`resumed delay out of range: ${resumed.delay}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Veskslop core bridge — the only external control surface.
|
* Veskslop core bridge — the only external control surface.
|
||||||
*
|
*
|
||||||
@@ -35,6 +41,9 @@ const KNOWN_ACTIONS = new Set<string>([
|
|||||||
PuppetAction.EventsSubscribe,
|
PuppetAction.EventsSubscribe,
|
||||||
PuppetAction.StateGet,
|
PuppetAction.StateGet,
|
||||||
PuppetAction.VoiceGet,
|
PuppetAction.VoiceGet,
|
||||||
|
PuppetAction.VoiceOverview,
|
||||||
|
PuppetAction.UserSearch,
|
||||||
|
PuppetAction.DmList,
|
||||||
PuppetAction.MessageSend,
|
PuppetAction.MessageSend,
|
||||||
PuppetAction.MessageRead,
|
PuppetAction.MessageRead,
|
||||||
PuppetAction.MessageFetch,
|
PuppetAction.MessageFetch,
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import { VesktopNative } from "./VesktopNative";
|
|||||||
|
|
||||||
// Veskslop: seed the Discord auth token before the client boots, unless a session exists.
|
// Veskslop: seed the Discord auth token before the client boots, unless a session exists.
|
||||||
try {
|
try {
|
||||||
const seed = ipcRenderer.sendSync(IpcEvents.VESKSLOP_GET_TOKEN) as
|
const seed = ipcRenderer.sendSync(IpcEvents.VESKSLOP_GET_TOKEN) as { token?: string; force?: boolean } | undefined;
|
||||||
| { token?: string; force?: boolean }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (seed?.token && (seed.force || !localStorage.getItem("token"))) {
|
if (seed?.token && (seed.force || !localStorage.getItem("token"))) {
|
||||||
localStorage.setItem("token", JSON.stringify(seed.token));
|
localStorage.setItem("token", JSON.stringify(seed.token));
|
||||||
|
|||||||
+217
-34
@@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Veskslop core actions — the validated capability surface.
|
* Veskslop core actions — the validated capability surface.
|
||||||
*
|
*
|
||||||
@@ -11,6 +17,7 @@ import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
|
|||||||
import { findByPropsLazy } from "@vencord/types/webpack";
|
import { findByPropsLazy } from "@vencord/types/webpack";
|
||||||
import {
|
import {
|
||||||
ChannelStore,
|
ChannelStore,
|
||||||
|
GuildMemberStore,
|
||||||
GuildStore,
|
GuildStore,
|
||||||
MessageStore,
|
MessageStore,
|
||||||
UserStore,
|
UserStore,
|
||||||
@@ -68,8 +75,11 @@ function validateContent(content: unknown): string {
|
|||||||
PuppetErrorCode.ContentTooLong,
|
PuppetErrorCode.ContentTooLong,
|
||||||
`content exceeds ${PUPPET_LIMITS.maxContentLength} characters`
|
`content exceeds ${PUPPET_LIMITS.maxContentLength} characters`
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line no-control-regex
|
assert(
|
||||||
assert(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(trimmed), PuppetErrorCode.InvalidParam, "content has control characters");
|
!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(trimmed),
|
||||||
|
PuppetErrorCode.InvalidParam,
|
||||||
|
"content has control characters"
|
||||||
|
);
|
||||||
|
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
@@ -109,15 +119,37 @@ function getSendableChannel(channelId: string) {
|
|||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape a Discord message for the daemon. Attachments carry CDN links only. */
|
||||||
|
function mapMessage(message: any) {
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
author: {
|
||||||
|
id: message.author.id,
|
||||||
|
username: message.author.username,
|
||||||
|
avatar: message.author.avatar ?? null
|
||||||
|
},
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
attachments: (message.attachments ?? []).map((attachment: any) => ({
|
||||||
|
filename: attachment.filename,
|
||||||
|
url: attachment.url ?? null,
|
||||||
|
contentType: attachment.contentType ?? null,
|
||||||
|
width: attachment.width ?? null,
|
||||||
|
height: attachment.height ?? null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildState() {
|
function buildState() {
|
||||||
const user = UserStore.getCurrentUser();
|
const user = UserStore.getCurrentUser();
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: { id: user.id, username: user.username },
|
user: { id: user.id, username: user.username, avatar: user.avatar ?? null },
|
||||||
guilds: GuildStore.getGuildsArray().map(guild => ({
|
guilds: GuildStore.getGuildsArray().map(guild => ({
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
name: guild.name,
|
name: guild.name,
|
||||||
|
icon: guild.icon ?? null,
|
||||||
channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id))
|
channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id))
|
||||||
.slice(0, PUPPET_LIMITS.maxChannelsPerGuild)
|
.slice(0, PUPPET_LIMITS.maxChannelsPerGuild)
|
||||||
.map(channel => ({ id: channel.id, name: channel.name, type: channel.type }))
|
.map(channel => ({ id: channel.id, name: channel.name, type: channel.type }))
|
||||||
@@ -125,6 +157,85 @@ function buildState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function voiceMember(state: any) {
|
||||||
|
const user = UserStore.getUser(state.userId);
|
||||||
|
return {
|
||||||
|
id: state.userId,
|
||||||
|
username: user?.username ?? state.userId,
|
||||||
|
globalName: user?.globalName ?? null,
|
||||||
|
avatar: user?.avatar ?? null,
|
||||||
|
mute: !!state.mute,
|
||||||
|
deaf: !!state.deaf,
|
||||||
|
selfMute: !!state.selfMute,
|
||||||
|
selfDeaf: !!state.selfDeaf
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUserResult(user: any, guildId?: string) {
|
||||||
|
const nick = guildId ? GuildMemberStore.getNick(guildId, user.id) : null;
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
globalName: user.globalName ?? null,
|
||||||
|
avatar: user.avatar ?? null,
|
||||||
|
nick: nick ?? null,
|
||||||
|
bot: !!user.bot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMatches(user: any, needle: string, guildId?: string): boolean {
|
||||||
|
const nick = guildId ? GuildMemberStore.getNick(guildId, user.id) : null;
|
||||||
|
return [user?.username, user?.globalName, nick].some((value: string | null | undefined) =>
|
||||||
|
(value ?? "").toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRank(user: any, needle: string, guildId?: string): number {
|
||||||
|
const username = (user?.username ?? "").toLowerCase();
|
||||||
|
if (username === needle) return 0;
|
||||||
|
if (username.startsWith(needle)) return 1;
|
||||||
|
|
||||||
|
if ((user?.globalName ?? "").toLowerCase().startsWith(needle)) return 2;
|
||||||
|
|
||||||
|
const nick = (guildId ? GuildMemberStore.getNick(guildId, user.id) : null) ?? "";
|
||||||
|
if (nick.toLowerCase().startsWith(needle)) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search the client cache: guild members when a guild is given, otherwise all known users. */
|
||||||
|
function searchUsers(query: string, guildId: string | undefined, limit: number) {
|
||||||
|
const needle = query.replace(/^@/, "").trim().toLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
|
||||||
|
if (isSnowflake(needle)) {
|
||||||
|
const user = UserStore.getUser(needle);
|
||||||
|
return user ? [toUserResult(user, guildId)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool: any[] = guildId
|
||||||
|
? GuildMemberStore.getMembers(guildId)
|
||||||
|
.map(member => UserStore.getUser(member.userId))
|
||||||
|
.filter(Boolean)
|
||||||
|
: Object.values(UserStore.getUsers());
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const matches: any[] = [];
|
||||||
|
|
||||||
|
for (const user of pool) {
|
||||||
|
if (!user?.id || seen.has(user.id) || !userMatches(user, needle, guildId)) continue;
|
||||||
|
seen.add(user.id);
|
||||||
|
matches.push(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort(
|
||||||
|
(a, b) =>
|
||||||
|
userRank(a, needle, guildId) - userRank(b, needle, guildId) ||
|
||||||
|
String(a.username).localeCompare(String(b.username))
|
||||||
|
);
|
||||||
|
|
||||||
|
return matches.slice(0, limit).map(user => toUserResult(user, guildId));
|
||||||
|
}
|
||||||
|
|
||||||
async function send(channelId: string, content: string): Promise<unknown> {
|
async function send(channelId: string, content: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
return await sendMessage(channelId, { content }, false);
|
return await sendMessage(channelId, { content }, false);
|
||||||
@@ -141,7 +252,9 @@ async function resolveMessageId(raw: unknown, channelId: string, content: string
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < 10; attempt++) {
|
for (let attempt = 0; attempt < 10; attempt++) {
|
||||||
const messages: any[] = MessageStore.getMessages(channelId)?._array ?? [];
|
const messages: any[] = MessageStore.getMessages(channelId)?._array ?? [];
|
||||||
const match = [...messages].reverse().find(message => message.author?.id === selfId && message.content === content);
|
const match = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find(message => message.author?.id === selfId && message.content === content);
|
||||||
if (match) return match.id;
|
if (match) return match.id;
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
@@ -164,10 +277,7 @@ async function resolveDmChannel(userId: string): Promise<string> {
|
|||||||
return channelId;
|
return channelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePuppetAction(
|
export async function handlePuppetAction(action: string, params: Record<string, unknown> = {}): Promise<PuppetResult> {
|
||||||
action: string,
|
|
||||||
params: Record<string, unknown> = {}
|
|
||||||
): Promise<PuppetResult> {
|
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case PuppetAction.Ping:
|
case PuppetAction.Ping:
|
||||||
@@ -191,7 +301,12 @@ export async function handlePuppetAction(
|
|||||||
const targetId = (userId as string | undefined) ?? UserStore.getCurrentUser()?.id;
|
const targetId = (userId as string | undefined) ?? UserStore.getCurrentUser()?.id;
|
||||||
assert(targetId, PuppetErrorCode.NotLoggedIn, "no user is logged in");
|
assert(targetId, PuppetErrorCode.NotLoggedIn, "no user is logged in");
|
||||||
|
|
||||||
const voiceState = VoiceStateStore.getVoiceState((guildId as string | null) ?? null, targetId);
|
const voiceState =
|
||||||
|
guildId !== undefined
|
||||||
|
? VoiceStateStore.getVoiceState(guildId as string, targetId)
|
||||||
|
: (VoiceStateStore.getVoiceStateForUser(targetId) ??
|
||||||
|
VoiceStateStore.getVoiceState(null, targetId));
|
||||||
|
|
||||||
if (!voiceState?.channelId) {
|
if (!voiceState?.channelId) {
|
||||||
return ok({ guildId: guildId ?? null, channelId: null, channelName: null, members: [] });
|
return ok({ guildId: guildId ?? null, channelId: null, channelName: null, members: [] });
|
||||||
}
|
}
|
||||||
@@ -203,18 +318,85 @@ export async function handlePuppetAction(
|
|||||||
guildId: channel?.guild_id ?? guildId ?? null,
|
guildId: channel?.guild_id ?? guildId ?? null,
|
||||||
channelId: voiceState.channelId,
|
channelId: voiceState.channelId,
|
||||||
channelName: channel?.name ?? null,
|
channelName: channel?.name ?? null,
|
||||||
members: Object.values(states).map(state => {
|
members: Object.values(states).map(voiceMember)
|
||||||
const user = UserStore.getUser(state.userId);
|
|
||||||
return {
|
|
||||||
id: state.userId,
|
|
||||||
username: user?.username ?? state.userId,
|
|
||||||
mute: state.mute,
|
|
||||||
deaf: state.deaf
|
|
||||||
};
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case PuppetAction.VoiceOverview: {
|
||||||
|
const { guildId } = params;
|
||||||
|
assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake");
|
||||||
|
|
||||||
|
const byChannel = new Map<string, any[]>();
|
||||||
|
for (const state of Object.values(VoiceStateStore.getVoiceStates(guildId as string))) {
|
||||||
|
if (!state?.channelId) continue;
|
||||||
|
const members = byChannel.get(state.channelId) ?? [];
|
||||||
|
members.push(state);
|
||||||
|
byChannel.set(state.channelId, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
guildId,
|
||||||
|
channels: [...byChannel.entries()].map(([channelId, states]) => ({
|
||||||
|
channelId,
|
||||||
|
channelName: ChannelStore.getChannel(channelId)?.name ?? null,
|
||||||
|
members: states.map(voiceMember)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case PuppetAction.UserSearch: {
|
||||||
|
const { query, guildId, limit = 10 } = params;
|
||||||
|
assert(
|
||||||
|
typeof query === "string" && query.trim().length > 0 && query.length <= 100,
|
||||||
|
PuppetErrorCode.InvalidParam,
|
||||||
|
"query must be a string of 1 to 100 characters"
|
||||||
|
);
|
||||||
|
if (guildId !== undefined) {
|
||||||
|
assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake");
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
Number.isInteger(limit) &&
|
||||||
|
Number(limit) >= 1 &&
|
||||||
|
Number(limit) <= PUPPET_LIMITS.maxUserSearchResults,
|
||||||
|
PuppetErrorCode.InvalidParam,
|
||||||
|
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxUserSearchResults}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
query,
|
||||||
|
guildId: guildId ?? null,
|
||||||
|
users: searchUsers(query, guildId as string | undefined, Number(limit))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case PuppetAction.DmList: {
|
||||||
|
const { limit = 25 } = params;
|
||||||
|
assert(
|
||||||
|
Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= PUPPET_LIMITS.maxDmList,
|
||||||
|
PuppetErrorCode.InvalidParam,
|
||||||
|
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxDmList}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const channels = ChannelStore.getSortedPrivateChannels()
|
||||||
|
.slice(0, Number(limit))
|
||||||
|
.map(channel => ({
|
||||||
|
id: channel.id,
|
||||||
|
type: channel.type,
|
||||||
|
name: (channel as any).name ?? null,
|
||||||
|
icon: (channel as any).icon ?? null,
|
||||||
|
lastMessageId: (channel as any).lastMessageId ?? null,
|
||||||
|
recipients: Object.values((channel as any).recipients ?? {}).map((user: any) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
globalName: user.globalName ?? null,
|
||||||
|
avatar: user.avatar ?? null,
|
||||||
|
bot: !!user.bot
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return ok({ channels });
|
||||||
|
}
|
||||||
|
|
||||||
case PuppetAction.MessageSend: {
|
case PuppetAction.MessageSend: {
|
||||||
const { channelId } = params;
|
const { channelId } = params;
|
||||||
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
|
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
|
||||||
@@ -266,17 +448,16 @@ export async function handlePuppetAction(
|
|||||||
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxReadLimit}`
|
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxReadLimit}`
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(ChannelStore.getChannel(channelId), PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
|
assert(
|
||||||
|
ChannelStore.getChannel(channelId),
|
||||||
|
PuppetErrorCode.ChannelNotFound,
|
||||||
|
`channel ${channelId} not found`
|
||||||
|
);
|
||||||
const messages = MessageStore.getMessages(channelId)?._array ?? [];
|
const messages = MessageStore.getMessages(channelId)?._array ?? [];
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
cached: messages.length,
|
cached: messages.length,
|
||||||
messages: messages.slice(-Number(limit)).map(message => ({
|
messages: messages.slice(-Number(limit)).map(mapMessage)
|
||||||
id: message.id,
|
|
||||||
author: { id: message.author.id, username: message.author.username },
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,9 +475,17 @@ export async function handlePuppetAction(
|
|||||||
if (params.after !== undefined) {
|
if (params.after !== undefined) {
|
||||||
assert(isSnowflake(params.after), PuppetErrorCode.InvalidSnowflake, "after must be a snowflake");
|
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(
|
||||||
|
!(params.before && params.after),
|
||||||
|
PuppetErrorCode.InvalidParam,
|
||||||
|
"before and after are mutually exclusive"
|
||||||
|
);
|
||||||
|
|
||||||
assert(ChannelStore.getChannel(channelId), PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
|
assert(
|
||||||
|
ChannelStore.getChannel(channelId),
|
||||||
|
PuppetErrorCode.ChannelNotFound,
|
||||||
|
`channel ${channelId} not found`
|
||||||
|
);
|
||||||
|
|
||||||
const fetchMessages = (MessageActions as any)?.fetchMessages;
|
const fetchMessages = (MessageActions as any)?.fetchMessages;
|
||||||
assert(
|
assert(
|
||||||
@@ -325,13 +514,7 @@ export async function handlePuppetAction(
|
|||||||
return ok({
|
return ok({
|
||||||
channelId,
|
channelId,
|
||||||
fetched: filtered.length,
|
fetched: filtered.length,
|
||||||
messages: filtered.slice(-Number(limit)).map(message => ({
|
messages: filtered.slice(-Number(limit)).map(mapMessage)
|
||||||
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 }))
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Veskslop core events — normalize client events and push them to main.
|
* Veskslop core events — normalize client events and push them to main.
|
||||||
*
|
*
|
||||||
@@ -6,8 +12,8 @@
|
|||||||
* is guarded.
|
* is guarded.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChannelStore } from "@vencord/types/webpack/common";
|
|
||||||
import { filters, waitFor } from "@vencord/types/webpack";
|
import { filters, waitFor } from "@vencord/types/webpack";
|
||||||
|
import { ChannelStore } from "@vencord/types/webpack/common";
|
||||||
import type { PuppetEvent } from "shared/puppetProtocol";
|
import type { PuppetEvent } from "shared/puppetProtocol";
|
||||||
|
|
||||||
function normalizeMessage(event: any): PuppetEvent | undefined {
|
function normalizeMessage(event: any): PuppetEvent | undefined {
|
||||||
@@ -23,18 +29,19 @@ function normalizeMessage(event: any): PuppetEvent | undefined {
|
|||||||
author: {
|
author: {
|
||||||
id: message.author.id,
|
id: message.author.id,
|
||||||
username: message.author.username,
|
username: message.author.username,
|
||||||
|
avatar: message.author.avatar ?? null,
|
||||||
bot: !!message.author.bot
|
bot: !!message.author.bot
|
||||||
},
|
},
|
||||||
content: message.content ?? "",
|
content: message.content ?? "",
|
||||||
mentions: [
|
mentions: [...(message.mentions ?? []).map((user: any) => user.id), ...(message.mentionIds ?? [])],
|
||||||
...(message.mentions ?? []).map((user: any) => user.id),
|
|
||||||
...(message.mentionIds ?? [])
|
|
||||||
],
|
|
||||||
timestamp: message.timestamp ?? new Date().toISOString()
|
timestamp: message.timestamp ?? new Date().toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReaction(event: any, type: "MESSAGE_REACTION_ADD" | "MESSAGE_REACTION_REMOVE"): PuppetEvent | undefined {
|
function normalizeReaction(
|
||||||
|
event: any,
|
||||||
|
type: "MESSAGE_REACTION_ADD" | "MESSAGE_REACTION_REMOVE"
|
||||||
|
): PuppetEvent | undefined {
|
||||||
if (!event?.messageId || !event?.userId) return undefined;
|
if (!event?.messageId || !event?.userId) return undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -50,6 +57,24 @@ function normalizeReaction(event: any, type: "MESSAGE_REACTION_ADD" | "MESSAGE_R
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVoiceState(payload: any): PuppetEvent | undefined {
|
||||||
|
const state = payload?.voiceState ?? payload;
|
||||||
|
const userId = state?.userId ?? state?.user_id;
|
||||||
|
if (!userId) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "VOICE_STATE_UPDATE",
|
||||||
|
userId,
|
||||||
|
channelId: state.channelId ?? state.channel_id ?? null,
|
||||||
|
guildId: state.guildId ?? state.guild_id ?? null,
|
||||||
|
sessionId: state.sessionId ?? state.session_id ?? null,
|
||||||
|
mute: !!state.mute,
|
||||||
|
deaf: !!state.deaf,
|
||||||
|
selfMute: !!state.selfMute,
|
||||||
|
selfDeaf: !!state.selfDeaf
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
export function initPuppetEvents() {
|
export function initPuppetEvents() {
|
||||||
@@ -84,6 +109,26 @@ export function initPuppetEvents() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dispatcher.subscribe("VOICE_STATE_UPDATE", (event: any) => {
|
||||||
|
try {
|
||||||
|
const normalized = normalizeVoiceState(event);
|
||||||
|
if (normalized) VesktopNative.puppet.emit(normalized);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[puppet] voice state normalization failed:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatcher.subscribe("VOICE_STATE_UPDATES", (event: any) => {
|
||||||
|
try {
|
||||||
|
for (const state of event?.voiceStates ?? []) {
|
||||||
|
const normalized = normalizeVoiceState(state);
|
||||||
|
if (normalized) VesktopNative.puppet.emit(normalized);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[puppet] voice state normalization failed:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.log("[veskslop] puppet events active");
|
console.log("[veskslop] puppet events active");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Veskslop core entry — starts the event push and exports the action handler.
|
* Veskslop core entry — starts the event push and exports the action handler.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Vesktop ⇄ Veskslop daemon protocol.
|
* Vesktop ⇄ Veskslop daemon protocol.
|
||||||
*
|
*
|
||||||
@@ -11,6 +17,9 @@ export const enum PuppetAction {
|
|||||||
EventsSubscribe = "events.subscribe",
|
EventsSubscribe = "events.subscribe",
|
||||||
StateGet = "state.get",
|
StateGet = "state.get",
|
||||||
VoiceGet = "voice.get",
|
VoiceGet = "voice.get",
|
||||||
|
VoiceOverview = "voice.overview",
|
||||||
|
UserSearch = "user.search",
|
||||||
|
DmList = "dm.list",
|
||||||
MessageSend = "message.send",
|
MessageSend = "message.send",
|
||||||
MessageRead = "message.read",
|
MessageRead = "message.read",
|
||||||
MessageFetch = "message.fetch",
|
MessageFetch = "message.fetch",
|
||||||
@@ -23,6 +32,9 @@ export const PuppetActions: readonly PuppetAction[] = [
|
|||||||
PuppetAction.EventsSubscribe,
|
PuppetAction.EventsSubscribe,
|
||||||
PuppetAction.StateGet,
|
PuppetAction.StateGet,
|
||||||
PuppetAction.VoiceGet,
|
PuppetAction.VoiceGet,
|
||||||
|
PuppetAction.VoiceOverview,
|
||||||
|
PuppetAction.UserSearch,
|
||||||
|
PuppetAction.DmList,
|
||||||
PuppetAction.MessageSend,
|
PuppetAction.MessageSend,
|
||||||
PuppetAction.MessageRead,
|
PuppetAction.MessageRead,
|
||||||
PuppetAction.MessageFetch,
|
PuppetAction.MessageFetch,
|
||||||
@@ -61,13 +73,10 @@ export interface PuppetRequest {
|
|||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PuppetResult =
|
export type PuppetResult = { ok: true; result?: unknown } | { ok: false; error: PuppetError };
|
||||||
| { ok: true; result?: unknown }
|
|
||||||
| { ok: false; error: PuppetError };
|
|
||||||
|
|
||||||
export type PuppetResponse =
|
export type PuppetResponse =
|
||||||
| { id?: string | number; ok: true; result?: unknown }
|
{ id?: string | number; ok: true; result?: unknown } | { id?: string | number; ok: false; error: PuppetError };
|
||||||
| { id?: string | number; ok: false; error: PuppetError };
|
|
||||||
|
|
||||||
export interface PuppetEvent {
|
export interface PuppetEvent {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -79,6 +88,8 @@ export const PUPPET_LIMITS = {
|
|||||||
maxContentLength: 2000,
|
maxContentLength: 2000,
|
||||||
maxReadLimit: 100,
|
maxReadLimit: 100,
|
||||||
maxFetchLimit: 100,
|
maxFetchLimit: 100,
|
||||||
|
maxUserSearchResults: 25,
|
||||||
|
maxDmList: 50,
|
||||||
minActionIntervalMs: 500,
|
minActionIntervalMs: 500,
|
||||||
maxActionsPerMinute: 30,
|
maxActionsPerMinute: 30,
|
||||||
maxRequestBytes: 64 * 1024,
|
maxRequestBytes: 64 * 1024,
|
||||||
|
|||||||
Reference in New Issue
Block a user