@openclaw/discord 2026.5.2-beta.1 → 2026.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/discord",
3
- "version": "2026.5.2-beta.1",
3
+ "version": "2026.5.2",
4
4
  "description": "OpenClaw Discord channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "openclaw": "workspace:*"
22
22
  },
23
23
  "peerDependencies": {
24
- "openclaw": ">=2026.5.2-beta.1"
24
+ "openclaw": ">=2026.5.2"
25
25
  },
26
26
  "peerDependenciesMeta": {
27
27
  "openclaw": {
@@ -61,13 +61,14 @@
61
61
  "install": {
62
62
  "npmSpec": "@openclaw/discord",
63
63
  "defaultChoice": "npm",
64
- "minHostVersion": ">=2026.4.10"
64
+ "minHostVersion": ">=2026.4.10",
65
+ "allowInvalidConfigRecovery": true
65
66
  },
66
67
  "compat": {
67
- "pluginApi": ">=2026.5.2-beta.1"
68
+ "pluginApi": ">=2026.5.2"
68
69
  },
69
70
  "build": {
70
- "openclawVersion": "2026.5.2-beta.1"
71
+ "openclawVersion": "2026.5.2"
71
72
  },
72
73
  "release": {
73
74
  "publishToClawHub": true,
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { ApplicationCommandType, ComponentType, Routes } from "discord-api-types/v10";
2
5
  import { afterEach, describe, expect, it, vi } from "vitest";
3
6
  import { Client, ComponentRegistry, type AnyListener } from "./client.js";
@@ -274,6 +277,35 @@ describe("Client.deployCommands", () => {
274
277
  expect(post).toHaveBeenCalledTimes(1);
275
278
  });
276
279
 
280
+ it("skips unchanged command deploys across client restarts using the hash store", async () => {
281
+ const hashStorePath = path.join(
282
+ await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-command-deploy-")),
283
+ "hashes.json",
284
+ );
285
+ const first = createInternalTestClient([createTestCommand({ name: "one" })], {
286
+ commandDeployHashStorePath: hashStorePath,
287
+ });
288
+ const firstGet = vi.fn(async () => []);
289
+ const firstPost = vi.fn(async () => undefined);
290
+ attachRestMock(first, { get: firstGet, post: firstPost });
291
+
292
+ await first.deployCommands({ mode: "reconcile" });
293
+
294
+ const second = createInternalTestClient([createTestCommand({ name: "one" })], {
295
+ commandDeployHashStorePath: hashStorePath,
296
+ });
297
+ const secondGet = vi.fn(async () => []);
298
+ const secondPost = vi.fn(async () => undefined);
299
+ attachRestMock(second, { get: secondGet, post: secondPost });
300
+
301
+ await second.deployCommands({ mode: "reconcile" });
302
+
303
+ expect(firstGet).toHaveBeenCalledTimes(1);
304
+ expect(firstPost).toHaveBeenCalledTimes(1);
305
+ expect(secondGet).not.toHaveBeenCalled();
306
+ expect(secondPost).not.toHaveBeenCalled();
307
+ });
308
+
277
309
  it("caches REST object fetches briefly and invalidates from gateway updates", async () => {
278
310
  const client = createInternalTestClient();
279
311
  const get = vi.fn(async () => ({ id: "c1", type: 0, name: "general" }));
@@ -44,6 +44,7 @@ export interface ClientOptions {
44
44
  disableDeployRoute?: boolean;
45
45
  disableInteractionsRoute?: boolean;
46
46
  disableEventsRoute?: boolean;
47
+ commandDeployHashStorePath?: string;
47
48
  devGuilds?: string[];
48
49
  eventQueue?: DiscordEventQueueOptions;
49
50
  restCacheTtlMs?: number;
@@ -205,6 +206,7 @@ export class Client {
205
206
  clientId: this.options.clientId,
206
207
  commands: this.commands,
207
208
  devGuilds: this.options.devGuilds,
209
+ hashStorePath: this.options.commandDeployHashStorePath,
208
210
  rest: () => this.rest,
209
211
  });
210
212
  for (const component of handlers.components ?? []) {
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
2
4
  import { ApplicationCommandType, type APIApplicationCommand } from "discord-api-types/v10";
3
5
  import {
4
6
  createApplicationCommand,
@@ -20,12 +22,14 @@ type SerializedCommand = ReturnType<BaseCommand["serialize"]>;
20
22
 
21
23
  export class DiscordCommandDeployer {
22
24
  private readonly hashes = new Map<string, string>();
25
+ private hashesLoaded = false;
23
26
 
24
27
  constructor(
25
28
  private readonly params: {
26
29
  clientId: string;
27
30
  commands: BaseCommand[];
28
31
  devGuilds?: string[];
32
+ hashStorePath?: string;
29
33
  rest: () => RequestClient;
30
34
  },
31
35
  ) {}
@@ -124,11 +128,67 @@ export class DiscordCommandDeployer {
124
128
  options: { force?: boolean },
125
129
  ): Promise<void> {
126
130
  const hash = stableCommandSetHash(commands);
131
+ await this.loadPersistedHashes();
127
132
  if (!options.force && this.hashes.get(key) === hash) {
128
133
  return;
129
134
  }
130
135
  await deploy();
131
136
  this.hashes.set(key, hash);
137
+ await this.persistHashes();
138
+ }
139
+
140
+ private async loadPersistedHashes(): Promise<void> {
141
+ if (this.hashesLoaded) {
142
+ return;
143
+ }
144
+ this.hashesLoaded = true;
145
+ const storePath = this.params.hashStorePath;
146
+ if (!storePath) {
147
+ return;
148
+ }
149
+ try {
150
+ const raw = await fs.readFile(storePath, "utf8");
151
+ const parsed = JSON.parse(raw) as { hashes?: unknown };
152
+ if (!parsed.hashes || typeof parsed.hashes !== "object") {
153
+ return;
154
+ }
155
+ for (const [key, value] of Object.entries(parsed.hashes)) {
156
+ if (typeof value === "string" && key.trim() && value.trim()) {
157
+ this.hashes.set(key, value);
158
+ }
159
+ }
160
+ } catch {
161
+ // Best-effort cache only. A corrupt or missing file should never block startup.
162
+ }
163
+ }
164
+
165
+ private async persistHashes(): Promise<void> {
166
+ const storePath = this.params.hashStorePath;
167
+ if (!storePath) {
168
+ return;
169
+ }
170
+ try {
171
+ await fs.mkdir(path.dirname(storePath), { recursive: true });
172
+ const tmpPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
173
+ await fs.writeFile(
174
+ tmpPath,
175
+ `${JSON.stringify(
176
+ {
177
+ version: 1,
178
+ updatedAt: new Date().toISOString(),
179
+ hashes: Object.fromEntries(
180
+ [...this.hashes.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
181
+ ),
182
+ },
183
+ null,
184
+ 2,
185
+ )}\n`,
186
+ "utf8",
187
+ );
188
+ await fs.rename(tmpPath, storePath);
189
+ } catch {
190
+ // The cache is only an optimization to avoid redundant Discord writes.
191
+ }
132
192
  }
133
193
 
134
194
  private get rest(): RequestClient {
@@ -1,6 +1,6 @@
1
1
  import { ComponentType, InteractionType } from "discord-api-types/v10";
2
2
  import { vi, type Mock } from "vitest";
3
- import { Client } from "./client.js";
3
+ import { Client, type ClientOptions } from "./client.js";
4
4
  import type { BaseCommand } from "./commands.js";
5
5
  import type { RawInteraction } from "./interactions.js";
6
6
  import type { QueuedRequest, RequestClient, RequestData } from "./rest.js";
@@ -58,13 +58,17 @@ export function createAbortableFetchMock() {
58
58
  };
59
59
  }
60
60
 
61
- export function createInternalTestClient(commands: BaseCommand[] = []): Client {
61
+ export function createInternalTestClient(
62
+ commands: BaseCommand[] = [],
63
+ options?: Partial<ClientOptions>,
64
+ ): Client {
62
65
  return new Client(
63
66
  {
64
67
  baseUrl: "http://localhost",
65
68
  clientId: "app1",
66
69
  publicKey: "public",
67
70
  token: "token",
71
+ ...options,
68
72
  },
69
73
  { commands },
70
74
  );
@@ -1,7 +1,9 @@
1
+ import path from "node:path";
1
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2
3
  import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
3
4
  import { danger } from "openclaw/plugin-sdk/runtime-env";
4
5
  import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
6
+ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
5
7
  import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
6
8
  import {
7
9
  Client,
@@ -136,6 +138,11 @@ export async function createDiscordMonitorClient(params: {
136
138
  publicKey: "a",
137
139
  token: params.token,
138
140
  autoDeploy: false,
141
+ commandDeployHashStorePath: path.join(
142
+ resolveStateDir(process.env),
143
+ "discord",
144
+ "command-deploy-cache.json",
145
+ ),
139
146
  requestOptions: {
140
147
  timeout: DISCORD_REST_TIMEOUT_MS,
141
148
  runtimeProfile: "persistent",