fix(mattermost): anchor slash state on globalThis (#68113) (#90534)

Summary:
- The branch stores Mattermost slash-command account state in a process-wide Symbol.for/globalThis Map and adds module-reload regression coverage.
- PR surface: Source +21, Tests +43. Total +64 across 2 files.
- Reproducibility: yes. at source level: current main's route handler returns 503 when its module-local accoun ... pulate state through a separate loader path. I did not run a live Mattermost POST in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 3cf28a1f96.
- Required merge gates passed before the squash merge.

Prepared head SHA: 3cf28a1f96
Review: https://github.com/openclaw/openclaw/pull/90534#issuecomment-4627897262

Co-authored-by: ben.li <ly85206559@163.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-06-05 04:10:43 +00:00
committed by GitHub
parent cb5bb9b936
commit 69d1d78649
2 changed files with 67 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
// Mattermost tests cover slash state plugin behavior.
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostRegisteredCommand } from "./slash-commands.js";
@@ -48,6 +48,49 @@ const slashApi = {
runtime: RuntimeEnv;
};
const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states");
describe("slash-state global singleton", () => {
afterEach(() => {
deactivateSlashCommands();
});
it("anchors accountStates on globalThis", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-a"],
registeredCommands: [],
api: slashApi,
});
const globalStore = globalThis as Record<PropertyKey, unknown>;
const map = globalStore[ACCOUNT_STATES_KEY];
expect(map).toBeInstanceOf(Map);
expect((map as Map<string, unknown>).has("a1")).toBe(true);
});
it("preserves slash state across module reloads", async () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-reload"],
registeredCommands: [],
api: slashApi,
});
vi.resetModules();
const reloaded = await import("./slash-state.js");
const match = reloaded.resolveSlashHandlerForToken("tok-reload");
expect(match.kind).toBe("single");
if (match.kind !== "single") {
throw new Error("expected single match after module reload");
}
expect(match.accountIds).toEqual(["a1"]);
});
});
describe("slash-state token routing", () => {
it("returns single match when token belongs to one account", () => {
deactivateSlashCommands();

View File

@@ -62,8 +62,29 @@ type SlashCommandAccountState = {
triggerMap: Map<string, string>;
};
/** Map from accountId → per-account slash command state. */
const accountStates = new Map<string, SlashCommandAccountState>();
/**
* Map from accountId → per-account slash command state.
*
* Anchored to globalThis so that jiti-loaded (route registration) and
* native-ESM-loaded (monitor/activation) module instances share the
* same Map. Without this, each module loader creates its own copy of
* the module-level variable and the HTTP handler never sees the tokens
* populated by the monitor.
*/
const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states");
function getSlashAccountStates(): Map<string, SlashCommandAccountState> {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[ACCOUNT_STATES_KEY];
if (existing instanceof Map) {
return existing as Map<string, SlashCommandAccountState>;
}
const accountStates = new Map<string, SlashCommandAccountState>();
globalStore[ACCOUNT_STATES_KEY] = accountStates;
return accountStates;
}
const accountStates = getSlashAccountStates();
export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch {
const matches: Array<{