mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(discord): persist slash command deploy hash
This commit is contained in:
@@ -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" }));
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user