diff --git a/extensions/discord/src/internal/client.test.ts b/extensions/discord/src/internal/client.test.ts index 7fc185c9b3b..59ae1626485 100644 --- a/extensions/discord/src/internal/client.test.ts +++ b/extensions/discord/src/internal/client.test.ts @@ -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" })); diff --git a/extensions/discord/src/internal/client.ts b/extensions/discord/src/internal/client.ts index 7db1f0a10b0..9a5678af7c6 100644 --- a/extensions/discord/src/internal/client.ts +++ b/extensions/discord/src/internal/client.ts @@ -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 ?? []) { diff --git a/extensions/discord/src/internal/command-deploy.ts b/extensions/discord/src/internal/command-deploy.ts index 526e34c1e1a..48541a82eab 100644 --- a/extensions/discord/src/internal/command-deploy.ts +++ b/extensions/discord/src/internal/command-deploy.ts @@ -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; export class DiscordCommandDeployer { private readonly hashes = new Map(); + 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 { 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 { + 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 { + 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 { diff --git a/extensions/discord/src/internal/test-builders.test-support.ts b/extensions/discord/src/internal/test-builders.test-support.ts index d427f686eac..81f1c7986cd 100644 --- a/extensions/discord/src/internal/test-builders.test-support.ts +++ b/extensions/discord/src/internal/test-builders.test-support.ts @@ -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, +): Client { return new Client( { baseUrl: "http://localhost", clientId: "app1", publicKey: "public", token: "token", + ...options, }, { commands }, ); diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index 5d6db6c3a98..18dadd3e54c 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -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",