diff --git a/poc/.gitignore b/poc/.gitignore index 9f6206d..39b5695 100644 --- a/poc/.gitignore +++ b/poc/.gitignore @@ -4,3 +4,5 @@ node_modules/ .token .secrets/ .veskslop-state.json +actions.jsonl +actions.jsonl.* diff --git a/poc/src/communication.ts b/poc/src/communication.ts index 2b33cde..744f9e6 100644 --- a/poc/src/communication.ts +++ b/poc/src/communication.ts @@ -93,11 +93,6 @@ export function isTrusted( 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 */ export interface ContextOptions { diff --git a/poc/src/core-client.ts b/poc/src/core-client.ts index ea0aaaa..b6cf44f 100644 --- a/poc/src/core-client.ts +++ b/poc/src/core-client.ts @@ -31,6 +31,7 @@ export class CoreClient { private nextId = 1; private readonly pending = new Map(); private readonly listeners = new Set(); + private readonly closeListeners = new Set<() => void>(); private constructor(private readonly socket: net.Socket) { socket.setEncoding("utf-8"); @@ -52,6 +53,8 @@ export class CoreClient { request.reject(new CoreError("DISCONNECTED", "core disconnected")); } this.pending.clear(); + + for (const listener of this.closeListeners) listener(); }); socket.on("error", () => socket.destroy()); @@ -109,6 +112,11 @@ export class CoreClient { return () => this.listeners.delete(listener); } + onClose(listener: () => void) { + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + /* Convenience actions */ 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() { this.socket.destroy(); } diff --git a/poc/src/mock-core.ts b/poc/src/mock-core.ts index a45fa9c..c3c233b 100644 --- a/poc/src/mock-core.ts +++ b/poc/src/mock-core.ts @@ -20,6 +20,7 @@ const FAKE_GUILDS = [ { id: "100000000000000001", name: "Mock Guild", + icon: null, channels: [ { id: "200000000000000001", name: "general", type: 0 }, { id: "200000000000000002", name: "voice", type: 2 } @@ -27,21 +28,70 @@ const FAKE_GUILDS = [ } ]; +const MOCK_IMAGE_URL = + "data:image/svg+xml," + + encodeURIComponent( + '' + + '' + + 'mock image' + + "" + ); + const FAKE_MESSAGES = [ { id: "300000000000000001", - author: { id: "1", username: "mockuser" }, + author: { id: "1", username: "mockuser", avatar: null }, 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) => ({ 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}`, 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) { @@ -49,7 +99,7 @@ function handle(action: string | undefined, params: any) { case "ping": return "pong"; 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": return { guildId: params?.guildId ?? "100000000000000001", @@ -60,6 +110,57 @@ function handle(action: string | undefined, params: any) { { 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": return { channelId: params?.channelId, messageId: "msg-1", sent: true }; case "reaction.add": diff --git a/poc/src/pacing.ts b/poc/src/pacing.ts index e1ebc02..090220a 100644 --- a/poc/src/pacing.ts +++ b/poc/src/pacing.ts @@ -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 - * 500-2800 ms. Commands are serialized through one queue, and identical - * concurrent queries are coalesced so the total query count stays minimal. + * Outbound sends use a human-emulation policy: the first send of a burst goes + * out quickly (30-200 ms), later sends in the same burst are spaced 500-2800 ms. + * 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 - * actions, risky fast mode) lives in AGENTS.md and is not active yet. + * The core adds a second, hard rate limit. See docs/core-bridge.md. */ export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -/** Current per-query delay: 500-2800 ms. */ -export function queryDelay(): number { - return 500 + Math.floor(Math.random() * 2301); +export const PACING = { + firstMinMs: 30, + 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 = Promise.resolve(); 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(task: () => Promise): Promise { + 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(task: () => Promise): Promise { - const delay = queryDelay(); + const delay = randomBetween(PACING.readMinMs, PACING.readMaxMs); const run = queue.then(async () => { await sleep(delay); queryCount++; if (process.env.DEBUG === "1") { - console.error(`[rest] query #${queryCount} (+${delay}ms)`); + console.error(`[read] query #${queryCount} (+${delay}ms)`); } return task(); }); diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index b66835a..3abda22 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -19,7 +19,6 @@ import { parsePoll, parseTier, POLL_EMOJIS, - randomActionDelay, stripMentions, toCachedMessage, type AssembledContext, @@ -29,11 +28,13 @@ import { type TrustTier } from "./communication.js"; import { MessageCache } from "./message-cache.js"; -import { paced } from "./pacing.js"; +import { paced, pacedSend } from "./pacing.js"; const DM_CHANNEL = 1; const GROUP_DM_CHANNEL = 3; +const POLL_ENABLED = false; + const DEFAULT_OWNER_ID = "177117296140091393"; const STATE_FILE = 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); } + /** Write a web UI action to the persistent log. */ + appendAction(entry: Record & { type: string }) { + this.actionLog.append(entry); + } + addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record { for (const id of userIds) { if (id && id !== this.ownerId) this.tiers.set(id, tier); @@ -440,7 +446,7 @@ export class Puppet { if (command?.name === "whitelist") { 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); } } @@ -664,11 +670,11 @@ export class Puppet { const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName); const mode = override ?? (sensitive ? "dm" : policy.defaultMode); - await new Promise(resolve => setTimeout(resolve, randomActionDelay())); - try { - if (mode === "dm") await this.core.sendDm(requesterId, content); - else await this.core.sendMessage(originChannelId, content); + await pacedSend(async () => { + if (mode === "dm") await this.core.sendDm(requesterId, content); + else await this.core.sendMessage(originChannelId, content); + }); this.actionLog.append({ type: "reply", diff --git a/poc/src/test-communication.ts b/poc/src/test-communication.ts index 9153aab..2260e67 100644 --- a/poc/src/test-communication.ts +++ b/poc/src/test-communication.ts @@ -19,13 +19,13 @@ import { parseCommand, parsePoll, parseTier, - randomActionDelay, redactSecrets, stripMentions, toCachedMessage, type CachedMessage, type PuppetMessage } from "./communication.js"; +import { PACING, planSend } from "./pacing.js"; function cached(id: number, content = `m${id}`): CachedMessage { 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); } -// 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++) { - const delay = randomActionDelay(); - assert.ok(delay >= 500 && delay <= 2800, `delay out of range: ${delay}`); + const first = planSend(1_000_000, 0); + 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}` + ); } } diff --git a/src/main/puppetBridge.ts b/src/main/puppetBridge.ts index 8bf04df..c84929c 100644 --- a/src/main/puppetBridge.ts +++ b/src/main/puppetBridge.ts @@ -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. * @@ -35,6 +41,9 @@ const KNOWN_ACTIONS = new Set([ PuppetAction.EventsSubscribe, PuppetAction.StateGet, PuppetAction.VoiceGet, + PuppetAction.VoiceOverview, + PuppetAction.UserSearch, + PuppetAction.DmList, PuppetAction.MessageSend, PuppetAction.MessageRead, PuppetAction.MessageFetch, diff --git a/src/preload/index.ts b/src/preload/index.ts index 800e175..82d26d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,9 +11,7 @@ import { VesktopNative } from "./VesktopNative"; // Veskslop: seed the Discord auth token before the client boots, unless a session exists. try { - const seed = ipcRenderer.sendSync(IpcEvents.VESKSLOP_GET_TOKEN) as - | { token?: string; force?: boolean } - | undefined; + 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)); diff --git a/src/renderer/puppet/actions.ts b/src/renderer/puppet/actions.ts index d143642..19696dc 100644 --- a/src/renderer/puppet/actions.ts +++ b/src/renderer/puppet/actions.ts @@ -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. * @@ -11,6 +17,7 @@ import { openPrivateChannel, sendMessage } from "@vencord/types/utils"; import { findByPropsLazy } from "@vencord/types/webpack"; import { ChannelStore, + GuildMemberStore, GuildStore, MessageStore, UserStore, @@ -68,8 +75,11 @@ function validateContent(content: unknown): string { 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"); + assert( + !/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(trimmed), + PuppetErrorCode.InvalidParam, + "content has control characters" + ); return trimmed; } @@ -109,15 +119,37 @@ function getSendableChannel(channelId: string) { 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() { const user = UserStore.getCurrentUser(); if (!user) return null; return { - user: { id: user.id, username: user.username }, + user: { id: user.id, username: user.username, avatar: user.avatar ?? null }, guilds: GuildStore.getGuildsArray().map(guild => ({ id: guild.id, name: guild.name, + icon: guild.icon ?? null, channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)) .slice(0, PUPPET_LIMITS.maxChannelsPerGuild) .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(); + 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 { try { 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++) { 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; await new Promise(resolve => setTimeout(resolve, 100)); } @@ -164,10 +277,7 @@ async function resolveDmChannel(userId: string): Promise { return channelId; } -export async function handlePuppetAction( - action: string, - params: Record = {} -): Promise { +export async function handlePuppetAction(action: string, params: Record = {}): Promise { try { switch (action) { case PuppetAction.Ping: @@ -191,7 +301,12 @@ export async function handlePuppetAction( 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); + const voiceState = + guildId !== undefined + ? VoiceStateStore.getVoiceState(guildId as string, targetId) + : (VoiceStateStore.getVoiceStateForUser(targetId) ?? + VoiceStateStore.getVoiceState(null, targetId)); + if (!voiceState?.channelId) { return ok({ guildId: guildId ?? null, channelId: null, channelName: null, members: [] }); } @@ -203,18 +318,85 @@ export async function handlePuppetAction( 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 - }; - }) + members: Object.values(states).map(voiceMember) }); } + case PuppetAction.VoiceOverview: { + const { guildId } = params; + assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake"); + + const byChannel = new Map(); + 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: { const { channelId } = params; 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}` ); - 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 ?? []; 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 - })) + messages: messages.slice(-Number(limit)).map(mapMessage) }); } @@ -294,9 +475,17 @@ export async function handlePuppetAction( 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( + !(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; assert( @@ -325,13 +514,7 @@ export async function handlePuppetAction( 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 })) - })) + messages: filtered.slice(-Number(limit)).map(mapMessage) }); } diff --git a/src/renderer/puppet/events.ts b/src/renderer/puppet/events.ts index 7590c30..4a335dc 100644 --- a/src/renderer/puppet/events.ts +++ b/src/renderer/puppet/events.ts @@ -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. * @@ -6,8 +12,8 @@ * is guarded. */ -import { ChannelStore } from "@vencord/types/webpack/common"; import { filters, waitFor } from "@vencord/types/webpack"; +import { ChannelStore } from "@vencord/types/webpack/common"; import type { PuppetEvent } from "shared/puppetProtocol"; function normalizeMessage(event: any): PuppetEvent | undefined { @@ -23,18 +29,19 @@ function normalizeMessage(event: any): PuppetEvent | undefined { author: { id: message.author.id, username: message.author.username, + avatar: message.author.avatar ?? null, bot: !!message.author.bot }, content: message.content ?? "", - mentions: [ - ...(message.mentions ?? []).map((user: any) => user.id), - ...(message.mentionIds ?? []) - ], + mentions: [...(message.mentions ?? []).map((user: any) => user.id), ...(message.mentionIds ?? [])], 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; 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; 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"); }); } diff --git a/src/renderer/puppet/index.ts b/src/renderer/puppet/index.ts index 8579b29..098892c 100644 --- a/src/renderer/puppet/index.ts +++ b/src/renderer/puppet/index.ts @@ -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. */ diff --git a/src/shared/puppetProtocol.ts b/src/shared/puppetProtocol.ts index 4a38f3e..1555139 100644 --- a/src/shared/puppetProtocol.ts +++ b/src/shared/puppetProtocol.ts @@ -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. * @@ -11,6 +17,9 @@ export const enum PuppetAction { EventsSubscribe = "events.subscribe", StateGet = "state.get", VoiceGet = "voice.get", + VoiceOverview = "voice.overview", + UserSearch = "user.search", + DmList = "dm.list", MessageSend = "message.send", MessageRead = "message.read", MessageFetch = "message.fetch", @@ -23,6 +32,9 @@ export const PuppetActions: readonly PuppetAction[] = [ PuppetAction.EventsSubscribe, PuppetAction.StateGet, PuppetAction.VoiceGet, + PuppetAction.VoiceOverview, + PuppetAction.UserSearch, + PuppetAction.DmList, PuppetAction.MessageSend, PuppetAction.MessageRead, PuppetAction.MessageFetch, @@ -61,13 +73,10 @@ export interface PuppetRequest { params?: Record; } -export type PuppetResult = - | { ok: true; result?: unknown } - | { ok: false; error: PuppetError }; +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 }; + { id?: string | number; ok: true; result?: unknown } | { id?: string | number; ok: false; error: PuppetError }; export interface PuppetEvent { type: string; @@ -79,6 +88,8 @@ export const PUPPET_LIMITS = { maxContentLength: 2000, maxReadLimit: 100, maxFetchLimit: 100, + maxUserSearchResults: 25, + maxDmList: 50, minActionIntervalMs: 500, maxActionsPerMinute: 30, maxRequestBytes: 64 * 1024,