mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(discord): prevent presence wake floods after reconnects (#107969)
* feat(discord): throttle online-presence events after gateway reconnects After a Discord gateway (re)connect the presence replay burst emitted one system event per member, waking the agent each time. Add a per-account emission gate: a post-reconnect suppression window (default 5 min), a sliding-window burst limit (default 8/60s, logged once per episode), and a configurable per-user greeting cooldown (default 8h). New guild presenceEvents knobs: cooldownSeconds, reconnectSuppressSeconds, burstLimit, burstWindowSeconds. * fix(discord): preserve presence throttle retries * fix(discord): preserve presence throttle retries * fix(discord): scope presence throttle per guild Co-authored-by: openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com> * fix(discord): keep presence gate internals private Co-authored-by: openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com> --------- Co-authored-by: openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
co-authored by
openclaw-clawsweeper[bot] <openclaw-clawsweeper[bot]@users.noreply.github.com>
Peter Steinberger
Peter Steinberger
parent
74eea60e1b
commit
3a208a5068
@@ -876,6 +876,9 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
|
||||
presenceEvents: {
|
||||
channelId: "222222222222222222",
|
||||
users: ["333333333333333333"], // optional; omit for all humans
|
||||
reconnectSuppressSeconds: 300, // optional; new-session quiet window (0 disables)
|
||||
burstLimit: 8, // optional; max events per burst window
|
||||
burstWindowSeconds: 60, // optional; sliding burst-detection window
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -884,7 +887,7 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
|
||||
}
|
||||
```
|
||||
|
||||
`presenceEvents` requires an enabled heartbeat for the routed agent and the privileged **Presence Intent** on the application's Bot page in the Discord Developer Portal. OpenClaw seeds current online members from each complete `GUILD_CREATE` snapshot, routes observed offline-to-online transitions, and also treats a later first online signal for an unseen member as newly available. That member may have come online or joined after the snapshot, so the event does not assert an exact prior status. OpenClaw ignores bots and unchanged online states and persists an eight-hour per-user cooldown across Gateway restarts. Discord limits snapshots for guilds above 75,000 members; there, OpenClaw requires an explicit offline update before greeting. The system event carries immutable user, guild, and channel IDs without embedding mutable display names. The agent decides whether and how to greet.
|
||||
`presenceEvents` requires an enabled heartbeat for the routed agent and the privileged **Presence Intent** on the application's Bot page in the Discord Developer Portal. OpenClaw seeds current online members from each complete `GUILD_CREATE` snapshot, routes observed offline-to-online transitions, and also treats a later first online signal for an unseen member as newly available. That member may have come online or joined after the snapshot, so the event does not assert an exact prior status. OpenClaw ignores bots and unchanged online states and persists an eight-hour per-user cooldown across Gateway restarts. When Discord establishes a new Gateway session and sends `READY`, OpenClaw suppresses presence-derived events for `reconnectSuppressSeconds` (default 300, `0` disables) while guild presence state is rebuilt, so re-observed members cannot wake the agent one by one. It additionally rate-limits successfully queued events per guild to `burstLimit` events (default 8) per `burstWindowSeconds` sliding window (default 60), logging each guild's suppression episode once. A resumed session is not treated as a new session. Discord limits snapshots for guilds above 75,000 members; there, OpenClaw requires an explicit offline update before greeting. The system event carries immutable user, guild, and channel IDs without embedding mutable display names. The agent decides whether and how to greet.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures.
|
||||
- `channels.discord.streaming` is the canonical stream mode key. Discord defaults to `streaming.mode: "progress"` so tool/work progress appears in one edited preview message; set `streaming.mode: "off"` to disable it. Legacy flat keys (`streamMode`, `chunkMode`, `blockStreaming`, `draftChunk`, `blockStreamingCoalesce`) are no longer read at runtime; run `openclaw doctor --fix` to migrate persisted config.
|
||||
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
|
||||
- `channels.discord.guilds.<id>.presenceEvents` routes human availability arrivals into one configured Discord channel as agent system events. It seeds current online members from complete `GUILD_CREATE` snapshots, routes observed offline-to-online transitions, and treats a first later online signal for an unseen member as newly available without asserting whether they came online or joined after the snapshot. Guilds above Discord's 75,000-member snapshot limit require an explicit offline update first. It requires `channels.discord.intents.presence=true`, the privileged Presence Intent in Discord's Developer Portal, and an enabled agent heartbeat.
|
||||
- `channels.discord.guilds.<id>.presenceEvents` routes human availability arrivals into one configured Discord channel as agent system events. It seeds current online members from complete `GUILD_CREATE` snapshots, routes observed offline-to-online transitions, and treats a first later online signal for an unseen member as newly available without asserting whether they came online or joined after the snapshot. Guilds above Discord's 75,000-member snapshot limit require an explicit offline update first. Throttling knobs: `reconnectSuppressSeconds` (quiet window after a new Gateway session while guild presence state is rebuilt, default 300, `0` disables) and `burstLimit`/`burstWindowSeconds` (per-guild successfully queued event rate limit, default 8 events per 60s sliding window). Resumed sessions do not start the reconnect suppression window. The existing per-user re-greet cooldown remains eight hours. It requires `channels.discord.intents.presence=true`, the privileged Presence Intent in Discord's Developer Portal, and an enabled agent heartbeat.
|
||||
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
|
||||
- `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization.
|
||||
- `enabled`: `true`, `false`, or `"auto"` (default). In auto mode, exec approvals activate when approvers can be resolved from `approvers` or `commands.ownerAllowFrom`.
|
||||
|
||||
@@ -395,6 +395,43 @@ describe("discord config schema", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts online-presence throttling knobs", () => {
|
||||
const cfg = expectValidDiscordConfig({
|
||||
intents: { presence: true },
|
||||
guilds: {
|
||||
"123456789012345678": {
|
||||
presenceEvents: {
|
||||
channelId: "234567890123456789",
|
||||
reconnectSuppressSeconds: 0,
|
||||
burstLimit: 4,
|
||||
burstWindowSeconds: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.guilds?.["123456789012345678"]?.presenceEvents?.burstLimit).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects invalid online-presence throttling values", () => {
|
||||
const issues = expectInvalidDiscordConfig({
|
||||
guilds: {
|
||||
"123456789012345678": {
|
||||
presenceEvents: {
|
||||
channelId: "234567890123456789",
|
||||
reconnectSuppressSeconds: -1,
|
||||
burstLimit: 0,
|
||||
burstWindowSeconds: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const field of ["reconnectSuppressSeconds", "burstLimit", "burstWindowSeconds"]) {
|
||||
expect(issues.some((issue) => issue.path.join(".").endsWith(field))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects mutable names in online-presence event routing", () => {
|
||||
const issues = expectInvalidDiscordConfig({
|
||||
guilds: {
|
||||
|
||||
@@ -316,6 +316,18 @@ export const discordChannelConfigUiHints = {
|
||||
label: "Discord Online Presence User IDs",
|
||||
help: "Optional immutable Discord user ID allowlist. Omit to include all human members in the guild.",
|
||||
},
|
||||
"guilds.*.presenceEvents.reconnectSuppressSeconds": {
|
||||
label: "Discord Online Presence Reconnect Suppression",
|
||||
help: "Suppress online-presence events for this many seconds after a new Gateway session while guild presence state is rebuilt. Resumed sessions are unaffected. 0 disables. Default: 300.",
|
||||
},
|
||||
"guilds.*.presenceEvents.burstLimit": {
|
||||
label: "Discord Online Presence Burst Limit",
|
||||
help: "Maximum successfully queued online-presence events for this guild per burst window; the rest are suppressed and logged once. Default: 8.",
|
||||
},
|
||||
"guilds.*.presenceEvents.burstWindowSeconds": {
|
||||
label: "Discord Online Presence Burst Window",
|
||||
help: "Sliding window in seconds used for burst detection. Default: 60.",
|
||||
},
|
||||
activityType: {
|
||||
label: "Discord Presence Activity Type",
|
||||
help: "Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("DiscordPresenceListener", () => {
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1" } },
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1", burstLimit: 1 } },
|
||||
},
|
||||
cooldownStore: store,
|
||||
nowMs: () => nowMs,
|
||||
@@ -365,7 +365,8 @@ describe("DiscordPresenceListener", () => {
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1" } },
|
||||
// Disable the reconnect window; this test targets stale-lookup detachment.
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1", reconnectSuppressSeconds: 0 } },
|
||||
},
|
||||
cooldownStore: cooldownStore(),
|
||||
nowMs: () => 1_000,
|
||||
@@ -479,15 +480,15 @@ describe("DiscordPresenceListener", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps transition state per guild and rejects bots from partial payloads", async () => {
|
||||
it("keeps transition state per guild and does not charge partial bots to the burst limit", async () => {
|
||||
let nowMs = 0;
|
||||
const store = cooldownStore();
|
||||
const register = vi.spyOn(store, "register");
|
||||
const registerIfAbsent = vi.spyOn(store, "registerIfAbsent");
|
||||
const listener = new DiscordPresenceListener({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1" } },
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1", burstLimit: 1 } },
|
||||
},
|
||||
cooldownStore: store,
|
||||
nowMs: () => nowMs,
|
||||
@@ -501,11 +502,19 @@ describe("DiscordPresenceListener", () => {
|
||||
await listener.handle(presence("offline"), botClient);
|
||||
nowMs += 1000;
|
||||
await listener.handle(presence("online"), botClient);
|
||||
nowMs += 1000;
|
||||
await listener.handle(presence("offline", "human-1"), client());
|
||||
nowMs += 1000;
|
||||
await listener.handle(presence("online", "human-1"), client());
|
||||
|
||||
expect(fetchUser).toHaveBeenCalledWith("user-1");
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(mocks.requestHeartbeat).not.toHaveBeenCalled();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user_id="human-1"'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.requestHeartbeat).toHaveBeenCalledTimes(1);
|
||||
expect(registerIfAbsent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let another guild's status suppress the configured guild", async () => {
|
||||
@@ -531,6 +540,182 @@ describe("DiscordPresenceListener", () => {
|
||||
expect(mocks.requestHeartbeat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses the presence replay burst after a gateway reconnect", async () => {
|
||||
let nowMs = 0;
|
||||
const info = vi.fn();
|
||||
const listener = new DiscordPresenceListener({
|
||||
cfg: {} as OpenClawConfig,
|
||||
logger: { info } as never,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1" } },
|
||||
},
|
||||
cooldownStore: cooldownStore(),
|
||||
nowMs: () => nowMs,
|
||||
});
|
||||
const humanClient = client();
|
||||
|
||||
nowMs = 30_000;
|
||||
listener.resetGatewaySession();
|
||||
listener.seedGuildSnapshot(guildSnapshot([]));
|
||||
nowMs += 1000;
|
||||
await listener.handle(presence("online", "replayed-1"), humanClient);
|
||||
await listener.handle(presence("online", "replayed-2"), humanClient);
|
||||
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(mocks.requestHeartbeat).not.toHaveBeenCalled();
|
||||
expect(info).toHaveBeenCalledTimes(1);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
"Discord presence events suppressed",
|
||||
expect.objectContaining({ reason: "reconnect-window" }),
|
||||
);
|
||||
|
||||
// Post-window: replayed members stay marked online; a fresh transition emits.
|
||||
nowMs += 5 * 60 * 1000;
|
||||
await listener.handle(presence("online", "replayed-1"), humanClient);
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
await listener.handle(presence("offline", "replayed-1"), humanClient);
|
||||
await listener.handle(presence("online", "replayed-1"), humanClient);
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user_id="replayed-1"'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors a configured reconnect suppression window, including disabling it", async () => {
|
||||
let nowMs = 0;
|
||||
const listener = new DiscordPresenceListener({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": {
|
||||
presenceEvents: { channelId: "channel-1", reconnectSuppressSeconds: 0 },
|
||||
},
|
||||
},
|
||||
cooldownStore: cooldownStore(),
|
||||
nowMs: () => nowMs,
|
||||
});
|
||||
const humanClient = client();
|
||||
|
||||
nowMs = 30_000;
|
||||
listener.resetGatewaySession();
|
||||
listener.seedGuildSnapshot(guildSnapshot([]));
|
||||
nowMs += 1000;
|
||||
await listener.handle(presence("online", "came-online"), humanClient);
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rate-limits presence event bursts and logs the suppression once", async () => {
|
||||
let nowMs = 0;
|
||||
const info = vi.fn();
|
||||
const listener = new DiscordPresenceListener({
|
||||
cfg: {} as OpenClawConfig,
|
||||
logger: { info } as never,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": {
|
||||
presenceEvents: { channelId: "channel-1", burstLimit: 2, burstWindowSeconds: 60 },
|
||||
},
|
||||
},
|
||||
cooldownStore: cooldownStore(),
|
||||
nowMs: () => nowMs,
|
||||
});
|
||||
const humanClient = client();
|
||||
|
||||
nowMs = 30_000;
|
||||
listener.seedGuildSnapshot(guildSnapshot([]));
|
||||
for (const userId of ["burst-1", "burst-2", "burst-3", "burst-4"]) {
|
||||
nowMs += 100;
|
||||
await listener.handle(presence("online", userId), humanClient);
|
||||
}
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.requestHeartbeat).toHaveBeenCalledTimes(2);
|
||||
expect(info).toHaveBeenCalledTimes(1);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
"Discord presence events suppressed",
|
||||
expect.objectContaining({ reason: "burst" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps burst limits independent per guild", async () => {
|
||||
let nowMs = 30_000;
|
||||
const listener = new DiscordPresenceListener({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": { presenceEvents: { channelId: "channel-1", burstLimit: 1 } },
|
||||
"guild-2": { presenceEvents: { channelId: "channel-2", burstLimit: 1 } },
|
||||
},
|
||||
cooldownStore: cooldownStore(),
|
||||
nowMs: () => nowMs,
|
||||
});
|
||||
const humanClient = client();
|
||||
|
||||
listener.seedGuildSnapshot(guildSnapshot([]));
|
||||
listener.seedGuildSnapshot({ ...guildSnapshot([]), id: "guild-2" });
|
||||
for (const [guildId, userId] of [
|
||||
["guild-1", "guild-1-first"],
|
||||
["guild-2", "guild-2-first"],
|
||||
["guild-1", "guild-1-overflow"],
|
||||
["guild-2", "guild-2-overflow"],
|
||||
] as const) {
|
||||
nowMs += 1;
|
||||
await listener.handle({ ...presence("online", userId), guild_id: guildId }, humanClient);
|
||||
}
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user_id="guild-1-first"'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user_id="guild-2-first"'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the burst window after a delayed user lookup", async () => {
|
||||
let nowMs = 30_000;
|
||||
let resolveUser!: (value: { bot: boolean }) => void;
|
||||
const fetchUser = vi.fn(
|
||||
() =>
|
||||
new Promise<{ bot: boolean }>((resolve) => {
|
||||
resolveUser = resolve;
|
||||
}),
|
||||
);
|
||||
const listener = new DiscordPresenceListener({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "molty",
|
||||
guildEntries: {
|
||||
"guild-1": {
|
||||
presenceEvents: { channelId: "channel-1", burstLimit: 1, burstWindowSeconds: 60 },
|
||||
},
|
||||
},
|
||||
cooldownStore: cooldownStore(),
|
||||
nowMs: () => nowMs,
|
||||
});
|
||||
|
||||
listener.seedGuildSnapshot(guildSnapshot([]));
|
||||
const delayed = listener.handle({ ...presence("online", "delayed"), user: { id: "delayed" } }, {
|
||||
fetchUser,
|
||||
} as unknown as Client);
|
||||
await vi.waitFor(() => expect(fetchUser).toHaveBeenCalledTimes(1));
|
||||
nowMs += 61_000;
|
||||
resolveUser({ bot: false });
|
||||
await delayed;
|
||||
nowMs += 1;
|
||||
await listener.handle(presence("online", "next"), client());
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user_id="delayed"'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops offline baselines when the gateway session resets", async () => {
|
||||
let nowMs = 0;
|
||||
const listener = new DiscordPresenceListener({
|
||||
|
||||
@@ -20,6 +20,10 @@ export { DiscordReactionListener, DiscordReactionRemoveListener } from "./listen
|
||||
import { type DiscordGuildEntryResolved, resolveDiscordGuildEntry } from "./allow-list.js";
|
||||
import { clearPresences, setPresence } from "./presence-cache.js";
|
||||
import { openDiscordPresenceCooldownStore } from "./presence-cooldown-store.js";
|
||||
import {
|
||||
DiscordPresenceEmissionGate,
|
||||
resolveDiscordPresenceGateOptions,
|
||||
} from "./presence-emission-gate.js";
|
||||
import {
|
||||
DISCORD_PRESENCE_GREETING_COOLDOWN_MS,
|
||||
isDiscordOfflineStatus,
|
||||
@@ -103,6 +107,7 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
private readonly guildPresenceState = new Map<string, GuildPresenceState>();
|
||||
private gatewayGeneration = 0;
|
||||
private readonly cooldownStore: PluginStateSyncKeyedStore<number>;
|
||||
private readonly emissionGate: DiscordPresenceEmissionGate;
|
||||
|
||||
constructor(
|
||||
private readonly params: {
|
||||
@@ -114,11 +119,13 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
nowMs?: () => number;
|
||||
cooldownStore?: PluginStateSyncKeyedStore<number>;
|
||||
presenceBaseline?: DiscordPresenceBaselineCache;
|
||||
emissionGate?: DiscordPresenceEmissionGate;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.cooldownStore = params.cooldownStore ?? openDiscordPresenceCooldownStore();
|
||||
this.presenceBaseline = params.presenceBaseline ?? new DiscordPresenceBaselineCache();
|
||||
this.emissionGate = params.emissionGate ?? new DiscordPresenceEmissionGate();
|
||||
}
|
||||
|
||||
seedGuildSnapshot(data: GuildCreateEvent): void {
|
||||
@@ -190,6 +197,9 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
|
||||
resetGatewaySession(): void {
|
||||
this.gatewayGeneration += 1;
|
||||
// A READY event starts a new Gateway session and rebuilds guild presence state. Hold emission
|
||||
// during that rebuild so the re-observation burst cannot wake the agent per member.
|
||||
this.emissionGate.noteGatewaySessionReset(this.params.nowMs?.() ?? Date.now());
|
||||
this.presenceBaseline.clear();
|
||||
this.guildPresenceState.clear();
|
||||
// Generations make old REST results inert. Detach their chains so a hung lookup cannot block
|
||||
@@ -264,6 +274,23 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
return;
|
||||
}
|
||||
|
||||
const gateOptions = resolveDiscordPresenceGateOptions(config);
|
||||
const reconnectGate = this.emissionGate.evaluateReconnectWindow(nowMs, gateOptions);
|
||||
if (!reconnectGate.allowed) {
|
||||
if (reconnectGate.shouldLog) {
|
||||
const logger = this.params.logger ?? discordEventQueueLog;
|
||||
logger.info("Discord presence events suppressed", {
|
||||
reason: reconnectGate.reason,
|
||||
accountId: this.params.accountId,
|
||||
guildId: data.guild_id,
|
||||
});
|
||||
}
|
||||
// Mark online so the member is not re-greeted at window end; a later observed
|
||||
// offline-to-online transition still emits normally.
|
||||
this.recordPresenceBaseline(data.guild_id, presenceKey, "online");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchedUserIsBot =
|
||||
data.user.bot === undefined && (await client.fetchUser(userId)).bot === true;
|
||||
if (!this.isCurrentGeneration(data.guild_id, gatewayGeneration, guildGeneration)) {
|
||||
@@ -280,6 +307,23 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
guildId: data.guild_id,
|
||||
peer: { kind: "channel", id: presenceEvent.channelId },
|
||||
});
|
||||
// Reserve only after fallible user lookup and routing. Release the slot unless an event is
|
||||
// actually queued; rejected attempts must remain retryable and must not crowd out humans.
|
||||
const burstNowMs = this.params.nowMs?.() ?? Date.now();
|
||||
const burstGate = this.emissionGate.reserveBurst(data.guild_id, burstNowMs, gateOptions);
|
||||
if (!burstGate.allowed) {
|
||||
if (burstGate.shouldLog) {
|
||||
const logger = this.params.logger ?? discordEventQueueLog;
|
||||
logger.info("Discord presence events suppressed", {
|
||||
reason: burstGate.reason,
|
||||
accountId: this.params.accountId,
|
||||
guildId: data.guild_id,
|
||||
});
|
||||
}
|
||||
this.recordPresenceBaseline(data.guild_id, presenceKey, "online");
|
||||
return;
|
||||
}
|
||||
const burstReservation = burstGate.reservation;
|
||||
try {
|
||||
// Reserve only after fallible Discord lookups. Rejecting at capacity preserves every live
|
||||
// cooldown; skipping this wake is safer than allowing a duplicate greeting.
|
||||
@@ -287,12 +331,14 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
ttlMs: DISCORD_PRESENCE_GREETING_COOLDOWN_MS,
|
||||
});
|
||||
if (!reserved) {
|
||||
this.emissionGate.releaseBurst(data.guild_id, burstReservation);
|
||||
// Another live listener won the durable claim while this one awaited Discord. Treat the
|
||||
// member as online locally so overlapping provider generations cannot retry the greeting.
|
||||
this.recordPresenceBaseline(data.guild_id, presenceKey, "online");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.emissionGate.releaseBurst(data.guild_id, burstReservation);
|
||||
const logger = this.params.logger ?? discordEventQueueLog;
|
||||
logger.warn(danger(`discord presence cooldown persistence failed: ${String(err)}`));
|
||||
return;
|
||||
@@ -309,12 +355,14 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.emissionGate.releaseBurst(data.guild_id, burstReservation);
|
||||
if (this.cooldownStore.lookup(presenceKey) === nowMs) {
|
||||
this.cooldownStore.delete(presenceKey);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!queued) {
|
||||
this.emissionGate.releaseBurst(data.guild_id, burstReservation);
|
||||
if (this.cooldownStore.lookup(presenceKey) === nowMs) {
|
||||
this.cooldownStore.delete(presenceKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DiscordPresenceEmissionGate,
|
||||
resolveDiscordPresenceGateOptions,
|
||||
} from "./presence-emission-gate.js";
|
||||
|
||||
const options = resolveDiscordPresenceGateOptions(undefined);
|
||||
const guildId = "guild-1";
|
||||
|
||||
describe("resolveDiscordPresenceGateOptions", () => {
|
||||
it("defaults to a five-minute reconnect window and bounded burst", () => {
|
||||
expect(options).toEqual({
|
||||
reconnectSuppressMs: 5 * 60 * 1000,
|
||||
burstLimit: 8,
|
||||
burstWindowMs: 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts configured seconds and honors zero as disabled", () => {
|
||||
expect(
|
||||
resolveDiscordPresenceGateOptions({
|
||||
reconnectSuppressSeconds: 0,
|
||||
burstLimit: 3,
|
||||
burstWindowSeconds: 10,
|
||||
}),
|
||||
).toEqual({ reconnectSuppressMs: 0, burstLimit: 3, burstWindowMs: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordPresenceEmissionGate", () => {
|
||||
it("suppresses emission during the reconnect window and logs once", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
gate.noteGatewaySessionReset(1_000);
|
||||
|
||||
expect(gate.evaluateReconnectWindow(1_001, options)).toEqual({
|
||||
allowed: false,
|
||||
reason: "reconnect-window",
|
||||
shouldLog: true,
|
||||
});
|
||||
expect(gate.evaluateReconnectWindow(2_000, options)).toEqual({
|
||||
allowed: false,
|
||||
reason: "reconnect-window",
|
||||
shouldLog: false,
|
||||
});
|
||||
expect(gate.evaluateReconnectWindow(1_000 + options.reconnectSuppressMs, options)).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs again for each new reconnect window", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
gate.noteGatewaySessionReset(0);
|
||||
expect(gate.evaluateReconnectWindow(1, options)).toMatchObject({ shouldLog: true });
|
||||
gate.noteGatewaySessionReset(options.reconnectSuppressMs * 2);
|
||||
expect(
|
||||
gate.evaluateReconnectWindow(options.reconnectSuppressMs * 2 + 1, options),
|
||||
).toMatchObject({
|
||||
shouldLog: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not suppress when the reconnect window is disabled", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
gate.noteGatewaySessionReset(1_000);
|
||||
expect(gate.evaluateReconnectWindow(1_001, { ...options, reconnectSuppressMs: 0 })).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rate-limits emission bursts within the sliding window", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
const burstOptions = { ...options, burstLimit: 2, burstWindowMs: 10_000 };
|
||||
|
||||
expect(gate.reserveBurst(guildId, 1_000, burstOptions)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst(guildId, 2_000, burstOptions)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst(guildId, 3_000, burstOptions)).toEqual({
|
||||
allowed: false,
|
||||
reason: "burst",
|
||||
shouldLog: true,
|
||||
});
|
||||
expect(gate.reserveBurst(guildId, 4_000, burstOptions)).toEqual({
|
||||
allowed: false,
|
||||
reason: "burst",
|
||||
shouldLog: false,
|
||||
});
|
||||
// The window drains as old emissions age out; logging re-arms for the next burst.
|
||||
expect(gate.reserveBurst(guildId, 12_500, burstOptions)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst(guildId, 12_600, burstOptions)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst(guildId, 12_700, burstOptions)).toEqual({
|
||||
allowed: false,
|
||||
reason: "burst",
|
||||
shouldLog: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("releases failed attempts without spending burst capacity", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
const burstOptions = { ...options, burstLimit: 1 };
|
||||
const first = gate.reserveBurst(guildId, 1_000, burstOptions);
|
||||
|
||||
expect(first.allowed).toBe(true);
|
||||
if (!first.allowed) {
|
||||
throw new Error("expected burst reservation");
|
||||
}
|
||||
gate.releaseBurst(guildId, first.reservation);
|
||||
|
||||
expect(gate.reserveBurst(guildId, 1_001, burstOptions)).toMatchObject({ allowed: true });
|
||||
});
|
||||
|
||||
it("keeps burst limits and logging independent per guild", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
const strict = { ...options, burstLimit: 1, burstWindowMs: 10_000 };
|
||||
const loose = { ...options, burstLimit: 2, burstWindowMs: 60_000 };
|
||||
|
||||
expect(gate.reserveBurst("guild-a", 1_000, strict)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst("guild-b", 1_000, loose)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst("guild-a", 2_000, strict)).toEqual({
|
||||
allowed: false,
|
||||
reason: "burst",
|
||||
shouldLog: true,
|
||||
});
|
||||
expect(gate.reserveBurst("guild-b", 2_000, loose)).toMatchObject({ allowed: true });
|
||||
expect(gate.reserveBurst("guild-b", 3_000, loose)).toEqual({
|
||||
allowed: false,
|
||||
reason: "burst",
|
||||
shouldLog: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the sliding burst window across gateway resets", () => {
|
||||
const gate = new DiscordPresenceEmissionGate();
|
||||
const burstOptions = { ...options, reconnectSuppressMs: 0, burstLimit: 1 };
|
||||
|
||||
expect(gate.reserveBurst(guildId, 1_000, burstOptions)).toMatchObject({ allowed: true });
|
||||
gate.noteGatewaySessionReset(1_001);
|
||||
|
||||
expect(gate.reserveBurst(guildId, 1_002, burstOptions)).toEqual({
|
||||
allowed: false,
|
||||
reason: "burst",
|
||||
shouldLog: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Discord plugin module gates presence-event emission after reconnects and during bursts.
|
||||
const DISCORD_PRESENCE_RECONNECT_SUPPRESS_MS = 5 * 60 * 1000;
|
||||
const DISCORD_PRESENCE_BURST_LIMIT = 8;
|
||||
const DISCORD_PRESENCE_BURST_WINDOW_MS = 60 * 1000;
|
||||
|
||||
type DiscordPresenceGateConfig = {
|
||||
reconnectSuppressSeconds?: number;
|
||||
burstLimit?: number;
|
||||
burstWindowSeconds?: number;
|
||||
};
|
||||
|
||||
type DiscordPresenceGateOptions = {
|
||||
reconnectSuppressMs: number;
|
||||
burstLimit: number;
|
||||
burstWindowMs: number;
|
||||
};
|
||||
|
||||
export function resolveDiscordPresenceGateOptions(
|
||||
config: DiscordPresenceGateConfig | undefined,
|
||||
): DiscordPresenceGateOptions {
|
||||
return {
|
||||
reconnectSuppressMs:
|
||||
config?.reconnectSuppressSeconds !== undefined
|
||||
? config.reconnectSuppressSeconds * 1000
|
||||
: DISCORD_PRESENCE_RECONNECT_SUPPRESS_MS,
|
||||
burstLimit: config?.burstLimit ?? DISCORD_PRESENCE_BURST_LIMIT,
|
||||
burstWindowMs:
|
||||
config?.burstWindowSeconds !== undefined
|
||||
? config.burstWindowSeconds * 1000
|
||||
: DISCORD_PRESENCE_BURST_WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
type DiscordPresenceReconnectDecision =
|
||||
| { allowed: true }
|
||||
| { allowed: false; reason: "reconnect-window"; shouldLog: boolean };
|
||||
|
||||
type DiscordPresenceBurstEntry = {
|
||||
id: number;
|
||||
atMs: number;
|
||||
};
|
||||
|
||||
type DiscordPresenceBurstState = {
|
||||
reservations: DiscordPresenceBurstEntry[];
|
||||
logged: boolean;
|
||||
};
|
||||
|
||||
type DiscordPresenceBurstReservation = number;
|
||||
|
||||
type DiscordPresenceBurstDecision =
|
||||
| { allowed: true; reservation: DiscordPresenceBurstReservation }
|
||||
| { allowed: false; reason: "burst"; shouldLog: boolean };
|
||||
|
||||
/**
|
||||
* Per-account lifecycle gate for online-presence events. Reconnect state belongs to the Gateway
|
||||
* session, while burst state follows the guild-owned configuration that supplies its limits.
|
||||
*/
|
||||
export class DiscordPresenceEmissionGate {
|
||||
private lastSessionResetAtMs?: number;
|
||||
private reconnectLogged = false;
|
||||
// Sharing this history across guilds lets one guild's settings prune or consume another's
|
||||
// window. Keep each configured guild's rate-limit state and logging episode independent.
|
||||
private readonly burstByGuild = new Map<string, DiscordPresenceBurstState>();
|
||||
private nextReservationId = 0;
|
||||
|
||||
noteGatewaySessionReset(nowMs: number): void {
|
||||
this.lastSessionResetAtMs = nowMs;
|
||||
this.reconnectLogged = false;
|
||||
}
|
||||
|
||||
evaluateReconnectWindow(
|
||||
nowMs: number,
|
||||
options: DiscordPresenceGateOptions,
|
||||
): DiscordPresenceReconnectDecision {
|
||||
if (
|
||||
this.lastSessionResetAtMs !== undefined &&
|
||||
options.reconnectSuppressMs > 0 &&
|
||||
nowMs - this.lastSessionResetAtMs < options.reconnectSuppressMs
|
||||
) {
|
||||
const shouldLog = !this.reconnectLogged;
|
||||
this.reconnectLogged = true;
|
||||
return { allowed: false, reason: "reconnect-window", shouldLog };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
reserveBurst(
|
||||
guildId: string,
|
||||
nowMs: number,
|
||||
options: DiscordPresenceGateOptions,
|
||||
): DiscordPresenceBurstDecision {
|
||||
const state = this.burstByGuild.get(guildId) ?? { reservations: [], logged: false };
|
||||
state.reservations = state.reservations.filter(
|
||||
(reservation) => nowMs - reservation.atMs < options.burstWindowMs,
|
||||
);
|
||||
this.burstByGuild.set(guildId, state);
|
||||
if (state.reservations.length >= options.burstLimit) {
|
||||
const shouldLog = !state.logged;
|
||||
state.logged = true;
|
||||
return { allowed: false, reason: "burst", shouldLog };
|
||||
}
|
||||
state.logged = false;
|
||||
const reservation = { id: this.nextReservationId++, atMs: nowMs };
|
||||
state.reservations.push(reservation);
|
||||
return { allowed: true, reservation: reservation.id };
|
||||
}
|
||||
|
||||
releaseBurst(guildId: string, reservation: DiscordPresenceBurstReservation): void {
|
||||
const state = this.burstByGuild.get(guildId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.reservations = state.reservations.filter((candidate) => candidate.id !== reservation);
|
||||
if (state.reservations.length === 0 && !state.logged) {
|
||||
this.burstByGuild.delete(guildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -6,4 +6,13 @@ export type DiscordPresenceEventsConfig = {
|
||||
channelId: string;
|
||||
/** Optional immutable Discord user ID allowlist. Omit to include all human members. */
|
||||
users?: string[];
|
||||
/**
|
||||
* Suppress presence-derived online events for this many seconds after a new Gateway
|
||||
* session while guild presence state is rebuilt. 0 disables. Default: 300.
|
||||
*/
|
||||
reconnectSuppressSeconds?: number;
|
||||
/** Maximum queued online events for this guild per burst window. Default: 8. */
|
||||
burstLimit?: number;
|
||||
/** Sliding burst-detection window in seconds. Default: 60. */
|
||||
burstWindowSeconds?: number;
|
||||
};
|
||||
|
||||
@@ -41,5 +41,8 @@ export const DiscordPresenceEventsSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
channelId: DiscordSnowflakeStringSchema,
|
||||
users: z.array(DiscordSnowflakeStringSchema).optional(),
|
||||
reconnectSuppressSeconds: z.number().int().min(0).optional(),
|
||||
burstLimit: z.number().int().positive().optional(),
|
||||
burstWindowSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
Reference in New Issue
Block a user