fix(discord): persist slash command deploy hash

This commit is contained in:
Vincent Koc
2026-05-02 14:35:57 -07:00
parent ce04ad83fa
commit 343e4723d8
5 changed files with 107 additions and 2 deletions

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ApplicationCommandType, ComponentType, Routes } from "discord-api-types/v10";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Client, ComponentRegistry, type AnyListener } from "./client.js";
@@ -274,6 +277,35 @@ describe("Client.deployCommands", () => {
expect(post).toHaveBeenCalledTimes(1);
});
it("skips unchanged command deploys across client restarts using the hash store", async () => {
const hashStorePath = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-command-deploy-")),
"hashes.json",
);
const first = createInternalTestClient([createTestCommand({ name: "one" })], {
commandDeployHashStorePath: hashStorePath,
});
const firstGet = vi.fn(async () => []);
const firstPost = vi.fn(async () => undefined);
attachRestMock(first, { get: firstGet, post: firstPost });
await first.deployCommands({ mode: "reconcile" });
const second = createInternalTestClient([createTestCommand({ name: "one" })], {
commandDeployHashStorePath: hashStorePath,
});
const secondGet = vi.fn(async () => []);
const secondPost = vi.fn(async () => undefined);
attachRestMock(second, { get: secondGet, post: secondPost });
await second.deployCommands({ mode: "reconcile" });
expect(firstGet).toHaveBeenCalledTimes(1);
expect(firstPost).toHaveBeenCalledTimes(1);
expect(secondGet).not.toHaveBeenCalled();
expect(secondPost).not.toHaveBeenCalled();
});
it("caches REST object fetches briefly and invalidates from gateway updates", async () => {
const client = createInternalTestClient();
const get = vi.fn(async () => ({ id: "c1", type: 0, name: "general" }));

View File

@@ -44,6 +44,7 @@ export interface ClientOptions {
disableDeployRoute?: boolean;
disableInteractionsRoute?: boolean;
disableEventsRoute?: boolean;
commandDeployHashStorePath?: string;
devGuilds?: string[];
eventQueue?: DiscordEventQueueOptions;
restCacheTtlMs?: number;
@@ -205,6 +206,7 @@ export class Client {
clientId: this.options.clientId,
commands: this.commands,
devGuilds: this.options.devGuilds,
hashStorePath: this.options.commandDeployHashStorePath,
rest: () => this.rest,
});
for (const component of handlers.components ?? []) {

View File

@@ -1,4 +1,6 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { ApplicationCommandType, type APIApplicationCommand } from "discord-api-types/v10";
import {
createApplicationCommand,
@@ -20,12 +22,14 @@ type SerializedCommand = ReturnType<BaseCommand["serialize"]>;
export class DiscordCommandDeployer {
private readonly hashes = new Map<string, string>();
private hashesLoaded = false;
constructor(
private readonly params: {
clientId: string;
commands: BaseCommand[];
devGuilds?: string[];
hashStorePath?: string;
rest: () => RequestClient;
},
) {}
@@ -124,11 +128,67 @@ export class DiscordCommandDeployer {
options: { force?: boolean },
): Promise<void> {
const hash = stableCommandSetHash(commands);
await this.loadPersistedHashes();
if (!options.force && this.hashes.get(key) === hash) {
return;
}
await deploy();
this.hashes.set(key, hash);
await this.persistHashes();
}
private async loadPersistedHashes(): Promise<void> {
if (this.hashesLoaded) {
return;
}
this.hashesLoaded = true;
const storePath = this.params.hashStorePath;
if (!storePath) {
return;
}
try {
const raw = await fs.readFile(storePath, "utf8");
const parsed = JSON.parse(raw) as { hashes?: unknown };
if (!parsed.hashes || typeof parsed.hashes !== "object") {
return;
}
for (const [key, value] of Object.entries(parsed.hashes)) {
if (typeof value === "string" && key.trim() && value.trim()) {
this.hashes.set(key, value);
}
}
} catch {
// Best-effort cache only. A corrupt or missing file should never block startup.
}
}
private async persistHashes(): Promise<void> {
const storePath = this.params.hashStorePath;
if (!storePath) {
return;
}
try {
await fs.mkdir(path.dirname(storePath), { recursive: true });
const tmpPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(
tmpPath,
`${JSON.stringify(
{
version: 1,
updatedAt: new Date().toISOString(),
hashes: Object.fromEntries(
[...this.hashes.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
),
},
null,
2,
)}\n`,
"utf8",
);
await fs.rename(tmpPath, storePath);
} catch {
// The cache is only an optimization to avoid redundant Discord writes.
}
}
private get rest(): RequestClient {

View File

@@ -1,6 +1,6 @@
import { ComponentType, InteractionType } from "discord-api-types/v10";
import { vi, type Mock } from "vitest";
import { Client } from "./client.js";
import { Client, type ClientOptions } from "./client.js";
import type { BaseCommand } from "./commands.js";
import type { RawInteraction } from "./interactions.js";
import type { QueuedRequest, RequestClient, RequestData } from "./rest.js";
@@ -58,13 +58,17 @@ export function createAbortableFetchMock() {
};
}
export function createInternalTestClient(commands: BaseCommand[] = []): Client {
export function createInternalTestClient(
commands: BaseCommand[] = [],
options?: Partial<ClientOptions>,
): Client {
return new Client(
{
baseUrl: "http://localhost",
clientId: "app1",
publicKey: "public",
token: "token",
...options,
},
{ commands },
);

View File

@@ -1,7 +1,9 @@
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
Client,
@@ -136,6 +138,11 @@ export async function createDiscordMonitorClient(params: {
publicKey: "a",
token: params.token,
autoDeploy: false,
commandDeployHashStorePath: path.join(
resolveStateDir(process.env),
"discord",
"command-deploy-cache.json",
),
requestOptions: {
timeout: DISCORD_REST_TIMEOUT_MS,
runtimeProfile: "persistent",