Rework pacing, pause poll, add user search, voice overview, and DM list
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -31,6 +31,7 @@ export class CoreClient {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private readonly listeners = new Set<CoreEventListener>();
|
||||
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();
|
||||
}
|
||||
|
||||
+106
-5
@@ -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(
|
||||
'<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 = [
|
||||
{
|
||||
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":
|
||||
|
||||
+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
|
||||
* 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<void> {
|
||||
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<unknown> = 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<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> {
|
||||
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();
|
||||
});
|
||||
|
||||
+13
-7
@@ -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<string, unknown> & { type: string }) {
|
||||
this.actionLog.append(entry);
|
||||
}
|
||||
|
||||
addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
|
||||
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",
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user